Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
PlatformAugust 4, 202617 min read

Static Reachability Analysis: How Call Graphs Actually Get Built

How static reachability analysis builds call graphs, resolves symbols, and proves an execution path from entry point to vulnerable function, or fails to.

O
O3 Security Team
Research & Engineering
TRACE illustration
Key takeaways
  • Static reachability analysis proves or disproves an execution path from an entry point to a specific vulnerable function, not just whether a vulnerable package is present or imported.
  • "Imported" and "reachable" are different claims. A package can be imported at startup, sitting in memory, and still have zero call path from any entry point, exactly the inverse of what most people assume dead code looks like.
  • Call graph construction has a hard tradeoff between soundness (catching every real call) and completeness (never reporting a fake one). No static tool gets both for free in a dynamic language.
  • Reachability is measured per function, not per package. A dependency can have five reachable call paths into it and still be safe, if none of those paths touch the one function that carries the CVE.
  • Static reachability analysis and SCA answer different questions than SAST. Reachability tells you if a known-CVE function is reachable. SAST finds bugs in your own code that were never assigned a CVE at all, and reachability tooling will walk right past them.

Static reachability analysis is a technique that reads source code, without running it, to prove whether an actual call path exists from an entry point to a specific vulnerable function. That's the definition. The interesting part is how often the obvious shortcut, is this package imported, gets the answer wrong in both directions.

Take urllib3 1.26.4 in a small Python app: imported at startup, wired into a connection pool, never flagged as a live risk by a scanner that only checks whether a package is imported. It looked dead. An is this even used heuristic would have deprioritized it in five seconds. Full reachability analysis found something different: a real, walkable execution path from the app's entry point straight into the vulnerable function. Same package. Opposite verdict. It depends entirely on which question you ask.

That gap, between imported and reachable, is what the rest of this piece unpacks: how static reachability analysis actually builds a call graph, where the construction breaks, and what a real trace looks like end to end.

Note

This is Part 2 of a two-part series. Part 1 covers the broader question of static vs. runtime reachability and why "vulnerable" and "exploitable" aren't the same word. This piece goes one level deeper into how static reachability is actually computed.

The three things a static reachability engine has to do

Strip away the tooling and static reachability analysis reduces to three mechanical steps, done in order, every time it runs against a codebase. Get any one of them wrong and the final verdict is wrong, no matter how good the other two are.

  1. Find the entry points. Every place attacker-influenced or externally-triggered execution can begin: HTTP route handlers, CLI argument parsers, message queue consumers, scheduled jobs, deserialization hooks. Miss an entry point and every path that starts there is invisible to the analysis, full stop.
  2. Build the call graph. Starting from each entry point, resolve every function call, every method dispatch, every import, into a graph of who-calls-whom, deep enough to reach into third-party dependencies and their own transitive dependencies.
  3. Search the graph for a path to the target. Given a vulnerable function (say, a specific function in a specific version of a specific package), search the call graph for any path from any entry point to that function. If a path exists, the finding is reachable. If no path exists after an exhaustive search, it's not, at least not through any code the analysis can see.

Step 1 and step 3 are comparatively mechanical. Step 2, building the call graph, is where almost all the real difficulty lives, and it's worth spending the rest of this section on why.

How static reachability analysis builds a call graph

In a language where every call site unambiguously names the function it invokes, building a call graph is close to bookkeeping. Read the source, record every call, connect the dots. Most real-world languages don't work that way. Python has duck typing: the same method name can resolve to entirely different functions depending on what object shows up at runtime. JavaScript has prototype chains and functions passed around as values. Even statically typed languages have interfaces, virtual dispatch, and reflection, cases where the concrete function a call site invokes just isn't decidable from syntax alone.

This is the resolution problem. It's the reason two static analyzers can read the identical codebase and produce two different call graphs. The engine has to decide, for every ambiguous call site, which real-world function it's actually pointing at. Get that decision wrong, and the whole downstream path search inherits the mistake.

A concrete failure mode: same name, wrong package

Consider a call site like `img.convert(...)`, a color-mode conversion on an image object. Without full type or object resolution, a static engine has to guess what `img` actually is and which package's `convert()` it's calling. If the analysis infers from the method name alone, instead of tracing the object's real type back to its constructor and import, it can match the call site to a same-named function in the wrong package entirely. Two unrelated libraries can both ship a function called `convert()`. If one of them holds a real CVE, that's the exact setup where a name-based resolver produces a confident, specific, and completely wrong finding: a vulnerable-looking call path into a package the code never actually touches at that line.

