PR 本記事には広告(Amazonアソシエイト・もしもアフィリエイト・A8.net等)が含まれます。掲載情報の正確性には努めていますが、商品の詳細は必ずリンク先で最新情報をご確認ください。
CTF・セキュリティ学習

【Heap応用編】Use-After-Free・Double Freeで世界を壊す|CTF思考フレームワーク #63

かも次郎とアンペンが「Heap」を解説するマスコットイラスト
安全に生きたい編集部

こんにちは、アンペンです!

今回はHeap応用編・Use-After-Free(UAF) と Double Free。実世界のCVEで頻出する2大ヒープバグです。ChromiumやFirefoxのRCE、Linuxカーネル特権昇格の多くがこの2種類に分類されます。tcache poisoningの『仕掛け作り』を引き起こす引き金として、両者の実装ミスを実コードレベルで理解します。

共通の本質は『すでにアロケータが管理を奪い返した領域を、アプリケーションがまだ自分のもののように扱う』こと。アプリとアロケータの『所有権』がズレた瞬間に攻撃が成立します。

ちょっと抽象的なので、たとえで掴んでおきましょう。メモリの“所有権”は、本来アプリとアロケータ(malloc/freeの管理人)の間でキャッチボールされています。freeした瞬間、その領域の所有権は管理人に返る——なのにアプリが『まだ自分のものだ』と勘違いして触り続ける。この“持ち主の食い違い”こそ、UAFもDouble Freeも共通の正体です。要は『返したはずのものを、まだ握っている』ミスなんですね。

『使用後の解放』とか『二重解放』って、そんなに致命的なの?

致命的。UAFは古いポインタが指す領域がC++のオブジェクトとして再利用されると、vtable(仮想関数テーブル)を攻撃者が差し替えて関数呼び出しを乗っ取れる。Double Freeは前回扱ったtcacheリストを循環させて任意mallocに繋げる。どちらもRCE直結だよ。

まず結論

UAFは『freeしたポインタを使い続ける』バグで、同サイズのアロケートが間に挟まると古いポインタ経由で別オブジェクトを書き換えられます。C++ではvtable書換→任意メソッド呼出という典型経路でRCEに直結。Double Freeは同じチャンクを2回freeすると、tcache連結が同チャンクへの自己循環になり、続くmallocを攻撃者制御下に置けます。glibc 2.29+はtcache_entry.keyでdouble-freeを即abortしますが、UAF経由でkeyフィールドごと潰せば回避可能。守りは『free直後に必ずNULL代入+AddressSanitizer+所有権モデル(Rust)』

この記事で分かること

  • UAFの時系列と『なぜvtableが書き換わるか』の仕組み
  • vtable hijackからRCEまでの道筋
  • Double Freeで作る自己循環の3ステップ
  • glibc 2.29+のkey検出と回避法
  • ASan / Rust / MiraclePtr による根本対策
難易度:中〜上級 所要時間:14分 体験:UAFでvtable書換 おすすめ:#62の後

📖 はじめてのWebセキュリティ #63|Heap応用編
UAF・Double Freeで世界を壊す手筋を扱います。 シリーズ一覧を見る →

⚠️ 大事なお約束
本番システム・他者バイナリへのexploitは違法。CTF・自作バイナリ・how2heap等の検証環境のみで確認してください。

UAFの時系列:4ステップで成立する

UAFは下記4ステップで成立します。プログラマが『free後にポインタを使わない』ルールを守れば起きませんが、特にコールバック・参照カウント・グローバル変数経由でポインタが残り、本人すら気づかないことが多いバグです。

  • ①malloc(A):オブジェクトAを確保、コードはポインタpAを保持
  • ②free(A):Aはtcacheに戻されるが、pAはそのまま残る(dangling pointer)
  • ③malloc(B):偶然(または攻撃者誘導で)同サイズBを確保→同じアドレスがBに割り当てられ、pBも同所を指す
  • ④pA経由で書込or呼出:アプリは『古いA』のつもりで操作、しかし実体はB。Bが関数ポインタ/vtableを持つなら、そこを攻撃者が書いた値で呼び出してRCEへ

