From 4171e412b53747d3b89c65a54d69d6bba733517c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 30 Aug 2026 21:23:43 +0200 Subject: [PATCH] feat(verify): save and replay fuzz corpora Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 5 ++ cmd/gasm/main.go | 124 +++++++++++++++++++++++++++++++-- docs/CLI.md | 8 +++ verify/corpus_test.go | 158 ++++++++++++++++++++++++++++++++++++++++++ verify/fuzz.go | 114 +++++++++++++++++++++++++++++- verify/verify.go | 2 +- 6 files changed, 403 insertions(+), 8 deletions(-) create mode 100644 verify/corpus_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb85e2..bc64c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ Unreleased changes on the `development` branch. - **`gasm verify --args`.** Scalar arguments (`name=value`, decimal or `0x` hex) can now be supplied to a `--call` invocation alongside `--buf` buffers, closing the gap where only buffers could be supplied. +- **Fuzz corpus save and replay.** `gasm verify --fuzz --save-corpus dir` + records every input that crashes or mismatches as replayable JSON (buffer + contents and scalars, not raw pointers), and `gasm verify --replay dir` + re-runs the saved entries against the kernel in isolated child processes, + reporting whether each one reproduces. - **`gasm audit-instructions`.** Black-box diff of a gasm encoder against the installed `go tool asm`, for amd64, arm64, riscv64 and loong64 (`gasm audit-instructions `): superset encodings diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 16aa9eb..9ed6762 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -9,6 +9,7 @@ package main import ( "bytes" + "encoding/json" "flag" "fmt" "io" @@ -17,6 +18,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "sort" "strconv" "strings" @@ -1062,6 +1064,12 @@ With -profile, the static basic-block structure is listed for each function. With -call, a single function is invoked with user-supplied buffers (-buf) instead of the smoke/abi/fuzz sweeps. Useful for partial functions (e.g. decoders) that crash on random input but should succeed on valid data. + +With -save-corpus (and -fuzz), every input that crashes or mismatches is +written to the directory as replayable JSON. -replay re-runs saved +entries against the kernel, one child process per entry, so an input that +crashed the original run crashes only the child: the report says whether +each entry reproduces. `) smoke := set.Bool("smoke", false, "call each NOSPLIT function with zeroed args") abi := set.Bool("abi", false, "run ABI-checking calls (sentinel registers + red zone)") @@ -1074,6 +1082,8 @@ decoders) that crash on random input but should succeed on valid data. bufSpec := set.String("buf", "", "buffer spec for -call: name:size:pattern[,name:size:pattern] (zero, ones, seq, or hex)") scalarSpec := set.String("args", "", "scalar args for -call: name=value[,name=value] (decimal or 0x hex)") repeat := set.Int("repeat", 1, "number of times to repeat a -call invocation") + saveCorpus := set.String("save-corpus", "", "with -fuzz: write each failing input to this directory as replayable JSON") + replay := set.String("replay", "", "replay saved corpus entries (JSON files in this directory) against the kernel") set.Parse(args) if set.NArg() != 1 { fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-fuzz] [-ground-truth] [-profile] [-call] ") @@ -1119,6 +1129,15 @@ decoders) that crash on random input but should succeed on valid data. return cmdVerifyCall(k, path, *call, *bufSpec, *scalarSpec, *repeat) } + // Corpus replay: re-run every saved entry in its own child process, so + // an input that crashed the original run crashes only the child. + if rp := os.Getenv("GASM_VERIFY_REPLAY_ONE"); rp != "" { + return cmdReplayOne(k, rp) + } + if *replay != "" { + return cmdVerifyReplay(path, *replay) + } + // Subprocess mode: fuzz a single function and exit. The parent selects // the function through the environment, so no internal flag leaks into // the -h output. @@ -1145,7 +1164,24 @@ decoders) that crash on random input but should succeed on valid data. fmt.Printf("%s: not in go tool asm\n", fuzzOne) return 0 } - res := k.FuzzFunc(fuzzOne, sig, goCode, *fuzzN, 42) + var onSave func(verify.CorpusEntry) + if *saveCorpus != "" { + if err := os.MkdirAll(*saveCorpus, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + saved := 0 + onSave = func(e verify.CorpusEntry) { + file := filepath.Join(*saveCorpus, fmt.Sprintf("%s@%d.json", sanitize(e.Func), saved)) + saved++ + data, err := json.MarshalIndent(e, "", " ") + if err != nil { + return + } + _ = os.WriteFile(file, data, 0o644) + } + } + res := k.FuzzFuncHook(fuzzOne, sig, goCode, *fuzzN, 42, onSave) fmt.Printf("%s\n", res) if !res.OK() { return 1 @@ -1258,7 +1294,11 @@ decoders) that crash on random input but should succeed on valid data. } // Run in a subprocess: if the function crashes on random // input (partial function), we report it and move on. - res := fuzzInSubprocess(path, name, *fuzzN) + var extra []string + if *saveCorpus != "" { + extra = append(extra, "-save-corpus", *saveCorpus) + } + res := fuzzInSubprocess(path, name, *fuzzN, extra...) if res != "" { fmt.Printf(" %s\n", res) if strings.Contains(res, "MISMATCH") { @@ -1341,14 +1381,90 @@ decoders) that crash on random input but should succeed on valid data. } // fuzzInSubprocess runs the fuzz for a single function in a child process. +// cmdVerifyReplay replays every saved corpus entry against the kernel, one +// child process per entry so an input that crashed the original run crashes +// only the child. Exits non-zero when any entry crashes or fails. +func cmdVerifyReplay(path, dir string) int { + self, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: cannot find self: %v\n", err) + return 1 + } + files, err := filepath.Glob(filepath.Join(dir, "*.json")) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + if len(files) == 0 { + fmt.Fprintf(os.Stderr, "gasm verify: no corpus entries in %s\n", dir) + return 1 + } + slices.Sort(files) + rc := 0 + for _, f := range files { + cmd := exec.Command(self, "verify", path) + cmd.Env = append(os.Environ(), "GASM_VERIFY_REPLAY_ONE="+f) + out, err := cmd.CombinedOutput() + name := filepath.Base(f) + switch { + case err == nil: + fmt.Printf(" %s: OK\n", name) + case replayCrashed(err): + rc = 1 + fmt.Printf(" %s: CRASH (reproduced)\n", name) + default: + rc = 1 + detail := strings.TrimSpace(string(out)) + if detail == "" { + detail = err.Error() + } + fmt.Printf(" %s: FAIL (%s)\n", name, detail) + } + } + return rc +} + +// replayCrashed reports whether a replay child died from a signal, which +// means the saved input reproduced its original crash. +func replayCrashed(err error) bool { + exitErr, ok := err.(*exec.ExitError) + if !ok { + return false + } + ws, ok := exitErr.Sys().(syscall.WaitStatus) + return ok && ws.Signaled() +} + +// cmdReplayOne is the child half of corpus replay: rebuild one entry and +// call it, reporting the outcome on stdout. +func cmdReplayOne(k *verify.Kernel, file string) int { + data, err := os.ReadFile(file) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + var e verify.CorpusEntry + if err := json.Unmarshal(data, &e); err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %s: %v\n", file, err) + return 1 + } + if _, err := k.ReplayEntry(e.Func, e); err != nil { + fmt.Printf("%s: %v\n", e.Func, err) + return 1 + } + return 0 +} + // If the child is killed by a signal (e.g. SIGSEGV from a partial function // faulting on random input), it returns a CRASH report instead of dying. -func fuzzInSubprocess(path, funcName string, n int) string { +func fuzzInSubprocess(path, funcName string, n int, extra ...string) string { self, err := os.Executable() if err != nil { return fmt.Sprintf("%s: cannot find self: %v", funcName, err) } - cmd := exec.Command(self, "verify", "-n", strconv.Itoa(n), path) + fuzzChildArgs := append([]string{"verify", "-n", strconv.Itoa(n)}, extra...) + fuzzChildArgs = append(fuzzChildArgs, path) + cmd := exec.Command(self, fuzzChildArgs...) cmd.Env = append(os.Environ(), "GASM_VERIFY_FUZZ_ONE="+funcName) out, err := cmd.CombinedOutput() if err != nil { diff --git a/docs/CLI.md b/docs/CLI.md index e918406..b0b5529 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -79,6 +79,8 @@ Assemble FILE, map it into executable memory, and run dynamic checks. | `--buf ` | Buffer spec for `--call`: `name:size:pattern[,name:size:pattern]` | | `--args ` | Scalar args for `--call`: `name=value[,name=value]` (decimal or `0x` hex) | | `--repeat ` | Number of times to repeat a `--call` invocation (default: 1) | +| `--save-corpus ` | With `--fuzz`: write each failing input to DIR as replayable JSON | +| `--replay ` | Re-run saved corpus entries (JSON in DIR), one child process per entry | The `--fuzz` mode runs each function in a subprocess; a partial function (e.g. a decoder that faults on malformed input) is reported as @@ -92,6 +94,12 @@ offsets, and prints the arg block before and after the call, showing return values and any output written to the buffers. Scalar parameters are supplied with `--args` (decimal, or `0x` hex) at their ABI0 offsets. +The `--save-corpus` mode records the logical arguments (buffer contents and +scalars, not raw pointers) of every failing fuzz input as JSON. `--replay` +rebuilds a live argument block from each entry and calls it in its own child +process, reporting `OK`, `CRASH (reproduced)` or `FAIL` per entry and +exiting non-zero when any entry fails. + ## `gasm debug [--func ] [--buf spec] [--script file] ` Interactive debugger for JIT-assembled functions (amd64, arm64, riscv64, diff --git a/verify/corpus_test.go b/verify/corpus_test.go new file mode 100644 index 0000000..2aa9206 --- /dev/null +++ b/verify/corpus_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +import ( + "encoding/json" + "math/rand" + "os" + "runtime" + "strconv" + "testing" +) + +func loadBasicKernel(t *testing.T) *Kernel { + t.Helper() + k, err := Load("../testdata/verify/basic_amd64.s") + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + return k +} + +func TestReplayEntry(t *testing.T) { + k := loadBasicKernel(t) + + e := CorpusEntry{Func: "add", Args: []CorpusArg{ + {Kind: "int", Value: "2"}, + {Kind: "int", Value: "3"}, + }} + out, err := k.ReplayEntry("add", e) + if err != nil { + t.Fatalf("ReplayEntry: %v", err) + } + if got := int64(GetUint64(out, 16)); got != 5 { + t.Errorf("replay add(2, 3) = %d, want 5", got) + } +} + +func TestCorpusRoundTrip(t *testing.T) { + k := loadBasicKernel(t) + + e := CorpusEntry{Func: "add", Args: []CorpusArg{ + {Kind: "int", Value: "20"}, + {Kind: "int", Value: "22"}, + }} + data, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back CorpusEntry + if err := json.Unmarshal(data, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + out, err := k.ReplayEntry("add", back) + if err != nil { + t.Fatalf("ReplayEntry: %v", err) + } + if got := int64(GetUint64(out, 16)); got != 42 { + t.Errorf("round-trip replay = %d, want 42", got) + } +} + +// TestGenDualArgsEntryReplayable checks that the entry recorded alongside a +// generated input replays to the same observable call. +func TestGenDualArgsEntryReplayable(t *testing.T) { + k := loadBasicKernel(t) + + sig, ok := parseFuncSig("// func add(a, b int) int") + if !ok { + t.Fatal("parseFuncSig failed") + } + _, _, bufs, entry := genDualArgs(rand.New(rand.NewSource(1)), sig, 24) + if len(entry.Args) != 2 || entry.Args[0].Kind != "int" { + t.Fatalf("unexpected entry: %+v", entry) + } + out, err := k.ReplayEntry("add", entry) + if err != nil { + t.Fatalf("ReplayEntry: %v", err) + } + want := int64(GetUint64(out, 16)) + got := entryInt(t, entry.Args[0]) + entryInt(t, entry.Args[1]) + if got != want { + t.Errorf("replayed sum = %d, want %d", want, got) + } + runtime.KeepAlive(bufs) +} + +func entryInt(t *testing.T, a CorpusArg) int64 { + t.Helper() + v, err := strconv.ParseUint(a.Value, 10, 64) + if err != nil { + t.Fatalf("entry value %q: %v", a.Value, err) + } + return int64(v) +} + +// TestFuzzHookSavesFailures fuzzes add against the go-tool-asm build of a +// sub kernel with the same signature, so every iteration mismatches (safely: +// both kernels read only their own arguments) and the hook must record +// replayable entries. +func TestFuzzHookSavesFailures(t *testing.T) { + src := `#include "textflag.h" + +// func add(a, b int) int +TEXT ·add(SB), NOSPLIT, $0-24 + MOVQ a+0(FP), AX + ADDQ b+8(FP), AX + MOVQ AX, ret+16(FP) + RET + +// func sub(a, b int) int +TEXT ·sub(SB), NOSPLIT, $0-24 + MOVQ a+0(FP), AX + SUBQ b+8(FP), AX + MOVQ AX, ret+16(FP) + RET +` + dir := t.TempDir() + file := dir + "/addsub_test_amd64.s" + if err := os.WriteFile(file, []byte(src), 0o644); err != nil { + t.Fatalf("write kernel: %v", err) + } + k, err := Load(file) + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + + sig, ok := parseFuncSig("// func add(a, b int) int") + if !ok { + t.Fatal("parseFuncSig failed") + } + gt, err := GroundTruth(file) + if err != nil { + t.Skipf("go tool asm unavailable: %v", err) + } + + var saved []CorpusEntry + res := k.FuzzFuncHook("add", sig, gt["sub"], 5, 42, func(e CorpusEntry) { + saved = append(saved, e) + }) + if res.Mismatches == 0 { + t.Fatal("expected mismatches against the sub reference") + } + if len(saved) == 0 { + t.Fatal("hook saved no entries despite mismatches") + } + for _, e := range saved { + if e.Func != "add" || len(e.Args) != 2 { + t.Errorf("bad entry: %+v", e) + } + if _, err := k.ReplayEntry(e.Func, e); err != nil { + t.Errorf("saved entry does not replay: %v", err) + } + } +} diff --git a/verify/fuzz.go b/verify/fuzz.go index 8894057..986e839 100644 --- a/verify/fuzz.go +++ b/verify/fuzz.go @@ -4,6 +4,7 @@ package verify import ( + "encoding/hex" "fmt" "math/rand" "regexp" @@ -40,6 +41,24 @@ func (r FuzzResult) String() string { return s } +// CorpusArg is one replayable argument of a corpus entry. +type CorpusArg struct { + Kind string `json:"kind"` // "slice", "ptr", "int", "scalar" + Len int `json:"len,omitempty"` // slice: declared length in elements + Data string `json:"data,omitempty"` // slice/ptr: hex-encoded buffer content + Value string `json:"value,omitempty"` // int/scalar: decimal value +} + +// CorpusEntry is a replayable fuzz input: the logical arguments of one +// generated call, stored as JSON. A raw argument block replays nowhere +// (its pointers point into mappings that died with the process), so the +// corpus records buffer contents and scalars instead and ReplayEntry +// rebuilds a live block from them. +type CorpusEntry struct { + Func string `json:"func"` + Args []CorpusArg `json:"args"` +} + // funcSig is a parsed // func signature from the assembly source. type funcSig struct { name string @@ -157,6 +176,13 @@ func ExtractSignatures(src string) map[string]funcSig { // The signature comment must appear immediately above the TEXT directive // in the source (the conventional Go assembly layout). func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations int, seed int64) FuzzResult { + return k.FuzzFuncHook(name, sig, goCode, iterations, seed, nil) +} + +// FuzzFuncHook is FuzzFunc with a hook invoked for every failing input (a +// crash or a mismatch), receiving a replayable corpus entry. A nil hook +// behaves exactly like FuzzFunc. +func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iterations int, seed int64, onSave func(CorpusEntry)) FuzzResult { result := FuzzResult{Func: name, Iterations: iterations} rng := rand.New(rand.NewSource(seed)) @@ -181,7 +207,8 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in // Generate inputs and build TWO independent arg blocks (one per // version) so that functions which write to their arguments // (e.g. histogram increments) don't corrupt the other's input. - gasmArgs, goArgs, bufs := genDualArgs(rng, sig, fl.Args) + gasmArgs, goArgs, bufs, entry := genDualArgs(rng, sig, fl.Args) + entry.Func = name // Save the current input for crash diagnostics. result.CrashInput = gasmArgs @@ -193,6 +220,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in if result.FirstFail == "" { result.FirstFail = fmt.Sprintf("iter %d: gasm call: %v", i, err) } + if onSave != nil { + onSave(entry) + } runtime.KeepAlive(bufs) continue } @@ -204,6 +234,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in if result.FirstFail == "" { result.FirstFail = fmt.Sprintf("iter %d: go call: %v", i, err) } + if onSave != nil { + onSave(entry) + } runtime.KeepAlive(bufs) continue } @@ -219,6 +252,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in if result.FirstFail == "" { result.FirstFail = fmt.Sprintf("iter %d: output mismatch at result offset %d", i, resultOff) } + if onSave != nil { + onSave(entry) + } } else { result.Matches++ } @@ -230,7 +266,7 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in // genDualArgs generates two independent ABI0 argument blocks (for gasm and // go) with identical logical content but separate backing buffers, so that // functions which write to their arguments don't corrupt the other's input. -func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []byte, bufs [][]byte) { +func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []byte, bufs [][]byte, entry CorpusEntry) { gasmArgs = make([]byte, argSize) goArgs = make([]byte, argSize) off := 0 @@ -267,6 +303,11 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b putU64(goArgs, off+16, uint64(declaredLen)) off += 24 sliceIdx++ + entry.Args = append(entry.Args, CorpusArg{ + Kind: "slice", + Len: declaredLen, + Data: hex.EncodeToString(buf1[:n*elemSize]), + }) case strings.HasPrefix(p.typ, "*["): nElem := arrayLen(p.typ) @@ -280,21 +321,88 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) putPtr(goArgs, off, unsafe.Pointer(&buf2[0])) off += 8 + entry.Args = append(entry.Args, CorpusArg{ + Kind: "ptr", + Data: hex.EncodeToString(buf1), + }) case p.typ == "int" || p.typ == "uint" || p.typ == "int64" || p.typ == "uint64": v := uint64(rng.Intn(256)) putU64(gasmArgs, off, v) putU64(goArgs, off, v) off += 8 + entry.Args = append(entry.Args, CorpusArg{Kind: "int", Value: strconv.FormatUint(v, 10)}) default: v := rng.Uint64() putU64(gasmArgs, off, v) putU64(goArgs, off, v) off += 8 + entry.Args = append(entry.Args, CorpusArg{Kind: "scalar", Value: strconv.FormatUint(v, 10)}) } } - return gasmArgs, goArgs, bufs + return gasmArgs, goArgs, bufs, entry +} + +// ReplayEntry rebuilds the argument block of a corpus entry and invokes the +// named function once, returning the argument block after the call. Slice +// buffers get the same safety padding the fuzzer uses, so over-reads that +// were harmless during the original run stay harmless on replay. +func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) { + fl, err := k.Func(name) + if err != nil { + return nil, err + } + args := make([]byte, fl.Args) + var bufs [][]byte + off := 0 + for _, a := range e.Args { + switch a.Kind { + case "slice": + data, err := hex.DecodeString(a.Data) + if err != nil { + return nil, fmt.Errorf("corpus: slice data: %w", err) + } + buf := make([]byte, len(data)+8192) + copy(buf, data) + bufs = append(bufs, buf) + if off+24 > len(args) { + return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name) + } + putPtr(args, off, unsafe.Pointer(&buf[0])) + putU64(args, off+8, uint64(a.Len)) + putU64(args, off+16, uint64(a.Len)) + off += 24 + + case "ptr": + data, err := hex.DecodeString(a.Data) + if err != nil { + return nil, fmt.Errorf("corpus: ptr data: %w", err) + } + buf := make([]byte, max(len(data), 8)) + copy(buf, data) + bufs = append(bufs, buf) + if off+8 > len(args) { + return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name) + } + putPtr(args, off, unsafe.Pointer(&buf[0])) + off += 8 + + default: // "int", "scalar" + v, err := strconv.ParseUint(a.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("corpus: %s value: %w", a.Kind, err) + } + if off+8 > len(args) { + return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name) + } + putU64(args, off, v) + off += 8 + } + } + out, err := k.CallFunc(name, args) + runtime.KeepAlive(bufs) + return out, err } func elemSizeFor(sliceType string) int { diff --git a/verify/verify.go b/verify/verify.go index 1a9a44e..d7887ca 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -145,7 +145,7 @@ func (k *Kernel) FuzzFuncChecked(name string, sig funcSig, iterations int, seed violations := 0 for i := range iterations { - gasmArgs, _, bufs := genDualArgs(rng, sig, fl.Args) + gasmArgs, _, bufs, _ := genDualArgs(rng, sig, fl.Args) result.CrashInput = gasmArgs _, report, err := k.CallFuncChecked(name, gasmArgs)