adjudicating-taint-paths · git:20260816.faa9c07 · 2026-08-16 · sha256 8e6143228d3d408e
adjudicating-taint-paths git:20260816.faa9c07A
Immutable. This exact content is served forever at /api/v1/blob/8e6143228d3d408e.
---
name: adjudicating-taint-paths
description: >-
Decide whether a whitebox lead is a real bug by tracing taint from an untrusted
source to a dangerous sink and confirming every hop against live source. Use
after a scanner, a candidate list, or your own reading surfaces a "this looks
dangerous" sink (SQL exec, system/exec, file open, deserialize, template
render, redirect target, memcpy) and you must decide whether attacker-
controlled input actually reaches it - or kill the lead with evidence. Covers
forward and reverse taint, witness paths, sanitizer analysis, and the evidence
rules that separate a finding from a false positive.
license: MIT
---
# Adjudicating taint paths: lead → decided finding
A lead is a *fact* about structure - "an input-shaped value can reach a
dangerous sink." It is never a verdict. Adjudication is the disciplined work of
deciding whether that structural possibility is a real, reachable bug on the
current source, and recording the decision so it isn't re-litigated next pass.
## When to use
- A scanner or candidate list flagged a sink and you must confirm or kill it.
- You spotted a sink by hand and want to know if attacker input reaches it.
- You need to *kill* a plausible-looking lead with evidence, not vibes.
## Scope check
Authorized source only (your own, OSS, CTF, in-scope engagement). If you can't
name the authorization, stop.
## The loop
1. **Name source and sink precisely.** Which exact argument of which sink is
dangerous, and what is the *actual* untrusted entry - a request param, header,
filename, env var, deserialized field? Vague framing ("user input reaches it
somewhere") is how false positives survive.
2. **Trace the reverse cone into the sink.** What values can flow *into* this sink
argument? This enumerates every origin. If none trace back to an untrusted
source, the lead is dead - kill it, record why.
3. **Trace the forward cone from the source.** Where does the untrusted value go?
If it never touches the sink, the lead is dead. Forward and reverse must agree;
if they don't, you mis-specified an endpoint - fix it and redo.
4. **Get a witness path.** The strongest evidence is a concrete `source → … →
sink` path. Good tooling returns either a witness or an *honest negative*
("no path"). A witness is a hypothesis to verify, not a proof.
5. **Confirm every hop against live source.** Read the actual body of each
function on the path at the commit you're adjudicating. Verify the value is
genuinely carried hop-to-hop and is not: reassigned to a constant/trusted
value; validated, sanitized, or encoded by a guard on the path; narrowed to a
safe type or bounded before the sink; or never actually passed by any caller.
6. **Decide and record - in the schema.** Survivor → `confirmed`: source, path,
sink, evidence, impact. Killed → record the exact hop where taint breaks. Both
go in the [finding schema](../../FINDING-SCHEMA.md); killed findings are kept.
## Evidence rules
- **Confidence is not truth.** A high-confidence edge is strong support; a
conservative/over-approximated edge is included to avoid missing a path and is
frequently spurious - a witness leaning on one demands extra source
confirmation.
- **An absent edge is not proof of safety.** The tool may not model that path.
Dynamic dispatch (attribute/vtable), function pointers, and
`getattr`/`eval`/reflection are standard blind spots - "no path" through one of
those is inconclusive, not clean. Confirm by reading source.
- **A sanitizer only helps if it covers the payload class.** An HTML encoder does
nothing for a SQL context; a `realpath` check does nothing for a symlink race.
Match the guard to the sink's *context*, not to its name.
## Worked example (a kill and a confirm)
> **Kill.** Lead: `GET /search?q=` → `cursor.execute(sql)`. Reverse cone shows
> `q` reaches `execute`, but reading the hop shows `execute(sql, (q,))` - `q` is
> a bound parameter, never concatenated into `sql`. **Killed**, `kill_reason` =
> "bound param at the sink; q never enters the SQL string."
>
> **Confirm.** Lead: `body.filename` → `open(path, 'w')`. Forward cone reaches
> the sink; reading each hop shows `name = body['filename']` (unchecked) →
> `path = base / name` → `open`. `/etc/x`-style input escapes `base`. **Confirmed**,
> `high`, impact = arbitrary file write → RCE via config/cron drop.
## Rationalizations to reject
- *"The witness path is enough."* → Not without reading source on every hop.
- *"There's a sanitizer, it's fine."* → Only if it covers this sink's context.
Check what it actually enforces.
- *"No path found, so it's safe."* → Not if the path would run through a blind
spot (reflection, function pointers, dynamic dispatch). Confirm by hand.
- *"I'll skip writing down why I killed it."* → Then you re-open it next pass.
Record the kill reason.
## Executing this in practice
Run the loop with whatever answers three questions from a real parse: what
reaches a sink argument (reverse cone), where a source value flows (forward
cone), and the exact current source of any function on the path. A code property
graph answers all three; a taint-tracking analyzer covers most; on a small
target you trace by hand. Step 5 (source confirmation) is never optional - the
tool proposes, you confirm.
## Related
- `hunting-bugs-with-a-code-graph` - the master loop that surfaces leads.
- `auditing-guard-gaps` - when the "sanitizer" is present on one path but missing
on a sibling.
- [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md) - the shape every decision takes.