Teardown: chaining small bugs into a real breach

Teardown: chaining small bugs into a real breach

Most reports score a bug on its own, then move on. That habit hides the real danger, because exploit chaining is how three small issues that each look harmless turn into one account takeover. In this teardown we walk through an invented app called Acme Notes and follow a chain from a leaky endpoint to a full password reset, link by link, proving each step before we connect it to the next.

What exploit chaining means

A chain is a sequence of findings where the output of one becomes the input of the next. Alone, each link earns a low severity rating. Read in order, they hand an attacker something they should never reach. The exploit chain meaning is simple to state and easy to miss: severity is not a property of one bug, it is a property of the path.

Acme Notes is a small notes app. Users sign up, write notes, and reset a forgotten password by email. We found three issues. A public endpoint that lists user ids. An access control gap that returns a reset token for any id you ask for. A reset flow that accepts that token without a second check. Each was filed by a different reviewer as low. Together they are critical.

Severity is not a property of one bug. It is a property of the path an attacker can walk end to end.

Link one: a public endpoint leaks user ids

Acme Notes has a directory feature so teammates can find each other. The endpoint needs no auth and returns a tidy list.

GET /api/v1/directory?team=acme HTTP/1.1
Host: app.acmenotes.example

200 OK
[
  { "id": 4821, "name": "Dana Lee" },
  { "id": 4822, "name": "Sam Ortiz" }
]

On its own this reads as minor. Names are semi public anyway, and the team field is guessable. The reviewer who filed it wrote “info disclosure, low” and they were right about the impact in isolation. What matters for a chain is not the names. It is the id field. We now have a clean list of valid internal user ids, the exact input the next link wants.

Why prove it first

Before treating this as link one, we confirmed the endpoint really needs no session. We sent the request with no cookie and with a logged out client. Same 200, same ids. That is the evidence. We do not assume the ids are real or stable, we test that the same id maps to the same user across requests. It does. Now the link is verified and we can build on it.

Link two: an IDOR exposes a reset token tied to an id

Acme Notes lets a signed in user view their own pending reset status, so the support team can tell people whether a reset email is still valid. The route takes a user id.

GET /api/v1/users/4821/reset_status HTTP/1.1
Host: app.acmenotes.example
Authorization: Bearer <any valid user token>

200 OK
{ "pending": true, "token": "f3a9c1e8b2d47..." }

This is an insecure direct object reference. The server checks that you are logged in. It never checks that the id you asked for is your own. So any authenticated user, even a brand new free account, can read the reset status of any other id, and the response includes the live reset token.

Filed alone, this looks like a leak of a value that should be secret but that an attacker cannot target, because how would they know which ids exist or matter? That assumption is the weak point. Link one already answered it. We have the id list, so we are not guessing.

Verify, then connect

We confirmed the IDOR with two accounts we controlled. From account A we requested the reset status of account B by its id and read back B’s token. We did not stop at “the field is present.” We checked that the token value actually belonged to B’s account and not a placeholder. Only after that evidence did we treat link one and link two as joined.

Link three: a weak reset flow accepts the token

The final link is the reset endpoint itself. A well built flow ties the token to a session, an email confirmation, or a short expiry plus a one time use guard. Acme Notes does none of that. It accepts any token that matches a pending reset and sets the new password.

POST /api/v1/password/reset HTTP/1.1
Host: app.acmenotes.example
Content-Type: application/json

{ "token": "f3a9c1e8b2d47...", "new_password": "attacker_chosen" }

200 OK
{ "status": "password_updated" }

On its own the team rated this medium and noted the token “is hard to obtain.” True in a vacuum. Links one and two removed that condition. The token is no longer hard to obtain, it is a field in a JSON response any user can read.

Reading the chain end to end

Put the three verified links in order and the picture changes:

  • Step one. Pull the user id for a target from the public directory.
  • Step two. Use any logged in account to read that id’s reset status and copy the live token.
  • Step three. Submit the token to the reset endpoint and set a new password.

The result is account takeover of any user, starting from a free signup. None of the three findings would have triggered a page on their own. The chain is the bug. This is the gap between scanning for known payloads and understanding what an app assumes about its own data, a theme we cover across our attack teardowns.

The defensive lesson

The fix is not only to patch each link, though you should. It is to stop trusting that a low severity finding stays low. Three habits help.

  • Treat identifiers as reachable. Once an id appears in any unauthenticated response, plan as if every attacker holds the full list. Sequential integer ids make this worse, so prefer unguessable values, but do not rely on secrecy of ids as a control.
  • Check ownership on every object route. The IDOR existed because the server confirmed authentication but never authorization. “Is this caller allowed to see this specific record” is a separate question from “is this caller logged in.” Ask both.
  • Bind reset tokens to context. A reset token should be single use, short lived, and tied to the email that requested it or the session that follows the link. A token that any holder can redeem is a password waiting to be changed.

The wider lesson is about how you review. When you file a finding, write down what the next attacker would need to make it worse, and whether your own app already provides that. The reset bug looked safe only because the reviewer assumed tokens were hard to reach. A second reviewer looking one step ahead would have asked where reset tokens are exposed, and found link two.

How to verify a chain honestly

Do not claim a chain you have not walked. Reproduce each link with evidence: the raw request, the raw response, and the accounts you used. Confirm that the value carried between links is the real value, not a lookalike. Then walk the whole path once, from public directory to changed password, on accounts you own in a test environment. If any link fails to reproduce, the chain is a theory, not a finding.

Closing

Small bugs are not small when they line up. The way to catch a chain is to understand the app, question each assumption, and prove every link before you trust it. This is exactly the kind of problem an autonomous researcher that tests assumptions, rather than matching a fixed list of payloads, is built to find. You can read more about that approach on our about page.