Category: Attack Teardowns

Step by step walkthroughs of how real bug classes are found and chained, using safe examples.

  • 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.

  • Teardown: how an IDOR quietly exposes another user’s data

    Teardown: how an IDOR quietly exposes another user’s data

    This is an idor example built from scratch so you can watch how one quietly exposes another user’s data. We will use an invented app called Acme Notes, map how it works, form an assumption about a weak spot, then test it with real requests. Nothing here touches a live system. The goal is to teach how the bug works and how to spot it before an attacker does.

    What an idor example actually is

    IDOR stands for insecure direct object reference. It happens when an app uses an id from the request to look up a record, but never checks that the person asking is allowed to see that record. The id is the direct object reference. When ownership is not verified, the reference becomes insecure. That gap is the whole bug.

    This bug is common for one reason. Developers think about authentication, who you are, far more than authorization, what you are allowed to touch. Acme Notes asks you to log in. It forgets to ask whether the note you requested is yours.

    An IDOR is rarely about a clever payload. It is the server trusting a number it should have checked.

    Step one: map the app like a researcher

    Before testing anything, understand how the app is meant to work. Acme Notes is a small notes tool. You sign in, you see a list of your notes, you click one to read it. Open the browser network tab and watch what the page sends. When you click a note, the front end makes this request:

    GET /api/notes/4012 HTTP/1.1
    Host: app.acmenotes.example
    Authorization: Bearer eyJhbGciOiJI (your token)
    Accept: application/json

    The server answers with the note as JSON:

    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
      "id": 4012,
      "owner_id": 88,
      "title": "Q3 launch checklist",
      "body": "Ship the billing page before Friday."
    }

    Two facts stand out. The note id, 4012, is a plain sequential number that sits right in the URL. The response also carries an owner_id. Your account is owner 88. So the app knows who owns the note. The question is whether it checks that ownership on every read.

    Step two: form the assumption

    Good testing starts with a guess you can prove or disprove. Here the assumption is direct: the server may load a note by id without confirming the requester owns it. Sequential ids make this worth testing, because note 4011 and note 4013 almost certainly belong to other users. If the server only checks your token and then trusts the id, you can read notes that are not yours.

    An attacker would form the same assumption. The difference is that a researcher tests it on an app they control or have permission to test, and reports it so it gets fixed.

    Step three: test by requesting a neighbouring id

    Keep your own valid login. Change only the id in the URL. Ask for the note next door:

    GET /api/notes/4011 HTTP/1.1
    Host: app.acmenotes.example
    Authorization: Bearer eyJhbGciOiJI (your token)
    Accept: application/json

    If the app is safe, you should get a refusal. Something like this:

    HTTP/1.1 403 Forbidden
    Content-Type: application/json
    
    { "error": "You do not have access to this note." }

    But Acme Notes is not safe. It returns the note in full:

    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
      "id": 4011,
      "owner_id": 73,
      "title": "Investor call notes",
      "body": "Runway is tight. Do not share outside the board."
    }

    Look at owner_id. It is 73, not 88. You are logged in as 88, yet the server handed you another user’s note. That is the bug, proven in one request.

    Step four: confirm it is real, not a guess

    One odd response is not proof. Before you call this a finding, rule out the boring explanations. A careful check answers a few questions.

    • Is the data really someone else’s? The owner_id in the response differs from your account id. Log in as a second test user, note their real id, and confirm the leaked note belongs to a third party, not to you under another label.
    • Does it repeat? Request 4010, 4009, 4008. If a range of ids you do not own all return 200 with full bodies, this is a pattern, not a fluke.
    • Is the token doing anything? Send the same request with no Authorization header. If that returns 401 but a valid token for the wrong user returns 200, the app checks login but not ownership. That is the exact shape of an IDOR.
    • Can you see the write side too? Try a read only method first. Only test edits or deletes on data you are allowed to change, so you never damage real records while confirming the issue.

    When the leaked owner id is consistently not yours, the behaviour repeats across a range, and a valid login is the only thing the server checks, you have evidence rather than a hunch. That is the line between a real finding and noise. For more on how access control bugs are grouped and tested, see access control.

    Step five: assess the impact

    Impact is about what an attacker can reach and how easily. In Acme Notes, ids are sequential and the endpoint returns full note bodies. A script can count from 1 upward and pull every note in the system in minutes. That turns one weak check into a full data exposure.

    Now widen the lens. The same pattern often appears on more than one route. If /api/notes/{id} is broken, test the siblings the same way:

    • /api/invoices/{id} for billing records
    • /api/users/{id}/profile for personal details
    • /api/files/{id}/download for attachments

    One missing ownership check is bad. The same check missing across several endpoints is how a small bug becomes a breach. This is why one confirmed finding is worth turning into a repeatable test, so the same gap cannot return on a new route later.

    How to fix an insecure direct object reference example

    The fix is not to hide the id or scramble it. Hiding the reference only slows an attacker down. The real fix is to check ownership on the server, on every request, every time.

    Check ownership at the data layer

    Bind the lookup to the logged in user. Instead of fetching a note by id alone, fetch it by id and owner together:

    -- weak: trusts the id from the request
    SELECT * FROM notes WHERE id = 4011;
    
    -- safe: ties the note to the caller
    SELECT * FROM notes
    WHERE id = 4011 AND owner_id = :current_user_id;

    If the second query returns no rows, the app returns a 404 or 403. The user never learns whether the note exists, so they cannot map your id space by probing.

    Centralise the rule and add a regression test

    Put the ownership check in one place that every route calls, not copied into each handler where one can be forgotten. Then write a test that logs in as user A, requests user B’s note, and fails the build if the response is anything but a refusal. That test is what keeps the bug from coming back during the next refactor.

    What to take away

    An IDOR is a trust mistake, not a complex exploit. The app trusts an id it should have checked against the logged in user. You find it by mapping the app, noticing a guessable reference like a sequential note id, assuming ownership might not be verified, and proving it with a single request that returns someone else’s data. You fix it by checking ownership on the server for every object, every time.

    This is exactly the kind of bug an autonomous researcher that tests an app’s assumptions is built to find, because it comes from understanding how the app should behave, not from matching a known payload. If that approach is useful to you, read more about UnboundCompute.