# Write-up MiniCTF PIS 2025
### **Category**: Web Challenge
### Cre: Duy Khang
## 1. How to play ctf?

---

- Bài đầu tiên này sẽ yêu cầu chúng ta tìm những mảnh flag được dấu trong source code, tiến hành bật F12 và tìm những thành phần của nó
1. Bật F12 lên và khi chuyển sang console thì ta sẽ tìm được PartA: `PIS{hidden_`

2. Tiếp tục quay lại tab Element mở toàn bộ souce code lên và ta đã thấy được flag PartC ở content của meta là `f12_` và PartD ở alt của thẻ img là `find_`

3. Tiếp tục truy cập đến file style.css và ta đã tìm được PartB `in_`

4. Tiếp tục chuyển đến tab Network nhìn kỹ headers thì ta sẽ thấy PartE là `me}`

=> Từ những mảng PartA,B,C,D,E ta ghép lại được thành flag: `PIS{hidden_in_f12_find_me}`
## 2. Robots Everywhere

---

- Ở bài này ta thấy trang web có giao diện của 1 trò chơi lật thẻ bài, tiến hành xem souce code để xem trò chơi này có liên quan gì đến flag không nhé

- Sau 1 lúc đọc code js của trò chơi thì ta không phát hiện có đoạn nào dẫn đến tìm flag cả, ta thử đổi hướng khác.
- Cùng xem thử trang web có file robots.txt không nhé

- Đúng như dự đoán web có file robots.txt và đã disallow rất nhiều đường dẫn và có thêm 1 `Fake Flag` ở cuối file
- Tiến hành truy cập thử vào từng url thì khi đến /garbarca6677 ta đã thấy có dòng chữ như flag

- Ở đây ta thấy đoạn flag đang được bọc thêm vào trong dấu "..." rất có thể nó đã bị encode và rất giống mã base64, tiến hành decode và ta đã thấy được flag chính thức

=> flag chính thức là `PIS{I_4M_W13UUUUUUUWWUUUUUUUU}`
## 3. TeConCak vs KT

--

- Khi truy cập vào trang web ta thấy nó đã dẫn đến trang login, tiến hành tạo tài khoản và đang nhập vào ta sẽ nhìn thấy giao diện chính

- Mục tiêu chúng ta nhắm đến là làm thế nào đó để mua được flag với giá 1000 coin trong khi lúc tạo tài khoản thì chỉ cho 900 coin.
Tiến hành phân tích source code được cho:

- Ở đây ta sẽ bắt đầu với cơ chế WAF(Web Application Firewall), những dòng phân tích đã được comment sẵn trong code ở dưới
```php
<!-- db.php -->
function waf($s) {
if (is_array($s)) return false; // chặn các payload dạng array
$s = (string)$s;
for ($i=0; $i<2; $i++) { // cái này chống double-encoding
$d = rawurldecode($s);
if ($d === $s) break;
$s = $d;
}
$s = preg_replace('/\/\*.*?\*\//s', '', $s); // xóa comment /**/ -> UN/**/ION hong sài được
$s = preg_replace('/[\x00-\x1F\x7F]/', '', $s); // chặn control chars
// block key SQL nguy hiểm không phân biệt hoa thường
if (preg_match('/\b(select|union|update|delete|insert|drop|alter|truncate|create|replace|sleep|benchmark|load_file|outfile|infile)\b/i', $s)) {
return false;
}
// chặn các ký tự dùng để sử dụng của các hàm như concat(),...
if (preg_match('/[()|&;]/', $s)) return false;
return strlen((string)$s) <= 64;
}
```
- Sau khi đọc các file còn lại, mình thấy các file `index.php`, `buy.php`, `login.php`, `bet.php`... đều đã được WAF lọc input rất cẩn thận, nhưng mà ở file `profile.php` thì mình thấy có 1 điểm yếu và có thể pypass được
```php
<!-- profile.php -->
if (isset($_GET['new_balance']) && waf($_GET['new_balance'])) {
$nb = $_GET['new_balance'];
if (strlen($nb) > 3) {
$msg = "<div class='notice error'>Only 1–3 chars allowed</div>";
} else {
$sql = sprintf("UPDATE coins SET coin=%s WHERE uid=%d", $nb, $uid);
if ($mysqli->query($sql)) {
$msg = "<div class='notice success'>Coins updated!</div>";
} else {
$msg = "<div class='notice error'>Update failed: " . htmlentities($mysqli->error) . "</div>";
}
}
}
```
- biến `$nb` được truyền vào thông quá params `$_GET['new_balance']` được đưa trực tiếp vào truy vấn SQL với giới hạn chỉ 3 kí tự và phải không nằm trong WAF
- Thế sẽ như thế nào nếu mình truyền vào cho nó 1 hằng số khoa học như 1e2,1e3,1e4... tiến hành research thì mình biết được là SQL nó vẫn nhận các hằng số này, thế ta sẽ thử xem được không nhé?


- Và thế là ta đã set được coin lên 1000000000 rồi, tiến hành mua flag thôi

