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.