| // 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 |
| } |