This isn't a hypothetical edge case invented for this article. It's a known, general failure mode for any static analyzer that under-invests in type inference, and it shows up in the DropVault worked example later in this piece. The fix is expensive: real object and type resolution, tracing a variable back through its assignment to the constructor that actually created it, not just pattern-matching on method names.

Soundness vs. completeness: pick your failure mode

Every call graph construction algorithm makes a tradeoff between two properties, and no algorithm gets both for free in a dynamic language.

  • Soundness means the graph contains every call edge that could really happen. A sound-but-imprecise analyzer errs toward including more edges when it's unsure, which produces false positives: reachability findings on paths that don't actually exist.
  • Completeness (often called precision in this literature) means the graph contains no edges that couldn't really happen. A precise-but-unsound analyzer errs toward excluding edges when it's unsure, which produces false negatives: real, exploitable paths the analysis never surfaces at all.

Research on this tradeoff is not new, and not close. A 2024 study on call graph soundness in Android static analysis benchmarked thirteen widely used static analysis tools. On average, they failed to capture 61% of the methods that were actually executed at runtime. That's not a rounding error. It's a majority miss rate. The paper's authors trace it back to exactly this dynamic: tools tuned to avoid false-positive edges pay for that precision with a large number of missed real ones. Specialized mitigations for hard cases like reflection didn't close the gap by much either. This isn't a bug waiting to be patched. It's structural.

A high level of precision in call graph construction is, empirically, a synonym for a high level of unsoundness. You don't get to avoid a failure mode. You pick which one you'd rather have.

For reachability analysis specifically, this tradeoff has a direct product consequence. Lean toward soundness and you flag more things as reachable, some of which aren't. That's back to alert fatigue. Lean toward completeness and precision, and you'll cleanly deprioritize things that are genuinely exploitable. That's the worse failure mode, because it's silent. A team acts on what the tool shows them. It has no way to act on the finding the tool decided not to show.

Path search: shortest path vs. all paths

Once the call graph exists, the search step itself has a design choice buried in it. A shortest-path search answers "is there at least one way in" as cheaply as possible, which is fast and fine for a binary reachable/not-reachable verdict. An all-paths search enumerates every distinct route from every entry point to the target function, which costs more to compute but tells you something a shortest-path search can't: how many independent ways an attacker has to get there, and whether any of those paths pass through an authentication or authorization check that would gate the exploit. Two findings can both be "reachable" and differ enormously in urgency depending on whether the only path runs through an admin-gated route or whether five different unauthenticated entry points all lead to the same vulnerable line.

Static reachability analysis in practice: five packages, five verdicts

Abstract mechanics only go so far. DropVault is a small, deliberately vulnerable FastAPI application (FastAPI plus Jinja2 templates, PyJWT for session auth, SQLite via SQLAlchemy, local file storage) built specifically to pin known-vulnerable dependencies at different reachability tiers so the ground truth is known in advance. It's a useful worked example precisely because the answer is already known: five real Python packages, five deliberately different verdicts, and the reasoning is checkable against the source instead of taken on faith.

Tier 1: reachable and runtime-confirmed, on every request

PyJWT 2.3.0 handles session authentication. Every authenticated route runs through `get_current_user()`, which calls `jwt.decode()` directly. That's about as hot a path as a call graph produces: no conditional branch, no feature flag, no admin gate. Every logged-in request touches it.

trace: PyJWT reachability
any authenticated route (entry point)
  → get_current_user()        app/auth.py
  → jwt.decode()               PyJWT 2.3.0  [TARGET]

PyJWT 2.3.0 is affected by CVE-2022-29217, an algorithm confusion bug: when a verifier is configured to accept both asymmetric and HMAC algorithms without pinning one explicitly, an attacker can submit a token signed with HMAC using the server's own public key as the secret, and the library will validate it. NVD rates it CVSS 7.5, CWE-327 (use of a broken or risky cryptographic algorithm). Against DropVault's call graph, findings tied to that specific vulnerable path came back confirmed reachable.

The instructive part is what didn't come back confirmed. PyJWT ships other functions with their own CVE history, and at least one of those findings was explicitly flagged not reachable, because the application's code calls other functions in the library but never the specific one carrying that particular vulnerability. Same package, same version, two different functions, two different verdicts. That's the whole argument for doing reachability at function granularity instead of package granularity in one example.

