fold/tests/unit/test_pcode_summary.cpp
trvon 75f9d41210
feat: transitive write-loop overflow summaries (Part 2)
Propagate "writes-unbounded into pointer-param k" across call hops so a buffer
passed through a wrapper (2+ levels) is still flagged — no third-party call
graph needed; the raw P-Code stream already gives resolved call edges.

- pcode_summary: PointerWriteSummary gains forwarding edges — a Call that passes
  one of the function's own pure-input pointer params straight through to a
  callee argument (my_param → callee:arg), detected via in_def + paramIndexOf.
  Forwards are computed regardless of the loop/size-bound gates (a non-looping
  wrapper still forwards).
- pcode_router: expose annotateCallTargets(ssa, raw, names) — factor the
  raw→site→SSA name correlation so the summary pre-pass can resolve callee names
  for forwarding edges.
- binary_pcode_scan: the pre-pass now annotates each function's SSA call targets
  (via a raw lift) before summarizing, collects direct + forwarding summaries,
  then runs a worklist fixpoint: a function forwarding a pure-param pointer to an
  unbounded-writer parameter becomes an unbounded writer on that param. The
  closure feeds both the interprocedural detector and the write-loop prototype
  set (so multi-hop callers recover the buffer arg).

Validated: baby_bof (AArch64 + x86-64) still fires; corpus BOF counts unchanged
(3/1/1); the x86-64 static stays at 1 finding (the transitive closure adds no
FP). Unit: test_pcode_summary +forwarding cases (14 assertions). meson test 77/77.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:04:39 -04:00

151 lines
6.2 KiB
C++

