【注意】このサイトに記載されていることを他人に試すことは「不正アクセス禁止法」に該当する場合があります。詳しくはこちらから

リターンアドレスが壊れた瞬間、CPUは何をするか(episode-10)

黒ネコと学ぶ・論理的エクスプロイト開発への道

バッファオーバーフローは「メモリがはみ出す」だけで終わりません。
特に怖いのは、はみ出しが「リターンアドレス」に到達したときです。リターンアドレスは「関数が終わったあとに戻る場所」を示す値なので、ここが壊れると CPUが次に実行する場所が変わってしまいます。
この記事では、CPUが実際にどの順番で何をしているのかを、"call" と "ret" の動作から論理的に説明し、GDBで「見える化」する観察ポイントまで整理します。

くろちゃん

「次に実行する場所」が変わるって、そんなに大ごとなんですか?


白猫先生

はい。CPUは「次の命令の住所」を信じて進むだけです。住所が壊れる=進路が壊れる、ということです。



※イメージです。

リターンアドレスとは

リターンアドレスは、関数呼び出しで使われる「戻り先の命令アドレス」です。ざっくり言うと、CPUは次の流れで関数を呼び出します。
"call 関数" を実行する
・戻り先(次に実行すべき命令の住所)をスタックに積む。(push)
・呼び出し先の関数へジャンプする。
そして、関数が終わるときに、"ret"を実行します。"ret"は「スタックの一番上にある値を戻り先として使う命令」です。つまり、CPUはスタックに置かれた値を「帰り道」として信用しています。

くろちゃん

リターンアドレスって、どこか別の安全な場所に置かれてるんですか?


白猫先生

基本はスタックです。だからこそ、スタックが壊れると「帰り道の住所」も巻き込まれます。

  

なぜ壊れるのか

リターンアドレスが壊れる原因は、ほとんどの場合「境界チェックの不足」です。
特にC言語では、配列やバッファに書き込むときに 自動で長さチェックをしてくれません。その結果、想定より長い入力が来ると、バッファの外側(隣のメモリ)まで上書きしてしまうことがあります。
 
スタック上では多くの場合、下から上へ次の順で並びます(細部はコンパイラ等で変わりますが、理解としてこの順が重要です)。
・ローカル変数(例:"char buf[16]")
・保存されたRBP(スタックフレームの基準点)
・リターンアドレス(戻り先)
つまり、ローカル変数からはみ出すと、まず保存RBP、さらに進むとリターンアドレスに到達します。

くろちゃん

「はみ出し」って、やっちゃダメなのに、なぜ止めてくれないんですか?


白猫先生

C言語は「速さ」や「自由度」を優先して設計されています。止めない設計=安全はプログラマ側の責任、という前提です。

  

リターンアドレスが壊れた瞬間、CPUは何をするか

ここがこの記事の核心です。CPUは、"ret"を実行するときに次のことをします(x86_64の基本イメージです)。
1.スタックポインタ(RSP)が指している場所から値を取り出す。
2.その値を 命令ポインタ(RIP) として採用する。(次に実行する住所になる)
3.RSPを進める。(スタックのトップが1つ戻る)
4.RIPの住所へジャンプして命令を実行し始める。
つまり、"ret"は「スタック上の値=戻り先住所」として 機械的にジャンプ します。CPUは、その値が正しいか、危険か、意図的に壊されたかを判断しません。CPUはただ「住所だと思って飛ぶ」だけです。

くろちゃん

CPUって、壊れてる住所だと気づかないんですか?


白猫先生

気づきません。気づくのはOSの保護機構(実行不可やアクセス違反など)で、CPU自体は「住所」として処理します。

  

何が起きるか

リターンアドレスが壊れると、起きることは大きく3つに整理できます。
(A) 存在しない場所に飛ぶ
→ すぐクラッシュ(例:Segmentation fault)になります。
(B) 実行はできるが想定外の場所に飛ぶ
→ 予期せぬ命令が実行され、動作が乱れます。
(C)(攻撃の本質)飛び先が制御される
→ 「壊れる場所と内容」が狙って制御されると、振る舞いが意図した方向へ寄せられます。
 
ここで重要なのは、「攻撃=壊す」ではなく、壊れる場所(どこ)と壊れる内容(何を書き込むか)を制御する という点です。リターンアドレスは 「次の実行先」 そのものなので、制御できたときの影響が非常に大きくなります。

くろちゃん

クラッシュするだけなら、まだマシなんですね…?


白猫先生

はい。クラッシュは「異常に気づける」からです。怖いのは、壊れているのに動いてしまうケースです。

   

GDBで観察すると理解が固定されます

仕組みを本当に理解するには「見たことがある」が強いです。ここでは 「観察」 に絞って、何を見れば論理がつながるかを整理します。
 
観察で押さえる3点
・RBP / RSP の位置関係(スタックフレームの形)
・RBP付近のメモリの中身(保存RBPとリターンアドレスが見える)
・"bt"(バックトレース)(どこから来て、どこへ戻ろうとしたか)
 
よく使うコマンド
・"info registers rbp rsp"
→ 今の 基準点(RBP) と スタック先端(RSP) を確認します。
・"x/xx $rbp"(例:"x/16gx $rbp" が見やすいことが多いです)
→ RBP周辺のメモリを表示して、保存RBPやリターンアドレスらしき値を探します。
・"bt"
→ 関数の呼び出し履歴を出して、スタックが「正常な道」を辿れているかを確認します。
※表示形式("x/xx" の "xx")は見たい量と単位で変わります。64bitなら "gx"(8バイト表示)が直感的です。

くろちゃん

コマンドは覚えられそうですけど、「何を見ればいいか」が不安です…


白猫先生

RBP周辺に「保存RBP」と「戻り先っぽい値」が並んでいるのを確認できればOKです。形が見えると、理解が固まります。

  

リターンアドレスが壊れた瞬間、CPUは何をするかのまとめ

"ret"は、スタック上の値を取り出して、それを次に実行するアドレス(RIP)として採用し、そこへジャンプする命令です。つまりリターンアドレスが壊れた瞬間、CPUは「壊れた住所」を「正しい住所」として扱い、機械的に飛んでしまいます。C言語では境界チェックが自動では行われないため、ローカル変数のはみ出しが保存RBPやリターンアドレスへ到達し得ます。結果として、クラッシュで止まる場合もあれば、想定外の実行へ進む場合もあります。GDBでRBP/RSPとRBP周辺メモリ、そして"bt"を観察すると、現象が「運」ではなく「理解できる手順」として見えるようになります。