💀

runtime/secret でGoのランタイムから秘匿情報を消す

に公開

はじめに

この記事は、Google Developer Experts Advent Calendar 2025 13日目の記事です。

先日、Go の tip に入った面白い新機能についての話をしようと思います。

https://github.com/golang/go/commit/a3fb92a7100f3f2824d483ee0cbcf1264584b3e4

このコミットで、runtime/secret という新しいパッケージが入りました。

runtime/secret

このコミット、テストケースが多いだけであり実はそれほど大きな変更ではありません。実際に追加された主たる関数は secret.Dosecret.Enabled だけです。

// Do invokes f.
//
// Do ensures that any temporary storage used by f is erased in a
// timely manner. (In this context, "f" is shorthand for the
// entire call tree initiated by f.)
//   - Any registers used by f are erased before Do returns.
//   - Any stack used by f is erased before Do returns.
//   - Any heap allocation done by f is erased as soon as the garbage
//     collector realizes that it is no longer reachable.
//   - Do works even if f panics or calls runtime.Goexit.  As part of
//     that, any panic raised by f will appear as if it originates from
//     Do itself.
//
// Limitations:
//   - Currently only supported on linux/amd64 and linux/arm64.  On unsupported
//     platforms, Do will invoke f directly.
//   - Protection does not extend to any global variables written by f.
//   - Any attempt to launch a goroutine by f will result in a panic.
//   - If f calls runtime.Goexit, erasure can be delayed by defers
//     higher up on the call stack.
//   - Heap allocations will only be erased if the program drops all
//     references to those allocations, and then the garbage collector
//     notices that those references are gone. The former is under
//     control of the program, but the latter is at the whim of the
//     runtime.
//   - Any value panicked by f may point to allocations from within
//     f. Those allocations will not be erased until (at least) the
//     panicked value is dead.
//   - Pointer addresses may leak into data buffers used by the runtime
//     to perform garbage collection. Users should not encode confidential
//     information into pointers. For example, if an offset into an array or
//     struct is confidential, then users should not create a pointer into
//     the object. Since this function is intended to be used with constant-time
//     cryptographic code, this requirement is usually fulfilled implicitly.
func Do(f func()) {
// Enabled reports whether [Do] appears anywhere on the call stack.
func Enabled() bool {

現在は Linux の amd64 と arm64 だけサポートしています。この secret.Do は秘匿情報を隠す為に使われます。実際に試してみましょう

secret.Do

secret.Do は引数および戻り値なしの関数を引数に取ります。secret.Do を呼び出すと、その引数で渡した関数が呼ばれ、その関数内で作られたスタックメモリを消去するという物です。

まず、本当にその様なメモリを外部から参照できるのか試してみましょう。

package main

import (
	"fmt"
	"log"

	"golang.org/x/crypto/ssh/terminal"
)

func main() {
	fmt.Print("Enter Password: ")
	bytePassword, err := terminal.ReadPassword(0)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("\n\nYou typed: " + string(bytePassword))
	fmt.Scanln()
}

この Go のコードをいつもの様にコンパイルし実行します。

$ go build main1.go
$ ./main1
Enter Password:

パスワードプロンプトが表示されたら hoge と入力してみます。プログラムは待ちになります。この間に別のターミナルを開いて以下を実行してみましょう。

$ pgrep -f main1
25537

$ sudo gcore 25537
[New LWP 25538]
[New LWP 25539]
[New LWP 25540]
[New LWP 25541]
warning: File "/home/mattn/dev/go/src/runtime/runtime-gdb.py" auto-loading has been declined by your `auto-load safe-path' set to "$debugdir:$datadir/auto-load".
To enable execution of this file add
        add-auto-load-safe-path /home/mattn/dev/go/src/runtime/runtime-gdb.py
line to your configuration file "/root/.config/gdb/gdbinit".
To completely disable this security protection add
        set auto-load safe-path /
line to your configuration file "/root/.config/gdb/gdbinit".
For more information about this security protection see the
"Auto-loading safe path" section in the GDB manual.  E.g., run from the shell:
        info "(gdb)Auto-loading safe path"
0x0000000000492483 in runtime.futex.abi0 ()
Saved corefile core.25537
[Inferior 1 (process 25537) detached]

すると main1 の core ファイルが core.$PID の形式で出力されます。この core ファイルには main1 の現在のメモリスタックが含まれています。試しに検索してみましょう。

$ strings core.25537 | grep Password:
nter Password: hoge

入力したパスワードが抜き取れる事が分かります。

次に secret.Do を使ったコードを用意します。

package main

import (
	"fmt"
	"log"
	"runtime/secret"

	"golang.org/x/crypto/ssh/terminal"
)

func main() {
	secret.Do(func() {
		fmt.Print("Enter Password: ")
		bytePassword, err := terminal.ReadPassword(0)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println()
		_ = bytePassword
	})
	fmt.Scanln()
}

同じ様にコンパイルし実行します。

$ go build main2.go
$ ./main2
Enter Password:

同じ様に hoge を入力し、別のターミナルから core を出力させます。

$ pgrep -f main2
31834

$ sudo gcore 31834
[New LWP 31835]
[New LWP 31836]
[New LWP 31837]
[New LWP 31838]
warning: File "/home/mattn/dev/go/src/runtime/runtime-gdb.py" auto-loading has been declined by your `auto-load safe-path' set to "$debugdir:$datadir/auto-load".
To enable execution of this file add
        add-auto-load-safe-path /home/mattn/dev/go/src/runtime/runtime-gdb.py
line to your configuration file "/root/.config/gdb/gdbinit".
To completely disable this security protection add
        set auto-load safe-path /
line to your configuration file "/root/.config/gdb/gdbinit".
For more information about this security protection see the
"Auto-loading safe path" section in the GDB manual.  E.g., run from the shell:
        info "(gdb)Auto-loading safe path"
0x000000000040a1ae in internal/runtime/syscall/linux.Syscall6 ()
Saved corefile core.31834
[Inferior 1 (process 31834) detached]

パスワードを抜き取ってみましょう。

$ strings core.31834 | grep Password:
nter Password:

パスワードが盗み取れませんでした。

どういう仕組みか

どの様な実装で実現しているのか調べてみましょう。secret.Do の実装は以下の通り。

func Do(f func() {
	const osArch = runtime.GOOS + "/" + runtime.GOARCH
	switch osArch {
	default:
		// unsupported, just invoke f directly.
		f()
		return
	case "linux/amd64", "linux/arm64":
	}

	// Place to store any panic value.
	var p any

	// Step 1: increment the nesting count.
	inc()

	// Step 2: call helper. The helper just calls f
	// and captures (recovers) any panic result.
	p = doHelper(f)

	// Step 3: erase everything used by f (stack, registers).
	eraseSecrets()

	// Step 4: decrement the nesting count.
	dec()

	// Step 5: re-raise any caught panic.
	// This will make the panic appear to come
	// from a stack whose bottom frame is
	// runtime/secret.Do.
	// Anything below that to do with f will be gone.
	//
	// Note that the panic value is not erased. It behaves
	// like any other value that escapes from f. If it is
	// heap allocated, it will be erased when the garbage
	// collector notices it is no longer referenced.
	if p != nil {
		panic(p)
	}

	// Note: if f calls runtime.Goexit, step 3 and above will not
	// happen, as Goexit is unrecoverable. We handle that case in
	// runtime/proc.go:goexit0.
}

手順としては以下の通り。

  • inc() を実行して秘匿情報隠蔽始まるよというサイン
  • 引数の関数 f を panic レシーバを付けた状態で呼び出す
  • eraseSecrets() を呼び出しスタックを完全消去
  • dec() を実行して秘匿情報隠蔽終わったよというサイン
  • もし f の中で panic が発生したなら p を使って panic を投げる

実際の処理は eraseSecrets の中。

//go:linkname secret_eraseSecrets runtime/secret.eraseSecrets
func secret_eraseSecrets() {
	// zero all the stack memory that might be dirtied with
	// secrets. We do this from the systemstack so that we
	// don't have to figure out which holes we have to keep
	// to ensure that we can return from memclr. gp.sched will
	// act as a pigeonhole for our actual return.
	lo := getg().stack.lo
	systemstack(func() {
		// Note, this systemstack call happens within the secret mode,
		// so we don't have to call out to erase our registers, the systemstack
		// code will do that.
		mp := acquirem()
		sp := mp.curg.sched.sp
		// we need to keep systemstack return on top of the stack being cleared
		// for traceback
		sp -= goarch.PtrSize
		// TODO: keep some sort of low water mark so that we don't have
		// to zero a potentially large stack if we used just a little
		// bit of it. That will allow us to use a higher value for
		// lo than gp.stack.lo.
		memclrNoHeapPointers(unsafe.Pointer(lo), sp-lo)
		releasem(mp)
	})
	// Don't put any code here: the stack frame's contents are gone!
}

systemstack を使っていったん別のスタックに戻り場所を確保しておき、現在のスタックから戻り先の直前までを全てメモリクリアしていますね。本当は戻れなくなるはずなんですが、systemstack を使っているので戻れています。

実用できるのか

気になったので幾らかのパターンを試そうと思います。めちゃめちゃ性格の悪そうなテストを用意しました。

panic を起こす

関数内で panic を起こすとスタックが残る可能性があると考えました。

package main

import (
	"fmt"
	"log"
	"runtime/secret"

	"golang.org/x/crypto/ssh/terminal"
)

func main() {
	defer func() {
		fmt.Scanln()
	}()

	secret.Do(func() {
		fmt.Print("Enter Password: ")
		bytePassword, err := terminal.ReadPassword(0)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println()
		panic("!!")
		_ = bytePassword
	})
}

結果は、セーフ。

ただし panic の引数に秘匿情報を渡すとエスケープしてしまうのでヒープに代わってしまいます。お気を付けて。(いやいや、やらんやろ)

runtime.Goexit を呼び出す

runtime.Goexit を呼び出すと関数が中断されスタックにメモリが残る可能性があると考えました。

package main

import (
	"fmt"
	"log"
	"runtime"
	"runtime/secret"

	"golang.org/x/crypto/ssh/terminal"
)

func main() {
	secret.Do(func() {
		fmt.Print("Enter Password: ")
		bytePassword, err := terminal.ReadPassword(0)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println()
		runtime.Goexit()
		_ = bytePassword
	})

	fmt.Scanln()
}

結果は、セーフ。

外のメモリを触る

まぁ、さすがにこれは消されないよな、と思いながら。

package main

import (
	"fmt"
	"runtime/secret"
	"unsafe"
)

func main() {
	var outside [32]byte
	secret.Do(func() {
		ptr := uintptr(unsafe.Pointer(&outside[0])) // 外のメモリを触る
		*(*byte)(unsafe.Pointer(ptr + 0)) = 'h'
		*(*byte)(unsafe.Pointer(ptr + 1)) = 'o'
		*(*byte)(unsafe.Pointer(ptr + 2)) = 'g'
		*(*byte)(unsafe.Pointer(ptr + 3)) = 'e'
		*(*byte)(unsafe.Pointer(ptr + 4)) = 0
	})

	fmt.Println(string(outside[:]))
}

まぁ、消されるはずないよな。

ポインタを返す

もはや嫌がらせの諸行。

package main

import (
	"fmt"
	"runtime/secret"
)

func main() {
	var leaked *[]byte
	secret.Do(func() {
		tmp := []byte("secret")
		leaked = &tmp // 外にリーク
	})

	fmt.Println(string(*leaked))
}

エスケープしているのでスタックではないですね。

heap は...

まぁスタックを消す関数なので

package main

import (
	"fmt"
	"runtime/secret"
)

func main() {
	secret.Do(func() {
		buf := make([]byte, 64)
		buf[0] = 'h'
		buf[1] = 'o'
		buf[2] = 'g'
		buf[3] = 'e'
	})

	fmt.Scanln()
}

なんかうまく GC が走っちゃってるのか分かりませんが core ファイルからは抜き取れませんでした。

runtime.KeepAlive を呼ぶ

いいテストだと思います。

package main

import (
	"fmt"
	"runtime"
	"runtime/secret"
)

func main() {
	secret.Do(func() {
		x := "hoge"
		runtime.KeepAlive(&x)
	})

	fmt.Scanln()
}

結果は... セーフ!

map に入れる

これまたヤラしい

package main

import (
	"fmt"
	"runtime/secret"
)

func main() {
	secret.Do(func() {
		m := map[string]string{
			"hoge": "moge",
		}
		_ = m
	})

	fmt.Scanln()
}

結果は... hoge も moge も検出されず。セーフ!

まとめ

今回のテストにより、secret.Do の外のメモリを触るなどの特殊ケースをやらない限りは、スタックにある秘匿情報をうまく消してくれていると思います。
当然ですがヒープは対象外なので、秘匿情報を扱ってるなら runtime.GC() を呼び出して祈るしかありませね。

おわりに

Go1.26 で入るであろう runtime/secretsecret.Do 関数について調べました。もちろんヒープは GC で消されるものなので対象外ではあるんですが、少なくともスタックだけでも消してくれるのでニーズはあるのかなと思いました。

Discussion