{"schema":"threatline.record/2","record":{"id":"GHSA-ccp2-6q93-hggh","aliases":["GHSA-ccp2-6q93-hggh"],"summary":"Unauthenticated SQL injection in YesWiki Bazar entries search API via unescaped backtick in query field name","details":"## Summary\r\n\r\nYesWiki exposes a public, unauthenticated Bazar entries search API at `GET /api/forms/{formId}/entries/{output}/{selectedEntries}` (and the sibling `/api/entries/bazarlist` route). The `query` and `searchfields` request parameters let a caller name the entry fields to search. An attacker-supplied field name flows unmodified into a SQL SELECT clause where it is placed inside a backtick-quoted column alias. The only escaping applied is `mysqli_real_escape_string`, which does not escape the backtick character, so a field name containing a backtick closes the identifier context and injects attacker-controlled SQL. A single unauthenticated GET request achieves arbitrary SQL SELECT execution, allowing full read of the database (including user credentials) and denial of service.\r\n\r\n## Root Cause\r\n\r\nThe search pipeline resolves each requested field name against the target form's field definitions. A field name that matches no known property is not rejected; it is retained with a `MISSING_PROPERTY` descriptor and still proceeds to SQL construction. During construction the field name is embedded into a backtick-quoted SQL alias, escaped only with `mysqli_real_escape_string`. That function is designed for string-literal contexts and escapes NUL, newline, carriage return, backslash, single quote, double quote, and Ctrl+Z; it does not escape the backtick, which is MySQL's identifier-quoting character. Because the value is used as an identifier (an alias) rather than a string literal, the escaping is contextually wrong and a backtick in the field name breaks out of the alias.\r\n\r\n```text\r\ntools/bazar/services/SearchManager.php (line 809-812)\r\n  $vSQLNom = mysqli_real_escape_string($this->wiki->dblink, $vFieldName);\r\n  $vRenamedSQLNom = mysqli_real_escape_string($this->wiki->dblink, $this->renameJSONPathVariable($vFieldName));\r\n  $vSelectRequest[] = 'JSON_UNQUOTE(JSON_EXTRACT(body, \\'$.' . $vSQLNom . '\\')) AS `' . $vRenamedSQLNom . '`';\r\n\r\nVulnerable function: SearchManager::prepareSearchRequest()\r\nPublic entry route:  tools/bazar/controllers/ApiController.php (line 221)\r\n```\r\n\r\n## Impact\r\n\r\nAn unauthenticated network attacker who can reach the site and knows or guesses a Bazar form identifier (Bazar is a core YesWiki feature present in typical deployments) can execute arbitrary SQL SELECT statements in the application's database context. Scalar subqueries injected as additional SELECT columns can read any table the database user can access; because the API serializes result columns into its JSON response, extracted data (for example username and password-hash pairs from the users table) is returned directly, with no blind technique required. Injecting a `SLEEP()` subquery blocks database worker threads; concurrent unauthenticated requests exhaust the connection pool and render the site unavailable. Execution is limited to a single statement (the database driver rejects stacked queries), so direct INSERT, UPDATE, or DELETE through this vector is not demonstrated; the impact is confidentiality and availability, not direct integrity modification.\r\n\r\n## Proof of Concept\r\n\r\n```text\r\nPreconditions: a reachable YesWiki instance (>= 4.6.7) with at least one Bazar form.\r\nNo authentication, cookies, or tokens are required. Form id 1 is used for illustration.\r\n```\r\n\r\nData exfiltration (reads username and password-hash pairs from the users table into the JSON response):\r\n\r\n```text\r\nGET /api/forms/1/entries?query=x%60%2C(SELECT+GROUP_CONCAT(name%2C0x3a%2Cpassword)+FROM+yeswiki_users)+as+%60d!=x HTTP/1.1\r\nHost: target\r\n```\r\n\r\nThe `query` value decodes to the field name `x`,(SELECT GROUP_CONCAT(name,0x3a,password) FROM yeswiki_users) as `d` followed by the `!=x` operator and comparison value. `yeswiki_` is the default table prefix; adjust it for a customized install.\r\n\r\nDenial of service (blocks a database thread for 30 seconds per request):\r\n\r\n```text\r\nGET /api/forms/1/entries?query=x%60%2C(SELECT+SLEEP(30))+as+%60d!=x HTTP/1.1\r\nHost: target\r\n```\r\n\r\n```text\r\nResulting SQL fragment produced in the generated SELECT list:\r\n  JSON_UNQUOTE(JSON_EXTRACT(body, '$.x`,(SELECT SLEEP(30)) as `d')) AS `x`,(SELECT SLEEP(30)) as `d`\r\nMySQL parses this as two columns: an alias `x` and an independent scalar subquery\r\n(SELECT SLEEP(30)) aliased `d`, which executes for every evaluated row.\r\n```\r\n\r\n## Attack Chain\r\n\r\n1. **Exposure:** The route `GET /api/forms/{formId}/entries/{output}/{selectedEntries}` is annotated `options={\"acl\":{\"public\"}}` (tools/bazar/controllers/ApiController.php line 221), so it is reachable with no authentication. The `/api/entries/bazarlist` route is likewise public.\r\n2. **Control:** The handler reads the `query` parameter (and, equivalently, `searchfields`) directly from the request. `aggregateQueries` only splits on `|` and deduplicates; no character filtering is applied, so the attacker fully controls the field-name text.\r\n3. **Path:** The value reaches `SearchManager::prepareSearchRequest()`, where `parseQuery()` extracts the field name with the regex character class `[^=!<>]*`. Only `=`, `!`, `<`, and `>` are excluded, so backticks, parentheses, commas, and spaces survive. An unknown field is tagged `MISSING_PROPERTY` but is still added to the fields collection and reaches SQL construction.\r\n4. **Guard:** The only neutralization is `mysqli_real_escape_string` applied to the field name before it is placed in a backtick-quoted alias. That function does not escape the backtick, and no identifier whitelist, allowed-character check, or authorization gate exists on the path. `renameJSONPathVariable()` only replaces dots, which the payload avoids.\r\n5. **Primitive:** The backtick in the field name closes the alias identifier, and the following text is parsed by MySQL as additional SELECT columns, including an attacker-chosen scalar subquery. The `!=` operator against a `MISSING_PROPERTY` field yields an unconditional `TRUE` in the WHERE clause, so rows are evaluated and the injected subquery runs.\r\n6. **Result:** A single unauthenticated request yields arbitrary SQL SELECT execution: subqueries read arbitrary tables (credentials, content, configuration) and return the data in the JSON response, and `SLEEP()` subqueries exhaust database threads for denial of service.\r\n\r\n## Bypass Evidence\r\n\r\nNo effective guard exists on this path, and no prior fix addresses it. The escaping in place, `mysqli_real_escape_string`, is the wrong primitive for an identifier context: it escapes string-literal metacharacters but leaves the backtick untouched, which is exactly the character needed to break out of a backtick-quoted alias. The following disproof attempts were made and each failed to block the chain:\r\n\r\n- Searched the Bazar tools for request-filtering middleware, kernel-request subscribers, or field-name validation and found none; the public ACL annotation is authoritative.\r\n- Traced the full data flow from the raw request parameter to SQL construction; no whitelist or sanitization is applied at any step. `aggregateQueries` only splits and deduplicates.\r\n- Confirmed the `parseQuery` regex `[^=!<>]*` excludes only four characters, so backtick, parentheses, comma, and space pass through.\r\n- Confirmed unknown field names are not skipped: they receive `MISSING_PROPERTY` yet are still added to the field collection and built into the SELECT clause (the extraction flag is false, so the alias SQL is emitted).\r\n- Considered whether MySQL would reject the statement: the invalid JSON path inside the string literal returns NULL rather than a parse error, and the injected `(SELECT ...)` is a grammatically independent column that executes.\r\n- Verified the driver uses single-statement execution, which correctly prevents stacked-query INSERT/UPDATE/DELETE, so the assessed impact is confined to SELECT-context reads and availability.\r\n\r\n## Affected Versions\r\n\r\n- `Ecosystem: composer`\r\n- `Package: yeswiki/yeswiki`\r\n- `Confirmed affected range: >= 4.6.7, <= 5.0.0-alpha3`\r\n- `Latest release checked: 4.6.7 (Packagist), 5.0.0-alpha3 (Packagist prerelease)`\r\n- `Fix status: not fixed in any released version or on the current development branch`\r\n\r\nThe backtick-quoted SELECT alias sink was present from 4.6.0, but the demonstrated end-to-end chain depends on the search building an outer query that returns rows so the injected column value is serialized into the API response. In releases 4.6.0 through 4.6.6 a requested field that matches no form property produces an unconditional false search condition (and, from 4.6.4, the request is emptied entirely), so the described payload returns no rows and the injected data does not surface. Starting in 4.6.7 the missing-property branch produces a true condition for the `!=` operator, so all rows are returned and the injected subquery result is serialized directly into the JSON response; this behavior is unchanged on the current development branch and in the 5.0.0-alpha1 through 5.0.0-alpha3 prereleases (where the search code is relocated to `src/Search/Service/SearchManager.php` with an unquoted alias but the same reachable public route and missing-property handling). Releases 4.5.5 and earlier ship a different, much smaller search implementation without `prepareSearchRequest()` or the alias pattern and are not affected. No single upper bound is given because the confirmed set spans the stable 4.6.7 release and the 5.0.0-alpha prerelease line; earlier 4.6.x releases were reviewed and are not confirmed affected for this chain.\r\n\r\n## Suggested Fix\r\n\r\nField names used to build SQL identifiers must be validated as identifiers, not escaped as string literals. Enforce a strict allowlist (for example, restrict field names to `[A-Za-z0-9_.]+`) and reject or drop any requested field that does not match a known form property, so that unknown `MISSING_PROPERTY` names never reach SQL construction. If arbitrary identifiers must be supported, escape them for identifier context by doubling embedded backticks before placing them inside a backtick-quoted alias. Apply the same validation to every caller and every parameter that feeds field names into the search pipeline (both `query` and `searchfields`, and both the forms-entries and bazarlist routes). As an interim mitigation, restrict network access to the public Bazar entries API endpoints until the input is validated.\r\n\r\nReported by zx (GitHub: @manus-pi).","categories":["Unclassified"],"product":"yeswiki/yeswiki","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-ccp2-6q93-hggh","source_modified_at":"2026-10-09T13:37:25Z","fetched_at":"2026-10-25T21:40:15.971031Z","package":{"ecosystem":"composer","name":"yeswiki/yeswiki"},"range":">= 4.6.7, < 4.7.0"},{"source":"GitHub Advisory Database","source_url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-ccp2-6q93-hggh","source_modified_at":"2026-10-09T13:37:25Z","fetched_at":"2026-10-25T21:40:15.971031Z","package":{"ecosystem":"composer","name":"yeswiki/yeswiki"},"range":">= 5.0.0-alpha1, < 5.0.0-alpha4"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-ccp2-6q93-hggh","source_modified_at":"2026-10-09T13:37:25Z","fetched_at":"2026-10-25T21:40:15.971031Z","package":{"ecosystem":"composer","name":"yeswiki/yeswiki"},"version":"4.7.0"},{"source":"GitHub Advisory Database","source_url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-ccp2-6q93-hggh","source_modified_at":"2026-10-09T13:37:25Z","fetched_at":"2026-10-25T21:40:15.971031Z","package":{"ecosystem":"composer","name":"yeswiki/yeswiki"},"version":"5.0.0-alpha4"}],"cvss":9.1,"severity":"Critical","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-09T13:37:25Z","modified_at":"2026-10-09T13:37:25Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-ccp2-6q93-hggh","fetched_at":"2026-10-25T21:40:15.971031Z","source_modified_at":"2026-10-09T13:37:25Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-09T13:37:25Z","content_hash":"f0125b6d9a4e85793808368b05df2b7f9a659c04be9d4c735004aed70d3bfe1e"}}
