Ricerca CTF 2023 にチーム TSG として参加しました。全チーム中 10 位で、また賞金獲得条件を満たす国内の学生チーム1の中では 1 位となりました。
私は主に Web の問題を解いていました。そのうち ps converter の writeup を書いていきます。
ps converter
概要
backend の handleRoot
は、X-Forwarded-For ヘッダーの末尾の IP アドレス2に TCP 接続して script
を送信します。実は handleRoot
が読み取る X-Forwarded-For ヘッダーを自由に変えることができるので、SSRF ができます。そこでこの値を 123.45.67.102
(backend) にすると、handleRoot
は本来 frontend:3000
にある ps2pdf daemon に対して script
を送信するはずが、backend:3000
に送信してしまいます。
http://backend:3000/admin
に (一定の条件のもとで) フラグを返すエンドポイントがあるので、 SSRF で送信される script
を /admin
への HTTP リクエストとすることで、フラグが得られます。ただし handleRoot
は script
を ps2pdf daemon に送信する前に、file
コマンドと ps2ps
コマンドを用いて、script
が PostScript ファイルであることを検証します。従って、script
を PostScript と HTTP リクエストの polyglot にする必要があります。
X-Forwarded-For ヘッダーの偽装
特に細工をせず http://frontend:5000/converter
にリクエストすると、proxy が X-Forwarded-For: 123.45.67.100
というヘッダーを付与するため、script
は 123.45.67.100
(frontend) に送信されます。
クライアントからのリクエストに X-Forwarded-For: XXXXXXXX
をつけておくと、proxy はその末尾に frontend の IP アドレスを追記します。従って backend が受け取る HTTP リクエストでは X-Forwarded-For: XXXXXXXX, 123.45.67.100
となっており、SSRF の送信先となる末尾の IP アドレスは相変わらず 123.45.67.100
です。
実は、クライアントからのリクエストに X-Forwarded-For ヘッダーを複数つけておくと、backend の handleRoot
で req.Header.Get("X-Forwarded-For")
した結果は 1 個目の X-Forwarded-For ヘッダーとなり、proxy による追記を受けません。これによって、backend が読み取る X-Forwarded-For ヘッダーを任意の値にすることができます。
この原理を説明します。
クライアントは X-Forwarded-For ヘッダーを 2 つつけたリクエストを送信します:
X-Forwarded-For: XXXXXXXX X-Forwarded-For: YYYYYYYY
すると、proxy は 2 つ目の X-Forwarded-For ヘッダーに frontend の IP アドレスを追記します:
X-Forwarded-For: XXXXXXXX X-Forwarded-For: YYYYYYYY, 123.45.67.100
(同名のヘッダーが複数ある場合、そのヘッダーの combined field value は個々の header value を上から順にカンマで join したリストです (RFC 9110)。したがって、リストの末尾に追記する意図で proxy が一番下の X-Forwarded-For ヘッダーの末尾に IP アドレスを追記する、という挙動はまっとうなものです。)
backend の handleRoot
は req.Header.Get("X-Forwarded-For")
というコードで X-Forwarded-For ヘッダーの値を取得しますが、req.Header.Get
は重複するヘッダーが複数ある場合に先頭の値、今回の場合は "XXXXXXXX"
を返します。従って XXXXXXXX
を適当に差し替えることで、handleRoot
が読み取る X-Forwarded-For ヘッダーを任意の値にすることができます。
余談: ボツ方針
ちなみに、X-Forwarded-For ヘッダーが 1 個もない HTTP リクエストを backend に届けることができれば、それでも backend への SSRF ができます。
X-Forwarded-For ヘッダーをどうにか除去できないかと proxy 周りを眺めたのですが、結局除去方法が思いつかなかったのでこの方針は使いませんでした。
X-Forwarded-For ヘッダーを除去できた仮定のもとで、backend にリクエストが飛ぶ原理は次の通りです。
handleRoot
でリクエストを処理する際、X-Forwarded-For ヘッダーがないのでx_forwarded_for := req.Header.Get("X-Forwarded-For")
はゼロ値 ""
となります。
すると、 ips := strings.Split(x_forwarded_for, ", ")
は []string{""}
となります。Go のドキュメントによれば、strings.Split
を使って空文字列を split すると、結果は空のスライスではなく[]string{""}
となるようです。個人的には、空のスライスを期待するのですが。
If s does not contain sep and sep is not empty, Split returns a slice of length 1 whose only element is s.
https://pkg.go.dev/strings#Split
従って、ps2pdf := ips[len(ips)-1]
は ""
となります (strings.Split
の謎仕様のおかげで index out of range が発生せずに済みます)。
すると tcp_addr, err := net.ResolveTCPAddr("tcp", ps2pdf+":3000")
は ":3000"
のパース結果、つまり &net.TCPAddr{IP:net.IP(nil), Port:3000, Zone:""}
となります。
このアドレスに DialTCP
するとローカルに飛んでくるので、backend から backend への TCP 通信となります。
If the IP field of raddr is nil or an unspecified IP address, the local system is assumed.
https://pkg.go.dev/net#DialTCP
PostScript と HTTP の polyglot
作成した script
はこちらです。
%! /admin HTTP/1.1 %a:a ( Host: backend:3000 Client-Ip: 127.0.0.1 a: )
要件
SSRF を利用してフラグを得るために、script
が以下の要件を満たすようにします。
file
コマンドの実行結果にPostScript document text
が含まれるようにするps2ps
コマンドに与えてもエラーを起こさない/admin
への HTTP リクエストとして解釈でき、Client-Ip: 127.0.0.1
ヘッダーを持つ
Polyglot を組み立てる
どのようにして上記の polyglot を構成したのかを、順を追って説明します。
まず file
コマンドに PostScript document text
を出させる方法を調べました。file
コマンドのソースコードを雑に grep すると、magic/Magdir/printer
がヒットします。これはファイル種別を認識するための magic number が記載されているファイルで、ファイルの記法は man magic
にて説明されています。
8 行目の記述から、ファイルの先頭が %!
であれば PostScript document text
と認識されることがわかります。
0 string %! PostScript document text
このファイルを読み進めると、他にも \004%!
で始まるファイルや \033%-12345X@PJL
で始まり途中に %!
があるファイルでもよいことがわかりますが、制御文字が入ると余計しんどくなりそうなので %!
でファイルを始めることにします。
今度は HTTP の視点で見てみます。%!
で HTTP リクエストを開始しなければならないので面食らいますが、実は RFC 9110, RFC 9112 を読むと、メソッドには結構色々な記号を使えることがわかります。
method = token
token = 1*tchar tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
というわけで、実は %!
は HTTP メソッドになることができます。handleAdmin
にはメソッドをチェックしている箇所がないので、%!
メソッドでリクエストができるはずです。では、/admin
への %!
メソッドリクエストを書いてみます。ただしフラグを得るために Client-Ip: 127.0.0.1
ヘッダーが必要なので、それもつけておきます。
%! /admin HTTP/1.1 Host: backend:3000 Client-Ip: 127.0.0.1
さて、これをどうにか PostScript として解釈できるようにしましょう。ちなみにこの問題を解くまで PostScript を書いたことはなく、資料を見ながら色々試行錯誤しました。
PostScript におけるコメントは %
から行末までです。従って、1 行目はコメントとして無視されます。複数行コメントの記法はないようですが、文字列リテラル (...)
は複数行にまたがることができます。従って、次のように残り部分を丸括弧で囲えば ps2ps
チェックに成功します。
%! /admin HTTP/1.1 ( Host: backend:3000 Client-Ip: 127.0.0.1 )
ですが、このままでは HTTP リクエストの構文を満たしません。閉じ括弧の方は適当に Foo: )
としてやれば良いのですが、開き括弧が厄介です。RFC 9110 において、ヘッダー名は次のように定義されています。
field-name = token
前掲した token
の定義と合わせると、開き括弧が field-name
に出現することはできないとわかります。そこで Foo: (
のように括弧を field-value
の中に書いてやれば HTTP の構文を満たすのですが、今度は ps2ps
チェックに引っかかるようになります。いま、未完成の polyglot は次のようになっています。
%! /admin HTTP/1.1 Foo: ( Host: backend:3000 Client-Ip: 127.0.0.1 Foo: )
これを PostScript として解釈すると Foo: (文字列)
というコードになっていますが、Foo:
という識別子が定義されていないのでエラーが発生します。識別子を定義するには /Foo: 0 def
などと書いておけば良いのですが、今度は HTTP リクエストの構文を満たさなくなります。field-name
には /
が出現することもできないので、field-name
を用意して /
を field-value
に追いやる必要があります。ですがそもそも Foo:
を定義したくなったのは (
が field-name
に出現できないから field-value
に追いやりたい、というものでした。これでは堂々巡りです。
PostScript で定義済みの識別子を使って field-name
を構成できれば良さそうな気もしますが、これもうまくいきません。HTTP ヘッダーとしては field-name
の直後に :
を書く必要があるのですが、PostScript において :
はアルファベットと同様に識別子に使うことができる普通の文字なので、field-name:
全体が一つの識別子としてパースされます。PostScript の定義済みの識別子の中にコロンを含むものはおそらくないでしょう。
%
は field-name
に含めることができるので、 newpath%:
のようにしてコロンを無視することはできます。しかしそうすると field-value
部分もコメントアウトされてしまうので、肝心の (
を導入することができません。
PostScript における特殊文字は ()<>[]{}/%
なのですが、このうち field-name
に出現できるのは %
のみです。頼みの綱の %
ですが、PostScript のコメントも HTTP のヘッダーも行単位で解釈されるので、まさに「あちらを立てればこちらが立たず」という状態でした。CR・LF の解釈で差を生んでくれないかとかも試しましたが、ダメでした。
突破口となったのは、HTTP の Obsolete Line Folding です。これは、スペースから始まる行が新しいヘッダーではなく前の行の field-value
の続きであると解釈される仕様です。つまり、「PostScript におけるコメントを脱出するが、HTTP における field-value
を脱出しない」ことができます。
そうして出来上がったのが冒頭のペイロードです。
%! /admin HTTP/1.1 %a:a ( Host: backend:3000 Client-Ip: 127.0.0.1 a: )
2行目は PostScript のコメントであり、HTTP ヘッダー %a
でもあります。3行目は PostScript における文字列リテラルの開始ですが、HTTP としては Obsolete Line Folding により 2 行目 の field-value
の続きと解釈されます。その後は PostScript としては文字列リテラル、HTTP としてはヘッダーの羅列となっています。これで polyglot の完成です。
あとはこの script
を http://frontend:5000/converter
にアップロードするようなリクエストを投げればフラグが得られます。
Request:
POST /converter HTTP/1.1 Host: ps-converter.2023.ricercactf.com:51514 X-Forwarded-For: backend X-Forwarded-For: 0.0.0.0 Content-Type: multipart/form-data; boundary=---------------------------386748468817694655632698534101 Content-Length: 314 -----------------------------386748468817694655632698534101 Content-Disposition: form-data; name="file"; filename="payload.ps" Content-Type: application/postscript %! /admin HTTP/1.1 %a:a ( Host: backend:3000 Client-Ip: 127.0.0.1 a: ) -----------------------------386748468817694655632698534101--
Response:
HTTP/1.1 200 OK Server: nginx/1.18.0 Date: Mon, 24 Apr 2023 08:28:12 GMT Content-Type: application/pdf Content-Length: 128 Connection: keep-alive Age: 0 HTTP/1.1 200 OK Content-Type: text/plain Date: Mon, 24 Apr 2023 08:28:12 GMT Content-Length: 26 RicSec{0mg_It_d035nt_l00k_anyth1n9_f6b0c427d3e2}
本来は ps2pdf の結果をレスポンスボディとして返すエンドポイントなので、レスポンスボディが HTTP レスポンスになっている入れ子構造になっていますね。
別解
apolloteapot さんの writeup を読んだところ、なんと quit
という命令を使うことで早々に PostScript の評価を終わらせることができたようです。
%!GET /admin HTTP/1.1 quit%: dummy Host: backend Client-Ip: 127.0.0.1
私も「quit
的な命令があればいけるかも」というのは思いついていたのですが、ググり力が足りず quit
命令の存在には気づけませんでした。exit
とか試して「これじゃないな〜」とか言ってました。まあおかげで HTTP ヘッダーの仕様に詳しくなったのでよしとします。
余興
文字列リテラルで polyglot を作るとシンタックスハイライトしたときにあんまり美しくないと思ったので、文字列リテラルを使わないペイロードも考えてみました。
%! /admin HTTP/1.1 %a:a /Client-Ip: {} def /127.0.0.1 {} def /Host: {} def /backend:3000 {} def Host: backend:3000 Client-Ip: 127.0.0.1
まあ、出てくる識別子を全部定義するだけです。Obsolete Line Folding が強すぎて割となんでも書けてしまいます。ところで 127.0.0.1
という名前のマクロを定義できるのはちょっとびっくりです。
おわりに
冒頭にも書きましたが、国内の学生チームの中で 1 位を取れたことを嬉しく思います。もちろんこれは私の力で達成したことではなく、他の問題を解いてくれたチームメイトのおかげです。
思い返すと、私が CTF を始めたきっかけは、私が大学 1 年のときに TSG が SECCON で優勝し、「CTF にめちゃくちゃ強いチームが私のすぐそばにあるのか。せっかくだから教えを請おう」と思ったことでした。大会や「1 位」の基準は違うものの、「TSG が CTF で 1 位をとる」という記録に今度はプレイヤーとして貢献できたのがとても感慨深いです。これがまた誰かの CTF を始めるきっかけになっていたとしたら、嬉しく思います。このような機会を用意してくださった運営や作問者のみなさま、ありがとうございました。
最後に宣伝で恐縮ですが、東大の学生で CTF に興味がある方はぜひ TSG に入部してください。S セメスターでは初心者向けの CTF 勉強会を予定しているので、今が始めどきです。お待ちしています。