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
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.
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.
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.
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.
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.
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; 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 - the shape every decision takes.
1---2name: adjudicating-taint-paths3description: 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.4license: MIT5---67# Adjudicating taint paths: lead → decided finding89A lead is a *fact* about structure - "an input-shaped value can reach a10dangerous sink." It is never a verdict. Adjudication is the disciplined work of11deciding whether that structural possibility is a real, reachable bug on the12current source, and recording the decision so it isn't re-litigated next pass.1314## When to use1516- A scanner or candidate list flagged a sink and you must confirm or kill it.17- You spotted a sink by hand and want to know if attacker input reaches it.18- You need to *kill* a plausible-looking lead with evidence, not vibes.1920## Scope check2122Authorized source only (your own, OSS, CTF, in-scope engagement). If you can't23name the authorization, stop.2425## The loop26271. **Name source and sink precisely.** Which exact argument of which sink is28 dangerous, and what is the *actual* untrusted entry - a request param, header,29 filename, env var, deserialized field? Vague framing ("user input reaches it30 somewhere") is how false positives survive.31322. **Trace the reverse cone into the sink.** What values can flow *into* this sink33 argument? This enumerates every origin. If none trace back to an untrusted34 source, the lead is dead - kill it, record why.35363. **Trace the forward cone from the source.** Where does the untrusted value go?37 If it never touches the sink, the lead is dead. Forward and reverse must agree;38 if they don't, you mis-specified an endpoint - fix it and redo.39404. **Get a witness path.** The strongest evidence is a concrete `source → … →41 sink` path. Good tooling returns either a witness or an *honest negative*42 ("no path"). A witness is a hypothesis to verify, not a proof.43445. **Confirm every hop against live source.** Read the actual body of each45 function on the path at the commit you're adjudicating. Verify the value is46 genuinely carried hop-to-hop and is not: reassigned to a constant/trusted47 value; validated, sanitized, or encoded by a guard on the path; narrowed to a48 safe type or bounded before the sink; or never actually passed by any caller.49506. **Decide and record - in the schema.** Survivor → `confirmed`: source, path,51 sink, evidence, impact. Killed → record the exact hop where taint breaks. Both52 go in the [finding schema](../../FINDING-SCHEMA.md); killed findings are kept.5354## Evidence rules5556- **Confidence is not truth.** A high-confidence edge is strong support; a57 conservative/over-approximated edge is included to avoid missing a path and is58 frequently spurious - a witness leaning on one demands extra source59 confirmation.60- **An absent edge is not proof of safety.** The tool may not model that path.61 Dynamic dispatch (attribute/vtable), function pointers, and62 `getattr`/`eval`/reflection are standard blind spots - "no path" through one of63 those is inconclusive, not clean. Confirm by reading source.64- **A sanitizer only helps if it covers the payload class.** An HTML encoder does65 nothing for a SQL context; a `realpath` check does nothing for a symlink race.66 Match the guard to the sink's *context*, not to its name.6768## Worked example (a kill and a confirm)6970> **Kill.** Lead: `GET /search?q=` → `cursor.execute(sql)`. Reverse cone shows71> `q` reaches `execute`, but reading the hop shows `execute(sql, (q,))` - `q` is72> a bound parameter, never concatenated into `sql`. **Killed**, `kill_reason` =73> "bound param at the sink; q never enters the SQL string."74>75> **Confirm.** Lead: `body.filename` → `open(path, 'w')`. Forward cone reaches76> the sink; reading each hop shows `name = body['filename']` (unchecked) →77> `path = base / name` → `open`. `/etc/x`-style input escapes `base`. **Confirmed**,78> `high`, impact = arbitrary file write → RCE via config/cron drop.7980## Rationalizations to reject8182- *"The witness path is enough."* → Not without reading source on every hop.83- *"There's a sanitizer, it's fine."* → Only if it covers this sink's context.84 Check what it actually enforces.85- *"No path found, so it's safe."* → Not if the path would run through a blind86 spot (reflection, function pointers, dynamic dispatch). Confirm by hand.87- *"I'll skip writing down why I killed it."* → Then you re-open it next pass.88 Record the kill reason.8990## Executing this in practice9192Run the loop with whatever answers three questions from a real parse: what93reaches a sink argument (reverse cone), where a source value flows (forward94cone), and the exact current source of any function on the path. A code property95graph answers all three; a taint-tracking analyzer covers most; on a small96target you trace by hand. Step 5 (source confirmation) is never optional - the97tool proposes, you confirm.9899## Related100101- `hunting-bugs-with-a-code-graph` - the master loop that surfaces leads.102- `auditing-guard-gaps` - when the "sanitizer" is present on one path but missing103 on a sibling.104- [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md) - the shape every decision takes.