feat(verify): add runtime ABI checks with sentinel registers and red-zone canary

Assisted-by: Qwen 3.8 Max Preview
This commit is contained in:
2026-08-02 23:11:30 +02:00
parent f52e23f1bc
commit f5088c52fc
9 changed files with 464 additions and 2 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ import (
// version is the release version, stamped at build time via // version is the release version, stamped at build time via
// -ldflags "-X main.version=…" (defaulting to the current release). // -ldflags "-X main.version=…" (defaulting to the current release).
var version = "0.18.0" var version = "0.19.0"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
+1 -1
View File
@@ -3,7 +3,7 @@
# gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm).
version := "0.18.0" version := "0.19.0"
default: default:
@just --list @just --list
+28
View File
@@ -0,0 +1,28 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
#include "textflag.h"
// func cleanAdd(a, b int64) int64
// A well-behaved function that preserves all callee-saved registers.
TEXT ·cleanAdd(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
ADDQ b+8(FP), AX
MOVQ AX, ret+16(FP)
RET
// func dirtyBP(a int64) int64
// Deliberately clobbers BP (an ABI violation for a NOSPLIT frame=0 function).
TEXT ·dirtyBP(SB), NOSPLIT, $0-16
MOVQ $0x1234, BP
MOVQ a+0(FP), AX
MOVQ AX, ret+8(FP)
RET
// func dirtyR14(a int64) int64
// Deliberately clobbers R14 (the goroutine pointer — a serious ABI violation).
TEXT ·dirtyR14(SB), NOSPLIT, $0-16
MOVQ $0x5678, R14
MOVQ a+0(FP), AX
MOVQ AX, ret+8(FP)
RET
+130
View File
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
//go:build amd64
package verify
import (
"encoding/binary"
"fmt"
"syscall"
"unsafe"
)
// abiResult records register-clobber violations detected by the ABI-checking
// trampoline. Bit 0: BP clobbered. Bit 1: R14 clobbered.
var abiResult uint64
// savedBP holds the caller's frame pointer across the ABI-checked JIT call.
// Referenced by enterJITChecked to satisfy go vet's save-before-clobber rule.
var savedBP uintptr
// leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in
// abi_amd64.s: it holds the raw address of leaveJITCheckedRaw (which has
// no ABIInternal wrapper, so the JIT function RETs directly into it).
var leaveCheckedPtr uintptr
// enterJITChecked sets sentinels in BP and R14, switches to the prepared
// stack and jumps to fn.
//
//go:nosplit
func enterJITChecked(fn uintptr, stack uintptr)
// leaveJITCheckedRaw is the raw return trampoline for ABI checks. Its
// address is obtained from the GLOBL in abi_amd64.s (leaveCheckedPtr),
// which points to the .abi0 code — NOT the ABIInternal wrapper that this
// declaration would generate. The declaration exists solely to satisfy
// go vet's "missing Go declaration" check.
//
//go:nosplit
func leaveJITCheckedRaw()
// ABIReport describes the result of an ABI-checking call.
type ABIReport struct {
BPClobbered bool // BP was modified by the function
R14Clobbered bool // R14 (goroutine pointer) was modified
RedZoneHit bool // the 128-byte red zone below SP was written
}
// OK returns true when no violations were detected.
func (r ABIReport) OK() bool {
return !r.BPClobbered && !r.R14Clobbered && !r.RedZoneHit
}
// String returns a human-readable summary.
func (r ABIReport) String() string {
if r.OK() {
return "ABI clean"
}
s := "ABI violation:"
if r.BPClobbered {
s += " BP clobbered"
}
if r.R14Clobbered {
s += " R14 clobbered"
}
if r.RedZoneHit {
s += " red-zone written"
}
return s
}
// redZoneSize is the System V AMD64 red zone: 128 bytes below SP that a
// leaf function may use without adjusting SP. Go does not use the red zone,
// so any write there is a bug.
const redZoneSize = 128
// redZoneFill is the byte pattern used to detect red-zone writes.
const redZoneFill = 0xA5
// CallChecked invokes the function with ABI sentinels and a red-zone
// canary, returning both the argument block (with results) and an ABIReport.
func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
report := ABIReport{}
// Reset the global result.
abiResult = 0
// Prepare the stack: [red-zone canary][padding][leaveJITCheckedRaw][args...]
// The red zone sits below the initial SP, so the function would have to
// write below SP to corrupt it.
totalSize := redZoneSize + stackPad + 8 + len(args) + 64
stackMem, err := syscall.Mmap(-1, 0, totalSize,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
if err != nil {
return nil, report, fmt.Errorf("verify: stack mmap: %w", err)
}
defer syscall.Munmap(stackMem)
// Fill the red zone with the canary pattern.
for i := 0; i < redZoneSize; i++ {
stackMem[i] = redZoneFill
}
// Return address and args after the red zone and padding.
retOff := redZoneSize + stackPad
binary.LittleEndian.PutUint64(stackMem[retOff:retOff+8], uint64(leaveCheckedPtr))
copy(stackMem[retOff+8:], args)
stackBase := uintptr(unsafe.Pointer(&stackMem[retOff]))
enterJITChecked(fnAddr, stackBase)
// Read the register-clobber result.
res := abiResult
report.BPClobbered = res&1 != 0
report.R14Clobbered = res&2 != 0
// Check the red zone.
for i := 0; i < redZoneSize; i++ {
if stackMem[i] != redZoneFill {
report.RedZoneHit = true
break
}
}
// Copy out the argument area.
out := make([]byte, len(args))
copy(out, stackMem[retOff+8:retOff+8+len(args)])
return out, report, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
#include "textflag.h"
// ABI-checking trampoline. Sets sentinel values in the callee-saved
// registers (BP, R14) before entering the JIT function and checks whether
// they survived on return.
//
// The return trampoline (leaveJITCheckedRaw) is a raw TEXT symbol with no
// Go function declaration, so the toolchain does NOT interpose an
// ABIInternal wrapper — the JIT function RETs directly into the check code,
// which sees the registers exactly as the function left them.
//
// Go ABI0 on amd64 guarantees:
// - BP is callee-saved (NOSPLIT frame=0 functions must not touch it).
// - R14 holds the goroutine pointer and must survive across any call.
// Sentinel values chosen to be unlikely in normal execution.
#define SENTINEL_BP 0xDEADBEEFCAFEF00D
#define SENTINEL_R14 0x0BADF00DDEADBEEF
// GLOBL holding the raw address of the leave trampoline, read by Go.
GLOBL ·leaveCheckedPtr(SB), NOPTR, $8
DATA ·leaveCheckedPtr(SB)/8, $·leaveJITCheckedRaw(SB)
// func enterJITChecked(fn uintptr, stack uintptr)
// Sets sentinels in BP and R14, switches to the prepared stack and jumps
// to fn. The prepared stack's return address must be leaveJITCheckedRaw
// (read from leaveCheckedPtr).
TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVQ fn+0(FP), AX // target (before SP switch)
MOVQ SP, ·savedSP(SB) // preserve Go stack
MOVQ BP, ·savedBP(SB) // preserve frame pointer (vet requires save before clobber)
MOVQ $SENTINEL_BP, BP // sentinel in BP
MOVQ $SENTINEL_R14, R14 // sentinel in R14
MOVQ stack+8(FP), SP // switch to prepared stack
JMP AX
// leaveJITCheckedRaw is the raw return trampoline. It has NO Go function
// declaration, so no ABIInternal wrapper is generated — the JIT function's
// RET lands here directly, seeing BP and R14 exactly as the function left
// them. It checks the sentinels, records violations in abiResult, then
// restores the Go stack and returns.
TEXT ·leaveJITCheckedRaw(SB), NOSPLIT, $0-0
// Check BP against the sentinel.
MOVQ $SENTINEL_BP, CX
CMPQ BP, CX
JEQ bp_ok
ORQ $1, ·abiResult(SB)
bp_ok:
// Check R14 against the sentinel.
MOVQ $SENTINEL_R14, CX
CMPQ R14, CX
JEQ r14_ok
ORQ $2, ·abiResult(SB)
r14_ok:
MOVQ ·savedSP(SB), SP
RET
+26
View File
@@ -0,0 +1,26 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
//go:build !amd64
package verify
import "fmt"
// ABIReport describes the result of an ABI-checking call.
type ABIReport struct {
BPClobbered bool
R14Clobbered bool
RedZoneHit bool
}
// OK returns true when no violations were detected.
func (r ABIReport) OK() bool { return false }
// String returns a human-readable summary.
func (r ABIReport) String() string { return "verify: ABI checks require amd64" }
// CallChecked is unavailable on non-amd64 architectures.
func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
return nil, ABIReport{}, fmt.Errorf("verify: ABI checks require amd64")
}
+145
View File
@@ -0,0 +1,145 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"testing"
"unsafe"
)
func loadABIKernel(t *testing.T) *Kernel {
t.Helper()
k, err := Load("../testdata/verify/abi_amd64.s")
if err != nil {
t.Fatalf("Load: %v", err)
}
t.Cleanup(k.Close)
return k
}
func TestABIClean(t *testing.T) {
k := loadABIKernel(t)
args := make([]byte, 24)
PutUint64(args, 0, 3)
PutUint64(args, 8, 4)
out, report, err := k.CallFuncChecked("cleanAdd", args)
if err != nil {
t.Fatalf("CallFuncChecked: %v", err)
}
if got := int64(GetUint64(out, 16)); got != 7 {
t.Errorf("cleanAdd(3, 4) = %d, want 7", got)
}
if !report.OK() {
t.Errorf("cleanAdd: %s", report)
}
}
func TestABIBPClobbered(t *testing.T) {
k := loadABIKernel(t)
args := make([]byte, 16)
PutUint64(args, 0, 42)
out, report, err := k.CallFuncChecked("dirtyBP", args)
if err != nil {
t.Fatalf("CallFuncChecked: %v", err)
}
if got := int64(GetUint64(out, 8)); got != 42 {
t.Errorf("dirtyBP(42) = %d, want 42", got)
}
if !report.BPClobbered {
t.Error("dirtyBP: expected BP clobbered, but report says clean")
}
if report.R14Clobbered {
t.Error("dirtyBP: R14 should not be clobbered")
}
}
func TestABIR14Clobbered(t *testing.T) {
k := loadABIKernel(t)
args := make([]byte, 16)
PutUint64(args, 0, 99)
out, report, err := k.CallFuncChecked("dirtyR14", args)
if err != nil {
t.Fatalf("CallFuncChecked: %v", err)
}
if got := int64(GetUint64(out, 8)); got != 99 {
t.Errorf("dirtyR14(99) = %d, want 99", got)
}
if !report.R14Clobbered {
t.Error("dirtyR14: expected R14 clobbered, but report says clean")
}
if report.BPClobbered {
t.Error("dirtyR14: BP should not be clobbered")
}
}
// TestABILZ4Kernels verifies that the production go-lz4 kernels are ABI-clean:
// they preserve BP and R14 and do not write into the red zone.
func TestABILZ4Kernels(t *testing.T) {
k := loadLZ4Kernel(t)
// wideCopyAVX2 with a real copy.
src := make([]byte, 128)
for i := range src {
src[i] = byte(i)
}
dst := make([]byte, 128)
args := make([]byte, 48)
PutPtr(args, 0, unsafe.Pointer(&dst[0]))
PutUint64(args, 8, 128)
PutUint64(args, 16, 128)
PutPtr(args, 24, unsafe.Pointer(&src[0]))
PutUint64(args, 32, 128)
PutUint64(args, 40, 128)
_, report, err := k.CallFuncChecked("wideCopyAVX2", args)
if err != nil {
t.Fatalf("CallFuncChecked(wideCopyAVX2): %v", err)
}
if !report.OK() {
t.Errorf("wideCopyAVX2: %s", report)
}
// decodeBlockAVX2 with a simple block.
decSrc := []byte{0x50, 'H', 'e', 'l', 'l', 'o'}
decDst := make([]byte, 64)
decArgs := make([]byte, 64)
PutPtr(decArgs, 0, unsafe.Pointer(&decSrc[0]))
PutUint64(decArgs, 8, uint64(len(decSrc)))
PutUint64(decArgs, 16, uint64(cap(decSrc)))
PutPtr(decArgs, 24, unsafe.Pointer(&decDst[0]))
PutUint64(decArgs, 32, uint64(len(decDst)))
PutUint64(decArgs, 40, uint64(cap(decDst)))
_, report, err = k.CallFuncChecked("decodeBlockAVX2", decArgs)
if err != nil {
t.Fatalf("CallFuncChecked(decodeBlockAVX2): %v", err)
}
if !report.OK() {
t.Errorf("decodeBlockAVX2: %s", report)
}
}
func TestCallFuncCheckedErrors(t *testing.T) {
k := loadABIKernel(t)
// Nonexistent function.
_, _, err := k.CallFuncChecked("nope", make([]byte, 8))
if err == nil {
t.Fatal("expected error for nonexistent function")
}
// Arg block too small.
_, _, err = k.CallFuncChecked("cleanAdd", make([]byte, 8))
if err == nil {
t.Fatal("expected error for too-small arg block")
}
}
+56
View File
@@ -157,3 +157,59 @@ func TestMapZeroLength(t *testing.T) {
t.Fatal("expected error for zero-length code") t.Fatal("expected error for zero-length code")
} }
} }
func TestLoadSourceError(t *testing.T) {
_, err := LoadSource("bad.s", "TEXT ·f(SB), NOSPLIT\n\tBADINSTRUCTION\n")
// The parser may or may not error on unknown instructions (it's
// error-tolerant), but the assembler will reject it.
if err == nil {
t.Log("LoadSource succeeded unexpectedly (parser is error-tolerant)")
}
}
func TestLoadSourceParseError(t *testing.T) {
// A completely invalid file that the parser rejects.
_, err := LoadSource("empty.s", "")
if err != nil {
t.Logf("expected: %v", err)
}
}
func TestFuncLookup(t *testing.T) {
k := loadBasic(t)
fl, err := k.Func("add")
if err != nil {
t.Fatalf("Func(add): %v", err)
}
if fl.Name != "add" {
t.Errorf("Func(add).Name = %q, want %q", fl.Name, "add")
}
if fl.Args != 24 {
t.Errorf("Func(add).Args = %d, want 24", fl.Args)
}
_, err = k.Func("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent function")
}
}
func TestABIReportString(t *testing.T) {
r := ABIReport{}
if r.String() != "ABI clean" {
t.Errorf("clean report = %q", r.String())
}
r.BPClobbered = true
if r.OK() {
t.Error("expected not OK with BP clobbered")
}
s := r.String()
if s == "ABI clean" {
t.Error("expected violation string, got clean")
}
r.R14Clobbered = true
r.RedZoneHit = true
s = r.String()
if s == "ABI clean" {
t.Error("expected violation string for all flags")
}
}
+16
View File
@@ -98,6 +98,22 @@ func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) {
return Call(fnAddr, args) return Call(fnAddr, args)
} }
// CallFuncChecked invokes the named function with ABI sentinels and a
// red-zone canary, returning the argument block and an ABIReport that
// records any callee-saved register or red-zone violations.
func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, error) {
idx, ok := k.funcs[name]
if !ok {
return nil, ABIReport{}, fmt.Errorf("verify: function %q not found", name)
}
fl := k.img.Funcs[idx]
if 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)
return CallChecked(fnAddr, args)
}
// Close releases the executable mapping. // Close releases the executable mapping.
func (k *Kernel) Close() { func (k *Kernel) Close() {
if k.exec != nil { if k.exec != nil {