Taint Analysis Explained

Taint Analysis Explained

Most serious injection bugs come down to one story: data that a user controls travels through your code and reaches a dangerous function without ever being cleaned. Taint analysis is the technique that follows that story automatically. It marks untrusted input as tainted, tracks the taint as the value moves through the program, and raises an alert if it lands somewhere sensitive without passing an accepted sanitizer.

What taint analysis actually does

Think of taint as a stain. The moment a value comes from outside your trust boundary, you color it. Then you watch where that color spreads. If you copy the value into a new variable, the new variable is stained too. If you glue it onto a string, the whole string is stained. If you pass it into a function, whatever the function returns from it stays stained. The stain only comes off when the value goes through a function you have declared safe for the place it is heading.

The check at the end is simple to state. Did a stained value reach a dangerous operation? If yes, and no approved cleaning step happened on the way, that is a finding.

The three ingredients: sources, sinks, sanitizers

Every taint engine is built from three lists. Get these right and the analysis works. Get them wrong and it either screams about nothing or stays silent on real bugs.

  • Sources are where untrusted data enters. A request parameter, a request body, an HTTP header, a cookie, a file you read, a message off a queue. Anything an attacker can influence.
  • Sinks are the dangerous operations where tainted data causes harm. A database query, a shell command, an HTML response, a file path, a call that builds more code to run.
  • Sanitizers are the steps that make a value safe for a specific sink. A parameterized query binder for SQL. HTML escaping for a web page. An allowlist check for a file path. A sanitizer is only valid for the sink it matches, which is a point people miss.

That last point deserves weight. HTML escaping cleans a value for a web page and does nothing for a SQL query. A sanitizer is not a magic cleanser. It is safe for one destination and useless for another. The engine has to know which sanitizer counts for which sink, or it will wave a value through as clean when it is still loaded for a different target.

A concrete SQL injection example, from parameter to query

Here is the shape of bug taint analysis was built to catch. A search endpoint takes a name from the query string and builds a SQL statement out of it.

name = request.args.get("name")        # source: tainted
greeting = "Hello " + name             # taint propagates through concatenation
query = "SELECT * FROM users WHERE name = '" + greeting + "'"
db.execute(query)                      # sink: tainted value reaches SQL

Walk the stain. On line one, name comes from the request, so it is tainted. On line two, we concatenate it into greeting, so greeting is tainted too. On line three, that value goes into the query string, which is now tainted. On line four, the tainted string reaches db.execute, a SQL sink. No parameter binding happened anywhere on that path. The engine reports SQL injection.

Now the fixed version, where the same trace comes back clean.

name = request.args.get("name")        # source: tainted
query = "SELECT * FROM users WHERE name = ?"
db.execute(query, [name])              # name is bound as a parameter, not spliced in

The value is still tainted, but it never enters the SQL text. It rides in as a bound parameter, which the database treats as data and never as code. The parameter binder is the accepted sanitizer for a SQL sink, so the taint reaches the sink in a safe form and no alert fires.

Propagation rules, briefly

The interesting work is in the middle, between source and sink. Propagation rules say how taint moves. A few common ones:

  • Assignment. b = a makes b tainted if a is.
  • Concatenation and formatting. Join a tainted value with anything and the result is tainted.
  • Function return. Pass tainted data into a function and the value it returns is usually treated as tainted, unless the function is a known sanitizer.
  • Collections. Put a tainted value in a list or map and reading it back gives you tainted data.

Real engines get pickier than this, and the picky parts are where cross function tracking lives. For how taint follows a value across function boundaries and call context, see interprocedural taint analysis. For a shared vocabulary of what counts as a source and what counts as a sink, see sources and sinks explained.

Taint analysis does not ask whether code looks suspicious. It asks one question over and over: did untrusted data reach a dangerous place without being cleaned for that place.

Static taint analysis vs dynamic taint analysis

There are two ways to run the trace, and they trade off against each other.

Static taint analysis: read the code, do not run it

Static taint analysis reads the source and models data flow without executing anything. It can see every branch, including the error path that fires once a year and the admin route no test ever hits. That coverage is its strength. Its weakness is that it has to guess about things it cannot run: values that come from config, dynamic dispatch, a call into a library it does not parse. So it can flag a path that a real check would have made safe, which is a false positive.

Dynamic taint analysis: track it at run time

Dynamic taint analysis tags real values while the program runs and watches the tags flow through actual execution. Because it sees what really happened, its findings are precise and its false positive rate is lower. The catch is coverage. It only tracks code that actually runs during the test. A route no request touches is a route it never checks, so a bug on an unexercised path stays invisible.

The tradeoff in one line: static sees every path but guesses about reality, dynamic sees reality but only the paths you exercise. Neither dominates. Serious setups often use both.

Where taint analysis reaches its limit

Taint analysis is strong on injection because injection is exactly a source to sink flow. It is weak where the bug is not about tainted data at all. Consider GET /api/invoices/42 from a user who only owns invoice 41. The invoice id is technically tainted, but it never reaches a SQL string unsafely and no sanitizer is missing. The query is parameterized and clean. Taint analysis sees nothing wrong, because the fault is that the app never checked ownership. That is broken access control, and no source to sink trace describes it.

Knowing that boundary is the point. Taint analysis is a sharp tool for one large class of bugs and blind to another. For more on where pattern tracing ends and reasoning about an app begins, read scanners vs research, and for the graph structure these traces run over, see what is a code property graph.

UnboundCompute runs taint analysis as one input among many, then tests the assumptions an app makes on top of it, which is where the access control style bugs that taint alone cannot see tend to hide. Read more on our about page.

Frequently asked questions

What is taint analysis in simple terms?

Taint analysis marks data from an untrusted source as tainted, then follows that value as it is copied, joined, and passed through functions. If a tainted value reaches a dangerous operation like a database query or a shell command without passing an accepted sanitizer for that operation, the analysis raises an alert. It is the main technique behind finding injection bugs automatically.

What are sources, sinks, and sanitizers?

Sources are where untrusted data enters, such as a request parameter, header, or cookie. Sinks are dangerous operations where that data can cause harm, such as a SQL query or an HTML response. Sanitizers are steps that make a value safe for a specific sink, like a parameter binder for SQL or HTML escaping for a web page. A sanitizer is only valid for the sink it matches.

What is the difference between static and dynamic taint analysis?

Static taint analysis reads the source code without running it, so it can trace every branch, but it has to guess about values that come from config or code it cannot parse, which can cause false positives. Dynamic taint analysis tags real values while the program runs, so its findings are precise, but it only sees code paths that actually execute during testing. Static covers more paths, dynamic reflects real behavior.

Can taint analysis find broken access control?

Usually no. A request like GET /api/invoices/42 from a user who only owns invoice 41 carries a tainted id, but that id never reaches a sink unsafely and no sanitizer is missing. The query is clean, so taint analysis sees nothing wrong. The fault is that the app never checked ownership, which is a logic bug that no source to sink trace describes.


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.