Tier 2: reachable, but through one narrow, gated door

Pillow 8.3.0 handles thumbnail generation. The only call path into it runs through `POST /admin/regenerate-thumbnails`, a route gated behind admin privileges.

trace: Pillow reachability
POST /admin/regenerate-thumbnails (entry point, admin-gated)
  → generate_thumbnail()       app/services/thumbnails.py
  → PIL.Image.open() / processing  Pillow 8.3.0  [TARGET]

This tier carries a nuance worth sitting with. At the package level, the finding shows as reachable: there is a real call path from an entry point into Pillow. But the function-level check for the exact CVE'd function in that package showed zero reachable call paths for that specific function, a "not reachable" verdict once you narrow from the package to the individual vulnerable function. Both statements are true at once, and they're answering different questions. "Pillow is reachable" and "the specific vulnerable function in Pillow is reachable" are two separate claims, and conflating them is exactly how a package-level scan overstates urgency on a finding that, at the function level, doesn't hold up. Even setting that nuance aside, the admin gate alone puts this well below tier 1 in practical urgency: an attacker needs admin credentials before this path means anything.

Tier 3: the symbol resolution failure, live

This is the tier that makes the soundness discussion concrete instead of theoretical. DropVault's own reachability scan flagged wheel 0.37.1 as an open SCA finding it labels Wheel CLI DoS: a regular-expression denial-of-service in wheel's filename-parsing regex, fixed in 0.38.1 (GHSA-qwmp-2cf2-g9g6, CVE-2022-40898). The scan reported five reachable functions tied to that finding, with three carrying a full call trace, and one of the confirmed trace targets was `convert()`. DropVault's scanner rates this finding Low; NVD and the GitHub advisory both score the underlying CVE 7.5 High, a gap worth noticing on its own before trusting any single tool's severity label at face value.

Note

This is a real finding from DropVault's own reachability scan, not a hypothetical. 27 of 27 entry points verified, 49 functions analyzed, 5 call sites reaching the vulnerable library.

trace: confirmed finding, DropVault reachability scan
repo                            entry point in your application
  → regenerate_thumbnails()     admin_routes.py
  → generate_thumbnail()        thumbnails.py
  → wheel                       vulnerable package
  → convert()  [TARGET]         vulnerable function called

Here's what's confirmed and what isn't. Confirmed: `generate_thumbnail()` in thumbnails.py calls an object method named `convert()`, for Pillow color-mode conversion, the same object-method pattern described in the call graph section above. Also confirmed: DropVault's scanner traced a real call path from that call site down into wheel, and flagged `convert()` specifically as the reachable, vulnerable target. wheel does ship its own unrelated function literally named `convert()`, in `wheel/cli/convert.py`, which dispatches into a real, traceable call chain (`egg2wheel()` or `wininst2wheel()`, both instantiating `WheelFile`, whose constructor runs the exact filename regex GHSA-qwmp-2cf2-g9g6 patched). What isn't directly observable is the scanner's own internal resolution logic, since that's closed inside the tool. But the most plausible mechanism, given that two entirely unrelated packages ship a function sharing the exact same name, is a name-based match: the scanner sees a call to `convert`, finds a vulnerable `convert` inside wheel, and connects them without verifying that the object being called is actually a Pillow `Image` rather than anything to do with wheel's installer-conversion CLI. That's inference about mechanism, not a confirmed root cause. The reachable finding itself is confirmed. Guarding against this class of misattribution requires tracing the calling object back to its constructor, not pattern-matching on the tail end of a dotted call.

Tier 4: reachable despite looking dormant

urllib3 1.26.4 is imported at application startup in `app/services/fetch_url.py`, which is itself pulled in by `app/main.py`. `download_from_url()` in that file has no HTTP route wired to it under the app's normal design; a `is this route registered` heuristic would call it dead code and move on. But it's still a real Python function, callable from anywhere in the process, and a full reachability analysis doesn't stop at "is there a route," it searches the whole call graph for any path, including one that doesn't originate from an HTTP handler.

trace: urllib3 reachability
app entry point
  → download_from_url()         app/services/fetch_url.py
  → urllib3.request()
  → connection_from_url()       urllib3 1.26.4  [TARGET]

