メインコンテンツまでスキップ

ヒープバッファーオーバーフロー

ヒープバッファーオーバーフローは、プログラムがヒープメモリに割り当てられたバッファの境界を越えてデータを書き込むことによって発生します。これにより、隣接するメモリ領域が破損し、予期しない動作やクラッシュを引き起こす可能性があります。

この問題の厄介な点は、ヒープメモリの管理がプログラムの実行時に動的に行われるため、オーバーフローが発生してもすぐには検出されないことです。オーバーフローが発生した後プログラムは正常に動作しているように見えることがありますが、オーバーフローによってヒープの構造が破損しているため、最終的にはクラッシュやデータの不整合を引き起こす可能性があります。

問題が生じる例

以下のC++コードは、ヒープバッファーオーバーフローの問題が生じる例です。

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>

__declspec(noinline)
void DoBadThing(char *buffer)
{
strcpy(buffer, "This is a long string text that exceeds the buffer size.");
}

int main()
{
char *buffer = new char[8];
DoBadThing(buffer);
delete[] buffer;
printf("finished\n");
}

このコードでは、bufferに8バイトのメモリを割り当てていますが、strcpy関数でそのバッファのサイズを超える文字列を書き込んでいます。これにより、ヒープメモリの隣接領域が破損し、プログラムがクラッシュする可能性があります。

Visual Studio 2022 version 17.14.7でx64|Releaseビルドを実行すると、最後のprintf関数が実行される前にクラッシュし、イベントログにApplication ErrorのソースでイベントID1000のエラーが以下のメッセージで記録されます。

障害が発生しているアプリケーション名: ConsoleApplication1.exe、バージョン: 0.0.0.0、タイム スタンプ: 0x687218e5
障害が発生したモジュール名: ntdll.dll、 バージョン: 10.0.26100.4652、タイム スタンプ: 0x6c6bd922
例外コード: 0xc0000374
フォールト オフセット: 0x000000000011dc15
フォールト プロセス ID: 0x3028
アプリケーションのフォールトの開始時刻: 0x1DBF304B1F2899B
Faulting アプリケーション パス: C:\source\repos\ConsoleApplication1\x64\Release\ConsoleApplication1.exe
Faulting モジュール パス: C:\WINDOWS\SYSTEM32\ntdll.dll
Report Id: 1e001417-6327-4d51-ad7c-54809e97c930

この例ではntdll.dllが障害の発生源として示されており、例外コード0xc0000374はヒープの破損を示しています。一見するとntdll.dllの不具合に見えますが、実際にはアプリケーション側のコードに問題があり、ntdll.dllはヒープの破損を検出したため例外を発生させる羽目になった被害者です。(例外を発生させなければクラッシュはしないでしょうが、アプリケーションが予測不能な振る舞いをするよりはクラッシュさせたほうがマシです。)

調査方法

1. クラッシュダンプの取得

まず、Windows Error Reporting (WER)を使用してクラッシュダンプファイルを取得しましょう。 以下は、WERの設定を行うためのレジストリファイルの例です。このファイルをwer.regとして保存し、管理者権限でインポートすることでWERの設定を変更できます。

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps]
"DumpFolder"=hex(2):43,00,3a,00,5c,00,4c,00,6f,00,63,00,61,00,6c,00,44,00,75,\
00,6d,00,70,00,73,00,00,00
"DumpType"=dword:00000002

もちろん手動で設定を変更しても構いません。

Windows Error Reportingの設定

レジストリを設定した後にアプリケーションを実行して問題を再現させると、以下のようにクラッシュダンプファイルが生成されます。

クラッシュダンプの例

2. クラッシュダンプの解析

クラッシュダンプを解析するためには、Visual StudioやWinDbgなどのデバッガーを使用します。どちらを使用するかは好みですが、Visual StudioはGUIベースで直感的に操作できるためツールに習熟していなくても比較的簡単に解析できます。一方、WinDbgはコマンドベースで詳細情報の取得や解析作業の自動化が可能なため、経験豊富な開発者には強力なツールです。今回はWinDbgを使用して解析を行います。

