326 lines
10 KiB
Go
326 lines
10 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
||
|
|
|
||
|
|
//go:build linux && amd64
|
||
|
|
|
||
|
|
package debug
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"runtime"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Regression tests for the debugger audit: memory access at mapping
|
||
|
|
// boundaries, watchpoint slot attribution, launch failure latency, stray
|
||
|
|
// trap instructions and the REPL's argument validation. All drive a real
|
||
|
|
// ptrace session, so they run on amd64 hosts only.
|
||
|
|
|
||
|
|
// memMap is one line of /proc/pid/maps.
|
||
|
|
type memMap struct {
|
||
|
|
lo, hi uint64
|
||
|
|
perms string
|
||
|
|
name string
|
||
|
|
}
|
||
|
|
|
||
|
|
// readMaps parses the debuggee's memory map.
|
||
|
|
func readMaps(t *testing.T, pid int) []memMap {
|
||
|
|
t.Helper()
|
||
|
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/maps", pid))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("read maps: %v", err)
|
||
|
|
}
|
||
|
|
var out []memMap
|
||
|
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
if len(fields) < 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var lo, hi uint64
|
||
|
|
if _, err := fmt.Sscanf(fields[0], "%x-%x", &lo, &hi); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
m := memMap{lo: lo, hi: hi, perms: fields[1]}
|
||
|
|
if len(fields) >= 6 {
|
||
|
|
m.name = fields[5]
|
||
|
|
}
|
||
|
|
out = append(out, m)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// boundaryByte returns the last byte of a writable, ordinary mapping that is
|
||
|
|
// followed by an unmapped gap: an access there is inside the mapping, while
|
||
|
|
// the 8-byte word starting at it crosses into unmapped memory.
|
||
|
|
func boundaryByte(t *testing.T, pid int) uint64 {
|
||
|
|
t.Helper()
|
||
|
|
maps := readMaps(t, pid)
|
||
|
|
for i, m := range maps {
|
||
|
|
if !strings.Contains(m.perms, "rw") ||
|
||
|
|
strings.Contains(m.name, "vvar") || strings.Contains(m.name, "vdso") ||
|
||
|
|
strings.Contains(m.name, "vsyscall") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
gap := uint64(1) << 62
|
||
|
|
if i+1 < len(maps) {
|
||
|
|
gap = maps[i+1].lo - m.hi
|
||
|
|
}
|
||
|
|
if gap >= 4096 {
|
||
|
|
return m.hi - 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Skip("no writable mapping followed by a hole; cannot construct the boundary")
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestReadMemoryPageBoundary proves ReadMemory never reads past the requested
|
||
|
|
// range: one byte at the end of a mapping followed by a hole must be
|
||
|
|
// readable, which the old word-at-a-time tail read failed because its final
|
||
|
|
// 8-byte Peek crossed into the unmapped page.
|
||
|
|
func TestReadMemoryPageBoundary(t *testing.T) {
|
||
|
|
sess, _, _ := launchKernel(t, buildGasm(t), boundaryKernel(t), "boundary", nil)
|
||
|
|
addr := boundaryByte(t, sess.Pid())
|
||
|
|
mem, err := sess.ReadMemory(addr, 1)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ReadMemory(%#x, 1): %v (the read must not cross into the unmapped page)", addr, err)
|
||
|
|
}
|
||
|
|
if len(mem) != 1 {
|
||
|
|
t.Fatalf("ReadMemory returned %d bytes, want 1", len(mem))
|
||
|
|
}
|
||
|
|
// A request whose own range crosses into the hole must still fail.
|
||
|
|
if _, err := sess.ReadMemory(addr, 8); err == nil {
|
||
|
|
t.Fatal("ReadMemory past the mapping end should fail")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestDisassemblePageBoundary proves the disassembler shrinks its read
|
||
|
|
// window at a mapping end instead of failing: the instruction stream cannot
|
||
|
|
// be decoded at all when the fixed 15-byte read crosses into the hole.
|
||
|
|
func TestDisassemblePageBoundary(t *testing.T) {
|
||
|
|
sess, _, _ := launchKernel(t, buildGasm(t), boundaryKernel(t), "boundary", nil)
|
||
|
|
addr := boundaryByte(t, sess.Pid())
|
||
|
|
if _, _, err := sess.Disassemble(addr); err != nil {
|
||
|
|
t.Fatalf("Disassemble(%#x): %v (the read window must shrink at the mapping end)", addr, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWriteMemoryPageBoundary proves WriteMemory writes exactly the bytes it
|
||
|
|
// is given: one byte at the end of a mapping followed by a hole must be
|
||
|
|
// writable, which the old read-modify-write of the final partial word failed
|
||
|
|
// because its Peek crossed into the unmapped page.
|
||
|
|
func TestWriteMemoryPageBoundary(t *testing.T) {
|
||
|
|
sess, _, _ := launchKernel(t, buildGasm(t), boundaryKernel(t), "boundary", nil)
|
||
|
|
addr := boundaryByte(t, sess.Pid())
|
||
|
|
orig, err := sess.ReadMemory(addr, 1)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ReadMemory(%#x, 1): %v", addr, err)
|
||
|
|
}
|
||
|
|
if err := sess.WriteMemory(addr, []byte{orig[0]}); err != nil {
|
||
|
|
t.Fatalf("WriteMemory(%#x, 1): %v (the write must not read past the range)", addr, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWatchpointSlotAttribution proves a hit is attributed to the slot that
|
||
|
|
// fired, not to an earlier one whose DR6 status bit is still set: the B0-B3
|
||
|
|
// bits are sticky, so they must be acknowledged when read.
|
||
|
|
func TestWatchpointSlotAttribution(t *testing.T) {
|
||
|
|
bin := buildGasm(t)
|
||
|
|
const kernel = `#include "textflag.h"
|
||
|
|
|
||
|
|
// func wptwo(x, y int64) (a, b int64)
|
||
|
|
TEXT ·wptwo(SB), NOSPLIT, $0-32
|
||
|
|
MOVQ $0x1111, AX
|
||
|
|
MOVQ AX, a+16(FP)
|
||
|
|
MOVQ $0x2222, BX
|
||
|
|
MOVQ BX, b+24(FP)
|
||
|
|
RET
|
||
|
|
`
|
||
|
|
path := writeKernel(t, kernel)
|
||
|
|
sess, bm, fl := launchKernel(t, bin, path, "wptwo", nil)
|
||
|
|
|
||
|
|
entry := sess.CodeBase() + uint64(fl.Offset)
|
||
|
|
if _, err := bm.Set(entry, "entry"); err != nil {
|
||
|
|
t.Fatalf("Set: %v", err)
|
||
|
|
}
|
||
|
|
runToEntry(t, sess, bm, entry)
|
||
|
|
regs, err := sess.GetRegs()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("GetRegs: %v", err)
|
||
|
|
}
|
||
|
|
// FP sits one word above the entry stack pointer (the return address
|
||
|
|
// occupies [RSP]), so a+16(FP) = RSP+24 and b+24(FP) = RSP+32.
|
||
|
|
watchA := regs.RSP + 24
|
||
|
|
watchB := regs.RSP + 32
|
||
|
|
|
||
|
|
if err := sess.SetWatchpoint(0, watchA, WatchWrite, 8); err != nil {
|
||
|
|
t.Fatalf("SetWatchpoint(0): %v", err)
|
||
|
|
}
|
||
|
|
if err := sess.SetWatchpoint(1, watchB, WatchWrite, 8); err != nil {
|
||
|
|
t.Fatalf("SetWatchpoint(1): %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
for i, want := range []uint64{watchA, watchB} {
|
||
|
|
if err := sess.Continue(); err != nil {
|
||
|
|
t.Fatalf("Continue (hit %d): %v", i+1, err)
|
||
|
|
}
|
||
|
|
reason, addr := sess.StopInfo()
|
||
|
|
if reason != StopWatchpoint {
|
||
|
|
t.Fatalf("hit %d: stop reason = %v, want StopWatchpoint", i+1, reason)
|
||
|
|
}
|
||
|
|
if addr != want {
|
||
|
|
t.Fatalf("hit %d reported %#x, want %#x (the sticky DR6 bit misattributes the slot)", i+1, addr, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clearing a watchpoint must zero its address register: a stale
|
||
|
|
// address in a disabled slot turns any sticky status bit into a
|
||
|
|
// misattributed report later.
|
||
|
|
if err := sess.ClearWatchpoint(0); err != nil {
|
||
|
|
t.Fatalf("ClearWatchpoint(0): %v", err)
|
||
|
|
}
|
||
|
|
dr0, err := ptracePeekUser(sess.Pid(), drOffset)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("read DR0: %v", err)
|
||
|
|
}
|
||
|
|
if dr0 != 0 {
|
||
|
|
t.Fatalf("DR0 = %#x after ClearWatchpoint, want 0 (the address register must be cleared)", dr0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLaunchFailsFastOnDeadDebuggee proves a debuggee that dies before
|
||
|
|
// signalling readiness surfaces promptly: the ready poll used to run its
|
||
|
|
// full 2.5 seconds before the wait discovered the exit.
|
||
|
|
func TestLaunchFailsFastOnDeadDebuggee(t *testing.T) {
|
||
|
|
runtime.LockOSThread()
|
||
|
|
defer runtime.UnlockOSThread()
|
||
|
|
bin := buildGasm(t)
|
||
|
|
path := boundaryKernel(t)
|
||
|
|
start := time.Now()
|
||
|
|
sess, err := Launch(bin, path, "nosuchfunction", nil)
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
if err == nil {
|
||
|
|
sess.Kill()
|
||
|
|
t.Fatal("Launch with an unknown function should fail")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "before signalling readiness") &&
|
||
|
|
!strings.Contains(err.Error(), "debuggee exited") {
|
||
|
|
t.Errorf("error does not name the dead debuggee: %v", err)
|
||
|
|
}
|
||
|
|
if elapsed >= 1500*time.Millisecond {
|
||
|
|
t.Fatalf("Launch took %v to report the dead debuggee; the readiness poll must detect the exit, not time out", elapsed)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestStrayTrapRunsThrough proves the continue loop survives a trap
|
||
|
|
// instruction planted in the kernel itself (BYTE $0xCC, the same byte the
|
||
|
|
// debugger patches in): on architectures that report the trap in place the
|
||
|
|
// loop must surface the stop, and on amd64 it runs through to the exit. A
|
||
|
|
// regression here hangs, so a watchdog fails the run.
|
||
|
|
func TestStrayTrapRunsThrough(t *testing.T) {
|
||
|
|
bin := buildGasm(t)
|
||
|
|
const kernel = `#include "textflag.h"
|
||
|
|
|
||
|
|
// func stray() int64
|
||
|
|
TEXT ·stray(SB), NOSPLIT, $0-8
|
||
|
|
MOVQ $7, AX
|
||
|
|
BYTE $0xCC
|
||
|
|
MOVQ AX, ret+0(FP)
|
||
|
|
RET
|
||
|
|
`
|
||
|
|
path := writeKernel(t, kernel)
|
||
|
|
sess, bm, _ := launchKernel(t, bin, path, "stray", nil)
|
||
|
|
|
||
|
|
timer := time.AfterFunc(time.Minute, func() {
|
||
|
|
panic("watchdog: the continue loop hung on the stray trap instruction")
|
||
|
|
})
|
||
|
|
defer timer.Stop()
|
||
|
|
|
||
|
|
out := captureStdout(t, func() {
|
||
|
|
REPL(sess, bm, sess.CodeBase(), 0, 0, 0, nil, nil,
|
||
|
|
strings.NewReader("continue\nquit\n"))
|
||
|
|
})
|
||
|
|
if !strings.Contains(out, "debuggee exited") {
|
||
|
|
t.Errorf("the stray trap wedged the continue loop; output:\n%s", out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestStepIntoFaultReportsSignal proves the step command reports a genuine
|
||
|
|
// signal-delivery-stop instead of silently printing the faulting
|
||
|
|
// instruction as if the step had succeeded.
|
||
|
|
func TestStepIntoFaultReportsSignal(t *testing.T) {
|
||
|
|
bin := buildGasm(t)
|
||
|
|
const kernel = `#include "textflag.h"
|
||
|
|
|
||
|
|
// func crash() int64
|
||
|
|
TEXT ·crash(SB), NOSPLIT, $0-8
|
||
|
|
XORQ AX, AX
|
||
|
|
MOVQ (AX), AX
|
||
|
|
MOVQ AX, ret+0(FP)
|
||
|
|
RET
|
||
|
|
`
|
||
|
|
path := writeKernel(t, kernel)
|
||
|
|
sess, bm, fl := launchKernel(t, bin, path, "crash", nil)
|
||
|
|
entry := sess.CodeBase() + uint64(fl.Offset)
|
||
|
|
if _, err := bm.Set(entry, "entry"); err != nil {
|
||
|
|
t.Fatalf("Set: %v", err)
|
||
|
|
}
|
||
|
|
runToEntry(t, sess, bm, entry)
|
||
|
|
|
||
|
|
out := captureStdout(t, func() {
|
||
|
|
REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, nil, nil,
|
||
|
|
strings.NewReader("step 2\nquit\n"))
|
||
|
|
})
|
||
|
|
if !strings.Contains(out, "stopped on signal") {
|
||
|
|
t.Errorf("stepping into the fault did not report the signal; output:\n%s", out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestREPLRejectsBadArguments proves the command loop reports malformed
|
||
|
|
// input instead of silently defaulting: an unknown label for x would read
|
||
|
|
// address 0, and a malformed count would silently step one instruction.
|
||
|
|
func TestREPLRejectsBadArguments(t *testing.T) {
|
||
|
|
bin := buildGasm(t)
|
||
|
|
path := boundaryKernel(t)
|
||
|
|
sess, bm, fl := launchKernel(t, bin, path, "boundary", nil)
|
||
|
|
entry := sess.CodeBase() + uint64(fl.Offset)
|
||
|
|
if _, err := bm.Set(entry, "entry"); err != nil {
|
||
|
|
t.Fatalf("Set: %v", err)
|
||
|
|
}
|
||
|
|
runToEntry(t, sess, bm, entry)
|
||
|
|
|
||
|
|
out := captureStdout(t, func() {
|
||
|
|
REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, nil, nil,
|
||
|
|
strings.NewReader("x nosuchlabel\nstep abc\ndisas abc\nwatch 0x1000 q 8\nquit\n"))
|
||
|
|
})
|
||
|
|
for _, want := range []string{
|
||
|
|
"unknown address: nosuchlabel",
|
||
|
|
"invalid count: abc",
|
||
|
|
"unknown watchpoint type: q",
|
||
|
|
} {
|
||
|
|
if !strings.Contains(out, want) {
|
||
|
|
t.Errorf("output missing %q:\n%s", want, out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if got := strings.Count(out, "invalid count: abc"); got != 2 {
|
||
|
|
t.Errorf("invalid count reported %d times, want 2 (step and disas):\n%s", got, out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// boundaryKernel is a minimal kernel for the boundary tests, which only need
|
||
|
|
// a live, stopped debuggee.
|
||
|
|
func boundaryKernel(t *testing.T) string {
|
||
|
|
t.Helper()
|
||
|
|
const kernel = `#include "textflag.h"
|
||
|
|
|
||
|
|
// func boundary() int64
|
||
|
|
TEXT ·boundary(SB), NOSPLIT, $0-8
|
||
|
|
MOVQ $1, AX
|
||
|
|
MOVQ AX, ret+0(FP)
|
||
|
|
RET
|
||
|
|
`
|
||
|
|
return writeKernel(t, kernel)
|
||
|
|
}
|