Deep dive

Taint analysis

How the AST-level static analyzer traces untrusted input from WordPress's seven superglobals through function calls, into dangerous sinks, and decides whether the flow is sanitized along the way.

What it is

Data flow, not pattern match

Two flows, one source
Safe path
source
$_GET['id']
sanitizer
intval()
sink
$wpdb->query
Unsafe path
source
$_GET['id']
passes through
helper_fn($id)
sink · SQLi
$wpdb->query
Critical · unsanitized flow to SQL sink
Illustrative. The real tracker follows taint across every branch and call site in the AST, and records the exact file:line for each node in the flow.

WordPress plugins fail in predictable ways: untrusted HTTP input reaches a dangerous sink - $wpdb->query(), eval(), include, echo of raw HTML - without passing through an escape or sanitize call on the way. That pattern is the root cause of most of the SQL injection, XSS, LFI, and RCE CVEs in the WordPress ecosystem.

Pattern-matching misses the real signal. Grepping for $wpdb->query turns up hundreds of call sites per plugin, most of them safe (prepared statements, integer casts, internal-only IDs). Grepping for $_GET finds the sources but doesn't tell you which ones reach a sink. What matters is the path: does untrusted data flow from a source to a sink, and is there a sanitizer somewhere along the way?

Taint analysis makes that question decidable at the AST level. Every variable that receives user input becomes "tainted;" taint propagates through assignments and function calls; passing through a known sanitizer clears it. A flow that lands at a sink while still tainted is the signal. That's what the tracker in server/analysis/ast/taint-tracker.ts does across every plugin version in the corpus.

Taxonomy

7 sources, 31 sinks, 47 sanitizers

The complete taxonomy the tracker works against, grouped by role. Sinks are further split into severity tiers so "unsanitized flow to RCE" and "unsanitized flow to a low-severity update_option" aren't treated equivalently.
Sources · 7 superglobals
$_GET
query string params
$_POST
form body params
$_REQUEST
merged get/post/cookie
$_COOKIE
client-supplied cookies
$_SERVER
selected header values
$_FILES
upload metadata
$_ENV
shell env (rarely tainted in prod)
Sinks · 31 functions, 6 severity tiers
critical
eval, assert, create_function
direct code execution
critical
exec, system, passthru, shell_exec, proc_open, popen
shell execution
high
call_user_func, call_user_func_array
dynamic dispatch
high
unserialize
object injection
high
include, include_once, require, require_once
LFI / RFI
high
$wpdb->query, get_results, get_row, get_var, get_col
raw SQL execution
medium
file_get_contents, file_put_contents, fopen
file access
medium
wp_remote_get, wp_remote_post, wp_remote_request
SSRF
medium
wp_redirect, header
HTTP headers / open redirect
low
update_option
WP option overwrites
low
echo, print (top-level)
output / XSS
Sanitizers · 47 functions mapped to what they clear
esc_* family
esc_html, esc_attr, esc_url, esc_js, esc_url_raw, … clears XSS + open redirect
wp_kses family
wp_kses, wp_kses_post, sanitize_text_field, … clears XSS
integer coercions
intval, absint, floatval - clears SQLi + XSS + LFI in one call
path / URL safety
sanitize_file_name, realpath, basename, wp_validate_redirect
Guards · reduce severity when present
wp_verify_nonce, check_ajax_referer, check_admin_referer
proves the request came from an authorised form - downgrades severity one tier
current_user_can
proves the caller has the capability - downgrades severity one tier
$wpdb->prepare
special-cased: format string is a sink, substitution params are safe
Algorithm

Two phases: summarize, then resolve

A single-pass walk can't answer "does taint reach a sink across function boundaries" without rebuilding the call graph for every source. The two-phase split computes per-function summaries once, then resolves cross-function flows against that cache - roughly linear in the size of the plugin rather than quadratic.
Phase 1 · per-function summaries (line 119)
paramToSink
which param positions end up at a sink
paramSanitized
which params are cleared before the sink
directSources
superglobal reads inside the function body
callsWithTaint
calls to other functions with tainted args
directFlows
source-to-sink paths contained in this function
Phase 2 · inter-procedural resolution (line 131)
Propagate taint across calls
if param i of callee reaches a sink, and caller passes a tainted value as arg i, that's a flow
Transitive handlers
a function that calls a superglobal-reading helper is itself treated as a source handler (one hop)
Dedup & aggregate
same (source, sink, sanitized, via) collapses to one flow with a count
Output · TaintFlowResult graph
source + sink location
exact file:line for both endpoints
category + severity
RCE / SQLi / XSS / LFI / SSRF / object-injection, with its tier
sanitized flag + via
whether a sanitizer applied + intermediate function name for inter-procedural flows
Special cases

