C/C++のコードとアセンブリ命令の対応
逆アセンブルから元のC/C++ソースを一意に復元することはできません。ただし、アドレス計算、アクセス幅、分岐、呼び出し規約を組み合わせると、配列アクセスやメンバー参照などの候補を絞れます。
本記事では、C/C++ソースに対応する命令とデータがWinDbgでどのように見えるかを示します。まずx86の出力を読み、その後にWindows x64・ARM64との違いを比較します。
出力例について
プロンプトから始まる例は、MSVC 19.51.36256でビルドしたx86・x64・ARM64の実行ファイルを、WinDbgと同じデバッグエンジンを使うCDB 10.0.28000.2526で表示した実測出力の抜粋です。最適化は/O2、インライン展開は/Ob0で無効化し、PDBを生成しています。機械語の左側に表示されるアドレスやレジスターの割り当ては、ビルドや実行環境によって変わります。
取得環境はWindows on ARMで、x86はx86版CDB、x64・ARM64はARM64版CDBを使用しました。x64版サンプルはエミュレーション下で起動し、逆アセンブル時に.effmach amd64を指定しています。そのためx64のプロンプトは0:000:AMD64>です。これはARM64ECとしてコンパイルしたサンプルではありません。手順は自分の環境で確認するにまとめています。
起動時のモジュール一覧など、説明に関係しない出力は省略しています。以下ではWindowsの通常の既定パッキングを前提とし、intを4バイト、ポインターをx86では4バイト、x64・ARM64では8バイトとして説明します。
配列コピー、文字列ループ、具象クラスの仮想関数テーブルは、サンプルを拡張して追加取得しています。これらは通常のC++ランタイムをリンクしたmain付きビルドです。以前の最小ビルドと配置が異なるため、同じモジュール名でも各取得例のアドレスは共通ではありません。掲載している関数内オフセットも、別のビルドで同じとは限りません。
| コマンド | 確認するもの |
|---|---|
uf sample!関数名 | 関数全体の逆アセンブル |
u アドレス | 指定位置からの逆アセンブル(u sample!read_value L3なら3命令) |
dt sample!型名 | PDBの型情報とメンバーのオフセット |
dt sample!変数名 | 型情報を使った変数の値 |
dd アドレス L4 | 4バイト単位の値を4個表示 |
db アドレス L4 | 1バイト単位の値を4個表示 |
du アドレス | UTF-16文字列を表示 |
dps アドレス L4 | ポインター幅で4個読み取り、対応するシンボルも表示 |
ln アドレス | そのアドレスに近いシンボルを表示 |
sampleは調査用に作成した実行ファイルのモジュール名です。実際の調査では対象のモジュール名へ置き換えます。ソースだけでなく、実行ファイルに一致するPDBが必要です。
単純な代入
int read_value(const int *p)
{
return *p;
}
void write_value(int *p, int value)
{
*p = value;
}
ポインターの指す値を読む
0:000> uf sample!read_value
sample!read_value:
000f10e0 8b442404 mov eax,dword ptr [esp+4]
000f10e4 8b00 mov eax,dword ptr [eax]
000f10e6 c3 ret
先頭の列は命令のアドレス、次が機械語のバイト列、その右が逆アセンブルです。
関数入口の[esp]には戻りアドレスがあり、[esp+4]が第1引数pです。最初のmovでポインターをeaxへ読み込み、次のmovでそのポインターの指す4バイトを読み取っています。結果は整数の戻り値としてeaxに残ります。
ポインターの指す先へ書く
0:000> uf sample!write_value
sample!write_value:
000f10f0 8b442404 mov eax,dword ptr [esp+4]
000f10f4 8b4c2408 mov ecx,dword ptr [esp+8]
000f10f8 8908 mov dword ptr [eax],ecx
000f10fa c3 ret
eaxが第1引数p、ecxが第2引数valueです。mov dword ptr [eax],ecxはeaxが指す先への4バイトの書き込みです。ロードとストアの向きを確認するにはデータ操作命令も参照してください。
x64・ARM64との比較
Windows x64の引数は主にレジスターで渡されます。x64では第1引数がrcx、ARM64ではx0です。
x64
0:000:AMD64> uf sample!read_value
sample!read_value:
00007ff6`9aa31110 8b01 mov eax,dword ptr [rcx]
00007ff6`9aa31112 c3 ret
0:000:AMD64> uf sample!write_value
sample!write_value:
00007ff6`9aa31120 8911 mov dword ptr [rcx],edx
00007ff6`9aa31122 c3 ret
ARM64
0:000> uf sample!read_value
sample!read_value:
00007ff6`696b1100 b9400000 ldr w0,[x0]
00007ff6`696b1104 d65f03c0 ret
0:000> uf sample!write_value
sample!write_value:
00007ff6`696b1110 b9000001 str w1,[x0]
00007ff6`696b1114 d65f03c0 ret
読み取りはいずれも、ポインターの指す先から4バイトを読み取ります。戻り値は32ビット整数なので、64ビット環境でも転送幅は4バイトです。書き込みは、x64ではedx、ARM64ではw1の値を第1引数の指す先へ格納しています。
配列アクセス
int values[] = {10, 20, 30, 40};
int read_element(const int *p, unsigned int index)
{
return p[index];
}
0:000> dd sample!values L4
000f3000 0000000a 00000014 0000001e 00000028
0:000> uf sample!read_element
sample!read_element:
000f10b0 8b4c2408 mov ecx,dword ptr [esp+8]
000f10b4 8b442404 mov eax,dword ptr [esp+4]
000f10b8 8b0488 mov eax,dword ptr [eax+ecx*4]
000f10bb c3 ret
ddの表示は16進数です。0a、14、1e、28が、初期値の10、20、30、40に対応します。
第1引数pをeax、第2引数indexをecxへ読み込み、eax + ecx * 4を参照しています。intは4バイトなので、添字を4倍してベースアドレスへ加算します。
たとえばp = 0x000f3000、index = 2なら、読み取り先は0x000f3008です。ただし、この関数には配列長がないため、命令列から分かるのはアクセス先の計算までです。計算結果が読み取れても、配列の範囲内とは限りません。
x64・ARM64との比較
x64
0:000:AMD64> dd sample!values L4
00007ff6`9aa33000 0000000a 00000014 0000001e 00000028
0:000:AMD64> uf sample!read_element
sample!read_element:
00007ff6`9aa310e0 8bc2 mov eax,edx
00007ff6`9aa310e2 8b0481 mov eax,dword ptr [rcx+rax*4]
00007ff6`9aa310e5 c3 ret
ARM64
0:000> dd sample!values L4
00007ff6`696b3000 0000000a 00000014 0000001e 00000028
0:000> uf sample!read_element
sample!read_element:
00007ff6`696b10d0 b8615800 ldr w0,[x0,w1 uxtw #2]
00007ff6`696b10d4 d65f03c0 ret
x64のmov eax,edxは、添字をコピーすると同時にraxの上位32ビットを0にし、64ビットのアドレス計算へ使えるようにします。後続のmovが読み取る位置はrcx + rax * 4です。
ARM64のuxtw #2は、32ビットのw1をゼロ拡張して2ビット左シフトします。符号付き添字なら、符号拡張を伴うsxtwや別の命令列が現れる場合があります。
構造体とアラインメント
struct Record {
char kind;
int value;
Record *next;
};
Record tail = {'B', 200, nullptr};
Record head = {'A', 100, &tail};
int read_next_value(const Record *p)
{
return p->next->value;
}
dtで型と値を確認する
0:000> dt sample!Record
+0x000 kind : Char
+0x004 value : Int4B
+0x008 next : Ptr32 Record
0:000> dt sample!head
+0x000 kind : 65 'A'
+0x004 value : 0n100
+0x008 next : 0x000f3010 Record
+0x004などは構造体の先頭からのオフセットです。kindは1バイトですが、valueはオフセット1ではなく4から始まります。間にアラインメント調整用のパディングがあるためです。
Ptr32は4バイトのポインターです。0n100の0nは、WinDbgで10進数表記であることを示します。
x64・ARM64でも同じコマンドを実行できます。nextの表示がPtr64になり、値も64ビットのアドレスです。
x64
0:000:AMD64> dt sample!Record
+0x000 kind : Char
+0x004 value : Int4B
+0x008 next : Ptr64 Record
0:000:AMD64> dt sample!head
+0x000 kind : 65 'A'
+0x004 value : 0n100
+0x008 next : 0x00007ff6`9aa33010 Record
ARM64
0:000> dt sample!Record
+0x000 kind : Char
+0x004 value : Int4B
+0x008 next : Ptr64 Record
0:000> dt sample!head
+0x000 kind : 65 'A'
+0x004 value : 0n100
+0x008 next : 0x00007ff6`696b3010 Record
Windowsの通常の既定パッキングでは、次の配置になります。
| メンバー | x86オフセット | x64・ARM64オフセット |
|---|---|---|
kind | 0(1バイト) | 0(1バイト) |
| パディング | 1~3 | 1~3 |
value | 4(4バイト) | 4(4バイト) |
next | 8(4バイト) | 8(8バイト) |
| 構造体全体のサイズ | 12バイト | 16バイト |
#pragma pack、明示的なアラインメント、継承などでも配置は変わります。PDBがあれば、サイズを推測するよりdtで実際の型情報を確認する方が確実です。
メンバーをたどる命令
0:000> uf sample!read_next_value
sample!read_next_value:
000f10c0 8b442404 mov eax,dword ptr [esp+4]
000f10c4 8b4008 mov eax,dword ptr [eax+8]
000f10c7 8b4004 mov eax,dword ptr [eax+4]
000f10ca c3 ret
[esp+4]から第1引数pを読み取るp + 8からnextポインターを読み取るnext + 4からvalueを読み取る
最初のメンバーロードで失敗した場合はp、次のロードならp->nextを基準とした参照を調べます。障害命令の直前でどのポインターを読み取ったかを追うと、同じアクセス違反でも切り分け方が変わります。
x64・ARM64では、nextのロード幅が8バイトになります。
x64
0:000:AMD64> uf sample!read_next_value
sample!read_next_value:
00007ff6`9aa310f0 488b4108 mov rax,qword ptr [rcx+8]
00007ff6`9aa310f4 8b4004 mov eax,dword ptr [rax+4]
00007ff6`9aa310f7 c3 ret
ARM64
0:000> uf sample!read_next_value
sample!read_next_value:
00007ff6`696b10e0 f9400408 ldr x8,[x0,#8]
00007ff6`696b10e4 b9400500 ldr w0,[x8,#4]
00007ff6`696b10e8 d65f03c0 ret
構造体配列
int read_record_value(const Record *p, unsigned int index)
{
return p[index].value;
}
0:000> uf sample!read_record_value
sample!read_record_value:
000f10d0 8b442408 mov eax,dword ptr [esp+8]
000f10d4 8d0c40 lea ecx,[eax+eax*2]
000f10d7 8b442404 mov eax,dword ptr [esp+4]
000f10db 8b448804 mov eax,dword ptr [eax+ecx*4+4]
000f10df c3 ret
leaでindex * 3を求め、後続のロードでさらに4倍しています。読み取り先はp + index * 12 + 4です。12はx86でのsizeof(Record)、4はvalueのオフセットに対応します。
x64・ARM64では構造体のサイズが16バイトになるため、添字の倍率も変わります。
x64
0:000:AMD64> uf sample!read_record_value
sample!read_record_value:
00007ff6`9aa31100 8bc2 mov eax,edx
00007ff6`9aa31102 4803c0 add rax,rax
00007ff6`9aa31105 8b44c104 mov eax,dword ptr [rcx+rax*8+4]
00007ff6`9aa31109 c3 ret
ARM64
0:000> uf sample!read_record_value
sample!read_record_value:
00007ff6`696b10f0 8b215008 add x8,x0,w1,uxtw #4
00007ff6`696b10f4 b9400500 ldr w0,[x8,#4]
00007ff6`696b10f8 d65f03c0 ret
x64は添字を2倍してから8倍、ARM64はゼロ拡張して4ビット左シフトし、どちらもp + index * 16 + 4を参照します。倍率だけから型名を断定せず、アクセス幅と型情報も照合します。
配列をコピーするループ
要素の読み取りだけでなく、コピー元・コピー先・反復条件を合わせて見ると、処理の目的を読み取りやすくなります。
void copy_ints(int *destination, const int *source, unsigned int count)
{
for (unsigned int index = 0; index < count; ++index)
destination[index] = source[index];
}
x86・x64:要素コピーとmemcpyへの置き換え
このビルドでは、x86・x64ともに、件数や領域の重なりを確認してmemcpyへ渡す経路と、1要素ずつコピーする経路が生成されました。次はuf sample!copy_intsで全体を確認した後、スカラーループだけをuで表示した出力です。
x86
0:000> u sample!copy_ints+0x44 L5
sample!copy_ints+0x44:
00931064 8b0c82 mov ecx,dword ptr [edx+eax*4]
00931067 890c87 mov dword ptr [edi+eax*4],ecx
0093106a 40 inc eax
0093106b 3bc6 cmp eax,esi
0093106d 72f5 jb sample!copy_ints+0x44 (00931064)
x64
0:000:AMD64> u sample!copy_ints+0x43 L5
sample!copy_ints+0x43:
00007ff6`6bef1063 8b0c82 mov ecx,dword ptr [rdx+rax*4]
00007ff6`6bef1066 890c83 mov dword ptr [rbx+rax*4],ecx
00007ff6`6bef1069 ffc0 inc eax
00007ff6`6bef106b 413bc2 cmp eax,r10d
00007ff6`6bef106e 72f3 jb sample!copy_ints+0x43 (00007ff6`6bef1063)
どちらも4バイトを読み、別の配列へ4バイトを書き、添字を1増やしています。jbは符号なしの比較で「添字が件数より小さい間」反復する分岐です。この箇所の役割は次のとおりです。
| 役割 | x86のスカラーループ | x64のスカラーループ |
|---|---|---|
| コピー元 | edx | rdx |
| コピー先 | edi | rbx |
| 添字 | eax | eax(アドレス計算ではrax) |
| 件数 | esi | r10d |
| コピー中の要素 | ecx | ecx |
このループのecxはカウンターではなく、配列要素の値です。 レジスター名だけで用途を断定せず、どの命令が更新し、何と比較しているかを追うことが重要です。
一方、x64のmemcpy呼び出し経路では次の命令が生成されました。
0:000:AMD64> u sample!copy_ints+0x2e L4
sample!copy_ints+0x2e:
00007ff6`6bef104e 4d8bc2 mov r8,r10
00007ff6`6bef1051 488bcb mov rcx,rbx
00007ff6`6bef1054 49c1e002 shl r8,2
00007ff6`6bef1058 e873b60600 call sample!memcpy (00007ff6`6bf5c6d0)
ここではrcxがコピー先、rdxがコピー元、r8がバイト数です。shl r8,2により、intの要素数を4倍しています。同じ関数の中でも、分岐した先でレジスターの意味や単位が変わります。
生成されたmemcpyが見えても、元のソースで直接memcpyを呼んでいたとは限りません。また、この前向きコピーは、重なる領域を常に正しく移動するmemmoveと同じ契約ではありません。
ARM64:添字によるロードとストア
0:000> uf sample!copy_ints
sample!copy_ints:
00007ff6`d1564020 52800008 mov w8,#0
00007ff6`d1564024 340000c2 cbz w2,sample!copy_ints+0x1c (00007ff6`d156403c)
sample!copy_ints+0x8:
00007ff6`d1564028 b8685829 ldr w9,[x1,w8 uxtw #2]
00007ff6`d156402c b8285809 str w9,[x0,w8 uxtw #2]
00007ff6`d1564030 11000508 add w8,w8,#1
00007ff6`d1564034 6b02011f cmp w8,w2
00007ff6`d1564038 54ffff83 blo sample!copy_ints+0x8 (00007ff6`d1564028)
sample!copy_ints+0x1c:
00007ff6`d156403c d65f03c0 ret
x0がコピー先、x1がコピー元、w2が件数、w8が添字、w9がコピー中の要素です。uxtw #2で添字を4倍しています。今回のARM64ビルドでは、同じソースでもmemcpy呼び出しには置き換わりませんでした。
最適化の設定やコンパイラーが変わると、SIMD、ループ展開、rep movsなどが現れる場合もあります。ソースコードの記述だけから生成命令を断定せず、実際の実行経路を確認します。
文字列
char narrow_text[] = "ABC";
wchar_t wide_text[] = L"ABC";
0:000> db sample!narrow_text L4
000f3028 41 42 43 00 ABC.
0:000> du sample!wide_text
000f302c "ABC"
dbはバイト単位の内容と右側の文字表示、duはUTF-16文字列を表示します。Windowsではcharが1バイト、通常のwchar_tとWCHARが2バイトです。
x64・ARM64でも、アドレスの表示幅は変わりますが、文字列の確認方法と内容は同じです。
x64
0:000:AMD64> db sample!narrow_text L4
00007ff6`9aa33030 41 42 43 00 ABC.
0:000:AMD64> du sample!wide_text
00007ff6`9aa33038 "ABC"
ARM64
0:000> db sample!narrow_text L4
00007ff6`696b3030 41 42 43 00 ABC.
0:000> du sample!wide_text
00007ff6`696b3038 "ABC"
文字列ループでは、次の命令とポインターの増分が手掛かりになります。
| 対象 | x86・x64のロード例 | ARM64のロード例 | ポインターの増分 |
|---|---|---|---|
char列 | movzx eax,byte ptr [rcx](x64) | ldrb w0,[x1] | 1バイト |
wchar_t列 | movzx eax,word ptr [rcx](x64) | ldrh w0,[x1] | 2バイト |
ただし、最適化された文字列関数はSIMDや大きな単位のロードを使用することがあるため、「8バイトロードだから8バイト文字」という意味ではありません。命令だけではUTF-8などの文字エンコーディングも確定できません。また、UTF-16の2バイトはコード単位であり、必ず1文字を表すとは限りません。
終端までコピーする
void copy_text(char *destination, const char *source)
{
while ((*destination++ = *source++) != '\0') {}
}
次はx86・x64のループ部分と、ARM64の関数全体です。
x86
0:000> u sample!copy_text+0x8 L6
sample!copy_text+0x8:
00931088 8a0a mov cl,byte ptr [edx]
0093108a 8d4001 lea eax,[eax+1]
0093108d 8848ff mov byte ptr [eax-1],cl
00931090 8d5201 lea edx,[edx+1]
00931093 84c9 test cl,cl
00931095 75f1 jne sample!copy_text+0x8 (00931088)
x64
0:000:AMD64> u sample!copy_text+0x10 L6
sample!copy_text+0x10:
00007ff6`6bef1090 0fb602 movzx eax,byte ptr [rdx]
00007ff6`6bef1093 488d5201 lea rdx,[rdx+1]
00007ff6`6bef1097 8801 mov byte ptr [rcx],al
00007ff6`6bef1099 488d4901 lea rcx,[rcx+1]
00007ff6`6bef109d 84c0 test al,al
00007ff6`6bef109f 75ef jne sample!copy_text+0x10 (00007ff6`6bef1090)
ARM64
0:000> uf sample!copy_text
sample!copy_text:
00007ff6`d1564040 38c01428 ldrsb w8,[x1],#1
00007ff6`d1564044 38001408 strb w8,[x0],#1
00007ff6`d1564048 35ffffc8 cbnz w8,sample!copy_text (00007ff6`d1564040)
sample!copy_text+0xc:
00007ff6`d156404c d65f03c0 ret
いずれも「1バイト読む → 1バイト書く → 読んだ値が0でなければ反復」です。終端の0も書き込んでから終了します。ARM64の[x1],#1と[x0],#1はアクセス後にポインターを1進める指定です。ldrsbは符号拡張を伴いますが、この例の終了条件は0かどうかなので、読み取る単位は変わらず1バイトです。
この関数にはコピー先容量の引数がありません。終端を含めて収まる領域が必要で、容量が足りなければ書き越します。 コピー元に有効な終端がなければ、読み取り側も範囲外へ進みます。これは命令を読むためのサンプルであり、外部入力をそのままコピーする実装として推奨するものではありません。
文字列長を調べる
unsigned int text_length(const char *text)
{
unsigned int length = 0;
while (text[length] != '\0')
++length;
return length;
}
戻り値を読みやすくするため、ここでは長さがunsigned intで表せる小さな文字列を扱います。標準のstrlenの戻り値はsize_tであり、このサンプルはその実装を再現したものではありません。
x86
0:000> uf sample!text_length
sample!text_length:
00931220 8b542404 mov edx,dword ptr [esp+4]
00931224 33c0 xor eax,eax
00931226 3802 cmp byte ptr [edx],al
00931228 7410 je sample!text_length+0x1a (0093123a)
sample!text_length+0xa:
0093122a 660f1f440000 nop word ptr [eax+eax]
sample!text_length+0x10:
00931230 8bc8 mov ecx,eax
00931232 40 inc eax
00931233 807c110100 cmp byte ptr [ecx+edx+1],0
00931238 75f6 jne sample!text_length+0x10 (00931230)
sample!text_length+0x1a:
0093123a c3 ret
x64
0:000:AMD64> uf sample!text_length
sample!text_length:
00007ff6`6bef1270 33c0 xor eax,eax
00007ff6`6bef1272 3801 cmp byte ptr [rcx],al
00007ff6`6bef1274 7408 je sample!text_length+0xe (00007ff6`6bef127e)
sample!text_length+0x6:
00007ff6`6bef1276 ffc0 inc eax
00007ff6`6bef1278 803c0100 cmp byte ptr [rcx+rax],0
00007ff6`6bef127c 75f8 jne sample!text_length+0x6 (00007ff6`6bef1276)
sample!text_length+0xe:
00007ff6`6bef127e c3 ret
ARM64
0:000> uf sample!text_length
sample!text_length:
00007ff6`d15642a0 aa0003e9 mov x9,x0
00007ff6`d15642a4 39c00128 ldrsb w8,[x9]
00007ff6`d15642a8 52800000 mov w0,#0
00007ff6`d15642ac 34000088 cbz w8,sample!text_length+0x1c (00007ff6`d15642bc)
sample!text_length+0x10:
00007ff6`d15642b0 11000400 add w0,w0,#1
00007ff6`d15642b4 38e04928 ldrsb w8,[x9,w0 uxtw #0]
00007ff6`d15642b8 35ffffc8 cbnz w8,sample!text_length+0x10 (00007ff6`d15642b0)
sample!text_length+0x1c:
00007ff6`d15642bc d65f03c0 ret
これらには書き込みがなく、0を探しながら添字を増やしています。x86・x64はeax、ARM64はw0が長さです。x86のecxは更新前の添字を一時保存しており、参照先はedx + ecx + 1です。
このような場所で読み取りアクセス違反になった場合、長さを調べる関数が書き壊したとは限りません。入力ポインターの寿命、終端の欠落、既に破損していた文字列を疑う手掛かりになります。
指定文字を検索する
const char *find_char(const char *text, char target)
{
for (;; ++text) {
if (*text == target)
return text;
if (*text == '\0')
return nullptr;
}
}
x86
0:000> uf sample!find_char
sample!find_char:
009310a0 8b442404 mov eax,dword ptr [esp+4]
009310a4 8a4c2408 mov cl,byte ptr [esp+8]
009310a8 3808 cmp byte ptr [eax],cl
009310aa 7411 je sample!find_char+0x1d (009310bd)
sample!find_char+0xc:
009310ac 0f1f4000 nop dword ptr [eax]
sample!find_char+0x10:
009310b0 803800 cmp byte ptr [eax],0
009310b3 7406 je sample!find_char+0x1b (009310bb)
sample!find_char+0x15:
009310b5 40 inc eax
009310b6 3808 cmp byte ptr [eax],cl
009310b8 75f6 jne sample!find_char+0x10 (009310b0)
sample!find_char+0x1a:
009310ba c3 ret
sample!find_char+0x1b:
009310bb 33c0 xor eax,eax
sample!find_char+0x1d:
009310bd c3 ret
x64
0:000:AMD64> uf sample!find_char
sample!find_char:
00007ff6`6bef10b0 3811 cmp byte ptr [rcx],dl
00007ff6`6bef10b2 740c je sample!find_char+0x10 (00007ff6`6bef10c0)
sample!find_char+0x4:
00007ff6`6bef10b4 803900 cmp byte ptr [rcx],0
00007ff6`6bef10b7 740b je sample!find_char+0x14 (00007ff6`6bef10c4)
sample!find_char+0x9:
00007ff6`6bef10b9 48ffc1 inc rcx
00007ff6`6bef10bc 3811 cmp byte ptr [rcx],dl
00007ff6`6bef10be 75f4 jne sample!find_char+0x4 (00007ff6`6bef10b4)
sample!find_char+0x10:
00007ff6`6bef10c0 488bc1 mov rax,rcx
00007ff6`6bef10c3 c3 ret
sample!find_char+0x14:
00007ff6`6bef10c4 33c0 xor eax,eax
00007ff6`6bef10c6 c3 ret
ARM64
0:000> uf sample!find_char
sample!find_char:
00007ff6`d1564050 39c00008 ldrsb w8,[x0]
00007ff6`d1564054 6b21811f cmp w8,w1,sxtb #0
00007ff6`d1564058 54000100 beq sample!find_char+0x28 (00007ff6`d1564078)
sample!find_char+0xc:
00007ff6`d156405c 39c00008 ldrsb w8,[x0]
00007ff6`d1564060 340000a8 cbz w8,sample!find_char+0x24 (00007ff6`d1564074)
sample!find_char+0x14:
00007ff6`d1564064 38c01c08 ldrsb w8,[x0,#1]!
00007ff6`d1564068 6b21811f cmp w8,w1,sxtb #0
00007ff6`d156406c 54ffff81 bne sample!find_char+0xc (00007ff6`d156405c)
sample!find_char+0x20:
00007ff6`d1564070 d65f03c0 ret
sample!find_char+0x24:
00007ff6`d1564074 d2800000 mov x0,#0
sample!find_char+0x28:
00007ff6`d1564078 d65f03c0 ret
検索ループには「検索文字との一致」と「終端との一致」の2種類の比較があります。x64の検索文字はdl、走査中のポインターはrcxです。rcxはカウンターではありません。ARM64ではw1の下位8ビットを符号拡張し、ロードした文字と比較しています。
検索文字が0なら終端のアドレスを返し、0以外が見つからず終端に到達した場合はNULLを返します。文字列長の計算と違い、戻り値は件数ではなくポインターです。
関数呼び出し
通常の呼び出しと可変長引数
int add_values(int a, int b);
この関数なら、x86の__cdeclでは引数をスタックへ、Windows x64ではecx・edx、Windows ARM64ではw0・w1へ渡す形が基本です。
x86の__stdcallでは引数領域の解放を呼び出し先が行います。一方、printfのような可変長引数では、浮動小数点引数も含めて通常関数と異なる規則があります。詳細と比較表は呼び出し規約にまとめています。
関数ポインター
int invoke(int (*callback)(int), int value)
{
return callback(value);
}
0:000> uf sample!invoke
sample!invoke:
000f1090 ff742408 push dword ptr [esp+8]
000f1094 ff542408 call dword ptr [esp+8]
000f1098 83c404 add esp,4
000f109b c3 ret
2つの[esp+8]は、同じ値を参照しているわけではありません。
- 入口の
[esp+8]は第2引数valueで、pushによって呼び出し先の引数として積みます。 pushでespが4減るため、その後の[esp+8]は、元の第1引数callbackの位置です。call dword ptr [esp+8]は、その位置から読み取ったアドレスを呼び出し先とします。add esp,4で、callbackへ渡した引数の領域を解放します。
今回のx64・ARM64ビルドでは、いずれも末尾呼び出しに最適化されています。
x64
0:000:AMD64> uf sample!invoke
Flow analysis was incomplete, some code may be missing
sample!invoke:
00007ff6`9aa310c0 488bc1 mov rax,rcx
00007ff6`9aa310c3 8bca mov ecx,edx
00007ff6`9aa310c5 48ffe0 jmp rax
ARM64
0:000> uf sample!invoke
Flow analysis was incomplete, some code may be missing
sample!invoke:
00007ff6`696b10b0 aa0003e8 mov x8,x0
00007ff6`696b10b4 2a0103e0 mov w0,w1
00007ff6`696b10b8 d61f0100 br x8
x64ではcallbackをraxへ保持し、valueを第1引数用のecxへ移してjmpします。ARM64ではcallbackをx8へ保持し、valueをw0へ移してbrします。どちらも、新しい戻りアドレスを作らずに呼び出し先へ移ります。ufの警告については次節で説明します。
C++の仮想関数
struct Reader {
virtual int read() = 0;
};
int invoke_reader(Reader *reader)
{
return reader->read();
}
0:000> uf sample!invoke_reader
Flow analysis was incomplete, some code may be missing
sample!invoke_reader:
000f10a0 8b4c2404 mov ecx,dword ptr [esp+4]
000f10a4 8b01 mov eax,dword ptr [ecx]
000f10a6 ff20 jmp dword ptr [eax]
この例では、readerをecxへ読み、オブジェクト先頭の仮想関数テーブルポインターをeaxへ読み、テーブル先頭の関数へ移ります。ecxは、x86の__thiscallでthisを渡すレジスターでもあります。
最後がcallではなくjmpなのは、末尾呼び出しへ最適化されているためです。呼び出されたread()はinvoke_readerへ戻らず、その呼び出し元へ直接戻ります。Cソースに呼び出しがあるからといって、必ずcallが表示されるわけではありません。
この実測例では、間接分岐先を静的に追い切れず、ufが先頭の警告を表示しています。これは、表示された範囲だけで関数の全経路を復元できるとは限らないという意味で、メモリー破損を示すものではありません。必要に応じてuで範囲を指定して命令を確認し、テーブルや分岐先も調べます。
x64・ARM64の実測出力も、末尾呼び出しになっています。
x64
0:000:AMD64> uf sample!invoke_reader
Flow analysis was incomplete, some code may be missing
sample!invoke_reader:
00007ff6`9aa310d0 488b01 mov rax,qword ptr [rcx]
00007ff6`9aa310d3 48ff20 jmp qword ptr [rax]
ARM64
0:000> uf sample!invoke_reader
Flow analysis was incomplete, some code may be missing
sample!invoke_reader:
00007ff6`696b10c0 f9400008 ldr x8,[x0]
00007ff6`696b10c4 f9400108 ldr x8,[x8]
00007ff6`696b10c8 d61f0100 br x8
x64はrcx、ARM64はx0でthisを渡したまま、仮想関数テーブル先頭の関数へ分岐します。ARM64ではblrではなくbrなので、LRは書き換えません。このサンプルはMicrosoft C++の単純なオブジェクト配置で、テーブル先頭の仮想関数を呼び出しています。
多重継承、this調整、最適化による直接呼び出し、Control Flow Guardの検査・中継コードなどによって実際の形は変わります。仮想関数の障害では、オブジェクト、仮想関数テーブル、呼び出し先のどの参照で失敗したかを分けて確認するとよいでしょう。
dtとdpsで仮想関数テーブルをたどる
命令だけでなく、呼び出し対象のオブジェクトとテーブルも確認できます。サンプルには次の具象クラスとインスタンスを追加しています。
struct ValueReader : Reader {
int value;
constexpr ValueReader(int initial) : value(initial) {}
__declspec(noinline) int read() override { return value; }
};
ValueReader reader_instance{123};
Reader *reader_pointer = &reader_instance;
調べる対象は、次の3段階です。
reader_pointerの格納場所 → オブジェクトのアドレス- オブジェクト先頭 → 仮想関数テーブルのアドレス(vfptr)
- 仮想関数テーブルの各スロット → 呼び出し先の関数アドレス
x86
0:000> dt sample!reader_instance
+0x000 __VFN_table : 0x0098a1b4
+0x004 value : 0n123
0:000> dps sample!reader_pointer L1
0099403c 00994034 sample!reader_instance
0:000> dps sample!reader_instance L2
00994034 0098a1b4 sample!ValueReader::`vftable'
00994038 0000007b
0:000> dps poi(sample!reader_instance) L1
0098a1b4 00931000 sample!ValueReader::read
x64
0:000:AMD64> dt sample!reader_instance
+0x000 __VFN_table : 0x00007ff6`6bf5e398
+0x008 value : 0n123
0:000:AMD64> dps sample!reader_pointer L1
00007ff6`6bf72050 00007ff6`6bf72040 sample!reader_instance
0:000:AMD64> dps sample!reader_instance L2
00007ff6`6bf72040 00007ff6`6bf5e398 sample!ValueReader::`vftable'
00007ff6`6bf72048 00000000`0000007b
0:000:AMD64> dps poi(sample!reader_instance) L1
00007ff6`6bf5e398 00007ff6`6bef1000 sample!ValueReader::read
ARM64
0:000> dt sample!reader_instance
+0x000 __VFN_table : 0x00007ff6`d15dd368
+0x008 value : 0n123
0:000> dps sample!reader_pointer L1
00007ff6`d15ed050 00007ff6`d15ed040 sample!reader_instance
0:000> dps sample!reader_instance L2
00007ff6`d15ed040 00007ff6`d15dd368 sample!ValueReader::`vftable'
00007ff6`d15ed048 00000000`0000007b
0:000> dps poi(sample!reader_instance) L1
00007ff6`d15dd368 00007ff6`d1564000 sample!ValueReader::read
dtの__VFN_tableは、PDBの型情報からデバッガーが表示したvfptrです。dpsは現在の対象のポインター幅で読み取り、値に対応するシンボルも表示します。最初の列が読み取った場所、次の列がそこに格納されていた値です。64ビット版のL2の2行目は、4バイトのvalueとその後ろのパディングを合わせた8バイトであり、2個目の仮想関数ではありません。
MASM式のpoi(アドレス)は、その位置からポインター幅の値を1個読み取ります。上のdps poi(sample!reader_instance) L1は、オブジェクト先頭から取得したテーブルを表示しています。reader_pointerはポインター変数、reader_instanceはオブジェクトそのものなので、同じ回数だけ間接参照すればよいわけではありません。
このサンプルのテーブルにはread()の1スロットしかありません。数が分からないまま長い範囲を表示すると、隣のテーブルやRTTIなども仮想関数のように見えてしまいます。型やインターフェイス定義に合わせて表示数を決める方が確実です。PDBがあっても、デバッガーが全メソッド名を自動的に一覧化できるとは限りません。
COMインターフェイスポインターを調べるとき
WindowsのCOMも、インターフェイスポインターから関数テーブルをたどって呼び出します。ただし、上のValueReaderはCOMオブジェクトではありません。 COMではIUnknownを継承するため、先頭の3スロットは次の順序です。
| メソッド | スロット番号(0始まり) | x86のテーブル内オフセット | x64・ARM64のテーブル内オフセット |
|---|---|---|---|
QueryInterface | 0 | 0x00 | 0x00 |
AddRef | 1 | 0x04 | 0x08 |
Release | 2 | 0x08 | 0x10 |
IUnknownを直接継承したインターフェイスの最初の追加メソッド | 3 | 0x0c | 0x18 |
IDispatchなど別のインターフェイスを経由して継承する場合は、そのメソッドも先に並びます。call [テーブル+0x18]という命令だけでは、特定の業務メソッドへの呼び出しとは断定できません。呼び出し元の型、IID、IDLやヘッダーに定義された順序と照合します。
たとえば、x64のCOMメソッド入口でrcxがインターフェイスポインターだと確認できている場合は、次のように調べられます。これは入力例で、実測したCOMログではありません。
r @$t0 = @rcx
dps @$t0 L1
dps poi(@$t0) L4
最初にインターフェイス先頭のvfptrを確認し、次にそのテーブルを確認しています。L4は対象インターフェイスに4スロット以上あると分かっている場合の例です。IUnknownだけならL3にします。一般のC++ __thiscallと違い、x86の通常のCOMメソッドは__stdcallで、入口の[esp+4]が明示的なthis引数です。x64ではrcx、ARM64ではx0が基本ですが、関数の途中では既に別の値へ再利用されている可能性があります。
COMの障害では、特に次の点が手掛かりになります。
- インターフェイスとオブジェクト全体を区別する。 同じCOMオブジェクトでも、別インターフェイスのポインターは異なるアドレスになり得ます。多重継承や集約、
this調整のサンクを、直ちに破損と判断しないようにします。 - 解放後参照を疑う。 vfptrが不正な場合、参照カウントの不整合、
Release後の利用、別スレッドによる寿命の終了、バッファの書き越しなどが候補です。テーブルが正常に見えても、解放済み領域がまだ上書きされていないだけかもしれません。 - 呼び出し先のモジュールも見る。
lnやlmで、関数のアドレスがどこに属するかを確認します。プロキシやマーシャリングを経由する場合、アプリケーション本体ではなく中継コードが見えることがあります。シンボル名が出ないだけでは破損の証拠になりません。 - アパートメントと受け渡しを確認する。 インターフェイスがagileか、適切にマーシャリングしているかなども調査対象です。ポインターの形が正しく見えることと、そのスレッドから利用できることは別です。
調査のためにライブ対象へAddRefやReleaseを呼ぶと、参照カウントや寿命を変えてしまいます。 まずはポインター、メモリー、スタックを読み取る方法で確認する方が安全です。COMには参照カウントを外部から直接読める共通のメンバー配置もありません。
仮想呼び出しのどこで失敗したかを分ける
| 失敗した位置 | 確認するもの |
|---|---|
| オブジェクト先頭からvfptrをロードするとき | オブジェクトまたはインターフェイスポインターのアドレス、寿命、読み取り可能性 |
| テーブルから関数アドレスを読むとき | vfptrの値、スロットのオフセット、想定するインターフェイス |
| 間接呼び出し・分岐した先 | 実際の分岐先、所属モジュール、実行可能性、サンクやCFGの中継 |
| メソッド内でメンバーへアクセスするとき | 渡されたthis、メンバーの状態、呼び出し側と実装側の型の整合性 |
!addressで領域の状態を調べることもできます。ただし、メモリーがコミット済みで読めることは、オブジェクトがまだ生存している証明ではありません。ダンプに対象のページが保存されていないだけでも読み取りに失敗します。既知の破損パターンや????????の表示だけで、解放後参照と断定しない方がよいでしょう。
バッファ境界とカウンターを照合する
配列や文字列の障害では、コピー関数の名前だけでなく、どの命令が、何バイトを、どのアドレスから読み、どこへ書こうとしたかを確認します。例外ダンプなら.exr -1で読み取り・書き込み・実行の区別と対象アドレスを確認し、.ecxrで例外コンテキストを選んでからrと逆アセンブルを照合します。
実際の停止状態から次のアクセス先を求める
サンプルのcopy_bufferはint[4]、つまり16バイトです。mainはまず4要素をコピーし、次に3要素をコピーします。後者ではスカラーループを通るので、x86ビルドの添字が2になった位置で止めてみます。
以下は今回のx86ビルド用のブレークポイント入力例です。別のビルドではuf sample!copy_intsでロード命令の位置を探し、そのオフセットに置き換えます。
bp sample!copy_ints+0x44 ".if (@eax == 2) { .echo SCALAR_COPY_STATE } .else { gc }"
g
次はその停止時の実測値です。停止直後の全レジスター表示は省略しています。
0:000> r eax,esi,ecx
eax=00000002 esi=00000003 ecx=00000014
0:000> ?? sizeof(sample!copy_buffer)
unsigned int 0x10
0:000> ? @edi + @eax * 4
Evaluate expression: 10046152 = 00994ac8
0:000> ? sample!copy_buffer + 0x10
Evaluate expression: 10046160 = 00994ad0
eax = 2は次にコピーする添字、esi = 3は要求された要素数です。ecx = 0x14は直前にコピーした要素の20であり、残り件数ではありません。停止位置は次のロード命令の直前なので、まだ次の要素を読み取っていません。- コピー先の有効範囲は
[0x00994ac0, 0x00994ad0)です。次のストアは0x00994ac8から4バイトなので範囲内です。 sizeofの結果はPDBの型情報に基づく静的配列のサイズです。ポインター引数にsizeofを適用しても、指しているバッファの容量にはなりません。
この状態は正常です。一方、同じ4要素のコピー先に対して、添字が4で書き込みを行うなら、開始位置は終端アドレスそのものになり、4バイト書き越します。要求件数が5ならindex < countは成立してしまいます。ループの終了条件が成立していることと、実際のバッファ容量に収まることは別です。 この異常ケースは説明用であり、掲載した実測では範囲外アクセスを実行していません。
境界を確認するときの順序
- 停止位置を確定する。 ロード・ストアの前か後か、添字の加算前か後かを確認します。x86の文字列コピー例では
eaxを先に進めてから[eax-1]へ書いています。 - レジスターの役割を追う。 現在の値が元の件数、添字、残り件数、単なるデータのどれかを、更新命令と比較命令から判断します。入口の引数レジスターがそのまま残っているとは限りません。
- 単位をそろえる。 要素数、バイト数、UTF-16コード単位数を区別します。
int4個は16バイトです。rep movsbの反復数はバイト単位、rep movsdは4バイト単位で、rep実行途中のecx・rcxは残り反復数です。進行方向にはDFも関係します。 - 実際の容量と照合する。 配列型、呼び出し元の確保処理、管理している長さなどから容量を調べ、コピー元・コピー先をそれぞれ確認します。ヒープの確保ブロックが大きくても、その中の配列メンバーの境界を越えてよいわけではありません。
- アクセス幅全体を見る。 先頭アドレスだけでなく、4バイトや16バイトのアクセス全体が有効範囲に入るかを確認します。整数の乗算やアドレス加算が桁あふれしていないかも確認対象です。
- 文字列なら終端分も数える。
"ABC"の長さは3ですが、コピー先には終端を含めて4バイト必要です。WCHARで同じ内容なら4コード単位、8バイトです。長さを調べる処理での障害は、読み取り側の終端欠落の可能性もあります。
符号付きの負の長さを符号なしへ変換した結果、非常に大きな値になっていることもあります。反対に、巨大なコピー要求が整数の桁あふれで小さな確保サイズになっている場合もあります。レジスターの値が大きいというだけでは断定せず、その値を渡した呼び出し元と確保サイズまで照合するとよいでしょう。
壊された場所と壊した場所を区別する
memcpy内のアクセス違反、仮想関数テーブルの破損、ヒープ解放時のエラーは、破損を検出した場所であって、最初に書き壊した命令とは限りません。書き越しても隣がアクセス可能なメモリーならその場では停止せず、後から無関係に見える処理で障害になることがあります。
再現できる場合、ライブデバッグで既知の境界へデータブレークポイントを設定したり、対象がヒープ上ならPageHeapやApplication Verifierを使ったりする方法が考えられます。たとえばPageHeapの設定例は次のとおりです。これらは操作例で、本記事の実測取得では実行していません。
gflags /p /enable target.exe /full
gflags /p /disable target.exe
有効化してから対象を再起動して再現し、調査後は無効化して再起動します。設定はプロセス終了やOS再起動だけでは消えません。フルPageHeapはメモリー使用量や動作タイミングへ影響するため、本番環境へ無条件に適用しないでください。 また、ヒープ用の機能であり、すべてのスタック配列や構造体内部の書き越しを検出できるわけではありません。
PageHeapを有効にした対象では、!heap -p -a 対象アドレスなどから割り当てを調べられます。本サンプルのcopy_bufferやreader_instanceはグローバル変数なので、ヒープ割り当てとして調べる対象ではありません。
最適化により対応が崩れる例
- 定数畳み込みで、計算自体がなくなる
- インライン化で、関数呼び出しやフレームがなくなる
- ループの展開やベクトル化で、複数要素をまとめて処理する
- レジスターが再利用され、元の変数の値が残らない
- 未定義動作が起きないという前提で、チェックが除去される
ソースの1行と命令1個が対応するとは限りません。障害箇所を調べる際は、実行されたバイナリと一致するソース・PDBを用意し、命令の動作を起点に解釈する方が確実です。
自分の環境で確認する
実測に使用したsample.cppをダウンロードできます。関数やグローバル変数をextern "C"で宣言し、各関数に__declspec(noinline)を指定しています。mainからコピー、長さ計算、検索、仮想呼び出しを実行し、最後にinspect_resultsで停止できる構成です。
これは引数なしで起動した場合の動作です。引数を1つ付けると、引数とローカル変数をダンプから調べる例を実行し、途中の__debugbreak()で停止します。
アーキテクチャごとにビルドする
Visual StudioのC++ビルドツールと、対象アーキテクチャ用のツールセットを使用します。x64・ARM64には対応するC++ビルドツール、CDBにはWindows SDKの「Debugging Tools for Windows」が必要です。
vcvarsall.batで環境を設定する場合の引数は次のとおりです。インストール先はVisual Studioのバージョンやエディションによって異なります。
| ターゲット | 環境設定の例 |
|---|---|
| x86 | vcvarsall.bat x86 |
| x64 | vcvarsall.bat amd64 |
| ARM64 | vcvarsall.bat amd64_arm64 |
対象の環境を設定したコマンドプロンプトで、次を実行します。実行ファイルとPDBが混在しないよう、アーキテクチャごとに作業ディレクトリを分けるとよいでしょう。
cl /nologo /W4 /WX /O2 /Ob0 /MT /Zi /Fesample.exe /Fosample.obj /Fdsample.pdb sample.cpp /link /DEBUG /INCREMENTAL:NO /OPT:NOREF /OPT:NOICF
/Ziと/DEBUGでデバッグ情報を生成し、/OPT:NOREFで未参照の関数が取り除かれること、/OPT:NOICFで同一の関数がまとめられることを防ぎます。インライン化は/Ob0で無効化していますが、それ以外の最適化や末尾呼び出しは残ります。/MTはC++ランタイムの静的リンクです。具象クラスのRTTIや、最適化で生成されたmemcpyもランタイムを利用するため、ここでは通常の起動処理を使います。
CDBで表示する
実行ファイルとPDBのあるディレクトリから、CDBを起動します。CDBにPATHが通っていなければ、Windows SDKのDebuggers\x86、Debuggers\x64、Debuggers\arm64などから、ホスト環境と対象に対応する実行ファイルを指定します。
cdb.exe -y . -logo cdb.log sample.exe
今回の取得では、x86版サンプルにはx86版CDB、ARM64版サンプルにはARM64版CDBを使いました。ARM64 Windows上でx64版サンプルをARM64版CDBから起動すると、最初はネイティブ側のARM64ECコンテキストで停止しました。x64のサンプル関数を逆アセンブルする前に、次のようにモードを指定します。
0:000:ARM64EC> .effmach amd64
Effective machine: AMD64 (CHPEv2) (AMD64)
0:000:AMD64> uf sample!read_element
.effmachは命令を解釈するモードの指定であり、バイナリを別のCPU向けに変換するものではありません。エミュレーションプロセスにはネイティブ側のコードも含まれるため、常にamd64で読めばよいわけではなく、調べるコードのアーキテクチャに合わせます。今回のARM64版サンプルは最初からARM64モードで表示できました。
逆アセンブルと静的データは、最初の停止位置のままで確認できます。-logoで指定したファイルにコマンドと出力が記録されます。
uf sample!read_value
uf sample!write_value
uf sample!read_element
dt sample!Record
dt sample!head
dd sample!values L4
uf sample!read_next_value
uf sample!read_record_value
db sample!narrow_text L4
du sample!wide_text
uf sample!invoke
uf sample!invoke_reader
uf sample!copy_ints
uf sample!copy_text
uf sample!text_length
uf sample!find_char
dt sample!reader_instance
dps poi(sample!reader_instance) L1
q
実行結果も確認する場合は、qの代わりに次のコマンドを入力します。途中の調査で設定したブレークポイントがある場合は、不要なものを解除してから進めます。
bp sample!inspect_results
g
dd sample!copy_buffer L4
da sample!text_copy
dd sample!measured_length L1
da poi(sample!found_text)
dd sample!reader_result L1
q
3アーキテクチャで、コピー結果は{11, 20, 30, 40}、文字列は"ABC"、長さは3、検索結果は"BC"の先頭、仮想関数の戻り値は123でした。配列先頭が10ではなく11なのは、先にwrite_value(values, 11)を呼んでいるためです。
WinDbgのGUIを使う場合も、同じ実行ファイルを起動して同じコマンドを実行できます。シンボルが見つからない場合は、PDBのあるディレクトリを設定して再ロードします。
.sympath+ C:\work\asm-sample
.reload /f sample.exe
uf sample!read_element
dt sample!Record
dd sample!values L4
C:\work\asm-sampleは、作成した実行ファイルとPDBのディレクトリへ置き換えます。本サンプルのデータは静的に初期化されており、初期停止位置で確認できます。別のサンプルでCRTによる動的初期化が必要な場合や、関数の実行後に調べる場合では、同じ値になるとは限りません。
ビルド設定、リンカー、ASLRにより、本記事とアドレスや命令列が完全一致するとは限りません。uで現在の命令、ufで関数の流れ、dtでメンバー配置、ddなどで実データを照合する、という読み方に注目するとよいでしょう。
参考資料
- u, ub, uu (Unassemble) | Microsoft Learn
- uf (Unassemble Function) | Microsoft Learn
- dt (Display Type) | Microsoft Learn
- .effmach (Effective Machine) | Microsoft Learn
- d, da, db, dc, dd, dD, df, dp, dq, du, dw (Display Memory) | Microsoft Learn
- x64 calling convention | Microsoft Learn
- Overview of ARM64 ABI conventions | Microsoft Learn
- Control Flow Guard | Microsoft Learn
- Interface Pointers and Interfaces | Microsoft Learn
- IUnknown interface | Microsoft Learn
- Implementing Reference Counting | Microsoft Learn
- GFlags and PageHeap | Microsoft Learn
- !heap | Microsoft Learn