Post

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é!

V1T CTF 2026 Writeup

Certificate

1. BasicQNA

image.png

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.

image.png

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.

image.png

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!

image.png

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.

image.png

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.

image.png

image.png

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.

image.png

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.

image.png

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.

image.png

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.

image.png

URL decode ra, mình thu được: backup_name=daily-contracts; echo HOME=$HOME; pwd; ls -la

image.png

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?

image.png

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.

image.png

Đây là 3 file mà attacker đã cố gắng đọc.

image.png

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

image.png

Đáp án đã lộ diện ngay từ câu trước!

Answer: Ich1ck3nPlus

Flag: v1t{llm_c0uld_s0lv3_th1s_ez_chall3ng3!!!}

2. Green Goblin

image.png

Đề 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"

image.png

Mình dump file này ra để kiểm tra chi tiết:

1
vol -f GreenGoblin.raw windows.dumpfiles --virtaddr 0xaa083c41b650

image.png

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.

image.png

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ự.

image.png

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.

image.png

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.

image.png

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 = 0x38603330
  • uStack_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.

image.png

Ở 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.

image.png

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

image.png

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).

image.png

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.

image.png

Cấu trúc của một Cell bao gồm 2 phần:

image.png

  • 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 đến 7 ➔ Số DƯƠNG (Free - Cell đã bị xóa).
  • Nếu chữ số đó từ 8 đến F ➔ 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

image.png

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.

image.png

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.

image.png

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 00 là số lượng value bên trong khóa này là 4 Values.
  • Trường Value List Offset a0 30 18 00 là địa chỉ trỏ đến Value List chứa 4 Value (0x1830a0 cộng thêm 0x1000 Base Block = 0x1840a0).

image.png

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

image.png

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

image.png

Sau khi tìm được vị trí, mình tìm đến node key đó để kiểm tra

image.png

  • 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.

image.png

  • 4031 1800 -> Logical 0x183140 -> Physical 0x184140 Đây là cho Config
  • e031 1800 -> Logical 0x1831e0 -> Physical 0x1841e0 Đây là cho Cfg

Kiểm tra Value Key Cfg, mình thấy có điểm bất thường.

image.png

  • 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,

image.png

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 0xFFFFFF80 khi đổ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.

image.png

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.

image.png

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'))

image.png

Flag: V1T{f4r3_w3ll_buddy}

This post is licensed under CC BY 4.0 by the author.