読者です 読者をやめる 読者になる 読者になる

わらばんし仄聞記

南の国で引きこもってるWeb屋さん

原書で学ぶ64bitアセンブラ入門(4)

続いて9章。
これまたついこの前の原書で学ぶ64bitアセンブラ入門(4)でやった内容になります。
この章は関数呼び出しの挙動やスタックフレームなんかを扱います。

chapter9 Functions

The stack

まずはスタックについて。
ちょっとまだエントリには書いていない、以前やった3章ではスタックのアドレスは最高値が0x7fffffffffffになるとの記述があったんですが、この章でいきなりこの件をちゃぶ台返しして、「いやすまん。アレは嘘だ」と述べてくれます。

んじゃ実際どうなってるのよ?ということで、適当にターミナルを開いてシェルのプロセスの様子を見てみます。

$ cat /proc/$$/maps
00400000-004d4000 r-xp 00000000 fd:00 354                                /bin/bash
006d3000-006dd000 rw-p 000d3000 fd:00 354                                /bin/bas

.....(中略)

7fff5b107000-7fff5b11c000 rw-p 00000000 00:00 0                          [stack]
7fff5b1ff000-7fff5b200000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

という具合に、プロセスがどのようにメモリ上へマップされているかを見てみると、最後の方に [stack] なんて書かれてますね。見ての通り、この出力結果からするとstackの最高値は7fff5b11c000です。
ちなみにこの値、プロセス毎に異なってはいるものの、常に7fffXXXXX000というパターンになってます。何でこんな事になってるかについての正確な理由については述べられてないんですが、著者曰く、いつも決まったアドレスから始まっていると、悪意あるコードがスタックの保持している値を書き換えるのが容易になってしまうので、それを防ぐための"stack randomization"によるものだろう。とのことです。

あとはstackへのpushとpopは普通、8バイト単位で行われるべきであると。それより小さいバイト数でのpushも可能だが、それをやるとデータの境界が曖昧になってしまうので面倒なことになるからやめておいた方がイイよと記されています。

Call instraction

関数呼び出しのcall命令について。
call命令の実行方法は

call    my_function

てな具合。
さて、このcall命令は一体何をしてくれてるのか?要はmy_fucntionラベルのある位置へインストラクションポインタを書き換えて、callでの呼び出しが終わった後に処理が返ってくるべきアドレス(return address)をスタックへpushしてくれます。

ちなみにこの場合、関数へ処理が移った直後のスタックを見れば呼び出し元の命令がどの辺りのアドレスに位置していたのかがわかることになりますね。というわけで、main関数を呼び出す__libc_start_mainがどこにあるのかをmain実行直後のスタックの様子から探る事ができます。
この時の値を見ると、アドレスはtext領域上ではなく、スタック上の変な位置を指していることがわかります。

f:id:warabanshi21:20140417003656p:plain

これまた、悪意あるコードからの防御策としてこうなってるんじゃないかとのこと。

Return instruction

続いて関数から呼び出し元へ戻るときに使われる、ret命令について。
ret命令が何をしてくれるかというと、スタックのトップにある値をアドレスとしてポップし、制御をそのアドレスへ移すといったことをしてくれます。

というわけで、こんなコードについて考えてみます。

本書ではebeという著者自作のデバッグツールを使ってますが、ここでは一般的なものを使おうということでgdbにしておきます。
手元の環境がCentOSのminimal-installだったのでデバッグ用環境が入ってなかった為、そこら辺の設定もついでにメモ。

# yum -y install nasm gdb yum-utils
# vi /etc/yum.repos.d/CentOS-Debuginfo.repo
  -> enable=0 を enable=1 に編集
# dubuginfo-install glibc

こんな感じで環境作成はOK。では挙動を見ていきましょう。
まずはデバッグ情報を付けてのコンパイル

$ nasm -f elf64 -g IALPL-9.3.s
$ gcc -g -o IALPL-9.3 IALPL-9.3.o
$ gdb IALPL-9.3

gdbでの操作内容はこんな感じに。

(gdb) b main
Breakpoint 1 at 0x400486: file IALPL-9.3.s, line 7.
(gdb) r
Starting program: /home/warabanshi/sandbox/IALPL-9.3 

