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>
91 lines
2.8 KiB
C++
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());
|
|
}
|