今回はいよいよアセンブラでの関数[1] の実現について説明します。
ただし、関数実現の仕組みのうち、今回は関数での局所的な情報管理に関してのみ説明し、呼び出し元/呼び出し先の連携については次回で説明します。
[1] 「サブルーチン」( sub-routine) /「 手続き」( procedure)と呼んだり、これらを厳密に区別する言語もありますが、とりあえずここではこれらをまとめて「関数」(function)と呼びます。
復帰位置の取得
「関数」の原理
「関数」が「分岐」と決定的に異なるのは、分岐での制御遷移は「遷移させたきり」であるのに対して、関数は呼び出し元に戻ってくる点と言っても良いでしょう。
C/C++ や、その他さまざまな高級言語を使用していた時には「何となく良きに計らってくれる」程度の認識であったとしても、本連載をここまで読まれた方であれば、データもプログラムも等しくメモリ上に転がっている 感覚が身に付いているでしょうから:
復帰位置(アドレス)を記録しておいて、関数での処理が完了したなら、復帰位置に制御遷移する
という手順を踏めば、関数という枠組み が実現されるであろうことに、見当が付いているのではないでしょうか。
たとえば以下のような実装によって、関数的なもの を実現することが可能です。
リスト1 手製の関数的な仕組み
※ 呼び出し元
:
leal rp, %eax
jmp func1
rp:
:
※ 呼び出し先
func1:
:
jmpl *%eax
呼び出し先(この例では func1
)の処理が済み次第、レジスタeaxに格納された復帰先に制御遷移することで、呼び出し先は呼び出し元がどこであるかを意識せずに、適切な位置に復帰することができます。
専用命令の使用
先の実装例では、jmp
を用いた関数呼び出しに先立って、復帰先アドレスをレジスタに格納しました。
しかし、ここでちょっと考えてみましょう。
関数から復帰する位置は、( Intel x86アーキテクチャの場合なら)関数呼び出しを行うjmp
命令の次の命令位置 以外にありえません。そして一般的なCPUであれば、連載第3回 で説明したように、命令実行位置が格納されている「プログラムカウンタ」と呼ばれるレジスタ[2] を備えています。
つまり、わざわざプログラムを書いて復帰位置を記録しなくても、関数呼び出しの際に復帰位置を記録するための道具立ては、CPU自身が持っているはず、ということです。
期待に違わず、一般的なCPUであれば、関数呼び出し(および関数からの復帰)用途に特化した命令を提供しています。
Intel x86アーキテクチャの場合、関数の呼び出しを行う命令として"call
"(CALL procedure)命令が、関数から呼び出し元への復帰を行う命令として"ret
"(RETurn from procedure)命令が提供されています。これらの命令を使用することで、先述の実装は以下のように書き換えることができます(復帰位置の記録に関する詳細は後述します) 。
リスト2 専用命令による関数呼び出しの実現
※ 呼び出し元
:
call func1
:
※ 呼び出し先
func1:
:
ret
局所的情報の保持
局所変数領域の確保
個々の関数実行時における固有の変数領域、いわゆる局所変数 (local variable)は、関数における処理の開始から終了まで存在し続けます。
実のところ、継続 (continuation)と呼ばれる概念をサポートするプログラミング言語では、関数からの復帰は、必ずしも局所変数領域の解放を意味しません。
継続サポートでの記述性向上による利便性と、資源消費のバランスについて考えてみるのもおもしろいでしょう。
局所変数領域は、再帰呼び出し のように自分自身が入れ子になる状況や、複数のスレッド が同じ関数を同時に呼びすような状況であっても、各呼び出しごとに独立 して確保されなれければなりませんので、本連載のこれまでのプログラム例で見てきたような、あらかじめメモリ上に領域を確保する方法では実現できません。
このような特徴を持つ局所変数領域を実現するために、通常はスタック (stack)と呼ばれるデータ構造を用います。
スタック構造を用いて各関数の局所的な情報を格納する領域を「スタック領域」と呼び、通常はアドレス高位から低位へ向かって データを格納します。このスタック領域を参照するためのレジスタをスタックポインタ (stack pointer)と呼び、32ビットIntel x86アーキテクチャの場合はESP(Extended Stack Pointer)レジスタが相当します。
「スタック領域」のデータ格納順がアドレス高位から低位に向かっているのは、慣れるまでは少々奇異に感じるかもしれません。
より詳しく知りたい場合は、実行時に割り当てられる、いわゆるヒープ (heap)領域との兼ね合いなど、OSレベルでのプロセス/メモリ管理について調べてみては如何でしょうか。
まずは関数での処理の開始の際に、以下のような処理を行います。
ES Pの値を4[3] だけ減算
EB P(Extended Base Pointer)の値をES Pの位置に記録
ES PをEB Pの位置に移動
関数で必要となる局所変数領域の分だけES Pを減算
図1 局所変数領域の確保
この時、レジスタESPの指す位置(アドレス低位側)から、レジスタEBPの指す位置(アドレス高位側)までのスタック領域上のメモリが、その関数呼び出しにおける局所変数として使用できる領域となります。一般的にはこの領域をスタックフレーム (stack frame)と呼び、スタックポインタと対を成してスタックフレームを形成するためのレジスタを、フレームポインタ (frame pointer[4] )と呼びます。
スタックフレーム中に確保された局所変数領域へのデータの読み書きは、フレームポインタを使用したレジスタ相対での間接参照で行います。
たとえば、リスト3 のCプログラムに相当するような処理は、リスト4 のようにして実現されます。
リスト3 C言語での局所変数アクセス
void
func1(){
int index;
index = 1;
:
}
リスト4 アセンブラでの局所変数アクセス
subl $4, %esp
movl %ebp, (%esp)
movl %esp, %ebp
subl $4, %esp
movl $1, -4(%ebp)
EBPと局所変数格納領域アドレスとの差分である"-4"を用いた即値付き間接アドレッシングで、当該メモリの内容変更=局所変数への代入を行います。
データ構造としてスタックを扱う場合、スタックポインタをデータ未格納方向に移動させつつデータを書き込む処理をプッシュ (push) 、その逆に、現位置からデータを読み出しつつスタックポインタを逆方向に移動させる処理をポップ (pop)と呼ぶのが通例です。
同名のアセンブラ命令(もちろん挙動も同一)をサポートするCPUアーキテクチャも少なくありません。
Intel x86アーキテクチャもサポートしており、スタックとのデータ入出力には push
およびpop
を使用するのが一般的です。
また、関数での処理の終了の際には、以下のような処理を行います。
ES Pの位置をEB Pの位置に移動
ES Pの位置から以前のEB P値を取り出してEB Pを復旧
ES Pの値を4だけ加算
図2 局所変数領域の解放
アセンブラで実装するなら以下のようになります(この実装例では、「 ebp の復旧」と「esp の移動」を pop
命令で実施しています) 。
リスト5 局所変数領域の解放
movl %ebp, %esp
popl %ebp
これにより、関数呼び出し元に復帰した際には、ESPおよびEBPは共に、呼び出し時点と変わらない状態に復旧していることになります。
ところで、先のプログラム例では、自分で直接ESP/EBPレジスタを操作して、復帰の際に必要な情報の退避や、局所変数領域の確保などを行いました。
しかし、Intel x86アーキテクチャの場合、このような関数冒頭/末尾での記述量を減らすための専用命令として、関数冒頭でのスタックフレーム確保にはenter
命令、関数末尾でのスタックフレーム解放には leave
命令が提供されています。これらを用いて書き換えたプログラムを以下に示します。
リスト6 enter
/leave
での実装
func1:
enter $4, $0
movl $1, -4(%ebp)
leave
ret
enter
命令の第1引数には局所変数領域のサイズを指定します。
第2引数は関数のネスティングレベル(nesting level)を表しますが、高級言語における入れ子関数定義等でない限り 0 指定で構いません。
復帰先アドレスの保存
専用命令call
/ret
を使用したプログラム例では、復帰先アドレスの格納に関してはあえて説明しませんでした。
実は、call
命令はスタック上に復帰先アドレスを格納するため、局所変数の実現方法を通してスタックそのものについて説明するまで、説明を先に延ばしていたのです。
call
命令は復帰先アドレスをスタック領域にプッシュ し、ret
命令はスタック領域からポップ した復帰先アドレスをEIPレジスタに格納=制御遷移します。
低レイヤーから見た関数呼び出し
今回は、低レイヤーからの視点で見た関数呼び出しとスタックに関するトピックとして、とある脆弱性攻撃手法について説明したいと思います。
冒頭で「関数的なもの」を実現する際に、「 関数呼び出し」および「呼び出し元への復帰」を行う命令として、共に無条件分岐命令jmp
を使用しました。
一般的には専用の命令であるcall
およびret
を使うわけですが、基本的な動作原理としてはjmp
での実現方法と変わりはありません。つまり
ある関数の途中から別の関数の先頭へ と制御遷移する「関数呼び出し」も、ある関数の(論理的)末尾から別の関数の途中へ と制御遷移する「呼び出し元復帰」も、どちらも制御遷移である
ということです。
「それがどうしたの?」と思われるかもしれませんので、ひとつ実験をしてみましょう。
リスト7 呼ばれるはずのないfunc2
.text
.align 4
func1:
ret
func2:
ret
.global entry_point
entry_point:
int3
call func1
.global end_of_program
end_of_program:
int3
上記のプログラムは見ての通り、関数func1
を呼び出すだけのプログラムです。
しかし、func1
からの復帰直前にスタックを操作することで
図3 呼ばれてしまうfunc2
(gdb) run
....
0x00401003 in entry_point ()
(gdb) disassemble func1
Dump of assembler code for function func1:
0x00401000 <func1+0>: ret
End of assembler dump.
(gdb) disassemble func2
Dump of assembler code for function func2:
0x00401001 <func2+0>: ret
End of assembler dump.
(gdb) stepi
0x00401000 in func1 ()
(gdb) info register esp
esp 0x22ff88 0x22ff88
(gdb) x/1x 0x22ff88
0x22ff88 : 0x00401008
(gdb) set var *0x22ff88 =0x00401001
(gdb) stepi
0x00401001 in func2 ()
(gdb)
関数 func1
から戻るはずが、関数func2
を呼び出してしまいました。
この実行例では、ある関数からの復帰が想定外の関数呼び出しにつながっただけですが、スタックの上書きによるアドレス格納を精緻に行うことで、呼び出したい一連の関数を次々に呼び出すことも不可能ではありません。
これは脆弱性攻撃手法の1つで、アークインジェクション (arch injection)と呼ばれるもので、ret
命令による復帰先が、本来の呼び出し元だろうが別な関数の冒頭だろうが、CPUにとってはどちらも単なる制御遷移に過ぎない、というのがこの攻撃手法の味噌です。
アークインジェクションと同様にバッファオーバーラン (buffer overrun)脆弱性を利用する攻撃手法として、スタック上に攻撃者の実行したいプログラムを書き込むコードインジェクション (code injection)はよく知られた脆弱性攻撃の手法ですが、こちらの攻撃は、スタック領域として使用するメモリ部分をOSによって「実行禁止」にしてしまうことで回避可能です。
しかし、スタック上に上書きするのが単なるデータ(呼び出し先アドレス)であるアークインジェクションの場合、OSによるスタック領域の「実行禁止」では防ぐことはできないのです!