GitHub Security Advisory: GHSA-289f-fq7w-6q2w
Package: thorsten/phpmyfaq (COMPOSER)
Severity: CRITICAL (CVSS 9.8)
CWE: CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Vulnerable versions: <= 4.1.1
Patched in: 4.1.2

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:

```php
$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()`):

```php
$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:

```php
$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:

```php
#[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