Konvu is a RSAC Launch Pad finalist 🎉Meet the founders in SF →

    What Is Reachability Analysis (And Why It Misses Real Vulnerabilities)

    Hugo Guillaume
    2026-04-06

    How static reachability analysis works, where it structurally breaks down, and what that means for your vulnerability backlog


    SCA tools flag every vulnerable dependency in your tree. On a typical service, that's hundreds of findings. Most of them don't matter, but you can't tell which ones from a flat list.

    Reachability analysis was supposed to solve this. The promise: if your code never calls the vulnerable function, the finding is safe to ignore. Tools mark findings as "reachable" or "unreachable," and teams use that signal to prioritize.

    It helps. But it also fails in ways that are hard to spot. We recently investigated reachability results on a real codebase and found exploitable vulnerabilities hidden behind "unreachable" labels, and non-exploitable findings flagged as "reachable." The analysis was wrong in both directions.

    This post explains how reachability analysis works, then walks through four real CVEs where it produces incorrect results. Three are false negatives (exploitable vulns marked unreachable), one is a false positive (non-exploitable vuln marked reachable). All are reproducible.

    We've written separately about the gap between reachability and full exploitability analysis. This post focuses on reachability itself: the mechanics, the structural limitations, and the proof.


    What is reachability analysis

    Reachability analysis determines whether your application actually uses a vulnerable piece of code in a dependency. If the vulnerable function is never called, the CVE doesn't apply to you.

    The spectrum of approaches

    SCA tools implement reachability at different levels of depth:

    Manifest and lockfile analysis checks which packages are declared as dependencies and which versions are resolved. This tells you whether a vulnerable package is in your tree, but nothing about whether your code uses the vulnerable part of it.

    Static reachability analysis goes further. It builds a call graph from your application's entry points (HTTP handlers, CLI commands, background jobs) into your dependencies. If there's a path from an entry point to the vulnerable function, the finding is "reachable." If not, "unreachable."

    Dynamic or runtime analysis instruments the application at runtime and observes which dependency code actually executes. This is the most precise form, but requires a running application with representative traffic.

    Most modern SCA tools that claim reachability are doing static call graph analysis. That is what we'll examine here, because it's the most common approach and its failure modes are the least understood.

    How static reachability works

    The tool:

    1. Identifies entry points in your application (route handlers, exported functions, event listeners)
    2. Builds a call graph from those entry points through your code and into dependencies
    3. Checks whether any path reaches a function or method associated with a CVE

    If a path exists, the finding is marked reachable. If no path is found, unreachable.

    In simplified form:

    for vulnerable_symbol in advisory_db:
    for entrypoint in app_entrypoints:
    if path_exists(entrypoint, vulnerable_symbol, call_graph):
    mark("reachable")
    break
    else:
    mark("unreachable")

    What reachability gets right

    Credit where it's due. Reachability analysis:

    • Eliminates findings for libraries your code never imports
    • Deprioritizes dependencies only used in test or build tooling
    • Is strictly better than treating every transitive dependency as critical

    If all you had was a flat SCA list sorted by CVSS score, reachability is a meaningful step forward. The problem is what happens when the call graph is wrong.


    When "unreachable" means exploitable: EJS remote code execution

    CVE-2022-29078 is a remote code execution vulnerability in EJS, the popular JavaScript templating engine. An attacker can inject template options through query parameters to execute arbitrary code on the server.

    This is the kind of vulnerability you absolutely cannot afford to miss. Most of the time static reachability analysis misses it.

    The application

    A standard Express app using EJS as its view engine:

    const express = require("express");
    const app = express();
    app.set("view engine", "ejs");
    app.get("/profile", (req, res) => {
    res.render("profile", req.query);
    });
    app.listen(3000);

    The template at views/profile.ejs:

    <h1>Hello, <%= username %></h1>
    <p><%= bio %></p>

    Normal usage: GET /profile?username=Alice&bio=Hello renders a profile page. Nothing unusual.

    What the analyzer sees

    The application source code never imports ejs. It never calls ejs.render() or ejs.renderFile(). It calls res.render(), which is an Express method.

    The reachability analyzer scans the app source, builds a call graph, and looks for direct references to the vulnerable EJS symbols. It finds none. Verdict: unreachable.

    What actually happens

    Express resolves view engines internally. When you call res.render("profile", data), Express:

    1. Looks up the registered view engine for .ejs files
    2. Calls require("ejs") inside node_modules/express/lib/view.js
    3. Invokes ejs.renderFile() with the template path and data object

    The call chain is res.render() -> View.render() -> ejs.renderFile(). It passes through Express's own source code inside node_modules. The analyzer does not trace into node_modules, so the entire chain is invisible.

    The exploit

    Because req.query is passed directly as the data object to ejs.renderFile(), an attacker can inject EJS options through query parameters:

    # Baseline: normal rendering
    curl "http://localhost:3000/profile?username=Alice&bio=Hello"
    # => <h1>Hello, Alice</h1><p>Hello</p>
    # Exploit: inject outputFunctionName to execute arbitrary code
    curl "http://localhost:3000/profile?username=Alice&bio=Hello&settings[view%20options][outputFunctionName]=x;process.mainModule.require(%27child_process%27).execSync(%27id%27);s"

    The second request executes id on the server. You can dump process.env (leaking all environment variables including secrets), read files, or establish a reverse shell. This is full RCE.

    The reachability analyzer said "unreachable." The vulnerability is not only reachable, it's exploitable with a single HTTP request.

    Why static analysis misses it

    The analyzer only looks for direct imports of ejs in the app source. Express resolves view engines internally through require() calls in its own code. Static call graph analysis does not trace into framework internals inside node_modules. The boundary between your code and your framework's code is invisible to it.


    The pattern repeats

    The EJS case is not an edge case. The same structural limitation produces false negatives across different frameworks, different vulnerability types, and different severity levels.

    body-parser denial of service (CVE-2024-45590)

    body-parser 1.20.2 passes depth: Infinity to qs.parse(), allowing an attacker to send deeply nested payloads that block the Node.js event loop.

    The application:

    const express = require("express");
    const app = express();
    app.use(express.urlencoded({ extended: true }));
    app.post("/submit", (req, res) => {
    res.json({ received: true });
    });
    app.listen(3000);

    The app never imports body-parser. It calls express.urlencoded(), which is a thin re-export of body-parser.urlencoded() inside node_modules/express/lib/express.js. The vulnerable body-parser code runs on every request.

    The reachability analyzer finds no direct reference to body-parser in the app source. Verdict: unreachable.

    A ~1MB payload with 341,000 levels of nesting blocks the event loop for ~8 seconds (1,300x slower than normal). During that time, the server cannot process any other requests. Confirmed DoS.

    This came from a real customer engagement. In production, the app was a NestJS service where the indirection is even deeper: @nestjs/platform-express calls body-parser internally via ExpressAdapter.registerParserMiddleware(). App code only calls NestFactory.create(). The gap between what the analyzer sees and what actually executes is wider than it looks.

    follow-redirects credential leak (CVE-2022-0155)

    follow-redirects 1.14.0 leaks cookies when following redirects across different ports on the same hostname. It compares only hostname (not host + port), so a redirect from localhost:4000 to localhost:4001 is treated as "same host" and the Cookie header is forwarded.

    The application:

    const axios = require("axios");
    async function fetchData() {
    const response = await axios.get("http://localhost:4000/api/data", {
    headers: { Cookie: "session=abc123secret" },
    });
    return response.data;
    }

    The app never imports follow-redirects. It calls axios.get(). Internally, axios delegates all HTTP redirect handling to follow-redirects. The transitive dependency is invisible in the app source.

    The reachability analyzer finds no direct reference to follow-redirects. Verdict: unreachable.

    At runtime, if the server at localhost:4000 responds with a 302 redirect to localhost:4001, the Cookie: session=abc123secret header is sent to the second server. Confirmed credential leak.

    The common thread

    All three false negatives share the same root cause. The application calls a framework or library method (res.render(), express.urlencoded(), axios.get()) which internally delegates to a vulnerable dependency. The delegation happens inside node_modules, where the analyzer does not trace. The call graph has a gap at the boundary between app code and framework code.

    This is not a bug in any specific tool. It is a structural limitation of static call graph analysis applied to ecosystems with deep dependency chains and framework-internal delegation.


    The other direction: reachable but not exploitable

    Reachability analysis also fails by over-reporting. When the analyzer finds a path to a vulnerable function, it marks the finding as reachable. But "the function is called" and "an attacker can trigger the vulnerability" are different questions.

    axios DoS via config merging (CVE-2026-25639)

    CVE-2026-25639 is a denial of service in axios <= 1.13.4. The mergeConfig function iterates object keys without filtering __proto__, so a config object created via JSON.parse() with a __proto__ property crashes the application.

    Exploitation requires attacker-controlled JSON reaching the config parameter of an axios request method.

    const axios = require("axios");
    async function notifyService(event) {
    await axios.post("https://internal-api.example.com/events", event, {
    headers: { "X-Service": "payments", "Content-Type": "application/json" },
    timeout: 5000,
    });
    }

    The analyzer sees axios.post() and marks CVE-2026-25639 as reachable. Technically correct: the vulnerable function is on the call path.

    But the config object (third argument) is entirely hardcoded. No user input ever reaches mergeConfig. The vulnerability requires attacker-controlled JSON in the config, and there is none. This finding is noise.

    Reachability checks whether the function is called. It does not check whether attacker-controlled data reaches the vulnerable parameter. That requires real dataflow analysis, which most static reachability tool does not perform.

    For a deeper look at how false positives from reachability waste engineering time, and how full exploitability analysis addresses them, see our detailed comparison of reachability vs. exploitability.


    Why static reachability analysis breaks down

    The four examples above demonstrate three structural limitations, not implementation bugs in any specific tool.

    It cannot trace framework internals and re-exports

    express.urlencoded() wraps body-parser. res.render() delegates to ejs through Express's view engine resolution. These calls happen inside node_modules. Static analysis tools typically analyze your application source code and treat node_modules as a boundary. Everything on the other side is a black box.

    In the JavaScript and TypeScript ecosystem, where frameworks routinely re-export, wrap, and delegate to their dependencies, this boundary hides a large surface area of actually-executed code.

    It cannot trace transitive dependency chains

    axios uses follow-redirects internally for HTTP redirect handling. Your app depends on axios. follow-redirects is a transitive dependency you may not even know about. The analyzer sees your direct dependency but not what it delegates to at runtime.

    It has very limited dataflow or taint analysis

    Knowing that a function is called is different from knowing that attacker-controlled data reaches it in the form an exploit requires. The axios false positive shows this clearly: the function is called, but the config is hardcoded. Reachability answers "is this code executed?" It does not answer "can an attacker supply the input this vulnerability needs?"


    What fills the gap

    Accurate vulnerability prioritization requires more than a call graph. It requires understanding:

    • Framework behavior. How Express resolves view engines, how NestJS registers middleware, how axios delegates to transitive dependencies. These are documented, deterministic behaviors that an analysis system can model.
    • Dataflow. Whether attacker-controlled input reaches the vulnerable sink in the form the exploit requires. Not "is the function called," but "does untrusted data flow into it."
    • Configuration and environment. Whether the features and settings required for exploitation are actually enabled in this deployment.

    This is what we built Konvu to do. For the EJS case, Konvu's analysis would evaluate the full picture:

    {
    "cve": "CVE-2022-29078",
    "service": "profile-service",
    "reachable": true,
    "exploitable": true,
    "status": "exploitable",
    "conditions_evaluated": [
    {
    "name": "ejs_used_as_view_engine",
    "result": true,
    "evidence": "app.set('view engine', 'ejs') in src/case3-ejs-rce.js:4"
    },
    {
    "name": "user_input_passed_to_render",
    "result": true,
    "evidence": "res.render('profile', req.query) — req.query is attacker-controlled"
    },
    {
    "name": "vulnerable_ejs_version",
    "result": true,
    "evidence": "ejs@3.1.6 in package-lock.json, affected range <= 3.1.6"
    },
    {
    "name": "no_input_sanitization",
    "result": true,
    "evidence": "No middleware filtering settings[] or view options from query params"
    }
    ]
    }

    Reachability said "unreachable." The vulnerability is exploitable with a single GET request. The difference between those two verdicts is the difference between a hidden RCE and a finding that gets triaged, escalated, and fixed.

    If you're relying on reachability to prioritize your SCA findings, we'd like to show you what your tools are missing.