OpenSSL 3.0.7がリリース バッファオーバーフローを引き起こす脆弱性を修正
2022年12月15日 09時00分更新
プライム・ストラテジー「KUSANAGI」開発チームの石川です。
今回は先日公開されたOpenSSL 3.0.7で修正された脆弱性CVE-2022-3602を起点にバッファオーバーフローに関して少し詳しくお話したいと思います。
OpenSSL 3.0.7は2022年11月1にopenbsd.orgからリリースされたOpenSSLの最新版で、以下の2つのセキュリティアドバイザリーに対応しています。
X.509 Email Address 4-byte Buffer Overflow(CVE-2022-3602)
X.509 Email Address Variable Length Buffer Overflow(CVE-2022-3786)
両方とも重要度「High」の脆弱性ですが、CVE-2022-3602は当初は重要度「Critical」としてアナウンスされたため、一時注目が集まりました。最終的には重要度「High」に引き下げられましたが、今回はこの脆弱性の概要とそれがどうして重要度「Critical」から「High」となったのかを解説します。
1. バッファオーバーフローとは
CVE-2022-3602の脆弱性はX.509証明書(いわゆるSSL証明書と呼ばれるもの)を検証する処理において、バッファオーバーフロー(バッファオーバーランとも言います)が起きるバグがあったというものでした。バッファオーバーフローとはプログラム内で処理のために確保されているメモリを超えてメモリを上書きすることをいいます。
バッファオーバーフローの簡単な例を以下に示します(※Linux x86_64 gcc 8.5.0の場合です。アーキテクチャやOSによっては再現しない場合があります)。
overflow.c #include <stdio.h> #include <string.h> int main(int argc, char *argv[]) { int x = 0x12345678; // 変数x char buf[12]; // 12バイトバッファ printf("0x%x\n", x); // 変数xを表示 memset(buf, 0x00, sizeof(buf) + 2); // バッファを14バイト0x00で埋める printf("0x%x\n", x); // 変数xを再度表示 return 0; }
$ gcc overflow.c -o overflow $ ./overflow 0x12345678 ← memset前の変数x 0x12340000 ← memset後の変数x
このコードでは12バイトで宣言しているバッファを、あえて14バイト(12バイト+2バイト)で上書きしています。その結果、変数xの下位2バイトが上書きされて0x12345678となるべきものが0x12340000になってしまっています。もしも12バイト+4バイト(intのサイズ)を上書きしていれば、0x00になっていました。これがバッファオーバーフローです。
この例ではバッファを0x00で埋めていますが、ここが任意の値で上書きする処理であった場合には変数xを任意の値で上書きすることができます。特に実行できる内容をメモリに書き込むことで、攻撃者はこれを利用してプログラムに意図しない処理を起こし、システム障害や重要なデータの取得などを行なうのです。
2. CVE-2022-3602の概要
さて、話をCVE-2022-3602に戻します。
当初重要度「Critical」とされたのは、特別に細工したX.509証明書を使用することで、任意のコードを実行できると考えられたからです。OpenSSLはTLSサーバやTLSクライアントのライブラリとして一般的に使用されています。TLSサーバへの攻撃例としては、例えばNginxのようなWebサーバに対して特別に細工したX.509クライアント証明書を提示して、WebサーバのOpenSSLライブラリに検証させることで、任意コードを実行させることが考えられます。
TLSクライアントへの攻撃としては、特別に細工したX.509サーバ証明書を設置したWebサーバを用意して、curlのようなOpenSSLライブラリを使用するクライアントでアクセスさせることで、任意コードを実行させます。「悪用しやすい」脆弱性に見えたので「Critical」だったのです。
しかし、実際に実証をしてみるとそこまで任意コードを実行させることが容易ではないことが明らかになってきました。
- バッファオーバーフローを起こす処理が限定されている
まず、このバッファオーバーフローを起こす処理はX.509証明書の検証において punycode [*] をデコードする処理で発生するものでした。しかもX.509証明書のメールアドレスを検証する処理でのみ使われていました。
オレオレ証明書ならばともかく、権威のある認証局によって署名されたX.509証明書でバッファオーバーフローを起こす細工を行なうのは難しいというのが1つ目のポイントです。
[*] punycode
RFC 3492で定義されている国際化ドメイン名で使われる文字符号化方式。
例えば「プライム・ストラテジー.jp」は「 xn--eckxbc6cj1gyctde1pqa.jp」に変換される。
- バッファオーバーフローの大きさ
次にpunycodeのデコード処理で発生していたバッファオーバーフローについてです。for文やwhile文を書いたことがある人なら身に覚えがあると思いますが、終了条件が間違っていて、本来よりも1回分多くコピーしていたというものです。よってバッファオーバーフローするのは4バイト分に限定されていました。
任意コードを実行させることを考えると、この4バイト分ではできることが限定されるのが2つ目のポイントです。
- Linux環境ではコンパイラによっては発生しない場合がある
gccのようなコンパイラはソースコードからバイナリにする際にメモリを効率よく使えるような最適化をしています。その1つに8バイトアラインメントというものがあります。
全ての変数はぴったりと敷き詰めるようにメモリに格納されているわけではなく、gcc x86_64の場合には8バイトでアラインされるように格納されます。この場合、アラインメント間に「余白」が4バイト分あります。よって、4バイト分あふれて上書きしたとしても、別の変数の値を上書きしないことが分かったのです。
先程の例を見てみましょう。
overflow.c int x = 0x12345678; // 変数x char buf[12]; // 12バイトバッファ
この時のスタックは以下のようになっているので変数xを上書きできました。
4byte 4byte 4byte 4byte +------+------+------+------+ |char buf[12] |int x | +------+------+------+------+
しかし、バッファを16バイトで宣言するとどうなるでしょうか。
overflow_16.c int x = 0x12345678; // 変数x char buf[16]; // 16バイトバッファ
この時のスタックは8バイトアラインされて以下のようになるので、16+2バイトで上書きしても、変数xを上書きしません。
4byte 4byte 4byte 4byte 4byte 4byte +------+------+------+------+------+------+ |char buf[16] | 余白 |int x | +------+------+------+------+------+------+
実際に16バイトに変えて実行すると以下のようになるのが分かります。
$ gcc overflow_16.c -o overflow_16 $ ./overflow_16 0x12345678 ← memset前の変数x 0x12345678 ← memset後の変数x、値が変わらない
以上のことから「悪用しやすい」脆弱性ではないという判断に変わり、重要度「Critical」から重要度「High」に引き下げられたのです。
3. gccのバッファオーバーフロー対策
gccにはこういったバッファオーバーフローを防止するための仕組みとして「-fstack-protector」というオプションがあります。これを有効にしてソースコードをコンパイルした場合、スタックとスタックの間にクッキーを入れるようになります。
バッファオーバーフローが起きた場合はクッキーが上書きされるので、このクッキーが改変された場合には処理を止めることができます。全てのLinux環境のOpenSSLバイナリで、このオプションが有効化されてコンパイルされている保証はありませんが、有効であればCVE-2022-3602のようなバッファオーバーフローは防止できることになります(もっともCVE-2022-3602では、先に書いた通りあふれた分がアラインメントの余白で吸収されるので、そもそもクッキーを上書きすることがないのですが……)。
先程の例を12バイトのままで「-fstack-protector」を有効にしてコンパイルします。
$ gcc -fstack overflow.c -o overflow $ ./overflow 0x12345678 ← memset前の変数x 0x12345678 ← memset前の変数x、クッキーが上書きされるので値が変わらない *** stack smashing detected ***:terminated ← クッキーの改変を検知して終了 中止 (コアダンプ)
他にx86_64ではCPUレベルでオーバーフローした部分のコードを実行させない仕組み(NXビットやXDビットと呼ばれる)もあります。これらについてはIPAの記事が参考になりますので、興味があれば見てみてください。
4. 余談: リリースが取り止められた OpenSSL 3.0.6 と脆弱性対策の難しさ
2022年11月7日時点のhttps://www.openssl.org/ を見ると、以下の記述があります。
12-Oct-2022 OpenSSL 3.0.6 and 1.1.1r are withdrawn. New releases will be created in due course.
また、ソースコードのアーカイブにも3.0.6はありません。3.0.6は2022年10月11日にリリースされたものの、regression(劣化、日本語ではデグレードと言われることが多い)が見つかったことで取り下げられたのです。
では、3.0.6はなぜ取り下げられたのでしょうか?
OpenSSLの内部でスタックを扱う内部ライブラリ関数があるのですが、実は先のような脆弱性の原因となるバッファオーバーフローや、それに類する処理を事前に検知できるように、サイズを超えるメモリにアクセスしたり、負のサイズを参照したりした際にエラーを出すような修正を入れたのです。しかし、これが大きな問題を呼ぶことになりました。
例えばstackのX番目の値を取得するsk_get(stack, X)があります。このXに stack のサイズを超えた値や負の値を指定するとエラーを出すようにしました。他に最後の値を取得するsk_pop(stack, X)があります。
これらもstackが既に空(値が残っていない)状態で sk_pop(stack, X)を実行するとエラーになるようにしました。いずれも、範囲外の処理をしようとしていることをOpenSSLがOpenSSLの利用者に対して分かるようにしたものです。
しかし、いざエラーを有効にしてみるとこういった範囲外の値を指定した処理が各所にあることが分かったのです。
特にsk_popに関しては、以下のようにNULLになるまでpopするというのはCでは一般的な書き方です。そのため、動作上もコーディング上も問題はないのですが、最後に空のstackに対してpopすることで新規にエラーが発生することが、利用者の混乱を呼んだのです。
while ((item = sk_pop(stack)) != NULL ) { // ループ処理 }
範囲外の値を参照しないようにエラーを出すのは正しいことですし、範囲外の値を参照するようなコードを書くことにも問題があります。しかし、OpenSSLのようにいろいろなソフトウェアが利用するライブラリがこれまでエラーを出してこなかったものが、急にエラーを出すようになると、全てのソフトウェアが一気に正しく直すことができるわけではありません。
本件については、参照するべきではない処理であったとしても「元々エラーを出していなかった処理で、新規にエラーを出すべきではない」として最終的には3.0.6で追加したエラーメッセージは全て外して3.0.5の状態に戻すことになりました。
そもそも参照するべきではない処理とは何なのか、何をもってエラーとするのか、そして脆弱性をなくしていくためであっても、広く使われているソフトウェアの仕様を変えるとどういったインパクトがあるのかを考えらせられる内容でした。