Where naïve taint analysis gets WordPress wrong

$wpdb->prepare

The format string passed as the first argument to $wpdb->prepare() is a SQLi sink (tainted format strings let attackers smuggle placeholders), but every substitution parameter after it is safe (prepare escapes them). The tracker treats only argument 0 as a sink on this call, everything else as sanitized.

lines 254-279
array_map / array_walk

Higher-order functions that apply a callback to every element. Naïvely treating the output as tainted misses that array_map('intval', $_POST['ids']) is, in fact, sanitized - the callback is a known integer-coercion sanitizer. The tracker recognises this pattern and marks the result as cleared for SQLi + XSS + LFI categories.

lines 234-241
Nonce & capability guards

A flow into a sink is dangerous in proportion to who can trigger it. If the enclosing function checks wp_verify_nonce or current_user_can somewhere in its body, the tracker downgrades the flow's severity one tier - critical to high, high to medium, and so on. The vulnerability isn't cleared, but it's weighted by the real-world probability of exploitation.

lines 188-194, 290-299
Sample output

A flow, as the analyzer records it

Abridged but structurally real shape of one row in plugin_taint_flows. A single TaintFlowResult entry ties a source to a sink with the call-chain that carries the taint between them and the sanitizer check at each step.

{
  "plugin_slug": "example-plugin",
  "plugin_version": "3.4.1",
  "flow_id": "flw_8f3c2a1",

  "source": {
    "kind": "GET",
    "variable": "$_GET['id']",
    "file": "admin/handlers.php",
    "line": 148
  },

  "sink": {
    "kind": "sql",
    "callable": "$wpdb->query",
    "file": "lib/query-runner.php",
    "line": 64,
    "severity": "critical"
  },

  "path": [
    { "file": "admin/handlers.php",  "line": 148, "op": "source" },
    { "file": "admin/handlers.php",  "line": 161, "op": "call",   "callee": "acme\\build_filter" },
    { "file": "lib/filter-builder.php", "line": 22, "op": "assign", "var": "$where" },
    { "file": "lib/filter-builder.php", "line": 44, "op": "return" },
    { "file": "lib/query-runner.php", "line": 64, "op": "sink" }
  ],

  "sanitizers_seen": [],
  "guards": {
    "nonce_verified": false,
    "capability_required": null
  },

  "severity_final": "critical",
  "confidence": 0.93,

  "notes": "no sanitizer on path; unauthenticated reach; standard SQLi shape"
}

Why the call chain lives in the row. The point of an AST tracker is not "there's a vulnerability" - that's table stakes. The point is showing which specific lines the taint moved through so a reviewer can walk the analyzer's reasoning top-to-bottom and either confirm or reject it. Every path step carries a file and a line number so the report is reviewable against the actual plugin source, not just against a black-box verdict.

Severity is derived, not claimed.severity_final is computed from the sink's baseline severity, downgraded by any nonce / capability guards found on the path (guards block), and up-weighted if the source is reachable without authentication. Confidence reflects how narrow the analyzer's assumptions had to be to trace the path - a flow that threaded through two helper functions with static arguments is higher confidence than one that required an inference about an object's runtime type.

Scale

Running this on every plugin version ever shipped

Today
On-demand svn export
Network-bound
AST analyzer
taint tracker · per version
svn export · per versionrate-limit · RTT · TLS setup
api.svn.wordpress.org
unauthenticated HTTP
  • Net I/O per sweep~GBs
  • Per-version latency~100-500 ms
  • Concurrency ceilingthrottled
Target · WIP
Full local SVN mirror
I/O-bound
AST analyzer
taint tracker · any version pair
local FS readunthrottled · µs per file
Local SVN mirror
full plugins/ tree · every revision
svnsync keeps the mirror fresh against upstream on a scheduled tick - not on the analyzer's hot path.
  • Net I/O per sweep0
  • Per-version latency< 1 ms
  • Concurrency ceilingdisk-bound
Both paths coexist during the transition: production analysis still backs onto on-demand svn export today, and the mirror layers in beside it as it fills. Once coverage is complete, the analyzer switches to the local path by default and the on-demand fallback is retained only for versions the mirror hasn't synchronised yet.

The taxonomy above defines what the tracker does on one file. The interesting part is running it on every plugin version ever published - not just latest. A plugin's security score benefits from looking at how its taint surface changed over time: did this release add new unsanitized flows? Did a patched CVE genuinely remove the source-to-sink path, or did the fix only plug one reachable branch? Those questions need the AST for arbitrary version pairs.

Source code comes from WordPress.org's SVN repositories - every plugin hosted on .org has a full SVN history, which is the ground truth for "what did the code look like in version X." In day-to-day operation the analyzer fetches specific version tags on demand, which works but has two downsides at scale: SVN rate-limits unauthenticated pulls, and the round-trip latency per svn export dominates wall-clock time once you're analyzing thousands of plugin-versions.

