runtime/secret: Go 1.26’s Answer to Secrets Leaking in Core Dumps

8 min read Original article ↗

Your crash file is a confession you never wrote.

Cheikh seck

There is a file on your server right now that contains every secret your program has ever touched. It was written without your permission. It has no access controls. And when your process died — at 2am, on a Tuesday, in a container you forgot was running — the operating system photographed its entire mind and saved it to disk.

We call that photograph a core dump.

On Linux, with GOTRACEBACK=crash, Go doesn't ask. A SIGSEGV or SIGABRT triggers a full snapshot of the process: stack, heap, every span, every buffer, every plaintext token your service decoded at startup and "forgot" to wipe. The file lands in /tmp or the working directory. A supervisor catches it. Ships it to a post-mortem bucket. Some junior engineer downloads it three months later to debug a flaky test, and your production API key is now in their laptop's search index.

You never logged it. You never meant to. The kernel did it for you.

For years the accepted answer was to manually zero your buffers after use — the hand-written defer that wipes a slice byte by byte before the function returns. A spell to keep the demons out. But here's what nobody tells you: you don't own most of the copies of your secret. The runtime makes them behind your back. A stack that grew and abandoned its old frame. An interface boxing that copied your bytes onto the heap. A GC cycle that evacuated your buffer to a new span and left the old bytes sitting there, readable, until some later sweep bothers to reclaim them. Those copies were never yours to zero. And the compiler? It optimized your cleanup away the moment it proved the variable dead. The secret is gone from your code and alive in memory.

Go 1.26 answers this for the first time at the only layer that can actually answer it: the runtime itself. runtime/secret.

