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.