はじめに
ペンギンになりたいエンジニアの島ノ江です。
普段は CSIG で「FutureVuls」という脆弱性管理サービスの開発を担当しています。
Go1.26リリース連載 の6日目。今回は Go 1.26 で New experimental として追加される runtime/secret を、特にどのような議論がされてきたかについて見ていきます(リリースノート)
…と思っていたのですが、既に mattn さんがこのパッケージの概説と実験をされています。本機能の導入によりどのように機密情報の扱いが変わるかについてはこちらの記事をご参照ください。
https://zenn.dev/mattn/articles/64d85241fd3726
そこで、本記事では以下の内容に触れていこうと思います。
- 前方秘匿性について
- Go でのメモリ管理について
- 本機能が提案された背景(issue を遡って)
- 機能概要・注意事項
前方秘匿性について
現代のセキュリティプロトコルでは前方秘匿性(Forward Secrecy)は必須の要件となっています。これは、将来的に長期的な秘密鍵が漏洩したとしても、過去の通信内容が解読されないことを保証する性質を指します。もし前方秘匿性がない場合、攻撃者が過去の暗号化された通信をすべて記録していた場合に、後から秘密鍵を手に入れるとその瞬間にすべての過去ログが筒抜けになってしまいます。
前方秘匿性は、セッションごとに使い捨てられる Ephemeral Key(一時鍵)により支えられます。セッションが終わればこの一時鍵は破棄され、二度と復元できないことが前提となります。
2014年には OpenSSL に Heartbleed 脆弱性 が見つかりました。OpenSSL のメモリ管理の不備により、サーバー上の機密情報が外部に漏洩するリスクが生じ、このころ前方秘匿性が注目されるようになりました。
Go のドキュメントにも、前方秘匿性について詳細を知りたい場合は Wikipedia を参照してくれと書いているので、詳細が気になる場合はこちらをご参照ください。
なお、TLS1.3 では前方秘匿性を持たない古い鍵交換方式が廃止され、必須の要件になっています(リンク)。
all public-key based key exchange mechanisms now provide forward secrecy. |
しかし、これを実装する場合に、次のような問題が生じます。「プロトコル上で鍵を破棄しても、サーバーのメモリ上にその残骸が残っていたら、そこから漏洩してしまうのでは?」という問題です。
理論上は使い捨てでも、実装上メモリに一時的に保管されるデータが残存していては、それは「一時的」な秘密鍵にはなりません。もし攻撃者がメモリリークやコアダンプにより残存データを吸い上げられたら、そもそも前方秘匿性の前提が崩れてしまいます。
今回の runtime/secret では、この「物理メモリ上に一時的な情報を即時消去することを、Go のランタイムレベルで保証する」という機能です。
ところで、暗号の話で出てくる鍵共有アルゴリズムに DHE がありますが、このうちの E は Ephemeral なんですよね。RFC 7919 にも「Diffie-Hellman Ephemeral(DHE)鍵交換」として説明されています。私は最初 Hellman か Key Exchange の E だと思い込んでいました。DHE については過去の HTTPS に関するブログも参考になれば幸いです。
メモリ管理について
C++ や Rust のような言語では、開発者がメモリのライフサイクルを完全に掌握します。秘密鍵などの機密情報を使い終えたら、explicit_bzero や memset_s といった関数を呼び出すことで、コンパイラの最適化によって消されることなく即座にメモリを物理的にゼロ埋めできます。
一方で、Go のような GC を採用している言語では、このメモリ管理が抽象化されているため、以下の観点からメモリを即座にゼロ埋めすることが簡単にはいきません。
- コンパイラの最適化:手動でゼロ埋めするコードを書いても、コンパイラがその処理を無視する場合がある
- GC によるコピー:Go のランタイムがスタックの拡張などの過程でメモリの中身を別の場所にコピーすることがあり、元の場所にデータ残存のリスクがある
そこで、WireGuard の実装では issue コメントにあるような形で、”Unholy Hacks“ な方法でゼロクリアをしようとしています(コメント)。このような工夫をせずに、言語仕様としてこのゼロ埋めを実行して、前方秘匿性を担保できるようにしたいことから issue に繋がっています。
ちなみに、Go の GC については先日棚井さんが記事を書いていたので、そちらもよろしければどうぞ。
https://future-architect.github.io/articles/20260130a/
issueの流れ
本機能に関する issue は 2017年9月まで遡ります。
実装までに8年以上を要しており、導入にとても慎重だったことがうかがえます。
https://github.com/golang/go/issues/21865
本機能は、セキュリティエンジニアの Jason A. Donenfeld 氏(WireGuardの開発者)からのセキュリティの観点での主張と、Go のコアチームからの言語としての整合性の観点が対立していたためです。
例えば、以下のような観点が議論されていました。
GC によるコピーの残存
メモリ効率のために、ランタイムがデータを移動させる場合があります。
例えば、スタックが足りなくなった場合に、新しいメモリ領域を確保して中身をコピーします。この場合、古いスタック領域にはデータのコピーが残ってしまいます。また、ライブラリの中で Clear() をして今のメモリデータを消去しても、ランタイムが勝手に作ったコピーが残っている可能性はありえます(コメント)。またさらに OS レイヤーでの話として、OS の割り込み処理が起きると CPU のレジスタにある機密情報はシグナルスタックに書き込まれますが、これの消去が難しいとも書かれています(コメント)。
「開発者は機密データを消したつもりだが、実際にはメモリ上にデータが残っている可能性がある」という “false sense of security“ に繋がりかねない点が懸念として提示されています。
最終的にこの課題は Daniel Morsing 氏の案で解決されました(コメント)。具体的には、「特定の実行スコープ(関数実行)に限定してクリーンアップを保証する」という現実的なアプローチが採用されたことで、8年にわたる議論が収束に向かいました(詳しくは mattn さんのブログにて)。
機能概要
runtime/secret の機能は GOEXPERIMENT=runtimesecret の環境変数を設定することで利用できるようになります。
この中身はシンプルで、func Do(f func()) と func Enabled() bool が定義されているだけです。
https://pkg.go.dev/runtime/secret
Do は、引数でとる関数 f の中で利用される一時的な記憶領域を確実に消去することを保証します。
また、Enabled は現在の実行コンテキストが Do の呼び出しスタック内にあるかを返します。
詳しくは、以下のような事項が保証されるようになります。
fによって使用されるレジスタとスタックは、Doが返る前に消去されるfによって行われたヒープメモリは、割り当てられたすべての値に到達できなくなったことを GC が認識するとすぐに消去される(注意:即時ではない)Doはfがパニックを起こしたりruntime.Goexitを呼び出したりした場合でも動作する(fで発生したパニックはDo自体から発生したかのように表示される)
注意事項
本機能はまだ実験的に導入されているものであり、注意が必要な点もあります。
ヒープ割り当ては即時で消去されない
前述のように、runtime/secret ではスタックとレジスタの即時消去を保証しています。一方で、new や make などで確保されるヒープ割り当てについては、GC が回収するまでは消去されません。
ランタイムが「GC が到達不能と判断した時点で消去」しますが、あくまで次の GC サイクルまでは情報が保持されます。そのため、機密情報を扱う場合、make([]byte, n) のようなヒープを使った方法ではなく、可能な限り [32]byte など固定長の配列を利用してスタックのまま留めるなど、注意を払う必要があります。
サポート対象
Go 1.26 では、linux/amd64 と linux/arm64 でのみサポートしています。
本機能は、単にメモリを書き換えるだけではなく、CPU レジスタの消去やランタイムによるスタックフレームの制御などが必要になります。これらはアーキテクチャごとに異なり、レジスタに残った機密情報を確実に消し去る場合、それぞれの CPU 命令セットに合わせてアセンブリレベルで実装しなければなりません。
また、レジスタの内容が OS にどう保存されるかは、OS のカーネルに依存します。これらの理由から、現時点では一部のアーキテクチャにのみ対応しています。
なお、サポートされていないプラットフォームでは、Do は単に f のラッパーになるだけで、保護機能は働きません(このような場合にコンパイルエラーにならないでちゃんと動作してくれるのは、互換性を保っていてすごいなと個人的に関心してます)。
Enabled() を使うと、保護機能が有効化どうかを実行時に判定できます。
if secret.Enabled() { |
パフォーマンス上の影響
スタックのゼロクリアにはコストがかかるため、少しパフォーマンスが悪くなります。
例えば、以下のようなコードで検証してみます。
package main |
結果は以下の通りです。 runtime/secret を利用している場合(Secret)は、利用しない場合(Normal)に比べて3倍程度実行速度が異なっていることがわかります。
Secret の中では、同じ領域に対して消去時を含めて2回書き込みをしているため、パフォーマンスに差が出てきます。しかし、暗号計算全体のコストに比べると小さな時間であり、それほど大きな影響はないでしょう。
$ GOEXPERIMENT=runtimesecret go1.26rc2 test -bench . -benchmem |
最後に
runtime/secret パッケージが導入されたことで、これまで Go のコミュニティが工夫して対応してきたメモリ上の情報管理を、ランタイムレベルでサポートするようになりました。暗号の安全な利用が可能になってくると、クリティカルなシステムインフラなどでも Go を採用していくケースが増えていくのでしょうか。
また、今後は Mac や Windows などその他のプラットフォームや、crypto/tls などの標準ライブラリ内部での runtime/secret の採用など、対応範囲の拡大も期待できます。
本機能は(暗号系の実装をしているわけではないため)、普段の開発には直接影響しなさそうです。しかし、言語レベルでの機能導入により、我々が意識しなくても前方秘匿性が担保され、全体でセキュリティレベルが向上していくのは喜ばしいですね。
以上で本記事を終えようと思います。ありがとうございました。