=> FLAG: `PIS{gre4t!!!!_th1s_1s_SQL_Inject10n_vuln3r4b1l1ty}`
## 4. TeConCak vs KT (V2)

--
- Ở V2 này thì server đã filter đi cách sử dụng hằng số khoa học rồi nên bắt buộc ta phải thay đổi cách tiếp cận khác
- Tiến hành phân tích tiếp tục code:
```php
<!-- register.php -->
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$fullname = $_POST['fullname'] ?? '';
$password = $_POST['password'] ?? '';
if (!waf($username) || !waf($fullname) || !waf($password)) {
$msg = "<div class='notice error'>WAF blocked</div>";
} elseif (strlen($username) < 1 || strlen($username) > 32 ||
strlen($fullname) < 1 || strlen($fullname) > 10 ||
strlen($password) < 1 || strlen($password) > 32) {
$msg = "<div class='notice error'>Invalid input</div>";
} else {
$chk = $mysqli->prepare("SELECT 1 FROM users WHERE username=? LIMIT 1");
$chk->bind_param("s", $username);
$chk->execute();
if ($chk->get_result()->num_rows) {
$msg = "<div class='notice error'>Username đã tồn tại, hãy chọn tên khác</div>";
} else {
$stmt = $mysqli->prepare("INSERT INTO users(username, fullname, password) VALUES(?,?,?)");
$stmt->bind_param("sss", $username, $fullname, $password);
$stmt->execute();
$uid = $stmt->insert_id;
$mysqli->query("INSERT INTO coins(coin, uid) VALUES (900, {$uid})");
$msg = "<div class='notice success'>Đăng ký thành công! Bạn có 900 coins.</div>";
}
}
}
```
- Ở phần đăng ký sẽ có 3 params được truyền vào là username, fullname, password với độ dài tối đa cho phép nhập là 32,10,32 và để ý kĩ thì chỉ cần pypass qua được WAF là sẽ lưu lên DB lun mà không có thay đổi gì về value hết.
```php
<!-- profile.php -->
if ($CTF_MODE) {
$username = $_SESSION['username'];
$vuln_sql = "SELECT password FROM users WHERE username='$username' LIMIT 1";
@$mysqli->query($vuln_sql);
}
```
- Ở đây có 1 đoạn code là nó sẽ lấy tham số username và đưa vào để query lun mà không có cần check lại vì đã quá tin vào WAF siêu xịn của hệ thống -> đây là trigger quan trọng dẫn đến lỗi `Second Order SQL Injection`
- Nói đơn giản về Second Order SQL Injection là thay vì khi ta inject mã sql vào thì ta sẽ nhận được phản hồi ngay lun, còn với lổ hỏng này ta sẽ inject mã đọc và phải cần được kích hoạt ở đâu đó trong hệ thống thì nó mới hoạt động.
- Và sau khi chạy xong hàm if trên thì ta xuống đoạn code bên dưới để set lại value
```php
if (isset($_GET['new_balance']) && waf($_GET['new_balance'])) {
$nb = $_GET['new_balance'];
if (strlen($nb) > 3) {
$msg = "<div class='notice error'>Only 1–3 chars allowed</div>";
} else {
$sql = sprintf("UPDATE coins SET coin=%s WHERE uid=%d", $nb, $uid);
if ($mysqli->query($sql)) {
$msg = "<div class='notice success'>Coins updated!</div>";
} else {
$msg = "<div class='notice error'>Update failed: " . htmlentities($mysqli->error) . "</div>";
}
}
}
```
- Ý tưởng đầu tiên của mình là lấy trực tiếp flag từ DB lun, mình nghĩ đến việc set các biến và cộng các chuỗi lại thành câu lệnh `SELECT UNION flag FROM items` như này

- Nhưng thực hiện thì nó quá khó vì WAF đã chặn rất nhiều cách để làm =)). Thế sẽ như thế nào nếu ta thay đổi chiến thuật khai thác vào set coin như v1 nhưng làm cách **VIP PRO MAX** hơn =))
- Tiến hành thử với payload `a' AND @a:=2222#` xem điều gì sẽ xảy ra nhé:
- Khi ta đưa payload này vào register thì ta sẽ vượt qua waf vì không có trường hợp nào bị chặn hết, nó sẽ được phép lưu vào DB
- Sau khi lưu vào DB, khi trigger khi được kích hoạt ta sẽ có câu query là `SELECT password FROM users WHERE username='a' AND @a:=1111#' LIMIT 1` khi chạy lệnh này thì trên server đã được set 1 biến @a với giá trị `1111`
- Sau khi gán xong ta nhập vào params `?new_balance=@a` là xong, thì lúc này $nb=@a và sẽ không nằm trong blacklist và cũng như len >= 3 thỏa mãn hết yêu cầu
- Tiến hành lấy flag



-> Ở đây máy chủ v2 bị lỗi nên ta demo tạm ở v1
## 5. Os command

--

- Ở đây ta thấy đây là trang web điều khiển terminal của hệ thống, tiến hành thử với các câu lệnh `id`, `ls`, `pwd` thì ta sẽ thực thi được, nhưng nếu sử dụng `whoami` thì lại không được