まずWinDbgを起動し、クラッシュダンプファイルを開きます。kコマンドを入力して、スタックトレースを表示すると、以下のような出力が得られます。

0:000> k
# Child-SP RetAddr Call Site
00 000000f9`cc51df48 00007ffa`53828ffa ntdll!NtWaitForMultipleObjects+0x14
01 000000f9`cc51df50 00007ffa`53828c63 ntdll!WerpWaitForCrashReporting+0x82
02 000000f9`cc51dfd0 00007ffa`538286c8 ntdll!RtlReportExceptionHelper+0x4d3
03 000000f9`cc51e150 00007ffa`538abd9f ntdll!RtlReportException+0x78
04 000000f9`cc51e180 00007ffa`538667e3 ntdll!RtlReportFatalFailure$filt$0+0x33
05 000000f9`cc51e1b0 00007ffa`538a650f ntdll!_C_specific_handler+0x93
06 000000f9`cc51e220 00007ffa`537b4527 ntdll!RtlpExecuteHandlerForException+0xf
07 000000f9`cc51e250 00007ffa`537b33b6 ntdll!RtlDispatchException+0x437
08 000000f9`cc51e9a0 00007ffa`5385dc15 ntdll!RtlRaiseException+0x206
09 000000f9`cc51f810 00007ffa`537777f9 ntdll!RtlReportFatalFailure+0x9
0a 000000f9`cc51f860 00007ffa`53758c02 ntdll!RtlReportCriticalFailure+0xa9
0b 000000f9`cc51f950 00007ffa`538624ba ntdll!RtlpHeapHandleError+0x12
0c 000000f9`cc51f980 00007ffa`537511eb ntdll!RtlpHpHeapHandleError+0x7a
0d 000000f9`cc51f9b0 00007ffa`53754718 ntdll!RtlpLogHeapFailure+0x4b
0e 000000f9`cc51f9e0 00007ffa`537d5930 ntdll!RtlpFreeHeap+0x2d8
0f 000000f9`cc51fbc0 00007ffa`510be0fb ntdll!RtlFreeHeap+0x620
10 000000f9`cc51fcb0 00007ff6`37b210cb ucrtbase!free_base+0x1b
11 000000f9`cc51fce0 00007ff6`37b21310 ConsoleApplication1!main+0x1b [C:\source\repos\ConsoleApplication1\ConsoleApplication1.cpp @ 16]
12 (Inline Function) --------`-------- ConsoleApplication1!invoke_main+0x22
13 000000f9`cc51fd10 00007ffa`51c5e8d7 ConsoleApplication1!__scrt_common_main_seh+0x10c
14 000000f9`cc51fd50 00007ffa`5377c34c kernel32!BaseThreadInitThunk+0x17
15 000000f9`cc51fd80 00000000`00000000 ntdll!RtlUserThreadStart+0x2c

#10のフレームからfree関数を呼び出したときに例外が発生したことが分かります。#11のフレームではConsoleApplication1.cppの16行目printf関数の行を指していますが、これはfree関数の後に実行される戻り先のコードとなっており実際に例外を発生させた処理の行ではないことに注意してください。

また、実際にヒープバッファーオーバーフローが発生したのは14行目ですがここでは例外は発生しておらず、free関数で例外が発生している点も注意が必要です。オーバーフローが発生してもそれを検知する処理はありませんので、すぐには例外が発生しないことが多く、後続のメモリ操作で例外が発生することが一般的です。これがヒープバッファーオーバーフローの厄介な点であり、問題の発生箇所を特定するのが難しい理由です。

次に、!heap -triageコマンドを実行してヒープの状態を確認すると、ヒープの問題が検出されたことが確認できます。

0:000> !heap -triage
**************************************************************
* *
* HEAP ERROR DETECTED *
* *
**************************************************************

Details:

Heap address: 000002a73a930000
Error address: 000002a73a9490c0
Last known valid blocks: before - 000002a73a9490a0, after - 000002a73a949650
Error type: HEAP_FAILURE_BUFFER_OVERRUN
Details: The heap manager detected an error whose features are
consistent with a buffer overrun.
Follow-up: Enable pageheap.

Stack trace:
Stack trace at 0x00007ffa5390e138
00007ffa537511eb: ntdll!RtlpLogHeapFailure+0x4b
00007ffa53754718: ntdll!RtlpFreeHeap+0x2d8
00007ffa537d5930: ntdll!RtlFreeHeap+0x620
00007ffa510be0fb: ucrtbase!free_base+0x1b
00007ff637b210cb: ConsoleApplication1!main+0x1b
00007ff637b21310: ConsoleApplication1!__scrt_common_main_seh+0x10c
00007ffa51c5e8d7: kernel32!BaseThreadInitThunk+0x17
00007ffa5377c34c: ntdll!RtlUserThreadStart+0x2c

**************************************************************
* *
* HEAP ERROR DETECTED *
* *
**************************************************************

Details:

Heap address: 000002a73a930000
Error address: 000002a73a9490c0
Last known valid blocks: before - 000002a73a9490a0, after - 000002a73a949650
Error type: HEAP_FAILURE_BUFFER_OVERRUN
Details: The heap manager detected an error whose features are
consistent with a buffer overrun.
Follow-up: Enable pageheap.

Stack trace:
Stack trace at 0x00007ffa5390e138
00007ffa537511eb: ntdll!RtlpLogHeapFailure+0x4b
00007ffa53754718: ntdll!RtlpFreeHeap+0x2d8
00007ffa537d5930: ntdll!RtlFreeHeap+0x620
00007ffa510be0fb: ucrtbase!free_base+0x1b
00007ff637b210cb: ConsoleApplication1!main+0x1b
00007ff637b21310: ConsoleApplication1!__scrt_common_main_seh+0x10c
00007ffa51c5e8d7: kernel32!BaseThreadInitThunk+0x17
00007ffa5377c34c: ntdll!RtlUserThreadStart+0x2c

**********************************************************
** !heap: Searching all heaps for errors...
**********************************************************

** !heap: Analyzing heap at 000002a73a930000...
** !heap: Analyzing heap at 000002a73a860000...


** !heap: The following heap entries have a block size that does not
match the previous block size field of the next block. This
is sometimes the result of user corruption, but occasionally
it can be detected if an unrelated exception occurs while
executing heap code.
** !heap: To view the state of the invalid blocks:
!heap -i <heap address>
!heap -i <entry address>

Next block's
Heap address Entry address Entry size (B) prev. size (B)
----------------------------------------------------------------------------
2a73a930000 2a73a9490a0 20 527a0


** !heap: If these failures are easily reproducible, they can
be detected as they occur by enabling pageheap for
this scenario.

Error typeはHEAP_FAILURE_BUFFER_OVERRUNとなっており、ヒープバッファーオーバーフローが検出されたことが分かります。また、Follow-upにはEnable pageheapとあり、ページヒープを有効にすることでさらに詳細な情報を取得できる可能性があることが示唆されています。これは次のセクションで説明します。

Error addressに記載されているアドレスの内容を確認すると、Windowsがヒープの問題を検出したアドレスの内容が確認できます。ここでdbコマンドを使用してアドレスの内容を表示すると、以下のようにオーバーフローしてバッファーからあふれた文字が確認できます。

0:000> db 000002a73a9490c0
000002a7`3a9490c0 74 72 69 6e 67 20 74 65-78 74 20 74 68 61 74 20 tring text that
000002a7`3a9490d0 65 78 63 65 65 64 73 20-74 68 65 20 62 75 66 66 exceeds the buff
000002a7`3a9490e0 65 72 20 73 69 7a 65 2e-00 91 94 3a a7 02 00 00 er size....:....

