この記事は下書き状態です。

ACE(Arbitrary Code Execution、任意コード実行)とは、プログラムの脆弱性を利用して、攻撃者が用意した任意のコードを対象プログラムの実行フローに組み込み、実行させることを指す。

『ゼルダの伝説 風のタクト』のACEでは、解放(delete)されたメモリを指し続けるダングリングポインタを利用することで、クレジットワープをプログラムの実行フローに仕込むことができる。

本稿では、その攻撃経路をアドレス単位で追ったうえで、同じ条件が現代のゲームエンジン ― ここでは Unreal Engineでどう成立しなくなるかを確認する。

1. 実行手順

ACE の成立には、次の4段階のセットアップを要する。

1-1. Text Delay

NPC に話しかけてテキストボックスの生成を予約させたまま、その NPC を画面外に出す操作である。

NPC アクターはカリング状態(画面外)にある間、更新が停止する。会話ウィンドウの生成も破棄も、画面内に戻るまで保留される。加えて、写し絵の箱を構えている間はテキストボックスが生成されない。この2つを組み合わせ、写し絵の箱のカメラモードとギャラリーモードを切り替えながら B ボタンでリンクを数フレーム動かし、NPC と会話した状態のまま視界外まで離脱する。

結果として、会話は開始されているがテキストボックスの実体がまだ生成されていない NPC ができる。

1-2. Text Stacking

Text Delay を2体の NPC に対して行い、両者が同一フレームでカリング解除されるようカメラを向ける。

通常、テキストボックスは同時に1つしか存在しない。この操作はその前提を破り、2体分のテキストボックスを同時にアクティブにする。

1-3. スクリーンデータの解放

1体目の NPC のテキストを最後まで進めて閉じる。テキストボックスの破棄処理が走り、スクリーンデータが確保されていたヒープ領域が解放される。

このとき、スクリーンデータの所在を保持しているポインタ sScreen は解放後もクリアされない。ダングリングポインタが発生するのはこの時点である。

1-4. 写し絵データによる上書きとペイロード配置

2体目の NPC を画面外に置いたまま最後のテキストボックスを閉じ、破棄処理を保留させる。その状態で写し絵の箱を取り出すと、解放済みのヒープ領域に写し絵の UI データが再確保される。

同時に、以下の3つを事前に成立させておく。

用意するもの手段
写し絵3枚目の特定ピクセル0x80ABBDFFタウラ島の茂みを所定の位置・角度で撮影する
リンクの座標X座標の末尾1バイトが 0x80 になる値(例: -200046.0)/Y座標が [0.5595093, 0.5595245] の範囲所定の座標に立つ
コントローラ2〜4の入力3ワードの PowerPC 命令外部ツールによる保持入力

この状態で2体目の NPC をカリング解除すると、保留されていた破棄処理が走り、ACE が発動する。

2. メモリ上で何が起きているか

2-1. 共有ポインタの前提

テキスト表示を管理するモジュールは、「テキストボックスは同時に1つしか存在しない」という不変条件のもとで実装されている。スクリーンデータの所在を保持する sScreen は、モジュールに1個だけ置かれたグローバルポインタである。

Text Stacking はこの不変条件を破る。2体分のテキストボックスが同時に生成された結果、sScreen は後から生成された側の値で上書きされ、2体の NPC アクターが同一のアドレスを参照する状態になる。

  【不変条件が守られている場合】
     NPC A ──▶ sScreen ──▶ [ A のスクリーンデータ ]
     A が閉じる → 解放 → 次に B が確保して sScreen を更新


  【Text Stacking 成立後】
     NPC A ─┐
            ├──▶ sScreen ──▶ [ B のスクリーンデータ ]
     NPC B ─┘
     A のスクリーンデータへの参照は失われている

2-2. delete 後の nullptr 代入漏れ

1体目の NPC のテキストが閉じられると、破棄処理が呼ばれる。この処理の構造は次のとおり。

static Screen* sScreen;

void Delete() {
    if (sScreen != nullptr) {   // NULL チェックは存在する
        sScreen->~Screen();     // 仮想デストラクタの呼び出し
        Free(sScreen);
    }
    // sScreen = nullptr;       // 代入が書かれていない
}

nullptr での代入が書かれていない。これが ACE の起点である。

