| name | bn-vr |
| description | Vulnerability-research methodology for finding security bugs in binaries via the bn CLI. Core discipline — exhaustive sink enumeration plus source→sink tracing — is what stops a fast false all-clear: when the import table looks empty or bounded, it forces the audit onto the binary's own copy/format/dispatch sinks and (on C++) the class lens to the directive/parse handlers, then taint/trace to prove or refute attacker control. Covers attack-surface mapping, the stripped/static lane, common bug patterns, the taint engine, and reporting. |
bn-vr — Vulnerability Research Methodology
Use this skill when the user wants to find vulnerabilities, audit for bugs, check security, or analyze attack surface in a binary. This is a methodology guide — it tells you what to look for and why. For command syntax, see the bn skill.
Attack Surface Identification
Start by mapping what the binary does and where untrusted data enters:
No imports? (static / stripped firmware.) If bn imports comes back empty or near-empty, the binary is statically linked and the import-first steps below won't bite — bn xrefs strcpy / function search strcpy return nothing when no function is named strcpy. Use the Stripped / static lane at the end of this section instead.
file=stripped ≠ static lane. file(1) reports "stripped" whenever .symtab is gone, but a very common middle case still keeps a fat .dynsym: default-visibility / -rdynamic firmware executables (and every shared object) export hundreds of GLOBAL DEFAULT FUNC symbols plus named PLT imports. There the import-first lane fully applies — BN has real names for nearly every function. Pick the lane by the tell, not by file: bn imports non-empty or bn function list mostly named (only a minority of sub_XXXX) ⇒ import-first, even when file says stripped. Route to the stripped/static lane only when bn imports is empty/near-empty and function list is overwhelmingly sub_XXXX.
Searching for a sink name? Use --word (or --exact), not plain substring. bn function search popen is a case-insensitive substring match, so it also returns unrelated symbols that merely contain the token (zipOpenArchive, my_popen_wrapper) — false positives you'd waste time chasing. bn function search popen --word matches popen only as a whole identifier token (still catches popen@plt); --exact matches the exact name. Always cross-check against bn imports / the actual call target.
Quick-loaded target? If the binary was opened with bn load --quick / bn session start --quick, the code is not analyzed yet: bn imports and bn sections work, but bn strings (step 3) errors until bn refresh (it refuses rather than return nothing) and bn function list / bn function search return only a partial set — a false "no dangerous strings, no sinks" all-clear. Confirm analysis_state is "full" (bn target info) and bn refresh before auditing. See "Quick Load" in the bn skill.
C++ / symbolicated target? Use the class lens to reach the handlers fast. On a binary with demangled C++ symbols/RTTI, bn class list --no-stl surfaces the domain classes and bn class show <Name> lists a class's methods + vtable — the quickest way to locate the directive/parse/dispatch/handle*/onReceive entry points that take untrusted input, before you enumerate sinks. (On stripped/static firmware with no symbols, skip it and use the "Stripped / static lane" below.)
C / firmware dispatch registered via a stack descriptor? Map the command→handler surface with bn evidence calls. When handlers register with an ops-table/command primitive by filling a transient stack descriptor and passing &desc (init_cmd(dev, &desc) with desc.command, desc.type, desc.callback = handler), recovering "which callback processes which command" was a manual per-caller decompile. bn evidence calls <reg-fn> --arg-struct N --field type:u8@2 --field callback:ptr@16 reports, per registration callsite, the descriptor's constant field values and the resolved callback symbol — the attacker-reachable dispatch map (which opcodes reach which handlers) in one command, so you can jump straight to auditing the handlers that parse untrusted fields. ptr fields resolve the callback's function symbol; unknown/merged fields and sibling-slot recoveries are marked, so treat ~-flagged values as heuristic (and a PIE/shared-lib callback reached through the GOT surfaces as <computed>, not a wrong symbol — confirm it at the source_address). See "evidence calls" in the bn skill's reference/reading.md.
-
Dangerous imports — scan for functions with known vulnerability history:
bn imports
Shortcut (step 1 of sink enumeration): bn taint models --role sink --present --callsites -t <target> lists every modeled sink actually present in this binary — the same catalog the taint engine uses, so the list can't drift — with each present sink's callsite addresses, so you can jump straight to tracing a site back to a source. It sees only modeled sinks: on a stripped/static target whose dangerous copy/format sinks are unnamed vendor functions, this list can be short or empty — that is not an all-clear; fall through to the "Stripped / static lane" below and recover sinks by shape. (Run bn taint models with no target to browse the whole catalog, or --class overflow_len to focus one bug class.)
Flag these categories:
- Unbounded copies:
strcpy, strcat, sprintf, gets, scanf (no length limit)
- Bounded but misusable:
strncpy, snprintf, memcpy, memmove (length may be attacker-controlled)
- Memory management:
malloc, calloc, realloc, free (UAF, double-free, heap overflow)
- Execution:
system, exec*, popen, dlopen (command/code injection)
- Format strings: any
*printf family where the format argument could be user-controlled
-
Input sources — identify where external data enters:
bn imports
Look for: read, recv, recvfrom, fgets, fread, getenv, argv access patterns. These are your taint sources.
-
Interesting strings — format strings, SQL fragments, shell commands, and paths hint at injection surfaces:
bn strings --regex --query '%s|%x|%n|SELECT|INSERT|/bin/' --no-crt --min-length 4
--regex makes --query a case-insensitive regex so the | actually means OR — without it --query is a literal substring and \| matches nothing. Use --no-crt to suppress locale/CRT noise and --min-length to skip short fragments; --section .rodata restricts to read-only data. On a symbol-rich C++ target, always scope with --section .rodata — bn strings scans .dynstr, so the real literals are otherwise buried under mangled _Z... symbol names (--no-crt does not drop those).
-
Memory layout — understand which regions are writable, executable, or both:
bn sections
Look for writable+executable sections (W+X) — these are high-value targets for code injection. Check section sizes and ranges to understand the binary's memory layout.
Stripped / static lane (no imports, no symbols)
On stripped static firmware (busybox, embedded ARM/MIPS), bn imports is empty and bn xrefs strcpy / function search strcpy return nothing — there are no import names and no function names. Invert the workflow: enter from data, not from imported symbols.
-
Confirm the case.
bn target info
bn imports
bn function list --count
A few-thousand-function count with sub_XXXX names and an empty import table is the signature.
-
Enter from strings. Strings are the surviving attack-surface map — config paths, format strings, command/applet names, protocol keywords. The first column of bn strings output is each string's address; pivot it to the code that uses it:
bn strings --regex --query '/etc/|/bin/|%s|%n|password|http|telnet|login' --no-crt --min-length 4
bn xrefs <string-address>
bn decompile <referencing-fn>
This strings -> xrefs <addr> -> decompile chain is the reliable spine when name/import search returns none.
-
Recover the libc-like sinks by shape. You can't bn xrefs strcpy if strcpy has no name — so identify the unnamed helpers behaviorally, then name them, which restores the source->sink tracing below. As you read the functions strings led you to, watch for a helper called from a copy/format pattern:
- libc primitives (memcpy, strcpy, strlen, sprintf) are small, leaf/near-leaf, and called from many sites. Run
bn xrefs <sub_addr> on a candidate — a high inbound count plus a tiny body is the tell.
- Decompile the candidate, recognize the idiom (byte-copy loop, copy-until-NUL, scan-for-zero), then
bn symbol rename <addr> memcpy --preview, verify, and apply.
- Now
bn xrefs memcpy works and the Pattern-based audit (below) is back in play.
-
Walk constructor and dispatch tables. Static firmware hides entry points the direct-call graph won't show (see bn-re's "Hidden Code Surfaces"):
bn evidence init
bn evidence table <addr>
-
Confirm the bound in disasm — not just widths. Stripped + ARM means the decompiler's story is frequently wrong for more than operand size: confirm field loads (ldrb vs ldr) and the guard/loop shape in bn disasm before concluding off-by-one / truncation / overflow. HLIL flattens ccmp/csel range guards into misleading ternaries, can render a hoisted loop-invariant limit relative to a moving pointer (so a real cap looks like it never fires), and hides a missing << 4 in a size accumulator — each has faked a critical finding. See the "HLIL can mislead beyond access width" note in the bn skill.
Worked example — busybox. BusyBox is an applet multiplexer: main dispatches on argv[0]/argv[1] to applet handlers, so its real attack surface is the applet table plus the strings that name applets:
bn strings --regex --query 'httpd|telnetd|login|/etc/(passwd|shadow)' --no-crt
bn xrefs <httpd-string-addr>
bn evidence table <applet-table-addr>
Then audit each reachable applet (httpd request parsing, telnetd, login) with the source->sink tracing below — now that the sinks have names from step 3.
Input Tracing: Sources to Sinks
The core of VR is connecting where data comes from to where it's used dangerously.
Forward tracing (from source)
Start at an input function and trace where its output flows:
bn xrefs read
bn callsites read --within <handler_function>
bn decompile <handler_function>
Read the decompilation: does the buffer from read() flow into strcpy(), sprintf(), or system() without validation?
Backward tracing (from sink)
Start at a dangerous function and trace where its arguments come from:
bn xrefs strcpy
bn callsites strcpy --within <function>
For each callsite, examine: is the source argument bounded? Is the destination buffer large enough? Can the attacker control the source?
Multi-hop tracing
Data often passes through several functions before reaching a sink. Follow it step by step:
- Identify the sink callsite and its arguments
- Trace each argument back through the caller's locals and parameters
- Use
bn xrefs on the caller to find its callers
- Repeat until you reach an input source or lose the trail
Sink-to-source tracing with bn trace
When you find a dangerous call (e.g. memcpy(dst, src, len)) and need to know where a specific argument originates, use bn trace to walk the SSA use-def chain backward:
bn trace <containing_function> <call_address> --arg N
This works within a single function (intraprocedural). For example, tracing which buffer flows into a memcpy destination reveals whether it's a stack local, a heap allocation, or a function parameter — letting you assess attacker control without reading every line of decompilation.
Add --interprocedural to cross call boundaries when the traced value is another function's return value:
bn trace handler 0x1234 --arg 0 --interprocedural
bn trace handler 0x1234 --arg 0 --interprocedural --ip-depth 3
Scope of --interprocedural: it follows a value into a callee only when that value is the callee's return value, then traces the callee's return-value origins and stops at the callee's own parameters. It does not map a callee's parameters back to the caller's argument expressions, and it does not walk up the caller chain. So for "this arg is the return of foo() — where does foo get it?" IP mode answers directly; for "this arg came from my own caller" or "trace up through every caller," step up manually with bn xrefs on the containing function and re-run bn trace in each caller.
The walk requires MLIL SSA, so use --view mlil (the default; --view hlil exists but often can't locate calls nested in assignment statements). IP mode works best on self-contained code (static binaries, kernel modules); for shared-library PLT/import calls the callee has no MLIL body, so IP mode correctly falls back to intraprocedural behavior. Use --format json to get structured step-by-step SSA variable information.
When the trail is indirect or the args are unclear
Plain bn xrefs/decompile thin out when dispatch is indirect or the decompiler's argument story is incomplete (common in C++/IPC services). Three evidence helpers (syntax in the bn skill — reference/reading.md) pick up the trail:
bn evidence xrefs <sink-or-string> — like bn xrefs but each ref carries section/segment/symbol + the referencing disassembly, so you can tell a real code caller from a vtable/RTTI/descriptor slot, and spot a sink that's reachable only through a vtable (no direct call).
bn evidence function <caller> — shows the raw ABI arguments (registers + LLIL/MLIL/HLIL) next to the pseudo-C at each call, including the vtable offset for an indirect/virtual call. Use it to recover a sink's real arguments without dropping to disasm, and to see through j_*/PLT thunks to the true callee.
bn evidence message <TypeName> — for protobuf/IPC message handlers, maps a message type-name string to its serializer/handler pointers, giving you the receive→parse→dispatch entry points to trace forward from.
Reminder: HLIL misleads beyond access/operand width — besides field-load size, it flattens ccmp/csel range guards into ternaries, aliases hoisted loop-invariant bounds to a moving pointer, and can drop a << 4 from a size accumulator. Confirm the bound in bn disasm before concluding on a truncation/off-by-one/overflow (see the "HLIL can mislead beyond access width" note in the bn skill).
Systematic Audit Workflow
Go pattern-based for breadth — enumerate sinks (bn xrefs strcpy, …), trace each callsite's args backward, skip provably-safe ones, prioritize those fed by input sources — then switch to line-by-line on the high-value code (parsers, auth, crypto), tracking what's audited so you don't leave gaps.
Manual audit lane: parser / fixed-header invariants (taint won't flag these)
Source→sink taint proves attacker data reaches a modeled sink. It says nothing about a loop guard that admits a partial fixed-header read — a TLV/option parser that checks remaining > 0 instead of >= <header-width> walks off the end of a short buffer with no memcpy/strcpy in sight. Taint comes back empty and the bug is invisible. An empty taint result is NOT an all-clear for parser-invariant bugs — audit these by hand.
Sanitized vulnerable shape (a TLV option loop):
while (remaining > 0) {
code = p[0];
opt_len = *(uint16_t *)(p + 2);
dispatch_option(code, p + 4, opt_len);
p += 4 + opt_len;
remaining -= 4 + opt_len;
}
Checklist:
- Loop guard vs fixed-header width — is the continue condition
>= sizeof(header), or a weaker > 0 / != 0 that lets the last field read past the buffer?
- Unknown-option skip — does an unrecognized
code advance p by an attacker- or stale-controlled length (p += 4 + opt_len) without bounding it against remaining? A single oversized opt_len desyncs the walk.
- Handler dispatch before complete-header validation — is
dispatch_option (or a vtable/table handler) called before all header fields are confirmed in-bounds?
- Stale bytes when alloc > received — if the buffer was allocated larger than the received length, an over-long
opt_len reads stale/uninitialized bytes, not OOB — same info-leak surface, no crash.
- Post-parse checks that limit impact — is there a later length/CRC/bounds check that constrains the damage, or does the parsed value flow straight to a copy/handler?
Command chain:
bn decompile parse_tlv --addresses
bn disasm 0x401120 --linear 64
bn evidence function parse_tlv --context 1
bn trace parse_tlv 0x401148 --arg 2
Confirm the guard and the field-load widths in bn disasm (not the decompiler — HLIL flattens the compare), then assess whether one crafted option can drive p/opt_len past remaining.
Destination-capacity audit (strcpy / strcat / repeated appends)
For an unbounded copy the decisive property is destination capacity vs the aggregate length written into it — not whether an explicit length argument is tainted (strcpy/strcat have none). A tainted-length trace can come back clean and the overflow still be real. Prove capacity, then prove the write bound.
Checklist:
- Trace dest (arg0) to its allocation —
bn trace <fn> <call> --arg 0 back to the stack slot / global / malloc site; recover the concrete capacity from the frame layout or the alloc size.
- Trace source (arg1) to provenance + max NUL-terminated length — where does the string come from, and what caps its length before the NUL?
- For
strcat, bound dest length + total appends — the overflow is initial_strlen(dest) + Σ(appended lengths); a per-append length that looks safe overflows in aggregate across a loop.
- For helper/wrapper APIs, audit EVERY caller — a
str_append(dst, src) wrapper is only as safe as its worst caller; classify none of them until all call sites' arg constraints are checked (bn xrefs <helper> / bn callsites).
- Confirm ABI + alloc sizes in disassembly —
bn disasm for the true register args and the stack-frame reservation; the decompiler can misattribute a buffer's size.
- Treat taint output as frontier guidance, not proof — a
coarse_memory_store leaf or an empty length-slice is where the manual capacity check begins.
Sanitized shapes:
char tmp[128];
strcpy(tmp, api_arg);
char out[2048];
for_each_chunk(msg, chunk)
strcat(out, decrypt(chunk));
Command pattern:
bn callsites strcat --within build_response
bn trace build_response 0x40219c --arg 0
bn trace build_response 0x40219c --arg 1
bn disasm 0x402180 --linear 48
Taint Analysis (bn taint)
bn taint automates the propagation step over Binary Ninja's MLIL-SSA: it
follows def-use chains through assignments, arithmetic, phi joins, and a
built-in function-model DB (recv/read/fgets/getenv sources;
memcpy/strcpy/sprintf/system/… sinks). It is interprocedural — it
descends into in-binary callees (depth-bounded by --max-depth, cached per
function) so a sink inside a helper is reported against the entry function with
the full cross-boundary path, and it carries taint back through output-pointer
parameters (a helper that fills *dst taints the caller's buffer). Heap/pointer
flows (*p = tainted; x = *p) are recovered via memory-SSA store/load
correlation, not just stack buffers, and locally-built descriptor structs
populated from input and passed by address (a common protocol-stack pattern)
are tracked through their field stores. It stays honest about its limits: every
coarse-memory step, every unmodeled external call, and every unresolved indirect
call is reported under assumptions / leaves, and the result always carries a
soundness disclaimer. It is a may-analysis, not a proof.
Forward — from a source to whatever sinks it reaches:
bn taint forward -f <handler> --source arg:recv:1
bn taint forward -f <handler> --source param:0
Reports one compact line per flow by default: the bug class (overflow_len,
command_injection, format_string, …), the sink address/arg, an address-free
grouping signature (source → wrapper → [class] sink), and structural
{steps / fns / unresolved} metrics. Add --verbose (--full) for the full SSA
path per flow; --format json always carries path, metrics, and signature
per finding, so you can rank and dedup flows across runs by their signature.
Distinct sink call-sites always render on their own line — nothing is folded
behind a count.
Global / struct-field buffer source → seed the parser entry instead. When the
recv destination is a long-lived pointer in a global or daemon struct
(recvfrom(fd, G.pkt, …), then later p = G.pkt; parse(p, n)), seeding
--source arg:recvfrom:1 (or arg:read:1) can report zero propagation —
the buffer-pointee taint isn't anchored across the global/struct-field pointer
load that re-derives the parser's argument, so the recv store and the parser
load aren't correlated. Seed the parser directly instead —
bn taint forward -f parse_packet --source param:0 — which surfaces the
byte-copy / option-extraction loops honestly as coarse_memory_store leaves.
The same indirect-load caveat is what bites whenever the recv destination is
reached through a pointer load rather than a direct local buffer.
Shallow/empty taint through an object parser or a raw decoder is NOT an all-clear — report the frontier type. Two common shapes bottom out taint without a modeled sink, and each has a name to put in the finding so a reader knows why it stopped, not that it's clean:
parser_object_frontier — taint reached an object/object_get* accessor but the field semantics aren't modeled, so per-field flow isn't tracked past the getter.
raw_decoder_store_frontier — a callee writes destination bytes with no output-length model (a decode_*/inflate_* that fills a buffer), so the written length is unknown to taint.
manual_disasm_required — the residual action: check by hand whether the destination is a fixed stack/global buffer and whether bounds checks happen before/after the decode call.
Sanitized shape: handle_request → parse_object → object_get_string → decode_payload(payload, decoded) — taint dies at object_get_string/decode_payload with an empty or 1-step slice. Confirm the real risk with:
bn trace parse_object 0x40a1c4 --arg 0
bn disasm 0x40a190 --linear 48
Backward — slice a sink's argument back to its origin:
bn taint backward -f <handler> --sink arg:memcpy:2
bn taint backward -f <handler> --sink arg:strcpy:1
When a slice bottoms out at a parameter, it continues up into callers
(caller_sites, depth-bounded by --max-depth), mapping the parameter back to
each call's argument — so a length checked in a helper is traced to the recv
that produced it. Each result lists the crosses: chain and an origin.
Backward now bridges register→stack parameter spills. When a callee spills an
incoming parameter to a stack slot on entry (common on MIPS/ARM), taint backward
canonicalizes the stack local to param:N (shown as origin: parameter … (via spill) + a #434 caveat) so caller-ascent still climbs into the callers. The
residual cases where backward stops short — a scalar arg passed as &local, or a
local filled by a callee through an out-pointer — still favor bn trace.
bn taint backward and bn trace (see "Sink-to-source tracing" above) are
complementary backward slicers: taint backward seeds on a sink/var locator
and ascends into callers to find where a value originates, while bn trace
pins an exact call argument at a specific address and descends into callees
for return-value provenance. Reach for trace when interrogating a concrete
callsite, taint backward when hunting origins across the caller chain.
JSON: the findings live under a different key per direction. taint forward
puts the discovered sinks in reached_sinks (top-level array; stats.sinks
is just its count). taint backward puts the discovered slices in slices —
its top-level sinks key echoes the input sink locators you passed, not
the findings. So extract with jq '.reached_sinks[]' (forward) vs jq '.slices[]'
(backward); reading backward .sinks as results misreports a real slice as "0
sinks".
Source/sink locator grammar: param:<n> · var:<selector> ·
ret:<callee> (forward only) · arg:<callee>:<n> (the buffer arg n points at).
Underlying primitives (useful on their own, and for auditing a taint result):
bn dataflow defuse <fn> --var <name#version>
bn dataflow callgraph <fn> --direction callees
bn dataflow values <fn> --at <addr>
bn function structured-il <fn>
Indirect calls reached by taint are resolved automatically when value-set
analysis can pin the target(s) (e.g. const function-pointer tables), and taint
follows into each resolved target; only genuinely unresolved ones become
leaves. You can force resolution with --resolve-map FILE
({"0x4011f0": ["0x401176", "0x401195"]}).
When taint stops (the known-hard cases), fall back to the manual chain. If a
flow hits a leaves entry — an indirect_call_unresolved (function pointer /
vtable VSA could not pin) or an un-modeled external — resolve it by hand and
continue:
Then assess exploitability as before: can the attacker control enough of the
input, are there length/sanitization checks in the path, and what is the memory
layout at the target?
Reporting Findings
Per finding: location (fn + addr), bug class, trigger condition (what input reaches it), root cause, impact, the source→sink data-flow path, and a PoC sketch if constructible. Always state the bn taint soundness caveat — it's a may-analysis, not a proof.