実際にはこの部分はUnicodeやShift-JISなどのエンコーディングで文字列が格納されていて文字列が格納されていることに気づきにくかったり、そもそも文字列以外の種類のデータでオーバーフローしている場合もあるため、ここまで分かりやすい結果は得られない場合も多いです。しかし、ヒープバッファーオーバーフローが発生している場合は何らかの形でオーバーフローしたデータがヒープに書き込まれていることが多いため、アドレスの内容を確認してみる価値はあります。特に、後述のページヒープはアプリケーションのパフォーマンスへの影響が大きいため適用が難しい場合も多く、ページヒープを有効にする前に、このようにアドレスの内容を確認してみることも選択肢として持っておくとよいでしょう。

3. ページヒープの有効化とクラッシュダンプの再取得

ページヒープを有効にすることで、ヒープの状態をより詳細に監視し、問題の発生箇所を特定しやすくなります。ページヒープは、各ヒープブロックのサイズをページ単位で管理することで、オーバーフローやアンダーフローの検出を容易にします。

ページヒープには軽量ページヒープと完全ページヒープの2種類があります。軽量ページヒープはオーバーフローやアンダーフローを検出するための基本的な機能を提供し、完全ページヒープはさらに詳細な情報を提供します。軽量ページヒープの基本的な考え方は、解放や再割り当ての際にヒープブロックの前後にパディングを追加することで各ヒープ関数で問題を検出できるようにすることです。つまり、原理的に軽量ページヒープはオーバーフローが生じたタイミングでは問題を検出できません。それに対して完全ページヒープは読み書きできないページを使用してヒープブロックの前後にパディングを追加し、オーバーフローやアンダーフローが発生した場合に例外を発生させることで問題を検出します。完全ページヒープはオーバーフローが生じたタイミングで例外が発生するため、問題の発生箇所を特定しやすくなりますが、パフォーマンスへの影響が大きいため注意が必要です。

