フォーマット文字列攻撃の原理
printf に変数をそのまま渡すと、なぜメモリの読み書きまで奪われるのか。%n が任意アドレス書き込みに化ける仕組みと GOT 上書き、現代の緩和まで原理で押さえられる。
- 1.printf 系に攻撃者入力をフォーマット文字列として直接渡すと、%x/%s で引数並びやスタック上の値を漏らし、%n でそのアドレスへ書き込みできてしまう。
- 2.%n は「これまでに出力した文字数」を引数ポインタの指す先へ書き込む指定子で、幅指定と直接引数アクセス(%k$n)を併用すれば任意アドレスに任意値を書ける。
- 3.GOT エントリを %n で書き換えれば関数呼び出しの飛び先を奪え制御を取れる。緩和は -Wformat、Full RELRO、FORTIFY_SOURCE、そして %n の無効化。
なぜ「ただの表示関数」が乗っ取りになるのか
フォーマット文字列攻撃の核心は、printf 系関数がフォーマット文字列を「データ」ではなく「ミニ言語のプログラム」として解釈する点にあります。printf は第 1 引数の文字列を左から走査し、% を見つけるたびに「次の可変長引数を 1 つ取り出して、指定された形式に整形せよ」という命令として実行します。問題は、printf にはフォーマット文字列がいくつの引数を要求しているかを検証する手段が一切ないことです。%d が 3 個あれば 3 個ぶんの引数を取り出しにいき、実際に渡されていなければスタック上のたまたまそこにある値を引数として読みます。
危険なのは、攻撃者が制御する入力をフォーマット文字列の位置に直接置いてしまうコードです。
printf(user_input); /* 危険:user_input がフォーマット文字列になる */
printf("%s", user_input); /* 安全:user_input は %s の対象データに過ぎない */
前者で user_input に %x を含めれば、printf はそれを命令と解釈し、本来渡されていない引数を読み出して 16 進表示します。入力にフォーマット指定子を仕込むだけで、printf のインタプリタを攻撃者が操れるわけです。これが読み出しと書き込みの両方に発展します。
解説は教育・防御目的です。攻撃手順そのものではなく、なぜ成立し、どの緩和がどこを塞ぐかという原理に絞ります。検証は許可された環境でのみ行うべきで、無断の実行はペネトレーションテストの節で触れたとおり法的責任を伴います。
スタック上の値を読み出す:%x と直接引数アクセス
可変長引数は、x86-64 の System V 呼び出し規約では最初の数個がレジスタ、残りがスタックに積まれます。printf は「もう引数があるはず」という前提で内部のポインタを進め、レジスタとスタックを順に読みます。攻撃者が %x を並べれば、その内部ポインタの先にある値が次々と漏れます。
入力: AAAA.%x.%x.%x.%x.%x.%x
出力: AAAA.<reg>.<reg>.....41414141.<stack>...
↑ 余分な引数として ↑ 入力した "AAAA" 自身が
レジスタ/スタックを読む スタック上に現れることもある
何個目の %x で自分の入力(上の 41414141)が出てくるかを数えれば、フォーマット文字列バッファがスタックの何番目の引数位置にあるか(オフセット)が分かります。これが後の任意書き込みで「どこへ書くか」を狙うための足場になります。さらに %s を使えば、引数として読んだ値をポインタとみなして指す先の文字列まで漏らせるため、任意アドレスの読み出しが可能です(不正なポインタを踏めばクラッシュします)。
毎回 %x を並べて目的の位置まで進むのは冗長なので、POSIX の直接引数アクセス %k$(k 番目の引数を指す)を使います。%7$x なら 7 番目の引数だけを一発で読めます。書き込みでも同じ記法が鍵になります。
任意アドレス書き込みの正体:%n
読み出しだけなら情報漏洩にとどまりますが、%n が状況を一変させます。%n は「フォーマット文字列をここまで処理して何文字出力したか」を、対応する引数(ポインタ)の指す先に整数として書き込む指定子です。表示ではなくメモリへの書き込みを行う、唯一の出力副作用を持つ指定子です。
攻撃者の戦略はこうです。
- 書き込み先アドレスをスタックに置く。フォーマット文字列バッファ自体がスタック上にあるので、入力の先頭に書き込みたいアドレスのバイト列を埋め込んでおく。
- 直接引数アクセスでそのアドレスを
%nの対象にする。先に数えたオフセットを使い、%k$nでちょうどそのアドレスを指す引数を選ぶ。 - 出力文字数で書き込む値を作る。
%nが書くのは「ここまでの出力文字数」なので、幅指定で出力長を水増しして任意の値にする。%100xなら 100 文字ぶん出力が進む。
[ 書きたいアドレス(8B) ][ %<幅>x で出力長を調整 ][ %k$n で確定 ]
↑ スタックに現れる ↑ ここまでの文字数 = 書き込む値 ↑ アドレスへ書く
たとえば値 0x1234(10 進 4660)を書きたければ、%n に到達するまでに 4660 文字を出力するよう幅を調整し、目的アドレスを指す引数番号で %n を撃ちます。4 バイト値をそのまま %n で書こうとすると数十億文字の出力が必要になり非現実的なので、実戦では1 バイトずつ書く %hhn(または 2 バイトの %hn)を使い、アドレスを 1 つずつずらして 4 回に分けて書きます。これで任意アドレスへ任意の 32/64 ビット値を、出力文字数という間接経路で書き込めます。
%n が書く値は攻撃者が直接指定するのではなく、それまでの累積出力文字数で決まります。だから大きな値は幅指定(%65536x のような巨大な幅)で「出力したことにして」作ります。書き込み先は %n に対応する引数のポインタが指す先なので、そのポインタを攻撃者がスタック経由で支配できることが任意書き込みの前提です。printf("%s", input) のように入力をデータ位置に置けば、入力が指定子として解釈される余地がなく、この経路は最初から塞がります。
GOT 上書きによる制御奪取
任意アドレス書き込みが手に入ると、次は制御フローを奪う標的を探します。古典的な標的が **GOT(Global Offset Table)**です。
動的リンクされたバイナリは、printf や system のような共有ライブラリ関数を遅延解決します。初回呼び出し時に動的リンカが実アドレスを解決し、その結果を GOT というテーブルに書き込みます。以降の呼び出しは PLT(Procedure Linkage Table)経由で GOT エントリに入ったアドレスへ間接ジャンプするだけです。つまり GOT は「次にどこへ飛ぶか」を保持する書き換え可能な関数ポインタの表です。
攻撃者は %n である関数の GOT エントリを別の飛び先(たとえば system や用意したガジェット)に書き換えます。すると、その関数が次に呼ばれた瞬間、制御は攻撃者の指定先へ移ります。
正常: call printf@plt → GOT["printf"] = <printf 本体> へジャンプ
攻撃後: %n で GOT["printf"] = <攻撃者の飛び先> に上書き
→ 次の printf 呼び出しで攻撃者のコードへ制御が移る
GOT が狙われるのは、位置が固定で(少なくとも PIE でなければ)特定しやすく、書き換えれば確実に呼ばれるからです。スタック上の戻りアドレスを直接書き換える手もありますが、GOT 上書きはスタックの配置に依存せず安定するため好まれてきました。ここから先は、メモリ破壊攻撃の記事で扱う ROP などと同じ「制御を奪った後にどう拡張するか」の世界に合流します。
フォーマット文字列脆弱性が極めて危険なのは、1 つの欠陥で「任意読み出し(%s)」「任意書き込み(%n)」「制御奪取(GOT 上書き)」がすべて揃う点です。さらに %x/%s による読み出しは、ASLR で隠されたライブラリのアドレスやスタックカナリアの値を漏洩させる手段にもなり、他の緩和を崩す踏み台にもなります。「ただのログ出力」と侮ると、情報漏洩から完全な制御奪取まで一直線につながります。
現代コンパイラ・ランタイムの緩和
幸い、この脆弱性は構造が単純なぶん、検出と緩和が効きやすい部類です。複数層で塞がれています。
コンパイラの静的検査が第一線です。GCC/Clang は -Wformat 系(特に -Wformat-security)で、フォーマット文字列が文字列リテラルでない呼び出しや、指定子と引数の型・個数の不一致を警告します。printf(user_input) は -Wformat-security で警告でき、-Werror=format-security でビルドエラーに昇格させれば混入を防げます。
FORTIFY_SOURCE はランタイム側の砦です。_FORTIFY_SOURCE=2 以上でビルドすると、glibc は書き込み可能なメモリ上にあるフォーマット文字列での %n を実行時に拒否し、*** %n in writable segment detected *** で中断します。攻撃の核心である %n の悪用そのものを止めにいく緩和です。
**Full RELRO(Relocation Read-Only)**は GOT 標的を無力化します。-Wl,-z,relro,-z,now でフル RELRO を有効にすると、起動時に全シンボルを解決したうえで GOT を読み取り専用に再マップします。%n で GOT を書き換えようとしても、そのページは書き込み不可なのでフォルトして失敗します。
| 緩和 | 層 | 塞ぐ部分 | 限界・補足 |
|---|---|---|---|
| -Wformat-security | コンパイル時 | 非リテラルのフォーマット文字列を警告 | 警告を無視・抑制すれば素通り。-Werror で強制すべき |
| FORTIFY_SOURCE=2/3 | 実行時 | 書込可能領域の %n を検出して中断 | 定数フォーマットや一部のケースに限られることがある |
| Full RELRO | リンク/起動時 | GOT を読み取り専用化し上書きを阻止 | 他の関数ポインタ・vtable は依然標的になり得る |
| %n 無効化 | ランタイム設定 | %n 自体を使用不可に(Windows 既定など) | POSIX 互換が要る環境では使えない場合がある |
| 安全なコード | 設計/実装 | 入力をデータ位置に置き解釈させない | 根治。既存コードの監査・修正が必要 |
加えて、%n 自体を環境ごと無効化する流れもあります。Windows の CRT は既定で %n を無効にしており、glibc でも一部ビルドで書き込み可能セグメントの %n を禁じます。とはいえ、これらはいずれも攻撃を高コスト化・部分的に遮断するもので、設定の有無や互換要件に左右されます。発想の整理はCFI と最新のエクスプロイト緩和で扱う「制御フローの正当性をどこで保証するか」と地続きです。
(1) 本質は printf(user_input) のように入力をフォーマット文字列に渡すこと。printf("%s", input) なら安全。(2) %x/%s でスタックや任意アドレスを読み、ASLR・カナリアの漏洩にも使える。(3) %n は出力文字数を引数ポインタの先へ書き込み、幅指定と %k$n/%hhn で任意アドレスに任意値を書ける。(4) GOT 上書きで関数の飛び先を奪い制御を取る。(5) 緩和は -Wformat-security/FORTIFY_SOURCE/Full RELRO/%n 無効化、根治は安全な実装。
実務での向き合い方:根治は入力をデータとして扱う
緩和を積んでも、根治は攻撃者の入力をフォーマット文字列として解釈させないことに尽きます。入口は実装の選択です。
- フォーマット文字列は必ずリテラルにする:
printf(s)ではなくprintf("%s", s)、fprintf/syslog/snprintfも同様に第 1 引数を定数文字列にする。可変メッセージはデータ引数として渡す。 - コンパイラ・リンカの防御を既定で全部入れる:
-Wformat -Wformat-security -Werror=format-security、-D_FORTIFY_SOURCE=2(可能なら 3)、-Wl,-z,relro,-z,nowを標準フラグにする。 - 継続的に検出する:ファジングとサニタイザで実行時の不正アクセスを掘り、静的解析で非リテラルなフォーマット呼び出しを機械的に洗い出す。レビューだけでは見落とすため自動化と組み合わせる。
万一の制御奪取に備え、プロセスを最小権限の原則で動かしておけば、侵害が成立しても攻撃者が得る能力を狭められます。フォーマット文字列攻撃が教えるのは、「データと命令を混ぜない」という分離の原則を破った瞬間に、表示関数すら任意メモリ操作の道具に化けるという事実です。
セキュリティ Article
フォーマット文字列攻撃の原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
フォーマット文字列
比較で見る軸
難易度: advanced / カテゴリ: セキュリティ / タグ数: 5
導入後に効く点
%n は「これまでに出力した文字数」を引数ポインタの指す先へ書き込む指定子で、幅指定と直接引数アクセス(%k$n)を併用すれば任意アドレスに任意値を書ける。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- セキュリティ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「フォーマット文字列 / printf」に近いか確認する。
- 強みである「printf 系に攻撃者入力をフォーマット文字列として直接渡すと、%x/%s で引数並びやスタック上の値を漏らし、%n でそのアドレスへ書き込みできてしまう。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。