hunting-bug-variants · git:20260816.c75b58d · 2026-08-16 · sha256 05f2d04d1121adfc

hunting-bug-variants git:20260816.c75b58dA

Immutable. This exact content is served forever at /api/v1/blob/05f2d04d1121adfc.

---
name: hunting-bug-variants
description: >-
  Given one confirmed vulnerability, systematically find its siblings: the same
  defect shape repeated elsewhere in the codebase, and the parts of it the fix
  left uncovered. Use right after you confirm or read about a bug (your own
  finding, a CVE, a patch, a writeup) and want the other instances instead of
  stopping at one. Turns a single seed into a structural signature and sweeps the
  whole tree for same-shape code, copy-paste clones, sibling handlers, and
  incomplete fixes. Covers signature extraction, the variant sweep, and
  adjudicating each candidate.
license: MIT
---

# Hunting bug variants: one seed, every sibling

One bug is almost never alone. The same mistake gets copy-pasted, re-implemented
by a second author, or left uncovered by a fix that only patched the reported
call site. Variant hunting takes a *single confirmed defect* and turns it into a
signature you can sweep the whole codebase with, so you find the three other
copies before an attacker does.

## When to use

- You just confirmed a bug and want its siblings, not a victory lap.
- A CVE, advisory, or writeup describes a bug in code you can read.
- A fix landed and you want to know whether it covered every instance.
- Someone says "we already patched that" and you want to verify it is really gone.

## Scope check

Authorized source only (your own, OSS, CTF, in-scope engagement). If you can't
name the authorization, stop.

## The loop

1. **Anchor the seed.** State the confirmed defect precisely: the exact sink, the
   exact missing or wrong step (no bounds check, no authz check, unnormalized
   path, unescaped context), and the untrusted input that drives it. A vague seed
   ("there was an injection somewhere") produces a useless sweep.

2. **Extract the shape, not the string.** Abstract the seed into a structural
   signature that survives renaming and reformatting. Usually one of: *this sink
   called without that guard*; *this unsafe idiom* (raw concat into a query,
   `base / user_name` without resolve-and-contain); *this type of value reaching
   this argument*. Grepping the literal code finds only exact copies; the shape
   finds the re-implementations.

3. **Sweep for same-shape sites.** Enumerate every place matching the signature:
   other callers of the same sink, other functions with the same step missing,
   copy-paste clones, sibling handlers in the same module, the same pattern in a
   different subsystem. Cast wide here; ranking is triage, not a filter.

4. **Check the fix itself for gaps.** If the seed came with a patch, treat the fix
   as suspect: does it cover *every* reachable path to the sink, or only the
   reported one? Was the check added to the caller but not a second caller? Is
   there a pre-existing path that bypasses the new check? Incomplete fixes are the
   highest-yield variants because everyone believes the bug is dead.

5. **Adjudicate each candidate.** A same-shape site is a lead, never a verdict.
   Confirm or kill each by tracing input to the sink and reading the hops on live
   source, exactly as for any lead. A structural twin can still be safe because a
   caller bounds the input or a guard sits one frame up.

6. **Record every decision in the schema.** Each confirmed variant is its own
   finding (its own `id`, path, evidence). Killed twins are kept with the reason
   they differ from the seed, so the next person doesn't re-chase them.

## What makes a good signature

- **Tight enough to rank, loose enough to catch re-implementations.** "Any call
  to `write`" is too loose; "the literal three lines from the seed" is too tight.
  Aim at "a filesystem write whose path is a user value joined to a base without a
  containment check."
- **Anchored on the invariant that was violated,** not on incidental details.
  The bug is "the containment check is missing," not "the function was named
  `export`." Sweep on the invariant.
- **Include the negative half.** Part of the signature is what the *correct*
  siblings do (the guard that the seed lacked). The correct ones tell you exactly
  what the broken ones are missing.

## Worked example (a confirm and a kill from one seed)

> **Seed (confirmed).** Path traversal in report export: `name = body['filename']`
> flows to `open(base / name, 'w')` with no containment check. `/etc/x`-style
> input escapes `base`.
>
> **Signature.** A filesystem sink whose path is `base / <user value>` with no
> resolve-and-reject-outside-base step between the input and the sink.
>
> **Sweep** surfaces three same-shape sites: backup restore, avatar upload, and
> log export.
>
> **Confirm.** Backup restore builds `restore_dir / archive['name']` and extracts
> there with no containment check; `../` in the archive entry escapes. **Confirmed**,
> new finding, `high`.
>
> **Kill.** Avatar upload builds `base / fname` but reading the hop shows `fname`
> is a server-generated UUID, never the client's name. **Killed**, `kill_reason` =
> "path component is a generated UUID, not attacker input; the seed's invariant
> holds here."

## Rationalizations to reject

- *"I found and fixed the bug, done."* → You fixed one instance. The sweep is the
  actual work; the seed was just the sample.
- *"Grep found no other copies, so it's unique."* → Grep finds strings. A second
  author's re-implementation has none of the same tokens and the same flaw. Sweep
  the shape.
- *"The fix obviously covers it."* → Fixes routinely patch the reported call site
  and miss a sibling caller or a pre-existing bypass. Verify coverage, don't
  assume it.
- *"It matches the pattern, so it's a bug."* → A match is a lead. Adjudicate it;
  a structural twin can be safe for a reason the seed wasn't.

## Executing this in practice

The sweep needs a way to enumerate structural matches across the tree from a real
parse: every caller of a sink, every function missing a step its peers have,
clones of an idiom. A code property graph or a structural-search tool does this;
on a small target you read the neighbors by hand. Extraction (step 2) and
adjudication (step 5) are human judgment the tool only feeds.

## Related

- `auditing-guard-gaps` - when the seed's invariant is specifically a missing
  guard and you want the unguarded peers of a guarded function.
- `adjudicating-taint-paths` - the confirm/kill loop each candidate goes through.
- `extracting-nday-from-a-patch` - when the seed is a patch rather than your own
  finding.
- [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md) - the shape every confirmed and
  killed variant takes.