ゼロ化 第1部:消去は状況を悪化させる可能性がある

メモリから鍵を消去するのはmemsetを呼ぶより難しい。最適化コンパイラはその書き込みを削除する権利があり、ストレージは保持されない可能性があり、コンパイラから隠すことにも代償がある。正しいゼロ化に関する連載の第1回。

日本語
コピー

2026年10月6日

鍵を扱うときの良い習慣は、使い終わったら消すことだ。

そういうわけで、プロジェクトには鍵らしきものすべてにゼロ埋め関数を追加しろという PR が山ほど届く。

これは簡単に取れる成果で、LLM はこうした問題を好んで報告してくるし、理屈の上では確かに役に立つ。だが残念ながら話はもう少し複雑だ。やみくもに鍵をゼロ埋めすると、かえって害のほうが大きくなりかねない。

実際に起きるのは、鍵を一度消去したせいで、元のコードなら存在しなかった鍵のコピーが残ることだ。しかもそれは関数が戻ったあともメモリ上に居座り続ける。

コード片とそのコンパイル結果はこの Godbolt の例で確認できる。

memset() から始める

古典的な出発点はこれだ。古くからある memset() の呼び出しが最適化で消される。

中間値を計算し、それを使って結果を出し、return の前に消す。

#include <stdint.h>
#include <string.h>

uint64_t ordinary_memset(uint64_t input)
{
    uint64_t intermediate = input ^ UINT64_C(0x9e3779b97f4a7c15);
    uint64_t result = (intermediate >> 17) ^
                      (intermediate * UINT64_C(0xd6e8feb86659fd93));
    memset(&intermediate, 0, sizeof intermediate);
    return result;
}

ここで memset() を外して、生成される命令を比べてみる。両者は完全に同一だ。

コンパイラは賢いので、intermediate の消去が無意味だと見抜いている。関数が戻ったあと、そこが二度と読まれないからだ。

だが修正に取りかかる前に、アセンブリ出力の別の点に注目してほしい。この中間値はそもそもメモリに置かれておらず、ずっとレジスタの中にある。

書き込みを本当にメモリへ届かせる

この問題に対する最も一般的な対処法は、memset() を volatile なバイト書き込みに置き換えることだ。

volatile unsigned char *bytes =
    (volatile unsigned char *)&intermediate;
for (size_t i = 0; i < sizeof intermediate; i++) {
    bytes[i] = 0;
}

さっそく試して、arm64 の出力を見てみよう(XCode 付属の Clang 21 を使っている)。

