第六話:EBPとESP、スタック領域の使われ方
前回の「第五話:スタックとLIFO、だから何?(怒)」はスタック領域の意味の分からなさにキレて終わったわけですが、今回はスタック領域の使われ方について触れたいと思います。
第五話でも書いた通り、スタック領域は変数などの値の一時的な格納場所です。枕詞として、各関数ごとに場所が違う、というのもつきます。
例えばこのシリーズで出てくるmain_sub.exeのスタック領域は以下のようなイメージです。こんな風に分けておくことで、他の関数のことは気にせずに変数の値を入れ替えることができるわけですね。
EBP ベースポインタ
では、スタック領域はどうやって各関数に分けるのか?ここで登場するのがEBP、ベースポインタです。
ベースポインタは、平たく言うと「今実行中の関数が使用しているスタック領域の底」です。底だからベースね。先ほどの図で説明するとこんな感じです。
このベースポインタは当然各関数ごとに変わるわけなので、関数の最初と最後で値を変更する処理が書かれています。
関数の最初の処理
では実際に動かしつつ見ていきましょう。
OllyDbg上でsub()関数の最初の行にブレイクポイントを設置し、実行します。
最初の2行に注目。
1: PUSH EBP2: MOV EBP, ESP
この処理はsub()に限らず、関数の最初には必ず書かれています。
まず1行目。PUSH EBPですね。
この時点では、EBPはまだmain()が使用しているスタック領域の底(=0012FF40)を示しています。ESPはmain()が使用しているスタック領域の一番上(=0012FF28)を指しているので、ここでPUSH EBPを実行すると、main()が使用しているスタック領域の一番上に、main()が使用しているスタック領域の底のアドレスが格納されます。PUSHするのでESPは4減算(=0012FF24)されます。図にするとこんな感じ。
OllyDbg上でPUSH EBPを実行した直後はこんな感じになります。ちょっとつぶれてて見にくいですが、上の図と同じようになっていることが分かりますかね。
さて、ここでいよいよスタック領域の切り替えが行われます。
命令は「MOV EBP, ESP」です。「現在のESPの値をEBPにする」ということですね。
OllyDbg上で実行してみると以下のようになります。
これで、main()が使用しているスタック領域には干渉せずに、sub()はスタック領域に変数の値などを書き込むことができます。書き込まれる場所はESPが指していて、PUSHやPOP(MOVもある→余談参照)によりESPの値は上下するわけですが、EBPより大きくなることはありません。
関数の最後の処理
さて。EBPの真骨頂は関数をCALLするときでなくRETするときに発揮されます。つまり、sub()からmain()に戻るときですね。
今度はsub()の最後の3行を見てみましょう。OllyDbgで[F7]を押して処理を進めてください。
9〜11行目ですね。
9: MOV ESP, EBP
10: POP EBP
11: RETN
まず9行目。MOV命令により、ESPをsub()のスタック領域の底EBPにします。
OllyDbgだとこんな感じ。ESPとEBPの値が同じになりました。
さて、ここで10行目のPOP EBPを実行するとどうなるか。ESP(=EBP)の直下には、main()のEBPが格納されています。つまり、POP EBPにより、(sub()のEBPだった)EBPが、main()のEBPに変わるということです。
OllyDbgの方も見てみましょう。
sub()をCALLする直前と同じ状態になりました。わお!すごい!
このように、callee(ここではsub())の関数の最初にcaller(ここではmain())のEBPをPUSHする処理をすることで、calleeのEBPの直下には必ずcallerのEBPが格納されることになり、calleeのRETNの前にMOV ESP, EBP+POP EBPをすることで、EBPをcallerのものに戻す、というお約束によって、callerのスタック領域はcalleeの干渉を受けずにそのまままた使えるようになるのです。なんて美しいのかしら。
ということで、ESPやEBP、LIFOというスタック領域の特性によって、他の関数に干渉せずに変数の値などが変えられるというお話でした。
次回は第四話でお話ししたCALL命令で起こることについて、スタック領域に注目しながら再度見ていきたいと思います。RETN命令によってcalleeからcallerへ戻るわけですが、「戻る場所」もスタック領域に格納されるんだよーという話です。そしてこの戻る場所、リターンアドレスこそが、古き良きスタックオーバーフローによる攻撃のキモとなるのです。
余談
以下余談です。
スタック領域はPUSH/POPによって値を出し入れすることで上に伸びたり縮んだりするわけですが、PUSH/POPのみで操作しているわけではなく、MOVでスタック領域のアドレスに直接書き込んだりもします。main()では、3行目でSUBによってESPを0C分上げて、4〜6行目で0C分上げたところに3つの値をスタック領域に書き込んでいます。
1: PUSH EBP
2: MOV EBP,ESP
3: SUB ESP,0C
4: MOV DWORD PTR SS:[LOCAL.1],0
5: MOV DWORD PTR SS:[LOCAL.2],0
6: MOV DWORD PTR SS:[LOCAL.3],0
これ、3回PUSHすれば同じ結果が得られるのになぜそれをしないのか疑問でしたが、以下に答えらしきものがありました。
why does it use the movl instead of push? - Stack Overflow
3回PUSHするということは3回ESPが変わることであり、かつ1回目のPUSH(によるESPの変更)が終わらないと次のPUSHを実行できないという理由から、SUBによりESPの変更は最初に1回だけ行うのが速いとコンパイラが判断して、こういうコードになっているようです。
でもsub()に引数を渡すところ(第七話で説明予定)ではPUSH使ってるんだよなぁ。3回以上だとMOVの方が速いとかあるんだろうか・・・と思ってsub()へ渡す引数が3つあるプログラムを作ってみましたが変わらず。コンパイラが良きに計らっているのでしょうけど、気になる。
(本稿はここで終わりです。ためになったという方、他のも読みたい!という方、投げ銭していただけると嬉しいです。)
この続きをみるには
記事を購入
100円
気軽にクリエイターの支援と、記事のオススメができます!