アンティーク・アセンブラ~Antique Assembler

第5回関数の機能 ~ 呼び出し元からの独立

今回はいよいよアセンブラでの関数[1]の実現について説明します。

ただし、関数実現の仕組みのうち、今回は関数での局所的な情報管理に関してのみ説明し、呼び出し元/呼び出し先の連携については次回で説明します。

復帰位置の取得

「関数」の原理

「関数」「分岐」と決定的に異なるのは、分岐での制御遷移は「遷移させたきり」であるのに対して、関数は呼び出し元に戻ってくる点と言っても良いでしょう。

C/C++ や、その他さまざまな高級言語を使用していた時には「何となく良きに計らってくれる」程度の認識であったとしても、本連載をここまで読まれた方であれば、データもプログラムも等しくメモリ上に転がっている感覚が身に付いているでしょうから:

復帰位置(アドレス)を記録しておいて、関数での処理が完了したなら、復帰位置に制御遷移する

という手順を踏めば、関数という枠組みが実現されるであろうことに、見当が付いているのではないでしょうか。

たとえば以下のような実装によって、関数的なものを実現することが可能です。

リスト1 手製の関数的な仕組み
※ 呼び出し元
         :
    leal    rp, %eax # 復帰位置 rp を eax に格納
    jmp     func1
rp:                  # 復帰位置(Return Position)
         :

※ 呼び出し先
func1:
         :
    jmpl    *%eax    # 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)は、関数における処理の開始から終了まで存在し続けます。

局所変数領域は、再帰呼び出しのように自分自身が入れ子になる状況や、複数のスレッドが同じ関数を同時に呼びすような状況であっても、各呼び出しごとに独立して確保されなれければなりませんので、本連載のこれまでのプログラム例で見てきたような、あらかじめメモリ上に領域を確保する方法では実現できません。

このような特徴を持つ局所変数領域を実現するために、通常はスタック(stack)と呼ばれるデータ構造を用います。

スタック構造を用いて各関数の局所的な情報を格納する領域を「スタック領域」と呼び、通常はアドレス高位から低位へ向かってデータを格納します。このスタック領域を参照するためのレジスタをスタックポインタ(stack pointer)と呼び、32ビットIntel x86アーキテクチャの場合はESP(Extended Stack Pointer)レジスタが相当します。

まずは関数での処理の開始の際に、以下のような処理を行います。

  1. ESPの値を4[3]だけ減算
  2. EBP(Extended Base Pointer)の値をESPの位置に記録
  3. ESPをEBPの位置に移動
  4. 関数で必要となる局所変数領域の分だけESPを減算
図1 局所変数領域の確保
図1 局所変数領域の確保

この時、レジスタESPの指す位置(アドレス低位側)から、レジスタEBPの指す位置(アドレス高位側)までのスタック領域上のメモリが、その関数呼び出しにおける局所変数として使用できる領域となります。一般的にはこの領域をスタックフレーム(stack frame)と呼び、スタックポインタと対を成してスタックフレームを形成するためのレジスタを、フレームポインタ(frame pointer[4]⁠)と呼びます。

スタックフレーム中に確保された局所変数領域へのデータの読み書きは、フレームポインタを使用したレジスタ相対での間接参照で行います。

たとえば、リスト3のCプログラムに相当するような処理は、リスト4のようにして実現されます。

リスト3 C言語での局所変数アクセス
void
func1(){
    int index;

    index = 1;
        :
}
リスト4 アセンブラでの局所変数アクセス
    # 局所変数領域の確保
    subl    $4, %esp     
                        # esp の移動
    movl    %ebp, (%esp) 
                        # ebp の格納
    movl    %esp, %ebp   
                        # ebp を esp 位置に移動
    subl    $4, %esp     
                        # index 領域の分だけ esp を移動

    # 局所変数へのアクセス
    movl    $1, -4(%ebp)

EBPと局所変数格納領域アドレスとの差分である"-4"を用いた即値付き間接アドレッシングで、当該メモリの内容変更=局所変数への代入を行います。

また、関数での処理の終了の際には、以下のような処理を行います。

  1. ESPの位置をEBPの位置に移動
  2. ESPの位置から以前のEBP値を取り出してEBPを復旧
  3. ESPの値を4だけ加算
図2 局所変数領域の解放
図2 局所変数領域の解放

アセンブラで実装するなら以下のようになります(この実装例では、⁠ebp の復旧」「esp の移動」pop 命令で実施しています⁠⁠。

リスト5 局所変数領域の解放
    movl    %ebp, %esp   # esp を ebp 位置に移動
    popl    %ebp         # ebp の復旧と、esp の移動

これにより、関数呼び出し元に復帰した際には、ESPおよびEBPは共に、呼び出し時点と変わらない状態に復旧していることになります。

ところで、先のプログラム例では、自分で直接ESP/EBPレジスタを操作して、復帰の際に必要な情報の退避や、局所変数領域の確保などを行いました。

しかし、Intel x86アーキテクチャの場合、このような関数冒頭/末尾での記述量を減らすための専用命令として、関数冒頭でのスタックフレーム確保にはenter命令、関数末尾でのスタックフレーム解放には leave命令が提供されています。これらを用いて書き換えたプログラムを以下に示します。

リスト6 enter/leaveでの実装
func1:
    enter   $4, $0
    movl    $1, -4(%ebp)
    leave
    ret

復帰先アドレスの保存

専用命令callretを使用したプログラム例では、復帰先アドレスの格納に関してはあえて説明しませんでした。

実は、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 ()
    ※ ret 実行直前
(gdb) info register esp
esp        0x22ff88        0x22ff88
(gdb) x/1x 0x22ff88
0x22ff88:  0x00401008
    ※ esp 値を元に復帰位置格納先を確認
       この時点では call の次の位置
(gdb) set var *0x22ff88=0x00401001
    ※ 復帰位置格納先を func2 のアドレスで上書き
(gdb) stepi
    ※ func1 の ret を実行
0x00401001 in func2 ()
    ※ 呼び出し元へ復帰せず func2 が呼ばれる
(gdb)

関数 func1から戻るはずが、関数func2を呼び出してしまいました。

この実行例では、ある関数からの復帰が想定外の関数呼び出しにつながっただけですが、スタックの上書きによるアドレス格納を精緻に行うことで、呼び出したい一連の関数を次々に呼び出すことも不可能ではありません。

これは脆弱性攻撃手法の1つで、アークインジェクション(arch injection)と呼ばれるもので、ret命令による復帰先が、本来の呼び出し元だろうが別な関数の冒頭だろうが、CPUにとってはどちらも単なる制御遷移に過ぎない、というのがこの攻撃手法の味噌です。

アークインジェクションと同様にバッファオーバーラン(buffer overrun)脆弱性を利用する攻撃手法として、スタック上に攻撃者の実行したいプログラムを書き込むコードインジェクション(code injection)はよく知られた脆弱性攻撃の手法ですが、こちらの攻撃は、スタック領域として使用するメモリ部分をOSによって「実行禁止」にしてしまうことで回避可能です。

しかし、スタック上に上書きするのが単なるデータ(呼び出し先アドレス)であるアークインジェクションの場合、OSによるスタック領域の「実行禁止」では防ぐことはできないのです!

おすすめ記事

記事・ニュース一覧