Files
t3mp3st/scripts/binary-vuln-bench.mjs
T
Lyubomir Bozhinov 6d4e01756a feat(binary): detect memcpy/memmove with a non-constant length (#151)
* test(binary): RED — no rule for memcpy/memmove with a non-constant length

Adds two corpus fixtures + direct assertions for a memcpy/memmove sink:
- memcpy-wire-length (bad): a wire-decoded length into a fixed buffer,
  expect B-MEMCPY. Fails on main — the ruleset has no memcpy rule.
- memcpy-bounded-control (clean): two safe memcpys (sizeof-bounded + a
  constant length). This forces a DISCRIMINATING rule: a naive /memcpy/
  match false-positives here and fails the zero-false-positive gate.

Refs #150

* feat(binary): detect memcpy/memmove with a non-constant length

Adds B-MEMCPY to the decompiled-vuln ruleset — the classic unbounded-copy
overflow. The framework's own reasoning layer treats this as its textbook
target (src/orchestration/prompts.ts:60,80 — 'a wire-controlled length flowing
into an unchecked memcpy'; orchestration/index.ts:11), but the static
pre-filter had no rule for it: strcpy was covered, its memcpy(dst, src, len)
sibling was not.

The rule inspects the length (3rd) argument and fires only when it is
non-constant — a variable, struct field, deref, or computed/decoded value —
staying silent on a sizeof(...) or numeric-literal length. Validated against a
22-case dangerous/safe matrix (perfect discrimination). Corpus 6+2 -> 7+3; the
new sizeof/constant-bounded control keeps the rule honest — a bare /memcpy/
match would false-positive on it.

Closes #150

* fix(binary): stop B-MEMCPY false-positive on nested-call args; disclose limits

Adversarial review found a real false positive: a comma inside a nested call in
arg 1/2 (memcpy(dst, get_src(a,b), sizeof(dst))) mis-split the naive
[^,]+,[^,]+, arg list, so a sizeof-BOUNDED call fired. Restricting args 1-2 to
[^,()]+ (paren-free) removes it — verified the bounded nested-call form is now
silent while every common non-constant length still fires. The trade is a
memcpy whose 1st/2nd arg is itself a call is not matched (an acceptable miss for
a directional pre-filter, now disclosed).

Rule comment rewritten to honestly enumerate the directional misses (leading-
digit computed length, sizeof-plus-term, nested-call args, inline comments,
__memcpy_chk/memcpy_s/wmemcpy variants). Test expanded into an in-tree
discrimination matrix (fires / silent / pinned disclosed-limit cases) so the
scope boundary is auditable and any future tightening surfaces deliberately.

Refs #150

* fix(binary): match pointer casts in B-MEMCPY args; correct disclosed limits

A second adversarial pass on the shipped rule caught that the previous
paren-free tightening ([^,()]+) over-corrected: it also stopped matching
pointer CASTS on args 1-2 — memcpy((void *)dst, (void *)src, len) — which are
the most common memcpy shape in real decompiled output. The rule was silently
missing the main case.

Replace the arg matcher with one level of BALANCED parens,
(?:[^,()]|\([^()]*\))+, so a cast (void *)dst and a comma-bearing call
get(a,b) are each consumed as ONE arg. This fires on casts again, still fires on
a call-bearing arg with a non-constant length (now a true positive, previously a
disclosed miss), and keeps the original nested-call-with-bounded-length false
positive silent. Verified no catastrophic backtracking on pathological input.

Also corrects the disclosed-limits comment: the previous 'arg is itself a call'
miss is now only DOUBLY-nested parens, and the inline-comment note was
backwards (a comment obscuring a sizeof/literal false-fires, it does not cause a
miss) — restated accurately, with decompiled output not emitting inline comments
noted. Tests pin the cast fires and the variant exclusions.

Refs #150
2026-08-12 13:28:55 -04:00

108 lines
6.5 KiB
JavaScript

#!/usr/bin/env node
// binary-vuln-bench.mjs — the Binary/RE loadout's decompiled-output vulnerability detector + benchmark.
//
// Ships a built-in ruleset for dangerous patterns in DECOMPILED / disassembled output (the pseudocode
// ghidra/radare2/objdump produce), and scores it against a committed corpus of decompiled-C fixtures
// with a known-vuln ground truth + safe controls.
//
// HONEST SCOPE: pattern detection over decompiled artifacts — surfaces *candidate* memory-safety /
// injection sinks. It is NOT full reverse-engineering (understanding/solving a binary) and NOT a pwn/
// exploit — those are the live ghidra/gdb path + the Cybench reversing track. Self-contained +
// deterministic: `--self-test` is the real run (no external tool, no binaries).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const CORPUS = path.join(HERE, '..', 'bench', 'binary-vulns', 'corpus.json');
const rx = (re) => (c) => re.test(c);
// Dangerous-pattern ruleset over decompiled/disassembled C-like pseudocode.
export const RULES = [
{ id: 'B-GETS', desc: 'gets() — unbounded stdin read (always unsafe)', test: rx(/\bgets\s*\(/) },
{ id: 'B-STRCPY', desc: 'strcpy() — unbounded copy into a fixed buffer', test: rx(/\bstrcpy\s*\(/) },
{ id: 'B-SPRINTF', desc: 'sprintf() — unbounded formatted write', test: rx(/\bsprintf\s*\(/) },
{ id: 'B-FORMAT-STRING', desc: 'printf(var) — user-controlled format string', test: rx(/\bf?printf\s*\(\s*[a-zA-Z_]\w*\s*\)/) },
{ id: 'B-CMD-INJECTION', desc: 'system()/popen() on a variable — command injection', test: rx(/\b(system|popen)\s*\(\s*[a-zA-Z_]\w*/) },
{ id: 'B-INT-OVERFLOW', desc: 'malloc/calloc with multiplication — integer-overflow alloc size', test: rx(/\b(malloc|calloc|alloca|realloc)\s*\([^)]*\*/) },
// Inspects the length (3rd) arg: fires when it begins with a variable / field /
// deref / decoded value (`len`, `hdr->len`, `ntohl(hdr->len)`) and stays silent
// when it begins with a `sizeof` expression or a numeric literal. Two subtleties:
// the `[^)\s]` after the lookahead is load-bearing — without it `\s*` backtracks
// onto whitespace and bounded forms leak through; and args 1-2 are matched with a
// single level of BALANCED parens `(?:[^,()]|\([^()]*\))+` so a cast `(void *)dst`
// and a comma-bearing call `get(a,b)` are each consumed as ONE arg — neither
// mis-splits the list (the original naive `[^,]+` split on the comma inside a
// call and false-fired on a bounded length). Directional static pre-filter, not
// taint analysis — disclosed misses (silently NOT flagged): a computed length
// that starts with a digit (`8*count`) or with `sizeof` plus a term
// (`sizeof(x)+n`), and a 1st/2nd arg with a DOUBLY-nested paren
// (`wrap(get(a,b))`). The `__memcpy_chk`/`memcpy_s`/`wmemcpy` variants are out of
// scope (the `\b...\(` shape); the `_chk` form is usually the bounded, safe one.
// Comments aren't stripped: a mid-argument `/* ... */` obscuring a sizeof/literal
// length would false-fire, but decompiled output does not emit inline comments.
// Decompiled output inlines constants, so a named-macro length does not arise.
{ id: 'B-MEMCPY', desc: 'memcpy()/memmove() with a non-constant length — candidate overflow from an unchecked/wire-controlled size', test: rx(/\b(?:memcpy|memmove)\s*\((?:[^,()]|\([^()]*\))+,\s*(?:[^,()]|\([^()]*\))+,\s*(?!\d|sizeof\b)[^)\s][^)]*\)/) },
];
export function loadCorpus() {
return JSON.parse(readFileSync(CORPUS, 'utf8'));
}
// Run the ruleset over one decompiled artifact → Set<rule-id>.
export function detect(content) {
const hits = new Set();
for (const r of RULES) if (r.test(content)) hits.add(r.id);
return hits;
}
export function scoreCorpus(corpus, detectFn = detect) {
const bad = corpus.fixtures.filter((f) => !f.clean);
const controls = corpus.fixtures.filter((f) => f.clean);
const perFixture = corpus.fixtures.map((f) => {
const detected = [...detectFn(f.content)];
if (f.clean) return { id: f.id, clean: true, ok: detected.length === 0, detected };
const hit = (f.expect || []).some((e) => detected.includes(e));
return { id: f.id, clean: false, ok: hit, expected: f.expect, detected };
});
const hits = perFixture.filter((r) => !r.clean && r.ok).length;
const fp = perFixture.filter((r) => r.clean && !r.ok).length;
return {
detection: { hits, total: bad.length, rate: bad.length ? hits / bad.length : 0 },
discrimination: { clean: controls.length, falsePositives: fp },
perFixture,
};
}
function report(score) {
const d = score.detection, g = score.discrimination;
console.log(`\n detection: ${d.hits}/${d.total} seeded sinks caught (${(100 * d.rate).toFixed(1)}%)`);
console.log(` discrimination: ${g.clean - g.falsePositives}/${g.clean} safe controls clean (${g.falsePositives} false-positive)`);
for (const r of score.perFixture) {
const mark = r.ok ? '✅' : '❌';
const detail = r.clean ? (r.ok ? 'control clean' : `FALSE-POSITIVE: ${r.detected.join(',')}`)
: (r.ok ? `caught ${r.expected.join('/')}` : `MISSED ${r.expected.join('/')} (got: ${r.detected.join(',') || 'nothing'})`);
console.log(` ${mark} ${r.id.padEnd(22)} ${detail}`);
}
}
function runSelfTest() {
const corpus = loadCorpus();
const score = scoreCorpus(corpus);
let pass = 0, fail = 0;
const ok = (l, c) => (c ? (pass++, console.log(' ✅ ' + l)) : (fail++, console.log(' ❌ ' + l)));
console.log('=== Binary/RE decompiled-vuln — DETECTOR REGRESSION CHECK (seeded corpus, deterministic) ===');
ok('every seeded sink is detected (its expected rule fired)', score.detection.hits === score.detection.total);
ok('every safe control is clean (zero false-positives — snprintf/strncpy/fgets not flagged)', score.discrimination.falsePositives === 0);
ok('ruleset covers memory-safety + injection + integer-overflow classes', RULES.length >= 6);
ok('every seeded fixture declares ground truth', corpus.fixtures.every((f) => f.clean || (f.expect || []).length > 0));
report(score);
console.log('\n NOTE: seeded corpus — the ruleset was authored against these patterns, so a pass is a regression FLOOR (guards the detector from breaking), not a real-world detection rate.');
console.log(`\n${fail === 0 ? '✅ ALL PASS' : '❌ ' + fail + ' FAILED'} — ${pass}/${pass + fail}\n`);
process.exitCode = fail === 0 ? 0 : 1;
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) runSelfTest();