A full local mirror of the .org SVN is the unlock. With every plugin's complete history on local disk, the AST analyzer can run against any version pair without network I/O, enqueue bulk analysis runs that would be prohibitive against the live SVN, and derive ecosystem-scale statistics like "fraction of plugins whose last release introduced a new unsanitized flow." The standard pattern is a straightforward svnsync-style mirror, which is what the pipeline is converging on.

Status: work in progress. The mirror is being built out incrementally; the on-demand SVN path still backs production analysis runs today, and the mirror is layered in beside it rather than as a wholesale swap. The public architecture page notes "plugin SVN mirror" as a Layer 1 ingestion source; the taint analyzer is the biggest downstream consumer once the mirror is fully filled.

Once the mirror is complete, the same taint tracker running against it produces the dataset for the planned "ecosystem-scale static-analysis findings" preprint - see Further reading below.

Limits

What the analyzer can and can't catch

best-effort
Dynamic dispatch resolution
Variable function names and reflection aren't statically resolvable
partial
String-built SQL via concat
Queries assembled across many small fragments can outpace the summary cache
tracked
Type coercion edge cases
Some PHP coercions aren't equivalent to a known sanitizer (e.g. casting to array)

False negatives are under-counted in the code-signal score; false positives land as downgraded severity through the guard detection. The aggregate signal holds across the corpus even when individual findings need a human look.

Dynamic dispatch. Static taint analysis is fundamentally bounded by what the analyzer can resolve without running the code. A plugin that builds a function name at runtime and calls it via $fn = $_GET['method']; $fn($userInput) is visible as a sink (the dynamic call site itself is tracked) but the tracker can't statically prove which concrete function is invoked. These flows land in the results tagged with an inferred-sink severity that the risk-assessment layer treats conservatively.

String-built SQL. When a plugin builds a SQL string by concatenating many small fragments across many functions - $sql = $a . $b; $sql .= $c; over five call sites - the per-function summary cache can outpace the resolver. The tracker detects the final sink but may miss that fragment N in the middle was tainted-but-then-concatenated-with-literal-text. These are caught on the code-signals layer rather than the taint layer (they show up as "raw SQL detected"), so the score still reflects the risk.

Type coercion edge cases. Some PHP coercions clean taint for certain categories but not others - (array)$input prevents SQL injection (it's no longer a string) but doesn't prevent LFI (the array's contents are still tainted). The sanitizer → category mapping has to be careful about these; the current mapping covers the common cases and adds entries as we find ones that slip through.

Further reading

Prior art & primary sources

  • FlowDroid - precise context/flow/field/object-sensitive taint analysis for Android (Arzt et al., PLDI 2014)
    The canonical inter-procedural taint analyzer in academic literature. Our two-phase design - per-function summaries, then cross-function resolution against the cache - maps cleanly onto the summary-based approach in this paper. The WordPress-specific adaptation is the sink / sanitizer / guard taxonomy, not the traversal machinery.
  • CodeQL - semantic code analysis via QL queries (Avgustinov et al., ECOOP 2016)
    A general-purpose framework for writing taint analyses as declarative queries over a code database. CodeQL's published rule-set for PHP includes WordPress-specific sinks; our tracker is narrower in scope (single-language, single-framework) but trades generality for very specific modelling of WordPress idioms like $wpdb->prepare and nonce guards.
  • RIPS - static source code analyzer for vulnerabilities in PHP scripts (Dahse & Holz, USENIX Security 2014)
    The reference static taint analyzer purpose-built for PHP. RIPS models PHP semantics directly rather than lowering to a general IR; our tracker takes the same route (walking the php-parser AST directly) and uses the same broad sink taxonomy, with additions for the WordPress core API.
  • PHP Parser (nikic)
    The parsing library the analyzer uses under the hood to turn PHP source into an AST. Standard across the PHP static-analysis ecosystem and the reason single-pass walks are cheap enough to run across every plugin version once the SVN mirror is in place.
  • Planned arXiv preprint - taint analysis at WordPress ecosystem scale
    Once the full WP.org SVN mirror is operational, running this tracker across every plugin version in the ecosystem will produce a labelled corpus of source-to-sink flows annotated with CVE ground truth. The planned preprint reports precision, recall, and prevalence statistics - treating the tracker as a detector and comparing its labels against the existing CVE database for the same versions. Working title: "Static-analysis findings at ecosystem scale: precision, recall, and prevalence of source-to-sink flows across a full WordPress plugin corpus."
About the author

See the flows in the wild.

Every plugin page shows its detected taint flows - sources, sinks, whether sanitized, and which entry point the flow starts from.