blob: 1bd099aa9362e527681e6c8d79bdb53c320e3c93 [file] [log] [blame] [edit]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build goexperiment.runtimesecret && linux
package secret
import (
"bytes"
"debug/elf"
"fmt"
"internal/testenv"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
)
// Copied from runtime/runtime-gdb_unix_test.go
func canGenerateCore(t *testing.T) bool {
// Ensure there is enough RLIMIT_CORE available to generate a full core.
var lim syscall.Rlimit
err := syscall.Getrlimit(syscall.RLIMIT_CORE, &lim)
if err != nil {
t.Fatalf("error getting rlimit: %v", err)
}
// Minimum RLIMIT_CORE max to allow. This is a conservative estimate.
// Most systems allow infinity.
const minRlimitCore = 100 << 20 // 100 MB
if lim.Max < minRlimitCore {
t.Skipf("RLIMIT_CORE max too low: %#+v", lim)
}
// Make sure core pattern will send core to the current directory.
b, err := os.ReadFile("/proc/sys/kernel/core_pattern")
if err != nil {
t.Fatalf("error reading core_pattern: %v", err)
}
if string(b) != "core\n" {
t.Skipf("Unexpected core pattern %q", string(b))
}
coreUsesPID := false
b, err = os.ReadFile("/proc/sys/kernel/core_uses_pid")
if err == nil {
switch string(bytes.TrimSpace(b)) {
case "0":
case "1":
coreUsesPID = true
default:
t.Skipf("unexpected core_uses_pid value %q", string(b))
}
}
return coreUsesPID
}
func TestCore(t *testing.T) {
// use secret, grab a coredump, rummage through
// it, trying to find our secret.
switch runtime.GOARCH {
case "amd64", "arm64":
default:
t.Skip("unsupported arch")
}
coreUsesPid := canGenerateCore(t)
// Build our crashing program
// Because we need assembly files to properly dirty our state
// we need to construct a package in our temporary directory.
tmpDir := t.TempDir()
// copy our base source
err := copyToDir("./testdata/crash.go", tmpDir, nil)
if err != nil {
t.Fatalf("error copying directory %v", err)
}
// Copy our testing assembly files. Use the ones from the package
// to assure that they are always in sync
err = copyToDir("./asm_amd64.s", tmpDir, nil)
if err != nil {
t.Fatalf("error copying file %v", err)
}
err = copyToDir("./asm_arm64.s", tmpDir, nil)
if err != nil {
t.Fatalf("error copying file %v", err)
}
err = copyToDir("./stubs.go", tmpDir, func(s string) string {
return strings.Replace(s, "package secret", "package main", 1)
})
if err != nil {
t.Fatalf("error copying file %v", err)
}
// the crashing package will live out of tree, so its source files
// cannot refer to our internal packages. However, the assembly files
// can refer to internal names and we can pass the missing offsets as
// a small generated file
offsets := `
package main
const (
offsetX86HasAVX = %v
offsetX86HasAVX512 = %v
)
`
err = os.WriteFile(filepath.Join(tmpDir, "offsets.go"), []byte(fmt.Sprintf(offsets, offsetX86HasAVX, offsetX86HasAVX512)), 0666)
if err != nil {
t.Fatalf("error writing offset file %v", err)
}
// generate go.mod file
cmd := exec.Command(testenv.GoToolPath(t), "mod", "init", "crashtest")
cmd.Dir = tmpDir
out, err := testenv.CleanCmdEnv(cmd).CombinedOutput()
if err != nil {
t.Fatalf("error initing module %v\n%s", err, out)
}
cmd = exec.Command(testenv.GoToolPath(t), "build", "-o", filepath.Join(tmpDir, "a.exe"))
cmd.Dir = tmpDir
out, err = testenv.CleanCmdEnv(cmd).CombinedOutput()
if err != nil {
t.Fatalf("error building source %v\n%s", err, out)
}
// Start the test binary.
cmd = testenv.CommandContext(t, t.Context(), "./a.exe")
cmd.Dir = tmpDir
var stdout strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stdout
err = cmd.Run()
// For debugging.
t.Logf("\n\n\n--- START SUBPROCESS ---\n\n\n%s\n\n--- END SUBPROCESS ---\n\n\n", stdout.String())
if err == nil {
t.Fatalf("test binary did not crash")
}
eErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("error is not exit error: %v", err)
}
if eErr.Exited() {
t.Fatalf("process exited instead of being terminated: %v", eErr)
}
rummage(t, tmpDir, eErr.Pid(), coreUsesPid)
}
func copyToDir(name string, dir string, replace func(string) string) error {
f, err := os.ReadFile(name)
if err != nil {
return err
}
if replace != nil {
f = []byte(replace(string(f)))
}
return os.WriteFile(filepath.Join(dir, filepath.Base(name)), f, 0666)
}
type violation struct {
id byte // secret ID
off uint64 // offset in core dump
}
// A secret value that should never appear in a core dump,
// except for this global variable itself.
// The first byte of the secret is variable, to track
// different instances of it.
//
// If this value is changed, update ./internal/crashsecret/main.go
// TODO: this is little-endian specific.
var secretStore = [8]byte{
0x00,
0x81,
0xa0,
0xc6,
0xb3,
0x01,
0x66,
0x53,
}
func rummage(t *testing.T, tmpDir string, pid int, coreUsesPid bool) {
coreFileName := "core"
if coreUsesPid {
coreFileName += fmt.Sprintf(".%d", pid)
}
core, err := os.Open(filepath.Join(tmpDir, coreFileName))
if err != nil {
t.Fatalf("core file not found: %v", err)
}
b, err := io.ReadAll(core)
if err != nil {
t.Fatalf("can't read core file: %v", err)
}
// Open elf view onto core file.
coreElf, err := elf.NewFile(core)
if err != nil {
t.Fatalf("can't parse core file: %v", err)
}
// Look for any places that have the secret.
var violations []violation // core file offsets where we found a secret
i := 0
for {
j := bytes.Index(b[i:], secretStore[1:])
if j < 0 {
break
}
j--
i += j
t.Errorf("secret %d found at offset %x in core file", b[i], i)
violations = append(violations, violation{
id: b[i],
off: uint64(i),
})
i += len(secretStore)
}
// Get more specific data about where in the core we found the secrets.
regions := elfRegions(t, core, coreElf)
for _, r := range regions {
for _, v := range violations {
if v.off >= r.min && v.off < r.max {
var addr string
if r.addrMin != 0 {
addr = fmt.Sprintf(" addr=%x", r.addrMin+(v.off-r.min))
}
t.Logf("additional info: secret %d at offset %x in %s%s", v.id, v.off-r.min, r.name, addr)
}
}
}
}
type elfRegion struct {
name string
min, max uint64 // core file offset range
addrMin, addrMax uint64 // inferior address range (or 0,0 if no address, like registers)
}
func elfRegions(t *testing.T, core *os.File, coreElf *elf.File) []elfRegion {
var regions []elfRegion
for _, p := range coreElf.Progs {
regions = append(regions, elfRegion{
name: fmt.Sprintf("%s[%s]", p.Type, p.Flags),
min: p.Off,
max: p.Off + min(p.Filesz, p.Memsz),
addrMin: p.Vaddr,
addrMax: p.Vaddr + min(p.Filesz, p.Memsz),
})
}
// TODO(dmo): parse thread regions for arm64.
// This doesn't invalidate the test, it just makes it harder to figure
// out where we're leaking stuff.
if runtime.GOARCH == "amd64" {
regions = append(regions, threadRegions(t, core, coreElf)...)
}
for i, r1 := range regions {
for j, r2 := range regions {
if i == j {
continue
}
if r1.max <= r2.min || r2.max <= r1.min {
continue
}
t.Fatalf("overlapping regions %v %v", r1, r2)
}
}
return regions
}
func threadRegions(t *testing.T, core *os.File, coreElf *elf.File) []elfRegion {
var regions []elfRegion
for _, prog := range coreElf.Progs {
if prog.Type != elf.PT_NOTE {
continue
}
b := make([]byte, prog.Filesz)
_, err := core.ReadAt(b, int64(prog.Off))
if err != nil {
t.Fatalf("can't read core file %v", err)
}
prefix := "unk"
b0 := b
for len(b) > 0 {
namesz := coreElf.ByteOrder.Uint32(b)
b = b[4:]
descsz := coreElf.ByteOrder.Uint32(b)
b = b[4:]
typ := elf.NType(coreElf.ByteOrder.Uint32(b))
b = b[4:]
name := string(b[:namesz-1])
b = b[(namesz+3)/4*4:]
off := prog.Off + uint64(len(b0)-len(b))
desc := b[:descsz]
b = b[(descsz+3)/4*4:]
if name != "CORE" && name != "LINUX" {
continue
}
end := off + uint64(len(desc))
// Note: amd64 specific
// See /usr/include/x86_64-linux-gnu/bits/sigcontext.h
//
// struct _fpstate
switch typ {
case elf.NT_PRSTATUS:
pid := coreElf.ByteOrder.Uint32(desc[32:36])
prefix = fmt.Sprintf("thread%d: ", pid)
regions = append(regions, elfRegion{
name: prefix + "prstatus header",
min: off,
max: off + 112,
})
off += 112
greg := []string{
"r15",
"r14",
"r13",
"r12",
"rbp",
"rbx",
"r11",
"r10",
"r9",
"r8",
"rax",
"rcx",
"rdx",
"rsi",
"rdi",
"orig_rax",
"rip",
"cs",
"eflags",
"rsp",
"ss",
"fs_base",
"gs_base",
"ds",
"es",
"fs",
"gs",
}
for _, r := range greg {
regions = append(regions, elfRegion{
name: prefix + r,
min: off,
max: off + 8,
})
off += 8
}
regions = append(regions, elfRegion{
name: prefix + "prstatus footer",
min: off,
max: off + 8,
})
off += 8
case elf.NT_FPREGSET:
regions = append(regions, elfRegion{
name: prefix + "fpregset header",
min: off,
max: off + 32,
})
off += 32
for i := 0; i < 8; i++ {
regions = append(regions, elfRegion{
name: prefix + fmt.Sprintf("mmx%d", i),
min: off,
max: off + 16,
})
off += 16
// They are long double (10 bytes), but
// stored in 16-byte slots.
}
for i := 0; i < 16; i++ {
regions = append(regions, elfRegion{
name: prefix + fmt.Sprintf("xmm%d", i),
min: off,
max: off + 16,
})
off += 16
}
regions = append(regions, elfRegion{
name: prefix + "fpregset footer",
min: off,
max: off + 96,
})
off += 96
/*
case NT_X86_XSTATE: // aka NT_PRPSINFO+511
// legacy: 512 bytes
// xsave header: 64 bytes
fmt.Printf("hdr %v\n", desc[512:][:64])
// ymm high128: 256 bytes
println(len(desc))
fallthrough
*/
default:
regions = append(regions, elfRegion{
name: fmt.Sprintf("%s/%s", name, typ),
min: off,
max: off + uint64(len(desc)),
})
off += uint64(len(desc))
}
if off != end {
t.Fatalf("note section incomplete")
}
}
}
return regions
}