- thử với `ls -la` vẫn không được dù lệnh `ls` sử dụng được, tiến hành xóa từ từ thì `ls -` thì nó báo lỗi chứ không còn in ra dòng chữ `bạn muốn làm hacker ???` nữa -> đã bị limit chỉ cho phép nhập lệnh không quá 4 char, thì ở đây mình có thể sử dụng 1 trick là sẽ như thế nào nếu ta tạo 1 file tên cat và đưa dấu * vào, tên file sẽ được sắp sếp từ a-z và chữ d đang là bé nhất, nên nếu ta đưa cat vào thì sẽ đưa nó lên đầu, khi nhập * vào thì nó sẽ đọc từ trên xuống và cat sẽ được đọc trước rồi đến d_flag..., cùng thử trên local xem có đúng không nhé.

- đúng như ý ta muốn, tiến hành thực thi trên web thôi !!


BUMP!!! mình đã đọc được file flag với 1 ký tự duy nhất
=> FLAG: `PIS{B41_n4y_th4t_l4_e4syzzzz!!!!!!!!}`
## 6. Garbarca’s Lesson

--

- Tiến hành truy cập vào trang web thì nó sẽ dẫn đến trang login
- Tiến hành phân tích code đề bài cho nhé
```php
<?php
function fil($str) {
return str_replace("GarBarCa", "GBC", $str);
}
class x {
public $username;
public $password;
public $isAdmin = false;
public function __construct($username, $password) {
$this->username = $username;
$this->password = $password;
}
public function __wakeup() {
if ($this->isAdmin) {
if (file_exists("flag.php")) {
include "flag.php";
$Secret= '<pre>XinChao Admin! Here is your flag: ' . htmlspecialchars($flag, ENT_QUOTES, 'UTF-8') . "</pre>";
echo $Secret;
} else {
echo "<pre class='error-message'>Lỗi: Không tìm thấy file flag.php.</pre>";
}
} else {
echo "Incorrect credentials.<br>";
}
}
}
// Read GET params (safe default)
$username = isset($_GET['username']) ? $_GET['username'] : '';
$password = isset($_GET['password']) ? $_GET['password'] : '';
$ser = fil(serialize(new x($username, $password)));
$o = @unserialize($ser);
// Debug option to show source
if (isset($_GET['debug'])) {
highlight_file(__FILE__);
}
?>
```
- Ở đây ta thấy, đầu tiên nó sẽ lấy 2 tham số params là username và password tiến hành tạo object x rồi serialize nó bên ngoài có 1 hàm fil dùng để thay thế chuỗi `GarBarCa` thành `GBC`
- Để có được flag thì ta cần làm sao để isAdmin = true
- Phân tích serialize
- thử với username = 'GarBarCa' và password = '123' thì khi serialize ta có `O:1:"x":3:{s:8:"username";s:8:"GBC";s:8:"password";s:3:"123";s:7:"isAdmin";b:0;}` ở đây ta thấy sau username được khai báo s:8 nhưng bên trong chỉ còn "GBC" tức là 3 byte thì -> bị mất 5 byte nên nó sẽ cắn thêm phần đằng sau 5 byte nữa cho đủ rồi mới tới password vậy ta cần thiết kế payload cho đúng cú pháp và sẽ lợi dụng điểm nuốt byte này để thực hiện việc set isAdmin = true và làm cho isAdmin được set sẵn ở cuối sẽ thành byte rác và nó sẽ không đọc đến
- Sau 2 ngày sửa payload thì mình đã tìm được payload có thể pypass thành công với username=`GarBarCaGarBarCaGarBarCaGarBarCaGarBarCa` và password= `nd";s:8:"password";s:3:"ndk";s:7:"isAdmin";b:1;"` tiến hành đi phân tích cách hoạt động của payload này nhé
- sau khi đưa payload vào thì serialize sẽ là `O:1:"x":3:{s:8:"username";s:40:"GBCGBCGBCGBCGBC";s:8:"password";s:48:"nd";s:8:"password";s:3:"ndk";s:7:"isAdmin";b:1;"";s:7:"isAdmin";b:0;}`
- `O:1:"x":3:` thì ta sẽ có 3 property.
- Property 1: ở đây ta thấy username được set s:40 byte để đọc username nhưng vì mình đã đưa 5 chuỗi GarBarCa vào nên nó đã bị thay thế chỉ còn 15 byte tức là bị thiếu 25 byte nữa thì nó sẽ tiến hành đọc tiếp đến `"nd` lun thì payload nó đọc sẽ là `GBCGBCGBCGBCGBC";s:8:"password";s:48:"nd` gán vào username
- Property 2: sẽ là `s:8:"password";s:3:"ndk"`
- Property 3: sẽ là `s:7:"isAdmin";b:1;`
- vì ta chỉ khai báo có 3 property thôi nên những kí tự sau nó sẽ không đọc nữa và coi nó là rác và như thế thì ta đã paypass và set `isAdmin = 1` và đọc được flag
- Tiến hành khai thác


=> Flag: `PIS{D@n1_c0D3_l1kE_GarBarCa-6677}`