GHSA-8f2v-2qhj-gfwg YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (`ApiController::deletePage`)
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.DetailsAffected 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 626The 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.php` line 14 - `DELETE … WHERE to_tag = '$tag'`, where `$tag = $this->GetPageTag()` is again the raw stored tag.`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.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 handlerA 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, so `UserIsOwner($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 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.**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.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 issuePreflightlab is up at http://localhost:8085Logging inadmin 'WikiAdmin' and low-priv 'TestUser01' both logged inTier 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-orphanedPROOF: yeswiki_links row → LinkPoc->SleepTag' OR SLEEP(2)--Tier 2 - DELETE /api/pages/<evil-tag>  (as TestUser01)baseline (non-existent tag)  : 0.468sexploit  (SLEEP(2) in tag)   : 2.555sdelta                        : 2.087sPROOF        : Δ ≥ 1.5 s → SLEEP(2) ran inside the DELETE on L626Tier 3 - time-based blind data exfiltrationchar='w'  elapsed=0.505s  misschar='x'  elapsed=0.495s  misschar='y'  elapsed=3.522s  <- HITchar='z'  elapsed=0.662s  missPROOF        : conditional SLEEP fired only for 'y'RESULT: second-order SQL injection in DELETE /api/pages/{tag} is CONFIRMED.PoCPre ReqsHad 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:Confirm the vulnerable line is actually there:**Expected output:**Log in as the low-privilege attacker. We will get the session in returnPlant 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.The API accepted a tag with a literal `'` and SQL keywords, completely unsanitized.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.`TestUser01` is the owner, so the eventual `UserIsOwner($tag)` check in the delete handler will pass for them.Now, create a second page that will link to the evil pageThe 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):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.The web edit handler called `LinkTracker::registerLinks($page, false, false)` (handlers/page/edit.php:69).`registerLinks()` formatted the page body and reached `preventTrackingActions()` (includes/services/LinkTracker.php:160).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'])`.`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:**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:`SLEEP(2)` runs once per row scanned. We seeded one row, so the call should hang ~2 s before responding.**Expected output (the precise timing varies by host, but the *delta* relative to step 6 is what matters):**ImpactBlind 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 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).`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.`_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**
CVSS v3.1 score: 8.3