Category: Attack Teardowns

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

  • Client Side Paywall Bypass: When Only the Browser Guards Paid Content

    Client Side Paywall Bypass: When Only the Browser Guards Paid Content

    A client side paywall bypass is what happens when the only thing standing between a free user and paid content is code running inside that user’s browser. The server hands the full article or the full capability to everyone who asks, and the frontend decides whether to reveal it. Anyone who opens developer tools, edits a response, or reads the JavaScript bundle gets the paid thing for nothing. This post covers the four shapes it takes, why it is access control rather than styling, and why scanners walk past it.

    What does a client side paywall bypass look like in practice?

    It looks like a server that answers honestly and a browser that lies politely on its behalf. Take an invented product, Acme Ledger, a reporting SaaS with a Free and a Pro tier. A free reader opens a premium report.

    GET /api/v1/reports/482
    Authorization: Bearer <free tier session>
    
    200 OK
    {
      "id": 482,
      "title": "Q3 margin breakdown",
      "preview": "The first two paragraphs...",
      "body": "...the entire 2,400 word report, in full...",
      "tier_required": "pro"
    }

    The body is already on the reader’s machine. The frontend then does the only enforcement in the system:

    if (report.tier_required === "pro" && !user.isPro) {
      return <PaywallOverlay preview={report.preview} />;
    }
    return <FullReport body={report.body} />;

    Nothing here is broken the way a crash is broken. The page renders. The paywall appears. And a free user reads the whole report out of the network tab.

    What are the four shapes this bug takes?

    Four recurring shapes, one root: the server never checked entitlement, so the browser had to.

    1. The hidden interface element

    A button or a form is rendered with display: none or disabled, but the endpoint it points at is live and unguarded. Deleting an attribute in the inspector, or calling the endpoint, performs the action. The visual control was the whole control.

    2. The client side role check

    The bundle contains a line like if (user.role === "admin") or if (session.plan !== "free"). Both values arrive in a response the user can intercept, and the comparison runs on hardware the user owns. Editing "free" to "pro" flips every gate. Same failure class as broken function level authorization, moved from a forgotten server check to a check that was never on the server at all.

    3. Content shipped, then masked

    The paid text or the paid rows sit in the payload, and the frontend blurs, truncates, or removes them from the DOM after the page loads. Often a reader does not even need developer tools: disabling JavaScript leaves the content on screen. When the masked field is one attribute in an object otherwise fine to return, this shades into broken object property level authorization.

    4. The API route the frontend simply does not call

    The quietest one. Acme has /api/v1/reports/482/export for Pro accounts, and the Free interface never renders the export button, so no free session touched that route in testing. Nobody wrote a check on it, because in the only flow anyone looked at it was unreachable. A free session sends the request and gets the file.

    The browser is not a place you can put a rule. It is a place you can put a hint, on a machine the attacker owns, for a program the attacker can rewrite.

    Why is this an access control failure rather than a UI bug?

    Because the damage is measured in data leaving the system, not in pixels. A UI bug means a user sees the wrong thing. Here a user obtains the thing: the paid report, the export, the allowance they did not buy. The interface was doing the job of an authorization layer, and it cannot, because it runs on the other side of the trust boundary.

    It is also a business logic vulnerability. There is no injection and no malformed input. Every request is one the application meant to support. The flaw is in what it decided was allowed, a question about the product, not the syntax.

    Why do AI generated applications produce this so often?

    Because a code generator is asked for the visible behaviour, not the server rule. “Free users should not see the full report” describes a screen. “Free users must not receive the full report” describes an authorization decision. The first produces an overlay. The second produces a check in the handler. Prompts almost always take the first shape, and the result looks correct in the browser, which is where it gets reviewed.

    Published third party research points the same direction. Scans of large numbers of vibe coded production applications have reported that a majority carried at least one security issue, and Imperva has published findings on authentication bypass in a named AI application builder. We have not tested those products, and no example here describes a real one. The pattern is the point: a paywall that looks right is the easiest kind to build with nothing behind it. Our overview of vibe coded app security covers the wider set of gaps, and Supabase row level security misconfiguration is the database shaped sibling of this mistake.

    Why do scanners miss it?

    Because there is nothing to match on. A scanner looks for inputs that make an application misbehave: a quote that breaks a query, a payload that echoes back. A paywall bypass has neither. The request is well formed, carries a real session, targets a documented endpoint, and the server answers it happily with a 200. On the wire, a free reader of a premium report looks identical to a paying one, because the server cannot tell them apart. Catching it means knowing what the product charges for, and intent never appears in a payload list.

    How is this different from a normal authorization bug?

    In a normal authorization bug the rule exists and fails on one path: a check present on nine endpoints and forgotten on the tenth. You find those by comparing paths, because a correct example sits next to the broken one.

    In a client side bypass the rule was never written on the server at all. There is no correct path to compare against and no inconsistency to spot. The backend is consistent and completely open. That is why reading it leaves a reviewer feeling fine. Nothing looks wrong, because nothing is there.

    How do you prevent it?

    Enforce entitlement on the server for every request that returns paid data or performs a paid action, and treat the frontend as a rendering layer with zero authority.

    • Never send data the user is not entitled to. If a free session cannot read the body, the body must not be in the response. Drop it at the query layer, not in the component.
    • Check entitlement where the action happens. Not in the route that renders the page, not in a gateway three services ago. In the handler that reads or writes the record.
    • Derive the plan from the server. Look up the account’s tier from your own store using the session identity. A plan field the client sent you is a wish, not a fact.
    • Give every premium route its own check. Default to deny, so a new endpoint is closed until someone writes the rule rather than open until someone remembers.
    • Test every premium route with a free account’s session. Skip the interface. Call each route from your API definition with a free tier token, and treat any 200 carrying paid data as a bug.
    • Assume the bundle is public. Feature flags, route names, and role strings in shipped JavaScript are a map of what to try. Fine, as long as the map leads to closed doors.

    What is the takeaway?

    If you can describe your paid tier only by what the screen shows, you do not have a paid tier, you have a suggestion. The fix is not a better overlay. It is a server that refuses, on every request, to hand out something the account did not buy. Finding this takes someone who understands what an application is for before they can tell it is broken, which is what UnboundCompute is built to do. More on our about page.

    Frequently asked questions

    What is a client side paywall bypass?

    It is an access control failure where the only thing enforcing a paid tier, a feature flag, or a role is code running in the user’s browser. The server returns the full content or the full capability to everyone, and the frontend decides whether to reveal it, so anyone who opens developer tools or calls the endpoint directly gets the paid thing for free.

    How is it different from a normal authorization bug?

    In a normal authorization bug the rule exists on the server and fails on one path, so a correct example sits next to the broken one. Here the server never had a rule at all. The backend is internally consistent and completely open, which is why reviewing the server code often turns up nothing that looks wrong.

    Why do vulnerability scanners miss it?

    Because there is no malicious input and no signature to match. The request is well formed, carries a real session, and targets a documented endpoint, and the server answers it happily with a 200. Finding the bug requires knowing what the application is supposed to charge for, which is a question about intent rather than about payloads.

    How do you prevent a client side paywall bypass?

    Enforce entitlement on the server for every request that returns paid data or performs a paid action. Never send data the user is not entitled to and mask it later, derive the plan from your own store rather than from the request, check entitlement in the same place you do the action, and test every premium route directly with a free account’s session.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • The Single Packet Attack: Making Web Race Conditions Reliable

    The Single Packet Attack: Making Web Race Conditions Reliable

    Most web apps do sensitive work in two steps. First they check a condition, then they act on it. Is there balance left? Then deduct it. Is the coupon unused? Then redeem it. Between those two steps sits a tiny window, and if two requests both pass the check before either one commits the action, the app does the guarded thing twice. The single packet attack is the technique that makes hitting that window reliable, turning a flaky timing bug into one an attacker can trigger on demand. This post walks the mechanism defensively, using an invented gift card app, and shows why the fix is atomic steps, not just a rate limit.

    The window between check and action

    Take an invented store, Acme Gift Cards. A card holds a balance, and redeeming it runs code that looks harmless:

    1. balance = SELECT amount FROM cards WHERE code = 'GC-4821'
    2. if balance < requested: reject
    3. UPDATE cards SET amount = amount - requested WHERE code = 'GC-4821'
    4. issue store credit for `requested`

    Read top to bottom, this is fine. A card with 50 dollars of balance can only be redeemed for 50 dollars. The problem is that steps 1 and 3 are separate. The app checks the balance, and then, a moment later, it subtracts from it. If a second request runs step 1 while the first request is still between its own step 1 and step 3, both read the same balance of 50, both pass the check, and both proceed to redeem. This is a business logic vulnerability: every request is individually valid, and the flaw is the assumption that the check and the action are one indivisible step.

    This class of bug has a name, time of check to time of use, and a cousin called limit overrun, where a per user or per resource cap gets exceeded because many requests count against it at once. Both live in the same gap. The only hard part for an attacker is arrival timing: to land two redeems in the same window, the two requests have to reach that code within a few milliseconds of each other.

    Why timing used to make the single packet attack hard

    Over a network, “send two requests together” does not mean “they arrive together.” Each request is a separate stream of packets, and every packet picks up a slightly different delay: queueing, routing, retransmits, the receiver’s own scheduling. That variation is called jitter. You might fire twenty redeem requests in a loop, but by the time they reach the server they are smeared across tens of milliseconds. The window you are aiming for might be one millisecond wide. So the race fires sometimes and not others, and an attacker cannot tell whether a failed attempt means the bug is absent or just that the timing missed.

    Security researcher James Kettle at PortSwigger published the fix for the attacker’s timing problem in 2023, in research titled “Smashing the state machine,” which won first place in PortSwigger’s Top 10 Web Hacking Techniques of that year. The idea removes network jitter as a variable so the race stops depending on luck.

    The bug was always there. The single packet attack just removes the noise that was hiding it, so a race that fired one time in fifty now fires almost every time.

    How the single packet attack removes the jitter

    The technique uses HTTP/2, which lets many requests share one connection as separate streams. The attacker prepares 20 to 30 redeem requests but does not finish them. For each request, it sends everything except the final byte or two, holding back the last frame that tells the server the request is complete. The server now has 20 to 30 requests parked, each waiting on its last piece.

    Then the attacker sends the withheld final frames of all of those requests inside a single TCP packet. One packet arrives at the server as one unit. The server reads it, sees that every parked request is now complete, and hands them all to be processed at essentially the same instant. There is no per request jitter left, because there is no per request packet. The requests were separated across the network while their bodies were in flight, and they get completed together by one arrival.

    Here is a simplified timeline for the Acme case:

    t0   attacker opens one HTTP/2 connection
    t1   sends redeem requests #1..#20, each missing its final byte
         -> server parks all 20, none can run
    t2   sends ONE TCP packet carrying the final byte of all 20
         -> server completes #1..#20 together
    t3   all 20 run step 1 (check balance = 50) before any runs step 3
         -> all 20 pass the check
    t4   all 20 run step 3 and step 4
         -> card redeemed ~20 times against a 50 dollar balance

    Because the requests enter the check at once, they all read the pre deduction balance. Each one sees enough money, passes, and commits a redeem. A card worth 50 dollars can pay out many times over. Swap the nouns and the same shape covers withdrawing one balance twice, using a single use invite more than once, applying one discount repeatedly, casting more votes than allowed, or slipping past an anti brute force counter. For a fuller tour of the underlying bug, see our writeup on race conditions and limit overrun.

    Why a rate limit does not fix it

    The instinct is to throttle the endpoint. Rate limiting helps against slow, repeated abuse, but it is the wrong tool here. A rate limiter usually reads a counter and then decides, which is its own time of check to time of use gap. Twenty requests that arrive in the same instant can all read the counter at zero and all pass before any of them increments it, so you have added a second race in front of the first. Rate limiting shapes traffic over seconds. The single packet attack operates inside a few milliseconds, underneath that resolution.

    The real fix: make check and action one step

    The durable fix is to close the window so the check and the action cannot be split. The database is the right place to enforce this, because it can make a read and a write atomic.

    • Lock the row you are about to change. Read the card with SELECT ... FOR UPDATE inside a transaction. The first request locks the row, and the others wait for it to commit instead of reading a stale balance. When they finally read, the deduction is already applied.
    • Make the update conditional and atomic. Instead of read then subtract, do it in one statement: UPDATE cards SET amount = amount - :r WHERE code = :c AND amount >= :r. The check lives inside the write. If the balance is too low, the row does not match and zero rows change, so a losing request simply does nothing.
    • Let a unique constraint catch duplicates. For single use tokens, coupons, or invites, put a unique index on the used marker so a second redeem of the same code violates the constraint and fails at the database, not in application logic.
    • Use idempotency keys. Require the client to attach a key to a redeem, and store it. A repeat with the same key returns the first result instead of running the action again.
    • Take a per resource lock. When the work spans several statements, hold one lock keyed to the resource, for example the card code, so only one operation on that card runs at a time.

    The common thread is that each of these removes the gap rather than trying to win the timing race. Rate limiting can still sit on top as defense in depth, but it is not the control that closes the bug.

    Closing

    The single packet attack is not really a new bug. It is a way to make an old one, a check and an action that were never atomic, fire on command by deleting the network noise that used to hide it. That is why these findings are hard to catch by matching known payloads: every request is valid, and the flaw only shows up when you reason about how the steps fit together. UnboundCompute is an autonomous researcher built to do exactly that, reasoning about an app’s logic and proving a race is real before reporting it. You can read more on our about page.

    Frequently asked questions

    What is the single packet attack?

    It is a technique that makes web race conditions reliable. By sending the final pieces of many HTTP/2 requests inside one TCP packet, all of them reach the server at the same instant, removing the network timing jitter that used to make the race fire only sometimes.

    What bugs does it exploit?

    Time of check to time of use and limit overrun flaws, where an app checks a condition then acts and many requests slip into the gap. Examples include redeeming one gift card many times, withdrawing a balance twice, or using a single use coupon repeatedly.

    Does rate limiting stop it?

    Not reliably. A rate limiter usually reads a counter then decides, which is its own check to action gap, and it works over seconds while the attack operates inside a few milliseconds. It can sit on top as defense in depth but does not close the bug.

    How do you fix it?

    Make the check and the action one atomic database operation, using row locking or a conditional update that carries the check inside the write, add unique constraints for single use tokens, and use idempotency keys so a repeat does not run the action twice.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Limit Overrun Race Conditions Explained

    Limit Overrun Race Conditions Explained

    A limit overrun race condition is a business logic bug where an app checks a limit once, acts on that check, and an attacker fires many requests into the tiny window before the state updates. It is a time of check to time of use flaw, TOCTOU for short, and it is how a single use coupon gets redeemed twenty times or a balance gets spent twice. The check passes, the app acts, and by the time the record is written several requests have already slipped through.

    The check then act window

    Most limit enforcement follows the same two steps. First the code reads some state and checks it against a rule: does this coupon still have a use left, does this account have enough balance, has this user stayed under their quota. Then, if the check passes, the code acts: it applies the discount, moves the money, records the usage. The gap between reading the state and writing the new state is the window. It is usually a few milliseconds, but it is real time on the clock, and during it the stored value has not changed yet.

    One request at a time, this is fine. The first request reads, checks, acts, and writes before the next one arrives. The trouble starts when two or more requests land inside that same window. Each one reads the old value, each one sees a limit that has not been spent yet, each one passes the check, and each one acts. The app meant to allow one action and allowed many at once.

    Why single request testing misses a limit overrun race condition

    A tester who sends one redeem request, sees it work, then sends a second and sees it rejected will conclude the limit holds. That is the normal path, and on the normal path the code is correct. The bug only appears under concurrency. You have to send the requests close enough together that they overlap in the window, which means firing them in parallel, not one after another. A scanner that walks endpoints one call at a time, or a person clicking through a flow, will never produce the overlap, so the flaw stays invisible to them.

    This is what makes timing bugs slippery. The input to every request is identical and completely valid. There is no strange payload, no injection string, no malformed field. Twenty honest looking requests, each one exactly what the API expects, together break a rule that any single one of them respects.

    A concrete example: one coupon, twenty redemptions

    Picture a typical SaaS app, call it Acme Notes, running a launch promotion. It has a coupon SAVE50 that each account may redeem once. The redeem endpoint looks like this in plain terms:

    POST /api/coupon/redeem
    
    def redeem(user, code):
        coupon = db.find(code)
        if coupon.times_used >= coupon.max_uses:   # the check
            return "already used"
        apply_discount(user, coupon)
        coupon.times_used = coupon.times_used + 1  # the act
        db.save(coupon)
        return "ok"

    Read one at a time this is correct. The attacker does not read it one at a time. They send twenty copies of the same request at the same instant:

    fire 20 requests together:
        POST /api/coupon/redeem   { "code": "SAVE50" }
        POST /api/coupon/redeem   { "code": "SAVE50" }
        POST /api/coupon/redeem   { "code": "SAVE50" }
        ... 17 more, all at once

    All twenty hit the server before any of them finishes writing. Every request runs db.find(code) and reads times_used as 0. Every request compares 0 against max_uses of 1, so every check passes. Every request applies the discount and then writes times_used = 1. The final stored value is 1, which looks perfectly consistent, but the discount was applied twenty times. The same shape turns a gift card into one that pays out twice, a withdrawal limit into a way to drain an account, and a one per customer quota into an unlimited one.

    Each request was valid on its own. The rule was broken by the space between the check and the act, not by any one message.

    Why it is a logic flaw, not an input flaw

    Input flaws come from data the app should not have trusted: a script tag, an SQL fragment, a path that climbs out of a folder. You defend against those by validating and encoding what comes in. A limit overrun race condition has none of that. The data is clean. The flaw lives in an assumption the code makes about itself, that a check it just ran still holds a moment later when it acts. Under load that assumption is false, and no amount of input filtering touches it. This is why it sits in the family of business logic vulnerabilities: it breaks a rule about how the app is supposed to behave, not a rule about what the app is allowed to receive. We take apart more of these in our attack teardowns.

    Preventing limit overrun

    The fix is always the same idea stated in different ways: make the check and the act happen as one indivisible step, so no other request can squeeze in between them. There are several practical ways to do that.

    • Atomic database operations. Let the database do the check and the update in a single statement instead of reading in the app and writing later. A guarded update like UPDATE coupons SET times_used = times_used + 1 WHERE code = 'SAVE50' AND times_used < max_uses checks and increments at once. Twenty of these run in a line, and only the ones that still satisfy the condition change a row. Look at how many rows each call actually updated to know whether it won.
    • Row locking. Wrap the read and the write in a transaction and lock the row while you work on it, with a pattern like SELECT ... FOR UPDATE. The first request holds the lock, does its check and act, and only then releases it. The others wait their turn and see the updated value, so their check fails honestly.
    • Idempotency keys. Give each intended action a unique key that the client sends, and store it. If two requests carry the same key, the second is recognized as a repeat and returns the first result instead of acting again. This stops accidental double submits and stops an attacker replaying the same intent many times.
    • Single flight per user. Serialize sensitive actions for a given account so only one runs at a time. A short lived lock keyed on the user id, or a queue that processes one request per user in order, removes the overlap that the attack depends on.

    Rate limiting does not fix this. An attacker needs only a handful of requests inside one window, well under most caps, and the requests look like normal traffic. The real fix is to close the gap between the check and the act.

    How to test for it

    Find every place the app checks a limit and then changes state: coupons, balances, quotas, invites, one time actions of any kind. For each one, send a burst of identical valid requests in parallel and count how many succeeded. If more than the limit went through, the window is open. The test is not about crafting a clever payload. It is about the timing assumption the code makes, and about proving that assumption wrong under concurrency.

    This is the kind of timing and logic assumption an autonomous security researcher is built to test, because it is invisible to single request scanners and only shows up when many valid requests overlap. An early, honest signal: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. You can read more about the approach on our about page.

    Frequently asked questions

    What is a limit overrun race condition?

    It is a business logic flaw where an app checks a limit once and then acts, and an attacker fires many requests in the same tiny window so several pass the check before the state updates. It is a time of check to time of use, or TOCTOU, problem.

    What kinds of limits does it break?

    Anything checked then acted on, such as redeeming a single use coupon many times, withdrawing or transferring a balance more times than allowed, using a gift card twice, or exceeding a per user quota.

    Why do single request tests miss it?

    The bug only appears when requests overlap. One request at a time always sees a correct balance, so a scanner that sends requests in sequence never triggers the window where several reads happen before the first write lands.

    How do you prevent a limit overrun race condition?

    Make the check and the update one atomic database operation, use row locking or conditional updates, add idempotency keys, and serialize sensitive actions per user so only one runs at a time.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • 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. When you write up indicators like the endpoints, hosts, and tokens involved, our free IOC extractor and defanger pulls those indicators out of your notes and defangs any live URLs so a report can be shared without anyone clicking something by accident. 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.

    Frequently asked questions

    What is exploit chaining?

    A chain is a sequence of findings where the output of one becomes the input of the next, so three issues that each look harmless on their own can combine into something critical like an account takeover. The key idea is that severity is not a property of one bug, it is a property of the path an attacker can walk end to end. The teardown shows this on an invented app called Acme Notes for teaching, not as a real engagement.

    Why do reviewers underrate bugs that later form a chain?

    Each link is filed in isolation, often by a different reviewer, and rated low because a precondition looks hard to meet. A reset token leak gets called minor because the token seems hard to obtain, but an earlier link that exposes the id list removes exactly that condition. A reviewer looking one step ahead would ask what the next attacker needs and whether the app already provides it.

    How do you defend against chained exploits?

    Treat identifiers as reachable, so once an id appears in any unauthenticated response you plan as if every attacker holds the full list. Check ownership on every object route, since being logged in is a separate question from being allowed to see a specific record. Bind reset tokens to context so they are single use, short lived, and tied to the email or session, and review the broader Broken Access Control guidance.

    How do you verify a chain honestly?

    Do not claim a chain you have not walked. Reproduce each link with the raw request, the raw response, and the accounts you used, confirm the value carried between links is the real value and not a lookalike, then walk the whole path once on accounts you own in a test environment. If any link fails to reproduce, the chain is a theory, not a finding.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

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

    Frequently asked questions

    What is an IDOR vulnerability?

    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, so the reference becomes insecure. It is common because developers think about authentication, who you are, far more than authorization, what you are allowed to touch. See PortSwigger on IDOR.

    How is an IDOR found in practice?

    You map the app and watch the requests, notice a guessable reference such as a sequential note id in a URL, and form the assumption that the server may load the record without confirming ownership. Then you keep your own valid login, change only the id to a neighbouring value, and see whether the server hands back a record that is not yours. The teardown uses an invented app called Acme Notes purely to illustrate this, not a real engagement.

    How do you confirm an IDOR is real and not a fluke?

    Rule out the boring explanations first. Check that the leaked owner_id really differs from your account, that a range of ids you do not own all return full bodies so it is a pattern, and that sending no Authorization header returns 401 while a wrong user’s valid token returns 200, which shows the app checks login but not ownership. Test read only methods so you never damage real records.

    How do you fix an IDOR?

    Do not just hide or scramble the id, since that only slows an attacker down. Bind the lookup to the logged in user, for example fetch a record by id and owner together so a non owner gets a 404 or 403, centralize that ownership check in one place every route calls, and add a regression test that requests another user’s record and fails the build on anything but a refusal. This class of weakness maps to CWE-639.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.