// Unit tests for summarizePointerWrites — the pure interprocedural write-loop
// summary. Builds synthetic ir::pcode::Function inputs (no r2/Ghidra) and checks
// which pointer params are flagged as unbounded-written.
#include <catch2/catch_test_macros.hpp>
#include <vector>
#include "fold/ir/pcode.hpp"
#include "fold/symbolic/pcode_summary.hpp"
namespace {
using namespace fold::ir::pcode;
using fold::symbolic::summarizePointerWrites;
Varnode x(int n) { return {Space::Register, 0x4000u + 0x8u * static_cast<unsigned>(n), 8}; }
Varnode uniq(std::uint64_t off, std::uint32_t sz = 8) { return {Space::Unique, off, sz}; }
Varnode con(std::uint64_t v, std::uint32_t sz = 8) { return {Space::Constant, v, sz}; }
PcodeOp mk(Op op, Varnode out, std::vector<Varnode> in) {
PcodeOp p;
p.op = op;
p.out = out;
p.in = std::move(in);
return p;
}
PcodeOp mkNoOut(Op op, std::vector<Varnode> in) {
PcodeOp p;
p.op = op;
p.in = std::move(in);
return p;
}
// Loop marker: an SSA phi defining the index slot (uniq 0x10).
PcodeOp loopPhi() { return mk(Op::MultiEqual, uniq(0x10, 4), {con(0, 4), con(1, 4)}); }
bool marks(const Function& fn, int param) {
return summarizePointerWrites(fn).writesUnbounded(param);
}
} // namespace
TEST_CASE("summary: store through param1 + loop index is unbounded write", "[summary]") {
// out = INT_ADD(x1, idx) ; STORE [out], v with a loop present.
Function fn;
fn.name = "decoder";
fn.ops.push_back(loopPhi());
fn.ops.push_back(mk(Op::IntAdd, uniq(0x100), {x(1), uniq(0x10, 4)}));
fn.ops.push_back(mkNoOut(Op::Store, {con(1), uniq(0x100), con(0x41, 1)}));
REQUIRE(marks(fn, 1));
REQUIRE_FALSE(marks(fn, 0));
}
TEST_CASE("summary: address via CAST chain is still resolved", "[summary]") {
Function fn;
fn.name = "decoder_cast";
fn.ops.push_back(loopPhi());
fn.ops.push_back(mk(Op::IntAdd, uniq(0x100), {x(0), uniq(0x10, 4)}));
fn.ops.push_back(mk(Op::Cast, uniq(0x200), {uniq(0x100)}));
fn.ops.push_back(mkNoOut(Op::Store, {con(1), uniq(0x200), con(0x41, 1)}));
REQUIRE(marks(fn, 0));
}
TEST_CASE("summary: a constant-index store (param + const) is NOT flagged", "[summary]") {
// STORE [x0 + 8] is a single fixed write, not an unbounded fill — even with a
// loop present, a constant subscript is not a write-loop overflow.
Function fn;
fn.name = "fixed_field";
fn.ops.push_back(loopPhi());
fn.ops.push_back(mk(Op::IntAdd, uniq(0x100), {x(0), con(8)}));
fn.ops.push_back(mkNoOut(Op::Store, {con(1), uniq(0x100), con(0x41, 1)}));
REQUIRE(summarizePointerWrites(fn).empty());
}
TEST_CASE("summary: a scratch-reused (written) param register base is NOT flagged", "[summary]") {
// x0 is overwritten (used as scratch), so it is not a pure-input pointer —
// the store base must be the parameter as passed, not a recomputed value.
Function fn;
fn.name = "scratch_base";
fn.ops.push_back(loopPhi());
fn.ops.push_back(mk(Op::IntAdd, x(0), {uniq(0x300), con(1)})); // x0 written (scratch)
fn.ops.push_back(mk(Op::IntAdd, uniq(0x100), {x(0), uniq(0x10, 4)}));
fn.ops.push_back(mkNoOut(Op::Store, {con(1), uniq(0x100), con(0x41, 1)}));
REQUIRE(summarizePointerWrites(fn).empty());
}
TEST_CASE("summary: a straight-line store (no loop) is NOT flagged", "[summary]") {
Function fn;
fn.name = "no_loop";
fn.ops.push_back(mk(Op::IntAdd, uniq(0x100), {x(1), con(8)}));
fn.ops.push_back(mkNoOut(Op::Store, {con(1), uniq(0x100), con(0x41, 1)}));
REQUIRE(summarizePointerWrites(fn).empty());
}
TEST_CASE("summary: x86-64 SysV store through RSI param + scratch index is flagged", "[summary]") {
// Mirrors x86-64 -O0 lifting: the buffer pointer (output = RSI) is added to a
// scratch-reused index in RAX, and the store goes through RAX. Real SSA
// (in_def) distinguishes the RSI *input* from RAX the *scratch* def.
using fold::symbolic::ParamAbi;
const Varnode rax{Space::Register, 0x0, 8}; // scratch
const Varnode rsi{Space::Register, 0x30, 8}; // SysV arg1 (output param)
Function fn;
fn.name = "decode_x64";
fn.ops.push_back(loopPhi()); // op0: loop marker
PcodeOp sext = mk(Op::IntSext, rax, {uniq(0x10, 4)}); // op1: RAX = sext(idx)
sext.in_def = {-1};
fn.ops.push_back(sext);
PcodeOp add = mk(Op::IntAdd, uniq(0x100), {rax, rsi}); // op2: RAX_idx + RSI
add.in_def = {1, -1}; // RAX defined at op1; RSI is input
fn.ops.push_back(add);
PcodeOp st = mkNoOut(Op::Store, {con(1), uniq(0x100), con(0x41, 1)}); // op3
st.in_def = {-1, 2, -1};
fn.ops.push_back(st);
REQUIRE(summarizePointerWrites(fn, ParamAbi::SysVX64).writesUnbounded(1));
// With the AArch64 ABI, 0x30 is not an arg register → not flagged.
REQUIRE(summarizePointerWrites(fn, ParamAbi::AArch64).empty());
}
TEST_CASE("summary: forwarding a pure-param pointer to a callee is recorded", "[summary]") {
// W(p) { g(p); } — p (x0) passed straight through as g's arg0 → forward edge.
Function fn;
fn.name = "wrapper";
PcodeOp call;
call.op = Op::Call;
call.call_target = "g";
call.in = {con(0x2000, 8), x(0)}; // in[0] target, in[1] = our param0
fn.ops.push_back(call);
auto fwd = summarizePointerWrites(fn).forwards;
REQUIRE(fwd.size() == 1);
REQUIRE(fwd[0].my_param == 0);
REQUIRE(fwd[0].callee_arg == 0);
REQUIRE(fwd[0].callee == "g");
}
TEST_CASE("summary: passing a non-param (local) to a callee is not a forward", "[summary]") {
Function fn;
fn.name = "wrapper2";
PcodeOp call;
call.op = Op::Call;
call.call_target = "g";
call.in = {con(0x2000, 8), uniq(0x300)}; // a local, not a parameter
fn.ops.push_back(call);
REQUIRE(summarizePointerWrites(fn).forwards.empty());
}
TEST_CASE("summary: store through a non-param base is NOT flagged", "[summary]") {
// base is a local (uniq), not an argument register.
Function fn;
fn.name = "local_write";
fn.ops.push_back(loopPhi());
fn.ops.push_back(mk(Op::IntAdd, uniq(0x100), {uniq(0x300), uniq(0x10, 4)}));
fn.ops.push_back(mkNoOut(Op::Store, {con(1), uniq(0x100), con(0x41, 1)}));
REQUIRE(summarizePointerWrites(fn).empty());
}