C++におけるセキュリティリスクのトップ5
Snyk Security Research Team
2022年8月16日
0 分で読めますC++は開発者に多くの強力な機能を提供するため、さまざまな業界や多くの中核システムで使われています。しかし、リソースを直接制御する機能が限られている一部の高級言語とは異なり、C++にはさまざまなセキュリティ上の懸念があり、プロジェクトに脆弱性を持ち込まないよう、開発者はコードを書く際に十分注意する必要があります。
開発者として、私たちはエンドユーザーのことを考えてアプリケーションを構築します。ユーザーはデータや時間、デバイスへのアクセスを私たちに託しています。アプリケーションとユーザーデータを常に安全に保つことは、私たちの責任です。
この記事では、コード開発に影響するC++の主なセキュリティ上の懸念を5つ取り上げ、その軽減策を紹介します。
1. バッファオーバーフロー
C++で最もよく見られるセキュリティ上の懸念の1つが、バッファオーバーフローです。C++にはメモリの上書きリスクを低減する境界チェック機能が組み込まれていないため、バッファオーバーフローが発生します。割り当てられたメモリの範囲外に書き込むと、プログラムがクラッシュしたり、データが破損したりする可能性があります。悪意のあるコードが実行されることさえあります。そのため、バッファオーバーフローは、2021 CWE Top 25 Most Dangerous Software Weaknessesの調査で最も危険なソフトウェアの弱点に選ばれました。
2019年にWhatsAppアプリケーションのVoIPスタックを狙ったバッファオーバーフロー攻撃は、この脆弱性がもたらす深刻な影響を示しています。この攻撃では、攻撃者がWhatsAppのバッファオーバーフロー脆弱性を悪用し、標的ユーザーのスマートフォンにスパイウェアを送り込みました。
実際のバッファオーバーフロー脆弱性の例を見てみましょう。次のコードでは、gets関数でユーザー入力を取得し、それがimportant_data変数に影響したかどうかを確認します。
このコードは一見問題なさそうですが、user_input配列の長さを超える文字列をユーザーが入力すると、バッファオーバーフロー攻撃に対して脆弱です。スタックは低位アドレス側に伸びるため、メモリ上ではimportant_dataがuser_inputの下に配置されています。
この問題を防ぐ方法はいくつかあり、コンパイラーやOS、カーネルの機能に依存するものもあります。データを保護するために使える主な機能は、スタックカナリア、アドレス空間配置のランダム化(ASLR)、データ実行防止(DEP)です。
スタックカナリアは、プログラムの起動時にランダムに選ばれた新しい秘密の値をスタックに追加します。関数が戻る前に、この値が検証されます。
ASLRはメモリの配置を攻撃者に知られないようにすることで、バッファオーバーフロー攻撃を困難にします。データがメモリのどこにあるかわからなければ、攻撃者はどのバッファを狙えばよいか判断できません。
DEPは、スタックなど特定のメモリ領域を実行不可として設定します。
OSやコンパイラーの機能を利用するだけでなく、境界チェックなど、適切なコーディングプラクティスを実践する必要があります。get、strcpy、strcat、scanf、printfなど、境界チェックを行わず、バッファオーバーフロー攻撃に対して脆弱な標準ライブラリ関数は避けましょう。代わりに、fgetsなどの同等の安全な関数を使用します。
2. 整数オーバーフローとアンダーフロー
整数オーバーフローとは、整数変数に格納しようとする値が、その整数型で表現できる最大値を超えることです。整数アンダーフローは、値が表現可能な最小値を下回ると発生します。その場合、値はラップアラウンドします。
2021 CWE Top 25 Most Dangerous Software Weaknessesの調査では、これは危険度の高いソフトウェアの弱点の第12位にランク付けされました。また、整数オーバーフローやアンダーフローは、バッファオーバーフロー脆弱性につながることがあります。
次のコードは、OpenSSH v3.3で実際に見つかったバグであり、整数オーバーフローによってバッファオーバーフロー攻撃が起こりうる例です。
このコードは整数オーバーフロー脆弱性を示しています。コードではゼロ値をチェックしていますが、入力値nrespが1073741824の場合、メモリ割り当てサイズがゼロになる可能性があります。この値に4(charポインターのサイズ)を掛けると変数がオーバーフローし、xmalloc(nresp*sizeof(char*))はサイズ0のバッファを割り当てます。
この脆弱性を軽減するには、変数値のオーバーフローやラップアラウンドを防ぐため、ゼロ値/最小値と最大値の範囲チェックを実施します。
3. ポインターの初期化
ポインターの初期化は非常に重要です。メモリ位置や関数を指すように初期化されていないポインターを使うと、多くのデータが露出する可能性があります。未初期化のポインターがメモリ位置を指している場合、予期しないメモリ位置からプログラムが読み取ったり、そこに書き込んだりすることがあります。関数を指している場合は、意図せず任意の関数が実行される可能性があります。
ポインターはヌルポインターのデリファレンス攻撃にも脆弱で、プログラムの信頼性に問題をもたらす可能性があります。ポインターのデリファレンス攻撃とは、ポインターがnullに初期化された状態でアクセスされ、プログラムに未定義動作(UB)が発生することです。多くの場合、プログラムはクラッシュします。攻撃者はヌルポインターのデリファレンスを強制することで、これを悪用できます。ポインターの初期化を誤ると、予期しない不安定な動作が発生します。攻撃者は一部のセキュリティチェックを回避したり、後で利用できるデバッグ情報を入手したりする可能性があります。
ポインターの初期化が不適切なために発生する、ポインターのデリファレンスの例を見てみましょう。
ご覧のとおり、ポインターは未初期化のままです。つまり、ランダムな値が割り当てられ、null以外の値である可能性があるため、チェックを通過してしまいます。
ポインターのデリファレンス攻撃を防ぐために、チェックやエラー例外処理だけに頼るべきではありません。可能であればポインターを使わず、代わりに参照を使うことを検討しましょう。ポインターを使う場合は、rawポインターの代わりにスマートポインターを使用します。
ポインターの代替策として、実装にリソース取得は初期化(RAII)の手法を取り入れる方法もあります。これにより、そのリソースにアクセスできるすべての関数でリソースが利用可能であることが保証されます。
4. 型変換の誤り
よくある脆弱性のもう1つが、型変換の誤りです。型変換に関する問題の多くは、符号付きから符号なしへの変換で発生します。通常、関数呼び出し時に誤った型のパラメーターを渡すことで起こります。また、doubleからfloat、long intからintのように、より大きなデータ型から変換する際に発生する型変換も一般的な脆弱性です。暗黙的な変換によってデータが失われる可能性があります。
型変換の誤りによる脆弱性の簡単な例を見てみましょう。
どのような入力文字列の長さも-1より大きいため、else文が実行されることはないように思えます。しかし、このコードは期待どおりに動作せず、常にelse文が実行されます。C++標準と整数昇格の概念によると、異なるデータ型の値を比較する場合、値の表現が変換されます。
この例では、signed short intの値が、より大きな型であるunsigned intにキャストされます。これにより、-1の値は4294967295と等しい符号なし整数値に変換され、プログラムの処理はelse文に進みます。
ユーザーが最初に小さい値を入力することはないと想定して、2つの入力値の減算に符号なし整数を使う場合にも、同じ問題が起こりえます。その結果、減算の結果が負の値になる可能性があります。
Google C++ Style Guideによると、符号なし整数を使った計算を避けることで、型変換に関する問題の大半を軽減できます(ビットフィールドの表現を除く)。また、符号の異なる型を混在させないことや、ポインターとサイズの代わりにイテレーターとコンテナーを使うことも推奨しています。
5. 書式文字列の脆弱性
書式文字列の脆弱性には、書式指定関数と書式文字列の2つの要素があります。書式文字列の脆弱性について見ていく前に、書式指定関数と書式文字列の役割を確認しましょう。
書式指定関数は、プログラミング言語の変数を人間が読める形式に変換する関数です。書式指定関数の例として、printfやfprintfがあります。
書式文字列は、テキストと書式文字列パラメーターを含む、書式指定関数の引数です。例を見てみましょう。
"This is a test text of number: %d ", 11"が書式文字列です。"%d"は、変換形式を指定する書式文字列パラメーターです。
書式指定関数に渡すパラメーターをチェックしないと、書式文字列攻撃が発生します。たとえば、次のようなアプリケーションを実装したとします。
このコードはユーザー入力をチェックしないため、書式文字列攻撃に対して脆弱です。攻撃者は、次のような書式文字列パラメーターを含む入力を渡して攻撃します。
この入力はスタックからデータを取得できます。プログラムの出力は、次のようになります。
この出力が発生するのは、printfが%pを、メモリアドレスを解釈しようとするvoidポインターへの参照として扱うためです。
この脆弱性を防ぐには、次のようにコードに書式引数を追加する必要があります。
上記のコードスニペットは文字列を解釈しないため安全です。たとえば、このコードをコンパイルして実行すると、次のように出力されます。
出力されるのは、voidポインターへの参照として解釈されることなく、プログラムに渡された文字だけです。
より優れた解決策は、使用を強制される場合を除き、可能な限りprintfを使わず、代わりにstd::formatとstd::vformat(C++ 20で導入)を使うことです。これらは実行時またはコンパイル時に入力と型が一致するか検証し、不一致がある場合はformat_errorを発生させます。
C++のセキュリティリスクを軽減する
この記事では、C++を扱う際の主なセキュリティリスクを5つ取り上げました。攻撃者がこれらの脆弱性を悪用してユーザーデータにアクセスしたり、アプリケーションから情報を取得したりする方法と、コードにおける脆弱性の例を見てきました。また、こうした脆弱性が深刻なセキュリティ侵害に発展するのを防ぐための対策も紹介しました。
リソースを直接制御する機能が限られている高級言語とは異なり、C++にはさまざまな脆弱性があり、プロジェクトに持ち込まないよう、開発者はコードを書く際に注意する必要があります。脆弱性の悪用にはさまざまな形があり、攻撃者は絶えず攻撃手法を広げています。C++の脆弱性について詳しくは、Snykのセキュリティリサーチチームによるこちらのブログと、C/C++におけるディレクトリトラバーサル脆弱性を解説したこちらの記事をご覧ください。
開発者として、ユーザー情報が漏えいするセキュリティ攻撃からソフトウェアを守り、信頼性を確保するためのセキュリティ対策を実施する責任があります。適切な対策を講じれば、この記事で取り上げた脆弱性はすべて防ぐことができます。
Capture the Flagを始めよう
オンデマンドのバーチャル入門ワークショップを見て、Capture the Flagの課題の解き方を学びましょう。