Breakpoint 1, main () at IALPL-9.3.s:7
7       main:   call    doit
(gdb) p $rip
$1 = (void (*)()) 0x400486 <main>
(gdb) step
doit () at IALPL-9.3.s:4
4       doit:   mov     eax, 1
(gdb) p $rip
$2 = (void (*)()) 0x400480 <doit>
(gdb) info stack
#0  doit () at IALPL-9.3.s:4
#1  0x000000000040048b in main () at IALPL-9.3.s:7
(gdb) step
doit () at IALPL-9.3.s:5
5               ret
(gdb) step
main () at IALPL-9.3.s:8
8               xor     eax, eax
(gdb) p $rip
$3 = (void (*)()) 0x40048b <main+5>

mainの最初にbreakpointを仕込み、一旦そこで停止。
ここで次に実行される命令は

call  doit

であり、まだこの命令は実行されていない為、ripが示す0x400486はこのcall命令がある場所である。
次に、step inしてcallされた先へ飛ぶと、処理はdoitの1行目

mov  eax, 1

へ移る。この時点でのripは0x400480と、先ほどより6byte少ない所を指している。元々のコードの通り、mainよりdoitの方が前にあるという構造がここから見て取れる。
更にstackの内容を見ると、0x40048bというアドレスがあるが、これがdoit関数が終わったときに戻るべき位置。つまり、call命令から返ってきた後、次に実行されるべき命令のあるアドレスを指している。

このようにスタックを利用して関数の挙動が実装されている事がわかる。

Function parameter and return value

関数呼び出しの時のパラメーターについてです。

x86-64 LinuxではSystem V ABIに従う為、パラーメーターの引き渡しには基本的に以下のレジスタを使います。

  • 関数呼び出し時
整数パラメーター rdi, rsi, rdx, rcx, r8, r9
浮動小数点数パラメーター xmm0 - xmm7
浮動小数点数パラメーター数 rax
上記レジスタを超過したパラメーター スタックへ逆順に積まれる
  • 関数からの戻り時
関数の返値(整数) rax
関数の返値(浮動小数点数 xmm0

スタックへ積まれる場合は、たとえば

printf("%d%d%d%d%d%d%d%d%d", a, b, c, d, e, f, g, h, i)

という関数呼び出しがあったとすると

rdi a
rsi b
rdx c
rcx d
r8 e
r9 f

と、レジスタに入る分はそれぞれ割り当てられ、超過している3要素についてはスタックへ

|          |
|----------|
|    g     |
|----------|
|    h     |
|----------|
|    i     |
------------

といった感じで格納される。レジスタの後の要素はpopしていけば順に取得できるような並びになっている。

Stack frames

ここまでで何度か出てきた、関数が始まる時の記述

push     rbp
mov      rbp, rsp

これと共に、関数呼び出し時にはcall実行によってスタックへ既に戻り先アドレスが入っている為、この処理の完了時点ではスタックは下図のようになっている。

f:id:warabanshi21:20140417021544p:plain

スタックに入っているrbxの値はこの関数が呼ばれた直後に保持していた値であり、これらの2命令が終わった時にはrbpに現在のrspのアドレスが入っている。
さて、この関数内から更に別の関数(同じのでもいいが)が呼ばれた場合にはまた下図のようになる。

f:id:warabanshi21:20140417022540p:plain

戻り先アドレスがスタックへ積まれ、その上にrbxがpushされる。ここでpushされるrbxの値は、先の状態でのrbx(rbx1)がある位置のアドレス(前にmov rbp, rspで保持していた、以前のrspの値)である。つまり、pushされるrbxは代々、一つ前の関数へのリンクを繋げる役割を果たしている。

というのがスタックフレームの簡単な例であるが、他にもスタックには関数内のローカル変数が入れられたりする。
スタックフレームへローカル変数用の領域を設ける場合は16の倍数バイトを確保する様にし、

push     rbp
mov      rbp, rsp
sub      rsp, 32

という感じで確保する。図にするとこんな感じ。

f:id:warabanshi21:20140417024126p:plain

この後に関数が呼ばれると、これらのローカル変数の上に次の戻り先アドレスとrbxの値がpushされることになる。なお、rbpにはローカル変数用にrspを減算する前のrspの位置をmovしているため、関数呼び出し間でのrbpによるリンクへ影響は無い。