https://github.com/trailofbits/gosentry
Kevin Valerio, Trail of Bits -- W3ST 2026
Rust has a rich fuzzing ecosystem (LibAFL, afl-rs, Nautilus, CMPLOG, etc).
Go has go test -fuzz — and not much else (that adds value).
Objective: bridging that gap with an easy to use AND unique tool
go test -fuzz interface, no new workflow to learn
A fork of the Go toolchain that replaces go test -fuzz with state-of-the-art fuzzing & bug detection.
struct inputs directlySame CLI, same testing.F
Go's native fuzzer only supports scalars ([]byte, string, numbers).
gosentry lets you fuzz structs, slices, arrays, pointers.
type Input struct {
Data []byte
S string
N int
OK bool
}
func FuzzStructInput(f *testing.F) {
f.Add(Input{Data: []byte("A"), S: "B", N: 7, OK: true})
f.Fuzz(func(t *testing.T, in Input) {
// fuzzer mutates `in` directly
})
}
Problem: LibAFL mutates raw []byte so we need an encoding
Why not JSON/gob? They reject most random inputs, bad for coverage, i.e generating "random" JSON will always most likely fail, wasting fuzzing cycles
Solution: Custom format that is tolerant to malformed data. Any byte slice decodes into some struct value.
Example: Input{Data: []byte("A"), S: "hi", N: 7, OK: true}
01 41 // Data: len=1, "A"
02 68 69 // S: len=2, "hi"
07 00 00 00 00 00 00 00 // N: int64 LE
01 // OK: true
Detect silent arithmetic bugs: overflow, underflow, lossy truncation.
Inspired by go-panikint (gosentry actually started by go-panikint).
# Disable overflow detection
GOFLAGS='-gcflags=-overflowdetect=false' ./make.bash
# Enable truncation detection
GOFLAGS='-gcflags=-truncationdetect=true' ./make.bash
Normal Go SSA for c := a + b
v1 = Arg "a"
v2 = Arg "b"
v3 = Add32 v1 v2
gosentry-instrumented SSA
v1 = Arg "a"
v2 = Arg "b"
v3 = Add32 v1 v2
v4 = IsOverflow(v1, v2, v3)
If v4 -> panicoverflow("+")
Example: int8(120) + int8(10) = -126 (wrapped). IsOverflow checks: both positive but result < a -> panic
For every + - * / on int8/16/32, uint8/16/32/64, the compiler inserts a check right after the operation. If overflow -> panic
Some software logs errors instead of crashing — invisible to fuzzers.
gosentry can force a panic when specific functions are called.
./bin/go test -fuzz=FuzzHarness --use-libafl \
--panic-on="pkg.(*Logger).Warning,pkg.(*Logger).Error" \
--focus-on-new-code=false --catch-races=false --catch-leaks=false
User writes:
--panic-on="pkg.(*Logger).Error"
Normal SSA:
v1 = StaticCall Logger.Error
gosentry-instrumented SSA:
v0 = panicOnCall("Logger.Error")
v1 = StaticCall Logger.Error
Panic is inserted before the call.
Fuzzer sees a crash, stops campaign.
go test parses patternsruntime.panicOnCall() in SSA"panic-on-call: pkg.(*Logger).Error"Replace Go's native fuzzer with LibAFL (Rust) — used by default.
Same go test -fuzz CLI, but the engine behind is LibAFL
Go native fuzzer — stuck forever:
if input == "IMARANDOMSTRINGJUSTCMPLOGMEMAN" {
panic("illegal")
}
// Random byte mutation will
// basically never match this string
LibAFL + CMPLOG — finds it instantly:
CMPLOG intercepts the == comparison.
It sees the target constant.
Next mutation: inject the exact string.
Comparison solved in ~1 iteration.
CMPLOG/Redqueen, better scheduling, token extraction, ... — the state of the art
Who generates what, who calls what
go test
captures fuzz callback
generates a .go bridge
compiles everything
.a static archive
test code + deps
C bridge (libFuzzer ABI)
8-bit coverage counters
Rust runner
LibAFL engine
mutate → call Go → read cov
CMPLOG, scheduling, ...
LLVMFuzzerInitialize()LLVMFuzzerTestOneInput(data)
The .a archive exposes the standard libFuzzer C ABI — LibAFL treats Go exactly like any C/Rust target
golibafl
golibafl/src/main.rs — links libharness.a and drives the fuzzing
// Step 1: Import the C symbols from libharness.a
use libafl_targets::{
extra_counters, // coverage map
libfuzzer::libfuzzer_test_one_input, // per-input entry
libfuzzer_initialize, // boot Go runtime
};
// Step 2: Initialize — Go runtime starts, registers coverage counters
let init_ret = unsafe { libfuzzer_initialize(&args) };
// Step 3: Define the harness — each iteration calls into Go
let mut harness = |input: &BytesInput| {
let target = input.target_bytes();
unsafe { libfuzzer_test_one_input(&target); } // → Go code runs
ExitKind::Ok
};
// Step 4: Wrap with coverage observer + timeout
let executor = InProcessExecutor::with_timeout(
&mut harness,
tuple_list!(edges_observer, time_observer), // reads .go.fuzzcntrs
&mut fuzzer, &mut state, &mut mgr, exec_timeout,
)?;
LibAFL normally expects LLVM SanitizerCoverage — 8-bit counters inserted by Clang.
Go uses its own compiler (not LLVM). So how does it work?
runtime/libfuzzer.go (excerpt)
//go:build libfuzzer
package runtime
// In libFuzzer mode, the compiler inserts calls to libfuzzerTraceCmpN
// (where N can be 1, 2, 4, or 8) for encountered integer comparisons
// in the code to be instrumented.
//go:nosplit
func libfuzzerTraceCmp1(arg0, arg1 uint8, fakePC uint) {
fakePC = fakePC % retSledSize
libfuzzerCallTraceIntCmp(&__sanitizer_cov_trace_cmp1,
uintptr(arg0), uintptr(arg1), uintptr(fakePC))
}
Google UUID — 5 min campaign
go-ethereum — 30 min campaign
Bias the fuzzer toward recently changed code (where bugs are most likely)
./bin/go test -fuzz=FuzzXxx --focus-on-new-code=true --catch-races=false --catch-leaks=false
Coverage stays as primary signal. Git recency is a scheduling bias.
file:line,
then asks git blame when that line last changed.
Coverage stays first. Git recency only biases which saved inputs get retried more often.
--catch-races
Replay seeds with -race build & GORACE=halt_on_error=1
--catch-leaks
Replay seeds with go.uber.org/goleak
catch-hangs (automatic)
Timeout detection + confirmation replays. Configurable via libafl.config.jsonc
All three stop the campaign on first finding (treat as crash)
Main fuzzer
golibafl fuzz loop
-> mutate -> execute -> observe
-> new coverage? write to queue/
Race sidecar (separate binary)
poll queue/ every 100ms
-> replay seed with -race build
-> "data race detected"?
copy to races/, stop campaign
Leak sidecar (separate binary)
poll queue/ every 1s
-> replay with goleak enabled
-> goroutine leak detected?
copy to leaks/, stop campaign
Hang detection (in main runner)
execution timeout?
-> replay N times, larger timeout
-> confirmed? hangs/, stop
Byte-level mutation wastes time on syntactically invalid inputs.
Nautilus generates inputs from a user-provided grammar.
[
["Expr", "{Term}"],
["Expr", "{Term}+{Expr}"],
["Term", "{Factor}*{Term}"],
["Factor", "({Expr})"],
["Factor", "{Num}"],
["Num", "0"], ["Num", "1"], ["Num", "2"]
]
Generates: 0+1*(2+0) — mutates: 2*(1+0)+1
Never produces: 0+è1*(@#
Where Nautilus plugs in
LibAFL picks one seed from queue/
-> Nautilus parses it with grammar.json
-> Nautilus mutates the grammar tree
-> Nautilus unparses the tree to bytes
-> Go harness runs on those bytes
-> if coverage grows, keep it
If the corpus is empty, Nautilus generates the first valid seeds from the grammar.
Practical mutation example
Grammar idea:
Expr -> {Num}+{Num}
Num -> 0 | 1 | 2 | 3
Seed picked by LibAFL:
"1+3"
Tree mutation:
replace right Num("3") with Num("0")
New bytes sent to Go:
"1+0"
It mutates grammar pieces like Num, not random bytes, so the input stays valid.
Tradeoff: slower exec/s, but much less time wasted on broken syntax.
Generate coverage report from a fuzz campaign
# After a campaign:
./bin/go test -fuzz=FuzzXxx --generate-coverage .
queue/ corpus from the same package and the same fuzz target.
cover.out and cover.html.
Good when you already have a strong corpus and just want to see what code that corpus reaches.
gosentry requires engineering across multiple domains:
Without LLMs, this would have taken > 1 year of glue engineering.
The LLM sometimes found the architecture itself, smart enough to make
the glue without me explicitly saying "do it that way."
Lessons learned creating tooling with LLM: follow OpenAI's harnessing guidelines