はじめに
この記事は低レイヤーについてほとんど知識がない人間がアセンブリ言語を学んでいく際のメモ書きです。学習の際には「低レベルプログラミング」という本を参考書にしています。今回は前回の続きとして、より複雑なアセンブリプログラムを書いてみて、その挙動をgdbで確認するということをやってみようと思います。
目次
- gdbによるデバッグ
- 起動と設定
- 実際にデバッグしてみる
- ブレークポイント
- レイアウトを変更する
- ステップインする
- メモリの内容を確認する
- レジスタの中身を出力するプログラム
-
lea
命令 -
sub
命令・add
命令 -
push
命令・pop
命令 - レジスタの中身を出力するプログラム
-
gdbによるデバッグ
gdbはバイナリを逆アセンブリしたり、ブレークポイントを置いてデバッグすることができるツールである。
今後、アセンブリした結果の挙動確認やデバッグに使用する際に便利そうなので、簡単な操作だけ学んでみた。
起動と設定
起動は
gdb デバッグしたいバイナリファイル
で行える。
gdb
で起動して、後でバイナリファイルを指定することもできる。
起動するとこのような画面になる。
起動後、バイナリファイルを指定する場合は
file デバッグしたいバイナリファイル
とする。
デフォルトではアセンブリ言語の表示がAT&T表記となっているが、ここでは参考図書にあわせるためIntel表記で表示させるように設定する。
set disassembly-flavor intel
私は毎回設定するのが面倒なので、~/.gdbinit
に上記の内容を保存している。
実際にデバッグしてみる
はじめに前回書いた以下のコードをgdbでデバッグしてみる。
使用するバイナリファイルは以下のアセンブリプログラムをアセンブリしたものである。
section .data
message: db 'hello, world', 10
section .text
global _start
_start:
mov rax, 1 ; システムコールの番号をraxに入れる
mov rdi, 1 ; 1は書き込み先(descriptor)
mov rsi, message ; messageは文字の先頭
mov rdx, 14 ; 14は書き込むバイト数
syscall ; システムコールの呼び出し
mov rax, 60 ; 60は'exit'のsyscall番号
xor rdi, rdi
syscall
ブレークポイント
まずは、ブレークポイントを置いてみる。
ブレークポイントはラベルに対して置くことができるので、今回は
break _start
で_start
ラベルにブレークポイントを置く。
すると
のような出力結果になる。
次にデバッグをスタートさせる。
start
すると以下のように聞かれるが、n
と答える。
Function "main" not defined.
Make breakpoint pending on future shared library load? (y or [n]) n
スタートすると以下のようにブレークポイントで止まる。
レイアウトを変更する
次に、レジスタの内容とアセンブリコードを表示させるためにレイアウトを変更する。
layout asm
layout regs
とすると、以下のような画面になる。
ステップインする
では、コードをステップインしてデバッグしてみる。
ステップインする方法は
si
でできる。
ステップインしていくと、レジスタの内容が随時更新されていって、実際にどのような値が入っていっているのかが確認できる。
メモリの内容を確認する
今回取り上げたhello, worldを出力するプログラムではwrite
システムコールを実行する際にレジスタrsi
にhello, worldが格納されたmessage
のアドレスが代入されている。
そこで、実際にrsi
に格納されたアドレスのメモリにhello, worldという文字列が格納されているのか確認してみる。
メモリを確認するにはx /a
というコマンドを用いる。また、文字列として表示させるのとhello, world\nの13字を表示させるために
x /13ac rsiに格納されたアドレス
を実行する。私の環境ではrsi
に格納されたアドレスは0x402000であったので
x /13bc 0x402000
を実行した。これは1バイトごとに文字列として0x402000
のアドレスに入っている内容を表示するコマンドである。
すると以下のように表示され、ちゃんとhello, worldが入っていることがわかる。
レジスタの中身を出力するプログラム
rax
レジスタの値を16進数として出力するプログラムを作成する。
コードは以下の通りになる。
section .data
codes:
db '0123456789ABCDEF'
section .text
global _start
_start:
mov rax, 0x1122334455667788
mov rdi, 1
mov rdx, 1
mov rcx, 64
; 4bitを16進数の1桁として出力していくために、
; シフト論理和(AND)によって1桁のデータを得る
; その結果は'codes'配列へのオフセットである。
.loop:
push rax
sub rcx, 4
; clはレジスタ(rcxの最下位byte)
; rax > eax > ax = (ah + al)
; rcx > ecx > cx = (ch + cl)
sar rax, cl
and rax, 0xf
lea rsi, [codes + rax]
mov rax, 1
; syscallでrcxとr11が変更される
push rcx
syscall
pop rcx
pop rax
; testは最速の'ゼロか?'チェックに使える
; マニュアルで'test'コマンドを参照
test rcx, rcx
jnz .loop
; exitシステムコール
mov rax, 60
xor rdi, rdi
syscall
これを実行する前に、新しく出てきた命令としてsub
やsar
、lea
、test
、pop
、push
命令の挙動を確認する。
順番が前後するが、まずlea
命令について確認してみる。
lea
命令
lea
命令はmov
命令とadd
命令を足し合わせた命令である。また、lea
命令には第二引数に[]
記号が使われている。lea
命令の挙動と[]
にどのような意味があるのかを確認するために以下のプログラムで実行してみる。
section .data
codes:
db '0123456789ABCDEF'
section .text
global _start
_start:
; rsi <- ラベル'codes'のアドレス(数値)
mov rsi, codes ; 1⃣
; rsi <- 'codes'というアドレスから始まるメモリの内容
; rsiは8バイト長なので連続する8バイトが取られる
mov rsi, [codes] ; 2⃣
; rsi <- ラベル'codes'のアドレス
lea rsi, [codes] ; 3⃣
; rsi <- (codes+rax)から始まるメモリの内容
mov rax, 0x8
mov rsi, [codes + rax] ; 4⃣
; rsi <- codes+rax
; これは
; mov rsi, codes
; add rsi, rax
; と同じ意味
lea rsi, [codes + rax] ; 5⃣
; exitシステムコール
mov rax, 60
xor rdi, rdi
syscall
はじめに1⃣の挙動をgdbを使用して確認してみる。
mov rsi, codes
をステップインしてみるとレジスタの状態は以下のようになる。
┌─Register group: general────────────────────────────────────────────────────────────────────┐
│rax 0x0 0 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x402000 4202496 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffd790 0x7fffffffd790 │
│r8 0x0 0 │
│r9 0x0 0 │
│r10 0x0 0 │
rsi
を見ると0x402000
が入っていることがわかる。
これは私の環境では0x402000
にcodes
の内容である0123456789ABCDEF
が入っているからである。
試しに
x /16cb &codes
を実行すると
(gdb) x /16cb &codes
0x402000: 48 '0' 49 '1' 50 '2' 51 '3' 52 '4' 53 '5' 54 '6' 55 '7'
0x402008: 56 '8' 57 '9' 65 'A' 66 'B' 67 'C' 68 'D' 69 'E' 70 'F'
となっていて、codes
は402000
と402008
にはいっていることがわかる。
次に更にステップインして、2⃣
mov rsi, [codes]
を実行してみる。
するとレジスタは
┌─Register group: general────────────────────────────────────────────────────────────────────┐
│rax 0x0 0 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x3736353433323130 3978425819141910832 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffd790 0x7fffffffd790 │
│r8 0x0 0 │
│r9 0x0 0 │
│r10 0x0 0 │
のようになる。
この0x3736353433323130
はcodes
の中身そのものである。
これを確認するために
x /xg &codes
を実行してcodes
の中身を8バイト16進数でみると
(gdb) x /xg &codes
0x402000: 0x3736353433323130
となっていて、rsi
にはcodes
の値が格納されていることがわかる。1つのレジスタは8バイト長なので、8バイト分のデータが入っている。
このようにmov
命令の際にシンボルに[]
をつけると、そのラベルの中身を扱うということがわかった。
対して、lea
命令での[]
記号はどのような意味があるのか見てみる。
3⃣をステップインすると、レジスタは
┌─Register group: general────────────────────────────────────────────────────────────────────┐
│rax 0x0 0 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x402000 4202496 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffd790 0x7fffffffd790 │
│r8 0x0 0 │
│r9 0x0 0 │
│r10 0x0 0 │
│r11 0x0 0 │
│r12 0x0 0 │
│r13 0x0 0 │
│r14 0x0 0 │
となった。これは1⃣のときの状態と同じである。
つまり、
mov rsi, codes
と
lea rsi, [codes]
は同じ挙動となり、[]
にはmov
命令のときにはあったラベルの中身の値を参照するという意味はなくなっている。
次に4⃣をステップインしてみる。
するとレジスタの状態は
┌─Register group: general────────────────────────────────────────────────────────────────────┐
│rax 0x8 8 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x4645444342413938 5063528411713059128 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffd790 0x7fffffffd790 │
│r8 0x0 0 │
│r9 0x0 0 │
│r10 0x0 0 │
│r11 0x0 0 │
│r12 0x0 0 │
│r13 0x0 0 │
│r14 0x0 0 │
となっていて、rsi
には0x4645444342413938
が入っていることがわかる。
この0x4645444342413938
は0x402008
のアドレスに入っている数値、すなわち89ABCDEF
である。
gdbで
x /xg 0x402008
を実行すると
(gdb) x /xg 0x402008
0x402008: 0x4645444342413938
x /8bc 0x402008
を実行すると
(gdb) x /8bc 0x402008
0x402008: 56 '8' 57 '9' 65 'A' 66 'B' 67 'C' 68 'D' 69 'E' 70 'F'
という結果が得られる。
最後に5⃣をステップインしてみると、レジスタの状態は
┌─Register group: general───────────────────────────────────────────────────────────┐
│rax 0x8 8 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x402008 4202504 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffe1f0 0x7fffffffe1f0 │
│r8 0x0 0 │
│r9 0x0 0 │
となる。
レジスタrsi
には0x402008
が入っており、codes
のアドレスにrax
に入っている値0x08
が加算された値が格納されていることがわかる。
これは
mov rsi, codes
add rsi, rax
に等しい。
sub
命令・add
命令
add
とsub
命令はそれぞれ加算と減算命令である。
サンプルとして以下のプログラムをデバッグしてみる。
section .text
global _start
_start:
mov rsi, 0x5
add rsi, 0x2
sub rsi, 0x4
mov rax, 60
xor rdi, rdi
syscall
gdbを用いてレジスタの様子を見ていくと
mov rsi, 0x5
ではもちろん以下のようにrsi
に5という値が入る。
┌─Register group: general───────────────────────────────────────────────────────────┐
│rax 0x0 0 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x5 5 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffe1f0 0x7fffffffe1f0 │
│r8 0x0 0 │
│r9 0x0 0 │
そして
add rsi, 0x2
をステップインするとレジスタは以下のようになり、rsi
に2が加算されていることがわかる。
┌─Register group: general───────────────────────────────────────────────────────────┐
│rax 0x0 0 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x7 7 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffe1f0 0x7fffffffe1f0 │
│r8 0x0 0 │
│r9 0x0 0 │
同じように、
sub rsi, 0x4
をステップインするとレジスタは以下のようになり、4が減算されていることがわかる。
┌─Register group: general───────────────────────────────────────────────────────────┐
│rax 0x0 0 │
│rbx 0x0 0 │
│rcx 0x0 0 │
│rdx 0x0 0 │
│rsi 0x3 3 │
│rdi 0x0 0 │
│rbp 0x0 0x0 │
│rsp 0x7fffffffe1f0 0x7fffffffe1f0 │
│r8 0x0 0 │
│r9 0x0 0 │
sar
命令, shr
命令
sar
命令とshr
命令はどちらも指定されたbitだけ右シフトする命令である。
違いとしては
-
sar
命令:算術右シフト -
shr
命令:論理右シフト
という、符号の考慮の有無がある。
具体的には
-
sar
命令:指定されたbitだけ右シフトし、最上位ビットが1であるときシフトした分だけ左を1で埋める -
shr
命令:指定されたbitだけ右シフトし、シフトした分だけ左を0で埋める
となる。
これを確かめるために、以下のプログラムを実行してみる。
section .text
global _start
_start:
mov rax, 0x8000000012345678
sar rax, 4
mov rax, 0x8000000012345678
shr rax, 4
mov rax, 60
xor rdi, rdi
syscall
sar rax, 4
をステップインするとrax
は0x8000000012345678
から0xf800000001234567
になる。
これはもとの値を右に4bitシフトすると右端の8
がなくなり0x800000001234567
になるわけだが、もとの値の最上位ビットが1(最上位バイトが8
つまり0b1000
)であるので、左端をシフトした4bit分1で埋める(0xf
が左につく)。
対して
shr rax, 4
をステップインすると、rax
は0x8000000012345678
から0xf800000001234567
になるだけである。
もとの値の最上位ビットが0のとき、(例えば上記プログラムでmov rax, 0x00000000012345678
としたとき)shr
とsar
の挙動は同じなる。
push
命令, pop
命令
push
とpop
命令は文字通りスタックにpushとpopを行う命令である。
一応、挙動を確認するために以下のプログラムを実行してみる。
section .text
global _start
_start:
mov rax, 0xffff0000ffff0000
push rax
pop rdi
mov rax, 60
xor rdi, rdi
syscall
これらをステップインして実行していってみると
push rax
を行った際には、レジスタの様子は以下のようになる。
┌─Register group: general───────────────────────────────────────────────────────────────────────────────────┐
│rax 0xffff0000ffff0000 -281470681808896 rbx 0x0 0 │
│rcx 0x0 0 rdx 0x0 0 │
│rsi 0x0 0 rdi 0x0 0 │
│rbp 0x0 0x0 rsp 0x7fffffffe1d8 0x7fffffffe1d8 │
│r8 0x0 0 r9 0x0 0 │
rsp
はスタックポインタで、rax
の内容を格納したメモリのアドレスが入っている。
(gdb) x /2x $rsp
でrsp
が指すメモリの中身を見てみると
0x7fffffffe1d8: 0xffff0000 0xffff0000
となってちゃんと格納されていることが確認できる。
pop rdi
をステップインするとレジスタは以下のようになり
┌─Register group: general───────────────────────────────────────────────────────────────────────────────────┐
│rax 0xffff0000ffff0000 -281470681808896 rbx 0x0 0 │
│rcx 0x0 0 rdx 0x0 0 │
│rsi 0x0 0 rdi 0xffff0000ffff0000 -281470681808896 │
│rbp 0x0 0x0 rsp 0x7fffffffe1e0 0x7fffffffe1e0 │
│r8 0x0 0 r9 0x0 0 │
ちゃんとrax
に入っていた値がrdi
に格納され、rsp
が指すアドレスも8byte少なくなっている事がわかる。
レジスタの中身を出力するプログラム
最後に最初に述べていた以下のプログラムを実行してみる。
section .data
codes:
db '0123456789ABCDEF'
section .text
global _start
_start:
mov rax, 0x1122334455667788
mov rdi, 1
mov rdx, 1
mov rcx, 64
; 4bitを16進数の1桁として出力していくために、
; シフト論理和(AND)によって1桁のデータを得る
; その結果は'codes'配列へのオフセットである。
.loop:
push rax
sub rcx, 4
; clはレジスタ(rcxの最下位byte)
; rax > eax > ax = (ah + al)
; rcx > ecx > cx = (ch + cl)
sar rax, cl
and rax, 0xf
lea rsi, [codes + rax]
mov rax, 1
; syscallでrcxとr11が変更される
push rcx
syscall
pop rcx
pop rax
; testは最速の'ゼロか?'チェックに使える
; マニュアルで'test'コマンドを参照
test rcx, rcx
jnz .loop
; exitシステムコール
mov rax, 60
xor rdi, rdi
syscall
実行結果は
1122334455667788
となり、rax
に入っている値が出力されていることがわかる。
まず、
mov rdi, 1
mov rdx, 1
mov rcx, 64
について
rdi
とrdx
はそれぞれwrite
システムコールを行う際のディスクリプタ(値は1であるので標準出力)と出力するデータの長さ(1文字)である。
そしてrcx
は出力したいrax
を右シフトしてマスキングするために使用する。例えばrcx
が40のとき、64(rax
のビット長)に対して40bit右シフトして、残りのの24bitが出力されるというような使い方である。
続いて
push rax
sub rcx, 4
sar rax, cl
and rax, 0xf
という部分について
push rax
はrax
の初期値を毎ループで使用するためにスタックにプッシュしている。こうすることでrax
が右シフトされても、また初期値を利用したいときにポップすることでrax
の値を初期値にすることができる。
sub rcx, 4
というのはrcx
をループごとに4ディグリーズさせる。rcx
の値の遷移は60, 56, 52, 48, ...
となっていく。rcx
はrax
の特定の部分を出力する際のマスキングに使用するので、ループごとにマスキングされる部分が4bitずつ増えるイメージである。
そしてsar rax, cl
で実際に右シフトしてrax
をマスキングしている。cl
はrcx
の下位8bitである(ループで使用されるrcx
の値は0~60なので8bitで間に合う)。
最後にand rax, 0xf
という部分はrax
の下位4bitを抽出している。0xf
は0b0000000000001111
なのでand
を取ると下位4bitを取ってこられる。
続いて
lea rsi, [codes + rax]
という部分について、rax
にはrcx
で指定した部分(下位{64-rcx
}bitのうち下位4bit)が入っている訳だが、.data
セクションで定義したcodes
を用いることで、その4bit分の値をcharに変換している。
例えば4bitの値が7
である場合、[codes+7]
でcodes
の8文字目が参照され、char型の7がrsi
に格納される。
続いて
mov rax, 1
push rcx
syscall
pop rcx
という部分では
mov rax, 1
でwrite
システムコール番号を指定して、syscall
でwrite
システムコールを実行している。
ここで、write
システムコールは実行される際にrcx
を利用するため、push rcx
とpop rcx
でrcx
の値をスタックに退避させている。
最後に
pop rax
test rcx, rcx
jnz .loop
の部分では
pop rax
でrax
を初期値に戻し、test rcx, rcx
及びjnz .loop
でrcx
が0になっていなければ.loop
に戻るということを行っている。