データ操作命令
アクセス違反の調査では、「どのアドレスへ、何バイト、読み取りと書き込みのどちらを行ったか」を最初に確認します。以下は共通サンプルをMSVCでビルドし、CDBで取得した出力です。レジスター名と幅は汎用レジスターも参照してください。
即値・レジスター・メモリー
命令が扱う値は、大きく次の3種類に分けられます。
| 種類 | 例 | 意味 |
|---|---|---|
| 即値 | 1、20h、ARM64の#32 | 命令に指定された定数 |
| レジスター | eax、rcx、w0、x1 | CPUのレジスター内の値 |
| メモリー | [rcx+8]、[x1,#8] | 計算したアドレスのメモリー |
角括弧は値そのものと、その値をアドレスとして参照する操作を区別する重要な記号です。ただし、x86・x64のleaは例外で、角括弧内のアドレス計算だけを行います。
x86・x64のMOV
Intel構文では、mov 書き込み先,読み取り元の順です。
int read_element(const int *p, unsigned int index) { return p[index]; }
void write_value(int *p, int value) { *p = value; }
0:000:AMD64> uf sample!read_element
sample!read_element:
00007ff6`81521350 8bc2 mov eax,edx
00007ff6`81521352 8b0481 mov eax,dword ptr [rcx+rax*4]
00007ff6`81521355 c3 ret
0:000:AMD64> uf sample!write_value
sample!write_value:
00007ff6`81521400 8911 mov dword ptr [rcx],edx
00007ff6`81521402 c3 ret
read_elementはedxの添字をeaxへ移し、rcx + rax * 4から4バイトを読みます。write_valueはedxの値をrcxの指す先へ4バイト書き込みます。よく使う形式を整理すると次のようになります。表中の命令は形式の説明で、個別の実測ログではありません。
| 命令 | 動作 |
|---|---|
mov eax,1 | 定数1をeaxへ格納 |
mov ecx,eax | eaxの32ビット値をecxへコピー |
mov eax,dword ptr [rcx+8] | rcx + 8から4バイト読み取る |
mov dword ptr [rcx+8],eax | rcx + 8へ4バイト書き込む |
mov rax,qword ptr [rcx+8] | rcx + 8から8バイト読み取る |
byte ptrは1バイト、word ptrは2バイト、dword ptrは4バイト、qword ptrは8バイトを示します。アドレスが64ビットでも、アクセス幅が8バイトとは限りません。
通常のmovでは、メモリー同士を直接コピーすることはできません。レジスターを経由するか、rep movsbのような別の命令を使用します。
実効アドレスを求める
上のread_elementのロードでは、ベースアドレスに添字を4倍して加えています。計算例としてrcx = 0x1000、rax = 3なら、読み取り先は0x100cで、アクセス幅は4バイトです。これはアドレス計算の例であり、取得したレジスター値ではありません。ロード命令の位置で停止している場合、次の入力で計算とメモリー内容を確認できます。
? @rcx + @rax * 4
dd @rcx + @rax * 4 L1
これはアドレス計算とそのメモリー内容の確認であり、参照先が有効な配列であることや、添字が範囲内であることを保証しません。アクセスは計算した先頭から複数バイトに及ぶため、ページ境界をまたいだ先で失敗することもあります。
LEAはメモリーを読み取らない
int *element_address(int *p, unsigned int index) { return p + index; }
0:000:AMD64> uf sample!element_address
sample!element_address:
00007ff6`81521110 8bc2 mov eax,edx
00007ff6`81521112 488d0481 lea rax,[rcx+rax*4]
00007ff6`81521116 c3 ret
これはread_elementと似たアドレス計算ですが、leaなので参照先から値を読み取らず、アドレスを返します。leaが使われていても、必ずしもポインター演算ではなく、整数の加算や定数倍かもしれません。また、leaは条件フラグを変更しません。
x64では、グローバル変数への参照にRIP相対アドレス指定が使われます。この場合の基準は命令の先頭ではなく、次の命令のアドレスです。デバッガーが表示した解決済みのシンボルやアドレスも利用すると読みやすくなります。
符号拡張とゼロ拡張
小さい整数を大きいレジスターへ読み込む場合、残りのビットをどう埋めるかが重要です。
int signed_byte(const signed char *p) { return *p; }
unsigned int unsigned_byte(const unsigned char *p) { return *p; }
long long widen_value(int value) { return value; }
0:000:AMD64> uf sample!signed_byte
sample!signed_byte:
00007ff6`815213a0 0fbe01 movsx eax,byte ptr [rcx]
00007ff6`815213a3 c3 ret
0:000:AMD64> uf sample!unsigned_byte
sample!unsigned_byte:
00007ff6`815213d0 0fb601 movzx eax,byte ptr [rcx]
00007ff6`815213d3 c3 ret
0:000:AMD64> uf sample!widen_value
sample!widen_value:
00007ff6`815213f0 4863c1 movsxd rax,ecx
00007ff6`815213f3 c3 ret
メモリーの1バイトが0xffなら、movzxはeax = 0x000000ff、movsxはeax = 0xffffffffとします。後者は32ビット符号付き整数としては-1ですが、x64でeaxへ書き込むためraxの上位32ビットは0になります。64ビットの-1が必要なら、64ビット宛先の符号拡張が必要です。
widen_valueのmovsxd rax,ecxは32ビットから64ビットへの符号拡張です。長さや添字の調査では、値が負として扱われたのか、大きな正数へ変わったのかを確認するとよいでしょう。
ARM64のロードとストア
ARM64では、通常、メモリーとの転送をldr・strなどで行い、演算はレジスター同士で行います。
0:000> uf sample!read_element
sample!read_element:
00007ff6`7bef43c0 b8615800 ldr w0,[x0,w1 uxtw #2]
00007ff6`7bef43c4 d65f03c0 ret
0:000> uf sample!write_value
sample!write_value:
00007ff6`7bef4480 b9000001 str w1,[x0]
00007ff6`7bef4484 d65f03c0 ret
0:000> uf sample!element_address
sample!element_address:
00007ff6`7bef40c0 8b214800 add x0,x0,w1,uxtw #2
00007ff6`7bef40c4 d65f03c0 ret
同じC++ソースでも、ロードがldr、ストアがstr、アドレス計算がaddになっています。上のuxtw #2は、32ビットの添字をゼロ拡張して4倍する指定です。基本形式の読み方は次のとおりです。
| 命令 | 動作 |
|---|---|
ldr w0,[x1,#8] | x1 + 8から4バイトをw0へ読み取る |
str w0,[x1,#8] | w0の4バイトをx1 + 8へ書き込む |
ldr x0,[x1,#8] | x1 + 8から8バイトをx0へ読み取る |
add x0,x1,#8 | アドレス計算だけを行い、メモリーは読まない |
ldrb・ldrhはそれぞれ1バイト・2バイトのゼロ拡張、ldrsb・ldrshは符号拡張付きロードです。ldrsw x0,[x1]は4バイトを読み取り、64ビットへ符号拡張します。
配列アクセスでは、ldr w0,[x1,x2,lsl #2]のように添字を4倍して加算できます。この例の実効アドレスはx1 + x2 * 4です。
ベースレジスターも更新する形式
実際のベース更新は、スタック操作と関数呼び出しのcall_pairの出力でも確認できます。
str lr,[sp,#-0x10]!はsp - 16へLRを保存し、spもその値へ更新するpre-index形式です。ldr lr,[sp],#0x10は現在のspからLRを復元し、その後spを16増やすpost-index形式です。
単純な[x1,#8]ではx1は変わりません。ループやスタック操作では、転送だけでなくベース更新を追うことが大切です。
NULL参照か、解放済みメモリーか
mov eax,[rcx+8]でrcx = 0、またはldr w0,[x1,#8]でx1 = 0なら、NULLを基準にした参照が疑われます。一方、0以外のアドレスだからといって有効なポインターとは限りません。
解放済み領域でも読み取りが成功することがあります。また、ダンプに含まれないメモリーは、実行時に存在していてもデバッガーから読めません。アクセス命令、例外レコードのアクセス種別とアドレス、メモリー領域の状態を照合し、必要なら再現環境でPageHeapやApplication Verifierなどを使用して破壊の起点を調べます。