{"repro_id":"REPRO-2026-00144","version":8,"title":"phpMyFAQ: unauthenticated SQL injection via User-Agent header in captcha API","repro_type":"security","status":"published","severity":"critical","description":"`phpMyFAQ` is a PHP FAQ / knowledge-base application. Its `BuiltinCaptcha`\nclass builds SQL by **interpolating the unsanitized `User-Agent` HTTP header**\ndirectly into queries:\n\n- `BuiltinCaptcha::garbageCollector()` — into a `DELETE` query\n- `BuiltinCaptcha::saveCaptcha()` — into an `INSERT` query\n\nThe **public, unauthenticated** endpoint `GET /api/captcha` reaches this code\npath. An attacker can therefore perform **unauthenticated time-based blind SQL\ninjection** simply by sending a request with a crafted `User-Agent` header — no\nlogin, no token, no user interaction required.","root_cause":"# RCA Report: CVE-2026-46364\n\n## Summary\n\nphpMyFAQ versions prior to 4.1.2 contain an unauthenticated SQL injection vulnerability in the `BuiltinCaptcha` class. The `User-Agent` HTTP header is interpolated directly into SQL `DELETE` and `INSERT` queries in `garbageCollector()` and `saveCaptcha()` without any escaping or parameterization. An attacker can exploit this by sending a crafted `User-Agent` header to the `/api/captcha` endpoint, causing arbitrary SQL to execute in the context of the application's database connection.\n\n## Impact\n\n- **Package/component affected**: `thorsten/phpmyfaq` — `phpMyFAQ\\Captcha\\BuiltinCaptcha`\n- **Affected versions**: `< 4.1.2` (vulnerable); `4.1.2` and later (fixed)\n- **Risk level**: Critical (CVSS 3.1 base 9.8)\n- **Consequences**: Unauthenticated time-based blind SQL injection via the public `GET /api/captcha` endpoint. An attacker can read, modify, or delete database contents, bypass authentication, or execute administrative actions depending on the database privileges of the application user.\n\n## Root Cause\n\nIn `BuiltinCaptcha::garbageCollector()` and `BuiltinCaptcha::saveCaptcha()` (phpMyFAQ 4.1.1), the `$this->userAgent`, `$this->ip`, `$this->code`, and language values are concatenated directly into SQL strings using `sprintf()`:\n\n```php\n$delete = sprintf(\n    \"DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'\",\n    Database::getTablePrefix(),\n    $this->userAgent,\n    $this->configuration->getLanguage()->getLanguage(),\n    $this->ip,\n);\n```\n\nBecause the `User-Agent` value is controlled by the HTTP client and is not escaped before interpolation, an attacker can inject SQL syntax (e.g., `' OR '1'='1`) that alters the query's semantics.\n\nThe fix commit (`545bdffb11244a4741cd29ac909a849c9a6a2e53` in the 4.1.2 release timeline) introduces an `escapeQueryValue()` helper that calls `$this->configuration->getDb()->escape()` on each untrusted value before concatenation, effectively neutralizing the injection vector.\n\n## Reproduction Steps\n\n1. Run `repro/reproduction_steps.sh`\n2. The script clones the phpMyFAQ repository, checks out the vulnerable tag `4.1.1`, installs dependencies via Composer, and starts a built-in PHP server (`php -S`) hosting a minimal captcha-generating endpoint.\n3. The script sends an HTTP `GET` request to `http://localhost:8766/` with a malicious `User-Agent: test' OR '1'='1` header.\n4. The captcha endpoint instantiates `BuiltinCaptcha` from the actual phpMyFAQ source code and calls `getCaptchaImage()`, which internally triggers `garbageCollector()` → `saveCaptcha()`.\n5. The script inspects the SQLite `faqcaptcha` table to verify the payload was executed as SQL rather than stored as a literal string.\n6. The script repeats steps 2–5 against the fixed tag `4.1.2` to confirm the payload is now escaped and stored literally.\n\n### Expected Evidence\n\n- **Vulnerable (4.1.1)**: The `useragent` column in the SQLite database contains `1` (the boolean result of the injected SQL expression `'1'='1'`), proving the payload was evaluated as SQL.\n- **Fixed (4.1.2)**: The `useragent` column contains the literal string `test' OR '1'='1`, proving the input was escaped before reaching the query.\n\n## Evidence\n\n### Log file\n- `logs/repro.log`\n\n### Key excerpts\n```\n=== Testing vulnerable version 4.1.1 ===\nDatabase useragent value: 1\nCONFIRMED: 4.1.1 is VULNERABLE (User-Agent payload executed as SQL, boolean result stored)\n\n=== Testing fixed version 4.1.2 ===\nDatabase useragent value: test' OR '1'='1\nCONFIRMED: 4.1.2 is FIXED (User-Agent stored as literal string)\n```\n\n### Runtime manifest\n- `repro/runtime_manifest.json` captures structured evidence:\n  - `vulnerable_useragent_value`: `\"1\"`\n  - `fixed_useragent_value`: `\"test' OR '1'='1\"`\n  - `payload`: `\"test' OR '1'='1\"`\n  - `target_url`: `http://localhost:8766/`\n  - `http_status`: `200`\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to phpMyFAQ 4.1.2 or later. The patch adds escaping via `$this->configuration->getDb()->escape()` for all untrusted values injected into captcha SQL queries.\n2. **Review other query-building code** in the codebase for similar unsanitized `sprintf()` patterns, especially in `Faq.php`, `Relation.php`, `Search.php`, `Tags.php`, and `SearchDatabase.php`, which were also refactored in the 4.1.2 release.\n3. **Add parameterized query enforcement** as a code-quality rule (e.g., via static analysis or custom linters) to prevent raw string concatenation with user input in SQL generation.\n4. **Regression testing**: The existing `BuiltinCaptchaTest` in the project now includes `testSaveCaptchaEscapesUserAgentAndIpValues()` and `testGarbageCollectorEscapesUserAgentAndIpValues()`, which should be run in CI for every release.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` was executed twice consecutively on a clean environment; both runs produced identical results, confirming the script is idempotent.\n- **Edge cases / limitations**: The reproduction uses SQLite because it is lightweight and requires no external database server. The injection vector is database-agnostic; MySQL/MariaDB users would observe similar behavior (with `SLEEP()`-based time delays as the primary signal). PostgreSQL and SQL Server backends are also affected because the root cause is unsanitized string interpolation, not a dialect-specific feature.\n- **No authentication required**: The reproduction hits an unauthenticated endpoint, matching the advisory's assessment that no login, token, or user interaction is needed to exploit this vulnerability.\n","ghsa_id":"GHSA-289f-fq7w-6q2w","cve_id":"CVE-2026-46364","source_url":"https://github.com/thorsten/phpMyFAQ","package":{"name":"thorsten/phpmyfaq","ecosystem":"composer","affected_versions":"< 4.1.2","fixed_version":"4.1.2"},"reproduced_at":"2026-05-22T10:56:40.275183+00:00","duration_secs":3734.691274642944,"tool_calls":328,"turns":298,"handoffs":2,"total_cost_usd":3.609693840000001,"agent_costs":{"repro":1.6060910299999993,"support":0.06860621000000001,"vuln_variant":1.9349966000000007},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":1.6060910299999993},"support":{"accounts/fireworks/models/kimi-k2p6":0.06860621000000001},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.9349966000000007}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T10:56:42.299196+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5591,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6270,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":6695,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":438,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":2896,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":736,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3712,"category":"ticket"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":659,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":2275,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1137,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4059,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2659,"category":"other"},{"path":"vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1161,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1987,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":600,"category":"other"},{"path":"vuln_variant/variant_test.php","filename":"variant_test.php","size":9203,"category":"other"},{"path":"logs/vuln_variant/variant.log","filename":"variant.log","size":1212,"category":"log"},{"path":"logs/repro.log","filename":"repro.log","size":371,"category":"log"}]}