fix(verify): arm64 stack save, adaptive canary and host gating

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-19 23:49:19 +02:00
parent 87b1081c53
commit 375182ef1f
25 changed files with 651 additions and 57 deletions
+36 -6
View File
@@ -16,21 +16,51 @@ import (
// canary below SP, returning both the argument block (with results) and an // canary below SP, returning both the argument block (with results) and an
// ABIReport. // ABIReport.
// //
// The callee is assumed to declare no local frame ($0 in its TEXT
// directive); use CallCheckedFrame, or Kernel.CallFuncChecked which reads
// the frame from the image, for a callee with a frame. A callee whose
// frame extends past the fixed call margin would otherwise trip the canary
// with perfectly legal writes.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
//
// The architecture-specific parts live in abi_<arch>.s: enterJITChecked // The architecture-specific parts live in abi_<arch>.s: enterJITChecked
// plants sentinels in the registers the Go ABI fixes across calls (the // plants sentinels in the registers the Go ABI fixes across calls (the
// frame pointer and the goroutine pointer) before switching to the // frame pointer and the goroutine pointer) before switching to the
// prepared stack, and the raw return trampoline leaveJITCheckedRaw // prepared stack, and the raw return trampoline leaveJITCheckedRaw
// compares them and records violations in abiResult. // compares them and records violations in abiResult.
func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
return CallCheckedFrame(fnAddr, args, 0)
}
// CallCheckedFrame is CallChecked with the canary gap sized for a callee
// that declares a local frame of frame bytes: the canary window is placed
// below the deepest write a legal kernel may make, its own frame plus the
// call margin, so only writes that go below the declared frame trip it.
// Callers that already hold the function layout pass asm.FuncLayout.Frame.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func CallCheckedFrame(fnAddr uintptr, args []byte, frame int) ([]byte, ABIReport, error) {
report := ABIReport{} report := ABIReport{}
// Reset the global result. // Reset the global result.
abiResult = 0 abiResult = 0
// Prepare the stack: [canary][padding][leaveJITCheckedRaw][args...] // The gap between the canary window and the entry stack pointer must
// The canary sits below the initial SP, so the function would have to // cover every write a legal kernel makes. Two parts:
// write below SP to corrupt it. // - frame: the callee's declared local frame, which the ABI lets it
totalSize := redZoneSize + stackPad + 8 + len(args) + 64 // write anywhere in [SP-frame, SP).
// - stackPad (64): the call margin. A kernel may CALL another
// function, which pushes a return address below the frame and runs
// a small prologue of its own; 64 bytes covers both. Callees'
// own frames are not accounted: a kernel calling deep into other
// frames can write below this gap without detection.
pad := stackPad + frame
// Prepare the stack: [canary][frame gap][leaveJITCheckedRaw][args...]
totalSize := redZoneSize + pad + 8 + len(args) + 64
stackMem, err := syscall.Mmap(-1, 0, totalSize, stackMem, err := syscall.Mmap(-1, 0, totalSize,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON) syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
if err != nil { if err != nil {
@@ -43,8 +73,8 @@ func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
stackMem[i] = redZoneFill stackMem[i] = redZoneFill
} }
// Return address and args after the canary and padding. // Return address and args after the canary and the frame gap.
retOff := redZoneSize + stackPad retOff := redZoneSize + pad
binary.LittleEndian.PutUint64(stackMem[retOff:retOff+8], uint64(leaveCheckedPtr)) binary.LittleEndian.PutUint64(stackMem[retOff:retOff+8], uint64(leaveCheckedPtr))
copy(stackMem[retOff+8:], args) copy(stackMem[retOff+8:], args)
+5 -3
View File
@@ -36,7 +36,8 @@ DATA ·leaveCheckedPtr(SB)/8, $·leaveJITCheckedRaw(SB)
TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVD fn+0(FP), R0 // target (before SP switch) MOVD fn+0(FP), R0 // target (before SP switch)
MOVD R30, savedLR(SB) // save link register MOVD R30, savedLR(SB) // save link register
MOVD R3, savedSP(SB) // save Go stack pointer MOVD RSP, R3 // save Go stack pointer: R3 relays the value, the
MOVD R3, savedSP(SB) // arm64 assembler cannot store RSP to memory
MOVD R29, savedFP(SB) // save frame pointer (vet requires save before clobber) MOVD R29, savedFP(SB) // save frame pointer (vet requires save before clobber)
MOVD g, savedG(SB) // save g MOVD g, savedG(SB) // save g
MOVD $SENTINEL_FP, R29 // sentinel in the frame pointer MOVD $SENTINEL_FP, R29 // sentinel in the frame pointer
@@ -85,8 +86,9 @@ restore:
// Package-level storage for the saved frame pointer. Like savedSP and // Package-level storage for the saved frame pointer. Like savedSP and
// savedLR in trampoline_arm64.s, this is assembly-side state: the amd64 // savedLR in trampoline_arm64.s, this is assembly-side state: the amd64
// checked trampoline saves the caller's frame pointer for vet's sake and // checked trampoline saves and restores the caller's frame pointer too
// never restores it, and this file mirrors that. // (leaveJITCheckedRaw restores BP before returning), and this file mirrors
// that.
GLOBL savedFP(SB), NOPTR, $8 GLOBL savedFP(SB), NOPTR, $8
GLOBL savedG(SB), NOPTR, $8 GLOBL savedG(SB), NOPTR, $8
+11
View File
@@ -64,3 +64,14 @@ g_ok:
JMP (R1) // return to Go caller JMP (R1) // return to Go caller
GLOBL savedG(SB), NOPTR, $8 GLOBL savedG(SB), NOPTR, $8
// leaveRawAddr holds the raw .abi0 address of leaveJIT, read by
// call_loong64.go in preference to reflect.ValueOf(leaveJIT), which returns
// the address of the ABIInternal wrapper the linker interposes: the
// wrapper's prologue clobbers the saved-register window the JIT call
// depends on. It lives here rather than beside leaveJIT in
// trampoline_loong64.s because that file is assembled by gasm itself in
// the ground-truth byte tests, and gasm cannot yet encode a symbol-valued
// DATA word.
GLOBL ·leaveRawAddr(SB), NOPTR, $8
DATA ·leaveRawAddr(SB)/8, $·leaveJIT(SB)
+5
View File
@@ -11,3 +11,8 @@ import "fmt"
func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
return nil, ABIReport{}, fmt.Errorf("verify: ABI checks are not supported on this architecture") return nil, ABIReport{}, fmt.Errorf("verify: ABI checks are not supported on this architecture")
} }
// CallCheckedFrame is unavailable on unsupported architectures.
func CallCheckedFrame(fnAddr uintptr, args []byte, frame int) ([]byte, ABIReport, error) {
return nil, ABIReport{}, fmt.Errorf("verify: ABI checks are not supported on this architecture")
}
+3 -1
View File
@@ -47,7 +47,9 @@ func (r ABIReport) String() string {
// redZoneSize is the canary window below the prepared stack pointer. On // redZoneSize is the canary window below the prepared stack pointer. On
// amd64 it is the System V red zone; on every architecture a Go function // amd64 it is the System V red zone; on every architecture a Go function
// must not write below SP, so any corruption there is a bug. // must not write below its own declared frame, so the canary sits below
// the frame plus the call margin (see CallCheckedFrame) and any corruption
// there is a bug.
const redZoneSize = 128 const redZoneSize = 128
// redZoneFill is the byte pattern used to detect writes below SP. // redZoneFill is the byte pattern used to detect writes below SP.
+84
View File
@@ -4,11 +4,13 @@
package verify package verify
import ( import (
"os"
"testing" "testing"
) )
func loadABIKernel(t *testing.T) *Kernel { func loadABIKernel(t *testing.T) *Kernel {
t.Helper() t.Helper()
requireHost(t, "amd64")
k, err := Load("../testdata/verify/abi_amd64.s") k, err := Load("../testdata/verify/abi_amd64.s")
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
@@ -93,3 +95,85 @@ func TestCallFuncCheckedErrors(t *testing.T) {
t.Fatal("expected error for too-small arg block") t.Fatal("expected error for too-small arg block")
} }
} }
// loadCanaryKernel writes an inline kernel pair that exercises the canary
// geometry against declared frames: frameLocal owns a $96 local frame and
// writes its lowest local (96 bytes below the entry stack pointer, well
// past the 64-byte call margin a frame-0 kernel gets); belowFrame writes
// 136 bytes below its own frame, deep into the canary window.
func loadCanaryKernel(t *testing.T) *Kernel {
t.Helper()
requireHost(t, "amd64")
src := `#include "textflag.h"
// func frameLocal(x int64) int64
TEXT ·frameLocal(SB), NOSPLIT, $96-16
MOVQ x+0(FP), AX
MOVQ AX, l-96(SP)
MOVQ l-96(SP), AX
MOVQ AX, ret+8(FP)
RET
// func belowFrame(x int64) int64
TEXT ·belowFrame(SB), NOSPLIT, $96-16
MOVQ $1, -136(SP)
MOVQ x+0(FP), AX
MOVQ AX, ret+8(FP)
RET
`
file := t.TempDir() + "/canary_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)
return k
}
// TestCallCheckedFrameLegal checks that a kernel whose declared frame
// extends below the fixed 64-byte margin does not trip the canary: the
// protected gap must adapt to the frame the TEXT directive declares.
func TestCallCheckedFrameLegal(t *testing.T) {
k := loadCanaryKernel(t)
args := make([]byte, 16)
PutUint64(args, 0, 42)
out, report, err := k.CallFuncChecked("frameLocal", args)
if err != nil {
t.Fatalf("CallFuncChecked(frameLocal): %v", err)
}
if got := int64(GetUint64(out, 8)); got != 42 {
t.Errorf("frameLocal(42) = %d, want 42", got)
}
if report.RedZoneHit {
t.Error("frameLocal: writing its own $96 frame must not count as a red-zone hit")
}
if !report.OK() {
t.Errorf("frameLocal: %s", report)
}
}
// TestCallCheckedBelowFrameCaught checks the other side of the adaptive
// gap: a kernel that writes below its own frame by more than the call
// margin must still be reported.
func TestCallCheckedBelowFrameCaught(t *testing.T) {
k := loadCanaryKernel(t)
args := make([]byte, 16)
PutUint64(args, 0, 42)
out, report, err := k.CallFuncChecked("belowFrame", args)
if err != nil {
t.Fatalf("CallFuncChecked(belowFrame): %v", err)
}
if got := int64(GetUint64(out, 8)); got != 42 {
t.Errorf("belowFrame(42) = %d, want 42", got)
}
if !report.RedZoneHit {
t.Error("belowFrame: expected RedZoneHit for a write below the declared frame")
}
}
+48 -9
View File
@@ -20,7 +20,11 @@ type BufSpec struct {
} }
// ParseBufSpec parses a "name:size:pattern[,name:size:pattern]" spec string // ParseBufSpec parses a "name:size:pattern[,name:size:pattern]" spec string
// into individual buffer specs. Empty input yields an empty slice. // into individual buffer specs. Empty input yields an empty slice. The
// size must be a plain decimal number over the whole field and the pattern
// must be a known name or a valid hex blob, so typos fail here with the
// offending spec in the message rather than silently allocating a zeroed
// buffer.
func ParseBufSpec(spec string) ([]BufSpec, error) { func ParseBufSpec(spec string) ([]BufSpec, error) {
if spec == "" { if spec == "" {
return nil, nil return nil, nil
@@ -31,15 +35,38 @@ func ParseBufSpec(spec string) ([]BufSpec, error) {
if len(fields) != 3 { if len(fields) != 3 {
return nil, fmt.Errorf("verify: invalid buffer spec %q (expected name:size:pattern)", part) return nil, fmt.Errorf("verify: invalid buffer spec %q (expected name:size:pattern)", part)
} }
var size int // strconv.Atoi parses the whole field, unlike fmt.Sscanf which
if _, err := fmt.Sscanf(fields[1], "%d", &size); err != nil || size <= 0 { // accepts trailing garbage ("1024abc" parsed as 1024).
size, err := strconv.Atoi(fields[1])
if err != nil || size <= 0 {
return nil, fmt.Errorf("verify: invalid buffer size %q in %q", fields[1], part) return nil, fmt.Errorf("verify: invalid buffer size %q in %q", fields[1], part)
} }
if err := validPattern(fields[2]); err != nil {
return nil, fmt.Errorf("verify: invalid pattern %q in %q: %v", fields[2], part, err)
}
out = append(out, BufSpec{Name: fields[0], Size: size, Pattern: fields[2]}) out = append(out, BufSpec{Name: fields[0], Size: size, Pattern: fields[2]})
} }
return out, nil return out, nil
} }
// validPattern checks one buffer pattern: a known name, or a non-empty hex
// blob. fillBuffer enforces the same rule again at fill time for specs
// that were not built by ParseBufSpec.
func validPattern(pattern string) error {
switch pattern {
case "zero", "ones", "seq":
return nil
}
data, err := hex.DecodeString(pattern)
if err != nil {
return fmt.Errorf("not a known pattern (zero, ones, seq) and not hex: %w", err)
}
if len(data) == 0 {
return fmt.Errorf("not a known pattern (zero, ones, seq) and the hex blob is empty")
}
return nil
}
// allocatedBuf is one live buffer in a pool. // allocatedBuf is one live buffer in a pool.
type allocatedBuf struct { type allocatedBuf struct {
spec BufSpec spec BufSpec
@@ -60,11 +87,15 @@ type BufPool struct {
} }
// Alloc allocates and fills the buffers described by specs. The returned // Alloc allocates and fills the buffers described by specs. The returned
// pool must be kept alive until every call using it has returned. // pool must be kept alive until every call using it has returned. An
// unknown pattern name or invalid hex blob is an error rather than a
// silently zeroed buffer.
func (p *BufPool) Alloc(specs []BufSpec) error { func (p *BufPool) Alloc(specs []BufSpec) error {
for _, s := range specs { for _, s := range specs {
data := make([]byte, s.Size+safetyMargin) data := make([]byte, s.Size+safetyMargin)
fillBuffer(data, s.Pattern) if err := fillBuffer(data, s.Pattern); err != nil {
return fmt.Errorf("verify: buffer %q: %w", s.Name, err)
}
p.bufs = append(p.bufs, allocatedBuf{spec: s, data: data}) p.bufs = append(p.bufs, allocatedBuf{spec: s, data: data})
} }
return nil return nil
@@ -128,8 +159,10 @@ func (p *BufPool) matchBuf(name string) *allocatedBuf {
} }
// fillBuffer fills buf with the named pattern: "zero" (no-op, already zeroed), // fillBuffer fills buf with the named pattern: "zero" (no-op, already zeroed),
// "ones" (0xFF), "seq" (i mod 256), or a hex blob repeated to fill. // "ones" (0xFF), "seq" (i mod 256), or a hex blob repeated to fill. An
func fillBuffer(buf []byte, pattern string) { // unknown pattern name or undecodable hex returns an error instead of
// leaving the buffer silently zeroed.
func fillBuffer(buf []byte, pattern string) error {
switch pattern { switch pattern {
case "zero": case "zero":
// Already zeroed by make. // Already zeroed by make.
@@ -142,12 +175,18 @@ func fillBuffer(buf []byte, pattern string) {
buf[i] = byte(i) buf[i] = byte(i)
} }
default: default:
if data, err := hex.DecodeString(pattern); err == nil && len(data) > 0 { data, err := hex.DecodeString(pattern)
if err != nil {
return fmt.Errorf("unknown pattern (want zero, ones, seq or hex): %w", err)
}
if len(data) == 0 {
return fmt.Errorf("unknown pattern (want zero, ones, seq or hex): empty hex blob")
}
for i := range buf { for i := range buf {
buf[i] = data[i%len(data)] buf[i] = data[i%len(data)]
} }
} }
} return nil
} }
// ApplyScalarArgs writes user-supplied scalar argument values into an ABI0 // ApplyScalarArgs writes user-supplied scalar argument values into an ABI0
+42
View File
@@ -115,6 +115,48 @@ func TestParseBufSpec(t *testing.T) {
t.Error("ParseBufSpec(\"dst:0:zero\") should error on zero size") t.Error("ParseBufSpec(\"dst:0:zero\") should error on zero size")
} }
}) })
t.Run("trailing-garbage-size", func(t *testing.T) {
// fmt.Sscanf("%d") accepted "1024abc" as 1024; the strict parse
// must reject the whole field.
if _, err := ParseBufSpec("dst:1024abc:zero"); err == nil {
t.Error("ParseBufSpec(\"dst:1024abc:zero\") should reject a size with trailing garbage")
}
})
t.Run("hex-size", func(t *testing.T) {
if _, err := ParseBufSpec("dst:0x40:zero"); err == nil {
t.Error("ParseBufSpec(\"dst:0x40:zero\") should reject a non-decimal size")
}
})
t.Run("unknown-pattern", func(t *testing.T) {
if _, err := ParseBufSpec("dst:64:wibble"); err == nil {
t.Error("ParseBufSpec(\"dst:64:wibble\") should reject an unknown pattern")
}
})
t.Run("bad-hex-pattern", func(t *testing.T) {
if _, err := ParseBufSpec("dst:64:zz"); err == nil {
t.Error("ParseBufSpec(\"dst:64:zz\") should reject undecodable hex")
}
})
t.Run("empty-pattern", func(t *testing.T) {
if _, err := ParseBufSpec("dst:64:"); err == nil {
t.Error("ParseBufSpec(\"dst:64:\") should reject an empty pattern")
}
})
t.Run("hex-pattern", func(t *testing.T) {
specs, err := ParseBufSpec("dst:6:deadbeef")
if err != nil {
t.Fatal(err)
}
if len(specs) != 1 || specs[0].Pattern != "deadbeef" {
t.Errorf("ParseBufSpec(\"dst:6:deadbeef\") = %+v", specs)
}
})
} }
func TestBufPoolBuildArgs(t *testing.T) { func TestBufPoolBuildArgs(t *testing.T) {
+4
View File
@@ -58,6 +58,10 @@ const stackPad = 64
// //
// The function must be NOSPLIT (no stack growth) and must not reference // The function must be NOSPLIT (no stack growth) and must not reference
// external symbols, the image is self-contained. // external symbols, the image is self-contained.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals
// (savedSP and friends).
func Call(fnAddr uintptr, args []byte) ([]byte, error) { func Call(fnAddr uintptr, args []byte) ([]byte, error) {
// Prepare the stack: [padding][leaveJIT addr][args...] // Prepare the stack: [padding][leaveJIT addr][args...]
stackSize := stackPad + 8 + len(args) + 64 // padding + ret + args + safety stackSize := stackPad + 8 + len(args) + 64 // padding + ret + args + safety
+3
View File
@@ -34,6 +34,9 @@ const stackPad = 64
// Call invokes the assembled function at fnAddr with the given ABI0 argument // Call invokes the assembled function at fnAddr with the given ABI0 argument
// block. On arm64, the ABI0 convention places arguments on the stack starting // block. On arm64, the ABI0 convention places arguments on the stack starting
// at [SP+8] (same as amd64 ABI0 in this project). // at [SP+8] (same as amd64 ABI0 in this project).
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func Call(fnAddr uintptr, args []byte) ([]byte, error) { func Call(fnAddr uintptr, args []byte) ([]byte, error) {
stackSize := stackPad + 8 + len(args) + 64 stackSize := stackPad + 8 + len(args) + 64
stackMem, err := syscall.Mmap(-1, 0, stackSize, stackMem, err := syscall.Mmap(-1, 0, stackSize,
+9 -5
View File
@@ -8,7 +8,6 @@ package verify
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"reflect"
"syscall" "syscall"
"unsafe" "unsafe"
) )
@@ -19,15 +18,20 @@ func enterJIT(fn uintptr, stack uintptr)
//go:nosplit //go:nosplit
func leaveJIT() func leaveJIT()
var leaveJITAddr uintptr // leaveRawAddr is the raw ABI0 address of leaveJIT, handed over by the
// GLOBL/DATA in abi_loong64.s (reflect would return the interposed
// ABIInternal wrapper instead: its prologue clobbers the saved-register
// window the JIT call depends on).
var leaveRawAddr uintptr
func init() { var leaveJITAddr = leaveRawAddr
leaveJITAddr = reflect.ValueOf(leaveJIT).Pointer()
}
const stackPad = 64 const stackPad = 64
// Call invokes the assembled function at fnAddr with the given ABI0 argument block. // Call invokes the assembled function at fnAddr with the given ABI0 argument block.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func Call(fnAddr uintptr, args []byte) ([]byte, error) { func Call(fnAddr uintptr, args []byte) ([]byte, error) {
stackSize := stackPad + 8 + len(args) + 64 stackSize := stackPad + 8 + len(args) + 64
stackMem, err := syscall.Mmap(-1, 0, stackSize, stackMem, err := syscall.Mmap(-1, 0, stackSize,
+2 -2
View File
@@ -7,7 +7,7 @@ package verify
import "fmt" import "fmt"
// Call is unavailable on non-amd64 architectures. // Call is unavailable off the four supported architectures.
func Call(fnAddr uintptr, args []byte) ([]byte, error) { func Call(fnAddr uintptr, args []byte) ([]byte, error) {
return nil, fmt.Errorf("verify: JIT execution requires amd64") return nil, fmt.Errorf("verify: JIT execution requires amd64, arm64, riscv64 or loong64")
} }
+3
View File
@@ -28,6 +28,9 @@ func init() {
const stackPad = 64 const stackPad = 64
// Call invokes the assembled function at fnAddr with the given ABI0 argument block. // Call invokes the assembled function at fnAddr with the given ABI0 argument block.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func Call(fnAddr uintptr, args []byte) ([]byte, error) { func Call(fnAddr uintptr, args []byte) ([]byte, error) {
stackSize := stackPad + 8 + len(args) + 64 stackSize := stackPad + 8 + len(args) + 64
stackMem, err := syscall.Mmap(-1, 0, stackSize, stackMem, err := syscall.Mmap(-1, 0, stackSize,
+80 -2
View File
@@ -4,8 +4,8 @@
package verify package verify
import ( import (
"encoding/hex"
"encoding/json" "encoding/json"
"math/rand"
"os" "os"
"runtime" "runtime"
"strconv" "strconv"
@@ -14,6 +14,7 @@ import (
func loadBasicKernel(t *testing.T) *Kernel { func loadBasicKernel(t *testing.T) *Kernel {
t.Helper() t.Helper()
requireHost(t, "amd64")
k, err := Load("../testdata/verify/basic_amd64.s") k, err := Load("../testdata/verify/basic_amd64.s")
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
@@ -71,7 +72,7 @@ func TestGenDualArgsEntryReplayable(t *testing.T) {
if !ok { if !ok {
t.Fatal("parseFuncSig failed") t.Fatal("parseFuncSig failed")
} }
_, _, bufs, entry := genDualArgs(rand.New(rand.NewSource(1)), sig, 24) _, _, bufs, entry := genDualArgs(newRNG(1), sig, 24)
if len(entry.Args) != 2 || entry.Args[0].Kind != "int" { if len(entry.Args) != 2 || entry.Args[0].Kind != "int" {
t.Fatalf("unexpected entry: %+v", entry) t.Fatalf("unexpected entry: %+v", entry)
} }
@@ -96,11 +97,88 @@ func entryInt(t *testing.T, a CorpusArg) int64 {
return int64(v) return int64(v)
} }
// strProbeSrc is a kernel that consumes an ABI0 string header: it
// dereferences the data pointer (proving it points at live memory) and
// returns len(s) + s[0] when the string is non-empty, len(s) otherwise.
const strProbeSrc = `#include "textflag.h"
// func strProbe(s string) int64
TEXT ·strProbe(SB), NOSPLIT, $0-24
MOVQ s_base+0(FP), SI
MOVQ s_len+8(FP), CX
XORQ AX, AX
TESTQ CX, CX
JZ probe_done
MOVB (SI), AL
ADDQ AX, CX
probe_done:
MOVQ CX, ret+16(FP)
RET
`
func loadStrProbeKernel(t *testing.T) *Kernel {
t.Helper()
requireHost(t, "amd64")
file := t.TempDir() + "/strprobe_amd64.s"
if err := os.WriteFile(file, []byte(strProbeSrc), 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)
return k
}
// TestStringParamRoundTrip pins the ABI0 string marshalling end to end:
// genDualArgs lays a string parameter out as a two-word header pointing at
// a live buffer, the corpus entry records it, and ReplayEntry rebuilds an
// equivalent header.
func TestStringParamRoundTrip(t *testing.T) {
k := loadStrProbeKernel(t)
sig, ok := parseFuncSig("// func strProbe(s string) int64")
if !ok {
t.Fatal("parseFuncSig failed")
}
args, _, bufs, entry := genDualArgs(newRNG(7), sig, 24)
if len(entry.Args) != 1 || entry.Args[0].Kind != "string" {
t.Fatalf("unexpected entry: %+v", entry)
}
out, err := k.CallFunc("strProbe", args)
if err != nil {
t.Fatalf("CallFunc: %v", err)
}
live := int64(GetUint64(out, 16))
want := int64(entry.Args[0].Len)
if d, err := hex.DecodeString(entry.Args[0].Data); err != nil {
t.Fatalf("entry data: %v", err)
} else if len(d) > 0 {
want += int64(d[0])
}
if live != want {
t.Errorf("live call = %d, want %d (len + first byte)", live, want)
}
replayed, err := k.ReplayEntry("strProbe", entry)
if err != nil {
t.Fatalf("ReplayEntry: %v", err)
}
if got := int64(GetUint64(replayed, 16)); got != want {
t.Errorf("replayed call = %d, want %d", got, want)
}
runtime.KeepAlive(bufs)
}
// TestFuzzHookSavesFailures fuzzes add against the go-tool-asm build of a // TestFuzzHookSavesFailures fuzzes add against the go-tool-asm build of a
// sub kernel with the same signature, so every iteration mismatches (safely: // sub kernel with the same signature, so every iteration mismatches (safely:
// both kernels read only their own arguments) and the hook must record // both kernels read only their own arguments) and the hook must record
// replayable entries. // replayable entries.
func TestFuzzHookSavesFailures(t *testing.T) { func TestFuzzHookSavesFailures(t *testing.T) {
requireHost(t, "amd64")
src := `#include "textflag.h" src := `#include "textflag.h"
// func add(a, b int) int // func add(a, b int) int
+3 -3
View File
@@ -5,7 +5,7 @@ package verify
import ( import (
"fmt" "fmt"
"sort" "slices"
) )
// Block describes one basic block within a function: a maximal sequence of // Block describes one basic block within a function: a maximal sequence of
@@ -13,7 +13,7 @@ import (
// a single exit (a jump, conditional jump or RET). // a single exit (a jump, conditional jump or RET).
type Block struct { type Block struct {
Offset int // byte offset within the function Offset int // byte offset within the function
Label string // label name ("" for the entry block) Label string // label name ("(entry)" for the function entry block)
} }
// Blocks identifies the basic blocks of a function from its local labels. // Blocks identifies the basic blocks of a function from its local labels.
@@ -40,7 +40,7 @@ func (k *Kernel) Blocks(name string) ([]Block, error) {
for off := range offToLabel { for off := range offToLabel {
offsets = append(offsets, off) offsets = append(offsets, off)
} }
sort.Ints(offsets) slices.Sort(offsets)
for _, off := range offsets { for _, off := range offsets {
blocks = append(blocks, Block{Offset: off, Label: offToLabel[off]}) blocks = append(blocks, Block{Offset: off, Label: offToLabel[off]})
} }
+110 -11
View File
@@ -6,7 +6,7 @@ package verify
import ( import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"math/rand" "math/rand/v2"
"regexp" "regexp"
"runtime" "runtime"
"strconv" "strconv"
@@ -14,6 +14,28 @@ import (
"unsafe" "unsafe"
) )
// newRNG builds the deterministic generator for a fuzz seed. PCG seeds
// with two 64-bit words; deriving the second from the first keeps one seed
// one stream and rules out the all-zero seed. The sequences differ from
// the retired math/rand ones for the same seed, but remain reproducible
// run to run, which is the property the fuzzers rely on.
func newRNG(seed int64) *rand.Rand {
lo := uint64(seed)
return rand.New(rand.NewPCG(lo, ^lo))
}
// fillRandom fills buf from rng, eight bytes per draw. math/rand/v2
// dropped Read from *rand.Rand, and this loop keeps the byte sequence a
// pure function of the generator state.
func fillRandom(rng *rand.Rand, buf []byte) {
for off := 0; off < len(buf); off += 8 {
v := rng.Uint64()
for j := 0; j < 8 && off+j < len(buf); j++ {
buf[off+j] = byte(v >> (8 * uint(j)))
}
}
}
// FuzzResult reports the outcome of a differential fuzz campaign for one // FuzzResult reports the outcome of a differential fuzz campaign for one
// function. // function.
type FuzzResult struct { type FuzzResult struct {
@@ -43,9 +65,9 @@ func (r FuzzResult) String() string {
// CorpusArg is one replayable argument of a corpus entry. // CorpusArg is one replayable argument of a corpus entry.
type CorpusArg struct { type CorpusArg struct {
Kind string `json:"kind"` // "slice", "ptr", "int", "scalar" Kind string `json:"kind"` // "slice", "string", "ptr", "int", "scalar"
Len int `json:"len,omitempty"` // slice: declared length in elements Len int `json:"len,omitempty"` // slice: declared length in elements; string: length in bytes
Data string `json:"data,omitempty"` // slice/ptr: hex-encoded buffer content Data string `json:"data,omitempty"` // slice/string/ptr: hex-encoded buffer content
Value string `json:"value,omitempty"` // int/scalar: decimal value Value string `json:"value,omitempty"` // int/scalar: decimal value
} }
@@ -175,6 +197,9 @@ func ExtractSignatures(src string) map[string]funcSig {
// //
// The signature comment must appear immediately above the TEXT directive // The signature comment must appear immediately above the TEXT directive
// in the source (the conventional Go assembly layout). // in the source (the conventional Go assembly layout).
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations int, seed int64) FuzzResult { func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations int, seed int64) FuzzResult {
return k.FuzzFuncHook(name, sig, goCode, iterations, seed, nil) return k.FuzzFuncHook(name, sig, goCode, iterations, seed, nil)
} }
@@ -182,10 +207,13 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
// FuzzFuncHook is FuzzFunc with a hook invoked for every failing input (a // FuzzFuncHook is FuzzFunc with a hook invoked for every failing input (a
// crash or a mismatch), receiving a replayable corpus entry. A nil hook // crash or a mismatch), receiving a replayable corpus entry. A nil hook
// behaves exactly like FuzzFunc. // behaves exactly like FuzzFunc.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iterations int, seed int64, onSave func(CorpusEntry)) FuzzResult { func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iterations int, seed int64, onSave func(CorpusEntry)) FuzzResult {
result := FuzzResult{Func: name, Iterations: iterations} result := FuzzResult{Func: name, Iterations: iterations}
rng := rand.New(rand.NewSource(seed)) rng := newRNG(seed)
// Map the Go-assembled code into a second executable region. // Map the Go-assembled code into a second executable region.
goExec, err := Map(goCode) goExec, err := Map(goCode)
@@ -203,6 +231,18 @@ func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iteration
return result return result
} }
// The result comparison below slices the parameter area off the
// argument block; a // func comment declaring more parameter bytes
// than the TEXT frame carries would slice past its end and panic.
// Fail the whole campaign with a clear message instead.
if ps := paramsSize(sig); ps > fl.Args {
result.Mismatches = iterations
result.FirstFail = fmt.Sprintf(
"signature declares %d parameter bytes, but the TEXT frame of %s carries %d argument bytes",
ps, name, fl.Args)
return result
}
for i := range iterations { for i := range iterations {
// Generate inputs and build TWO independent arg blocks (one per // Generate inputs and build TWO independent arg blocks (one per
// version) so that functions which write to their arguments // version) so that functions which write to their arguments
@@ -276,7 +316,7 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
switch { switch {
case strings.HasPrefix(p.typ, "[]"): case strings.HasPrefix(p.typ, "[]"):
elemSize := elemSizeFor(p.typ) elemSize := elemSizeFor(p.typ)
n := 1 + rng.Intn(127) n := 1 + rng.IntN(127)
var declaredLen int var declaredLen int
if sliceIdx == 0 { if sliceIdx == 0 {
declaredLen = n declaredLen = n
@@ -290,7 +330,7 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
// Two independent buffers with identical random content. // Two independent buffers with identical random content.
buf1 := make([]byte, bufBytes) buf1 := make([]byte, bufBytes)
buf2 := make([]byte, bufBytes) buf2 := make([]byte, bufBytes)
rng.Read(buf1[:n*elemSize]) fillRandom(rng, buf1[:n*elemSize])
copy(buf2, buf1) copy(buf2, buf1)
bufs = append(bufs, buf1, buf2) bufs = append(bufs, buf1, buf2)
putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0]))
@@ -309,13 +349,35 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
Data: hex.EncodeToString(buf1[:n*elemSize]), Data: hex.EncodeToString(buf1[:n*elemSize]),
}) })
case p.typ == "string":
// An ABI0 string is a two-word header (data pointer +
// length); a random pointer would fault kernels that read
// the string, so the header points at a real buffer with
// the same safety margin slices get.
n := 1 + rng.IntN(127)
buf1 := make([]byte, n+8192)
buf2 := make([]byte, n+8192)
fillRandom(rng, buf1[:n])
copy(buf2, buf1)
bufs = append(bufs, buf1, buf2)
putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0]))
putPtr(goArgs, off, unsafe.Pointer(&buf2[0]))
putU64(gasmArgs, off+8, uint64(n))
putU64(goArgs, off+8, uint64(n))
off += 16
entry.Args = append(entry.Args, CorpusArg{
Kind: "string",
Len: n,
Data: hex.EncodeToString(buf1[:n]),
})
case strings.HasPrefix(p.typ, "*["): case strings.HasPrefix(p.typ, "*["):
nElem := arrayLen(p.typ) nElem := arrayLen(p.typ)
elem := elemSizeFor("[]" + p.typ[strings.Index(p.typ, "]")+1:]) elem := elemSizeFor("[]" + p.typ[strings.Index(p.typ, "]")+1:])
size := max(nElem*elem, 8) size := max(nElem*elem, 8)
buf1 := make([]byte, size) buf1 := make([]byte, size)
buf2 := make([]byte, size) buf2 := make([]byte, size)
rng.Read(buf1) fillRandom(rng, buf1)
copy(buf2, buf1) copy(buf2, buf1)
bufs = append(bufs, buf1, buf2) bufs = append(bufs, buf1, buf2)
putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0]))
@@ -327,12 +389,24 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
}) })
case p.typ == "int" || p.typ == "uint" || p.typ == "int64" || p.typ == "uint64": case p.typ == "int" || p.typ == "uint" || p.typ == "int64" || p.typ == "uint64":
v := uint64(rng.Intn(256)) v := uint64(rng.IntN(256))
putU64(gasmArgs, off, v) putU64(gasmArgs, off, v)
putU64(goArgs, off, v) putU64(goArgs, off, v)
off += 8 off += 8
entry.Args = append(entry.Args, CorpusArg{Kind: "int", Value: strconv.FormatUint(v, 10)}) entry.Args = append(entry.Args, CorpusArg{Kind: "int", Value: strconv.FormatUint(v, 10)})
case p.typ == "complex64", p.typ == "complex128":
// complex64 is two float32s (8 bytes), complex128 two
// float64s (16): plain data words to the marshaller, one
// corpus scalar per word so replay rebuilds them exactly.
for range paramSize(p.typ) / 8 {
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)})
}
default: default:
v := rng.Uint64() v := rng.Uint64()
putU64(gasmArgs, off, v) putU64(gasmArgs, off, v)
@@ -346,8 +420,12 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b
// ReplayEntry rebuilds the argument block of a corpus entry and invokes the // ReplayEntry rebuilds the argument block of a corpus entry and invokes the
// named function once, returning the argument block after the call. Slice // named function once, returning the argument block after the call. Slice
// buffers get the same safety padding the fuzzer uses, so over-reads that // and string buffers get the same safety padding the fuzzer uses, so
// were harmless during the original run stay harmless on replay. // over-reads that were harmless during the original run stay harmless on
// replay.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) { func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) {
fl, err := k.Func(name) fl, err := k.Func(name)
if err != nil { if err != nil {
@@ -374,6 +452,21 @@ func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) {
putU64(args, off+16, uint64(a.Len)) putU64(args, off+16, uint64(a.Len))
off += 24 off += 24
case "string":
data, err := hex.DecodeString(a.Data)
if err != nil {
return nil, fmt.Errorf("corpus: string data: %w", err)
}
buf := make([]byte, len(data)+8192)
copy(buf, data)
bufs = append(bufs, buf)
if off+16 > 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))
off += 16
case "ptr": case "ptr":
data, err := hex.DecodeString(a.Data) data, err := hex.DecodeString(a.Data)
if err != nil { if err != nil {
@@ -431,6 +524,12 @@ func paramsSize(sig funcSig) int {
size += 8 // pointer size += 8 // pointer
case p.typ == "bool": case p.typ == "bool":
size += 1 size += 1
case p.typ == "string":
size += 16 // data pointer + length
case p.typ == "complex64":
size += 8 // two float32s
case p.typ == "complex128":
size += 16 // two float64s
default: default:
size += 8 // int, uint, etc. size += 8 // int, uint, etc.
} }
+71 -4
View File
@@ -90,6 +90,16 @@ func TestParamsSize(t *testing.T) {
if got := paramsSize(sig); got != 32 { if got := paramsSize(sig); got != 32 {
t.Errorf("paramsSize = %d, want 32 (24 for slice + 8 for int)", got) t.Errorf("paramsSize = %d, want 32 (24 for slice + 8 for int)", got)
} }
// ABI0 strings are ptr+len (16), complex64 packs two float32s (8),
// complex128 two float64s (16).
sig = funcSig{
name: "hdr",
params: []param{{name: "s", typ: "string"}, {name: "c64", typ: "complex64"}, {name: "c128", typ: "complex128"}},
}
if got := paramsSize(sig); got != 40 {
t.Errorf("paramsSize = %d, want 40 (16 string + 8 complex64 + 16 complex128)", got)
}
} }
func TestBlockCount(t *testing.T) { func TestBlockCount(t *testing.T) {
@@ -103,11 +113,34 @@ func TestBlockCount(t *testing.T) {
} }
} }
// TestNewRNGDeterministic pins the reproducibility contract of the fuzz
// seeds: the same seed must rebuild the same PCG stream, including the
// sub-word tail of fillRandom.
func TestNewRNGDeterministic(t *testing.T) {
draw := func() []byte {
rng := newRNG(7)
out := make([]byte, 20) // 8+8+4: exercises a full word and a tail
fillRandom(rng, out)
return out
}
a, b := draw(), draw()
for i := range a {
if a[i] != b[i] {
t.Fatalf("seed 7 produced different bytes at %d: %02x vs %02x", i, a[i], b[i])
}
}
if newRNG(0) == nil {
t.Fatal("newRNG(0) must build a generator")
}
}
func TestFillBuffer(t *testing.T) { func TestFillBuffer(t *testing.T) {
t.Run("zero", func(t *testing.T) { t.Run("zero", func(t *testing.T) {
// fillBuffer("zero") is a no-op; relies on make already zeroing. // fillBuffer("zero") is a no-op; relies on make already zeroing.
buf := make([]byte, 16) buf := make([]byte, 16)
fillBuffer(buf, "zero") if err := fillBuffer(buf, "zero"); err != nil {
t.Fatalf("fillBuffer(zero): %v", err)
}
for _, b := range buf { for _, b := range buf {
if b != 0 { if b != 0 {
t.Error("zero pattern: make should produce zeroed buffer") t.Error("zero pattern: make should produce zeroed buffer")
@@ -117,7 +150,9 @@ func TestFillBuffer(t *testing.T) {
}) })
t.Run("ones", func(t *testing.T) { t.Run("ones", func(t *testing.T) {
buf := make([]byte, 16) buf := make([]byte, 16)
fillBuffer(buf, "ones") if err := fillBuffer(buf, "ones"); err != nil {
t.Fatalf("fillBuffer(ones): %v", err)
}
for _, b := range buf { for _, b := range buf {
if b != 0xFF { if b != 0xFF {
t.Error("ones pattern should fill with 0xFF") t.Error("ones pattern should fill with 0xFF")
@@ -127,7 +162,9 @@ func TestFillBuffer(t *testing.T) {
}) })
t.Run("seq", func(t *testing.T) { t.Run("seq", func(t *testing.T) {
buf := make([]byte, 256) buf := make([]byte, 256)
fillBuffer(buf, "seq") if err := fillBuffer(buf, "seq"); err != nil {
t.Fatalf("fillBuffer(seq): %v", err)
}
for i, b := range buf { for i, b := range buf {
if b != byte(i) { if b != byte(i) {
t.Errorf("seq[%d] = %d, want %d", i, b, i) t.Errorf("seq[%d] = %d, want %d", i, b, i)
@@ -137,7 +174,9 @@ func TestFillBuffer(t *testing.T) {
}) })
t.Run("hex", func(t *testing.T) { t.Run("hex", func(t *testing.T) {
buf := make([]byte, 6) buf := make([]byte, 6)
fillBuffer(buf, "deadbeef") if err := fillBuffer(buf, "deadbeef"); err != nil {
t.Fatalf("fillBuffer(deadbeef): %v", err)
}
want := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD} want := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD}
for i, b := range buf { for i, b := range buf {
if b != want[i] { if b != want[i] {
@@ -146,6 +185,34 @@ func TestFillBuffer(t *testing.T) {
} }
} }
}) })
t.Run("unknown-pattern", func(t *testing.T) {
buf := make([]byte, 8)
if err := fillBuffer(buf, "wibble"); err == nil {
t.Error("fillBuffer(wibble): expected an error for an unknown pattern name")
}
})
t.Run("bad-hex", func(t *testing.T) {
buf := make([]byte, 8)
if err := fillBuffer(buf, "zz"); err == nil {
t.Error("fillBuffer(zz): expected an error for undecodable hex")
}
})
t.Run("empty-hex", func(t *testing.T) {
buf := make([]byte, 8)
if err := fillBuffer(buf, ""); err == nil {
t.Error("fillBuffer(\"\"): expected an error for an empty pattern")
}
})
}
// TestBufPoolAllocBadPattern checks that Alloc surfaces fill errors under
// the buffer's name, so hand-built specs fail as loudly as parsed ones.
func TestBufPoolAllocBadPattern(t *testing.T) {
var pool BufPool
defer pool.Close()
if err := pool.Alloc([]BufSpec{{Name: "dst", Size: 16, Pattern: "nope"}}); err == nil {
t.Fatal("Alloc with an unknown pattern must fail")
}
} }
func TestFuzzFuncChecked(t *testing.T) { func TestFuzzFuncChecked(t *testing.T) {
+68
View File
@@ -4,6 +4,8 @@
package verify package verify
import ( import (
"os"
"strings"
"testing" "testing"
) )
@@ -65,6 +67,72 @@ func TestFuzzWideCopy(t *testing.T) {
} }
} }
// TestFuzzFuncSigWiderThanFrame pins the guard against a // func comment
// that declares more parameter bytes than the TEXT frame carries: before
// the guard, slicing the result area at paramsSize(sig) past the end of
// the argument block panicked the whole test binary.
func TestFuzzFuncSigWiderThanFrame(t *testing.T) {
k := loadBasic(t)
gt, err := GroundTruth("../testdata/verify/basic_amd64.s")
if err != nil {
t.Skipf("go tool asm unavailable: %v", err)
}
goCode, ok := gt["add"]
if !ok {
t.Skip("add not in ground truth")
}
// add carries $0-24; four int parameters declare 32 bytes.
sig := funcSig{
name: "add",
params: []param{
{name: "a", typ: "int"},
{name: "b", typ: "int"},
{name: "c", typ: "int"},
{name: "d", typ: "int"},
},
}
res := k.FuzzFunc("add", sig, goCode, 3, 42)
if res.OK() {
t.Fatal("expected the over-wide signature to fail the campaign")
}
if !strings.Contains(res.FirstFail, "parameter bytes") || !strings.Contains(res.FirstFail, "argument bytes") {
t.Errorf("FirstFail = %q, want a clear signature-versus-frame message", res.FirstFail)
}
}
// TestFuzzStringParam runs the differential fuzzer over a kernel whose
// only parameter is a string: the marshaller lays out a real two-word
// header (data pointer + length) and the comparison slices at
// paramsSize(sig) = 16, so the length word is input, not result.
func TestFuzzStringParam(t *testing.T) {
k := loadStrProbeKernel(t)
file := t.TempDir() + "/strprobe_amd64.s"
if err := os.WriteFile(file, []byte(strProbeSrc), 0o644); err != nil {
t.Fatalf("write kernel: %v", err)
}
gt, err := GroundTruth(file)
if err != nil {
t.Skipf("go tool asm unavailable: %v", err)
}
goCode, ok := gt["strProbe"]
if !ok {
t.Skip("strProbe not in ground truth")
}
sig, ok := parseFuncSig("// func strProbe(s string) int64")
if !ok {
t.Fatal("parseFuncSig failed")
}
res := k.FuzzFunc("strProbe", sig, goCode, 100, 42)
if !res.OK() {
t.Errorf("strProbe fuzz: %s", res)
}
}
func TestParseFuncSig(t *testing.T) { func TestParseFuncSig(t *testing.T) {
tests := []struct { tests := []struct {
comment string comment string
+15 -3
View File
@@ -20,8 +20,8 @@ import (
// This is the universal oracle: any file that `go tool asm` accepts can // This is the universal oracle: any file that `go tool asm` accepts can
// be verified, with no hand-written reference. // be verified, with no hand-written reference.
// //
// For RISC-V sources the assembler is invoked with GOARCH=riscv64; // For the other architectures use the cross-assembly entry points
// the caller must set the architecture via GroundTruthArch. // GroundTruthARM64, GroundTruthRISCV and GroundTruthLOONG64.
func GroundTruth(path string) (map[string][]byte, error) { func GroundTruth(path string) (map[string][]byte, error) {
return groundTruthArch(path, "") return groundTruthArch(path, "")
} }
@@ -82,7 +82,19 @@ func groundTruthArch(path, goarch string) (map[string][]byte, error) {
cmd := exec.Command(asmBin, "-I", includeDir, "-p", pkg, "-o", objPath, path) cmd := exec.Command(asmBin, "-I", includeDir, "-p", pkg, "-o", objPath, path)
if goarch != "" { if goarch != "" {
cmd.Env = append(os.Environ(), "GOARCH="+goarch) // Replace, do not duplicate, any GOARCH the ambient environment
// exports. The Go runtime resolves duplicated keys last-wins,
// so the appended entry happens to win today; a single
// unambiguous entry keeps the child's target architecture from
// depending on that resolution order.
environ := os.Environ()
env := make([]string, 0, len(environ)+1)
for _, e := range environ {
if !strings.HasPrefix(e, "GOARCH=") {
env = append(env, e)
}
}
cmd.Env = append(env, "GOARCH="+goarch)
} }
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("verify: go tool asm (%s): %w\n%s", goarch, err, out) return nil, fmt.Errorf("verify: go tool asm (%s): %w\n%s", goarch, err, out)
+16
View File
@@ -82,6 +82,22 @@ func TestGroundTruthBadFile(t *testing.T) {
} }
} }
// TestGroundTruthCrossEnvGOARCH checks that a GOARCH exported in the
// ambient environment does not override cross-assembly: the child
// assembler must honour the requested GOARCH even when the environment
// already carries a GOARCH entry (os.Getenv reads the first entry, so a
// duplicate would win).
func TestGroundTruthCrossEnvGOARCH(t *testing.T) {
t.Setenv("GOARCH", "amd64")
gt, err := GroundTruthARM64("../testdata/verify/basic_arm64.s")
if err != nil {
t.Fatalf("GroundTruthARM64 with ambient GOARCH=amd64: %v", err)
}
if len(gt) == 0 {
t.Fatal("no functions extracted from ground truth")
}
}
func keys(m map[string][]byte) []string { func keys(m map[string][]byte) []string {
out := make([]string, 0, len(m)) out := make([]string, 0, len(m))
for k := range m { for k := range m {
+1
View File
@@ -12,6 +12,7 @@ import (
func loadBasic(t *testing.T) *Kernel { func loadBasic(t *testing.T) *Kernel {
t.Helper() t.Helper()
requireHost(t, "amd64")
k, err := Load("../testdata/verify/basic_amd64.s") k, err := Load("../testdata/verify/basic_amd64.s")
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
+7
View File
@@ -153,6 +153,13 @@ func paramSize(typ string) int {
return 2 return 2
case typ == "int8", typ == "uint8", typ == "byte", typ == "bool": case typ == "int8", typ == "uint8", typ == "byte", typ == "bool":
return 1 return 1
case typ == "string":
// ABI0 strings are a two-word header: data pointer + length.
return 16
case typ == "complex64":
return 8
case typ == "complex128":
return 16
default: default:
// Default to 8 bytes for unknown types. // Default to 8 bytes for unknown types.
return 8 return 8
+4 -1
View File
@@ -143,7 +143,10 @@ func TestParamSize(t *testing.T) {
{"uint8", 1}, {"uint8", 1},
{"byte", 1}, {"byte", 1},
{"bool", 1}, {"bool", 1},
{"string", 8}, // unknown type defaults to 8 {"string", 16}, // ABI0 header: data pointer + length
{"complex64", 8},
{"complex128", 16},
{"error", 8}, // unknown type defaults to 8
} }
for _, tt := range tests { for _, tt := range tests {
if got := paramSize(tt.typ); got != tt.want { if got := paramSize(tt.typ); got != tt.want {
+2 -1
View File
@@ -17,7 +17,8 @@
TEXT ·enterJIT(SB), NOSPLIT, $0-16 TEXT ·enterJIT(SB), NOSPLIT, $0-16
MOVD fn+0(FP), R0 // target function address MOVD fn+0(FP), R0 // target function address
MOVD R30, savedLR(SB) // save link register MOVD R30, savedLR(SB) // save link register
MOVD R3, savedSP(SB) // save Go stack pointer MOVD RSP, R3 // save Go stack pointer: R3 relays the value, the
MOVD R3, savedSP(SB) // arm64 assembler cannot store RSP to memory
MOVD stack+8(FP), R3 // load prepared stack pointer MOVD stack+8(FP), R3 // load prepared stack pointer
MOVD 0(R3), R30 // load leaveJIT address into LR MOVD 0(R3), R30 // load leaveJIT address into LR
MOVD R3, RSP // switch to the prepared stack: SP stays on MOVD R3, RSP // switch to the prepared stack: SP stays on
+17 -4
View File
@@ -5,7 +5,6 @@ package verify
import ( import (
"fmt" "fmt"
"math/rand"
"os" "os"
"runtime" "runtime"
@@ -101,6 +100,9 @@ func (k *Kernel) FuncNames() []string {
// The arg block is the raw bytes of the function's argument/result area // The arg block is the raw bytes of the function's argument/result area
// (as declared by the TEXT $frame-args suffix). Returns the arg block // (as declared by the TEXT $frame-args suffix). Returns the arg block
// after the call (with any results written back by the function). // after the call (with any results written back by the function).
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) { func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) {
idx, ok := k.funcs[name] idx, ok := k.funcs[name]
if !ok { if !ok {
@@ -116,7 +118,12 @@ func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) {
// CallFuncChecked invokes the named function with ABI sentinels and a // CallFuncChecked invokes the named function with ABI sentinels and a
// red-zone canary, returning the argument block and an ABIReport that // red-zone canary, returning the argument block and an ABIReport that
// records any callee-saved register or red-zone violations. // records any callee-saved register or red-zone violations. The canary
// gap is sized from the function's declared frame, so legal frame writes
// do not count as violations.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, error) { func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, error) {
idx, ok := k.funcs[name] idx, ok := k.funcs[name]
if !ok { if !ok {
@@ -127,11 +134,14 @@ func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, e
return nil, ABIReport{}, fmt.Errorf("verify: %s: arg block too small: got %d, need %d", name, len(args), fl.Args) return nil, ABIReport{}, fmt.Errorf("verify: %s: arg block too small: got %d, need %d", name, len(args), fl.Args)
} }
fnAddr := k.exec.FuncAddr(fl.Offset) fnAddr := k.exec.FuncAddr(fl.Offset)
return CallChecked(fnAddr, args) return CallCheckedFrame(fnAddr, args, fl.Frame)
} }
// FuzzFuncCheckedByName is like FuzzFuncChecked but extracts the signature // FuzzFuncCheckedByName is like FuzzFuncChecked but extracts the signature
// from the source code internally. // from the source code internally.
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) FuzzFuncCheckedByName(name, src string, iterations int, seed int64) FuzzResult { func (k *Kernel) FuzzFuncCheckedByName(name, src string, iterations int, seed int64) FuzzResult {
result := FuzzResult{Func: name, Iterations: iterations} result := FuzzResult{Func: name, Iterations: iterations}
sig, ok := ExtractSignatures(src)[name] sig, ok := ExtractSignatures(src)[name]
@@ -146,9 +156,12 @@ func (k *Kernel) FuzzFuncCheckedByName(name, src string, iterations int, seed in
// FuzzFuncChecked combines fuzzing with ABI checks: it generates varied // FuzzFuncChecked combines fuzzing with ABI checks: it generates varied
// inputs and verifies that callee-saved registers and the red zone are // inputs and verifies that callee-saved registers and the red zone are
// preserved even on deep execution paths (not just early exits). // preserved even on deep execution paths (not just early exits).
//
// Not safe for concurrent use: only one JIT call may be in flight at a
// time, the trampolines keep the saved registers in package globals.
func (k *Kernel) FuzzFuncChecked(name string, sig funcSig, iterations int, seed int64) FuzzResult { func (k *Kernel) FuzzFuncChecked(name string, sig funcSig, iterations int, seed int64) FuzzResult {
result := FuzzResult{Func: name, Iterations: iterations} result := FuzzResult{Func: name, Iterations: iterations}
rng := rand.New(rand.NewSource(seed)) rng := newRNG(seed)
fl, err := k.Func(name) fl, err := k.Func(name)
if err != nil { if err != nil {