ぺージヒープはレジストリの設定でアプリケーション毎に有効化することができます。Image File Execution Optionsキー(IFEO)の配下にアプリケーション名のサブキーを作成し、その中にGlobalFlagPageHeapFlagsの値を設定します。

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ConsoleApplication1.exe]
"GlobalFlag"=dword:02000000
"PageHeapFlags"=dword:00000003

もちろんレジストリエディタで直接編集しても構いません。

完全ページヒープの設定

ページヒープの設定はアプリケーションの起動時に適用されるため、設定を変更した後はアプリケーションを再起動する必要があります。再起動後にアプリケーションを実行し、問題を再現させると、ページヒープが有効になっているため、オーバーフローが発生したタイミングで例外が発生します。ページヒープを有効にする前と同様にWindows Error Reportingの機能でクラッシュダンプファイルが生成されますが、イベントログは例外コードがヒープ破損を示す0xc0000374からアクセス違反を示す0xc0000005に変わっている点に注意してください。完全ページヒープは割り当てたヒープブロックの前後に読み書きできないページを追加するため、オーバーフローが発生したタイミングでアクセス違反が発生してこのように検出されます。

障害が発生しているアプリケーション名: ConsoleApplication1.exe、バージョン: 0.0.0.0、タイム スタンプ: 0x687218e5
障害が発生したモジュール名: ConsoleApplication1.exe、 バージョン: 0.0.0.0、タイム スタンプ: 0x687218e5
例外コード: 0xc0000005
フォールト オフセット: 0x0000000000001081
フォールト プロセス ID: 0x1FBC
アプリケーションのフォールトの開始時刻: 0x1DBF314553C0DB2
Faulting アプリケーション パス: C:\source\repos\ConsoleApplication1\x64\Release\ConsoleApplication1.exe
Faulting モジュール パス: C:\source\repos\ConsoleApplication1\x64\Release\ConsoleApplication1.exe
Report Id: cd19b749-b3fb-4673-84a8-17e70f53a4c3

