FleetView: Choosing Between Three Findings in 90 Minutes
FleetView is a fleet-tracking SaaS. Your free-tier account can create one vehicle and one report. During recon you collect three leads. (a) GET /api/v1/reports/bulk?ids=a,b,c returns report metadata for any ID and does not check tenant ownership, unlike the single-report endpoint which does; report IDs are UUIDv1, so they encode a creation timestamp and a node value. (b) A remember cookie holds base64(PHP serialize(['uid'=>..., 'exp'=>...])) and the app calls unserialize() on it before the session middleware runs; a composer lock file from a leaked backup zip shows the app bundles a library with known magic-method gadget chains. (c) The JWT header contains {"alg":"HS256","kid":"2024-05"} and the backend uses kid in an unparameterised query against a signing_keys table to fetch the HMAC secret. You have 90 minutes left on the engagement and the client wants one confirmed, high-impact finding written up today.
1. Rank (a), (b) and (c) by expected time-to-impact for the next 90 minutes, and state what evidence you would accept as "confirmed" for each.
Read the worked answer
Order: (a) → (c) → (b), with (b) hard time-boxed.
(a) First, because it is 10–15 minutes and gives you a banked, certain finding. Create a second free account, create a report under it, then request its ID from account one's session via /api/v1/reports/bulk. If it returns that report's metadata, the vulnerability is confirmed beyond argument. Confirmed = two reports belonging to two tenants you created, retrieved from the wrong session, with the response body captured. The reason to do it first is portfolio management, not severity: it guarantees you leave the engagement with a documented finding, which de-risks the whole 90 minutes. Its weakness is impact - metadata only - so also determine whether the bulk endpoint has a sibling that returns contents or accepts a wildcard/list, and enumerate IDs by UUIDv1 timestamp to show scale.
(c) Second, because the impact is the highest of the three and the exploit path is deterministic. A kid injection that lets you choose the HMAC secret is a forged admin token, i.e. full application compromise. Realistically 25–40 minutes including the blind injection. Confirmed = a JWT you signed with a secret the server accepted, used to call an endpoint that rejects your normal session. Note the prerequisite: the key lookup must happen before signature verification, which is typical but must be tested - send a token signed with junk and an unknown kid and compare the response to a known-good kid with a bad signature. If both produce "invalid signature", you are not reaching the query and the whole lead changes shape.
(b) Last and time-boxed to 20 minutes. Highest theoretical impact (pre-auth RCE) but the most uncertain: the lock file tells you a gadget chain exists in the dependency tree, not that any of the loaded classes have a usable __destruct/__wakeup reachable from this entry point, nor that autoloading reaches them. Run phpggc against the identified library versions, and if nothing lands in 20 minutes, stop and report it as a residual-risk item - never as "RCE".
2. For (c), explain how you confirm the injection in kid without a visible error, and how you then forge an admin token.
Read the worked answer
Establish the oracle first. The header is attacker-controlled and is parsed before the signature can be verified - that is the premise you are testing. Send three requests with the same payload body and signature: (i) kid set to a value you know exists, (ii) kid set to a value that cannot exist (zzz-9999), (iii) kid with a trailing quote (2024-05'). If (ii) and (iii) both produce "invalid signature" indistinguishable from (i), the query is either parameterised, or it is not reached. If (ii) gives a distinct response - 500, "unknown key", or a different latency - you have a lookup that executes before verification and a boolean oracle.
Boolean confirmation. Compare 2024-05' AND '1'='1 against 2024-05' AND '1'='2 (this is a string context, so quote-escape and comment syntax matters - use -- - with the trailing space and dash, and be ready for the app to be MySQL or Postgres). The first returns the real row and your token validates; the second returns no row and it does not. Two responses that differ consistently is confirmation. Time-based (AND SLEEP(5)) is the fallback if the app swallows both into the same error. Keep the body and signature identical across every request so the only variable is the header.
Extraction and forging - two routes, try the fast one first.
- UNION the secret.
kid=x' UNION SELECT 'attackersecret'-- -returns exactly one row whose value you chose, so the app verifies your token againstattackersecret. This is the fastest path and needs no knowledge of the real key. Column count mismatch is the only obstacle; adjust the number of NULLs until it works. - Blind extraction. If UNION is filtered, extract the real secret character by character with boolean/substring predicates, then sign normally. Slower but robust.
Then forge. Sign {"sub":"<admin uuid>","role":"admin","exp":<future>} HS256 with attackersecret. Prove impact by calling an endpoint that your ordinary user cannot reach - for example listing all users or reading another tenant's fleet - and capture that response. If you cannot find an admin UUID, try role escalation first: the role claim may be checked directly rather than looked up.
Also worth thirty seconds: the sibling attack on kid - path traversal (../../../../dev/null) to force an empty key, or injecting a JWKS URL. If kid is used in a filesystem path anywhere in the signing library, that is a faster win than SQLi.
3. Assume (b) is real but you cannot find a working gadget chain in the bundled library within the window. What is the residual risk to report, and how do you avoid overstating it while still getting the client to fix it?
Read the worked answer
Report the defect you proved, describe the impact as conditional, and give the client the two facts they need to decide.
The defect is: user-controlled data from a cookie is passed to unserialize() with no allowed_classes restriction, before authentication. That is proven - you can demonstrate it by swapping the payload for O:8:"stdClass":0:{} and showing the app processes it (no error, session still resolves), and by sending a deliberately truncated object and observing a PHP unserialize notice or 500, which proves the data reaches the deserialiser rather than being validated first. That evidence stands on its own and does not depend on a gadget.
Residual risk wording. "PHP object injection in a pre-authentication code path. Exploitation depends on the presence of a usable magic-method gadget chain in the loaded dependency set; one library with published gadget chains is present in the composer lock file, but I did not achieve code execution within the testing window. The vulnerability class is unauthenticated RCE; the demonstrated result is unauthenticated object injection. Because the app runs composer install on deploy, a future dependency addition or version bump can make this exploitable with no change to the vulnerable code." That last sentence is the one that moves a client - the risk is latent and the attack surface grows on its own.
What not to say. Do not write "RCE" with a CVSS score derived from it; do not paste an untested phpggc payload into the report; do not claim "requires user interaction" or "low likelihood" either. The honest framing is unrated-on-exploitability but high-on-fix-priority, because the fix is trivial: unserialize($data, ['allowed_classes' => false]), or better, replace the cookie with a signed opaque token.
Make the fix easy to accept. Give the client a concrete diff, a note that the same unserialize pattern should be grepped for across the codebase (ssrf and object injection usually appear in clusters), and an offer to retest with an extended window specifically for gadget work. Also mention the parallel finding: the remember cookie is the session layer, so its integrity matters more than a normal parameter - object injection here is one bug away from unauthenticated RCE, which is why it is worth a second look even though you could not land it today.