この4ステップで一番こわいのは、③と④が“たまたま”起きてしまう点です。free後のポインタが残っているだけなら、運が良ければ何事もなく動いてしまう。でも、攻撃者が『同じサイズの確保』を意図的に挟み込むと、解放済みの場所に自分の用意したデータを“すべり込ませ”られる。つまりUAFは、タイミングを攻撃者が握った瞬間に、ただのバグから一気に乗っ取り手段へと化けるわけです。

malloc→free→再malloc→古いポインタ経由で書込みするUAF攻撃の時系列図解
図1:UAFは『古いポインタの再利用』で制御を奪う
🚪 たとえるなら、退去済みの部屋の鍵を持ち続ける

賃貸を解約した(=free)のに、コピーした古い鍵をまだ持ち歩いている人がいる。次の入居者(=新しいオブジェクトB)が入った後、その鍵で勝手にドアを開けて部屋に侵入し、家の中の機械(=vtable)の配線を入れ替える。次に入居者が『いつもの便利スイッチ』を押すと、別の場所に通報が飛ぶ──UAFはまさにこの構造。dangling pointer(古い鍵)を残さないことが第一防御です。

ここで覚える用語:vtable hijack
C++クラスのインスタンスは先頭8バイトにvtable(仮想関数テーブル)へのポインタを持ちます。obj->method()を呼ぶと、コンパイラは『vtableを参照してN番目の関数を呼ぶ』コードを生成。UAFで同サイズの『攻撃者制御文字列バッファ』が同アドレスに再アロケートされたら、先頭8バイトに偽のvtableポインタを書く→次のobj->method()呼出が偽vtable内の攻撃者指定関数を呼ぶ、という流れでRCE成立。Chromium/Firefox/JavaScriptエンジン関連のRCEはほぼこの形式。

Double Freeで作る自己循環tcache

Double Freeは同じチャンクを2回freeすると、tcacheに head → X → X → ... という自己循環が出来てしまうバグ。普通に同サイズを連続mallocすると、毎回同じXが返ってくる状態になります。これを使うとtcache poisoningよりさらに少ない手数で任意malloc誘導が可能。

Double Freeの不気味さは、『同じ箱を2回“返却済み”リストに積む』と、リストが自分自身をぐるぐる指す“蛇が尻尾を食う”ような状態になることです。こうなると、次に箱をくれと頼むたびに、毎回同じ箱が返ってくる。同じ箱を2つの用途で同時に使えてしまうので、片方を書き換えればもう片方も化ける。tcache poisoningより少ない手数で、任意の場所をmallocさせる足がかりになるわけです。

  • ①X=malloc(0x30); free(X); free(X); tcacheはhead→X→X→…
  • ②malloc()でXを回収 tcache headはまだX(2回目)
  • ③X.fdに偽アドレスtargetを書く tcacheはhead→X→target(=Xを再利用しfdを上書き)
  • ④もう一度malloc()でtargetが返る 任意書込プリミティブ獲得
同じチャンクを2回freeしてtcacheに自己循環を作り任意mallocを実現するDouble Free図解
図3:Double Free→tcache循環→任意malloc

glibc 2.29+ は tcache_entry 構造体にkeyフィールド(tcache_perthread_structへのポインタ)が追加され、freeする時に『すでに同じkeyが設定されていればabort』で双重解放を即検出します。回避するにはUAF経由でkeyを別値で潰す手順を挟む必要があります。

ここで守りの基本が効いてきます。実は『freeしたら、すぐそのポインタにNULLを入れる』——たったこれだけで、Double Freeの多くは無害化できるんです。NULLは何回freeしても何も起きないからです。古い鍵を持ち歩かず、その場で捨てる。地味ですが、これがUAF・Double Free対策の“いの一番”。だからこそ、後半の対策リストでも真っ先に挙がっています。

退去済みの賃貸の古い鍵で侵入して内部装置を書き換えるUAFのたとえイラスト
図2:古い鍵=danglingポインタで部屋に侵入

CTFでやってみよう:UAFでvtable hijack

やってみよう / 自分の環境・CTFのみ

関数ポインタ付き構造体を再利用してシェル奪取

目的は『UAFが起きた瞬間、攻撃者が次のmallocサイズを制御できれば任意関数呼出に化ける』の流れを実機で見ることです。

  1. C言語でstruct Obj { void (*action)(); char name[24]; }; を持つメニュー型バイナリを作成(create/free/use の3操作)
  2. checksecでnoPIE / Partial RELROを確認(検証を簡単にするため)
  3. obj=create() でAを生成→free(obj)でtcacheへ→obj_ptrはdanglingのまま
  4. 同サイズ(0x20)でname_buf=create_string()を呼び、先頭8バイトに p64(system_addr) を書く文字列を投入
  5. 古いobj_ptr経由でobj->action()を呼ぶ → 実体はname_buf先頭のsystem関数アドレスが呼ばれる
  6. 引数RDIに事前に”/bin/sh”のアドレスを仕込んでおく(ROPと同様)→シェル獲得
  7. 余裕があれば、how2heapのtcache_house_of_spirit等の現代版に挑戦
本番環境・他者バイナリへの適用厳禁。CTF・how2heap・自作バイナリのみ。CVE再現は許可済み環境(VM/コンテナ)で。

ここまで攻撃の形を見てきましたが、UAFもDouble Freeも、根っこは同じ『所有権のズレ』でした。ということは、守り方も自ずと決まります——“返したものは二度と触らない”を、仕組みで強制すればいい。次の道具立ては、その強制をどの層で効かせるか、という視点で並んでいます。

守る側:UAF/Double Freeを仕留める道具立て

対策の優先度マップ
  • すべてのfree直後にNULL代入を必須化(free(p); p = NULL;) ──二重freeをNULL書込で無害化
  • 所有権モデルがある言語へ移行:Rust(コンパイラがUAF/Double Freeを構文的に拒否)・Swift(ARC)
  • 開発・CIでAddressSanitizer(-fsanitize=address)を全実行に適用。UAF/double-free検出率は実質100%
  • libFuzzer / AFL++でカバレッジ誘導Fuzzingを24h以上回し、未知のヒープバグを早期発見
  • 大規模C++ではMiraclePtr / BackupRefPtr(Chromium)等のスコープド・スマートポインタ
  • 本番にはHardened allocator(Scudo/mimalloc-secure/GWP-ASan)を採用、確率的検出
  • glibcは2.29以上を維持。__free_hook/__malloc_hookを必要としない設計に

『退去後の鍵を持ち続けない=free後にNULL』、基本中の基本だね。

そう。次回はHouseシリーズ。House of Force / Spirit など、ヒープ攻撃の流派を整理するよ。

まとめ:『古いポインタは捨てる、二重解放は厳禁』

今回のポイント
  • UAFは4ステップ(malloc→free→再malloc→旧ポインタ使用)で成立
  • C++ではvtable書換でRCEに直結。実CVEで超頻出
  • Double Freeはtcache自己循環→3〜4回mallocで任意アドレス
  • glibc 2.29+のkey検出は強力だが、UAFと組合せれば回避可能
  • 守りはfree後NULL+ASan+Rust+Hardened allocatorの多層

今日の持ち帰りは『返したメモリは、もう他人のもの』。freeした瞬間に所有権は手を離れている——その一線を越えて触り続けることが、最悪RCEにまで化けるんです。だからこそ“free後はNULL、できればRustで所有権を言語に任せる”。攻撃の派手さに対して、守りの一手はとてもシンプルです。

次回はHouseシリーズ編。House of Force / Spirit / Lore / Orange など、glibc仕様の隙を突く古典的なヒープ乗っ取り技を整理します。

次に読みたい記事

参考資料

記事URLをコピーしました