V1T CTF 2026 Writeup
Đây là writeup cho phần forensics ở giải V1T CTF 2026. Giải đấu lần này mang đến khá nhiều thử thách thú vị và đa dạng. Dưới đây là phần tóm tắt và lời giải chi tiết cho các challenge mà mình đã giải quyết được. Cùng điểm qua từng bài một nhé!
1. BasicQNA
Một câu hỏi khởi động nhẹ nhàng. Đúng với tên gọi BasicQNA, mình chỉ cần trả lời các câu hỏi.
Q1. What are the attacker IP address and victim IP address? Format: attackerIP,victimIP
Kiểm tra tab Statistics > Conversation, mình nhận thấy lượng traffic khá lớn giữa 2 địa chỉ như hình dưới đây, một dấu hiệu đặc trưng của hành vi dò quét.
Answer: 172.29.9.159,13.212.67.96
Q2. What SSH service/version is running on the victim? Format: service_version distro_version
Tìm kiếm các kết nối ssh (port 22) giữa 2 địa chỉ IP đáng ngờ, mình dễ dàng thấy ngay đáp án.
Answer: SSH-2.0-OpenSSH_10.2p1 Ubuntu-2ubuntu3.2
Q3. What tool did the attacker use for reconnaissance?
Mình thử lọc các gói HTTP liên quan đến công cụ dò quét phổ biến nhất là nmap, và đoán trúng phóc!
Answer: Nmap
Q4. Which TCP stream shows that the attacker successfully created a temporary admin account? Format: tcp.stream eq XXXX
Lọc theo giao thức HTTP, mình nhận thấy từ frame 17848, attacker bắt đầu rà quét web bằng cách gửi hàng loạt request GET. Thay vì phân tích toàn bộ PCAP, mình giới hạn phạm vi từ frame này và tập trung vào các request trả về mã 200 OK để tìm endpoint bị khai thác.
Gói tin đầu tiên nhận phản hồi 200 OK chính là phiên đăng nhập thành công.
Answer: tcp.stream eq 4491
Q5. What is the temporary admin account created by the attacker? Format: username@domain
Mình có thể thấy ngay đáp án nằm trong kết quả ở câu trước.
Answer: support_c30cde@corpvault.local
Q6. What is the CVE of the abused technique?
Endpoint wpgmp_temp_access_ajax của plugin WP Maps Pro mắc lỗi unauthenticated privilege escalation. Vì nonce bị lộ và action đăng ký qua wp_ajax_nopriv_, kẻ tấn công dễ dàng tạo tài khoản Administrator và lấy magic login URL để đăng nhập không cần xác thực.
Answer: CVE-2026-8732
Q7. Which user-controlled parameter in the backup feature was abused to achieve RCE?
Sau khi có tài khoản, attacker tiếp tục truy cập các endpoint khác. Vẫn với bộ lọc cũ, mình tìm thấy response đáng chú ý dưới đây.
Follow stream, mình phát hiện attacker đã dùng quyền admin để truy cập trang Maintenance và thực hiện Command Injection.
URL decode ra, mình thu được: backup_name=daily-contracts; echo HOME=$HOME; pwd; ls -la
Như bên dưới, giá trị của tham số backup_name bị đưa thẳng vào shell command.
echo Building backup package: daily-contracts; echo HOME=$HOME; pwd; ls -la
Ký tự ; đã khiến hệ thống thực thi thêm các lệnh echo HOME=$HOME, pwd và ls -la.
Answer: backup_name
Q8. What are the two files that the attacker read to obtain the victim’s information after gaining command execution?
Mình lọc các gói tin POST và hiển thị value thành một cột trên Wireshark để dễ theo dõi hơn.
Đây là 3 file mà attacker đã cố gắng đọc.
Dù cả hai file /var/tmp/secret.txt và /app/static/.env đều chứa mã GitHub, chúng có chút khác biệt về chính tả ở tên hiển thị (có và không có chữ “c”).
Answer: /app/static/.env,/app/templates/.env
Q9. What is the found Github ID? Format: final magic string
Đáp án đã lộ diện ngay từ câu trước!
Answer: Ich1ck3nPlus
Flag: v1t{llm_c0uld_s0lv3_th1s_ez_chall3ng3!!!}
2. Green Goblin
Đề bài cho một file Dump Memory (GreenGoblin.raw) cùng với một đoạn mô tả chỉ ra vị trí của 5 mảnh flag nằm rải rác trong OS internals. Các chữ cái viết tắt R, KO, D, EL, và M tương ứng với các forensic artifacts tiêu chuẩn:
- R: Registry
- KO: Kernel Objects
- D: Disk
- EL: Event Logs
- M: Memory
Ban đầu, mình dùng pslist và psscan để tìm các tiến trình lạ nhưng không thấy gì nổi bật. Sau đó chuyển hướng tìm tất cả file .exe, mình phát hiện một file đáng ngờ tên GreenGoblin (1).exe.
1
$ vol -f GreenGoblin.raw windows.filescan | grep ".exe"
Mình dump file này ra để kiểm tra chi tiết:
1
vol -f GreenGoblin.raw windows.dumpfiles --virtaddr 0xaa083c41b650
Phân tích file qua Ghidra, đây là các function trọng tâm:
Đối với Registry (R)
Trong GenerateRegistryArtifact(), hàm RegCreateKeyExW tạo key tại 0xffffffff80000001 với đường dẫn Software\Policies\Microsoft\CloudFiles. Vị trí này ngụy trang giống hệt Group Policy thật của Windows nên rất khó bị phát hiện khi lướt Registry.
Fragment được ghi ngay sau đó. Lệnh builtin_memcpy chép 7 byte vào local_26 + 7, tiếp đến RegSetValueExA lưu 6 byte đó vào value mang tên DiagnosticData.
Chuỗi nguồn trên Ghidra hiển thị là ’%L0. Dấu backslash ở đầu chỉ là ký tự escape của Ghidra cho mã C, không hề tồn tại trong bộ nhớ. Thực chất ' và ‘ đều chung một byte (0x27). Vậy nội dung thật chỉ gồm 6 ký tự.
Fragment 1: ‘%L0
Đối với Kernel Objects (KO)
Kernel Object là tài nguyên do Kernel quản lý (như file, shared memory, mutex…). Chế độ user-mode không thể tác động trực tiếp mà phải xin Kernel cấp một Handle để thao tác gián tiếp.
Vì Kernel Object có thể đặt tên và dễ dàng tra cứu qua WinObj hoặc Process Explorer, đây chính là nơi hoàn hảo để giấu fragment.
Tại GenerateMemoryArtifact(), hàm CreateFileMappingW được gọi bằng handle INVALID_HANDLE_VALUE. Điều này tức là không hề ánh xạ file nào trên ổ cứng mà chỉ xin cấp phát một vùng RAM 4 KB. Vùng nhớ này mang tên Local\9cGb0c thuộc namespace *\Sessions<n>\BaseNamedObjects*.
Tên vùng nhớ đó cũng chính là fragment. Chẳng cần dump RAM hay debug, mình chỉ việc chạy chương trình rồi mở WinObj lên là thấy ngay object này.
Fragment 2: 9cGb0c
Đối với Disk (D)
Alternate Data Stream (ADS) của NTFS cho phép đính kèm luồng dữ liệu ẩn vào file qua cú pháp tên_file:tên_stream. Nội dung này hoàn toàn tàng hình trước Explorer, không làm tăng kích thước file và lệnh dir thông thường cũng không tra ra được.
Hàm CreateFileW bên trong GenerateADSArtifact() mở đường dẫn C:\Users\Public\Downloads\config.ini:hidden. Ở đây, dữ liệu bị tuồn vào luồng ẩn tên hidden, trong khi file config.ini bên ngoài không có dấu hiệu gì bất thường.
Fragment nằm gọn trong hai biến được nạp ngay trước khi gọi lệnh WriteFile:
local_1b = 0x38603330uStack_17 = 0x3930
Do kiến trúc x86 dùng little-endian (byte thấp nằm trước), mình đọc ngược từng giá trị và ghép lại theo thứ tự bộ nhớ:
0x38603330 → 30 33 60 38 → 0 3 ``` 8
0x3930 → 30 39 → 0 9
Lệnh WriteFile ghi chính xác 6 byte, ghép thành chuỗi 03`809.
Fragment 3: 03`809
Đối với Event Logs (EL)
Event Log là hệ thống nhật ký chung của Windows và các ứng dụng. Vì lượng log thật luôn rất lớn và dày đặc, một bản ghi giả vờ bị lỗi sẽ cực kỳ dễ dàng chìm nghỉm vào đám đông.
Ở hàm GenerateEventLogArtifact(), RegisterEventSourceW đăng ký log dưới cái tên Application Error. Đây là tên thật của hệ thống khi app bị crash, giúp bản log giả ngụy trang hoàn hảo.
Tiếp theo, ReportEventW ghi đè sự kiện với wType = 4 và dwEventID = 0x1000. Chuỗi text trong log mô phỏng hệt như thông báo crash chuẩn:
1
Faulting application name: ctfmon.exe, version: 10.0.22621.1, faulting module path: cC50C_
Phần faulting module path vốn dĩ chứa đường dẫn file DLL, nay bị tráo thành fragment. Chỉ việc mở Event Viewer, lọc mục Windows Logs → Application theo Application Error là mình tóm được nó.
Fragment 4: cC50C_
Đối với Memory (M)
Trở lại với hàm GenerateMemoryArtifact() lúc trước.
Nếu phần trước fragment chỉ là cái tên vùng nhớ dễ dàng bị WinObj điểm mặt, thì lần này sau khi MapViewOfFile gắn bộ nhớ vào tiến trình, hàm memcpy đã bí mật ghi 6 byte _dEbCN vào offset 0x250 (cách 592 byte tính từ đầu).
Chuỗi này chỉ sống duy nhất trên RAM và sẽ bốc hơi khi tiến trình kết thúc. Để bắt được nó, mình phải dùng Process Hacker hoặc procdump để dump RAM khi app đang chạy, hoặc mở file mapping Local\9cGb0c và đọc trực tiếp từ offset 0x250.
Đúng là một mũi tên trúng hai đích: tên object giấu trong Kernel namespace, còn dữ liệu thì giấu thẳng vào bộ nhớ tiến trình.
Fragment 5: _dEbCN
Ghép 5 fragment thu thập được theo thứ tự:
1
'`%L`09cGb0c03`809cC50C__dEbCN
Chuỗi nhìn qua thì khá vô nghĩa, nhưng biết flag có format chuẩn là V1T{…}, mình đem so sánh 4 ký tự đầu tiên và nhận ra quy luật:
- Ký tự ‘ (mã 39) lệch với V (mã 86) đúng 47 đơn vị,
- Ký tự % (mã 37) lệch với T (mã 84) đúng 47 đơn vị,
- Ký tự L (mã 76) lệch với { (mã 123) đúng 47 đơn vị,
- Riêng dấu backtick (mã 96) so với số 1 (mã 49) chênh lệch nhau đúng 47 đơn vị (theo vòng tròn).
→ Không còn nghi ngờ gì nữa, tác giả đã dùng mã hóa ROT47.
1
2
3
4
5
6
7
def rot47(s):
return ''.join(
chr(33 + (ord(c) - 33 + 47) % 94) if 33 <= ord(c) <= 126 else c
for c in s
)
print(rot47("'`%L`09cGb0c03`809cC50C__dEbCN"))
Flag: V1T{1_h4v3_4_b1g_h4rd_r005t3r}
3. Nice Try
May mắn thay, mình không biết bài THE MEDUSA nào vào năm 2023 😭
Khi giải nén các tệp của thử thách, mình nhận được một tệp NTUSER.DAT và một tệp văn bản nhỏ chứa gợi ý (nhớ là bật file ẩn lên nhé).
“Decrypt hidden registry slack by hashing a deleted key’s FILETIME with its physical-offset-sorted CRC32 payload.”
Khôi phục khóa và trích xuất FILETIME của khóa bị xóa
Phần gợi ý có nói “…by hashing a deleted key’s FILETIME…“, điều này nghĩa là flag thực chất đã bị hash bằng giá trị FILETIME của một khóa Registry nào đó bị xóa. Điều đó có nghĩa là trước tiên, mình cần phải tìm khóa bị xóa đó.
Trong Registry, dữ liệu được chia thành các khối lớn gọi là hbin (Hive Bin, thường có kích thước 4KB).
Bên trong hbin là các Cell. Một Cell là đơn vị lưu trữ cơ sở cho mọi thứ: key, value, dữ liệu bảo mật, v.v.
Cấu trúc của một Cell bao gồm 2 phần:
- Size Field: Chiếm đúng 4 byte đầu tiên của Cell.
Ví dụ ở đây, 4 byte đầu tiên của Cell tại offset 0x1020 là: A8 FF FF FF. Do hệ thống lưu trữ theo chuẩn Little-Endian, mình phải đảo ngược thứ tự các byte này lại để đọc đúng giá trị toán học: FF FF FF A8. Nhìn vào số Hex ngoài cùng bên trái.
- Nếu chữ số đó từ
0đến7➔ Số DƯƠNG (Free - Cell đã bị xóa). - Nếu chữ số đó từ
8đếnF➔ Số ÂM (Allocated - Cell đang được sử dụng). - Số của mình là
FF FF FF A8, bắt đầu bằng chữF. Do đó, đây là một giá trị ÂM, đồng nghĩa với việc Cell này là Allocated - Dữ liệu (Cell Data): Chứa nội dung thực sự.
trong mã nhị phân, một “Khóa” luôn luôn được đại diện bởi một cell có signature là nk (Node Key) hay (6E 6B).
Mục tiêu bây giờ là tìm một khóa đã bị xóa do thủ phạm cố tình xóa giấu vết.
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
filepath = "NTUSER.DAT"
with open(filepath, 'rb') as f:
data = f.read()
file_size = len(data)
hbin_offset = 4096
while hbin_offset + 32 <= file_size:
if data[hbin_offset : hbin_offset+4] != b'hbin':
hbin_offset += 4096
continue
hbin_size = int.from_bytes(data[hbin_offset+8 : hbin_offset+12], 'little')
if hbin_size < 32 or hbin_size % 4096 != 0:
hbin_offset += 4096
continue
cell_offset = hbin_offset + 32
hbin_end = min(hbin_offset + hbin_size, file_size)
while cell_offset + 8 <= hbin_end:
n = int.from_bytes(data[cell_offset : cell_offset+4], 'little', signed=True)
if n == 0:
break
cell_size = abs(n)
if cell_size < 8 or cell_size % 8 != 0:
break
# BỘ LỌC: Chỉ xử lý nếu ô này có chữ ký 'nk' (Node Key - Khóa)
if data[cell_offset+4 : cell_offset+6] == b'nk':
status = 'free' if n > 0 else 'alloc'
print(f"{hex(cell_offset)} {status}")
cell_offset += cell_size
hbin_offset = hbin_end
Sau khí lấy được vị trí của khóa bị xóa, giờ là lúc trích xuất giá trị FILETIME.
Tìm tới vị chí của khóa này mình có thể thấy 8 byte 80ac bfef b194 db01 chính là Giá trị FILETIME.
Lùng CRC32 payload
Phần gợi ý có nói “…with its physical-offset-sorted CRC32 payload….”, điều này nghĩa là flag đã bị hash bằng cả một CRC32 payload nào đó.
Trong cấu trúc dữ liệu của Registry, một khóa nk sẽ trỏ đến một danh sách vl (Value List) và danh sách đó trỏ đến các vk (Value Key), cuối cùng vk sẽ trỏ đến các Data Cell.
Tiếp tục nhìn vào nội dung của khóa này ta có thể thấy:
- Trường Number of Values
04 00 00 00là số lượng value bên trong khóa này là 4 Values. - Trường Value List Offset
a0 30 18 00là địa chỉ trỏ đến Value List chứa 4 Value (0x1830a0cộng thêm 0x1000 Base Block =0x1840a0).
Tìm tới địa chỉ 0x1840a0 mình có vị trí của 4 value key.
2030 1800 → đảo byte → 0x00183020 + 0x1000 = 0x00184020 8030 1800 → đảo byte → 0x00183080 + 0x1000 = 0x00184080 4030 1800 → đảo byte → 0x00183040 + 0x1000 = 0x00184040 6030 1800 → đảo byte → 0x00183060 + 0x1000 = 0x00184060
Mình sẽ không theo thứ tự trong vaule list mà sẽ sắp 4 offset tăng dần rồi ghép theo physical order: d0 3e 17 cb.
Target CRC32 Hash: d03e17cb
Tìm dữ liệu ẩn nằm trong Slack Space
Bây giờ, mục tiêu của mình là rà quét toàn bộ file NTUSER.DAT để tìm một khóa đang được allocated mà tên của nó khi đem băm CRC32 sẽ khớp với chuỗi d0 3e 17 cb này.
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
data = open("NTUSER.DAT", "rb").read()
o = 4096
while o < len(data):
if data[o:o+4] != b'hbin':
o += 4096
continue
end = o + int.from_bytes(data[o+8:o+12], 'little')
c = o + 32
while c + 8 <= end:
n = int.from_bytes(data[c:c+4], 'little', signed=True)
if n == 0 or abs(n) % 8 != 0: break
if n < 0 and data[c+4:c+6] == b'nk':
raw = data[c+80 : c+80+int.from_bytes(data[c+76:c+78], 'little')]
crc = 0xFFFFFFFF
for b in raw:
crc ^= b
for _ in range(8): crc = (crc >> 1) ^ 0xEDB88320 if crc & 1 else crc >> 1
if crc ^ 0xFFFFFFFF == 0xd03e17cb:
print(hex(c), raw.decode('latin-1' if data[c+6] & 0x20 else 'utf-16-le', 'replace'))
exit()
c += abs(n)
o = end
Sau khi tìm được vị trí, mình tìm đến node key đó để kiểm tra
0200 0000: Number of Values = 2. (Khai báo rằng trong thư mục này có 2 Value).0032 1800: Con trỏ dẫn đến Value List. Lật ngược lại là 0x00183200 (Logical Offset). Cộng thêm 0x1000 Base Block, ta có tọa độ vật lý là0x184200.
Tìm đến Value List.
4031 1800-> Logical0x183140-> Physical0x184140Đây là cho Confige031 1800-> Logical0x1831e0-> Physical0x1841e0Đây là cho Cfg
Kiểm tra Value Key Cfg, mình thấy có điểm bất thường.
766b: Chữ kývk(Value Key).0c00 0000: Khai báo dung lượng là 12 bytes. (Điểm bất thường nằm ở đây)6031 1800: Chỉ điểm nơi giấu đồ thực sự là0x183160(Tọa độ vật lý:0x184160)4366 67: Tên là Cfg.
Tìm tới Data Cell tại 0x184160,
Có thể thấy tại Size Field,
- Lật ngược nó lại, ta có giá trị Hex là
0xFFFFFF80. - Trong Registry, nếu một ô đang được sử dụng (Allocated/Live), kích thước của nó sẽ được lưu dưới dạng số âm (Two’s complement).
- Số Hex
0xFFFFFF80khi đổi ra số thập phân (số nguyên âm 32-bit) chính xác là 128. - Bỏ đi dấu trừ, ta biết được hệ điều hành đã cấp phát một Cell có tổng kích thước vật lý là 128 bytes.
- 128 bytes (Tổng) - 4 bytes (Size Field) = 124 bytes.
→ Vì ở trên khai báo chỉ có 12 bytes, suy ra Slack Space: 124 - 12 = 112 bytes.
Giải mã
Dựa vào gợi ý “hashing a deleted key’s FILETIME with its physical-offset-sorted CRC32 payload”, thuật toán giải mã sẽ tuân theo logic 3 bước sau:
Kết hợp hai mảnh dữ liệu mình đã lấy được.
- FILETIME: [80 ac bf ef b1 94 db 01], vì đây là raw binary. Trong cấu trúc Registry, dấu thời gian được hệ điều hành lưu dưới dạng một khối 8 bytes. Khi bạn trích xuất nó trực tiếp từ mã Hex của file, nó giữ nguyên bản chất là các byte thô, nên phải biểu diễn dưới dạng mảng byte.
- CRC32: b”d03e17cb”, vì đây là một chuỗi ký tự ASCII. Khi bạn ghép phần dữ liệu của 4 giá trị bị xóa lại với nhau, kết quả tác giả giấu bên trong thực chất là văn bản. Ký hiệu b”” được dùng để ép kiểu chuỗi văn bản này byte, từ đó mới có thể nối với mảng FILETIME ở trên.
→ Key = [80 ac bf ef b1 94 db 01] + b”d03e17cb”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import hashlib
payload = bytes.fromhex(
"fffd57fcb1e89478ea709d63b2672ba7215ba9d14a5ec24caa14cdb240e68896"
"ce7b5e9a429bebeb292966e087bf5e733abafb0fb8a6e9365c0160ef24f5fcd4"
"23005a282de8fb28f1037912650b4f1839f31771c3388b22df2085ae10183890"
"f73af4fdf9922ed2c534000000000000"
)
key = bytes.fromhex("80acbfefb194db01") + b"d03e17cb"
key_stream = b""
for i in range(4): # Chạy 4 vòng để tạo đủ 128 bytes (SHA256 trả về 32 bytes/vòng)
counter = i.to_bytes(4, 'little')
key_stream += hashlib.sha256(key + counter).digest()
# Giải mã bằng phép toán XOR bitwise
decrypted_bytes = bytes(p ^ k for p, k in zip(payload, key_stream))
print(decrypted_bytes.decode('utf-8', errors='ignore'))
Đoạn code trên đã thực hiện việc băm liên hoàn bằng SHA256 kết hợp bộ đếm để tạo ra luồng khóa đủ dài, sau đó tiến hành phép toán XOR bitwise với 112 bytes Payload để gỡ bỏ lớp mã hóa đầu tiên.
Khi thực thi đoạn mã, ta thu được chuỗi văn bản sau:
1
if-you-are-not-human-so-this-is-not-the-flag-bl6qcYi3SDxUmgiRxMTQBwJFq4QcZCTsY9x7YXL2YBNbecvxDinTkXnJKzXVVW
(kèm theo ký tự rác p bị đẩy xuống dòng).
Đoạn chuỗi ký tự lộn xộn phía sau được mã hóa dưới dạng Base62.
1
2
3
4
5
6
7
8
9
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
encoded_flag = "bl6qcYi3SDxUmgiRxMTQBwJFq4QcZCTsY9x7YXL2YBNbecvxDinTkXnJKzXVV"
num = 0
for char in encoded_flag:
num = num * 62 + alphabet.index(char)
b = num.to_bytes((num.bit_length() + 7) // 8, 'big')
print("Decode:", b.decode('utf-8', errors='ignore'))
Flag: V1T{f4r3_w3ll_buddy}










































