Skip to content

libafl - #50

Merged
ChengyuSong merged 103 commits into
mainfrom
libafl
Aug 11, 2026
Merged

libafl#50
ChengyuSong merged 103 commits into
mainfrom
libafl

Conversation

@ChengyuSong

Copy link
Copy Markdown
Collaborator

refactor hybrid fuzzing support, infra/solver improvements, and a libafl-based hybrid fuzzer.

ChengyuSong and others added 30 commits July 29, 2026 08:42
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>
ChengyuSong and others added 28 commits August 5, 2026 22:15
…(#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>
@ChengyuSong
ChengyuSong merged commit ecbe8a7 into main Aug 11, 2026
1 check passed
@ChengyuSong
ChengyuSong deleted the libafl branch August 11, 2026 05:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant