Ricerca CTF 2023 writeup (ps converter)

Ricerca CTF 2023 にチーム TSG として参加しました。全チーム中 10 位で、また賞金獲得条件を満たす国内の学生チーム1の中では 1 位となりました。

私は主に Web の問題を解いていました。そのうち ps converter の writeup を書いていきます。

ps converter

概要

backend の handleRoot は、X-Forwarded-For ヘッダーの末尾の IP アドレス2TCP 接続して 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 リクエストとすることで、フラグが得られます。ただし handleRootscript を 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 というヘッダーを付与するため、script123.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 の handleRootreq.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 の handleRootreq.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 の完成です。

あとはこの scripthttp://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 勉強会を予定しているので、今が始めどきです。お待ちしています。


  1. 私は現在修士 2 年です
  2. 実は IP アドレスでなくてもよく、ホスト名を指定するとよしなに名前解決される