Windows の例外コード(0xC0000005 など)を発生させてみる
Windows のいろいろな例外コード、たとえば 0xC0000005(アクセス違反)や 0xC00000FD(スタックオーバーフロー)を、簡単なプログラムで意図的に発生させてみます。具体的には、次の例外です。
- 0xC0000005(アクセス違反・リード)
- 0xC0000005(アクセス違反・ライト)
- 0xC0000005(アクセス違反・実行)
- 0xC0000094(ゼロ除算)
- 0xC000001D(不正な命令)
- 0xC0000096(特権命令)
- 0xC0000374(ヒープ破壊)
- 0xC0000017(メモリ不足)
- 0xC00000FD(スタックオーバーフロー)
- 0xC0000409(スタックバッファオーバーラン)
- 0xC000041D(ユーザーコールバック例外)
- 0xC0000602(FAIL FAST)
- 0x80000003(ブレークポイント)
動作確認環境
- Windows 11 Home 23H2
- Visual Studio Community 2022 (Visual C++)
- WinDbg 10.0
はじめに
実験の手順は次の通り。
- 未捕捉の例外が発生したらダンプファイルが生成されるよう、Windows の環境設定をしておく。
- C 言語のプログラムを作って例外を発生させる。
- 生成されたダンプファイルをデバッガで参照して、例外の内容を確認する。
環境設定の詳細は本稿の趣旨ではないため省略しますが、簡単に書くと、(1) レジストリの「AeDebug」から Debugger を削除、(2) レジストリの「Windows Error Reporting\LocalDumps」でダンプファイルの出力先などを設定、となります。
0xC0000005(アクセス違反・リード)
0xC0000005 は、アクセス違反例外(STATUS_ACCESS_VIOLATION)です。不正なメモリにアクセスしようとしたときに発生します。不正なメモリに対するリード/ライト/実行のどれを試みようとしたのかによって、例外のパラメーター値が変わります。
まずは、不正なメモリに対するリードの例です。
次のプログラムはアドレス 1 のメモリの内容を読み出そうとしています。しかし、アドレス 1 は OS によってアクセスが禁止されている領域です。
int main()
{
char c = *(char *)1; // アドレス 1 の読み出し(不可)
return c;
}
Visual C++ のコマンドプロンプトを起動し、
「c:\tmp」に置いた「test.c」をビルドします。「/Od」は最適化抑止(デフォルト)、「/Zi」はデバッグ情報を生成、の意味です。
C:\tmp> cl /Od /Zi test.c
Microsoft(R) C/C++ Optimizing Compiler Version 19.39.33523 for x64
Copyright (C) Microsoft Corporation. All rights reserved.
test.c
Microsoft (R) Incremental Linker Version 14.39.33523.0
Copyright (C) Microsoft Corporation. All rights reserved.
/out:test.exe
/debug
test.obj
実行します。
C:\tmp> test.exe
画面には何も表示されませんが、レジストリ LocalDumps で指定した場所にダンプファイル (.dmp) が生成され、プログラムが終了しました。
WinDbg の [File]-[Open Crash Dump…] でダンプファイル開き、
.exr コマンド (Display Exception Record) で例外レコードを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff666ee7164 (test!main+0x0000000000000004) ★ 例外を起こした命令のアドレス
ExceptionCode: c0000005 (Access violation) ★ 例外コード
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000000 ★ 例外コードが C0000005 のとき、「0:リード」「1:ライト」「8:実行」の意味
Parameter[1]: 0000000000000001 ★ 例外コードが C0000005 のとき、「アクセス対象アドレス」の意味
Attempt to read from address 0000000000000001
想定通り、「c0000005 (Access violation)」の例外が発生しています。
「Parameter[0]: 0000000000000000」の「0」は不正なメモリを「リード」しようとしたことを、「Parameter[1]: 0000000000000001」はその対象アドレスが「1」だったことを示しています。
「ExceptionAddress: 00007ff666ee7164」は、例外を起こした命令のアドレスです。
その命令は以下になります。アドレス 1 のメモリの内容を AL レジスタに格納しようとしていますが、アドレス 1 へのアクセスは禁止されているため実行できず、例外となった次第です。
0:000> u 00007ff666ee7164 L1
test!main+0x4 [C:\tmp\test.c @ 3]:
00007ff6`66ee7164 8a042501000000 mov al,byte ptr [1]
この調子でどんどん見ていきます。
0xC0000005(アクセス違反・ライト)
不正なメモリに対するライトの例です。
次のプログラムはアドレス 2 のメモリに 3 を書き込もうとしていますが、アドレス 2 は OS によってアクセスが禁止されている領域です。
int main()
{
*(char *)2 = 3; // アドレス 2 への書き込み(不可)
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff71c197160 (test!main) ★ 例外を起こした命令のアドレス
ExceptionCode: c0000005 (Access violation) ★ 例外コード
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000001 ★ 例外コードが C0000005 のときは「0:リード」「1:ライト」「8:実行」の意味
Parameter[1]: 0000000000000002 ★ 例外コードが C0000005 のときは「アクセス対象アドレス」の意味
Attempt to write to address 0000000000000002
想定通り、「c0000005 (Access violation)」の例外が発生しています。
「Parameter[0]: 0000000000000001」の「1」は不正なメモリに「ライト」しようとしたことを、「Parameter[1]: 0000000000000002」はその対象アドレスが「2」だったことを示しています。
ExceptionAddress を見ると、アドレス 2 に 3 を書き込もうとしていることがわかります。
00007ff7`1c197160 c604250200000003 mov byte ptr [2],3
0xC0000005(アクセス違反・実行)
不正なアドレスにある命令の実行例です。
次のプログラムはアドレス 4 にある命令を実行しようとしていますが、アドレス 4 は OS によってアクセスが禁止されている領域です。
int main()
{
((void (*)())4)(); // アドレス 4 にある命令の実行(不可)
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 0000000000000004 ★ 例外を起こした命令のアドレス
ExceptionCode: c0000005 (Access violation) ★ 例外コード
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000008 ★ 例外コードが C0000005 のときは「0:リード」「1:ライト」「8:実行」の意味
Parameter[1]: 0000000000000004 ★ 例外コードが C0000005 のときは「アクセス対象アドレス」の意味
Attempt to execute non-executable address 0000000000000004
想定通り、「c0000005 (Access violation)」の例外が発生しています。
「Parameter[0]: 0000000000000008」の「8」は不正なアドレスにある命令を「実行」しようとしたことを、「Parameter[1]: 0000000000000004」はそのアドレスが「4」だったことを示しています。
ExceptionAddress には、そもそもメモリが割り当てられていませんでした。
0:000> u 4 L1
00000000`00000004 ?? ???
^ Memory access error in 'u 4 l1'
メモリが割り当てられていたとしても、そのアドレスが実行可能(PAGE_EXECUTE*)としてマークされていなければ、やはりアクセス違反の例外が発生します。
0xC0000094(ゼロ除算)
0xC0000094 は、ゼロ除算例外(STATUS_INTEGER_DIVIDE_BY_ZERO)です。
整数を 0 で除算しようとしたときに発生します。
ゼロ除算するプログラムの例です。
int main()
{
int i = 0;
i = 5 / i; // ゼロ除算
return i;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff761db7171 (test!main+0x0000000000000011)
ExceptionCode: c0000094 (Integer divide-by-zero)
ExceptionFlags: 00000000
NumberParameters: 0
想定通り、「c0000094 (Integer divide-by-zero)」の例外が発生しています。
ExceptionAddress には割り算の命令 (idiv) があります。「eax レジスタの値 5」÷「rsp レジスタが指すメモリの内容 0」が計算できず、ゼロ除算例外になった次第です。
0:000> uf test!main
test!main [C:\tmp\test.c @ 2]:
2 00007ff7`61db7160 4883ec18 sub rsp,18h
3 00007ff7`61db7164 c7042400000000 mov dword ptr [rsp],0
4 00007ff7`61db716b b805000000 mov eax,5
4 00007ff7`61db7170 99 cdq
4 00007ff7`61db7171 f73c24 idiv eax,dword ptr [rsp] ★ ゼロ除算
......
0xC000001D(不正な命令)
0xC000001D は、不正な命令例外(STATUS_ILLEGAL_INSTRUCTION)です。
x86/x64 としてありえない命令(マシン語)を実行しようとしたときに発生するものです。
次のプログラムは、x86/x64 の未定義命令 UD2 (Undefined Instruction) を呼び出しています。
#include <intrin.h>
int main()
{
__ud2(); // 未定義命令
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff7dbb97160 (test!main)
ExceptionCode: c000001d (Illegal instruction)
ExceptionFlags: 00000000
NumberParameters: 0
想定通り、「c000001d (Illegal instruction)」の例外が発生しています。
ExceptionAddress には、未定義命令「ud2」がありました。
00007ff7`dbb97160 0f0b ud2 ★ 未定義命令
未定義命令が定義されているというのも変な話ですが。
0xC0000096(特権命令)
0xC0000096 は、特権命令例外(STATUS_PRIVILEGED_INSTRUCTION)です。
権限のない命令を実行しようとしたときに発生するものです。
次のプログラムは、CR3 レジスタに 7 をセットしていますが、CR3 レジスタの操作はユーザーモードでは許可されていません。
#include <intrin.h>
int main()
{
__writecr3(7); // CR3 レジスタの操作
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff64ca67165 (test!main+0x0000000000000005)
ExceptionCode: c0000096
ExceptionFlags: 00000000
NumberParameters: 0
想定通り、「c0000096」の例外が発生しています。
ExceptionAddress を逆アセンブルすると「mov tmm,rax」と表示されました。
0:000> uf test!main
test!main [C:\tmp\test.c @ 4]:
4 00007ff6`4ca67160 b807000000 mov eax,7
5 00007ff6`4ca67165 0f22d8 mov tmm,rax ★ TMM レジスタ?
6 00007ff6`4ca67168 33c0 xor eax,eax
7 00007ff6`4ca6716a c3 ret
「tmm」というのが何なのか調べてもわからず、試しに dumpbin コマンドで逆アセンブルしてみたところ、期待通り「mov cr3,rax」と表示されました。
C:\tmp> dumpbin /disasm test.exe
......
main:
0000000140007160: B8 07 00 00 00 mov eax,7
0000000140007165: 0F 22 D8 mov cr3,rax ★ CR3 レジスタ
0000000140007168: 33 C0 xor eax,eax
000000014000716A: C3 ret
......
WinDbg が「cr3」を「tmm」と表示した理由は不明です。バグでしょうか。
0xC0000374(ヒープ破壊)
0xC0000374 は、ヒープ破壊例外(STATUS_HEAP_CORRUPTION)です。
ヒープメモリを確保・解放しようとしたところ、ヒープメモリの管理情報がおかしいことを検出した、という意味です。ヒープメモリを壊した瞬間を検知するわけではありません。
次のプログラムは、ヒープメモリを二重解放しています。1 回目の free(p); では確保状態のメモリ p を解放状態に変更しており、これは正常です。しかし、2 回目の free(p); では確保状態にないメモリ p を解放しようとしており、これは異常です。
#include <windows.h>
int main()
{
char *p = (char *)malloc(8);
free(p);
free(p); // 二重解放
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ffb3735c8f9 (ntdll!RtlReportFatalFailure+0x0000000000000009)
ExceptionCode: c0000374
ExceptionFlags: 00000081
NumberParameters: 1
Parameter[0]: 00007ffb373d38b0
想定通り、「c0000374」の例外が発生しています。
ExceptionAddress は、例外を発生させた「call ntdll!RtlRaiseException」の次のアドレスです。
00007ffb`3735c8f4 e8b77ef4ff call ntdll!RtlRaiseException (00007ffb`372a47b0)
00007ffb`3735c8f9 eb00 jmp ntdll!RtlReportFatalFailure+0xb (00007ffb`3735c8fb)
アクセス違反やゼロ除算のハードウェア例外とは異なり、本例外は特定のマシン語の実行に失敗したわけではありません。free 関数がその内部でデータ構造の異常を検出し、例外コード 0xC0000374 を含む構造体を引数に RtlRaiseException 関数を呼び出し、自発的に発生させたソフトウェア例外になります。このとき、ExceptionAddress には RtlRaiseException 関数呼び出しの次のアドレス(戻りアドレス)がセットされることになっています。
0xC0000017(メモリ不足)
0xC0000017 は、メモリ不足例外(STATUS_NO_MEMORY)です。
細かく言うと、仮想メモリまたはページングファイルのクォータ不足例外です。
以下は、1MB のメモリ確保を無限に繰り返すプログラムです。HeapAlloc 関数に HEAP_GENERATE_EXCEPTIONS を指定しているのがポイントです。これがあると、メモリ不足時に例外が発生します。これがないと、単に NULL がリターンします。
#include <windows.h>
int main()
{
while (TRUE)
{
HeapAlloc(GetProcessHeap(),
HEAP_GENERATE_EXCEPTIONS,
1024 * 1024);
}
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました(ダンプファイルは 18GB にもなってしまいました)。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ffb37352cb7 (ntdll!RtlpAllocateHeapRaiseException+0x000000000000004f)
ExceptionCode: c0000017
ExceptionFlags: 00000080
NumberParameters: 1
Parameter[0]: 0000000000100010
想定通り、「c0000017」の例外が発生しています。
ExceptionAddress は、例外を発生させた「call ntdll!RtlRaiseException」の次のアドレスです。
00007ffb`37352cb2 e8f91af5ff call ntdll!RtlRaiseException (00007ffb`372a47b0)
00007ffb`37352cb7 488b8c24c0000000 mov rcx,qword ptr [rsp+0C0h]
HeapAlloc 関数がその内部でメモリ不足を検出し、例外コード 0xC0000017 を含む構造体を引数に RtlRaiseException 関数を呼び出し、自発的に発生させた例外になります。
0xC00000FD(スタックオーバーフロー)
0xC00000FD は、スタックオーバーフロー例外(STATUS_STACK_OVERFLOW)です。
スタック用のメモリが足りなくなったときに発生します。
次のプログラムは main 関数が無限に main 関数を呼び出しているため、スタック領域がコールスタックで埋め尽くされるはずです。
int main()
{
return main(); // 再帰
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff7ebea7164 (test!main+0x0000000000000004)
ExceptionCode: c00000fd (Stack overflow)
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000001
Parameter[1]: 0000003550e03fd8
想定通り、「c00000fd (Stack overflow)」の例外が発生しています。
ExceptionAddress には、main 関数への call 命令がありました。スタック不足でリターンアドレスが積めずに例外になったと思われます。
00007ff7`ebea7164 e829bcffff call test!ILT+7565(main) (00007ff7`ebea2d92)
0xC0000409(スタックバッファオーバーラン)
0xC0000409 は、スタックのバッファオーバーラン例外(STATUS_STACK_BUFFER_OVERRUN)です。
スタックの内容をチェックしたところ異常を検出した、という意味です。スタックを壊した瞬間を検知するわけではありません。
次のプログラムの sub 関数は、16 バイトのローカル変数に 32 バイトのゼロを書き込んでスタックを壊しています。その後、main 関数にリターンする際に内部的にスタックのセキュリティチェックが行われますが、ここでスタック破壊が検出されます。
#include <windows.h>
int sub()
{
char p[16]; // 16 バイトのローカル変数
ZeroMemory(p, 32); // 32 バイトの書き込み
return 0;
}
int main()
{
sub();
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff7e1b876e1 (test!__report_gsfailure+0x000000000000001d)
ExceptionCode: c0000409 (Security check failure or stack buffer overrun)
ExceptionFlags: 00000001
NumberParameters: 1
Parameter[0]: 0000000000000002
Subcode: 0x2 FAST_FAIL_STACK_COOKIE_CHECK_FAILURE
想定通り、「c0000409 (Security check failure or stack buffer overrun)」の例外が発生しています。
ExceptionAddress には、セキュリティチェックルーチンが例外を発生させるために使った int 29h がありました。
00007ff7`e1b876e1 cd29 int 29h
0xC000041D(ユーザーコールバック例外)
0xC000041D は、ユーザーコールバック中の例外(STATUS_FATAL_USER_CALLBACK_EXCEPTION)です。
次のプログラムでは、OS から呼び出されるローレベルマウスフック関数の中で NULL ポインタに値を書き込みメモリアクセス違反を発生させています。
#include <windows.h>
HHOOK g_hhook;
LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wparam, LPARAM lparam)
{
UnhookWindowsHookEx(g_hhook);
*(char *)NULL = 10; // コールバック関数内のアクセス違反
return CallNextHookEx(NULL, nCode, wparam, lparam);
}
int main()
{
g_hhook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, NULL, 0);
MessageBox(NULL, "move mouse", "test", MB_OK);
return 0;
}
ビルドして実行し、コールバック関数呼び出しのためマウスを少し動かすと、ダンプファイルが 2 つ生成されてプログラムが終了しました。
最初のダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff6888a717f (test!LowLevelMouseProc+0x000000000000001f)
ExceptionCode: c0000005 (Access violation)
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000001
Parameter[1]: 0000000000000000
Attempt to write to address 0000000000000000
NULL ポインタへの書き込みに起因する「c0000005」のアクセス違反例外が発生しています。
2 つ目のダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff6888a717f (test!LowLevelMouseProc+0x000000000000001f)
ExceptionCode: c000041d
ExceptionFlags: 00000001
NumberParameters: 0
ユーザーコールバック中の例外を意味する「c000041d」が発生しています。
ひとつのプログラムが 2 つのダンプファイルを生成する仕組みはよくわかりませんでしたが、最初のダンプファイルは命令レベルで、2 つ目のダンプファイルは関数レベルで例外発生の原因を示しています。
ExceptionAddress はいずれも同じで、アクセス違反例外を起こした命令を示していました。
00007ff6`888a717f c60425000000000a mov byte ptr [0],0Ah
0xC0000602(FAIL FAST)
0xC0000602 は、FAIL FAST 例外(STATUS_FAIL_FAST_EXCEPTION)です。
開発者が意図的に即座に発生させた例外です。
次のプログラムは、FAIL FAST 例外を発生させる RaiseFailFastException 関数を呼び出しています。例外捕捉のため __try ~ __except で囲んでありますが、それは無視され、即座に例外が発生します。
#include <windows.h>
#include <stdio.h>
int main()
{
__try
{
RaiseFailFastException(NULL, NULL, 0);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
printf("except\n");
}
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ff7eb247181 (test!main+0x0000000000000011)
ExceptionCode: c0000602
ExceptionFlags: 00000001
NumberParameters: 0
想定通り、「c0000602」の例外が発生しています。
ExceptionAddress には、例外を発生させた「call qword ptr [test!_imp_RaiseFailFastException]」≒「call KERNELBASE!RaiseFailFastException」の次のアドレスがセットされています。
00007ff7`eb24717b ff157f7e0900 call qword ptr [test!_imp_RaiseFailFastException (00007ff7`eb2df000)]
00007ff7`eb247181 eb0d jmp test!main+0x20 (00007ff7`eb247190)
0x80000003(ブレークポイント)
0x80000003 は、ブレークポイント例外(STATUS_BREAKPOINT)です。
指定のブレークポイントに到達した、というだけであり、本来起こるべきではない異常が発生した、というわけではありません。例外コードも、「エラー」を意味する 0xCXXXXXXX ではなく、「情報」を意味する 0x8XXXXXXX になっています。
以下のプログラムでは、DebugBreak 関数でブレークポイントを仕掛けています。
#include <windows.h>
int main()
{
DebugBreak();
return 0;
}
ビルドして実行すると、ダンプファイルが生成されました。
ダンプファイルを見てみましょう。
0:000> .exr -1
ExceptionAddress: 00007ffead9c7a72 (KERNELBASE!wil::details::DebugBreak+0x0000000000000002)
ExceptionCode: 80000003 (Break instruction exception)
ExceptionFlags: 00000000
NumberParameters: 1
Parameter[0]: 0000000000000000
想定通り、「80000003 (Break instruction exception)」の例外が発生しています。
ExceptionAddress には、x86/x64 のブレークポイント命令である int 3 がありました。
00007ffe`ad9c7a72 cc int 3
マシン語「cc」は覚えておきたい命令です。
おわりに
以上、0xC0000005(アクセス違反)や 0xC0000409(スタックバッファーオーバーラン)などの例外を、シンプルなテストプログラムで発生させてみました。