eor     x8, x0, x8
strb    wzr, [sp, #8]
strb    wzr, [sp, #7]
strb    wzr, [sp, #6]
strb    wzr, [sp, #5]
strb    wzr, [sp, #4]
strb    wzr, [sp, #3]
strb    wzr, [sp, #2]
strb    wzr, [sp, #1]

strb 命令が 8 つ。wzr がゼロ値を与える。

完璧だ。コンパイラの最適化パスを打ち負かし、スタックメモリにゼロバイトを 8 つ本当に書き込んでいる!

……待てよ。中間値をこれらのスタック領域に格納する命令はどこにある?

意外なことに、存在しない。

中間値は x8 にある。我々は今、目的の値が一度も入ったことのない 8 つの位置に、ゼロバイトを 8 つ書き込んだだけだ。

関数はその後 x8 から結果を計算して return し、それは消されていない。

本物の鍵の値は残ったままで、CPU サイクルだけを無駄にした。

消去処理を隠したらコンパイラはどうするか

もう一つよくある手を使う。消去コードを別ファイルにコンパイルした専用関数に移し、LTO を無効にする。

見覚えがあるはずだ。暗号ライブラリにはどこも、何らかの形の secure_zero() 関数が転がっている。

#include <stddef.h>

void opaque_wipe(void *pointer, size_t length)
{
    volatile unsigned char *bytes = pointer;
    while (length != 0) {
        *bytes++ = 0;
        length--;
    }
}

呼び出し側から見えるのは宣言だけなので、この呼び出しをコンパイルする時点で実装を確認できない。

opaque_wipe(&intermediate, sizeof intermediate);

では呼び出し側のアセンブリ出力を見てみよう。

面白い。呼び出しの前に命令が 1 つ増えている。

str     x8, [sp, #8]

おや? 今度は中間値をスタックに格納している? 元の関数はそんなことしていなかったぞ。

そう、消去にはアドレスが必要なのだ。そして呼び出し側は実装が見えない以上、呼ばれた側がそのアドレスの古い中身を読むと想定せざるを得ない。だから先に値を書き込む。

消去が消すのはこの新しいスタック上のコピーで、x8 はそのまま残る。

つまりこうだ。値がメモリ上に存在する時間窓をわざわざ作り、それを消すコストを払い、それでいてレジスタ上のコピーは依然として残っている。

おまけに、書き込み専用だと判別できる操作ならこのストアは省略できるし、LTO を有効にしていれば(今や珍しくない)、実装を別ファイルに隠す努力はほぼ無駄になる。

もう一つ、tag で比較してみる

次はよくある、しかし少しだけ自明でない処理だ。tag(HMAC の出力など)を計算し、比較し、消去し、結果を返す。

この例では tag を seed[i] ^ 0x5a として計算する。まったく安全ではなく、馬鹿げているが、アセンブリを追うには都合がいい。

これをアプリケーションから渡された tag と定数時間で比較する。

#include <stddef.h>

void opaque_wipe(void *pointer, size_t length);

int compare_and_wipe(const unsigned char seed[16],
                     const unsigned char candidate[16])
{
    unsigned char computed[16];
    for (size_t i = 0; i < 16; i++) {
        computed[i] = seed[i] ^ 0x5a;
    }

    unsigned int different = 0;
    for (size_t i = 0; i < 16; i++) {
        different |= (unsigned int)(computed[i] ^ candidate[i]);
    }
    int equal = different == 0;
    opaque_wipe(computed, 16);
    return equal;
}

呼び出し側と消去関数を別々にコンパイルし、LTO は無効にする。

clang -std=c11 -O3 -Wall -Wextra -Werror -S compare.c -o compare.s
clang -std=c11 -O3 -Wall -Wextra -Werror -c wipe.c -o wipe.o

C コードでは、比較は消去より前に現れる。

だが逆アセンブル出力をよく見てほしい。

sub     sp, sp, #80
stp     x29, x30, [sp, #64]
add     x29, sp, #64
ldr     q0, [x0]
movi.16b v1, #90
eor.16b v0, v0, v1
str     q0, [sp, #16]
stur    q0, [x29, #-24]
ldr     q0, [x1]
str     q0, [sp]
sub     x0, x29, #24
mov     w1, #16
bl      _opaque_wipe
ldp     q1, q0, [sp]
cmeq.16b v0, v1, v0

待て。XOR のあとに q0 のストアが 2 回あるのはなぜだ?

それに、比較命令 cmeq が消去呼び出しの後にあるのはなぜだ?

理解するために、まずスタックを整理しよう。スタックフレームを確保したあとのスタックポインタを S とし、フレームポインタ x29 は S + 64 に等しい。

スタックバイト内容消去されるか
S から S + 15候補値いいえ
S + 16 から S + 31計算した tag の追加コピーいいえ
S + 40 から S + 55アドレスを取られた computed オブジェクトはい

ああ、そういうことか。コンパイラは比較のオペランドを呼び出しの前にロードしたが、比較そのものは呼び出しのあとに先送りした。

計算した tag を呼び出しの間ずっと使えるようにするため、もう 1 部コピーしてスピル領域に置いたのだ。

我々の消去は要求どおり 16 バイトをきれいに消している。ここは問題ない。続いて ldp がもう一方のコピーを再ロードし、cmeq がそれと比較する。

だが関数が戻るまでに、そのスピル領域を消すものは何もない。

ここからが面白い。消去処理を外してもう一度コンパイルしたらどうなるか。

意外なことに、計算した tag はそもそもスタックに格納されなくなる。すべてレジスタに収まる。

消去処理を足したことで、かえって我々が残すコピーを作り出してしまった。

念のため言っておくと、これはコンパイラのバグではない。戻り値は正しく、アドレスを取られたオブジェクトも上書きされている。C はこの 2 つの間に望ましい安全な順序を保証していないだけだ。

Godbolt でこの 2 つの関数を比較できる。

そこではアドレスを取られたオブジェクトは sp + 32 ではなく sp + 40 にある(私が Xcode で得た結果だ)が、sp + 16 の余分なコピーは消去のあとも生き残ったままである。

スタック保護を無効にすると別のコードが生成されるが、それは修正でも良い考えでもなく、偶然で当てにならない副作用といったほうが近い。

つまり、あるときはストレージのために、またあるときは新たな呼び出しとスタックのコピーのために、代償を払うことになる。それでも秘密はそこに残ったままだ。クリーンアップを改善するはずの変更としては、まったくもどかしい話である。

レジスタを無視すべきではない

コンテキストスイッチのたびに、レジスタはメモリへコピーされる。レジスタはコアダンプ、ハイバネーションファイル、VM スナップショットのいずれにも現れる。マイクロアーキテクチャの脆弱性(Zenbleed)を通じて漏れることもある。

SIMD レジスタひとつに、256 ビットの鍵なら丸ごと収まってしまう。512 ビットのハッシュならなおさらだ。しかも汎用レジスタに比べて再利用される頻度はたいてい低い。ところが従来のゼロ埋め関数は、メモリにゼロを書き込むことだけを想定していて、実際の実行の流れについては何も知らない。

役に立つ消去は残す

では、パスワードのバッファはもう消去すべきではないのか。そうではない。もともとメモリ上に存在するバッファなのだから、使い終わったら消すのは理にかなっている。すでに確保済みの鍵スケジュールや、まもなく破棄されるコンテキストを消去するのも同じ理屈だ。

だが先ほどの例では、問題の値はほかの場所にも同時に存在していた。

だから小さなローカル変数に消去を加える前に、その値がすでにメモリ上にあるか、アドレスを取るとストレージが確保されてしまうか、呼び出しの間に何が生き残るかを確かめよう。確認するのは、実際にリリースする最適化ビルドだ。LTO やハードニングフラグも含めて。

あるいは、バッファ消去関数の呼び出しをあてもなくばら撒くのはやめよう。機密を消去するには、もっと確実な方法がある。

それは第二部で。

関連記事

出典: 00f.net← ホームへ戻る