sScreen は解放済みアドレスを保持し続け、そのアドレスは後続の確保要求に対して再利用可能な状態になる。

  t0   sScreen = 0x81579F34 ──▶ [ スクリーンデータ (vptr, ...) ]

  t1   1体目のテキスト終了 → ヒープ領域を解放
       sScreen = 0x81579F34 ──▶ [ 解放済み ]

  t2   写し絵の箱を開く → 同一ブロックが再確保される
       sScreen = 0x81579F34 ──▶ [ 写し絵3枚目のピクセルデータ ]

  t3   2体目の NPC がカリング解除 → 保留していた破棄処理が実行される

GameCube にはメモリ配置をランダム化する仕組みが無く、ヒープの確保順序も手順によって固定されるため、同じ手順を踏めば毎回同じアドレスに到達する。この決定性が、後述する ASLR(3-3)の効果と対をなす。

sScreen のアドレスそのものは一度も変化していない。参照先の内容だけが入れ替わっている。

2-3. 仮想デストラクタ呼び出しの乗っ取り

delete による破棄処理、すなわち仮想デストラクタの呼び出しは、次の2回のポインタ読み出しで実行される。32bit PowerPC ではポインタも vtable の1スロットも 4 バイトである。

  1. オブジェクト先頭(オフセット 0)から 4 バイトを読む。ここに格納されているのが vtable の先頭アドレス(vptr)である
  2. vtable + 8 の位置から 4 バイトを読む。これがデストラクタの関数アドレスである
  3. その関数アドレスへ分岐する

ステップ2の +8 は、4 バイトスロット2つ分(=8バイト)先という意味であり、次の2つの条件から決まる固定オフセットである。

  • Metrowerks CodeWarrior for GameCube の vtable は、先頭に 4 バイト × 2 個の予約領域(offset-to-top相当・RTTI相当)を置き、その直後から宣言順に仮想関数を並べる
  • 対象クラスはデストラクタを最初に宣言していると思われる。(Zelda SpeedRun の +8byteという記述より)

したがって、この2回の読み出しがそのまま攻撃経路になる。ステップ1で読まれる 4 バイトを写し絵のピクセルデータで置換すれば、ステップ2の読み出し先とステップ3の分岐先を任意に制御できる。

2-4. 写し絵のピクセルデータを vtable アドレスとして読ませる

再確保された領域のうち、0x81579F34 に配置されるのは、写し絵の箱に保存された3枚目の画像の 4x4 ピクセルブロックである。

GameCube のテクスチャフォーマット CMPR は S3TC(DXT1)系の圧縮方式で、4x4 ピクセルを 8 バイトに符号化する。前半 4 バイトが 2 つの基準色(RGB565 × 2)、後半 4 バイトが各ピクセル 2 ビットのインデックスであり、インデックスは 2 つの基準色の間を 4 段階で補間する。4x4 = 16 ピクセル × 2 ビット = 32 ビット、すなわちちょうど 4 バイトである。

ここで攻撃者が制御するのは後半 4 バイト、つまり ブロック内 16 ピクセルの明暗パターンである。左上から右下へ 2 ビットずつ読み出した値が、そのまま vtable アドレスとして解釈される。

  必要な値 0x80ABBDFF を 2bit ずつ展開すると

    0x80 = 10 00 00 00      1行目: 明暗レベル 2,0,0,0
    0xAB = 10 10 10 11      2行目: 明暗レベル 2,2,2,3
    0xBD = 10 11 11 01      3行目: 明暗レベル 2,3,3,1
    0xFF = 11 11 11 11      4行目: 明暗レベル 3,3,3,3

  → 左上が最も明るく、右下へ向かって暗くなるグラデーション

要求されるのは絶対的な色ではなく、ブロック内の相対的な明暗の並びである。撮影対象が連続的な階調を持つ被写体であれば、位置と角度の調整で目的のパターンに到達できる。実際に使われているのはタウラ島の茂みで、リンクの立ち位置とカメラ角度によって階調のかかり方を追い込む形になる。上位バイトが 0x80 に固定されるのは、GameCube の MEM1 が 0x80000000 始まりであるため、有効なアドレスとして解釈させるうえで都合がよい。

2-5. 分岐の連鎖