That trace is the entire argument against using "is it imported and does something call it eventually" as a proxy for "is it dead." Import-based dead code detection asks whether a module is loaded. Reachability analysis asks whether the graph contains a path, and those are different questions with different answers here. A package can be sitting quietly, unreferenced by any user-facing route, and still be one real call path away from an attacker-reachable vulnerability if anything in the codebase, including code paths intended for internal or future use, can trigger it.

Tier 5: not reachable, and there's nothing to argue about

requests 2.25.1 is declared in requirements.txt and installed into the environment. It is never imported anywhere in DropVault's source. No call graph edge touches it because there's no call site to begin the trace from. This is the easy case, the one static reachability analysis was always going to get right: no import, no call graph node, no path, correctly and cheaply excluded from the priority list.

PackageReachability tierEntry point to targetGating factor
PyJWT 2.3.0Reachable, runtime-confirmedEvery authenticated route → get_current_user() → jwt.decode()None. Hit on every request.
Pillow 8.3.0Reachable at package level, not at function levelPOST /admin/regenerate-thumbnails → generate_thumbnail() → Image processingAdmin privilege required; vulnerable function itself not reached
wheel 0.37.1 (Wheel CLI DoS, CVE-2022-40898)Reachable, confirmed findinggenerate_thumbnail() → wheel → convert() [TARGET]Fix in 0.38.1; verify which convert() before triaging, wheel and Pillow both ship one
urllib3 1.26.4Reachable, looked dormantApp entry point → download_from_url() → urllib3.request() → connection_from_url()No registered route, but a real call graph path exists
requests 2.25.1Not reachableNo call site anywhere in sourceNever imported
DropVault: five packages, five reachability verdicts
Key takeaway

Read top to bottom, these five tiers are the prioritization ladder: fix now, fix on a normal cadence, distrust and re-check, investigate before dismissing, and safely ignore. The severity score never changes the ordering. The call graph does.

Known blind spots, stated plainly

Static reachability analysis is not a complete oracle, and any framing that implies otherwise is selling something. The honest list of what it structurally can't see:

  • Dynamic dispatch and duck typing. When the function a call site invokes depends on the runtime type of an object rather than anything visible in the source, static analysis has to guess, and guesses are where both false positives and false negatives come from. Tier 3 above shows the ambiguity this creates in practice: two unrelated packages shipping a function with the same name.
  • Reflection, eval, and string-built imports. `getattr(obj, name)()`, `importlib.import_module(computed_string)`, `eval(user_input)`, anything where the callee is assembled at runtime from a string rather than named directly in source, is invisible to a call graph built from static text. The edge simply doesn't exist in the graph because there's no syntactic call site to find.
  • Symbol resolution without type information. Covered above in depth: name-based matching without tracing an object back to its real type produces confidently wrong call graph edges, not missing ones. That's arguably worse, because a wrong finding gets acted on while a missing one at least fails silently.
  • No taint or data-flow awareness. Reachability answers "can code get from A to B," not "can attacker-controlled data get from A to B carrying a malicious payload." A path can be technically reachable while every real caller only ever passes trusted, hardcoded values into it, which is a data-flow question reachability analysis, on its own, doesn't answer.

None of these are reasons to distrust reachability analysis wholesale. They're reasons to know what question it's actually answering, and to pair it with the tooling that answers the questions it structurally can't.

Where reachability stops and SAST starts

DropVault's actual designed vulnerability, the one it was built around, makes this boundary concrete better than any abstract explanation. `POST /import-bundle` takes a user-uploaded archive and passes it straight into `wheel.cli.unpack.unpack()` with no sanitization of the archive's member names, a textbook zip-slip path traversal: a crafted archive entry named something like `../../../etc/cron.d/evil` writes outside the intended extraction directory the moment the archive is unpacked.

trace: the actual designed vulnerability
POST /import-bundle (entry point, any authenticated user)
  → import_bundle()             app/services/import_bundle.py
  → wheel.cli.unpack.unpack()   no member-name sanitization  [SINK]

That vulnerability has no distinct CVE-linked finding in a reachability scan, and it never will, because it isn't a known vulnerability in a dependency. It's a logic bug in DropVault's own code: calling a legitimate library function in an unsafe way. SCA and reachability tooling are built to answer "is a known-CVE function reachable from my code." This bug doesn't involve a CVE at all. It involves application code making an unsafe call, which is exactly the class of problem SAST exists to catch: pattern-matching and data-flow analysis over your own source, independent of whether any dependency has a published vulnerability.

