2012/06/08 第17回 IT基礎技術勉強会 メモ ■ 第1部 まずはアセンブラに慣れる cross-20120226.zip のFreeBSD/i386の環境で実行バイナリを コンパイル・逆アセンブルして解析してみる. sample/Makefile の TARGETS を i386-elf のみにして,gcc や objdump の 呼び出しをネイティブのものにすると make でビルドできるが,以下のようにして 直接ビルドしてもよい. % gcc -static -O -Wall -fomit-frame-pointer -o sample sample.c 関数呼び出しかた,引数の渡しかた,戻り値の返しかたを見る. ・引数はスタック渡し ・戻り値はEAX渡し ・関数呼び出しはcall文 ・スタックポインタはESPレジスタ exec/Makefileのほうも同様に修正することで,バイナリ実行できる. execは以下でビルドできる. gcc -DARCH=\"i386-elf\" -fno-builtin -nostdinc -nostdlib -static -O -Wall \ sample.c lib-i386-elf.S -o sample -T ld.scr ■ 第2部 printf()→writeシステムコールの呼び出しを追う hello.cを作成 #include int main() { printf("Hello World!\n"); } ビルド % gcc hello.c -o hello 実行して確認 そのまま逆アセンブル % objdump -d hello write() や int 0x80 呼び出しを探すが見つからない. ダイナミックリンクされているため -static をつけてビルドし逆アセンブル int 0x80 呼び出しがいくつか出てくるが,どれが本命かわからず -g をつけてビルドしgdbで追う. layout asm mainでブレーク,stepiやnextiで実行を進めてどの関数で hello world が 表示されるか確認する,というのを繰り返して hello world が出力されるまでの 関数呼び出しの流れを追いかける.(原始的な方法で追いかける) 以下のようになる. 最適化の影響で,callでなくjmpで関数呼び出ししている部分が数箇所あるのに注意. (関数末尾の関数呼び出しは,jmpに置き換えられる) main() → puts() → __sfvwrite() → __fflush() → __sflush() → _swrite() → __swrite() → write() 逆アセンブラのwrite()を見る.write()という関数は無いが,gdbに表示されている アドレスから探すと,__sys_write()というのがあってそれみたいだ. readelfの出力で write() と __sys_write() を確認. write() は __sys_write() の weak シンボルになっているようだ. なので,__sys_write() を見ればよい. ホントにこれが本命の場所なのか確認する. バイナリエディタで int 0x80 の呼び出しをnop(0x90)に書き換えて実行. → 何も出力されなくなることを確認. __sys_write() のアセンブラをよく読んでみる. EAXに「4」を代入して int 0x80 を呼んでいる. ということはこれはシステムコール呼び出しのライブラリ関数. 他のシステムコールと比較.__sys_* という名前で検索してみる. 似たような感じで,EAXに固定値を設定して int 0x80 を呼んでいる. int 0x80 呼び出し時には引数をスタックに積むようなことは行っていない. → write()呼び出し時に渡された引数がそのままスタック経由でカーネルに渡される. ■ 第3部 カーネルの処理を追う ・FreeBSDカーネル内部でのシステムコール処理を見る. 割り込み処理はアーキ依存なので,/usr/src/sys/i386/i386 を見る. /usr/src/sys/i386/i386 で grep interrupt とか grep trap とか exception.s ってのがやたらひっかかるので,見てみると int0x80_syscall っていうのが見つかる.コメントを見ると, これがシステムコールの入口みたい. syscall という関数を呼んでいる.grepで探すと trap.c にあるみたい. syscall() を見てみる.syscallenter(),syscallret()を呼んでいる. syscallenter()は /usr/src/sys/kern/subr_syscall.c にあるみたい.見てみる. p->p_sysent->sv_fetch_syscall_args()というのを呼んでいる. システムコール引数の処理のようだ. その後,sa->callp->sy_call()を呼んでいる.これがシステムコール処理の本体の 呼び出しのように思える. エラーは td->td_errno に格納されるみたい. で,最後にp->p_sysent->sv_set_syscall_retval()を呼んでいる. まとめると以下のような感じ. int0x80_syscall → syscall() → syscallenter() → p->p_sysent->sv_fetch_syscall_args() → sa->callp->sy_call() → p->p_sysent->sv_set_syscall_retval() → syscallret() sv_fetch_syscall_args()の本体を探す.おそらくアーキごとの関数になっていると 思われるので,i386以下で似たような名前のものを探すと,以下が grep で ひっかかる. elf_machdep.c: .sv_fetch_syscall_args = cpu_fetch_syscall_args, cpu_fetch_syscall_args を探すと,trap.cにある.中身を見てみる. システムコールのコード番号をEAXからコピーしている. copyin()でスタックから引数をコピーしている. コード番号は sa->code に格納される. 引数は sa->args[] に格納されている. 似たようなのが無いか探してみる.linux/linux_sysvec.c に linux_fetch_syscall_args() というのがある.おそらくlinuxエミュレーションの ための処理.見てみると,こちらはレジスタから引数を持ってきている. システムコール処理の本体はどれか? 呼び出しは「sa->callp->sy_call()」のようになっているので, callpの設定を探すと,cpu_fetch_syscall_args()で以下のようにして設定している. sa->callp = &p->p_sysent->sv_table[sa->code]; sv_table[]がシステムコール番号をインデックスにする配列になっているみたい. sv_tableの設定を探す.grep で調べると,elf_machdep.c でsysentに設定されている. struct sysentvec elf32_freebsd_sysvec = { ... .sv_table = sysent, sysent を探すと,kern/init_sysent.c で以下のように定義されている. struct sysent sysent[] = { ... { AS(write_args), (sy_call_t *)write, AUE_NULL, NULL, 0, 0, 0 }, /* 4 = write */ ... つまり write() が呼ばれている.grepで探すと,以下みたい. kern/sys_generic.c:write() この呼び出しを追うと, エラーは関数の戻り値として EBADF とかの値をそのまま(正の値で)返している. 戻り値は td->td_retval[0] で返されている. ちなみにlinux用エントリは i386/linux/linux_sysent.c で定義されている. sv_fetch_syscall_args()と同様にして sv_set_syscall_retval() の本体を探す. vm_machdep.c に cpu_set_syscall_retval() がある.おそらくこれだろう. 戻り値は td->td_retval[0] に格納されている.正常終了の際にはそれをEAXに 格納して返している. ということはシステムコール呼び出し関数(write())は, mov $0x4,%eax int $0x80 ret ということだけやれば,引数は渡されたままにカーネルが解釈し,戻り値は そのまま関数の戻り値として返される,ということだ. エラーの際には EAX にエラー番号(errnoに相当)をそのまま(正の値のまま) 代入している. (sa->callp->sy_call()の戻り値として返されたエラー番号が, そのまま p->p_sysent->sv_set_syscall_retval() に渡されている. cpu_set_syscall_retval()ではそれをそのまま EAXに格納している) ということは,エラーの場合には errno に相当する値は EAX に格納されて 返るということだ.そしてそれは,EBADFとかの(man errno で出てくる) 正の値になっている. どうやって正常終了時の戻り値とエラー番号を区別するのか? errno の処理は? よく見ると,エラーの場合にはEFLAGSのPSL_Cを立てているみたい. これで区別している? ■ 第4部 後処理を見る システムコール呼び出し後のerrnoの処理を見る. helloの処理をもう一度見てみる. __sys_write では int 0x80 の呼び出し直後に jb で条件分岐している. おそらくEFLAGSのPSL_Cフラグ(名前的に,キャリフラグ)を見ている. 806121f: 72 ef jb 8061210 <__sys_close+0xc> ジャンプしない場合はそのままretで返っているので,このジャンプがエラーの 場合の処理のようだ.__sys_close+0xc の位置のコードを見てみると, __sys_write の終端と同じ(最適化処理でまとまってしまっている?)になっていて, .cerror という関数を呼んでいる. jmp 8061464 <.cerror> .cerror を見てみる.__error_unthreaded()という関数を呼んで, EAXに-1を設定している.つまりここで戻り値-1が設定される. さらに __error_unthreaded() を見てみると,0x807bd90 という位置に EAXの値(エラー番号)を書き込んでいる. 8061481: b8 90 bd 07 08 mov $0x807bd90,%eax readelfで 0x807bd90 を探すと,errno が配置されている.つまりerrnoにエラー番号 を設定して,戻り値は-1を返している.C言語風に書くと,以下のようなことを やっているわけだ. ret = write(...); if (エラーが返ってきた) { errno = ret; ret = -1; } return ret; ■ 第5部 Linuxカーネルとの比較 ・Linuxカーネルではどうなのか見てみて比較してみる. ・Linuxカーネルでは,各システムコール処理関数(do_write()とか)がエラーを -errno で返している.(つまり,負の値で返す) 以下のようになっている. システムコールの引数 戻り値の返しかた エラーの返しかた FreeBSD スタック渡し EAXで返す EFLAGSのCフラグで返す Linux レジスタ渡し EAXで返す -errnoを返す 実際にエラーを発生させるようなサンプル(不明ファイルでopen()とか)を open()でなくint 0x80を直接呼ぶようなかんじで作成し,戻り値を見てみる. FreeBSDとLinuxで実行させてみて比較する. 以下のようなかんじ. #include void __open(char *fname, int flags, int mode) { asm volatile ("mov $0x5,%eax"); /* open() */ asm volatile ("int $0x80"); } typedef int (*func_t)(char *fname, int flags, int mode); int main() { func_t f; int ret; f = (func_t)__open; ret = f("aaa", 0, 0); printf("%d\n", ret); return 0; } linuxの場合 arch/x86/kernel で grep syscall とかしてみる. entry_32.S というのがあってそれっぽい. 先頭になんかシステムコールの引数の渡しかたっぽいコメントがある. 上から見てみると,system_call というエントリがあってそれっぽい. grep system_call してみると,traps.c で割り込みベクタ登録しているみたいで, これがシステムコールの入口のようだ. traps.c: set_system_trap_gate(SYSCALL_VECTOR, &system_call); system_call を読んでみる. SAVE_ALL というマクロがある.実体は traps.c にあって,レジスタの値を スタックにpushしている.どうやら引数はレジスタ渡しで,それをスタックに コピーしているようだ. syscall_call: call *sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) # store the return value EAXで渡された値をインデックスにして sys_call_table[] に登録されている関数を 呼んでいるみたい.さらに戻り値をスタック上のEAXに相当する位置にコピーして いるようだ.(たぶん,アプリに処理が戻るときにEAXに値がコピーされて戻り値と なるのだろう.SAVE_ALL 付近に RESTORE_* みたいなマクロがいくつか定義されて いて,そんなようなこと(SAVE_ALLと反対のこと)をやっているような気がする) sys_call_table[] を探すと arch/x86/kernel/syscall_32.c にあって, 以下のようになっている. const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = { /* * Smells like a compiler bug -- it doesn't work * when the & below is removed. */ [0 ... __NR_syscall_max] = &sys_ni_syscall, #include }; sys_ni_syscall()というのは kernel/sys_ni.c で以下のように定義されている. niは Non-implemented のことみたい.つまり未実装. asmlinkage long sys_ni_syscall(void) { return -ENOSYS; } さらに syscalls_32.h というのをインクルードしている.syscalls_32.h を探すと, arch/x86/um/shared/sysdep/syscalls_32.h というのがあるが見てみてもよくわからんし,umの下は違う気がする. どうも無効なエントリで初期化しているので,正式なエントリはどこかで上書き して設定しなおしているような気がするのだが, syscalls_32.h が存在しないのが気になる.(おそらくumの下のやつは違う) どうも自動生成しているような気がする. システムコールのエントリは arch/x86/syscalls/syscall_32.tbl にあるようだ. ここからテーブルを自動生成しているのではなかろうか. arch/x86/syscalls/Makefile を見てみると,syscall_32.tbl から syscalls_32.h を 生成しているような部分がある.ここで生成された syscalls_32.h が sys_call_table[]の定義に #include で取り込まれるのではなかろうか. syscall32 := $(srctree)/$(src)/syscall_32.tbl ... $(out)/syscalls_32.h: $(syscall32) $(systbl) $(call if_changed,systbl) arch/x86/syscalls には syscalltbl.sh というシェルスクリプトがあるから, これで自動生成するのではなかろうか. 実際に以下のように実行してみたら,a.txt に一覧が生成された. /bin/sh syscalltbl.sh syscall_32.tbl a.txt どうやらこれのようだ. ということで a.txt を見てみる.writeシステムコールのエントリは以下のように なっている. __SYSCALL_I386(4, sys_write, sys_write) __SYSCALL_I386() は kernel/syscall_32.c で以下のように定義されている. #define __SYSCALL_I386(nr, sym, compat) extern asmlinkage void sym(void) ; #include #undef __SYSCALL_I386 #define __SYSCALL_I386(nr, sym, compat) [nr] = sym, ... const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = { ... #include }; つまりまず単なる関数のextern宣言になり,さらに配列の要素の定義に #define しなおしているわけだ. よって write システムコールでは,sys_write() がそのまま呼ばれるようだ. こーいう動的エントリ生成は,とてもLinuxらしい感じがする.(読みにくくて困るけど) ということで sys_open() とか sys_write() とかが呼ばれるようなのだが, これらを grep で探しても,これまた見つからない. それっぽいところをあたりをつけて読んでみる. ls */*open* とか ls */*write* とかやると, fs/open.c とか fs/read_write.c というのがあるみたい. fs/open.c の中身を見てみる.sysとかで検索すると,以下のような部分がある. long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode) { ... } SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode) { ... ret = do_sys_open(AT_FDCWD, filename, flags, mode); SYSCALL_DEFINE3()の定義を探す.include/linux/syscalls.h にあって, 以下のような定義になっている. #define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__) よくわからんがたぶんこのマクロで sys_open() とかが生成されて, sys_open() から do_sys_open() を呼び出すような処理になっているのでは なかろうか. fs/read_write.c のほうもそんなかんじになっている.ということでシステムコールの 本体処理が見たければ,SYSCALL_DEFINE で grep すればよさそうだ. 実際の処理を追ってみる.read/writeからはvfs_read()/vfs_write()が呼ばれていて, その戻り値がそのまま返されている.で,vfs_read()/vfs_write()を見てみると, return -EBADF; return -EINVAL; みたいな感じでエラー時には -errno を返している. entry_32.S でのシステムコールの入口処理では,戻り値をそのままEAXとして 返していた.ということはLinuxでは,エラー時には -errno を返すことになる. つまりLinuxのシステムコール呼び出しは,以下のように書くべき. int write(int fd, char *buf, int size) { int ret = int0x80(4, ...); if (ret < 0) { errno = -ret; ret = -1; } return ret; } FreeBSDとLinuxのシステムコール処理の比較 ・FreeBSDのシステムコール処理はアーキ依存部と共通処理が分離されて 共通処理は別にまとめられていた. Linuxはそのようなことは無く,処理関数をいきなりベタに呼び出している. なんか FreeBSD のほうがきれいに整理されているように思える. ・FreeBSDはx86の関数呼び出しと同じ引数フォーマット(引数はスタックに積む) でシステムコールを呼ぶ.Linuxは関数呼び出しとは異なり,レジスタに引数を コピーしてから int 0x80 を呼び出す必要がある.つまりシステムコール呼び出し 関数では int 0x80 を実行するだけではダメで,引数の準備をしてから int 0x80 を実行する必要がある.たとえば以下のような感じか. write: mov 12(%esp), %edx mov 8(%esp), %ecx mov 4(%esp), %ebx mov 4, %eax int 0x80 ret (実際にはレジスタ値の退避/復元も必要かも.でないとレジスタ値が破壊される) これはLinuxは効率が悪いように思えるが,そうとも言えない.FreeBSDは スタックで引数を渡すため,アプリの仮想メモリ上に引数があることになり, カーネル内部ではcopyin()で引数を取得している.対してLinuxはレジスタで 渡されているのでそのままレジスタ値をコピーするだけでよく,これはFreeBSDに 比べて処理が軽いように思える. あえて言うなら,FreeBSDはユーザランドにやさしい感じで,あとユーザランド側に アーキテクチャの違いをあまり見せないようにしている. (システムコール命令を呼ぶだけでいい) Linuxはカーネル側にやさしくしてあるというか,そのぶんアプリ側はカーネルに 合わせてね,ってなポリシーのように思える.(レジスタの設定など,アーキ依存の 部分がアプリ側に多く出てしまう) ■ 第6部 FreeBSD/Linuxで動作するバイカーネルなバイナリの作成 きっと時間が無いので cross-20120226.zip を元にして実施. ・スタックとレジスタの両方に引数を格納して,int 0x80 を呼び出す ・エラーは以下の手順で判断する. 1. キャリフラグが落ちているなら,そのまま終了 2. 戻り値が負の値なら,そのまま終了 3. 1,2に当てはまらない場合は,戻り値を反転して返す このようにすれば,エラーの場合は -errno の値が返るので, システムコール呼び出しライブラリ側で以下のように処理すればよい. void __write(int fd, char *buf, int size) { asm volatile ("mov $0x4,%eax"); /* open() */ asm volatile ("int $0x80"); } typedef int (*func_t)(char *fname, int flags, int mode); int write(int fd, char *buf, int size) { func_t f = (func_t)__write; ret = f(fd, buf, size); if (ret < 0) { errno = -ret; ret = -1; } return ret; } ・FreeBSDのLinuxエミュレーションに注意. ・以下がすべて正常に実行できることを確認済み. FreeBSDでビルドしたバイナリ Linuxでビルドしたバイナリ ------------------------------------------------------------------------------ FreeBSDでネイティブ実行 ○ ○ FreeBSDのLinuxエミュレーションで実行 ○ ○ Linuxでネイティブ実行 ○ ○ ・LinuxでビルドしたものをFreeBSDで実行するには,ELFヘッダの書き換えが 必要だったりする. ※ FreeBSDのLinuxエミュレーションでは,終了処理は非エミュレーション時と 同様の関数 cpu_set_syscall_retval() が共通で呼ばれるが,その中の 以下の箇所でエラー番号が linux 互換のものにトランスレートされる. if (td->td_proc->p_sysent->sv_errsize) { if (error >= td->td_proc->p_sysent->sv_errsize) error = -1; /* XXX */ else error = td->td_proc->p_sysent->sv_errtbl[error]; } 上記 sv_errtbl の本体は bsd_to_linux_errno[] という配列で, この際に,戻り値が負となって設定される.(つまりここでエラー時戻り値の 正負変換が行われる) 引数の変換もどっかで行われているような気がするが,未調査. おしまい