【はじめに】
この記事の内容はLinux/x86を対象にしており、他のCPUやOSでは異なる可能性がある。
OSのシステムコールレベルの関数は「write」「read」といった風にカッコを付けずに表すが、C言語のシステムコールラッパーである関数には「write()」「read()」といった風にカッコを付けて表す。
HelloWorld本の著者の坂井さんは、用語についてかなり気を使って書かれているので、この記事でも注意していく。
【1章】
この書籍で解析していくHelloWorldプログラムは、基本的に次のようなコマンドで生成されている。
- Wallは警告をすべて出すというオプション。
- gは実行形式に対して、シンボルデバッグができるように情報を付加するオプション。実行形式が少し大きくなる。
- O0は最適化を行わないというオプション。-O1は最適化を行い、-O2は更に進んだ最適化を行う。-Osというのもあるが、これは実行形式のサイズを減らす最適化を行う。
- staticはライブラリを静的にリンクするためのオプション。実行形式が極めて大きくなるため、最近ではあまり使われない。
gcc hello.c -o hello -Wall -g -O0 -static
【2章】
【manコマンド】
manコマンドを利用して「man 3 ライブラリ関数名」とすると、その関数についてしることができておすすめ。
どのヘッダファイルをインクルードしたらいいのかなどもわかるので、静的解析を行う際にも重宝する。
【lsコマンド】
lsコマンドを利用すると、findを利用せずとも簡単なファイル検索を行うことができる。(例えば、ls /tmp/exit*)
【findコマンド】
「find . -type f -name "test.*"」などとすることで、現在の階層よりも下の階層にあるtestというファイル名を持ったファイル(拡張子はワイルドカード指定されている)をすべて列挙することができる。
数が多すぎる場合は「find . -type f -name "test.*" | wc -l」としたり、「find . -type f -name "test.*" | less -N」としたりして、大まかな確認が可能である。
また、ファイルを探す階層の深さを指定したいときには「find . -maxdepth 1 -name "*.S"」というように、-maxdepthオプションを指定するといい。(なぜか、-maxdepthはオプションの中で一番初めに指定しないといけないみたい)
【grepコマンド】
ファイルを限定せずに探したい文字列を見つけるときは「grep -r 探したい文字列 .」
得られたファイル名から特定の文字列を含むものを除きたい場合「grep -r 探したい文字列 . | grep -v 除きたいファイルに含まれる文字列」とパイプを使用するとよい。
表示される文字列が大量で何が何だか分からない時には「grep -r TEST . | less -N」のあとに「/探したい文字列」とすることで、探したい文字列がわかりやすく表示される。
例えば、.Sという拡張子を持つファイルの中でtestという文字列を中身に含むものを列挙したいときには「find . -type f -name "*.S" | xargs grep test」とする。
【gdbserver】
gdbserverを利用したデバッグには、次のようなコマンドを打つと良い。
gdbserverを使用するメリットとしては、画面崩れを防いだり、プアな環境のデバッグが楽に行えるということがある。
// 1つ目のコンソールでは次のように打つ gdbserver localhost:12345 ./実行形式
// 2つ目のコンソールではつぎのようにgdbを起動する gdb -q 実行形式 // gdbをgdbserverに接続する target extended-remote localhost:12345 // gdbのコマンドを入力する // 出力はgdbserverを起動したシェルの方に出力される // gdbserverを終了する monitor exit
【画面に文字列が表示されるまで】
まず、glibcの中で定義されているスタートアップ関数(_start)から処理が開始し、main関数が呼ばれる。
main関数からはprintfに対して引数が渡され、printfの中では文字列の中のパラメータ部分を適切な文字列に置き換える。
そして、printfの内部からwrite()というシステムコールラッパーを呼び出す。(第一引数:ファイルポインタ、第二引数:文字列へのポインタ、第三引数:文字列の文字数(なる文字含む))
write()の内部では、printfから受け取った引数をebx, ecx, edxに格納して、更にeaxにはsys_writeの番号である4を入れてint命令を実行する。
int命令で呼び出される関数はOSによってすでに設定されていて、その名前はsystem_callとなっている。
system_callの内部では主に次のようなことを行っている。
・SAVE_ALLというマクロを用いて、レジスタを退避すると共にedx, ecx, ebxに順番に受け取った引数をpushする。(システムコール関数はC言語で書かれているため)
・sys_call_table + eax * 4のアドレスに入っているポインタが指す関数が呼ばれる。
・システムコール関数が終了すると、システムコールからはeaxに戻り値が返されるので、SAVE_ALLの中のeaxの部分に現在のeaxの値を格納する。
・RESTORE_REGSというマクロでアプリケーションのレジスタの状態に復元する。
このような処理により、int $0x80を呼ぶ前のレジスタの情報はeaxを除いて完全に復元される。
さらに、システムコールラッパーからの戻り値はeaxで受け取ることができる。(エラーの場合は-1になり、詳細についてはerrnoで知ることができる)
Linuxで自作Cライブラリを作ったときに、int 0x80を行う前にレジスタの退避をしなくて大丈夫なのかと心配になったが、system_call関数の中でSAVE_ALLやRESTORE_REGSで退避・復帰が適切に行われるので、心配しなくても良さそうだ。
【3章】
システムコールのエラーメッセージはerrnoに格納されるが、これはアプリのメモリの一部なのでOSが書き換えるのはあまりよろしくない。
しかも、errnoはただのグローバル変数であるから、リンク・ロードするたびにアドレスが変わってしまう。(アプリケーションからは、#include
なので、システムコールラッパーであるwrite()(__write_nocancel)がシステムコールからのエラーメッセージ(RESTORE_REGSで復元したeaxの値)を受け取る。
write()はもし返ってきた値が-4095から-1の間だった場合、この値を二の補数で符号変換したものをerrno(つまり、errnoは必ず正の数)に格納して、eaxに-1を代入する。
つまり、アプリから見たエラーメッセージは-1になる。詳しいエラー内容を知りたい場合はerrnoを参照しなければならない。
当然、システムコールから返ってきた戻り値が正常な場合はwrite()システムコールラッパーはerrnoを設定したりせずにアプリに返すという風になっている。
ここで注意すべきなのは、システムコールのエラー値は-4095から-1であるのに対して、システムコールラッパーからアプリケーションに返すエラー値は-1な部分である。
では、Linuxカーネルのシステムコールは負の値をどのように返すのか?
これまでは、システムコールの戻り値が-4095から-1の場合はエラーとして値を返してきた。
このままでは、エラーと負の正常な戻り値の違いがわからない。
実際の実装では、マイナスの部分がなくなるような適切なオフセットを加算してからシステムコールを終了し、それをシステムコールラッパーで修正するようだ。
こうすることで、システムコールからシステムコールラッパーへ返ってくる値は常に正になり、エラー値と区別がつく。
例えば、getpriorityでは-20から19の値を返したいが、マイナスの値はシステムコールの戻り値として好ましくない。
なので、20からシステムコールの戻り値(-20から19)の値を引いたものをとりあえずシステムコールの戻り値とする。
この値(1から40)をgetpriorityシステムコールラッパーが受け取り、20からこの値(1から40)を引くことで本来のシステムコールの戻り地に復元する。
数式で書くと、アプリケーションに対するシステムコールの戻り値 = 20 - (20 - システムコールラッパーに対するシステムコールの戻り値)
左辺と右辺が一致することを確認してほしい。
当然、システムコールからの戻り値が負の値でエラーが発生していたときには、この復元計算は行われない。
この場合はerrnoをみて、エラー処理すべきである。
ところで、システムコールの戻り値に0x00000000から0xffffffffまで返ってくるものがある。
この場合はどのように実装されているのだろうか?
この場合は32bitのデータを戻り値としてフルに活用するため、先程のような回避策(オフセットを加算して、負の部分を正の部分にずらす)は取れない。
こうなると、システムコールからの戻り値が-4095から-1だった場合などに、エラーなのかそうでないのかがわからないのではないか?
これは、実際の実装ではシステムコールラッパーからシステムコールに対して、int型の変数を一つポインタ渡しすることで解決している。
この変数にシステムコールの正規の戻り値が格納されてシステムコールラッパーに戻ってくる。
一方、システムコールのもう一つの戻り値であるeaxには、エラー番号(エラーでない場合はerrnoは0に設定される)が返ってくる。
もしエラーの場合には、システムコールラッパーがエラー番号を二の補数反転したものをerrnoを設定してから、アプリケーションには-1を返す。
もしエラーでない場合は、システムコールから返ってきたint型の変数の値をアプリケーションに返す。
なので、アプリケーションには正常な戻り値として-1が返ってくる可能性がある。
このタイプのシステムコールの場合はerrnoを見て、errnoが0ならば正常終了、errnoが1から4095ならば異常終了だと判断するように言われているようだ。
システムコールに6つよりも多い引数をレジスタ渡しすることはx86では不可能。
なので、スタックを利用するか構造体(値渡しは遅いのでポインタ)を渡すしかない。
x86ではスタックは利用せず、構造体に値を詰めてシステムコールに渡すみたいだ。
システムコールラッパーには、write()のようにアプリケーションから引数を一つずつ受け取り、ラッパー関数の中で構造体に詰めてシステムコールを呼び出す。
こうすることでアプリケーションからは構造体の詳細を知らずに済む。
Linuxカーネルでは、古いシステムコールに対して新しいシステムコールを作成する際に、2つの命名規則が存在する。
1つ目は、新しいシステムコールに対して_newといったプリフィックスをつけて、新しいシステムコール番号を採番する。
そして、システムコールラッパー内部のシステムコール番号をeaxに設定している部分を新しいシステムコール番号に書き換えるというもの。
2つ目は、古いシステムコールに_oldといったプリフィックスをつけて、新しいシステムコールに空いた名前を当てるというもの。
当然、システムコール番号は新たに採番する。
どちらにせよ、古いシステムコール番号や関数はそのまま残されるので、昔にビルドしたアプリはアップデート後も正常に動作する。
POSIXの定義は次のように書いてある。
「POSIXは、各種UNIXを始めとする異なるOS実装に共通のAPIを定め、移植性の高いアプリケーションソフトウェアの開発を容易にする」
POSIXでは、アプリケーションのAPIやコマンドについてなど、様々な仕様が決定されているみたい。
これまでの知識から、LinuxカーネルはPOSIXで規定されているAPIを実現するための機能を提供しているが、POSIXで規定されているAPI自体を提供しているわけではないように思える。
それを提供するのはシステムコールラッパーの役割であり、これはglibcに実装されている。
【4章】
GNU/Linuxディストリビューションには、glibc(GNU C Library)が使用されている。
僕らがprintfを呼び出すプログラムを書いたときに呼ばれるprintfはglibcによって提供されているし、その中で呼ばれるシステムコールラッパーもglibcによって提供される。
ストリングライブラリやマスライブラリのようなライブラリが提供されるのは分かるが、システムコールラッパーがglibcによって提供されるのはなぜだろうか?
これは、ユーザーがアセンブラでシステム依存の処理を記述することなく、C言語のみのプログラミングに専念できるようにするためである。
システムコールラッパーをglibcが用意することで、c言語のopen関数やwrite関数などをAPIの形でユーザに提供することができる。
システムコールのテンプレートはプリプロセッサのマクロによって定義されているみたい。
DO_CALLやPSEUDOといったマクロに適切なシステムコール名を渡すとシステムコールラッパーを生成する。
具体的には、DO_CALLはPSEUDOの中で呼ばれていて、レジスタの退避などを行った後に_dl_sysinfo_int80を呼び出すようだ。
glibcでは、glibcのビルド時にMakefileの出力(正確にはechoコマンドを用いた)をsyscall-template.Sというアセンブラのファイルに渡し、PSEUDO()というマクロの引数にすることによって実際のコードが生成されている。
これまでで「アプリケーション」「OSカーネル」「標準Cライブラリのシステムコールラッパー」という3つの階層すべてを一望した。
アプリケーションは、基本的にOSカーネルのシステムコールを利用する。
システムコールはOSカーネルで定義されており、x86ではeax, ebx, ecx, edx, esi, ediの6つまでのレジスタをセットしてint 0x80を行うことで利用できる。
システムコールラッパーはglibcで定義されており、Makefileやマクロを用いてソースファイルにコードを生成している。
Linux/x86でシステムコールを呼び出すには、レジスタに引数をセットしてからint 0x80を行えばいいが、これはLinux/x86特有の仕様であり「Linux/x86のABI」と呼ばれる。
当然、同じLinuxでもアーキテクチャが異なればレジスタ構成も異なる為、システムコール呼び出しの手順は異なることになる。
また、同じアーキテクチャでもOSが異なればOSのABI(システムコールのABI)は異なることになる。
なぜなら、OSのABIはOS開発者が自由に決めることだからだ。
実際にFreeBSD/i386では、同じx86向けのOSでもABIが異なるようで、システムコールの引数はスタック渡しにする。
Linux/x86においてアプリケーションから直接システムコールを呼ぶ簡単なコードを書いてみた。
実行結果は「Hello World!」となる。
.code32 .global main main: mov $4, %eax mov $1, %ebx mov $str, %ecx mov $13, %edx int $0x80 ret str: .string "Hello World!\n"
C言語にはwrite()といったシステムコールラッパーがあるが、これは上のようなアセンブリをいちいち書かなくても良いようにするためだ。
write()をアセンブリで実装するなら次のようになるだろうか。
writeというシンボル名を使用すると既存のwriteと名前衝突するので、今回は_writeという名前にしておいた。
この名前衝突はgccのオプションに-nostdlibをつけると解消されるが、この場合スタートアップ関数も記述しなければいけなくなる。
この場合はスタートアップ関数のエントリシンボルである_startという外部シンボルをアセンブリで記述して、その中からwriteを呼べばいいだろう。
あと、僕はいつも.textとか.dataとかをgasのコード中に書いていたけれど、.section .textとかにしたほうがいいみたい。
CentOSでは.rodataが構文エラーになっていた。(なぜ、.textや.dataがOKなのかはわからないけど、こういったものは無難に利用したい)
長くなってしまったが、実行結果は「Hello World!」となる。
// in test.c int main(void){ _write(1, "Hello World!\n", 13); return 0; }
.code32 .global _write .section .text _write: push %ebx mov $4, %eax mov 8(%esp), %ebx mov 12(%esp), %ecx mov 16(%esp), %edx int $0x80 ret
なんだか乗ってきたので、main関数もアセンブラで書いてみた。
_writeの戻り値も表示するようにしたので、実行結果は「Hello」と「6」になる。(ただし、6の後には改行がない)
.code32 .global main .section .text main: push $1 push $str push $6 call _write add $12, %esp add $0x30, %eax mov %eax, (temp) push $1 push $temp push $1 call _write add $12, %esp ret _write: push %ebx mov $4, %eax mov 16(%esp), %ebx mov 12(%esp), %ecx mov 8(%esp), %edx int $0x80 pop %ebx ret .section .rodata str: .string "Hello\n" .section .data temp: .skip 4, 0
ABIには2種類存在し、それは「OSのABI」「アーキテクチャのABI」である。
OSのABIは、異なるOSで生成されたアプリケーションの機械語が実行できるようにするための規約である。
先程も紹介したが、システムコールの引数の渡し方などが挙げられる。
アーキテクチャのABIは、異なる種類のコンパイラで生成されたオブジェクトファイルをリンクするための規約である。
関数を呼び出すときに引数をどの順番で積むとか、どのレジスタを壊してはいけないかとか、関数の戻り値がeaxで帰ってくるとか、そういった事柄が挙げられる。
あとは、構造体メモリの配置方法などもアーキテクチャのABIに含まれるそうだ。(アライメントとかパディングとかの取り決めだろうか?)
よって、アーキテクチャのABIに則ってアセンブラでプログラムを書くと、そのプログラムはC言語から呼び出すことができるようになる。
次にAPIについて解説する。
C言語では、標準Cライブラリが持つwrite()を使うことでwriteシステムコールを呼ぶことができる。
write()は「int write(int fd, const void *buf, size_t size);」などと宣言されていることが多いが、これをシステムコールのAPIと呼ぶ。
このAPIはPOSIXという仕様で規定されていて、POSIXではC言語のAPIの他にも様々な取り決めがなされている。(コマンドのオプションについてなど...)
このPOSIXの取り決めにより、POSIX準拠で書かれたCプログラムはPOSIX準拠の他の環境に持っていっても再ビルドするだけで実行することができる。
実際の内部処理はOS・アーキテクチャ依存になるので、ABIの差異を吸収して環境に依存しないようなプログラムを書く為にAPIはつくられた。
逆の言い方をすれば、APIが一致していれば内部が異なっていても問題ないということになる。
ABIとAPIについて最後にまとめておく。
ABIには「OSのABI」と「アーキテクチャのABI」があり、前者は主にシステムコールの仕様、後者はコンパイラの仕様となっている。
OSのABIが共通のOSでは実行ファイルを共有できるし、アーキテクチャのABIが共通の環境ではオブジェクトファイルを共有できる。
実行ファイルやオブジェクトファイルはバイナリデータであるため、Application Binary Interfaceで仕様が決定される。
APIは名前の通り、ソースコードレベルの互換性を持たすための仕様であり、実行ファイルやオブジェクトファイルなどの互換性は取り決めていない。
ソースコードはバイナリデータと言うとそうなのだが、今の文脈ではバイナリデータとは言えない。
なので、ソースコードレベルの互換はApplication Programming Interfaceで決定される。
ここからは僕の個人的な考えだけど、こういうことかな?(それとも、OSのABIレベルの互換性は
OSのABIレベルで互換性があれば、実行形式をそのまま持ち運ぶことができる。
OSのABIレベルでの互換性はないが、アーキテクチャレベルでの互換性があれば、オブジェクトファイルを持っていってリンクし直すだけで実行ファイルを作成することができる。(OSのABIレベルの互換性はないので、システムコールの呼び出し手順は異なるが、これはオブジェクトファイルをリンクする際にその環境で適切なglibcのシステムコールラッパーがリンクされるので、OSのABIレベルの互換性は吸収される。)
ABIレベルでの互換性はないが、APIレベルでの互換性があれば、ソースコードを持っていってビルドし直せば実行ファイルを作成することができる。
これは本にはない情報だけど、size_tについて少しまとめておく。
size_tとは、その環境において十分な大きさを持った符号なし整数のことで、多くの場合はtypedef unsigned long size_tなどとしてあるみたい。
size_tが32bitであるとか、64bitであるとか決め打ちすると酷い目に遭うことがあるので注意!
なぜか、size_tがいつもしっくりこない。
2章ではprintfの動作を追うためにデバッガを用いて動的解析を行い、3章と4章では主にLinuxカーネルやglibcのソースコードを読む静的解析を行った。
静的解析ではソースコードレベルで解析できるので、手っ取り早く解析が終了する場合もある。
しかし、静的解析では呼ばれる関数がわかりにくかったり、Makefileなどで自動生成されるファイルの内容がわかりにくかったりと不便な点もある。
なので、今回はglibcをビルドしていこう。
glibcを自前でビルドすることで、2章ではできなかったC標準ライブラリのシンボリックデバッグを行うことができる。
自己ビルドしたglibcはコンパイルオプションなどの違いで、現在の環境で使っているものと異なる可能性が高いが役には立つだろう。
ここでは要点をまとめておく。。
・アプリケーションのビルドは「./configure」「make」「make install」で行われることが多い。
./configureでは、環境に合わせたMakefileの作成を行い、makeでソースファイルからビルドを行う。
make installではビルドしたファイルを適切なディレクトリに移動させる。
・これらのコマンドの途中でエラーが出た場合は、エラーメッセージを読んで、エラーを起こしているファイルを見つける。
システムにインストールするような重要なアプリケーションでない場合は、エラーメッセージを出している部分を消すことで解決する場合も多い。
また、エラーが出ている段階を調べることで、今どの辺りまでビルドできているのかが分かる。
例えば、ldでエラーが出ていたら、ビルドが終りに近い事がわかる。
・まず、configureするまえにソースコードをコピーして別のディレクトリにまとめておくのもいいかもしれない。
なぜなら、ビルドする段階でエラーが出たときにソースコードを書き換える可能性があるからだ。
・./configure時に「error : you must configure in a separate build directory」と言われる場合、ソースコードとビルドのディレクトリを別にしろと言われている。
この場合は単純にmkdirでビルドするためのディレクトリを作ってやればよい。
./configureやmake、make installといったコマンドは、この新しく作成したディレクトリから打ち込むことになる。
./configureコマンドを打つときには、パスの指定が必要になるのでビルド用のディレクトリとソースコードのディレクトリがあまり離れていないほうがいいと思う。
・glibcの./configure時には、sanity checkが行われ、本当に/usr/localにglibcをインストールしても良いのかを聞いてくる。
なぜなら、既存のライブラリが上書きされると、システムが正常に動作しなくなる可能性があるからだ。
この場合は、./configure --prefix /usr/local/glibc-2.21などとして、/usr/localを避けてやることでエラーを回避する。
--prefixの後に記述するディレクトリは、make installとしたときにglibcをインストールするディレクトリ。
--disable-sanity-checksというオプションをつけることで、このエラーを無視して/usr/localにインストールすることもできるようだ。
・makeを行うときには「make -j 4 -s」などとすると、処理が早く終わって良い。
-jオプションはCPUのコア数を指定するもので、マルチコアなCPUでは処理が早く終了する。
-sオプションはmakeがいちいち画面にコマンドを表示しないようにするオプション。重要なエラーなどは大体表示されるはずなので安心。
makeを行ったときに「warning being treated as errors」というエラーが出た場合には、./configureの際に「--disable-werror」とつけると回避できる。
ただし、せっかくmakeまで来たのにもう一度./configureからやり直さないといけないのだけど。
・make installとすると、/usr/local/glibc-2.21にglibcがインストールされるので、diff libc.a /usr/local/glibc-2.21/lib/libc.aなどとして、同一ファイルであることを確かめる。
gcc hello.c -o hello1 -Wall -g -O0 -staticとすると、650000byteぐらいの実行ファイルが生成される。
gcc hello.c -o hello2 -Wall -g -O0 -static /usr/local/glibc-2.21/lib/libc.aとすると、2470000byteぐらいの実行ファイルが生成される。
readelf -a hello2として調べると、.debugで始まる大量のデバッグセクションがつけられていた。-gオプションをなくしてもサイズがほぼ変わらなかった。
-gオプションをつけるとファイル名や行番号、引数の値もバックトレースに含まれるようになる。
これは、バイナリ情報とソースコードの情報を対応させるためのデバッグ情報で、-gオプションとは関係なしにふかされるらしい。
実際にgdb -q helloなどとして、layout srcしてみるとソースコードやその行番号などが詳細に表示される。
bt(where)コマンドなどでも、様々なソース情報が表示されるようだ。
writeシステムコールラッパーは、ビルド用ディレクトリのsysd-syscallsにあった。
必要なパラメータをdefineした後に、syscall-template.Sの中のT_PSEUDOマクロを利用しながらシステムコールラッパーを生成しているようだ。
このように、自動生成されるファイルを見ることができるのが、自己ビルドの魅力だと言える。
2845 #### CALL=write NUMBER=4 ARGS=i:ibn SOURCE=- 2846 ifeq (,$(filter write,$(unix-syscalls))) 2847 unix-syscalls += write 2848 $(foreach p,$(sysd-rules-targets),$(foreach o,$(object-suffixes),$(objpfx)$(patsubst %,$p 2848 ,write)$o)): \ 2849 $(..)sysdeps/unix/make-syscalls.sh 2850 $(make-target-directory) 2851 (echo '#define SYSCALL_NAME write'; \ 2852 echo '#define SYSCALL_NARGS 3'; \ 2853 echo '#define SYSCALL_SYMBOL __libc_write'; \ 2854 echo '#define SYSCALL_CANCELLABLE 1'; \ 2855 echo '#include <syscall-template.S>'; \ 2856 echo 'weak_alias (__libc_write, __write)'; \ 2857 echo 'libc_hidden_weak (__write)'; \ 2858 echo 'weak_alias (__libc_write, write)'; \ 2859 echo 'libc_hidden_weak (write)'; \ 2860 ) | $(compile-syscall) $(foreach p,$(patsubst %write,%,$(basename $(@F))),$($(p)C 2860 PPFLAGS)) 2861 endif
【5章】main関数の前と後 (参考URL)
実は、main関数が開始する前にはスタートアップ関数というものが存在する。
スタートアップ関数はアセンブラで記述しなければ行けないので、glibcによって提供される。
スタートアップ関数の内部では、レジスタの初期化・ライブラリの初期化・スタックポインタの初期化・データ領域やBSS領域の初期化・argcやargvなどのmainの引数の初期化など、
さまざまな処理が行われるようだ。
スタートアップ関数を探るため「gdb -q hello」とやってみる。whereコマンドで見つかるだろうか?
しかし実際には、gdb -q hello直後にbt(where)としても、__libc_start_mainや_startは得られなかった。
一旦mainからretして__libc_start_mainに戻ってくると、btで_startから呼ばれていることは分かるようだ。
おそらく、一般のアプリケーションプログラマにはこれらの関数はデバッグする必要がないので、アプリケーションからのバックトレースではわからないようになっているのだろう。
ところで、_startの内部は次のようになっていた。
0x80481c0 <_start> xor %ebp,%ebp
0x80481c2 <_start+2> pop %esi
0x80481c3 <_start+3> mov %esp,%ecx
0x80481c5 <_start+5> and $0xfffffff0,%esp
0x80481c8 <_start+8> push %eax
0x80481c9 <_start+9> push %esp
0x80481ca <_start+10> push %edx
0x80481cb <_start+11> push $0x8048be0
0x80481d0 <_start+16> push $0x8048c20
0x80481d5 <_start+21> push %ecx
0x80481d6 <_start+22> push %esi
0x80481d7 <_start+23> push $0x80482bc
0x80481dc <_start+28> call 0x80482f0 <__libc_start_main>
0x80481e1 <_start+33> hlt
0x80481dcの部分で__libc_start_mainが呼ばれているが、その下の命令はhlt命令になっている。
プログラムはユーザランドで実行されるので、hlt命令を実行するとCPUの例外が発生するはずだ。
どうやら、__libc_start_mainからは_startへ帰ってこないようなので、call命令をnop命令に上書きしてみよう。
プログラムを上書きするためには、実行ファイル内での__libc_start_mainを呼び出すcall命令のオフセットを知らないといけいない。
ここでは「readelf -a hello」としてファイル内での.text領域のオフセットを知ることで、_startの位置を割り出してみよう。
readelf -a helloとすると、次のような結果になった。
ELF Header:
Magic: 7f 45 4c 46 01 01 01 03 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - Linux
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x80481c0
Start of program headers: 52 (bytes into file)
Start of section headers: 582900 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 5
Size of section headers: 40 (bytes)
Number of section headers: 41
Section header string table index: 38
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .note.ABI-tag NOTE 080480d4 0000d4 000020 00 A 0 0 4
[ 2] .note.gnu.build-i NOTE 080480f4 0000f4 000024 00 A 0 0 4
[ 3] .rel.plt REL 08048118 000118 000028 08 A 0 5 4
[ 4] .init PROGBITS 08048140 000140 000030 00 AX 0 0 4
[ 5] .plt PROGBITS 08048170 000170 000050 00 AX 0 0 4
[ 6] .text PROGBITS 080481c0 0001c0 069b4c 00 AX 0 0 16
[ 7] __libc_thread_fre PROGBITS 080b1d10 069d10 000079 00 AX 0 0 16
[ 8] __libc_freeres_fn PROGBITS 080b1d90 069d90 001838 00 AX 0 0 16
[ 9] .fini PROGBITS 080b35c8 06b5c8 00001c 00 AX 0 0 4
[10] .rodata PROGBITS 080b3600 06b600 0154bb 00 A 0 0 32
[11] __libc_thread_sub PROGBITS 080c8abc 080abc 000004 00 A 0 0 4
[12] __libc_subfreeres PROGBITS 080c8ac0 080ac0 000030 00 A 0 0 4
[13] __libc_atexit PROGBITS 080c8af0 080af0 000004 00 A 0 0 4
[14] .stapsdt.base PROGBITS 080c8af4 080af4 000001 00 A 0 0 1
[15] .eh_frame PROGBITS 080c8af8 080af8 00ca48 00 A 0 0 4
[16] .gcc_except_table PROGBITS 080d5540 08d540 0000ff 00 A 0 0 1
[17] .tdata PROGBITS 080d6640 08d640 000010 00 WAT 0 0 4
[18] .tbss NOBITS 080d6650 08d650 000018 00 WAT 0 0 4
[19] .ctors PROGBITS 080d6650 08d650 00000c 00 WA 0 0 4
[20] .dtors PROGBITS 080d665c 08d65c 00000c 00 WA 0 0 4
[21] .jcr PROGBITS 080d6668 08d668 000004 00 WA 0 0 4
[22] .data.rel.ro PROGBITS 080d666c 08d66c 000030 00 WA 0 0 4
[23] .got PROGBITS 080d669c 08d69c 00000c 04 WA 0 0 4
[24] .got.plt PROGBITS 080d66a8 08d6a8 000020 04 WA 0 0 4
[25] .data PROGBITS 080d66e0 08d6e0 000760 00 WA 0 0 32
[26] .bss NOBITS 080d6e40 08de40 001ba0 00 WA 0 0 32
[27] __libc_freeres_pt NOBITS 080d89e0 08de40 000018 00 WA 0 0 4
[28] .note.stapsdt NOTE 00000000 08de40 00023c 00 0 0 4
[29] .comment PROGBITS 00000000 08e07c 00002d 01 MS 0 0 1
[30] .debug_aranges PROGBITS 00000000 08e0a9 000020 00 0 0 1
[31] .debug_pubnames PROGBITS 00000000 08e0c9 00001b 00 0 0 1
[32] .debug_info PROGBITS 00000000 08e0e4 0000b7 00 0 0 1
[33] .debug_abbrev PROGBITS 00000000 08e19b 00005b 00 0 0 1
[34] .debug_line PROGBITS 00000000 08e1f6 000039 00 0 0 1
[35] .debug_frame PROGBITS 00000000 08e230 000034 00 0 0 4
[36] .debug_str PROGBITS 00000000 08e264 0000b1 01 MS 0 0 1
[37] .debug_pubtypes PROGBITS 00000000 08e315 000012 00 0 0 1
[38] .shstrtab STRTAB 00000000 08e327 0001cb 00 0 0 1
[39] .symtab SYMTAB 00000000 08eb5c 007e90 10 40 899 4
[40] .strtab STRTAB 00000000 0969ec 006d85 00 0 0 1
実はまだまだ表示されているのだが、現在はこれだけで構わない。
この表示の中で[6].textと書かれているところに注目する。
Offが0x0001c0となっている。
スタートアップ関数は.text領域の一番先頭に置かれているので、目的のcall命令までのオフセットは次のように計算できる。
offset = 0x1c0 + (0x80481dc - 0x80481c0) = 0x1dc
つまり、実行ファイルをバイナリエディタで開いて、0x1dcからの5byteを0x90(nop)で書き換えれば良い。
今回はhexeditというバイナリエディタを利用した。
Enterを押すと番地を入力する画面になるので、ここで1dcと入力すると目的の場所に行ける。
ちなみに/を打つと、入力したバイナリの並びを検索できる。
実行結果は次のようになった。やっぱりか。
[user@localhost hello-mine]# ./hello-nop
Segmentation fault
では、root権限で実行したらどうだろうか?
[root@localhost hello-mine]# ./hello-nop
Segmentation fault
。
これはhltしそうな気もしたのだが、できなかったようだ。
管理者権限のことがよくわかっていないのだが、管理者でもレベル0でアプリを実行できるわけではないのだろう。
となると、管理者権限とは何なのかが気になる。
調べてみると、管理者権限とはOS上のユーザ管理方法の一つでハードウェアには関係ないようだ。(https://teratail.com/questions/148228)
ところで、アプリケーションは次のように処理が行われるようだ。
OS→_start→__libc_start_main→main→__libc_start_main→exit→OS
__libc_start_mainから_startには戻っていないのが分かると思う。
実際に_startが__libc_start_mainを呼び出した直後の命令はhltとなっており、実行されないことを明記してある。
__libc_start_mainからはmainに対してargc・argv・envpを渡しており、mainからはアプリケーションの終了ステータスが__libc_start_mainに渡される。
__libc_start_mainはその戻り値を引数にしてexit()を呼び出すようだ。(参考:exit(main(argc, argv, envp));)
さて、スタートアップ関数の静的解析を行ってみる。
スタートアップ関数はglibcによって提供されるので、glibcのディレクトリに移動する。
そして「grep -r __libc_start_main .」と入力すると、137件のヒットが確認された。
__libc_start_mainとしたのは、_startから__libc_start_mainが呼ばれているので_startも同時に引っかかると思ったからだ。
ヒットしたファイルの中でも、x86(i386)に関係のあるファイルだけを探すことにしよう。
アセンブラはアーキテクチャに依存したファイルなので、これはファイルを絞る目安になる。
そうすると「./sysdeps/i386/start.S」「./csu/libc-start.c」の2つのファイルが怪しい。
start.Sの内容はこちら。
アセンブリで書かれていて、かつ内部で__libc_start_mainをcallしている上に、直後にhltがあることから_startの処理だと思われる。(というか、ラベルに_startって書いてある)
コメントも非常に参考になるので読むべき。
いきなり、push・popといったスタックを利用する命令を書いているので、スタックポインタはOSで設定されているはず。
#ifdef SHAREDといった部分があるが、SHAREDは共有ライブラリを利用する場合に定義されるので、glibcが静的ライブラリの場合と共有ライブラリの場合とで動作が違うようだ。
_startの中で
58 _start: 59 /* Clear the frame pointer. The ABI suggests this be done, to mark 60 the outermost frame obviously. */ 61 xorl %ebp, %ebp 62 63 /* Extract the arguments as encoded on the stack and set up 64 the arguments for `main': argc, argv. envp will be determined 65 later in __libc_start_main. */ 66 popl %esi /* Pop the argument count. */ 67 movl %esp, %ecx /* argv starts just at the current stack top.*/ 68 69 /* Before pushing the arguments align the stack to a 16-byte 70 (SSE needs 16-byte alignment) boundary to avoid penalties from 71 misaligned accesses. Thanks to Edward Seidl <seidl@janed.com> 72 for pointing this out. */ 73 andl $0xfffffff0, %esp 74 pushl %eax /* Push garbage because we allocate 75 28 more bytes. */ 76 77 /* Provide the highest stack address to the user code (for stacks 78 which grow downwards). */ 79 pushl %esp 80 81 pushl %edx /* Push address of the shared library 82 termination function. */ 83 84 #ifdef SHARED 85 /* Load PIC register. */ 86 call 1f 87 addl $_GLOBAL_OFFSET_TABLE_, %ebx 88 89 /* Push address of our own entry points to .fini and .init. */ 90 leal __libc_csu_fini@GOTOFF(%ebx), %eax 91 pushl %eax 92 leal __libc_csu_init@GOTOFF(%ebx), %eax 93 pushl %eax 94 95 pushl %ecx /* Push second argument: argv. */ 96 pushl %esi /* Push first argument: argc. */ 97 98 pushl main@GOT(%ebx) 99 100 /* Call the user's main function, and exit with its value. 101 But let the libc call main. */ 102 call __libc_start_main@PLT 103 #else 104 /* Push address of our own entry points to .fini and .init. */ 105 pushl $__libc_csu_fini 106 pushl $__libc_csu_init 107 108 pushl %ecx /* Push second argument: argv. */ 109 pushl %esi /* Push first argument: argc. */ 110 111 pushl $main 112 113 /* Call the user's main function, and exit with its value. 114 But let the libc call main. */ 115 call __libc_start_main 116 #endif 117 118 hlt /* Crash if somehow `exit' does return
ここで、argc・argv・envpがどのようにスタックに積まれているのか見ていこう。
OSからC言語で書かれたアプリケーションが起動された時、espが指しているメモリにはargcの値が入っている。
そして、esp+4の位置からはargv[0], argv[1]...という風に、コマンドラインで取った引数の文字列へのポインタが格納されている。
argvはchar*の配列であり、最後の要素の次のメモリにはNULLが格納されている。(プログラムの中の終了条件としては、NULLとargcの2つが使用できる。)
envpはargvの直後に置かれており、これもchar*の配列になっている。最後には当然NULLが入っている。
argvやenvpの要素が指している文字列はスタック上に保存されており、espよりも少し大きめの部分に存在するようだ。
「gdb -q hello」「break _start」「run」とした直後に次のような実験を行った。(見やすくするために改行やコメントとか入れてます)
0xbffff430のメモリには0x00000001とあるが、これはargcのことであり、argvの文字列が実行ファイル名の一つだけということ。
0xbffff434のメモリには0xbffff5a4とあるが、この内容は下の実験でも分かる通り"/home/user/Experiments/hello-mine/hello"となっている。
0xbffff438のメモリには0x00000000が入っており、これはargvの終端を表すNULLだろう。
0xbffff43cのメモリからはスタック領域の中にある0xbffff???を指すポインタが沢山入っているように見える。
実はこれはenvpの要素であり、実際にいくつか表示してみている。
envpの最後の要素はおそらく0xbffff4dcからの4byteであり、その次のアドレスである0xbffff4e0にはNULLが入っている。
0xbffff4e4からのメモリには、スタック以外の場所を指すようなアドレス(アドレスですらないだろう)が入っていて、envpの最後が0xbffff4dcなのに確信が持てる。
# espの値を表示する (gdb) i r esp esp 0xbffff430 0xbffff430 # espが指すメモリの先頭から200byteダンプ (gdb) x/50xw $esp 0xbffff430: 0x00000001 0xbffff5a4 0x00000000 0xbffff5cc 0xbffff440: 0xbffff5ec 0xbffff5ff 0xbffff61e 0xbffff63f 0xbffff450: 0xbffff663 0xbffff673 0xbffff67e 0xbffff6cf 0xbffff460: 0xbffff6dd 0xbffff716 0xbffff728 0xbffff73f 0xbffff470: 0xbffff749 0xbffffc48 0xbffffc75 0xbffffca5 0xbffff480: 0xbffffcf3 0xbffffcfe 0xbffffd47 0xbffffd61 0xbffff490: 0xbffffdb2 0xbffffdc1 0xbffffdd2 0xbffffdf8 0xbffff4a0: 0xbffffe0c 0xbffffe1d 0xbffffe26 0xbffffe3d 0xbffff4b0: 0xbffffe70 0xbffffe78 0xbffffe88 0xbffffeb4 0xbffff4c0: 0xbffffec1 0xbfffff23 0xbfffff46 0xbfffff53 0xbffff4d0: 0xbfffff60 0xbfffff84 0xbfffff99 0xbfffffbb 0xbffff4e0: 0x00000000 0x00000020 0x001fc414 0x00000021 0xbffff4f0: 0x001fc000 0x00000010 # argv[0]が指す文字列 (gdb) x/s 0xbffff5a4 0xbffff5a4: "/home/user/Experiments/hello-mine/hello" # envp[0]が指す文字列 (gdb) x/s 0xbffff5cc 0xbffff5cc: "ORBIT_SOCKETDIR=/tmp/orbit-use # envp[1]が指す文字列 (gdb) x/s 0xbffff5ec 0xbffff5ec: "SSH_AGENT_PID=8183" # envpの最後の要素(envp[40]かな?)が指す文字列 (gdb) x/s 0xbfffffbb 0xbfffffbb: "COLORTERM=gnome-terminal"
次は、./csu/libc-start.cについて見てみよう。
次のようなマクロが定義されていたが、このファイルは.cファイルなので、このマクロが使用されるのはこのファイルだけの閉じた範囲で良いだろう。
96 # define LIBC_START_MAIN __libc_start_main
ほかにも__libc_start_mainの定義部が見つかった。
環境変数は_startで設定されていなかったが、__libc_start_mainではevや__environといった変数に環境変数を指すポインタを設定しているようだ。
mainをargc, argv, envpを引数に呼び出し、戻り値をresultという変数で受け取っている。
最終的には、exit(result)という風にプログラムを終了しているようだ。
もし、このexitでプログラムが終了しないことが会った場合、一つ上の階層である_startにおけるhltでプログラムがクラッシュするだろう。
このプログラムを見ると、mainの終端で行う「return 0;」は「exit(0)」に置き換えることができそうだ。
だって、プログラム中でexit()を呼ばなくても、mainから帰った後の__libc_start_mainではexit()を呼ぶことになるのだから。
122 /* Note: the fini parameter is ignored here for shared library. It 123 is registered with __cxa_atexit. This had the disadvantage that 124 finalizers were called in more than one place. */ 125 STATIC int 126 LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL), 127 int argc, char **argv, 128 #ifdef LIBC_START_MAIN_AUXVEC_ARG 129 ElfW(auxv_t) *auxvec, 130 #endif 131 __typeof (main) init, 132 void (*fini) (void), 133 void (*rtld_fini) (void), void *stack_end) 134 { 135 /* Result of the 'main' function. */ 136 int result; ..... 141 char **ev = &argv[argc + 1]; 142 143 __environ = ev; ..... 244 if (init) 245 (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM); ..... 288 /* Run the program. */ 289 result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); ..... 318 #else 319 /* Nothing fancy, just call the function. */ 320 result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); 321 #endif 322 323 exit (result); 324 }
【exitについて】
_exitのソースコード (exitシステムコールラッパー)
#ifdef __NR_exit_groupの部分を見ると、__NR_exit_groupが存在する場合はexit_groupが呼ばれるが、そうでない場合は下のint 0x80の部分でexitが呼ばれるようだ。
コメントでも「try the new syscall first」「not available. now the old one」などと書かれている。
_exit: movl 4(%esp), %ebx /* Try the new syscall first. */ #ifdef __NR_exit_group movl $__NR_exit_group, %eax ENTER_KERNEL #endif /* Not available. Now the old one. */ movl $__NR_exit, %eax /* Don't bother using ENTER_KERNEL here. If the exit_group syscall is not available AT_SYSINFO isn't either. */ int $0x80 /* This must not fail. Be sure we don't return. */ hlt
man exit_groupの説明によると、exitは呼び出したスレッドのみを終了するが、exit_groupはプロセス内のすべてのスレッドを終了するようだ。
exitが1、exit_groupが252のところをみても、exitが相当初期の頃からあるシステムコールで、exit_groupが相当あとに作られたということが分かる。
そうなると、_exitにはexitを呼び出す処理しかなかったが、#ifdef以下を追加する形でexit_groupが付け加えられたのだろう。
次にexit()のソースコードを見てみる。
内部で__run_exit_handlersが呼ばれているようだ。
101 void 102 exit (int status) 103 { 104 __run_exit_handlers (status, &__exit_funcs, true); 105 }
同ファイル内で__run_exit_handlersは定義されていた。
最後に_exit()が呼ばれていることが確認できる。
whileの中で呼ばれているのは、おそらくatexit()の処理だと思われる。
28 /* Call all functions registered with `atexit' and `on_exit', 29 in the reverse of the order in which they were registered 30 perform stdio cleanup, and terminate program execution with STATUS. */ 31 void 32 attribute_hidden 33 __run_exit_handlers (int status, struct exit_function_list **listp, 34 bool run_list_atexit) 35 { 36 /* First, call the TLS destructors. */ 37 #ifndef SHARED 38 if (&__call_tls_dtors != NULL) 39 #endif 40 __call_tls_dtors (); 41 42 /* We do it this way to handle recursive calls to exit () made by 43 the functions registered with `atexit' and `on_exit'. We call 44 everyone on the list and use the status value in the last 45 exit (). */ 46 while (*listp != NULL) 47 { 48 struct exit_function_list *cur = *listp; 49 50 while (cur->idx > 0) 51 { 52 const struct exit_function *const f = 53 &cur->fns[--cur->idx]; 54 switch (f->flavor) 55 { 56 void (*atfct) (void); 57 void (*onfct) (int status, void *arg); 58 void (*cxafct) (void *arg, int status); 59 60 case ef_free: 61 case ef_us: 62 break; 63 case ef_on: 64 onfct = f->func.on.fn; 65 #ifdef PTR_DEMANGLE 66 PTR_DEMANGLE (onfct); 67 #endif 68 onfct (status, f->func.on.arg); 69 break; 70 case ef_at: 71 atfct = f->func.at; 72 #ifdef PTR_DEMANGLE 73 PTR_DEMANGLE (atfct); 74 #endif 75 atfct (); 76 break; 77 case ef_cxa: 78 cxafct = f->func.cxa.fn; 79 #ifdef PTR_DEMANGLE 80 PTR_DEMANGLE (cxafct); 81 #endif 82 cxafct (f->func.cxa.arg, status); 83 break; 84 } 85 } 86 87 *listp = cur->next; 88 if (*listp != NULL) 89 /* Don't free the last element in the chain, this is the statically 90 allocate element. */ 91 free (cur); 92 } 93 94 if (run_list_atexit) 95 RUN_HOOK (__libc_atexit, ()); 96 97 _exit (status); 98 }
atexit()についても調べてみる。
内部で__cxa_atexit()という関数が呼ばれているだけみたいだ。
/* Register FUNC to be executed by `exit'. */ int #ifndef atexit attribute_hidden #endif atexit (void (*func) (void)) { return __cxa_atexit ((void (*) (void *)) func, NULL, &__dso_handle == NULL ? NULL : __dso_handle); }
__cxa_atexitは./stdlib/cxa_atexit.cで次のように定義されている。
これも内部で__internal_atexitを呼び出しているだけのようだ。
/* Register a function to be called by exit or when a shared library is unloaded. This function is only called from code generated by the C++ compiler. */ int __cxa_atexit (void (*func) (void *), void *arg, void *d) { return __internal_atexit (func, arg, d, &__exit_funcs); }
__internal_atexitは同ファイル内で次のように定義されていた。
第4引数のlistpに対して、第1引数で渡される関数を登録しているようだ。
仮引数であるlistpの実引数は、__cxa_atexitを見ると&__exit_funcsとなっている。
実はexit()を見ると、__run_exit_handlersに対して第2引数に&__exit_funcsをわたしている。
これによって__run_exit_handlersの内部のwhileによって、atexitで登録された関数を順番に実行することができている。
int attribute_hidden __internal_atexit (void (*func) (void *), void *arg, void *d, struct exit_function_list **listp) { struct exit_function *new = __new_exitfn (listp); if (new == NULL) return -1; #ifdef PTR_MANGLE PTR_MANGLE (func); #endif new->func.cxa.fn = (void (*) (void *, int)) func; new->func.cxa.arg = arg; new->func.cxa.dso_handle = d; atomic_write_barrier (); new->flavor = ef_cxa; return 0; }
【exit()、_exit()、exit_group、exitについて】
・exit()・・・プログラム終了時に呼び出されるglibcのライブラリ関数
atexit()によって登録された関数を実行してから終了する
・_exit()・・・exit()の内部で呼ばれ、exit_group(場合によってははexit)を呼び出すシステムコールラッパー
atexit()によって登録された関数を呼び出さずに終了する
・exit_group・・・プログラムを終了させるLinuxのシステムコール
・exit・・・プログラムを終了させるLinux旧来のシステムコール(ほぼ使われてないはず)
Linuxカーネルが定義するのはシステムコールのABIであり、APIを提供するのはglibcの役割だ。
POSIXのexit()と_exit()を実現するための帰納として、Linuxにはexit_groupやexitといったシステムコールがあり、glibcはこれらを呼び出すことにより、POSIXの_exit()を提供している。
manコマンドのカテゴリ分けは、カテゴリ1はシェルで使用するコマンド、カテゴリ2はシステムコールラッパー、カテゴリ3はglibcのライブラリ関数となっている。
ライブラリ関数のexit()を調べたいときに「man exit」とすると、コマンドのexitがヒットするので、そういったときは「man 3 exit」などとしてカテゴリを指定できる。
「man 1 exit」ではシェルを閉じるexitコマンドがヒット。
「man 2 exit」ではexit_groupを呼び出す_exit()システムコールラッパーがヒット。
「man 3 exit」では_exitを呼び出すglibcのライブラリ関数がヒット。
もちろん、「man 2 exit」ではなく「man 2 _exit」とすることでも、_exitがヒットする。
FreeBSDにおいてもexit()はカテゴリ3であり、_exit()はカテゴリ2であるみたい。
しかしFreeBSDではexit_groupといったシステムコールは存在せず、_exit()はexitシステムコールを呼び出す。
LinuxでもFreeBSDでも、システムコール(OS)のABIレベルの差はシステムコールラッパーによって吸収されている。