(This article was co-authored with https://liteagent.cloud/ . Try it today!)

Press enter or click to view image in full size

The entire surface is two functions

package secret // import "runtime/secret"
func Do(f func()) // execute f with secret-mode enabled for this goroutine
func Enabled() bool // true only inside Do, and only on supported platforms

No Init. No global registry. No config file. You wrap the dangerous region of execution and the runtime takes custody of the memory it allocates there. Enabled() exists so code inside the closure can ask am I actually protected right now — and the answer is false on macOS, on Windows, on anything that isn't linux/amd64 or linux/arm64. Everywhere else, Do is a silent pass-through. Your secret leaks and the code looks correct. That is the trap.

The baseline: this is you

// canary/control/main.go
hexStr := os.Getenv("CANARY_HEX")
c, _ := hex.DecodeString(hexStr) // heap allocation, no special handling
runtime.KeepAlive(c) // pin it live past its last use
time.Sleep(60 * time.Second) // hold the process open for the signal

hex.DecodeString allocates c on the heap — a fresh, unmarked, fully-recoverable span. KeepAlive(c) is the only thing standing between this buffer and the optimizer; without it the compiler would prove c is dead after the decode and the whole experiment would be meaningless. The time.Sleep just keeps the process alive so the test can fire SIGSEGV into a living target.

We send the signal. The kernel writes the core. We open the 42-megabyte file and search it for the canary bytes.

Found: true.

The secret is in the crash. This is not a hypothetical — this is what nearly every Go service in production does today, including presumably yours.

The protected path — and the line that decides whether you’re safe or lying to yourself

// canary/secret/main.go
secret.Do(func() {
c, _ := hex.DecodeString(hexStr) // allocated inside secret mode
runtime.KeepAlive(c) // keep it live until Do returns
}) // ← allocations marked for erasure here

runtime.GC() // ← the line that separates safety from theater
time.Sleep(60 * time.Second)

Now read the closure carefully, because the mechanics are subtle and unforgiving.

Inside secret.Do, the goroutine enters secret mode. Every heap allocation the function makes — including the one hex.DecodeString returns — is tracked by the runtime and tagged for erasure on return. KeepAlive(c) ensures c stays live until the closure exits, so the runtime's erase logic actually observes it rather than finding it already collected.

Then Do returns. And here is where people get burned: secret.Do does not wipe your memory when the function returns. It marks the allocations. The bytes are physically purged only on the next garbage-collection cycle. If your process crashes in the window between Do returning and a GC happening to run — and in a real service that window is most of the time — the still-live, still-marked buffer survives intact into the core dump. You deployed "the secure version" and leaked anyway.

That is why runtime.GC() sits immediately after secret.Do. It forces the collection and the erasure to happen now, while the process is healthy and in control. By the time SIGSEGV arrives, the canary bytes have already been physically wiped from the heap. GC must be called right after secret.Do to guarantee sensitive data is purged from memory. Skip it and you have built a false sense of security with a green test you never wrote.

Get Cheikh seck’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Result: Found: false. The secret is absent from the core dump.

How the proof works, line by line

The harness is small and merciless.

var canaryBytes = []byte{
0x53, 0x55, 0x50, 0x45, 0x52, 0x2d, 0x53, 0x45,
0x43, 0x52, 0x45, 0x54, 0x2d, 0x4b, 0x45, 0x59,
}

The canary SUPER-SECRET-KEY is constructed from individual byte values, never as a string literal — so it cannot appear in the test binary's .rodata and contaminate the scan. The only place those bytes exist is the heap allocation inside the canary.

cmd := exec.Command("go", "build", "-o", bin, "./canary/"+variant)
cmd.Env = append(os.Environ(), "GOEXPERIMENT=runtimesecret")

Both canaries are compiled with GOEXPERIMENT=runtimesecret. Without it, the secret variant won't even link the experimental runtime — Do becomes a no-op and the test would falsely pass while leaking. The flag is not optional; it is the difference between protection and a compiled lie.

syscall.Getrlimit(syscall.RLIMIT_CORE, &rLimit)
rLimit.Cur = rLimit.Max
syscall.Setrlimit(syscall.RLIMIT_CORE, &rLimit)

The test raises RLIMIT_CORE to the maximum so the kernel is permitted to write a full dump rather than truncating it to zero bytes — a silent failure mode that would make both variants "pass" by producing no file at all.

origPattern, _ := os.ReadFile("/proc/sys/kernel/core_pattern")
corePath := filepath.Join(coreDir, "core")
os.WriteFile("/proc/sys/kernel/core_pattern", []byte(corePath), 0o644)
defer os.WriteFile("/proc/sys/kernel/core_pattern", origPattern, 0o644)

It rewrites /proc/sys/kernel/core_pattern to a known temp path and restores it on exit. This is why the Dockerfile must run --privileged: writing core_pattern and producing a core dump are privileged operations. In an unprivileged container the test fails before it begins.

cmd := exec.Command(bin)
cmd.Env = append(os.Environ(), "GOTRACEBACK=crash", "CANARY_HEX="+canaryHex)
cmd.Start()
time.Sleep(2 * time.Second)
cmd.Process.Signal(syscall.SIGSEGV)
cmd.Process.Wait()

The binary starts, holds for two seconds so the secret is resident, then receives SIGSEGV under GOTRACEBACK=crash — the exact condition under which Go emits a full core. The test then polls for the core file, opens it, and streams it in 1 MB chunks searching for canaryBytes.

func TestSecretNotInCoreDump(t *testing.T) {
for _, tc := range []struct {
name string
want bool
}{
{"control", true},
{"secret", false},
} {
t.Run(tc.name, func(t *testing.T) {
found := runCanaryAndScan(t, tc.name)
if found != tc.want {
t.Fatalf("canary found=%v, expected %v", found, tc.want)
}
})
}
}

Table-driven: control must leak, secret must not. One red, one green. That contrast is the article.

Run it under golang:1.26.5-bookworm --privileged:

=== RUN   TestSecretNotInCoreDump/control
canary found=true, expected true OK
=== RUN TestSecretNotInCoreDump/secret
canary found=false, expected false OK
--- PASS: TestSecretNotInCoreDump (10.33s)

The caveats that will hurt you if you ignore them

  • Globals are not protected. Only heap allocations made inside secret.Do. A package-level var key []byte is invisible to the runtime's erase logic. Put your secret in a global and secret.Do does nothing.
  • Erasure is best-effort. Pointer addresses can still leak into GC metadata buffers. The experiment is explicitly not part of the Go 1 compatibility promise — it can change or vanish in any release.
  • Linux only. No macOS. No Windows. Your local dev machine probably can’t run this; your production container can.
  • You own the GC() call. The runtime will not collect on your behalf before a signal lands. Forget it and you have a false sense of security with a green test you never wrote.

The takeaway

A core dump is the easiest place to leak a secret you didn’t know you were logging. runtime/secret is the first tool that lets the runtime itself erase the memory it allocated — but only if you wrap the right code, only on Linux, only with the experiment flag, and only if you call runtime.GC() the moment Do returns.

Wrap your key decode. Call GC. Then go check what’s actually sitting in your /tmp/core* files tonight. You may not like what you find.

Thank you for reading!