libafl - #50
Merged
Merged
Conversation
The Python module is a binding, not a front-end -- a peer of the Rust
binding added next rather than something that belongs at the repo root.
Collect both under bindings/.
Two fixes carried along, since the file had to be touched anyway:
- install(TARGETS Fastgen ...) named the wrong target, one already
installed by backend/. The pysymsan module it was meant to install
was therefore never installed at all; it is now.
- the runtime include path gains a level, for the deeper directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The event loop -- read_event plus the cond/gep/memcmp/add_constraint
dispatch, with the gep label double-check and the memcmp size+label
validation -- was written out four times: fgtest.cpp, afltest.cpp,
aflpp/symsan.cpp, and again in the Python binding. __dfsan::get_label_info,
which i2s-solver.cpp needs at link time, was hand-defined twice, both
copies carrying a FIXME. Adding a Rust front-end would have made it five
and three.
Three new pieces, none of them AFL++- or LibAFL-specific:
symsan::TraceSession (include/session.h)
the one event pump. Owns the launch.h configuration and dispatches
to a handler interface. Absorbs the deadloop detection that only
aflpp/symsan.cpp had. Defines get_label_info once, here.
rgd::ConcolicSession (include/concolic.h)
the RGD driver policy lifted out of aflpp/symsan.cpp: trace, turn
branches into tasks, walk the i2s -> jigsaw -> z3 ladder. The
per-input branch filter and index filter come across verbatim.
report_result(bool) replaces the mutation state machine and the
filename comparison the mutator used to guess with -- a front-end
that runs its own evaluation can now say what actually happened,
which is what decides whether a task escalates or retires.
the C ABI (include/symsan_c.h)
two layers: L1 primitives mirroring the Python binding, and L2
sessions over ConcolicSession. Built as libsymsan_c.so.
launch.h gets an extern "C" guard and the two includes it was relying on
its includers for. afltest.cpp loses its get_label_info copy and links
symsan-session instead; that is its only change.
One session per process: launch.c keeps its configuration in a file
global. symsan_session_create reports SYMSAN_ERR_BUSY for a second one
rather than letting two quietly corrupt each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A peer of bindings/python: a binding, not a front-end, with no LibAFL
dependency. Anyone driving SymSan from their own harness wants this
crate and nothing above it.
build.rs runs bindgen over include/symsan_c.h and finds the install
prefix by trying b4, b3, b2, build under the repo root. SYMSAN_BUILD_DIR
overrides it, and is a hard error when set but wrong rather than a silent
fall-through to a different build than the one you meant.
Session is an RAII handle: it owns the C pointer and destroys it in Drop.
It is Send but deliberately not Sync, matching one-session-per-process.
Two FFI choices worth the comments they carry in the source:
- the C enums come back as bindgen newtype structs, not Rust enums. A
Rust enum holding a value outside its variants is instant UB, and a
value crossing FFI cannot be proven in range.
- next_solution returns an owned Vec. A borrow would conflict with the
report_result call that has to follow it immediately. next_solution_ref
is the zero-copy escape hatch, and its lifetime shows why the copy is
the default.
The integration test traces a target with two chained magic values and
checks the solutions against an uninstrumented build of the same source.
It is one #[test] on purpose: cargo runs a file's tests on several threads
of one process, and only one session can exist per process, so a second
session test needs a second file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the AFL++ custom mutator as the end-to-end driver.
A stage rather than a mutator, and that is the whole point. A mutator
never learns what became of the bytes it produced, so driver/aflpp
inferred it afterwards by comparing queue-entry filenames. A stage runs
the evaluation itself and feeds the real ExecuteInputResult back through
report_result, which is what decides whether a task escalates to the next
solver in the ladder or retires. Exact accounting instead of a guess.
perform() skips corpus entries it has already traced, traces the current
one, and hands each solution to Evaluator::evaluate_filtered. A trace
failure logs and returns Ok: a target that will not run under concolic
execution should not take the fuzzer down with it.
SymSanStage carries no type parameters. All four generics live on the
impl, which is legal because each appears in the trait -- so no
PhantomData and no turbofish at the call site.
The example is forkserver_simple plus the stage. It needs the target
built twice, once by afl-cc for coverage and once by ko-clang for
constraints, which is the same arrangement the mutator wanted through
$SYMSAN_TARGET. Its two match arms differ only in whether the stage is
present: tuple_list! builds a distinct type per arrangement of stages, so
an optional stage is two code paths, not an if.
Solving compounds. Constraints only exist for branches the target
actually executed, so a branch nested behind an unsolved one is invisible
until the outer one falls; the solved input enters the corpus, gets
traced, and exposes the next level. On the two-chained-magic-value test
target with an abort() on the inner path, 90 second runs:
with --symsan crash in 1s, 18 executions
havoc only nothing in 106,434 executions
Two chained 4-byte equalities is 2^64 of search space.
cargo stays out of the default build -- a C++ user needs no Rust
toolchain. -DSYMSAN_BUILD_RUST=ON adds a non-ALL symsan-rust target that
shells out to cargo, which already tracks its own dependency graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shim now: the afl_custom_* entry points, the environment parsing and the out_file setup stay, everything below them is gone. The event loop, handle_cond, handle_gep, the solver walk, the mutation state machine and the second copy of get_label_info were all lifted into driver/session in an earlier commit; this deletes the originals. 492 lines out, 87 in. Behaviour is meant to be unchanged. The one improvement that falls out for free: afl_custom_queue_new_entry used to work out whether a solution had been interesting by comparing queue filenames, which ConcolicSession now takes as an argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tracing an input meant a full execv every time: dynamic linking, the shadow and union table mappings, interceptor setup -- all repeated per input, none of it depending on the input. Spawn the target once and fork a child per trace instead. On bindings/rust/symsan/tests/data /branch.c that is 24.6 -> 16.4 ms per trace, about 8 ms of fixed process setup removed; the smaller the target, the larger the fraction. The fork point is not a free choice. It sits between InitializeInterceptors() and InitializeTaintFile() in dfsan.cpp, reached through a new weak hook InitializeSymSanForkServer() that backend/forkserver.cpp overrides -- everything above that line is input-independent and worth amortizing, everything below reads *this* run's input. (thoroupy can fork after its whole init only because its input arrives as a ticket payload rather than a file, so its design does not transfer.) Nothing needs resetting per fork: __dfsan_last_label and the hashtable arena are private and reset by COW, and the MAP_SHARED union table is a separate mapping indexed directly. The wire protocol is AFL's, unmodified, on fds 198/199, so a SymSan binary stays drivable by AFL++ or LibAFL's forkserver executor -- which is what a coverage backend will want. That does leave one wrinkle: the solver event pipe no longer reaches EOF between runs, because its write end lives in the server. The child's wait status on fd 199 marks the end of a trace instead, and since the server only writes it after waitpid(), every event that child produced is already in the pipe by then. So forksrv_read_event() selects on both fds, drains events first, and treats "pipe empty, status ready" as end of trace. An earlier attempt sent an explicit terminator down the event pipe; it was dropped because it would have forced the wire format into launch.c, which is plain C, and deviated from AFL's protocol for no gain. Neither of the two preconditions -- file input, and a backend that has a server -- is an error: launch.c falls back to exec'ing per run, after rebuilding TAINT_OPTIONS with forksrv=0 so the fallback child does not try to be a server itself. Turning it on is therefore always safe, and SymSanStageBuilder defaults it on; --symsan-no-forkserver is the way out for a target that keeps state across main() a fork would wrongly share. tests/forkserver.rs traces one seed four times and checks the answer and the server pid do not drift, that exactly one child is alive between traces (zero would mean the exec fallback ran and the test was vacuous), and that dropping the session takes the server with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SymSan and the fuzzer watch the same execution but name branches
differently -- SymSan by source location, AFL++ by a sequential edge id --
so neither can use the other's knowledge. EdgeCovManager was handed the
branch id and threw it away, keying on the return address instead, which
means every freshly started session re-solves branches the fuzzer covered
long ago.
The join key both toolchains can compute is the branch's source location.
A patched AFL++ (patches/aflpp-document-ids.patch) makes
AFL_LLVM_DOCUMENT_IDS emit `dir=` and `src=file:line:col` alongside each
edge id, which is enough to build a cid -> [edge id] table:
- include/branch_id.h is the one definition of the key, so the hash
cannot drift between the instrumentation and the loader.
- getInstructionId() now hashes the DILocation's own filename rather
than Mod->getSourceFileName(). That is the only key that survives
inlining, and therefore the only one the link-time instrumentation can
compute for the same branch. Every cid value changes; they are
process-local, so nothing breaks, but old logs will not compare.
- BranchMap loads the file; SharedMapCovManager answers "is this branch
direction interesting?" from a snapshot of the fuzzer's history map
instead of only from what this process has seen. One source branch can
map to several edge ids after inlining, so an uncovered copy is still
worth solving for -- the test is "any", not "all".
- Where the map says nothing this degrades exactly to EdgeCovManager, so
a partial map costs opportunities, not correctness.
Plumbed through SYMSAN_BRANCH_MAP, the C ABI
(symsan_session_set_coverage), the Rust binding, the LibAFL stage (which
reads MaxMapFeedback's history map before each trace) and the bundled
fuzzer's --branch-map.
mapped_branches/unmapped_branches in the stats are the join rate, which is
the diagnostic for whether the two builds agree on branch names at all.
Three things keep a correct map partial, all documented in
patches/README.md: AFL++ prunes blocks that dominate their successors,
switch cases share one cid, and the two clangs must agree on columns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mapped_branches/unmapped_branches say how *much* of the join lands, but structurally cannot say whether it lands in the right place. A map that resolved every branch to some other branch's edge id reports a perfect ratio while telling the stage everything is already covered: nothing errors, the fuzzer just quietly finds less. Three ways to catch that, all built on the same one-directional invariant -- every branch direction SymSan executed must map to an edge the fuzzer's build recorded. The converse says nothing, since the fuzzer records concrete branches, switch cases and plain blocks that never reach a concolic trace. 1. Static, nothing executed. SYMSAN_DOCUMENT_IDS is the mirror image of AFL++'s AFL_LLVM_DOCUMENT_IDS: one `cid=`/`kind=`/`src=` line per instrumented branch, same shape. Diffing the src= columns separates "the two clangs disagree about the column" (join is dead) from "AFL++ pruned the block" (expected). kind= labels switch and select, which can never join, so they are not read as breakage. 2. One input, offline. driver/covcheck.cpp traces an input and holds every direction it took against an afl-showmap run of the fuzzer's build. tests/branch_map_join.c is that as a lit test, including the negative half -- the same run against a sed-corrupted map must come out INCONSISTENT, or a vacuous check would look identical to a passing one. Gated on a new `aflpp` lit feature so trees without AFL++ skip it. 3. Every entry, during a real run. --validate-branch-map audits each traced corpus entry and logs loudly on a contradiction. The ground truth is free: the fuzzer's map feedback is built with track_indices(), so every testcase already carries the edge set its own execution produced, and nothing is re-run. The recording is a hash insert per branch, so it is behind SYMSAN_VALIDATE_COV / Config::validate_coverage and off by default. It lives in add_branch() rather than is_branch_interesting(), which is handed the *negated* context -- add_branch is the only place the direction actually taken is in hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lit tests/ ran everything in one directory, so a test that needs a built fuzzer toolchain sat next to one that needs nothing outside this tree. CI runs "lit tests" on every pull request, and the only thing keeping a fuzzer out of the merge gate was that the two AFL++ tests happen to report UNSUPPORTED there -- an accident of what the workflow installs, not a boundary anyone declared. Declare it. tests/symsan is symbolic execution and solving and depends on nothing external; CI now names that suite. tests/fuzzing is integration with a real fuzzer, where every test must announce what it needs with REQUIRES: and is skipped where that is absent. tests/ucsan is reserved for the UCSan tests, which are not part of this tree yet and are not lit tests at all -- they explore paths under their own Python harness -- so its lit.local.cfg empties config.suffixes and lit walks past the directory. One lit.cfg still covers all of it (lit recurses), and tests/CMakeLists.txt creates the per-suite directories in the build tree because lit walks *up* from the path it is given to find lit.site.cfg. Per-test output therefore moves from b4/tests/Output/ to b4/tests/<suite>/Output/. No test changes beyond cross-references to moved files. Discovery is unchanged: 115 in tests/symsan, 2 in tests/fuzzing, same 117 as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13ce36e and 3e40e84 gave SymSan and the fuzzer a shared branch namespace, but left switches out, and the comments claimed they could never join. That was half wrong. AFL++ does give every case destination its own edge id -- SplitAllCriticalEdges has already made the switch block its predecessor. What stopped the join was that visitSwitchInst hashed the *switch* once and handed that one cid to every per-case __taint_trace_cmp, so a case arrived at on_cond as (switch cid, result), which cannot name a case; and the AFL++ patch only decorated a block whose predecessor ends in a conditional branch. A case has no source location either side can name -- SymSan holds only the SwitchInst, AFL++ only the case block, whose DebugLoc is the body rather than the label. The one thing both hold is the switch's location plus the case's value, so switch_case_cid() continues the switch's djb hash over the eight little-endian bytes of the value. Both sides normalise with zextOrTrunc(64), matching the CreateZExtOrTrunc the IR already does. It is integer-only and byte-order independent so the taint runtime can compute it without std::string. The runtime keeps forwarding whatever cid it is handed; __taint_trace_cmp stashes the case value alongside the label, and __taint_trace_switch_end recomputes switch_case_cid() to validate. That is strictly stronger than the old cid equality -- it checks the value and the switch identity together -- and costs one hash per executed symbolic switch rather than one per case. Deriving the id inside __taint_trace_cmp was the obvious alternative and is a trap: its second, currently #if 0'd caller passes a runtime c2, which would make the id namespace unbounded. No signatures change, so dfsan.cpp's weak defaults are untouched. Only dir=1 exists for a case, and that is the direction that matters: on_cond asks is_branch_interesting() about the *negated* direction, so for a case the trace did not take, the question is "would taking it be interesting?". The converse ("go anywhere but this case") is not one edge and stays unmapped, falling back to local-only behaviour. Two cases sharing a destination share an edge id and get one map line each, which is right: either case reaching it is the same edge. select is still not joined, but that is a missing AFL++ hunk rather than a limit of the approach -- it already has two edge ids and a branch_cid. The comments now say that instead of claiming it is impossible. tests/fuzzing/branch_map_switch.c cannot pass vacuously: it corrupts only the lines carrying a case and requires the verdict to flip to INCONSISTENT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every str*cmp wrapper tagged its comparison fstrcmp. Only solvers/z3-ts.cpp
knows that op; parsers/rgd-parser.cpp -- the parser behind afltest, the AFL++
mutator and libafl-symsan -- has no case for it, so the label fell through to
the OP_MAP lookup and was rejected with "invalid op: 85". The task was never
built, and i2s, jigsaw and the RGD z3 never saw the constraint. The whole
family was unsolvable for every fuzzer we ship, including the common case that
is plainly just a fixed-width byte comparison.
__dfsw_memcmp and __dfsw_bcmp already had the rule: string theory is for
operands that are themselves string terms (a substring, a strchr position);
everything else is an fmemcmp. str_cmp_op() applies the same test to the str
family.
Tagging alone is not enough, because an fmemcmp's operands have to be exactly
as wide as it says. get_str_label() measures with strlen(), which is wider
than the compared length for an unterminated buffer and wider again for a
string whose content label strdup stashed whole. Both mismatches reach the
solvers: z3 rejects the term ("Sorts (_ BitVec 56) and (_ BitVec 176) are
incompatible") and jigsaw's JIT emits "icmp eq i64 %x, i512 %y" and dies in
the IR verifier, taking the fuzzer with it. So str_cmp_operands() re-reads
exactly n bytes out of the shadow, and keeps the old fstrcmp labels if the
read somehow does not line up. For the unbounded strcmp/strcasecmp, n itself
was wrong for a byte comparison: str_cmp_len() takes the concrete side's
length plus its NUL, which is what "equal as strings" means and what keeps the
constant content inside the literal.
tests/symsan/strcmp_rgd.c covers both halves on the RGD path, since the
%fgtest suite cannot see either bug. With this, symsan-fuzz solves
fuzzer-challenges' test-strcmp -- all five string checks -- in 1.6s and 296
executions, where it used to run out the clock at 60s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing default The ladder had two knobs for three solvers. i2s was hardcoded first and unconditional -- "always use the simpler i2s solver", in four separate places -- so there was no way to ask what jigsaw or z3 could do on their own, which is exactly the question when deciding what a rung is worth. So i2s becomes a knob like the others. It stays on by default everywhere: it is nearly free, the other two assume it ran, and turning it off is a measurement, not a configuration. That is why its C spellings are negative (SYMSAN_NO_I2S, symsan_rgd_options_t::no_i2s) -- a zeroed options struct and a NULL pointer both have to keep meaning "i2s only", the way they did before. The default that does change is Z3's, and only in SymSanStageBuilder, i.e. only for the LibAFL fuzzer. Fuzzing is a throughput game and Z3 is the one rung that can block for seconds on a single task; the extra solves do not pay for the exec's they cost. It is a default and not a judgement -- --symsan-z3 turns it back on for a target whose checks jigsaw's descent cannot climb. The C++ and Config defaults are untouched: they already started from i2s alone and added to it, which is what the older drivers expect. symsan-fuzz grows --symsan-no-i2s / --symsan-no-jigsaw / --symsan-z3 and prints the ladder it ended up with, since an A/B between two ladders is otherwise two logs that look identical. With every rung off it says so: tracing still runs and still costs what tracing costs, but nothing can be solved, and the symptom (no inputs, ever) says nothing about the cause. tests/symsan/solver_ladder.c pins the plumbing rather than the solving. Every rung cracks a 4-byte magic compare, so a broken knob looks like a working one unless something checks the run where nothing should come out -- and the knob crosses ConcolicConfig, symsan_config_t and the Rust Config, where a break turns "z3 is off by default" into "everything is off by default" silently. Verified: lit tests/symsan 108/117 (the 9 failures are the untracked scratch files, unchanged); cargo test --workspace and clippy clean; the fuzzer-challenges sweep still 11 pass / 2 fail (transform, u128) with Z3 now off, i.e. Z3 was not what was solving them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Does concolic execution find things?" is the wrong question; "does it find things the cheap technique does not?" is the right one, and until now there was nothing to ask it against but plain havoc. --cmplog wires up LibAFL's own cmplog pipeline against a third build of the target (AFL_LLVM_CMPLOG=1): ColorizationStage to find which input bytes each comparison depends on, AflppCmplogTracingStage to log both operands, and AflppRedQueen to splice the wanted operand into place. It is LibAFL's implementation rather than a hand-rolled one so that the baseline is a cmplog somebody else tuned, and it runs inside the same fuzzer as --symsan so that nothing outside the stage list differs between the arms -- comparing against afl-fuzz -c would have made the scheduler, mutator and feedback variables too. The two flags are independent, which is four arms in one binary. That needed OptionalStage: tuple_list! bakes the arrangement of stages into a type, so two optional stages would otherwise be four fuzz_loop calls. Measured over the fuzzer-challenges targets, 60s cap, targets built with the suite's own -O0 -fno-inline -fno-builtin, 6 repetitions of each arm (3 for i2s-alone and real AFL++). Solved, out of 14 -- or out of 13 for the symsan arms, which have no build for test-longdouble: havoc alone 0/14 --cmplog 6/14 (identical set every rep) afl-fuzz -l 3ATX -Z -c 9/14 (8-10, the control) --symsan, i2s rung only 10/13 --symsan, full ladder 10/13 (same set) both 10/13 (same set) Two targets defeat cmplog for reasons in the instrumentation, and real AFL++ misses both as well, so this is the technique and not a weak baseline. test-u8 is twelve chained one-byte comparisons, and the cmplog pass drops every compare narrower than 16 bits on the assumption havoc covers them (cmplog-instructions-pass.cc: `if (!max_size || max_size < 16) continue`) -- havoc does not cover twelve in a row. test-extint compares 24/40/56-bit values, which the same pass widens to 32/64 before logging, so the operand it reports is not a byte string present in the input and RedQueen has nothing to match. Where the two arms overlap -- the six targets both solve -- SymSan's i2s rung is faster, not just broader: 3.5x less wall clock and 5.9x fewer executions, geometric mean, after subtracting the 1.25s that a crash costs on this machine regardless of who found it (apport handles the abort()). The execution ratio understates it, because colorization runs up to 2*input_len executions per corpus entry through `executor.run_target` directly, which state.executions() never counts. Honest remainders. This baseline is 3-4 solves behind real AFL++ (it misses u16, u32-cmp, u128 and longdouble that AFL++ gets); AFL++'s extreme-transform pass has no AflppRedQueen equivalent, which is the leading suspect but is not yet confirmed. Nothing solves test-transform. test-u128 is unsupported by SymSan. test-crc32 is solved by neither at -O0, though SymSan did solve it when the targets were built -O3, which is unexplained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A concolic trace already knows which input offsets each branch depends on -- RGDAstParser::scan_labels fills branch_to_inputs as a linear prefix, whether or not the branch becomes a SearchTask -- but nothing outside the session could ask. Expose it as one byte per input offset: 0 untainted, 1 open, 2 settled. The rule is coverage, not solve outcome: a byte is settled when every branch target that depends on it has been reached, whether SymSan solved it, a later trace took it the other way, or the fuzzer's own map says so. Keying on "did we build a task for it" would get all three of on_cond's non-solving exits wrong -- throttled, already-covered, parse failure. The classification is decided per data_flow_deps group, so a coupled four-byte integer is never half frozen. ConcolicSession records a TracedBranch at the top of on_cond, before the throttle, and on_gep does the same for a symbolic index; the two add_task sites link task to branch, and report_result(true) marks the target flipped. The context has to be copied -- add_branch hands back a single reusable _ctx. All of it is behind ConcolicConfig::export_taint, off by default, so on_cond is byte-for-byte what it was with the flag clear. Asking again at export time rather than reusing what on_cond computed is what makes the second case above free, but is_branch_interesting bumps the mapped_/unmapped_ census as it answers. Hence is_target_uncovered: the same question without the counters. EdgeCovManager's override also has to tolerate an address add_branch never saw, since on_gep records targets it does not register -- that path could deref end(). Carried up through symsan_session_input_taint (export_taint appended at the end of symsan_config_t, as the header requires) to Session::input_taint. Three integration tests, one per way the rule can be got wrong: a settled byte with no solving in that trace, a throttled branch whose bytes must stay open and tainted at all, and the fuzzer's coverage map settling a byte on its own (that one skips without a patched AFL++). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With --symsan and --cmplog both on, the two ran the same input-to-state technique over the same corpus entry, neither aware of the other -- and SymSan got there first, having proved which bytes reach which branch while it traced. Per entry cmplog was paying 1 + 2*input_len executions in ColorizationStage to rediscover a dependency map SymSan was already holding, and then feeding RedQueen comparisons SymSan had already cracked. SymSanStage now publishes a SymSanTaintMetadata per traced entry and two things consume it. SymSanColorizationStage builds the colorized input directly -- randomize the open and untainted bytes, keep the settled ones -- and verifies it with two executions, the original and the candidate against the same map hash; a mismatch records a fallback and the stock stage runs behind it, unchanged. An entry with no open byte left skips the whole cmplog group. Freezing a byte is the entire mechanism and needs no change to RedQueen: its integer arms act only when the operand moved between the two runs, so a byte held still drops out of every comparison it feeds. Two things that therefore cannot be filtered, both documented in the README: the Bytes/RTN arm has no such guard, and LibAFL has no U8 arm at all. The metadata lives on the testcase, not on the state. SymSanStage traces on the first scheduling and the cmplog group gates on the second, so a single state-wide slot is overwritten by a later entry's trace before the stage that wants it ever looks -- which it was, silently, until the smoke test showed the colorization stage never doing any work. Ranges rather than a per-byte flag keeps the per-entry cost negligible. On by default when both binaries are given; --no-symsan-cmplog-filter turns it off, and --cmplog alone is the stock pipeline byte for byte, which is what keeps the baseline arm of the measurement honest. Measured on fuzzer-challenges (-t 5000, -O0 -fno-inline -fno-builtin): both holds at 10/13 with the filter on, and 15 reps per arm put executions-to-crash at 0.95x of the unfiltered run (p=0.13) -- no regression, and the one target trending the other way is test-strcmp, which is the RTN case above. tests/fuzzing/cmplog_filter.c drives the whole thing end to end; lit.cfg gains the two features it needs, aflpp-cmplog separately from aflpp because the LTO driver cannot build a cmplog binary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two ways combineShadows could emit something the rest of the stack cannot honour, both reached by fuzzer-challenges targets. The `size > 64` filter read the width of the *result*, which for an icmp is i1, so it never fired on a comparison. A 128-bit icmp then had its operands CreateZExtOrTrunc'd down to 64 bits while the label went on recording size = 128 -- a formula that does not describe the program, which the solver models happily and answers wrongly. Take the operand width for a CmpInst before the filter, and drop the shadow when it is too wide; the same ceiling now applies in TaintFunction::visitCmpInst, which had no guard at all. The floating-point chain handled half/float/double and fell through to the same cast for everything else, so x86_fp80 produced `trunc x86_fp80 to i64` and clang-18 died with "Cannot select: i64 = truncate (f80 load)" -- the test-longdouble build did not compile at all. Return a zero shadow instead: imprecise, sound, and it builds. Neither target becomes solvable here; they become honestly unsolved, and cmplog is free to have a go at the compare itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test-transform stalled at offset 24 with the strncmp there reaching the session with shadow 0, so the branch was dropped and the check looked unsolvable. Three separate things were losing it. __dfsw_tolower/__dfsw_toupper built the mask node as dfsan_union(0, c_label, Or, 8, 0x20, 0) -- declared width 8 over an operand that is 32, because tolower takes an int and the caller promotes the byte first. Peel the promotion, apply the mask at the operand's own width, and re-wrap: the common `c = tolower(c)` then truncates on the store, the existing Trunc(ZExt(x)) -> x rule folds the pair, and the shadow left at the byte is an exactly 8-bit node -- the width cmp_label_fits needs to build an fmemcmp rather than degrade to fstrcmp. __dfsw_strncmp measured its operands with get_str_label(), which uses strlen() and so runs past the n bytes the comparison is about: 41 bytes for a 5-byte strncmp here, off the end of what the harness had written. Bound it the way strncmp itself is bounded. That overrun reached the alloca's never-written tail, where __taint_union_load returns kInitializingLabel. That value is deliberate for instrumented loads, but the custom wrappers pass what dfsan_read_label gives them straight to dfsan_get_label_info, whose dfsan_check_label reports "out of labels" and Die()s -- losing the whole trace from there on, over an unwritten buffer tail. Normalise it to 0 at that entry point. test-transform now gets past offset 24 and stops at 30, which is the hex[]/dehex[] constant-table lookup -- a different problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EdgeCovManager keys its record on the branch address and keeps it for the life of the session, so a branch inside a loop is solvable exactly twice -- once per direction, ever. fgtest never sees this, having a fresh process per input, but a fuzzer holds one session for its whole run, and that is where fuzzer-challenges' test-crc32 sat: 0 solves in 15 runs at a 60s cap. SharedMapCovManager already existed to ask the fuzzer's question instead, but ended in `locally_new && host_says_new`, so the stale session-lifetime record could still veto the map -- and it could only ever say "solved once already". When the map can name the branch, the map decides; the local record stays as the fallback for branches it cannot name, which is the documented degrade path. The other half is that `host_[e] == 0` is not the question MaxMapFeedback asks. LibAFL classifies edge counts into AFL's buckets before comparing against the history map, so the second traversal of an edge is a different observation from the first -- which is the whole reason a loop body is worth solving more than once. Track hits per branch per trace, and test the class the flipped edge would land in. It is an estimate: what the fuzzer records depends on the whole rewritten trace. It errs towards solving, which is the right way to err for a stage already gated to once per corpus entry and bounded by max_local_branch_counter. None of it fires unless a map is actually loaded, and nothing said whether one was. Auto-detect <executable>.map next to the coverage binary, and print which manager is in play either way -- a sweep that accidentally measured EdgeCovManager was indistinguishable from one that did not. test-crc32 goes 0/15 to 15/15, in under 2s. Across the ten targets that already passed, executions-to-crash moves 5% (geometric mean, n=15 per arm, ranges overlapping in every case). input_taint_map.rs phase A flips from Settled to Open for the same reason the rest of this works: the map now answers for a branch it can name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…me from
The AFL++ id-documentation patch asked only the *immediate* predecessor for
the conditional branch, on the reasoning that SplitAllCriticalEdges() has
already run. True as far as it goes, but pruning drops a block that fully
dominates its successors, so wherever one side of an `if` opens with a
straight-line chain -- a `while (1) { ...; return; }` bail macro, a loop body
falling into its latch -- the block that survives to be instrumented sits one
or more *unconditional* hops below the branch. Most edges on a real target
came out undocumented.
Walk those hops back up. Exact rather than heuristic: each step requires the
child to be its parent's only successor and the parent to be the child's only
predecessor, so the two blocks run under precisely the same condition and the
edge means the same thing at either end. Bounded at 16 because an
unconditional cycle is representable in IR, if not reachable.
Documented edges roughly double on fuzzer-challenges -- test-crc32 8 to 16,
test-transform 17 to 30 -- and the recovered ones are the branches inside
loops, which is exactly where a coverage-sharing consumer needs an answer.
test-crc32's `if (*p32 != crc)`, the check the whole target turns on, had no
mapped edge at all before this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A load from a static global carried label 0, so a hex-encoding loop produced
a fully concrete buffer and __dfsw_strncmp took its "both operands concrete"
early return -- the session never learned the check existed. This is what
stalls AFL's test-transform at offset 30.
Label the loaded value instead of inferring a relation between the index and
the compared bytes. A new dfsan op, tlookup, is emitted at the load when the
GEP's underlying object is a global that GlobalStatus reports as NotStored
(not isConstant(): at -O0 GlobalOpt never runs, and the tables that matter are
plain `static uint8_t`). The table's bytes ride along on a new table_msg, once
per distinct table per trace, because the solver runs in the fuzzer process and
cannot read the target's memory. Everything downstream is existing machinery:
tmp[] gets a real shadow, propagates through shadow memory for free, and the
strncmp becomes an ordinary memcmp constraint.
Scope is i2s. jigsaw and z3 decline rgd::TLookup rather than model it -- no
symbolic arrays, no ite chains over table entries -- following the precedent
set by the FP transcendentals. Both decline paths already existed; only
z3-solver.cpp gains an explicit case, so the decline reads as a decision in a
log rather than a parser bug.
i2s inverts a tlookup by scanning the shipped table for the wanted output and
driving the index expression to its position, trying each match in turn since
tables are routinely non-injective. Reaching the second loop in test-transform
needed more than the new op, so the integer path is generalized alongside it:
- a recursive integer evaluator, and recursion into nested binops instead of
inspecting only the comparison's direct children;
- bit-preserving inversion. A shift, mask or remainder determines only part
of its operand, and the rest is now carried over from the operand's current
value. Without this, `x >> 4` and `x % 16` write conflicting values for
the same input byte and destroy each other;
- operand value-domain enumeration. A tlookup can only produce what its
table holds, and that small set propagates through width casts and
constant-operand binops. This is what splits (dehex[a] << 4) + dehex[b]
== 0xab, where the left side is always a multiple of 16 and the right
always 0..15, so neither reaches the target from the value it happens to
hold.
Pinning an operand is a guess, so every candidate is re-evaluated over the
whole node before it is accepted, matching the discipline already used on the
FP side. A step budget bounds the search: enumerating a domain multiplies the
branching factor at every level, and i2s is the cheap path.
Measured on test-transform: offset 30 now clears in a single concolic step.
The offset-38 decode also solves exactly -- the run produces "464F4F4F", which
dehexes to "FOOO" -- but is rejected one check later by the surrounding ishex
guard, which accepts only lowercase. dehex holds 15 at both 'F' and 'f' and
i2s takes the first; choosing correctly needs the guards in the same task and
jointly satisfied, which is a pre-existing i2s limitation orthogonal to this
change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These were the only tests left using %brabander-clang, BRABANDER_OPTIONS and %run-all, so they had been sitting untracked and failing. None of them actually needed a fuzzer: the branch bucketing they were written for lives in solvers/z3.cpp (__solved_labels, MAX_BRANCH_COUNT) and KO_USE_Z3=1 reaches it directly. Running each program under both %fgtest, which has no such filter, and the in-process z3 runtime, which does, is what makes the counts a statement about the policy rather than about the program. bucket2.c -> context2.c two calls to one function in one trace bucket3.c -> branch_filter_count.c 512 distinct hits -> 512 vs 32 answers bucket4.c -> branch_filter_label.c 1024 identical hits -> 1024 vs 1 answer bucket.c is dropped rather than ported. Its bitmap_file=/session_id= suppression needed a real coverage map, that option is gone, and bindings/rust/symsan/tests/loop_coverage.rs already covers SharedMapCovManager; its other two assertions duplicate tests we already have. crc.c switches to CRC-CCITT and a 16-byte message. CRC-32 sets REFLECT_DATA, clang turns reflect(x, 8) into @llvm.bitreverse.i8 at -O3, and TaintPass has no case for that intrinsic -- so the message went concrete and the test passed in 0.04s having checked nothing. CCITT does not reflect, so the bit-serial CRC stays symbolic end to end. reflect()'s 1 << n is fixed to 1UL << n (UB for the header's WIDTH of 64) and crc.h now only defaults to CRC32 if no variant is already selected. Optimization level is load-bearing in both directions and is commented where it matters: loop.c and branch_filter_label.c need KO_DONT_OPTIMIZE=1 or the branch under test is vectorized/hoisted away, while logical.c must not have it -- its eight-term conjunction short-circuits at -O0 and only the -O3 branchless fold makes Good7 reachable. logical.c and crc.c replay generated inputs as a set rather than pinning ids, since the two arms disagree on both count and ordering. tests/symsan: 124/124. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TaintPass handled llvm.bswap and nothing else in that family, so a bitreverse dropped its shadow and everything downstream went silently concrete. clang's idiom recognizer emits @llvm.bitreverse.i8 for the reflection loop of every reflected CRC (CRC-32, CRC-16), so this took the whole message out of the session -- no crash, no warning, just branches nobody sees. tests/symsan/crc.c had to use CRC-CCITT to avoid it; its note is updated. Modelled as a dfsan op of its own rather than a decomposition: bswap's Extract/Concat expansion costs 2W-1 union-table entries per dynamic execution, which is 127 for an i64 and is paid once per message byte. Each solver expands it in whatever form suits it -- z3 (both stacks) as a concat of single-bit extracts, jigsaw as the native LLVM intrinsic, i2s by reversing the wanted value, which is exact because bit reversal is its own inverse. That involution is also a trap for i2s's value-matching heuristic: a palindromic byte (0x24, 0x18, ... sixteen of them) reverses to itself, so the compared value equals the input byte it came from, the match fires as though the side were a plain Read, and the replacement gets written unreversed -- a confident wrong answer. i2s now declines the plain value match on a reversed side and lets it fall through to the AST walk, which pushes the target down through the reversal and then verifies. Found by a randomized replay sweep; 0 spurious outputs over 2638 generated inputs afterwards (120 seeds x i2s / jigsaw / RGD-z3). tests/symsan/bitreverse.c covers all four widths on all five solver configurations, and is written against unsigned inequalities on purpose -- instcombine folds `icmp eq (bitreverse X), C` into a compare on X, so the equality form never reaches TaintPass and would assert nothing. tests/symsan/bitreverse_idiom.c reaches the same op the way real code does, through the hand-written reflection loop. Both fail with the TaintPass case removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_i2s_value picks the value to write into an input chunk so that the
comparison holds. Its rhs==true case means the input is op1 and v is op2,
so the returned value must satisfy (returned <comp> v) -- but the Ult/Slt
and Ugt/Sgt bodies were swapped. Ult returned v+1 for the op1 side, which
cannot be less than v.
All four strict predicates were wrong in both directions, so try_i2s has
never once snapped a strict integer inequality: every candidate it produced
was thrown away by the try_new_i2s_value gate, which re-runs the JIT'd
function and requires distance 0. That gate is why this was silent rather
than unsound -- a wrong guess is rejected, never acted on -- and why the
only symptom was i2s declining a class of comparison it can invert exactly.
Equal and the non-strict predicates were always correct.
Soundness and solve rate measured with smttest against z3 (every sat model
re-checked by /usr/local/bin/z3):
3389 QF_BV benchmarks containing bvult/bvslt/bvugt/bvsgt
before 184 sat / 0 unsound, after 184 sat / 0 unsound
4000 QF_BV/sage benchmarks, JIGSAW_SEED pinned (the restart PRNG is
non-deterministic otherwise, and an unseeded pair showed a spurious +10)
seed 11: 1005 / 1005 seed 22: 992 / 992
So no movement on SMT-LIB: the snap needs the raw input bytes to appear as
a recorded compare operand, and where that holds in these corpora gradient
descent already gets there. It matters for shapes GD cannot descend --
see the bitreverse commit that follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
try_i2s matches a candidate's raw input bytes against the recorded compare operands, with a byte-swapped variant for endianness. Bit reversal was missing, so a chunk that reaches the comparison through @llvm.bitreverse -- what clang's idiom recognizer emits for the reflect() loop of every reflected CRC -- never matched either operand, and the pass declined on a shape it can invert exactly (reversal is an involution, so the value to write back is just the reversal of the value the comparison wants). Gated on the constraint containing a BitReverse. Each encoding costs a JIT invocation to verify and this is the third, so tasks without the op pay nothing; SMT-LIB has no bit-reversal primitive, so smttest never sets the bit either. Sound by construction, like the two encodings beside it: try_new_i2s_value re-runs the JIT'd fn and requires distance 0, so a mismatched window is rejected rather than written. That gate is also why jigsaw was never exposed to the palindrome trap that i2s-solver.cpp needed a guard for in 08cec67 -- 0x24 reverses to itself, the snap looks right, and the JIT rejects it anyway. Also reset input/input_r per candidate. They are accumulated with |= and were declared once outside the loop, so a second candidate saw the first one's bits folded in, and input_r was read uninitialized on the first pass. Only ever a missed snap, never a wrong one, for the same reason. bitreverse.c now holds jigsaw to the same eight guards as the other four solver arms. It needs BOTH this and the get_i2s_value fix in 5abb684 to reach Good32/Good64: this supplies the match, that supplies a value on the correct side of the bound. Each alone still misses them, which is what the i32/i64 inequalities regression-test. 129/129 lit. smttest vs z3 unchanged from 5abb684 (3389 strict-inequality QF_BV and 4000 QF_BV/sage at two pinned seeds, identical sat counts to baseline, every model z3-validated, 0 unsound) -- that covers the accumulator reset, which is live for every constraint, but not the probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
combineShadows dropped the shadow of anything wider than 64 bits, so a target
doing `(unsigned __int128)a * b >> 64` lost the whole downstream path with no
diagnostic -- the shape at the core of every modern 64-bit hash and of
__builtin_mul_overflow on uint64_t. Raise the ceiling to 128.
A concrete wide operand cannot ride in the label's op1/op2 slots, which are 64
bits each and would silently truncate. It travels as a leaf label instead:
(l1=0, l2=0, op=WideConst, size=W, op1=lo64, op2=hi64). union_util's operator==
compares both slots, so dedup is exact -- one union-table entry per distinct
constant for the life of the trace, and no pipe message, parser cache or
per-trace state, unlike the memcmp and lookup-table channels.
Which operands are concrete is a runtime question. Deciding it in the
instrumentation would mean matching a ConstantInt, and at -O0 a literal
__int128 is not one: clang keeps it in an alloca, so `a == 0x0123...` reaches
the icmp as two loads. That shape would have worked only in optimized builds,
which is backwards for a fuzzing target. So __taint_get_wide normalizes one
operand at a time -- symbolic keeps its label, concrete gets a WideConst leaf --
and the result feeds the ordinary __taint_union. Both sides tainted costs two
calls that return their argument and no table entry.
Two hazards fixed, both of which produce wrong answers rather than declines:
- jit.cc ZExtOrTrunc'd both comparison operands to i64 so get_distance could
work in 64 bits. On a 128-bit compare that truncates, and dis == 0 on the
low half is a false SAT. Jigsaw now declines wide comparisons; jit-solver
returns SOLVER_TIMEOUT, so the chain still advances to z3.
- the ubsan overflow modeling computes `(1UL << size) - 1`, which at size 128
is UB and yields mask 0 on x86, making every derived overflow ICmp garbage
-- and it goes straight to __taint_trace_cond as a real constraint. Gated
to size <= 64 for now.
The traced op1/op2 values are now partial for a wide op, so the consumers that
read them as the true value are gated: FILTER_WRONG_AST skips wide nodes rather
than comparing truncated ones (truncation is consistent for add/sub/mul/and/or/
xor/shl but not for udiv/srem/lshr/ashr/icmp), and i2s declines them. i2s does
still solve a wide equality, via solve_memcmp_ast -- a byte-pattern matcher that
never reads the traced value and assembles the wanted bytes from consecutive
input_args slots, which is the form a WideConst already lands in. That needed a
wide-Read base case in i2s_walk_wide, since a contiguous 16-byte read parses to
one 128-bit Read rather than a Concat spine.
x86_fp80 stays declined, now explicitly and with a test pinning it. It has an
explicit significand bit, so z3's (_ FloatingPoint 15 64) is 79 bits wide and
mk_from_ieee_bv would be a wrong reinterpretation; x87 also produces unnormals
and pseudo-NaNs with no counterpart in that sort. Jigsaw carries every FP value
as a double bit pattern in a uint64 slot, so 80 bits does not round-trip either.
Planned as two commits, landed as one: stage 1 (raise the ceiling) on its own
truncates concrete wide operands into op1/op2, which is unsound, and stage 2 is
what closes it. Splitting them would have made the first commit wrong.
Verified: lit 131/131; smttest over 4939 files across 9 wide-heavy QF_BV
families with every model revalidated against z3, 0 unsound; fuzzer-challenges
12/14 in all three modes, with test-u128 going from 0/15 reps to a median of 45
executions (n=15) and test-crc32/test-u64 unmoved. The two remaining failures
are test-longdouble, which is the fp80 decline above, and test-transform, which
is unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With SYMSAN_USE_NESTED set, parse_cond builds two tasks per DNF clause: the last branch's constraints alone, and those plus every constraint sharing their input bytes. The second one exists precisely because its constraints must hold together -- but i2s's solve loop does the opposite, stacking one rewrite per constraint and reporting SAT if any single one matched. On a nested task that means confidently emitting a buffer in which the last rewrite has stomped the earlier ones. Hand it to z3, which walks the base_task chain and solves the conjunction properly. SOLVER_TIMEOUT rather than SOLVER_UNSAT, since UNSAT sets skip_next and drops the task outright in ConcolicSession::next_solution, denying z3 the shot this decline exists to give it. Inert at present -- nested_solving stays off by default, so every task i2s sees has a null base_task -- but the wrong answer it prevents is silent, and the flag is one env var away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A decode table is non-injective -- dehex maps both 'F' and 'f' onto 15 --
so inverting one is ambiguous, and i2s kept the first entry that inverted.
That loses hex decodes: 'F' comes first, and a guard gating the decode on
[0-9a-f] then rejects the answer one branch *earlier* than the branch
being solved for. The session reports SAT, the replay never arrives, and
the target reads as structurally unsolvable.
Nothing about the index expression separates the two candidates -- both
are one byte write away, and the obvious metrics over the table pick the
wrong one ('F' at 0x46 is nearer '0' than 'f' at 0x66 is). The evidence
is in the *input*, not the table. i2s_table_pref ranks every inverting
candidate: free, then agrees in character class with the byte already at
that offset in in_buf, then lowercase-when-there-is-no-evidence, then
anything.
This is AFL++'s rule, reached from the same problem: its redqueen
transforms keep hex_table_up and hex_table_low and choose between them
with from_up/to_up, inferred from the case of the hex digits already
present, defaulting to the low table when the input shows no case at all
(afl-fuzz-redqueen.c). Digits count as no evidence, which is the tier
that actually fires in practice, since guard-passing inputs arrive full
of '0'.
Solving decode and guard together would instead need both constraints in
one task -- nested solving, far too slow to leave on. This is the cheap
approximation, and where its guess is wrong the last tier still yields
whatever the plain scan used to.
Solves fuzzer-challenges' test-transform, which nothing had cracked: no
symsan mode, not LibAFL cmplog, not afl-fuzz -l 3ATX -Z. The fc sweep
goes 12/2 -> 13/1 in all three modes (~3-5 s), the remaining failure
being the deliberate fp80 decline. lit tests/symsan/ is 132/132.
table_lookup_case.c pins it by replay rather than by the existence of a
solution file, since the uppercase answer also produces one; degrading
i2s_table_pref to a constant reproduces the old "EF" and fails the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`long double` comparisons were dropped at the FP type filter in
combineShadows, so fuzzer-challenges' test-longdouble was unsolvable by
any mode. Admit x86_fp80 for COMPARISONS only, and let i2s solve them.
The split is permanent and is about format, not effort. z3's
(_ FloatingPoint 15 64) has an *implicit* integer significand bit and is
79 bits wide; x86_fp80 has an *explicit* one and is 80, so mk_from_ieee_bv
between them is a reinterpretation rather than a conversion -- and x87
additionally produces unnormals, pseudo-denormals and pseudo-NaNs with no
counterpart in that sort. jigsaw carries every FP value as a bit pattern
in a uint64 argument slot, which 80 bits does not fit. i2s is the
exception because it evaluates in C++ on x86, where `long double` IS
x86_fp80: decode is a memcpy and every compare, step and re-encode runs
on the hardware that produced the value, so there is no model to be wrong
about. Guarded on __LDBL_MANT_DIG__ == 64, the actual requirement.
Transport reuses the existing WideConst two-slot path (lo = the whole
significand, hi = sign+exponent). `bitcast x86_fp80 to i80` selects fine;
the pre-existing "cannot select" comment was about a direct
`trunc x86_fp80 to i64`, which is an FP-to-int op, not a width problem.
sizeof(long double) is 16 but only ten bytes are value, and solve_fcmp80
writes exactly those ten, leaving the six padding bytes alone.
Two silent-wrong-answer traps, reachable only once fp80 nodes exist:
- jigsaw's fp_type() was `bits == 32 ? float : double`, so it answered
"double" for 80 and emitted `bitcast i80 -> double`. It now throws
for any width but 32/64, stating jigsaw's format support in one
place instead of leaving it to a width check elsewhere -- that check
has already moved twice and stopped covering fp80 the second time.
- that invalid IR was JITed anyway, because verifyFunction's return
value was being discarded. A bad JIT is a wrong answer, not a
missing one, so a failed verify now declines the task.
Verified: fuzzer-challenges 13/1 -> 14/0 in all three modes
(test-longdouble ~2s, ~1.2k execs); lit tests/symsan 133/133; neither new
decline fires across 4500 QF_BV/QF_FP/QF_BVFP files, so both are inert on
legitimate formulas; SMT soundness re-run with the seed pinned is clean
(QF_BV 154 sat / 153 z3-validated, QF_FP 366 / 366, zero issues).
fp80_i2s.c covers the two shapes test-longdouble uses -- a two-sided
range and an exact equality against a promoted double literal -- and
verifies by replay rather than by a solution file existing. fp80_decline.c
keeps the z3 and jigsaw arms only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things symsan-fuzz was leaving to the caller that afl-fuzz does itself. MAP_SIZE was a 65536 constant, but the map size is a property of the build, not of the fuzzer. A target instrumented with more edges than that does not fail loudly: afl-cc's runtime reports its real size in the forkserver handshake and LibAFL refuses to start, or, under AFL_LLVM_MAP_DYNAMIC, the extra edges fold into the map we allocated and coverage quietly gets coarser. Read AFL_MAP_SIZE -- the name every AFL-family tool already uses, so a harness that sets it for afl-fuzz needs no second knob. Unset, unparseable or zero keep the old default. The second is worse because it looks like a target bug. afl-cc bakes ##SIG_AFL_PERSISTENT## / ##SIG_AFL_DEFER_FORKSRV## into a binary that can do either, but the markers only say what it *can* do; the runtime decides from __AFL_PERSISTENT / __AFL_DEFER_FORKSRV in the environment, and afl-fuzz is what scans and setenvs them. Without the deferred variable the forkserver starts from a constructor before main, so each forked child asks "am I under AFL?" after the fork, gets no, reads argv[1] as a corpus file and exits without calling LLVMFuzzerTestOneInput. Handshake succeeds, execs are counted, nothing warns -- and every map comes back empty, which the fuzzer reports as an uninstrumented target. Scan for the markers, and honour the same two environment overrides for a stripped binary. The cmplog binary is scanned separately since it is a separate build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(#115) dfsan_init reserves [UnusedAddr(), AppAddr()) with MAP_FIXED so no application allocation can come back without shadow behind it. The guard in front of it only asked whether *our own text* was in that range, which for a non-PIE target linked at 0x700000200000 is permanently no. What moves is the kernel's mmap base: TASK_SIZE less the stack rlimit, the guard gap and the 16GB stack-randomization pad, less a draw uniform over 16TB (vm.mmap_rnd_bits=32). The bottom 16GB of that range fall below AppAddr(), so 2^-10 of execs put ld.so inside the region, and MAP_FIXED unmapped it out from under the running _dl_init. Measured 3/3000 on a real target, matching the 0.13% of traced runs that read zero events -- which we had been attributing to the event transport, and which the shm ring (#138) therefore neither caused nor cured. Ask with MAP_FIXED_NOREPLACE and let the kernel refuse rather than clobber; on a refusal, re-exec with ADDR_NO_RANDOMIZE, which pins the mmap base at the top of the address space and survives execve, so the flag is its own loop guard (sanitizer_linux.cpp:2177 does the same for ppc64le). If it is still occupied after that, say so and name the stack rlimit -- an unlimited stack flips the kernel to the bottom-up layout at TASK_SIZE/3, below AppAddr() and growing toward it, which no personality bit can move. launch.c sets the same personality between fork() and execv() at both spawn sites. Every front-end that traces a target goes through it -- fgtest/afltest directly, driver/aflpp via rgd::ConcolicSession, symsan-fuzz via libsymsan_c -- so the runtime's recovery path stays unexercised on the paths we control, and traced runs get a reproducible address space for free. fgtest names how the child ended when a run delivers no events. A run that traced nothing and a run that died before it could trace are otherwise identical -- zero of everything, exit 0 -- and this is what turned "zero events" into "Segmentation fault" and made the bug findable. tests/symsan/launcher_aslr.c pins the launcher behaviour: AT_BASE moves across four direct runs of the same binary and does not move across the launcher's two spawn paths, always above AppAddr(). The direct-run control is what keeps it from passing vacuously; it degrades to a named skip where system ASLR is off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`union_hashtable __union_table(1<<20)` is a static object with a nontrivial
constructor, so it gets an .init_array entry. .init_array runs after all of
.preinit_array -- and dfsan_init, which is where the fork server's fork point
sits, is in .preinit_array. So the 8MB of buckets were allocated and zeroed
separately in every forked child, on the wrong side of the fork point that
exists to make exactly this kind of setup a once-per-campaign cost.
Default-construct it into .bss instead and give it an explicit init(), called
from dfsan_init right after allocator_init -- above the fork point, next to the
coverage map and the event ring, for the same reason.
Over 200 fork-server runs of a small target:
minor faults sys wall
before 441,527 0.74s 6.42s
after 38,176 0.20s 5.70s
403k faults, 2017 a run, which is 8MB/4KB.
The memset stays, and not only out of caution. The buckets really are already
zero -- allocator_alloc is a bump pointer over an anonymous kernel-zeroed arena
-- but dropping it measured *worse* in fork-server mode: 2149 extra faults over
the same 200 runs, because pre-faulting in the parent means a child inherits
present PTEs and does not fault on the bucket pages it merely reads. Only the
exec-per-run fallback pays per run for it (3.7ms an exec, 6%), and a campaign
does not take that path. reset() is split out for a persistent mode that does
not exist yet; nothing but init() calls it today.
A relapse here is silent -- the runtime still works, it is just slower per
child -- so runtime_no_static_ctors.c asserts an instrumented binary carries no
.init_array entry from the runtime, with upstream's vendored
sanitizer_common_libcdep.cpp both excluded and used as the proof that the check
is looking at a binary that links us at all. It reports the old constructor
when pointed at a pre-fix binary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ld against the branch Two things find_roots and parse_cond were getting wrong about rgd::Bool, plus the oracle change that makes both checkable. The leak (#130). Four arms of the bool-icmp case in find_roots turned `bool == false` / `bool != true` into an LNot over their child without looking at what the child was. If it had already folded to rgd::Bool, nothing above can read the result: to_nnf takes the Bool for a leaf, does not recognize the kind, and refuses the whole condition with "unexpected node kind". Every other arm -- Not, And, Or, Xor -- already collapses a Bool child, so these were the only sites leaking one. negate_bool_node() folds instead of wrapping. Latent on a real target, since the shape needs a constant operand to a boolean connective, which __taint_union folds away before a label exists; concretization is the way it can actually arrive. The policy (also #130). construct_task silently dropped a conjunct that would not parse, handing out a task *weaker* than the clause -- solutions to it need not flip the branch. That is a reasonable thing to do for a fuzzer and a bad thing to do quietly, so it is now named: strict_clauses_ picks between dropping the clause (exact, loses solutions) and keeping the weakened task (default, what the fuzzer wants), and weakened_clauses() counts the latter. On libpng it stays 0 -- all 28 conjunct skips are in clauses that lost every conjunct -- but "solved and never flipped" should not read as a solver failure the next time it isn't. The guard. parse_cond folds a condition to a constant and returns no task. Nothing checked *which* constant. Every route there ends in eval_icmp over the concrete operand values the runtime traced, so the folded value has to equal `result`, the direction the branch actually took; when it doesn't, either the shadow is stale or a fold rule is wrong, and both were silent because the refusal is the same either way. One comparison on a path that already returns early, named "cond folded against the branch". On the libpng corpus it fires on 317 conditions, 279 of which z3 also refuses as a stale shadow, and 38 of which z3 recomputes and accepts -- #142. Note this is the soundness guard for the path and the z3 comparison is not: RGD folding something z3 keeps is RGD giving up, and z3 folding something RGD keeps is a task a solver merely fails to satisfy. Neither can produce a wrong answer. normtest could not see a wrong root fold before -- its own comment said so, since parse_cond never said which constant. It can now, via the two reason strings, so the fold arm asserts the folded value against the truth table rather than only asserting constancy. The matrix already enumerates both truth values of `result` per formula, which makes the contradicting half free coverage of the guard: fold cases checked go from 187 to 374 on the --const-pct configs. 157/157 lit, unsound=0 lossy=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…se the AST cap Everything parse_stats counts is an aggregate -- by reason, by call site, by branch cid -- and none of those names a condition. A cid is one branch run thousands of times, and two histograms can agree on totals while disagreeing about every branch underneath. So an RGD-vs-z3 comparison off PARSE-REASON counts cannot answer "if RGD folded this to a constant, did z3 fold it too": z3 folds ~9x more conditions on libpng, and the totals leave open whether RGD's are a subset. SYMSAN_DUMP_CONDS=<file> writes the outcome per (input, label). The label is the runtime's index into that run's union table and the table resets per input, so replaying a seed through the same binary gives the same label to the same condition in whichever driver reads the trace -- which makes the two dumps joinable rather than merely comparable. tools/ cond-diff.py does the join, and checks the ordered label sequence per input first: if the two arms disagree about the n-th label the two traces were not the same trace, which is a much sharper denominator check than matching conds=. Off by default -- 4.5M lines on the libpng corpus. Loop-exit events are logged too, so the sequences line up event for event. SYMSAN_MAX_AST_SIZE=N on afltest is the other half. "cond folded to constant" conflates a condition the program made constant with one whose operands were concretized because the AST exceeded the cap -- both reach the same set_kind(rgd::Bool) and are indistinguishable downstream. Raising the cap and re-running separates them: whatever stops folding was never constant, only large. On libpng that bucket is 22,043 events at 200 and 120 at 5e5, for +4% parse time. There is deliberately no "unlimited" spelling -- ast_size_cache counts the AST as a tree, which on a libpng loop is billions of nodes, and the arena reserve throws. Also reports cond_weakened per input, from the counter the previous commit added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
is_branch_interesting() runs while the trace is still arriving, so every
task in a batch is judged against coverage from before the entry was
traced. But next_solution() hands solutions over one at a time and the
front-end runs each before asking for the next, so by the time task N
reaches a solver the fuzzer has already covered whatever tasks 1..N-1
reached. The parse-time filter cannot see that, and it was solving for
those targets anyway.
Two halves:
- SharedMapCovManager holds a borrowed `const uint8_t *` instead of a
std::vector copy, published through the new set_coverage_shared()
(C++ / C ABI / Rust). set_coverage() keeps copying for callers with
no buffer to lend -- a corpus sweep, a test. The LibAFL stage
re-publishes after every solution, which is a safety requirement and
not just a freshness one: MapFeedback::is_interesting can realloc
history_map, and comparing (addr, len) is two loads against the 8 KB
memcpy it replaces.
- next_pending_task() re-asks is_target_uncovered() about each task
just before handing it to a solver, and skips it if the answer
changed. Deliberately the same question and not a coarser one: the
covered-*bit* test would be easy here and would refuse every loop
branch after the first, because hit-count classes are what make
iteration k a distinct target from k-1.
note_branch() now records the task-to-branch link unconditionally. It
was export_taint's, but next_pending_task() needs it, and so did
report_result()'s `flipped` and --flip-log's branch name -- both of which
were silently empty without the export. Only the dependency scan stays
behind the flag. Stats gain stale_tasks, and the LibAFL stage publishes
`symsan_tasks: N queued, M solved, K dropped stale` as a UserStats field,
because a container campaign forwards no RUST_LOG and log::debug! is
unreachable from outside the process.
No off switch: measured on libpng (3x200s per arm, release, --flip-log)
the gate drops 2.1% of tasks for flat-to-slightly-up coverage and corpus
(+0.6% edges, +2.4% corpus, inside the noise) at 9% fewer executions.
It does not move the flip log's `already-covered` share, and it should
not: that is 47% either way, and it belongs to ~19 hot loop branch ids
that absorb 67-71% of all solutions and produce 30 of ~410 flips. Those
are legitimately uncovered targets by the class-faithful test. Refusing
them needs per-(cid, direction) productivity back-off, which is #135.
Closes #123.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
campaign.sh ships the working tree into the image, writes captain's rc
and runs it; results.sh summarizes a finished run; extract-corpus.sh
recovers the corpus from the ball. The fuzzer scripts live in the magma
checkout, but everything that decides *what to measure* lives here, next
to the code being measured -- an arm is then a flag rather than an
edited rc, and the image stamp refuses --no-rebuild against a build the
flags no longer describe.
--program restricts the schedule to one program of a multi-program
target, via the ${FUZZER}_${TARGET}_PROGRAMS name run.sh:291 actually
reads. It does not restrict the *build*: instrument.sh reads configrc
and instruments every program, each with its own .bmap, so --branch-map
is fine on such a target and only the document-ids file is ambiguous
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kernel only compares RLIMIT_CORE against the dump size when core_pattern names a file. Ubuntu ships |/usr/share/apport/apport, and for a pipe do_coredump skips that check entirely -- so the two sites that set rlim_cur = rlim_max = 0 to "disable core dumps" were dumping. 1 is the value do_coredump treats as abort-the-core for a pipe. It does not look like a crash, which is why it survived this long. A SymSan-linked process has ~114 TB of shadow VMAs and do_coredump walks them at 100% system time with SIGKILL blocked while PF_DUMPCORE is set, so the launcher's own timeout kill cannot reclaim it. What you see is a fuzzer that stops emitting stats, one core pinned, memory climbing to tens of GB, and nothing killable -- a hang plus a leak. With 1 the same libxml2 configuration ran at 1.6k exec/s immediately. grep CoreDumping /proc/<pid>/status is the tell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A front-end built with no solvers at all -- which is what a measurement arm that wants tracing without solving looks like -- reached solvers_[solver_index_] and segfaulted on the first task. Guard it by draining rather than by returning early: task_mgr_ belongs to the session and outlives the call, so leaving its queue full would carry the batch into the next input. Popping straight off the manager also keeps the tasks out of stale_tasks, which counts tasks a coverage change made pointless, not tasks nobody asked to solve. Closes #162. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
branch_to_inputs held one dense input_size_-bit dynamic_bitset per label, so parser memory was labels x input size -- the product, not either factor. On a 23 KB libxml2 seed that measured 4.1 GB peak, and 26.1 GB on the same seed padded to 95 KB, which is what was driving the fuzzer into swap on a campaign. A label now holds a uint32_t pool index and the pool holds each distinct set once; dep_pool[0] is always empty. The sets repeat heavily -- a hot loop re-traced tens of thousands of times yields the same set every iteration, and every unary op and every op with a constant operand has exactly its child's set. It is not a space-for-time trade, it is strictly faster, because of a second bug it removes. boost::dynamic_bitset's move ctor is not noexcept, so vector::_M_realloc_insert routes through __uninitialized_move_if_noexcept_a -> __do_uninit_copy and copy-constructs every bitset on every growth, a fresh heap buffer each: 5.9% of the run in memmove plus ~6% in tcmalloc. A vector<uint32_t> has nothing to deep-copy. What makes interning cheap enough to win is that the memos are keyed on how an entry is derived, never on its contents -- hashing a 3 KB bitset per label would cost more than the union it replaces. Three of them: single byte (a flat vector, there are at most input_size_), contiguous Load range (idx<<32|len), and union of two pool entries (lo<<32|hi). Before any memo, three O(1) answers cover the common case: a==b, a==0, b==0. No copy-on-write is needed because the only post-construction mutation is the whole-entry reassignment at the two concretize sites. Measured, same host, i2s+jigsaw, libxml2 seeds: seed bytes before after factor intsubset2.xml 11187 0.45 s / 482 MB 0.34 s / 377 MB 1.3x/1.3x xsdtest.xml 16510 1.54 s / 1561 MB 0.69 s / 444 MB 2.2x/3.5x good.xml 23765 4.85 s / 4097 MB 2.73 s / 935 MB 1.8x/4.4x testsuite.xml 25223 2.21 s / 2231 MB 1.06 s / 647 MB 2.1x/3.5x pad2.xml 47530 7.83 s / 7162 MB 3.41 s / 1380 MB 2.3x/5.2x pad4.xml 95060 21.97 s / 26143 MB 5.80 s / 2698 MB 3.8x/9.7x 157/157 lit tests pass, but the suite's seeds are tiny and would not catch a dependency-set difference, so the check that mattered was SYMSAN_PARSE_ONLY=1 SYMSAN_DUMP_CONDS on good.xml: 676,361 condition lines byte-for-byte identical between the two builds. Residual: a pool entry is still input_size_ bits, so memory still grows with seed size (935 MB -> 2698 MB for 4x the bytes at 1.7x the conditions). Sparse entries would flatten that and are not needed at current seed sizes. Closes #163. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a campaign question needs that afltest could not express. --target-args "<args>" passes an argument vector to the traced program, whitespace-split, with @@ standing for the input path and appended if the string does not contain it. Without this, reproducing a campaign's tracing outside the fuzzer means guessing at flags the harness passed: xmllint's schedule is not xmllint <file>. SYMSAN_ONLY_CIDS=a,b,c restricts solving to a set of branch cids, which is how you profile or bisect one branch out of a trace with millions of conditions. The filter is applied after retrieve_task rather than before, so the parser's tables grow exactly as they would in a full run and the numbers stay comparable -- filtering earlier would measure a different parse. The solving-label AOUT line gains the cid, so a trace can be joined against the branch map and against the fuzzer's coverage without re-deriving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--arm cmplog built the SymSan target and merely turned off i2s and jigsaw, so the tracing, the event ring and the parser all still ran and every task was built and then thrown away. Measured on xmllint that is 15 exec/s against 278 for the real thing, with 249k tasks built and 0 solved -- an arm that answers "what does SymSan cost when it does no work", which is not a question anyone asked. It is now USE_SYMSAN=0: a plain AFL++ build with cmplog and havoc and no SymSan in the process at all. The branch-map and solve-ub guards extend to it, since neither means anything without the instrumentation. Read the comparison the other way round now that the arm is honest: at 10 minutes on xmllint the baseline slightly beats `both`, which is the number the hybrid has to overcome rather than a bug in the arm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e (#135) uncovered() compared `have` -- the fuzzer's history hit-count class for the edge a solution would flip *to* -- against `want`, derived from trace_hits_[cid], the number of times this trace took the *branch*. Outside a loop those are the same number. Inside one they are unrelated, and the comparison is unsatisfiable by construction: a `for` header reached 200 times gives want = class(200) = 128, while the loop-exit edge is taken exactly once however the input is rewritten, so have = 1. `have < want` then holds at every iteration, on this trace and on every later one, and no solution can close it because the quantity it demands is not one a solution produces. Measured on libpng, ~19 branch ids absorbed 67-71% of all solutions for 30 of ~410 flips. trace_hits_ is now keyed on (cid, direction) and `want` is count_class(taken + 1): one more traversal of the direction we are solving toward, which is what flipping would actually produce. A side never taken still gives class(1), so a branch outside a loop is unchanged. A side taken 31 times gives class(32) = 64 against have = 32, so the traversal that crosses into a new class is still solved; taken 50 gives class(51) = 64 = have and is refused, because a 51st traversal is not new coverage. Measured on libxml2/xmllint, 3x10m: the self-loop cids that dominated before -- 8248, 27990, 6826, 8247, 10874, 10747, 10756, 10401, each ~1.5-2.5k solutions and each ~99.8% already-covered -- are queued zero times after. Tasks queued rise 64%, which is the criterion moving from "is the edge new" to "is the hit count new" and is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… solutions (#164) A 10-minute libxml2/xmllint campaign produced 155k solutions, and 94% of every execution in the run was one of them being replayed. Two causes, both of them the stage answering a question it had already answered. report_result(false) left the task in MUTATION_IN_VALIDATION so that next_solution() handed the same task to the next solver. "Not interesting" conflates two situations: the branch did not flip, where another solver's assignment might do better, and the branch flipped onto ground that turned out to be boring, where every remaining solver will flip the same branch to the same edge and be boring in the same way. Measured, the ladder returned 1.70 answers per task. report_result now takes a TargetOutcome; Reached retires the task even when the solution was uninteresting, NotReached and Unknown escalate as before. The LibAFL stage already computed the distinction in judge_flip and was throwing it away. Unknown is the default and is what the C ABI's one-argument form reports, so no existing caller changes behaviour. Reached also marks the traced branch flipped, which it did not before: a solution that reached its target onto covered ground used to leave the branch's input bytes open for the rest of the session. solved_branches still counts only interesting solutions, so it stays a measure of what the stage contributes rather than of what it retired. The stage also keeps a BloomInputFilter over the bytes it has handed over and skips a repeat. This is the stage's own filter rather than the fuzzer's, which StdFuzzer::new leaves as NopInputFilter: a filtered evaluate_filtered returns (None, None), indistinguishable at the call site from "ran and was boring", so every duplicate would be logged as a missed flip and reported as a failed solve. A skipped solution gets the new Flip::Duplicate class, so the flip log stays a census of solutions rather than of executions -- they are no longer the same count -- and the monitor line grows a `duplicate` field. --symsan-no-dedup turns it off for measurement. Measured on libxml2/xmllint, 3x10m, against the same commit without these changes: 53% of all solutions were byte-identical repeats, the `already-covered` class falls from 54.5% of solutions to 9 of 181,906, and the stage executes 35% fewer solutions for 5% more edges, 34% more corpus and a 65% higher flip yield per solution run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A concolic trace sees the concrete side of every comparison the target made against its input, including the ones no solver is asked about, and none of it reached the fuzzer. symsan::TokenCollector walks the raw union table -- deliberately not the parser, since a condition parse_cond() refuses is exactly the one whose constants are worth having -- and hands the byte strings to LibAFL's Tokens via --symsan-tokens. afltest gets --dump-tokens for sweeping a corpus offline. Three sources. The comparand a mem*/str* wrapper already ships verbatim; the byte chain, single-byte equalities at consecutive offsets assembled in trace order; and the constant of an integer comparison whose symbolic side is a byte image. The chain is the one an AFL++ LTO autodict cannot have: `RAW == '<' && NXT(1) == '!'` puts no string in the binary, and libxml2 spells its whole syntax that way. The image rule is what separates a dictionary from noise. `ntohl(tag) == 0x49484452` and `pos + 4 == len` are indistinguishable from the constant and distinguishable from the AST, so image_width() admits only loads, casts, Extract and Concat -- which covers bswap, since the runtime decomposes it into Extracts and Concats. On the 1254-seed libxml2 corpus it takes the collected set from 845 tokens to 292, and 8-byte tokens from 383 to 11; the 650 it drops are buffer positions, lengths and counters that no byte string in a file can match. Scans are cut out of a run rather than rejecting the run: three of the same byte in a row ends a segment, which recovers the "://" in front of a scan for '%'. Measured, that subsumes the majority-vote rule this started with, which fires 0 times on the corpus once the cut is in front of it. Also fixes the ordering of end_input() in both drivers -- it ran before the target rather than after, so the byte-comparison run in flight when the target exited was flushed one input late, and the last input's was lost entirely. Numbers, and the comparison against the autodict (32 shared, 260 new, and neither has much of the other's length range), in docs/dictionary-from-traces.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A task's link to its target was a SearchTask* -> index map in the session,
alongside a vector the session cleared at the top of every trace. So a task
could not be solved outside the trace that produced it: it lost its target --
and next_pending_task() reads a missing target as "solve it", so the loss was
silent, quietly bypassing the staleness re-ask that #123, #135 and #164 were
all about. That is the prerequisite for scheduling tasks against a budget
(#169): once the drain no longer runs to exhaustion, leftovers outlive their
trace by construction.
The target is now a shared_ptr<TaskTarget> on the task, shared between the
sibling tasks built for one branch -- which is what still makes `flipped`
retire the siblings queued behind a solved one -- and it is the same object
the session records for input_taint(). Two things fall out:
- It is a ContextAwareBranchContext, so the call-stack hash the runtime has
always shipped in pipe_msg.context is finally kept. Both cov managers
took the parameter and dropped it on the floor. Nothing reads it yet;
Marco's bp = (addr, ctx) is why it is being kept.
- A branch whose dependency scan bailed now gets a target too, instead of
falling through the "nobody said" hole above.
The target is built past the local branch counter, where the separate neg_ctx
used to be, and hung off the trace record from there. Measured on xmllint
with SYMSAN_DUMP_CONDS: the counter drops 84% of conditions on nvdcve_0.xml
and 97.9% on docbook_0.xml, so building one per *traced* branch instead --
which the first version of this did -- is 1.27M extra allocations on a single
trace, on the hottest path in the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A SearchTask is "these constraints over *these* bytes": its Reads are offsets into one particular input. The solver was handed whatever the session had traced most recently instead. While the queue was drained to exhaustion inside the trace that filled it those were the same buffer and nothing could tell them apart -- but #169 is about a queue that outlives its seed, and then they are not. Solving offset 3 of another file is not a worse answer to the same question, it is an answer to a different one. So SearchTask grows an `input`, set where `target` already was, and the session's own copy becomes a shared_ptr that trace() *replaces* rather than assigns over -- the old bytes stay alive exactly as long as some queued task still refers to them. One copy of the seed per traced entry, not per task. A caller that solves straight through (afltest, fgtest) leaves it null and passes its own buffer, which is the fallback next_solution() keeps. tests/task_input.rs is the control: two seeds of different length and filler, traced without draining in between, then drained. taint_loop.c never reads past its eighth byte, so the untouched tail of a solution says which seed built it. Before this, all 48 solutions wore the second seed's shape; the test reports that count rather than just failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The queue outlives the trace that filled it (031ceab), so under a budget its order decides which tasks are solved at all rather than merely when. Two managers now, chosen by SYMSAN_TASK_PRIORITY, both bounded by SYMSAN_MAX_TASKS (0 = unbounded, the old behaviour): FIFOTaskManager arrival order, refuses a newcomer when full PriorityTaskManager best score first, evicts the worst it holds Both drop the newcomer rather than the head -- the head is the next task out, so a bound that discarded it would lose precisely the work the queue was about to do -- and PriorityTaskManager breaks ties oldest-first. With a constant score the two are therefore the same queue, eviction included, which is what makes the A/B a one-variable comparison. The score is a grade, not a bool. is_target_uncovered() cannot rank anything here: a task is only ever built for a target is_branch_interesting() already said yes about (every ordinary condition sets F_ADD_CONS, so on_cond builds none otherwise), so every task in the queue would score the same. CovManager::target_novelty() answers the same question at the granularity the fuzzer's history map actually has -- it stores hit-count classes, not bits -- as kTargetCovered / kTargetNewClass / kTargetNewEdge, read from the same have/want that uncovered() already computes. It is defaulted to the two-valued answer, so a manager that only knows whether it has seen a direction needs no override. trace() now returns tasks *accepted*, counted on the session rather than derived from the queue's length: a bounded manager can evict more than the trace queued, and a negative return there is this function's error code. driver/queuetest.cpp + tests/symsan/task_queue.test pin both halves, because neither can be reached from a real trace: every task a real trace produces scores the same, and reaching kTargetNewClass means arranging a history map, a loop depth and a branch map at once. 33 checks; deleting the score comparison fails 5 of them and collapsing the two "new" grades fails 1, so it is not vacuous. Nothing changes by default -- unbounded FIFO is still what you get. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stage builds its Config directly, so SYMSAN_MAX_TASKS and SYMSAN_TASK_PRIORITY never reached it -- the knobs existed and the fuzzer could not turn them. --symsan-max-tasks and --symsan-task-priority now do, and the monitor's symsan_tasks line carries an eviction count once a bound is set (and only then; without one it is 0 forever). This is what makes the FIFO/priority A/B runnable at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The A/B cannot be read without this. A priority queue whose tasks all score the same *is* a FIFO, so a campaign that measures no difference has either found a tie or found that there was nothing to rank -- and from one arm's coverage number those are indistinguishable. PriorityTaskManager now counts every task offered by the grade it got, and that histogram reaches ConcolicStats, print_stats, the C ABI and the fuzzer's symsan_tasks line (only under --symsan-task-priority: 0/0/0 from a FIFO would read as "every destination is covered" rather than "nobody scored them"). Counted over offered rather than kept, so a bound cannot make a run look like it only ever saw the work it held on to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d at their first stat
A target built the way autoconf's AC_SYS_LARGEFILE builds almost everything --
libxml2, libsndfile, curl -- never calls `stat` or `open`. glibc redirects the
declarations under _FILE_OFFSET_BITS=64 and the IR calls `stat64` and `open64`,
names done_abilist.txt did not know. Each missing alias failed silently, in one
of two ways:
* a stat-like function left its output buffer's shadow at the
uninitialized-stack poison, because the kernel wrote the buffer behind the
shadow's back and no wrapper cleared it. The caller's first branch on
st_mode then reads kInitializingLabel, and __taint_trace_cond Die()s on that
under the default exit_on_memerror. For xmllint that ended every trace 29
conditions in, at xmlCheckFilename, before a byte of XML was parsed;
* an open-like function never registered the input as the taint source, so the
trace ran to completion over a program with no symbolic bytes in it.
Add wrappers for stat64, lstat64, fstat64, fstatat64, getrlimit64, open64,
openat64, freopen64 and mmap64, plus fstatat itself -- the spelling the rest of
the family is implemented in terms of, and the one std::filesystem reaches for
directly, which had no wrapper at all. All but fstatat and the two open-like
ones forward to their siblings: on LP64 Linux `struct stat64` is `struct stat`
and off64_t is off_t, so there is nothing for them to do differently, and a
second copy of the shadow-clearing is a second place to forget it. open64 and
openat64 are written out because varargs cannot be forwarded; they mirror their
siblings quirk for quirk.
The lit test compiles with -D_FILE_OFFSET_BITS=64, branches on st_mode from
stat/lstat/fstat/fstatat and on rlim_cur from getrlimit, then reads two bytes
through open() and two through openat() and solves over all four -- so a
poisoned shadow kills the run and a missing taint source leaves nothing to
solve. Checked non-vacuous: with the ten entries stripped from the installed
abilist it fails with the xmllint symptom, "no events ... child exited nonzero".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two prerequisites for making the solver ladder a scheduling decision rather than a nested loop. Neither changes behaviour: the ladder still advances on every non-SAT, in the same order, at the same points. SOLVER_DECLINE, appended to solver_result_t (the four existing values cross the C API as ints and keep their numbering). A solver had two failure modes and one return code for both, and they are opposite signals. A decline is near-free and is positive evidence for the next rung -- i2s refusing a nested task, jigsaw's codegen refusing an FP root -- while a timeout is a search that spent its whole budget, which is expensive and says nothing good about the rung above. i2s normalizes at its funnel rather than at ~60 internal exits, since it never searches: its only honest answers are an assignment and "not my kind of task". jigsaw splits jit_constraints() from gd_entry(). z3 is unchanged -- it is the last rung, so the distinction cannot route anything. Per-rung accounting in ConcolicStats, indexed by ladder position and named through the new Solver::name(). Measured at the one place every solve() call goes through, not inside the solvers: two of the three keep no counters at all, and jigsaw's own three clocks time its internals rather than what a call costs its caller. Surfaces as `i2s 157870@1us 124088/33782` in the monitor line and a line per rung in print_stats. First numbers off it, 120 s of prio xmllint: i2s 1 us/call, jigsaw 24 us/call, 3.9 s of solver CPU in a 120 s run. i2s's calls - sat - declined is exactly 0, which is the funnel normalization behaving as designed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The solver ladder is a portfolio -- i2s is fastest and least capable, z3 the reverse -- and until now the solving loop climbed it inline: a task that did not produce a kept solution went straight to the next rung, so every task got every solver before the next task was looked at. That makes "when do we pay for the expensive solver" a property of the loop, which knows about exactly one task, rather than of the queue, which knows what else is waiting. --symsan-requeue-tasks (SYMSAN_REQUEUE_TASKS) hands the task back instead. Its ladder position moves onto the task itself, because with the task leaving and re-entering the queue between two of its own attempts the session has no memory of how far this one had got. The requeued task is scored afresh and carries a newer seq_, so it is behind everything it ties with: a saturated queue spends its budget on breadth and evicts it, an idle one comes back to it and escalates. Eviction is the give-up rule, so there is no threshold to pick. Off by default and independent of --symsan-task-priority, so all four cells stay reachable and the baseline arm does not move. Also split UNSAT out of the per-rung residual. It was sharing a number with the exhausted searches, and the two are opposites: an UNSAT is a complete answer and usually the cheapest thing a rung can return, a timeout the most expensive. A shift between them was invisible, which is exactly what a 90 s pair looked like it had found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… it answered `sat` says a solver returned an assignment. It does not say the assignment was worth anything, and on this target it usually is not: the front-end reports back `interesting` or `Reached` for 0.28% of i2s's calls and 0.06% of jigsaw's. For every rung but the last that gap was already recoverable, since a task only reaches rung j+1 by failing at rung j, so `calls[j] - calls[j+1]` is exactly rung j's retirements. The last rung has no `calls[j+1]` to difference against, which is why the one rung whose value was in question was the one that could not be checked. `solver_retired[]` is incremented in `report_result()` on the rung the task is still sitting on -- `next_solution()` only advances `solver_index` when it comes back and finds the state unchanged -- and counted for Reached-but-boring as well as for interesting, because both retire the task. `solved_branches` stays the narrower "found something new" measure. Printed by both front-ends as `sat(retired)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rung that answered is finished with the task, whatever became of the answer. The next rung would be handed the same constraint set, so if one satisfying assignment did not flip the branch, a second one will not either: the constraints are stale or incomplete, the path changes under the new bytes so the branch is not reached, or the direction is infeasible. None of those is a solver capability problem. It is also the wrong reading of a solution in a hybrid fuzzer. An input that did not flip its branch is not a failure to be retried -- it went to the fuzzer, which is free to make something of it. Not every input has to flip. So SOLVER_TIMEOUT/SOLVER_DECLINE now set a new MUTATION_UNSOLVED rather than MUTATION_IN_VALIDATION (nothing went out to the front-end, so there is no validation to be in), and that is the only state next_solution() escalates out of. Measured on libxml2, the escalations this drops are 71.8% of all of them and retire at 0.06% at the top rung (#174). --symsan-escalate-unkept / SYMSAN_ESCALATE_UNKEPT restores the old unconditional behaviour so the two can be A/B'd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing covered it. `driver/afltest.cpp` runs its own loop over
`__solvers` rather than `ConcolicSession::next_solution`, so the lit suite
neither broke when the policy changed nor would notice it changing back.
Two test binaries, because the flag is fixed at init and there is one
Session per process. Each asserts the identity its arm implies:
default: jigsaw calls == i2s calls - i2s sat - i2s unsat
unkept: jigsaw calls == i2s calls - i2s unsat - i2s retired
Both closed to the call on the 600 s libxml2 cells, which is why they are
written as identities rather than as thresholds. Checked by mutation:
each fails with 4 against 0 when handed the other arm's flag.
tests/data/ladder.c is new -- four independent equality checks, which is
what i2s answers, so what the second rung does is policy and not
capability. Not taint_loop.c: outside the LTO pipeline its byte loads
merge into one wide load and the trace carries no comparisons at all. The
printf in each arm is load-bearing for the same kind of reason; with a
bare n++ the four compares are if-converted into zext-and-add.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add bindings/rust/HYBRID_FUZZING.md, a zero-to-campaign walkthrough (build twice, run, --branch-map coverage sharing, solver ladder, cmplog baseline, multi-core scaling, reading results) complementing the bindings/rust/README.md reference. Link it from the top-level README and the AFL++ custom-mutator README, naming the LibAFL integration the recommended path. Fix stale clang-12 defaults in the main README and bump the aflpp README from the LLVM-12/14 mix to LLVM-18 throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ubuntu 24.04 ships LLVM-18 natively and the tree tracks LLVM-18; align the image (base tag, llvm/clang/libc++/libunwind-18, LLVM_CONFIG, KO_CC/KO_CXX) and remove the repeated DEBIAN_FRONTEND/TZ ENV lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bindings/rust/Cargo.toml pointed libafl/libafl_bolts/libafl_targets at ../../../libafl -- a checkout beside the repo, i.e. a local-dev path baked into committed config that only built on one machine. Switch to a git dependency on AFLplusplus/LibAFL pinned to rev 2120857 (the tested 0.16 dev state; not on crates.io), with a version = "0.16.0" assertion to catch a bad rev bump. Cargo.lock now records the git source, so a fresh clone / CI / Docker build the bindings with no sibling checkout. CI: add a build-only compile-check of the bindings (cargo build --locked, no tests) to the existing job, reusing the just-installed libsymsan_c.so; add libclang-18-dev for bindgen. Docker: install a minimal Rust toolchain + libclang and build the bindings after the symsan install, so the image can run the recommended LibAFL hybrid-fuzzing front end. Verified: cargo build --locked compiles all three crates from the git rev and produces symsan-fuzz. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
refactor hybrid fuzzing support, infra/solver improvements, and a libafl-based hybrid fuzzer.