global-state-detector is a small C helper for LLVMFuzzerTestOneInput and AFL
__AFL_LOOP persistent harnesses that report instability (a value that you can
see when using AFL++'s afl-fuzz or in LibAFL).
It discovers persistent writable global state between fuzzer iterations by
snapshotting writable ELF PT_LOAD segments after target initialization, then
compares later memory against that baseline so target globals that drift across
inputs are visible.
This is useful when a fuzz target is expected to be deterministic and
iteration-local, but hidden .data or .bss state makes later inputs depend on
earlier ones.
- Writable non-executable
PT_LOADsegments in the main binary. - Writable non-executable
PT_LOADsegments in loaded shared objects discovered throughdl_iterate_phdr. - Page-level changes using a fast hash, followed by byte-range reporting for changed pages.
- Clang sanitizer coverage counters are ignored when the
__sancov_cntrsrange is present, so normal libFuzzer coverage does not dominate reports.
The detector intentionally does not inspect heap objects, anonymous mappings, thread-local storage, files, sockets, or other external process state.
Linux (ELF) and macOS (Mach-O) are supported. The two paths are selected at compile time and share all of the snapshot/diff/reporting logic.
Linux:
dl_iterate_phdrto walk loaded objectsdladdrand a fallback/proc/self/exeSHT_SYMTABparser for symbol resolution (the parser also picks upSTB_LOCALsymbols thatdladdrcannot see, e.g. Rust binary-crate statics)- ELF program headers from
<elf.h>/<link.h> - Link the harness with
-ldl -Wl,--export-dynamic -Wl,-z,nowso symbol names in the main executable can be resolved and lazy PLT/GOT binding does not show up as first-iteration writable state.
macOS:
_dyld_image_count/_dyld_get_image_header/_dyld_get_image_nameto walk loaded Mach-O imagesdladdrfor symbol resolution (already iterates the in-memorynlisttable, including locals — no separate fallback needed)- Mach-O headers from
<mach-o/dyld.h>/<mach-o/loader.h>/<mach-o/getsect.h> - No extra link flags required.
dlopen/dladdrship inlibSystem, and modern Mach-O linkers bind eagerly by default (chained fixups), so the Linux-z,now/--export-dynamicequivalents are unnecessary.
Builds should use a clang-based fuzzer compiler (e.g. afl-clang-fast for
AFL++, clang for libFuzzer).
On recent Darwin, -fsanitize=address does not work: any harness — even
hello-world — wedges at process init and spins at 100% CPU before main
runs. This has been observed with both Apple Silicon Apple Clang
(clang-2100.x) and Homebrew LLVM 21 on macOS 15+. The issue is in the
ASan runtime / loader interaction, not in this detector; the detector
itself works fine on macOS without ASan.
The provided Makefile therefore drops address from the example's
sanitizer set on Darwin, building with -fsanitize=fuzzer,undefined
instead of the full Linux -fsanitize=fuzzer,address,undefined. To
exercise ASan integration on macOS you'll need to find a working
clang/runtime combination and override FUZZER_CFLAGS /
FUZZER_LDFLAGS by hand.
makeThis builds:
global_state_detector.oharness_example
Include the global_state_detector.h header and run any one-time target
initialization before taking the detector snapshot:
#include "global_state_detector.h"
int LLVMFuzzerInitialize(int *argc, char ***argv) {
(void)argc;
(void)argv;
/* target_init(); */
return 0;
}Take or refresh the snapshot immediately before target execution and check immediately after it. This avoids reporting a fuzzer's own bookkeeping mutations between callbacks while still reporting writable global state changed by the target:
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
static int detector_ready = 0;
if (!detector_ready) {
detector_ready = 1;
global_state_detector_init();
} else {
global_state_detector_rebaseline();
}
target_process(data, size);
global_state_detector_check(/*rebaseline=*/1);
return 0;
}Pass a non-zero rebaseline value to update the snapshot after reporting. Pass
0 to keep comparing against the previous snapshot and report cumulative drift.
Build and run the included example:
make
./harness_example -runs=50The example intentionally mutates target_accumulator in target_process, so
the detector should report changed writable state during fuzz iterations.
Example report shape:
[global-state-detector] CHANGE 0x... len=... symbol+0x... ([main])
was: ...
now: ...
Some runtime libraries maintain writable process state. The detector skips
common libc, dynamic-linker, pthread, libstdc++, vDSO, and replacement malloc
implementations (jemalloc, mimalloc, tcmalloc, Hoard, snmalloc, rpmalloc,
Scudo) by basename prefix to reduce noise, but target-specific libraries may
still report expected state. On macOS the entire dyld shared cache and
/System frameworks tree are skipped by path prefix in addition to the
allocator basenames.
Only writable ELF segments are covered. If a target stores persistent state on
the heap or in custom mappings, this detector will not see it without additional
/proc/self/maps support.
The detector is not thread-safe. Use it from a single-threaded harness or add external synchronization around initialization and checks.
global-state-detector is distributed under the GNU Affero General Public
License version 3 or later. See LICENSE for the full license text.