gosentry

A security-focused and fuzzing-oriented fork of the Go toolchain

https://github.com/trailofbits/gosentry

Kevin Valerio, Trail of Bits -- W3ST 2026

Why that tool?

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

  • Same go test -fuzz interface, no new workflow to learn
  • Easy to use by humans and agents

What is gosentry?

A fork of the Go toolchain that replaces go test -fuzz with state-of-the-art fuzzing & bug detection.

1. Struct-aware fuzzing — fuzz struct inputs directly
2. Integer overflow & truncation detection
3. Panic on selected functions
4. LibAFL integration
5. Git-blame-oriented scheduling
6. Catch races, goroutine leaks & hangs
7. Grammar-based fuzzing (Nautilus)
8. Coverage report generation from corpus

Same CLI, same testing.F

Feature 1 — Struct-aware fuzzing

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
    })
}

How it works — The glue

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

Feature 2 — Integer overflow detection

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

How it works — SSA instrumentation

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

Feature 3 — Panic on selected functions

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

How it works — 3-stage pipeline

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.

  • 1. go test parses patterns
  • 2. Compiler injects runtime.panicOnCall() in SSA
  • 3. Runtime panics with "panic-on-call: pkg.(*Logger).Error"

Feature 4 — LibAFL integration

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

Architecture — how Go + LibAFL are wired

Who generates what, who calls what

go test

captures fuzz callback
generates a .go bridge
compiles everything

produces

.a static archive

test code + deps
C bridge (libFuzzer ABI)
8-bit coverage counters

links & calls

Rust runner

LibAFL engine
mutate → call Go → read cov
CMPLOG, scheduling, ...

LLVMFuzzerInitialize()
Rust calls once → boots Go runtime
LLVMFuzzerTestOneInput(data)
Rust calls per mutation → runs Go fuzz target

The .a archive exposes the standard libFuzzer C ABI — LibAFL treats Go exactly like any C/Rust target

The Rust side — fuzz loop in 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,
)?;

Coverage — how Go instrumentation works with LibAFL

LibAFL normally expects LLVM SanitizerCoverage — 8-bit counters inserted by Clang.
Go uses its own compiler (not LLVM). So how does it work?

Key takeaway: Go is libFuzzer-compatible (runtime/libfuzzer.go). That's the common interface.
Any fuzzer that speaks the libFuzzer ABI (LibAFL, AFL++, ...) can fuzz Go code through this glue.

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))
}

Benchmarks — LibAFL vs Go native

Google UUID — 5 min campaign

Lines covered ↑
LibAFL   : ██████████████████ 180+
Go native: ████████            ~90

go-ethereum — 30 min campaign

Lines covered ↑
LibAFL   : ████████████████████ 900+
Go native: ████████████          ~550

Feature 5 — Git-aware scheduling (experimental)

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.

How it works (simplified)

1. Build a map
At build time, gosentry maps each coverage counter back to a real file:line, then asks git blame when that line last changed.
2. Fuzz normally
LibAFL still does normal coverage-guided fuzzing and remembers which counters each input hits.
3. Add a small bonus
If an input reaches a recently changed line, the scheduler gives it a little extra weight and picks it more often.

Coverage stays first. Git recency only biases which saved inputs get retried more often.

Feature 6 — Catch races, leaks & hangs

--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)

How it works

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

Feature 7 — Grammar-based fuzzing

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*(@#

How it works — Nautilus in LibAFL

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.

Feature 8 — Coverage reports in one CLI

Generate coverage report from a fuzz campaign

# After a campaign:
./bin/go test -fuzz=FuzzXxx --generate-coverage .
1. Input
Use the existing queue/ corpus from the same package and the same fuzz target.
2. Replay
gosentry runs every saved input again, but this time with Go coverage instrumentation enabled.
3. Output
It writes standard Go files in the campaign coverage dir: cover.out and cover.html.

Good when you already have a strong corpus and just want to see what code that corpus reaches.

Written with LLMs

gosentry requires engineering across multiple domains:

  • Go compiler internals (SSA passes)
  • Go runtime modifications
  • C ABI / shared library linking
  • Rust + LibAFL fuzzer implementation
  • Git plumbing + binary analysis (addr2line)

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."

How to harness LLMs properly

Lessons learned creating tooling with LLM: follow OpenAI's harnessing guidelines

1. End-to-end tests: the LLM must prove the feature works
2. GitHub workflows to catch regressions
3. Tests must pass every time
4. Agentic looping: keep the LLM going until it works, never give up
5. Internal Markdown as "database"
6. AGENTS.md / CLAUDE.md
7. etc, see https://openai.com/index/harness-engineering