情報の取得が終わりページヒープの設定が不要になったら、設定したレジストリは削除しておきましょう。

4. 完全ページヒープ有効化後のクラッシュダンプの解析

完全ページヒープを有効化した状態でクラッシュダンプを再取得したら、WinDbgを使用して改めて解析を行います。kコマンドを入力してスタックトレースを表示すると、以下のような出力が得られます。

0:000> k
# Child-SP RetAddr Call Site
00 00000072`c92fdce8 00007ffa`50c6df43 ntdll!NtWaitForMultipleObjects+0x14
01 00000072`c92fdcf0 00007ffa`50c6de11 KERNELBASE!WaitForMultipleObjectsEx+0x123
02 00000072`c92fdfe0 00007ffa`51c812b0 KERNELBASE!WaitForMultipleObjects+0x11
03 00000072`c92fe020 00007ffa`51ca898d kernel32!WerpReportFaultInternal+0x62c
04 00000072`c92fe1a0 00007ffa`50d3a28c kernel32!WerpReportFault+0xc5
05 00000072`c92fe1f0 00007ffa`538a955f KERNELBASE!UnhandledExceptionFilter+0x34c
06 00000072`c92fe2e0 00007ffa`538667e3 ntdll!RtlUserThreadStart$filt$0+0x3f
07 00000072`c92fe310 00007ffa`538a650f ntdll!_C_specific_handler+0x93
08 00000072`c92fe380 00007ffa`537b4527 ntdll!RtlpExecuteHandlerForException+0xf
09 00000072`c92fe3b0 00007ffa`538a5e4e ntdll!RtlDispatchException+0x437
0a 00000072`c92feb00 00007ff6`37b21081 ntdll!KiUserExceptionDispatch+0x2e
0b 00000072`c92ff868 00007ff6`37b210c6 ConsoleApplication1!DoBadThing+0x11 [C:\source\repos\ConsoleApplication1\ConsoleApplication1.cpp @ 8]
0c 00000072`c92ff870 00007ff6`37b21310 ConsoleApplication1!main+0x16 [C:\source\repos\ConsoleApplication1\ConsoleApplication1.cpp @ 15]
0d (Inline Function) --------`-------- ConsoleApplication1!invoke_main+0x22
0e 00000072`c92ff8a0 00007ffa`51c5e8d7 ConsoleApplication1!__scrt_common_main_seh+0x10c
0f 00000072`c92ff8e0 00007ffa`5377c34c kernel32!BaseThreadInitThunk+0x17
10 00000072`c92ff910 00000000`00000000 ntdll!RtlUserThreadStart+0x2c

#0bおよび#0cのフレームからアクセス違反が発生した行が特定でき、DoBadThing関数で呼び出しているstrcpy関数の行で問題が生じたことが確認できます。例外コンテキストを表示するために.ecxrコマンドを実行すると、以下のような出力が得られます。

0:000> .ecxr
rax=0000025d0f887ff0 rbx=0000025d0f883fa0 rcx=0000025d0f887ff0
rdx=d0d0d0d0d0d0d0d0 rsi=0000000000000000 rdi=0000025d0f818e90
rip=00007ff637b21081 rsp=00000072c92ff868 rbp=0000000000000000
r8=0000000000000000 r9=0000025d0f887ff8 r10=0000025d0f887ff0
r11=00000072c92ff7e0 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010206
ConsoleApplication1!DoBadThing+0x11:
00007ff6`37b21081 0f114910 movups xmmword ptr [rcx+10h],xmm1 ds:0000025d`0f888000=????????????????????????????????

ここで、rcxレジスタにはオーバーフローが発生したヒープブロックのアドレスが格納されており、ripレジスタにはDoBadThing関数のアドレスが示されています。movups命令は、XMMレジスタからメモリにデータを移動する命令であり、ここでオーバーフローが発生していることが確認できます。ds:0000025d0f888000=????????????????????????????????の部分は実行した命令のアドレスに格納されているデータが不明であることを示しています。このアドレスを少し遡った部分を遡って内容を確認すると、文字列が途中までコピーされている状態も確認できます。

0:000> db 0x0000025d0f888000-0x10
0000025d`0f887ff0 54 68 69 73 20 69 73 20-61 20 6c 6f 6e 67 20 73 This is a long s
0000025d`0f888000 ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????

デバッガーの状態

ここで、割り当てたヒープブロックのサイズが8バイトであるにも関わらず、実際にオーバーフローが検知されたアドレスは16バイト目のアドレスである点に注意してください。完全ページヒープではオーバーフローの検出のために読み書きできないページを使用しますが、仮想メモリのページサイズは通常4096バイトであり、ヒープブロックのサイズが8バイトであっても、ページ単位で管理されるため、オーバーフローが発生したアドレスはページの境界に揃えられます。また、x86-x64アーキテクチャではポインターが指すが16バイトでアライメントされている必要があるため、16バイト未満のサイズのヒープブロックは16バイトに揃えられます。したがって、16バイト未満のサイズのオーバーフローは検出されないことに注意が必要です。

また、!heap -triageコマンドを実行してヒープの状態を確認しても、ヒープが破損する前にアクセス違反に至っているためエラーは検知できませんので注意してください。

ページヒープを使用する場合の注意点

ページヒープを使用することで、ヒープバッファーオーバーフローの検出が容易になりますが、いくつかの注意点があります。

  1. パフォーマンスへの影響: 完全ページヒープを使用すると、ヒープ操作のパフォーマンスが大幅に低下する可能性があります。特に、頻繁にヒープ操作を行うアプリケーションでは、パフォーマンスへの影響が顕著になるため、使用する際は注意が必要です。

  2. メモリ使用量の増加: ページヒープを有効にすると、各ヒープブロックの前後に読み書きできないページが追加されるため、メモリ使用量が増加します。特に、大量のヒープブロックを使用するアプリケーションでは、メモリ使用量の増加が問題となることがあります。

  3. 互換性の問題: 一部のアプリケーションやライブラリは、ページヒープを使用することで互換性の問題が発生する可能性があります。特に、低レベルのメモリ操作を行うアプリケーションでは、ページヒープとの相性が悪い場合があります。

  4. 別の問題の顕在化: ページヒープを有効にすると、目的の箇所以外でメモリ関連の問題が検出される可能性があります。これは未知の問題の発見で有益なものと捉えることもできますが、逆にデバッグ作業を複雑にする要因ともなります。また、目的の箇所で問題を検出するために、他の問題を先に取り除く必要が生じる可能性があります。

  5. 検出できない場合もある: ページヒープはオーバーフローやアンダーフローを検出するための強力なツールですが、すべてのケースで検出できるわけではありません。特に、ヒープブロックのサイズが小さい場合や、オーバーフローが発生していない場合は、ページヒープでも問題を検出できないことがあります。例えば、特定のデータや手順でのみ問題が発生する処理フローに至るケースでは、ページヒープを有効化したとしてもデータや手順といった再現条件を満たさない限り問題は検出されません。

なお、クラッシュダンプからページヒープを有効化できているか確認したい場合は、プロセス環境ブロックから状態を確認することができます。NtGlobalFlagの値が0x2000000であればページヒープが有効になっています。もしページヒープが有効になっていない場合は、レジストリの設定に誤りがないか確認してください。

0:000> !peb
PEB at 00000072c910d000
InheritedAddressSpace: No
ReadImageFileExecOptions: No
BeingDebugged: No
ImageBaseAddress: 00007ff637b20000
NtGlobalFlag: 2000000
NtGlobalFlag2: 0
...

参考情報