FurHire: Operator Injection in Account Recovery
Executive Summary
FurHire is a pet themed job board on the BugForge platform, built on Node.js/Express with Socket.IO for real time notifications, JWT (HS256) bearer authentication held in localStorage, and server rendered pages carrying inline JavaScript. It exposes two self selected roles, user (job seeker) and recruiter. An unlinked account recovery endpoint, POST /api/account/recover, accepts an email address and a backup code and reports whether the code is valid for that account.
Testing confirmed four findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Operator injection in account recovery gives verification bypass and blind extraction of stored backup codes | High | 7.5 | CWE-943, CWE-1287, CWE-257 | POST /api/account/recover |
| F2 | No rate limiting or lockout on account recovery | Medium | 5.3 | CWE-307 | POST /api/account/recover |
| F3 | Socket.IO delivers every notification to every connected client without authentication | Medium | 5.3 | CWE-200, CWE-306 | GET /socket.io/ |
| F4 | Registration discloses whether a username or email is already registered | Low * | 5.3 | CWE-204 | POST /api/register |
* F4’s raw CVSS math is 5.3, which sits in the Medium band. The Low label reflects that the demonstrated impact is account enumeration only, with no data read and no state change.
F1 is the flag bearing finding. Neither email nor backupCode is type checked before reaching the query layer, so sending a JSON object instead of a string turns the value into a set of query operators. That yields three effects in one request shape: verification passes without a valid code, the response discloses the matched account’s username, and an ordering comparison such as {"$gt": X} answers a single yes or no question about the stored value. Binary searching that comparison per byte recovered the backup code stored on recruiter whiskers_hr (id 5), which is the lab flag: bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}.
Objective
Recover the lab flag on the BugForge “FurHire” target.
Scope / Initial Access
# Target Application
URL: https://lab-1786726255893-tlm1li.labs-app.bugforge.io
# Auth
POST /api/register → {role, username, email, full_name, password} → JWT HS256
POST /api/login → {username, password} → {token, user}
payload: {"id":6,"username":"d4rk_seeker","role":"user","iat":...}
# Starting privileges
Self registered seeker account. No credentials supplied by the platform.
Registration is open and instant. The token is a bearer JWT sent in the Authorization header; no cookies were observed. Alongside the token, the client caches a user object in localStorage, and that cached object drives all role and ownership rendering in the browser.
The finding in F1 needs neither of those. POST /api/account/recover is reachable with no token at all.
Reconnaissance: Reading a Client That Guards Nothing
Route protection is implemented only in the browser. /public/js/app.js redirects to /login when no token is present in localStorage, but the server renders every page path with a 200 regardless, so the inline JavaScript on each page, and the full list of API routes it calls, can be read without an account. The API map extracted from that JavaScript was cross checked against an OPTIONS sweep for Allow headers on every path, and the two agreed exactly.
Observations that shaped the test plan:
- Every page returns 200 unauthenticated, so the API surface is fully readable with no account and no source map required.
POST /api/account/recoveris not linked from the homepage or the navigation. It appears only in the login page footer and in the client’spublicPathsarray. Its body is{email, backupCode}.- No route anywhere generates or issues a backup code. This was checked against the client JavaScript, the
OPTIONSsweep, and ffuf over/api/FUZZwithraft-medium. The recovery feature only verifies, so any stored code has to be seeded on the server. Obtaining a valid code was therefore the whole problem. - The recovery form’s placeholder advertises an expected format,
A1B2C3-D4E5F6-A7B8C9: three groups of six alphanumeric characters. Not every stored code follows it, as F1 shows. GET /api/jobs/3returns arecruiter_emailfield for the job’s owning recruiter, which supplied[email protected]and[email protected]as account selectors. Every captured request to this endpoint carried anAuthorizationheader, so whether it requires one was not established.app.jsreceivesnew_applicationandstatus_updateevents over Socket.IO and decides whether to show each one by comparing the cacheduser.idagainst an id inside the event payload, which places the filtering in the browser.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node.js / Express (X-Powered-By: Express), server rendered HTML with inline JavaScript |
| Frontend | Shared static client at /public/js/app.js (4KB, unminified), no build step or bundle |
| Real time | Socket.IO, reachable over the Engine.IO v4 polling transport |
| Auth | JWT HS256 bearer in localStorage, plus a cached user object driving role and ownership rendering in the browser. No cookies observed |
| Database | Relational. Not directly observable from the client. The operator set honoured on /api/account/recover maps one to one onto SQL comparison operators, which points to a hand written operator to SQL translation rather than a document store. Inference, see F1 |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Body carries a hidden role field rendered from ?role= |
/api/login |
POST | No | Returns {token, user} |
/api/account/recover |
POST | No | Unlinked. {email, backupCode}. F1, F2 |
/api/profile |
GET, PUT | Yes | Returns {profile, company} |
/api/company |
PUT | Yes | No GET; read through /api/profile |
/api/skills |
GET | Yes | Static list |
/api/jobs |
GET, POST | Yes | GET takes search, location, job_type |
/api/jobs/:id |
GET, PUT | PUT yes; GET not established | GET discloses recruiter_email. Every captured GET carried a token, so whether it requires one was never tested |
/api/jobs/:id/apply |
POST | Yes | {cover_letter} |
/api/jobs/:id/applicants |
GET | Yes | Applicant email, bio, skills, cover letters |
/api/applications/:id/status |
PUT | Yes | {status} |
/api/my-applications |
GET | Yes | |
/api/my-jobs |
GET | Yes | |
/api/saved-jobs |
GET | Yes | Reader with no writer in the client or the Allow sweep |
/api/notifications |
GET | Yes | Found by ffuf, absent from the client map |
/api/verify-token |
POST | Yes | Found by ffuf, absent from the client map |
/socket.io/ |
GET | No | Handshake accepts a connection with no token. F3 |
Known Users
| Username | ID | Role | How it surfaced |
|---|---|---|---|
pawsitive_hr |
4 | recruiter | Username disclosed by F1; id from recruiter_id on jobs 1 and 2, company “Pawsitive Ventures” |
whiskers_hr |
5 | recruiter | Username disclosed by F1; id from recruiter_id on job 3, company “Whiskers & Co” |
max_retriever |
not observed | not observed | Username disclosed by F1 through an operator on the email selector |
d4rk_seeker |
6 | user | Our registered account |
Attack Chain Visualization
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ POST /api/account/ │ │ POST /api/account/ │ │ POST /api/account/ │
│ recover │ │ recover │ │ recover │
│ backupCode: │──▶│ email:{"$exists":true} │──▶│ {"$gte":"bug{i", │
│ {"$ne":"x"} │ │ backupCode:{"$gt":"a"} │ │ "$lt":"bug{j"} → 200 │
│ 200 verified │ │ 200 whiskers_hr │ │ "starts with" test, │
│ + username disclosed │ │ 401 at {"$gt":"c"} │ │ 283 reqs for 37 bytes │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
│
▼
┌────────────────────────┐
│ POST /api/account/ │
│ recover │
│ plain string, no │
│ operators → 200 │
│ same string vs │
│ [email protected] → 401 │
└────────────────────────┘
Findings
F1: Operator injection in account recovery gives verification bypass and blind extraction of stored backup codes
Severity: High
CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-943 (Improper Neutralization of Special Elements in Data Query Logic), CWE-1287 (Improper Validation of Specified Type of Input), CWE-257 (Storing Passwords in a Recoverable Format)
Endpoint: POST /api/account/recover
Authentication required: No
Description
POST /api/account/recover takes {email, backupCode} and answers 200 when the code is valid for the account and 401 when it is not. Neither field is checked for type before it reaches the query layer, so a JSON object is interpreted as query operators instead of being compared as a value. Three defects compound:
- No type validation on either field.
{"backupCode":{"$ne":"x"}}returns 200 without any knowledge of the stored code. The same applies toemail, so an account can be selected without knowing any address at all:{"email":{"$exists":true}}resolves to a single account, the first row the query returns rather than the first by email order. - The success response names the account. A 200 carries
"username", so the request that bypasses the check also discloses the username. Note the limit of the 200 versus 401 split: an address with no account and a registered account with no code stored both return 401, so the split separates accounts holding a provisioned backup code from everything else, not registered addresses from unregistered ones. The disclosure here is the username in the 200 body. - The stored code is compared in cleartext, in order. Ordering comparisons succeed against the stored value (
{"$gt":"3"}returns 200 against a code beginning with a digit), so the comparison operates on the code itself rather than on a hash of it. That makes the stored value recoverable one byte at a time.
The honoured set was mapped against [email protected], whose code 3C9D25-CE8D85-5C9DA1 was extracted first by the method below, giving a control target with a known stored value. Each operator was sent twice, once in a form that should match and once in a form that should not, so that “works” stays distinguishable from “ignored” and “always true”:
| Honoured (discriminates) | Returns 500 | Accepted and ignored |
|---|---|---|
$ne $gt $gte $lt $lte $in $nin $exists |
$eq $regex $like $ilike $glob $regexp $match $startsWith $contains $prefix $not $all $type $mod $where $expr |
$size $elemMatch |
Multiple operators in one object are combined with AND, so {"$gt":"3","$lt":"4"} returns 200 while {"$gt":"4","$lt":"5"} returns 401, which makes a half open interval a direct “does the stored value start with this prefix” test. No regular expression, LIKE, or prefix operator exists at all; $in is the only exact match form, accepting roughly 1000 candidates per request and returning 413 at 5000.
The honoured set maps one to one onto SQL comparison operators (!=, >, >=, <, <=, IN, NOT IN, IS NOT NULL), and $eq is absent while $ne works. Most unrecognised keys throw 500 rather than being dropped, though $size and $elemMatch are accepted and ignored, so that third signal is weaker than the other two. Read together these point to a hand written operator to SQL translation rather than a document store, which is why this is written up as operator injection and type confusion rather than as NoSQL injection. That reading is an inference from the response behaviour; the backend was not observed directly.
The endpoint verifies only. It issues no token, sets no session and resets no password, and no reset completion route exists anywhere in the application (checked against the client map, the OPTIONS sweep and ffuf). Backup codes exist to authenticate a password reset, so in a deployment carrying the second half of that flow this would be a pre authentication account takeover. That step was not present here and was not demonstrated, so it is reasoned, not tested, and the severity reflects what was proven.
Impact
Unauthenticated disclosure of any account’s stored backup code and username. The code recovered from recruiter whiskers_hr is the lab flag.
Reproduction
Step 1: Pass verification without a valid code
POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"email":"[email protected]","backupCode":{"$ne":"x"}}
Response: 200 {"status":"verified","username":"whiskers_hr","message":"Backup code accepted. You can now reset your password."}. The object is treated as an operator rather than compared as a string, and the response names the account. Aim this at an account whose code is actually populated: the same payload against a freshly registered account returns the same 401 as a hardened endpoint would, because a new account has no code stored.
Step 2: Locate the flag bearing row without knowing an address
POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"email": {"$exists": true}, "backupCode": {"$gt": "a"}}
Response: 200 {"status":"verified","username":"whiskers_hr",...}. The comparison doubles as a search filter across accounts. Repeating it for bounds in 9 A F G Z _ a b returns 200 and for c z returns 401, placing the stored value between b and c. The only ordinary code recovered from this application, 3C9D25-CE8D85-5C9DA1, sorts far below that, so the row this bound selects is an outlier worth extracting. Note the limit of this step: $gt compares the whole string, so a 200 on $gt "bug{" does not by itself prove a bug{ prefix. The same 200 comes back for bog, beg or bacon, which all diverge earlier. This step identifies which row is worth extracting, nothing more.
Step 3: Turn the comparison into a per character test
POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"email": "[email protected]", "backupCode": {"$gte": "bug{i", "$lt": "bug{j"}}
Response: 200. The two operators are ANDed into a range, so the half open interval [P+c, P+next(c)) returns 200 only when the stored value starts with P+c. The same probe for h, j, Y and z returns 401.
Step 4: Extract the value
python3 tools/blind_extract_v2.py \
--email [email protected] \
--url https://lab-1786726255893-tlm1li.labs-app.bugforge.io/api/account/recover
Binary searching the range bounds per position recovers the 37 character value in 283 requests: bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}. The value terminates naturally when the whole remaining range returns 401.
Step 5: Confirm the value is byte exact and account bound
POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"email": "[email protected]", "backupCode": "bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}"}
Response: 200 {"status":"verified","username":"whiskers_hr",...}, from a plain string with no operators in it. The same string against [email protected] returns 401 {"status":"invalid","message":"That backup code is not valid for this account."}, which rules out the endpoint simply answering 200 to everything.
Remediation
Fix 1: Reject non string values before they reach the query layer
// BEFORE (Vulnerable)
app.post('/api/account/recover', async (req, res) => {
const { email, backupCode } = req.body;
if (!email || !backupCode) {
return res.status(400).json({ error: 'Email and backup code are required' });
}
const row = await db.findUser({ email, backup_code: backupCode });
...
});
// AFTER (Secure)
app.post('/api/account/recover', async (req, res) => {
const { email, backupCode } = req.body;
if (typeof email !== 'string' || typeof backupCode !== 'string') {
return res.status(400).json({ error: 'Email and backup code are required' });
}
const row = await db.findUser({ email, backup_code: backupCode });
...
});
Type validation belongs ahead of the operator translation layer, not inside it. A schema validator applied to the whole body (zod, joi, express-validator) is preferable to per field checks, because it closes the same hole on every other route at once.
Fix 2: Store a hash of the backup code and compare it in constant time
// BEFORE (Vulnerable)
const row = await db.get(
'SELECT username FROM users WHERE email = ? AND backup_code = ?',
[email, backupCode]
);
// AFTER (Secure)
const row = await db.get('SELECT username, backup_code_hash FROM users WHERE email = ?', [email]);
const ok = row && (await argon2.verify(row.backup_code_hash, backupCode));
Hashing removes the ordering comparison that made extraction possible: a comparison against a hash tells an attacker nothing about the code. Verifying the hash in the application also keeps the secret out of any query the operator layer can influence. Note the ordering in the fixed version: skipping argon2.verify when no row matches leaves an unregistered address measurably faster than a registered one, so verify against a dummy hash on the miss path and pair this with Fix 3.
Fix 3: Return one generic result that does not name the account
// BEFORE (Vulnerable)
return res.json({
status: 'verified',
username: row.username,
message: 'Backup code accepted. You can now reset your password.'
});
// AFTER (Secure)
return res.json({
status: 'ok',
message: 'If the details are correct, a reset link has been sent.'
});
The same generic body should be returned for a wrong code and an unregistered email, with the same status code and comparable timing, so the endpoint stops distinguishing registered accounts from unregistered ones.
Additional recommendations:
- Do not store a high value secret as a recoverable cleartext credential. Here the flag itself was the stored backup code.
- Issue backup codes single use and expire them on use.
- Apply the same type validation to
POST /api/loginand any other route that forwards request body values into a query. - Rate limit and lock out this endpoint per account and per source address (see F2).
F2: No rate limiting or lockout on account recovery
Severity: Medium
CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)
CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts)
Endpoint: POST /api/account/recover
Authentication required: No
Description
The recovery endpoint applies no throttling, no lockout, no CAPTCHA and no backoff. Over 1,100 verification attempts were sent to it across the engagement with no change in response behaviour. Request tempo was reconstructed from the Date headers on the proxy captured pairs rather than from the tooling’s own counters: 363 requests between 17:20:38 and 17:21:37 GMT, roughly 6.1 requests per second sustained with no gaps, zero 429 responses anywhere in the record, and no CAPTCHA markup in the recovery page. A control request sent after the burst still returned the ordinary 200 and 401 shapes, which is the positive evidence an absence claim needs.
The only ordinary code recovered here, 3C9D25-CE8D85-5C9DA1, is 18 hexadecimal characters, a space of 16^18 or roughly 4.7 x 10^21. Guessing a code of that shape is not feasible at any throttling setting, and the target account’s stored value is not even that shape: it is the 37 character string recovered in F1, from a wider character set again. So the missing limiter buys nothing against guessing the credential. What it buys is F1, whose extraction needs several hundred sequential requests with nothing on this endpoint to slow them down. Credential stuffing against this flow is also unconstrained, though that was reasoned rather than tested.
Two scoring notes. The C:L metric reflects that contribution rather than any disclosure F2 performs on its own; the extracted credential is scored once, at C:H, on F1 where it is actually disclosed. And F2 and F4 compute to the same 5.3 from the same vector while carrying different labels on purpose: F2 sits at Medium because its absence is the precondition that makes F1 practical, while F4 stays at Low because account existence is the whole of what it yields and nothing else here depends on it.
Impact
Removes a defence in depth layer on an unauthenticated endpoint and makes the several hundred request extraction in F1 practical.
Reproduction
Step 1: Send a sustained burst of verification attempts
for i in $(seq 1 400); do
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST https://lab-1786726255893-tlm1li.labs-app.bugforge.io/api/account/recover \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","backupCode":"AAAAAA-AAAAAA-AAAAAA"}'
done | sort | uniq -c
Response: every request returns 401. No 429, no Retry-After, no lockout, and response latency is flat across the run. The loop above is illustrative; the burst captured through the proxy and cited above was 363 requests.
Step 2: Confirm the account still behaves normally afterwards
POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"email":"[email protected]","backupCode":"bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}"}
Response: 200 verified. The account was never locked and the correct value is still accepted, so the uniform 401s in step 1 were genuine rejections rather than a silent block.
Remediation
Fix 1: Throttle and lock out on repeated failures
// BEFORE (Vulnerable)
app.post('/api/account/recover', recoverHandler);
// AFTER (Secure)
const recoverLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
keyGenerator: (req) => `${req.ip}:${String(req.body?.email ?? '')}`,
standardHeaders: true,
handler: (req, res) => res.status(429).json({ error: 'Too many attempts. Try again later.' })
});
app.post('/api/account/recover', recoverLimiter, recoverHandler);
Additional recommendations:
- Key the limit on both source address and target account, so one address cannot walk the user table and one account cannot be attacked from many addresses.
- Lock the account’s recovery flow after a small number of consecutive failures and require an out of band step to unlock it.
- Alert on bursts of failed recovery attempts. A run of several hundred against one account in under a minute is a strong signal on its own.
F3: Socket.IO delivers every notification to every connected client without authentication
Severity: Medium
CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)
CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-306 (Missing Authentication for Critical Function)
Endpoint: GET /socket.io/
Authentication required: No
Description
The Socket.IO handshake accepts a connection that carries no token, no auth payload and no cookie. The server then emits new_application and status_update to every connected socket with no per user room scoping, so a client that never authenticated receives the notifications generated for every other account on the platform. The only thing separating one user’s notifications from another’s is the comparison in /public/js/app.js, which runs in the browser:
socket.on('new_application', (data) => {
if (user && user.id === data.recruiterId) { showToast(data.message); }
});
A raw Engine.IO v4 polling client with no credentials received job titles, internal numeric user and job ids, and the fact and timing of applications and hiring decisions belonging to other accounts. The leaked ids and titles match server produced data elsewhere in the application (job id 2 is “Senior Fetch Specialist” owned by recruiter_id 4), so the events carry live data rather than placeholders. Both captured events were generated by test accounts under our control, so the platform wide reach of the leak follows from the absent scoping on the emit, not from observed third party traffic.
Impact
An unauthenticated observer sees applications and hiring decisions belonging to other accounts in real time, including job titles and internal account ids. No credentials appear in the payloads.
Reproduction
Step 1: Complete the handshake with no credentials
curl -s 'https://lab-1786726255893-tlm1li.labs-app.bugforge.io/socket.io/?EIO=4&transport=polling'
Response: 0{"sid":"...","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000}. No token was sent and the session was still issued.
Step 2: Join the default namespace and poll for events
python3 tools/sio_listen.py \
--url https://lab-1786726255893-tlm1li.labs-app.bugforge.io
The tool sends the bare namespace connect frame 40 with no auth object and no cookie jar, then polls. While a second browser session holding a registered seeker account submitted an application, and a registered recruiter account changed an application’s status, the client holding no account received:
42["new_application",{"recruiterId":4,"jobId":"2","message":"New application received for Senior Fetch Specialist"}]
42["status_update",{"userId":6,"message":"Your application status has been updated to: accepted"}]
Neither event was addressed to the listening client, which held no account at all.
Remediation
Fix 1: Authenticate the handshake and emit into a per user room
// BEFORE (Vulnerable)
io.on('connection', (socket) => { /* no auth, no rooms */ });
io.emit('new_application', { recruiterId, jobId, message });
// AFTER (Secure)
io.use((socket, next) => {
try {
const payload = jwt.verify(socket.handshake.auth?.token, process.env.JWT_SECRET);
socket.data.userId = payload.id;
socket.join(`user:${payload.id}`);
next();
} catch (err) {
next(new Error('unauthorized'));
}
});
io.to(`user:${recruiterId}`).emit('new_application', { jobId, message });
Additional recommendations:
- Drop the recipient id from the payload once delivery is scoped. It is only there to support the check in the browser, and it discloses internal account ids.
- Treat a check that runs in the browser as presentation logic, never as an access control.
- Re-verify the token on reconnect, not only on the first handshake.
F4: Registration discloses whether a username or email is already registered
Severity: Low (raw CVSS math is 5.3, in the Medium band; labelled Low because the demonstrated impact is enumeration only, with no data read and no state change)
CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)
CWE: CWE-204 (Observable Response Discrepancy)
Endpoint: POST /api/register
Authentication required: No
Description
Registration answers 400 {"error":"Username or email already exists"} for a taken username or email and 200 with a token for a free one, so the endpoint distinguishes registered accounts from unregistered ones without authentication. A follow up login using the original password confirmed that a 400 leaves the existing account untouched, so the 400 reports a genuine pre existing account rather than an overwrite.
Impact
Unauthenticated confirmation of whether a given username or email holds an account.
Reproduction
Step 1: Register a name that is free
POST /api/register HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"username":"d4rk_seeker","email":"[email protected]","full_name":"Darko Seeker","password":"Passw0rd!23","role":"user"}
Response: 200 with a JWT and {"user":{"id":6,"username":"d4rk_seeker",...},"needsOnboarding":true}.
Step 2: Repeat with a name that is taken
POST /api/register HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json
{"username":"d4rk_seeker","email":"[email protected]","full_name":"Darko Seeker","password":"Passw0rd!23","role":"user"}
Response: 400 {"error":"Username or email already exists"}. The two branches are distinguishable on status code and body.
Remediation
Fix 1: Separate the collision check from the response
// BEFORE (Vulnerable)
if (existing) {
return res.status(400).json({ error: 'Username or email already exists' });
}
// AFTER (Secure)
if (existing) {
await mailer.sendAccountExistsNotice(email);
return res.status(202).json({ message: 'Check your email to finish signing up.' });
}
Additional recommendations:
- Where a username has to be checked live for usability, put that check behind its own rate limited endpoint rather than deriving it from the registration result.
- Keep the response shape and timing identical on both branches.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: Socket.IO notifications intended for one account are delivered to every connected socket, with the only recipient check running in the browser (F3).
- A03:2021 Injection: Untyped JSON reaches the query layer on
/api/account/recover, so attacker supplied structure is interpreted as query operators rather than compared as a value (F1). - A04:2021 Insecure Design: The recovery flow verifies a code that is stored in a form allowing ordering comparison, names the matched account on success, and has no reset completion step for the code to protect (F1).
- A07:2021 Identification and Authentication Failures: Backup code verification is bypassable without a valid code, and the endpoint has no rate limiting or lockout (F1, F2).
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Proxy capture of request and response pairs, request tempo evidence from Date headers |
| curl | Manual probes, handshake checks, negative controls |
tools/operator_map.py |
Operator support matrix, one should match and one should not match form per operator |
tools/blind_extract_v2.py |
Range predicate extraction, deterministic per character |
tools/blind_extract.py |
First extraction method, kept because it documents the terminal byte failure the range method removes |
tools/sio_listen.py |
Raw Engine.IO v4 polling client with no dependencies and no credentials |
| ffuf | /api/FUZZ route discovery with raft-medium |
| hashcat | JWT HS256 secret attempt against rockyou (-m 16500) |
References
- CWE-943: Improper Neutralization of Special Elements in Data Query Logic
- CWE-1287: Improper Validation of Specified Type of Input
- CWE-307: Improper Restriction of Excessive Authentication Attempts
- CWE-204: Observable Response Discrepancy
- CWE-306: Missing Authentication for Critical Function
- CWE-257: Storing Passwords in a Recoverable Format
- OWASP Testing Guide: Testing for NoSQL Injection. The closest published methodology for operator payloads, though the evidence here points away from a document store
- OWASP Authentication Cheat Sheet
- Socket.IO: Middlewares and authentication
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Object payloads ($ne, $regex) against our own registered account |
Uniform 401, identical to a rejected string | A fresh account has no backup code stored, so the comparison never runs. The negative described the account’s emptiness, not the endpoint. The same payload against a seeded account returned 200 |
| Role self assignment at registration | All ten values fell back to user |
role is whitelisted to {user, recruiter} server side, and no third role exists in the application |
GET /api/jobs/:id/applicants against another recruiter’s job |
[] with a live application present on the target job |
Row level ownership scoping is applied. The control confirmed the empty array was scoping, not an empty table |
PUT /api/applications/:id/status against another account’s application |
404 not found or unauthorized, re-fired against a real row |
Ownership checked server side |
PUT /api/jobs/:id against another recruiter’s job |
404 not found or unauthorized |
Ownership checked server side |
Profile mass assignment (role, backup_codes, id, email, resume_url) |
Values silently dropped, profile unchanged | Explicit field whitelist on the update |
SQL injection in /api/jobs filters (search, location, job_type) |
Bare ' matched literally, zero rows, no error |
Queries are parameterized |
/api/notifications scope break, 12 parameter variants |
Byte identical to baseline | Hard scoped to the JWT subject |
JWT forgery (alg=none, None, NONE, empty signature, payload tampering) |
403 on every variant. Secret not in rockyou |
Signature verification is correct and the algorithm is pinned |
| Re-registering an existing username as a password reset | 400, original password still valid |
Registration does not overwrite an existing account |
| Backup code as a hash of a known field (189 md5/sha1/sha256 derivations of username, email, id, name) | All invalid | The code is not derived from any observable account field |
Wildcards and partial matches in the backup code (%, _, *, .*) as strings |
All invalid | The string comparison is exact. Wildcards only work through the operator form, and no LIKE or regex operator exists |
Route discovery with ffuf raft-medium over /api/FUZZ plus an OPTIONS Allow sweep |
Only /api/verify-token and /api/notifications beyond the client map |
No hidden admin surface, and no backup code generation route anywhere |
Untested, not cleared: mass assignment on POST /api/jobs, POST /api/jobs/:id/apply and PUT /api/company; arbitrary status values on PUT /api/applications/:id/status; injection in path parameters and on POST /api/login; server side socket.on() handlers; /api/saved-jobs scope breaks.
Tags: #operator-injection #type-confusion #blind-extraction #account-recovery #socket-io #bugforge
Document Version: 1.0
Last Updated: 2026-08-14