置換後の値をアドレスとして読み進めると、参照先が4つのメモリ領域を渡り歩く。

 ┌── FopMsg ヒープ ────────────────────────────────────────────┐
 │  0x81579F34   sScreen の参照先                               │
 │               本来 : スクリーンオブジェクトの vptr            │
 │               現在 : 写し絵3枚目のピクセルデータ = 0x80ABBDFF │
 └────────────┬────────────────────────────────────────────────┘
              │  ① vtable のアドレスとして読み出される

 ┌── Zelda ヒープ(アクター)──────────────────────────────────┐
 │  0x80ABBDFF + 8 = 0x80ABBE07 から 4 バイト読み出す            │
 │                                                             │
 │    0x80ABBE04  リンクの X座標 (float)  C8 43 5B [80]         │
 │    0x80ABBE08  リンクの Y座標 (float)  [3F 0F 3C] XX         │
 │                                        └──┬──┘               │
 │    読み出される値 = 0x803F0F3C                               │
 │    (X の第4バイト + Y の先頭3バイト)                        │
 └────────────┬────────────────────────────────────────────────┘
              │  ② デストラクタの関数アドレスとして分岐する

 ┌── 静的グローバル領域 ───────────────────────────────────────┐
 │  0x803F0F3C   コントローラ2〜4の入力値の格納先                │
 │                                                             │
 │    ctrl2: 80 AC 00 10  = lwz r5, 0x10(r12)                  │
 │    ctrl3: 4B C5 00 5C  = b   0x80040FA0                     │
 │    ctrl4: 80 23 4B 78  = アドレス定数                        │
 │                                                             │
 │  ③ 入力値が PowerPC 命令として実行される                      │
 └────────────┬────────────────────────────────────────────────┘
              │  ④ 既存コード内の gadget へ分岐し 1 バイト書き込む

 ┌── コード領域 ───────────────────────────────────────────────┐
 │  0x80234C7C   ステージ遷移時のクレジット発動を抑止する分岐命令 │
 │               先頭 1 バイトを潰すと無害な加算命令になる        │
 └─────────────────────────────────────────────────────────────┘

              ▼   ステージ遷移 → エンディング

写し絵のピクセル値、リンクの座標、コントローラの入力は、いずれもプレイヤーが値を決定できる。4 つの領域を経由して、最終的にコード領域の 1 バイトが書き換えられる。

なお、ステップ③の分岐先は 4 バイト境界に乗っている必要がある。PowerPC の bctr は分岐先アドレスの下位 2 ビットを無条件に切り捨てるため、末尾が 0/4/8/C にならない値を合成した場合、命令ストリームが 1〜3 バイトずれて別の命令列として解釈され、無効命令例外または不正アクセスで停止する。合成可能な値のうち有効なものは 1/4 に絞られる。

まとめ:風のタクトACEの本質

風のタクトの ACE は、単一のバグではなく、6つの条件が同時に成立して初めて到達可能になる連鎖である。起点は delete 後の nullptr 代入漏れだが、それ単体では vtable の固定オフセット、データ領域の実行可能性、アドレスの決定性が揃わない限り任意コード実行までは到達しない。

このうち、アプリケーション側のコードで直接防げるのは「ダングリングポインタ」の1点のみである。残りはコンパイラの ABI、ハードウェアの実行保護、OS のメモリ配置といった、アプリケーションより下のレイヤーに属する。次章では、この最下層にある nullptr 代入漏れが、Unreal Engine ではどう起こりえなくなっているかを見る。

3. Unreal C++ における防御

Unreal C++ にはUObject を継承したクラスに対して手動の delete が存在せず、UObject の寿命はガベージコレクタが管理する。GC は オブジェクトを破棄するだけでなく、それを参照していたポインタに nullptr を書き戻す

3-1. 強参照への nullptr 書き戻し

到達可能性解析の過程で、参照先が Garbage 判定されたポインタはその場で nullptr に置き換えられる。

FORCEINLINE_DEBUGGABLE void KillReference(UObject*& Object) { Object = nullptr; }

呼び出し側は次のとおり。

template<EKillable Killable>
static FORCEINLINE_DEBUGGABLE void ProcessReferenceDirectly(
    FWorkerContext& Context, FPermanentObjectPoolExtents PermanentPool,
    const UObject* ReferencingObject, UObject*& Object, FMemberId MemberId)
{
    if (ValidateReference(Object, PermanentPool, FReferenceToken(ReferencingObject), MemberId))
    {
        const int32 ObjectIndex = GUObjectArray.ObjectToIndex(Object);
        FReferenceMetadata Metadata(ObjectIndex);
        bool bKillable = Killable == EKillable::Yes;
        ...
        else if (Metadata.Has(KillFlag) & bKillable)   // KillFlag = EInternalObjectFlags::Garbage
        {
            check(ReferencingObject || IsEliminatingGarbage());
            KillReference(Object);                      // ここで nullptr が代入される
            return;
        }
        ...
    }
}

