Taint tracking is a way of following untrusted data through a program: data that enters from an untrusted source is marked "tainted," the mark travels with it as the program copies, joins and transforms it, and the program checks whether tainted data has reached a dangerous operation, called a sink. It is how you answer the question static search cannot: not "does this code call execute," but "does attacker-controlled data actually reach that execute." This article defines the four moving parts (sources, propagation, sinks, sanitizers), explains why observing it at runtime gives stronger evidence than matching a pattern, and is honest about what taint tracking cannot see.
The four parts
Sources: where untrusted data enters
A source is a place where data from outside the program arrives, where someone else could have chosen what it says. For a Python backend the usual sources are a network socket, a file, standard input, environment variables and command-line arguments, and code loaded from a location another user can write to. One source is easy to forget: data read back out of your own database. Your application stored it, but a previous request may have planted it, which is how stored cross-site scripting and second-order SQL injection work. Treating database reads as a source is what lets a tracker follow those delayed flows.
Propagation: the mark travels with the data
Propagation is the heart of it. When tainted data is copied, sliced, concatenated, formatted, decoded or passed into a function, the result is tainted too. This is why encoding tricks do not help an attacker against a taint tracker: if a tainted value is base64-decoded, the decoded bytes are still tainted; if it is split and rejoined, the pieces carry the mark. The program's own transformations do not launder the data, because the mark follows the data through them.
raw = read_from_socket() # tainted at the source
decoded = base64.b64decode(raw) # still tainted after decoding
name = decoded.decode().strip() # still tainted after transforms
query = "SELECT * FROM t WHERE n = '" + name + "'" # the query is now tainted
Sinks: where tainted data becomes dangerous
A sink is an operation that is risky when the data reaching it is attacker-controlled: issuing a database query, running a shell command, evaluating or importing code, deserializing bytes, opening a file path, or making an outbound request. A sink is not a bug by itself. Programs run commands and open files constantly. It becomes a finding when the value arriving at the sink is tainted. In the snippet above, the sink is the database query, and it has received a tainted string.
Sanitizers: what makes tainted data safe again
A sanitizer is an operation that neutralises tainted data for a particular sink, so that reaching the sink is no longer dangerous. The clearest example is parameterization: a value bound as a query parameter never enters the SQL text, so it cannot change the statement's structure no matter what it contains. A well-placed sanitizer should clear the taint, or at least downgrade it, so that a correctly written program produces no finding.
Sanitization is context-specific, and this is where precision matters. HTML-escaping a value makes it safe to place in HTML body text, but not safe as a shell argument or inside a SQL identifier. A good tracker treats a sanitizer as valid only for the sink class it actually protects, and stays cautious when it cannot prove safety: downgrade rather than suppress, so a value protected only under some conditions never looks as clean as one that was genuinely neutralised.
Runtime evidence next to a pattern match
You can approximate taint tracking statically, by reading source code and reasoning about how data might flow, and tools that do so are useful. But a running program settles questions that source code only raises.
- The dangerous string may not exist until runtime. A payload assembled by decoding, by joining two inputs, or by a template expansion is not present in the source at all. At the moment the sink runs, the final value exists as one concrete argument, fully assembled however it got there.
- The call target may be chosen at runtime. A sink reached through an alias, a dictionary dispatch,
getattr, or a callable stored in a variable is on no literal call pattern, but at runtime it is a real call to a real function. - Reachability is an observed fact. A pattern match says "a risky call exists here." Runtime taint tracking says "attacker-controlled data did arrive at this call on this input, and here is the path it took." The second is a smaller, higher-signal set, because it excludes every risky call that untrusted data never actually reaches.
That last point is also the honest limit on the strength of the claim, which the next section covers.
The honest limits
Taint tracking is not a universal bug finder, so ask any vendor, us included, where theirs stops. Four limits are worth stating plainly.
Reaching a sink is not proof of exploitability. An event means attacker-controlled data arrived at a dangerous operation, with the trace to show it. That is a much stronger and smaller signal than "a sink exists here," but it is not the same as "this is attackable." Upstream logic can pin a tainted value, for example an equality or allowlist check that only lets through a fixed set of strings, so the flow is real while the attacker cannot actually choose the payload. The defensible framing is "confirmed flows, not maybes": the tracker hands you the flow and the path, and you judge whether the value was truly the attacker's to control.
It only sees dataflow bugs. Whole classes of vulnerability are not a flow of attacker data into a dangerous call, and a taint tracker is silent on them by design: cross-site request forgery (a missing token check), broken authorization (a missing permission check), timing side channels, request smuggling (protocol parsing), and most denial-of-service (slowness, not a dangerous operation). In a measurement across the full history of one major framework's advisories, the categories that are a taint-to-sink dataflow (injection, path traversal, code execution) were caught, and the categories that are a missing check or a protocol flaw were not. An honest tool reports the second group as out of scope rather than inventing findings for it.
Coverage depends on what is watched. A tracker follows data through the code and sinks it knows about. Data that leaves through an unwatched path, or into a library whose internals are not followed, can lose its mark. Knowing where coverage ends is part of using it well.
Watching data has a cost. Following every byte of untrusted data as a program runs costs CPU. The cost scales with how many sources you track, so you pay for the tracking you enable and nothing for the sources you leave off. Measure it on your own workload rather than trusting a headline number.
How this maps to EyalSec
The definitions above are exactly the model behind es-python, the EyalSec Python runtime. It marks data from the taint sources you switch on, follows it through the program as it runs, and records an event when it reaches a sink, with the source, the path and the value. It checks the data at the operation itself, so the evidence is a real flow on real input rather than a pattern match. A value that was correctly parameterized never fires, and a value made safe for the sink it reached is suppressed rather than shown. In Report mode nothing is blocked and the program runs unchanged; in Report and Raise the operation is stopped. The glossary defines each term as the dashboard uses it, and How EyalSec works walks the whole path end to end.
For the concrete bug classes a tracker is strongest on, see the overviews of SQL injection and command injection.
Taint tracking is a precise tool for a specific job: finding where untrusted data reaches a dangerous operation. Used with its limits in mind, it turns a pile of "a risky call exists here" into a short list of "data actually got here, on this input." If you want to see that list for your own services, you can book a live demo.