Tool classQuestion it answersWould it catch DropVault's zip-slip bug?
SCADoes a known-CVE package version exist in my dependency tree?No. There's no CVE for this bug; it's not a dependency vulnerability.
Static reachabilityIs there a call path from an entry point to a specific known-CVE function?No. Same reason: nothing to trace a path to without a CVE'd target function.
SASTDoes my own code contain an unsafe pattern, like unsanitized archive extraction?Yes. This is precisely the class of bug SAST is designed to flag.
Where each tool answers a different question

That table is the whole point of drawing the boundary explicitly. Reachability analysis makes SCA findings actionable by adding a call-path requirement on top of "CVE exists in dependency tree." It does not, and structurally cannot, replace the need for SAST on your own application logic, because reachability's target is always a known vulnerability, and this bug was never assigned one.

The operational payoff: turning five tiers into a priority queue

Put the DropVault tiers in a security team's queue and the ordering falls out on its own, without needing a CVSS re-read for any of them.

  1. Fix now. PyJWT sits on every authenticated request with a confirmed reachable path to the vulnerable function. No gate, no admin requirement, no ambiguity. This is the one that gets a same-week patch.
  2. Fix on a normal cadence. Pillow is reachable at the package level but the specific vulnerable function isn't, and the only door in is admin-gated. Patch it in the next dependency bump, not tonight.
  3. Confirm which convert() before you patch. The wheel finding is real, reachable, and traceable to a real ReDoS in wheel's filename regex. But wheel and Pillow both ship a function named convert(), so a human still needs to confirm the call site is actually invoking wheel's convert() before assuming the fix is a simple wheel upgrade to 0.38.1.
  4. Don't deprioritize on vibes. urllib3 looked dead by an import-only heuristic and had a real, walkable path the whole time. This is the tier that punishes teams for trusting "is it imported" as a stand-in for "is it reachable."
  5. Correctly deprioritize. requests is declared, installed, and never imported. Zero call graph path exists. This is the one case where ignoring the finding is the right call, and reachability analysis can say so with confidence instead of a guess.

Five packages, five different actions, and the sorting logic came entirely from call graph structure, not from re-reading CVSS scores harder. That's what static reachability analysis is actually for: not eliminating dependency risk, but making sure the limited hours a security team has go toward the path that's real. Run static reachability analysis before a CVE list ever reaches triage, and the list a human has to read gets short enough to actually finish.

Frequently asked questions

What is static reachability analysis?

+
It's a technique that determines, by reading source code without executing it, whether an actual call path exists from an application's entry points to a specific vulnerable function. It builds a call graph and searches it for a path, producing a reachable or not-reachable verdict per function, not per package.

How is static reachability analysis different from checking if a package is imported?

+
Static reachability analysis requires a traceable call path from an entry point through the call graph into the specific vulnerable function, not just proof that a module is loaded into memory. A package can be imported and never reached, or look unused by import heuristics while still having a real call path, as DropVault's urllib3 tier shows.

Why does call graph construction fail sometimes?

+
Dynamic language features like duck typing, reflection, and string-built imports mean the function a call site invokes often can't be determined from syntax alone. Analyzers have to choose between soundness, catching every real edge, and completeness, avoiding false edges, and research shows tools rarely get both at once.

Can static reachability analysis produce false positives?

+
Yes. A common cause is symbol resolution without full type inference: an analyzer matches a call site to a same-named function in the wrong package because it can't trace the calling object back to its real type. Two unrelated libraries shipping a function with the same name is enough to trigger this.

Does reachability analysis replace SAST?

+
No. Reachability and SCA both target known, CVE'd vulnerabilities in dependencies. A custom logic bug in your own code, like an unsanitized archive extraction, has no CVE to trace a path to, so reachability tooling has nothing to flag. That class of bug is what SAST is built to catch.

Is reachable at the package level the same as reachable at the function level?

+
No, and static reachability analysis is precisely the technique that catches this gap. A call path into a package doesn't mean the path reaches the specific function carrying the CVE. DropVault's Pillow finding showed exactly this split: reachable at the package level, zero reachable paths for the actual vulnerable function.

See your full attack chain.
Code, build, runtime. One platform.