fold/tests/unit/test_binary_uaf.cpp
trvon 5294e6bdf9
feat(binary): structural use-after-free / double-free over disassembly
Binary mode had no UAF/double-free detection. In abstract-block mode the Z3
encoder gives every call argument a fresh, unconstrained constant, so an
aliasing check (freed_ptr == deref_ptr) is trivially SAT for unrelated
pointers — it would fire on every post-free deref. A Z3-proven aliasing UAF
is therefore not viable until per-instruction value flow lands.

Instead detect it structurally over r2 per-instruction disassembly, keyed on
the *stack slot* a heap pointer lives in (r2 names it, e.g. `[ptr]`/`[dest]`)
— a reliable identity within one function, no aliasing needed:

  scanInstructionsForUaf (pure, unit-tested): a per-function state machine.
    ldr <reg>,[slot]  -> reg sources slot
    bl free           -> slot dangling (double free if already dangling)
    bl <other>        -> use-after-free if the freed slot's pointer is the arg
    str <alloc>,[slot]-> slot revived (free+realloc+free is legitimate)
  detectUseAfterFreeFromDisasm / uafFindingsInSession: r2 wrapper, run from
    R2Backend::runDetectors on the shared session (no extra `aaa`), default-on
    alongside the import/string detectors and under --detectors
    security/use-after-free/double-free. Findings carry Heuristic confidence.

Cost is bounded: skip entirely when the binary imports no free/delete (instant
on the common case) and cap the per-function pdfj sweep at 4000 functions.

Validated on bench/corpora/ctf-challenges/libuaf_doublefree.dylib: +3 TP
(double_free_bug CWE-415, use_after_free_bug + use_global_after_free CWE-416 —
all previously FN in binary mode), zero new findings across the other 9 corpus
dylibs. 5 r2-free unit tests (test_binary_uaf) cover double-free, UAF,
clean-free, free+realloc+free, and cross-slot negatives. 69 Ok / 0 Fail /
1 pre-existing binary_backend timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:49:10 -04:00

91 lines
2.8 KiB
C++

// Unit tests for the pure UAF/double-free disassembly state machine
// (scanInstructionsForUaf), exercised with synthetic ARM64 instruction
// sequences — no radare2 required. Dangling state is keyed on the stack slot
// a heap pointer lives in (r2 names it, e.g. `[ptr]`).
#include <catch2/catch_test_macros.hpp>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "fold/binary/r2_backend.hpp"
#include "fold/report/finding.hpp"
namespace {
using Op = std::pair<std::uint64_t, std::string>;
std::vector<Op> seq(std::initializer_list<std::string> lines) {
std::vector<Op> ops;
std::uint64_t addr = 0x1000;
for (const auto& l : lines)
ops.emplace_back(addr += 4, l);
return ops;
}
} // namespace
TEST_CASE("uaf: double free on the same stack slot is detected", "[binary][uaf]") {
// malloc -> store to ptr -> free(ptr) -> reload ptr -> free(ptr) again.
auto hazards = fold::binary::scanInstructionsForUaf(seq({
"mov x0, 0x40",
"bl sym.imp.malloc",
"str x0, [ptr]",
"ldr x0, [ptr]",
"bl sym.imp.free",
"ldr x0, [ptr]",
"bl sym.imp.free",
}));
REQUIRE(hazards.size() == 1);
REQUIRE(hazards[0].kind == fold::FindingKind::DoubleFree);
REQUIRE(hazards[0].slot == "ptr");
}
TEST_CASE("uaf: free then pass freed pointer to a call is use-after-free", "[binary][uaf]") {
auto hazards = fold::binary::scanInstructionsForUaf(seq({
"str x0, [dest]",
"ldr x0, [dest]",
"bl sym.imp.free",
"ldr x0, [dest]",
"bl sym.imp.__strcpy_chk",
}));
REQUIRE(hazards.size() == 1);
REQUIRE(hazards[0].kind == fold::FindingKind::UseAfterFree);
REQUIRE(hazards[0].slot == "dest");
REQUIRE(hazards[0].callee == "strcpy"); // _chk suffix normalized away
}
TEST_CASE("uaf: a single well-formed free is not flagged", "[binary][uaf]") {
auto hazards = fold::binary::scanInstructionsForUaf(seq({
"str x0, [ptr]",
"ldr x0, [ptr]",
"bl sym.imp.free",
"ret",
}));
REQUIRE(hazards.empty());
}
TEST_CASE("uaf: free + reallocate + free is legitimate (slot revived)", "[binary][uaf]") {
// Storing a fresh allocation back into the slot clears the dangling state,
// so the second free is on a live pointer.
auto hazards = fold::binary::scanInstructionsForUaf(seq({
"ldr x0, [ptr]",
"bl sym.imp.free",
"mov x0, 0x40",
"bl sym.imp.malloc",
"str x0, [ptr]",
"ldr x0, [ptr]",
"bl sym.imp.free",
}));
REQUIRE(hazards.empty());
}
TEST_CASE("uaf: freeing one slot does not taint a different slot", "[binary][uaf]") {
auto hazards = fold::binary::scanInstructionsForUaf(seq({
"ldr x0, [a]",
"bl sym.imp.free",
"ldr x0, [b]",
"bl sym.imp.strcpy",
}));
REQUIRE(hazards.empty());
}