第4引数 UObject*& Object が参照渡しである点が要件になる。プロパティに格納されているポインタそのものを書き換えている。UPROPERTY() を付与した UObject* および TObjectPtr<> は参照トークンストリームに登録されるため、この経路を自動的に通過する。

sScreen に書かれていなかった代入を、エンジンが全参照に対して実行していることになる。

この防御が働かないのは、UPROPERTY() を付与していない生ポインタである。トークンストリームに載らないため GC の走査対象外となり、書き戻しも行われない。Unreal でダングリングポインタが発生する経路は実質的にこれに限られる。

3-2. AddressSanitizer によるヒープ Use-After-Free 検出

nullptr の書き戻しは GC の管理下にある参照にのみ働く。管理外の生ポインタ経由でダングリングポインタを踏んだ場合、これを検出するのは開発時のツールの役割になる。

Unreal Engine は Clang のサニタイザをビルドオプションとして組み込んでおり、bEnableAddressSanitizer(コマンドラインからは -EnableASan)を有効にしてビルドすると、AddressSanitizer(ASan)がリンクされる。ASan は解放済みメモリへのアクセスを実行時に検出し、確保時と解放時のコールスタックとともにプロセスを即座に停止させる。ヒープ/スタック/グローバルのバッファオーバーフローも同じ仕組みで検出できる。

本番のシップビルドには組み込まれない開発時限定の検出手段だが、CI に組み込んでおけばリリース前に同種のバグを検出できる。

3-3. ASLR(Address Space Layout Randomization)

ASan が「解放済み領域へのアクセスをその場で検出する」仕組みであるのに対し、ASLR は「攻撃者がアドレスを事前に決め打ちできないようにする」仕組みであり、防御の層が異なる。

GameCube にはメモリ配置をランダム化する機構が無い。main.dol のロード先、各ヒープの開始アドレス、確保順序はすべて起動のたびに同一であり、sScreen が指すアドレスも、リンクの座標が置かれるアドレスも、事前に固定値として特定できる。

現代の一般的な OS・コンソールでは、プロセスの起動ごとにコード領域・ヒープ・スタックの絶対アドレスをランダム化する ASLR が標準で有効になっている。これによって次の区別が生じる。

情報の種類ASLR 環境で成り立つか
「ヒープの先頭からのオフセットに座標データがある」という相対配置成り立つ(ASLR はここを変えない)
「座標データは絶対アドレス 0x80ABBE04 にある」という絶対値成り立たない(起動のたびに変わる)

風のタクトの ACE は、写し絵のピクセルに 0x80ABBDFF と書き込み、座標から 0x803F0F3C を合成するという、絶対アドレスを定数として埋め込む攻撃である。ASLR がある環境では、この定数が実行のたびに無効になるため同じ手順は成立せず、攻撃者はまず何らかの方法でアドレスを1つ知る(情報漏洩)必要がある。相対配置のグルーミングはできても、絶対アドレスへの決め打ちができなくなる分、攻撃の難易度は段階的に上がる。

まとめ:Unreal Engine での対処

風のタクトの ACE を成立させていた条件のうち、Unreal C++ は次の層でそれぞれ対処している。

  • ダングリングポインタの発生そのものは、GC 管理下の参照であれば KillReference() による自動的な nullptr 書き戻しで起こらない
  • GC の管理外にある生ポインタでの use-after-free は、ASan による開発時検出でカバーする
  • アドレスを定数として埋め込む攻撃は、OS レベルの ASLR によって前提から崩れる

これらは互いに独立した層であり、どれか1つに頼っているわけではない。ただし、いずれも条件付きの防御であることは変わらない。GC の書き戻しは UPROPERTY() を付けた参照にしか効かず、ASan はシップビルドには入らず、ASLR は情報漏洩と組み合わされれば突破されうる。風のタクトの時代に無かったのは特定の1つの機能ではなく、こうした層そのものである。

参考