// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause //go:build amd64 || arm64 || riscv64 || loong64 package verify import ( "encoding/binary" "fmt" "syscall" "unsafe" ) // CallChecked invokes the function at fnAddr with ABI sentinels and a // canary below SP, returning both the argument block (with results) and an // 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_.s: enterJITChecked // plants sentinels in the registers the Go ABI fixes across calls (the // frame pointer and the goroutine pointer) before switching to the // prepared stack, and the raw return trampoline leaveJITCheckedRaw // compares them and records violations in abiResult. 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{} // Reset the global result. abiResult = 0 // The gap between the canary window and the entry stack pointer must // cover every write a legal kernel makes. Two parts: // - frame: the callee's declared local frame, which the ABI lets it // 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, 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 func() { _ = syscall.Munmap(stackMem) }() // Fill the canary window with the detection pattern. for i := range redZoneSize { stackMem[i] = redZoneFill } // Return address and args after the canary and the frame gap. retOff := redZoneSize + pad 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.FPClobbered = res&1 != 0 report.GClobbered = res&2 != 0 // Check the canary window. for i := range redZoneSize { 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 }