← Back to Tomes

แก้โจทย์ Thailand Top Talent 2025 หมวด Network ด้วย ChatGPT {OPEN}Solving Thailand Top Talent 2025 Network Challenges with ChatGPT {OPEN}

กลับมาอีกครั้งกับ Blog ของผมแค่จะมาลง Blog โดยการที่ลองนำโจทย์การแข่งขัน TCTT หรือ Thailand Top Talent ของปี 2025 ให้ chatgpt solve โจทย์หมวด Network

โจทย์หมวด network มีทั้งหมด 4 ข้อดังนี้

- Meet the New Router 100 คะแนน
- Meet the Upgraded Router 100 คะแนน
- Whispers in the wire 200 คะแนน
- Custom Protocol v2 300 คะแนน

มาเริ่มที่ข้อแรก

Meet the New Router

  • ไฟล์ให้วิเคราะห์: thctt2025_open_netsec1_new-router.pcapng
  • ประเภทโจทย์: Forensics / Network (PCAP)
  • เป้าหมาย: ไล่ดึงค่า Data จากทุกแพ็กเก็ต ICMPv6 Echo Request (type=128) แล้วเรียงต่อกันเป็นข้อความ จากนั้นดึง flag ในรูปแบบ flag{32-hex} (MD5)

จากสกรีนช็อตจะเห็นว่า:

  • โปรโตคอล: ICMPv6
  • Type = 128 (Echo request)
  • มีฟิลด์ Data (1 byte) เช่นตัวอย่าง “Data: 66”
  • Identifier = 0x1337, Sequence ไล่เพิ่มทีละ 1
     -> น่าจะเป็นการส่งข้อความทีละ 1 ไบต์ ผ่าน Echo Request หลายๆ แพ็กเก็ต

วิธีทำ (สั้นที่สุดด้วย TShark one-liner)

ใช้ tshark ดึงฟิลด์ไบต์ดิบของ Data จากทุก Echo request แล้วแปลงเป็นข้อความ:

tshark -r thctt2025_open_netsec1_new-router.pcapng \  -Y 'icmpv6 && icmpv6.type==128 && data' \  -T fields -e data.data \| tr -d ':\r\n' \| xxd -r -p \| grep -aoE 'flag\{[0-9a-f]{32}\}'

คำอธิบาย:

  • -Y 'icmpv6 && icmpv6.type==128 && data' กรองเฉพาะ Echo Request ที่มีเลเยอร์ Data
  • -T fields -e data.data ดึงค่าเฮกซ์ของ Data (Wireshark ใช้ฟิลด์ data.data)
  • tr -d ':\r\n' ลบ : และขึ้นบรรทัดใหม่ เพื่อให้เหลือเฮกซ์ติดกัน
  • xxd -r -p แปลงจากเฮกซ์ → ไบต์จริง
  • grep -aoE 'flag\{[0-9a-f]{32}\}' หาแพตเทิร์น flag{md5}

ผลลัพธ์ที่ได้:

Meet the Upgraded Router

ดูเหมือนว่าแฮกเกอร์จะอัปเกรด C2 (Command & Control) ที่ใช้ขโมยข้อมูลเป็นเวอร์ชันใหม่แล้ว ไม่แน่ว่า… payload ที่ส่งออกมาอาจจะไม่ได้มาแบบธรรมดา ๆ เหมือนรอบที่แล้ว… XOR สักหน่อยดีไหม?

  • ไฟล์ให้วิเคราะห์: thctt2025_open_netsec2_upgraded-router.pcapng
  • โปรโตคอล: ICMPv6 Echo (type 128/129)
  • เทคนิค: รวม payload ตาม Identifier/Sequence, ลอง raw / zlib / gzip / XOR (1-byte)
  • สตรีมที่ใช่: type = 128 (Echo Request), ident = 4919

ภาพรวมโจทย์

ไฟล์ PCAP ที่ได้มามีการซ่อนข้อมูลในทราฟฟิก ICMPv6 echo (ping v6) 
ซึ่งเป็นแชนเนลที่ “ดูไม่ผิดปกติ” มากนักสำหรับระบบเฝ้าระวังทั่วไป ผู้ทำโจทย์ต้อง:

  1. แยก flow ตาม (ICMPv6 type, Identifier)
  2. ต่อ payload ตาม Sequence
  3. ทดลองถอดรหัส/บีบอัด/ถอด XOR แบบเบา ๆ
  4. หารูปแบบ flag{…}

ขั้นตอนละเอียด (Step-by-Step)

1) ส่องทราฟฟิกให้รู้ว่า “เล่นที่ไหน”

เปิดด้วย Wireshark แล้วกรอง:

icmpv6.type == 128 || icmpv6.type == 129
  • 128 = Echo Request
  • 129 = Echo Reply

ลองเพิ่มคีย์กรอง icmpv6.echo.identifier == 4919 เพื่อเจาะ flow ที่น่าสงสัย:

icmpv6 && icmpv6.echo.identifier == 4919

เช็คแถบ Packet Details → ICMPv6 Echo → มี Identifier, Sequence number, และ Data (payload) อยู่

2) ดึง payload ออกมา (tshark)

ถ้าถนัด CLI ใช้ tshark ดึง data ของสตรีมที่สนใจเป็นเฮกซ์:

# กรองเฉพาะ echo req ที่ ident=4919 แล้วเอา data เป็น hex ต่อๆ กันทีละบรรทัดtshark -r capture.pcapng \  -Y "icmpv6.type == 128 && icmpv6.echo.identifier == 4919 && data" \  -T fields -e data > echo_hex.txt

ต่อเฮกซ์ให้เป็นบัฟเฟอร์ไบต์:

tr -d '\n' < echo_hex.txt > one_line_hex.txtxxd -r -p one_line_hex.txt > echo_payload.bin
หมายเหตุ: ถ้ามีหลาย Sequence มักเรียงเวลาใน pcap อยู่แล้ว แต่ถ้าสุ่มหลุด ก็ sort ตาม frame number ก่อน cut/sort/awk ค่อยแปะรวมกัน

3) ลองถอดหลายแบบเร็ว ๆ

เช็ค plain ก่อน:

strings -n 6 echo_payload.bin | head# หรือ grep หาแพทเทิร์นstrings -n 6 echo_payload.bin | grep -i "flag{"

ลองบีบอัดยอดฮิต:

# zlib (raw/with header) + gzippython3 - << 'PY'import zlib,sys,reb=open("echo_payload.bin","rb").read()cands=[]for w in (zlib.MAX_WBITS, -zlib.MAX_WBITS, 16+zlib.MAX_WBITS):    try:        out=zlib.decompress(b,w)        cands.append(out)    except: passfor i,buf in enumerate(cands):    m=re.search(rb'flag\{[^}]+\}', buf)    if m: print("hit zlib mode",i,":", m.group(0).decode())PY

ถัดมา ลอง XOR คีย์เดี่ยว (0..255):

python3 - << 'PY'import re,sysdata=open("echo_payload.bin","rb").read()for k in range(256):    dec=bytes(b ^ k for b in data)    m=re.search(rb'flag\{[^}]+\}', dec, re.I)    if m:        print(f"Found with XOR key=0x{k:02x}:", m.group(0).decode())        breakPY
ในเคสนี้ เจอ flag โผล่จากสตรีม (type=128, ident=4919) แบบชัดเจนโดยไม่ต้องทำอะไรซับซ้อน (raw scan ก็เห็น) แต่การเช็ค zlib/gzip/XOR เป็น safety net เผื่อคู่กรณีแอบอัด/เข้ารหัสมาด้วย

4) สคริปต์รวมแบบอัตโนมัติ (Scapy-style)

ถ้าอยากทำให้ reproducible ในขวดเดียว:

# test.pyfrom scapy.all import PcapNgReader, IPv6, ICMPv6EchoRequest, ICMPv6EchoReply, rawimport re, zlib, syspcap = sys.argv[1] if len(sys.argv)>1 else "capture.pcapng"target_ident = 4919# 1) เก็บ payload ตาม seq เฉพาะ ident ที่สนใจseq2data = {}with PcapNgReader(pcap) as rd:    for pkt in rd:        if not pkt.haslayer(IPv6): continue        if pkt.haslayer(ICMPv6EchoRequest) or pkt.haslayer(ICMPv6EchoReply):            icmp = pkt.getlayer(ICMPv6EchoRequest) or pkt.getlayer(ICMPv6EchoReply)            if getattr(icmp, "id", None) != target_ident:                 continue            # Scapy: icmp.data เป็น bytes ของ payload            seq2data[getattr(icmp, "seq", 0)] = bytes(icmp.data)# 2) ต่อ payload ตามลำดับ seqbuf = b"".join(v for k,v in sorted(seq2data.items()))def hunt(b: bytes):    # plain    m = re.search(rb'flag\{[^}]+\}', b, re.I)    if m: return ("plain", m.group(0).decode())    # zlib/gzip    for w in (zlib.MAX_WBITS, -zlib.MAX_WBITS, 16+zlib.MAX_WBITS):        try:            out = zlib.decompress(b, w)            m = re.search(rb'flag\{[^}]+\}', out, re.I)            if m: return (f"zlib({w})", m.group(0).decode())        except: pass    # XOR 1-byte    for k in range(256):        dec = bytes(x ^ k for x in b)        m = re.search(rb'flag\{[^}]+\}', dec, re.I)        if m: return (f"xor 0x{k:02x}", m.group(0).decode())    return (None, None)how, flag = hunt(buf)print("Found via:", how)print("Flag:", flag)

รัน:

python3 test1.py thctt2025_open_netsec2_upgraded-router.pcapng 

5) Validation

  • รูปแบบแฟล็ก: flag{ + 32 hex + } → ตรงตามที่เจอ
  • ลอง cross-check ด้วยการค้นหาซ้ำในไฟล์ที่ประกอบแล้ว (grep -a) เพื่อมั่นใจว่าไม่มี false positive

Pitfalls / Tips

  • ICMPv6 echo มีทั้ง Request/Reply: ถ้าอีกฝั่ง “สะท้อน” payload กลับมา อาจเจอข้อมูลซ้ำสองชุด
  • ลำดับ packet ใน PCAP ส่วนใหญ่เรียงตามเวลาอยู่แล้ว แต่ถ้าดรอป/สลับ ให้เรียงตาม Sequence
  • ถ้าไม่เจอใน raw: ลอง zlib, gzip, และ XOR single-byte (เป็นเทคนิคซ่อนที่ย่อยง่ายและเร็ว)
  • ท้ายสุด ถ้าผ่านทุกอย่างแล้วยังว่างเปล่า แปลว่าอาจมี layer ซ้อนอีกชั้น (เช่น CBOR/Protobuf/custom framing) ให้ลองชิ้นส่วนที่คาดว่าจะเป็น header ก่อน

ผลลัพธ์ที่ได้:

Whispers in the wire

ฐานทัพไซเบอร์แห่งใหม่เพิ่งถูกสร้างขึ้นโดยประเทศเพื่อนบ้าน พวกเขาใช้ระบบควบคุมแบบ gRPC over HTTP/2 เพื่อซ่อนคำสั่งลับและข้อมูลสำคัญระหว่าง นักวิจัยและเซิร์ฟเวอร์ทดสอบอาวุธ แต่โชคดี ที่หน่วยไซเบอร์ของเราได้ แอบเก็บ ไฟล์ PCAP ที่บันทึกการสื่อสารได้มาแล้ว 🎧 แรก ๆ มันดูเหมือนข้อมูลไร้ค่า ที่อ่านไม่ออกทันที, ข้อความยังดูเหมือนอาจจะถูกบีบอัดด้วย zlib ไว้ อีกต่างหาก… อย่างไรก็ตาม มีข่าวลือว่า ข้อความตอบกลับจาก ซ่อน flag เอาไว้! 🏴‍☠️ ภารกิจของคุณคือแกะรอยจากไฟล์ PCAP เพื่อดึง flag ที่ถูกซ่อนอยู่ให้ได้ โจทย์

Overview

  • ไฟล์ที่ให้วิเคราะห์ 
    thctt2025_open_netsec3_whisper-in-the-wire.pcapng thctt2025_open_netsec3_whisper-in-the-wire.proto
  • พาร์ส PCAPNG → TCP reassembly → HTTP/2 (cleartext)
  • ดึง DATA frames (type=0x0) ตาม stream_id
  • แตก gRPC messages (ฟอร์แมต compressed_flag(1B) + length(4B, BE) + body)
  • ถ้า compressed_flag==1 ให้ลอง zlib/gzip
  • ค้นหา flag{...} ในเพย์โหลด

🔎 Step-by-Step Walkthrough

1) ตรวจว่าทราฟฟิกเป็น HTTP/2 แบบเคลียร์ (h2c)

  • เปิด Wireshark แล้วค้นหา Client Connection Preface:
  • PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
  • หรือฟิลเตอร์:
  • http2

(ถ้าดูเป็น raw TCP ก็ยังเห็น frame header 9 ไบต์ของ HTTP/2)

2) รวบรวม TCP flows และประกอบสตรีม

  • จับคู่ flow ตาม (src, sport, dst, dport)
  • เรียงตาม TCP sequence แล้วประกอบบัฟเฟอร์สองทิศทาง (client→server / server→client)

3) พาร์เซ HTTP/2 frames

  • เฮดเดอร์ 9 ไบต์/เฟรม:
  • len(3B BE) | type(1B) | flags(1B) | stream_id(31b)
  • เก็บเฉพาะ DATA frames (type=0x0) และ stream_id != 0
  • ต่อ payload ของ DATA เฟรมตามลำดับที่พบ (ภายใน stream เดียวกัน)

4) แตก gRPC message framing

  • gRPC บน HTTP/2 มี framing เพิ่ม 5 ไบต์ต่อ message:
  • compressed_flag(1B) | msg_length(4B BE) | msg_body(msg_length)
  • ถ้า compressed_flag == 1 ให้ลอง zlib/gzip (แม้ปกติ gRPC จะขึ้นกับค่า grpc-encoding ใน HEADERS แต่ในแคปเจอร์ CTF มักตรงไปตรงมา)

5) ค้นหา flag

  • สแกน msg_body หรือ decompressed_body ด้วย regex:
  • flag\{[^}]+\}

🧪 Quick commands (ถ้าชอบแนวเครื่องมือ)

  • ดู HTTP/2 ใน Wireshark: http2
  • แยก field (เช่น http2.streamid, http2.data.data) ด้วย tshark ก็ได้ แต่สำหรับกรณีนี้ ข้อดีของสคริปต์คือต่อและแตก message ให้อัตโนมัติ

🧰 โค้ดพร้อมใช้ซ้ำ (Python 3, no extra deps)

บันทึกเป็นไฟล์ grpc_h2_flag_extractor.py แล้วรันด้วย python3 grpc_h2_flag_extractor.py your.pcapng

#!/usr/bin/env python3import struct, zlib, re, sysfrom collections import defaultdictPCAPNG_SHB = 0x0A0D0D0APCAPNG_IDB = 0x00000001PCAPNG_EPB = 0x00000006PCAPNG_SPB = 0x00000003def read_pcapng(path):    data = open(path, "rb").read()    off = 0    interfaces = {}    packets = []    while off + 12 <= len(data):        btype, blen = struct.unpack_from("<II", data, off)        if blen < 12 or off + blen > len(data): break        block = data[off:off+blen]        if btype == PCAPNG_IDB:            if len(block) >= 20:                linktype = struct.unpack_from("<H", block, 8)[0]                iface_id = len(interfaces)                interfaces[iface_id] = linktype        elif btype == PCAPNG_EPB:            if len(block) >= 32:                iface_id = struct.unpack_from("<I", block, 8)[0]                cap_len  = struct.unpack_from("<I", block, 20)[0]                pkt_data = block[28:28+cap_len]                packets.append((interfaces.get(iface_id,1), pkt_data))        elif btype == PCAPNG_SPB:            if len(block) >= 12:                cap_len = struct.unpack_from("<I", block, 8)[0]                pkt_data = block[12:12+cap_len]                packets.append((1, pkt_data))        off += blen    return packetsdef ipv6_addr(b):    return ":".join(f"{int.from_bytes(b[i:i+2],'big'):x}" for i in range(0,16,2))def parse_ip_tcp(pkt):    if len(pkt) < 14: return None    eth_type = struct.unpack("!H", pkt[12:14])[0]    if eth_type == 0x0800:  # IPv4        ip = pkt[14:]        if len(ip) < 20: return None        ihl = (ip[0] & 0x0F) * 4        proto = ip[9]        if proto != 6: return None        total = struct.unpack("!H", ip[2:4])[0]        body = ip[ihl:total] if total <= len(ip) else ip[ihl:]        if len(body) < 20: return None        sport, dport, seq, ack, off_flags = struct.unpack("!HHIIH", body[:14])        doff = (off_flags >> 12) * 4        return ("IPv4", sport, dport, seq, body[doff:])    elif eth_type == 0x86DD:  # IPv6        ip = pkt[14:]        if len(ip) < 40: return None        plen = struct.unpack("!H", ip[4:6])[0]        nh = ip[6]        if nh != 6: return None        body = ip[40:40+plen]        if len(body) < 20: return None        sport, dport, seq, ack, off_flags = struct.unpack("!HHIIH", body[:14])        doff = (off_flags >> 12) * 4        return ("IPv6", sport, dport, seq, body[doff:])    return Nonedef reassemble_tcp(segments):    # segments: list[(seq, payload)]    segs = sorted(segments, key=lambda x: x[0])    out = bytearray()    cur = None    for seq, blob in segs:        if not blob: continue        if cur is None: cur = seq        if seq > cur:            out.extend(b"\x00" * (seq - cur))            cur = seq        overlap = cur - seq        if overlap < 0: overlap = 0        add = blob[overlap:]        out.extend(add)        cur += len(add)    return bytes(out)def parse_h2_frames(stream_bytes):    frames = []    i = 0    preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"    if stream_bytes.startswith(preface):        i = len(preface)    n = len(stream_bytes)    while i + 9 <= n:        length = int.from_bytes(stream_bytes[i:i+3], "big")        ftype  = stream_bytes[i+3]        flags  = stream_bytes[i+4]        sid    = int.from_bytes(stream_bytes[i+5:i+9], "big") & 0x7fffffff        i += 9        if i + length > n: break        payload = stream_bytes[i:i+length]        i += length        frames.append((ftype, flags, sid, payload))    return framesdef scan_grpc_messages(buf):    hits = []    i = 0    n = len(buf)    while i + 5 <= n:        comp = buf[i]        size = int.from_bytes(buf[i+1:i+5], "big")        i += 5        if size < 0 or i + size > n: break        body = buf[i:i+size]        i += size        cands = [body]        if comp == 1:            for w in (zlib.MAX_WBITS, -zlib.MAX_WBITS, 16+zlib.MAX_WBITS):                try: cands.append(zlib.decompress(body, w))                except: pass        for c in cands:            m = re.search(rb'flag\{[^}]+\}', c, re.I)            if m: hits.append(m.group(0).decode())    return hitsdef main(pcapng_path):    packets = read_pcapng(pcapng_path)    flows = defaultdict(list)  # key: (src,sport,dst,dport) but we only need ports here    for linktype, pkt in packets:        p = parse_ip_tcp(pkt)        if not p: continue        _, sport, dport, seq, payload = p        # สนใจเฉพาะ clear HTTP/2 (พอร์ตใดก็ได้) - ไม่จำกัดพอร์ตเพื่อความทั่วไป        # เก็บเฉพาะมี payload        if payload:            flows[(sport, dport)].append((seq, payload))    # reassemble per direction, then parse h2 frames; collect DATA per stream_id    DATA = 0x0    flags_found = set()    for key, segs in flows.items():        stream = reassemble_tcp(segs)        frames = parse_h2_frames(stream)        data_by_sid = defaultdict(bytearray)        for ftype, flags, sid, payload in frames:            if ftype == DATA and sid != 0:                data_by_sid[sid].extend(payload)        for sid, data in data_by_sid.items():            for flag in scan_grpc_messages(bytes(data)):                flags_found.add(flag)    if not flags_found:        print("No flag found.")    else:        for f in flags_found:            print(f)if __name__ == "__main__":    pcap = sys.argv[1] if len(sys.argv)>1 else "input.pcapng"    main(pcap)

ใช้งาน

python3 test.py thctt2025_open_netsec3_whisper-in-the-wire.pcapng

🧷 Notes & Pitfalls

  • gRPC compression มีทั้งระดับ message (ดู compressed_flag) และระดับเชื่อมต่อ (grpc-encoding ใน HEADERS) — สคริปต์นี้ลอง zlib/gzip อัตโนมัติให้แล้ว
  • HTTP/2 อาจแทรก HEADERS/SETTINGS/CONTINUATION — โค้ดนี้โฟกัส DATA เพียว ๆ (พอสำหรับ CTF/แคปเจอร์นี้)
  • ถ้าสตรีมยาวมาก: สามารถเพิ่มการตัดเฉพาะ sid ที่มี HEADERS :path เป้าหมายได้ (แต่ต้องพาร์เซ HEADERS/Hpack เพิ่ม)

ผลลัพธ์หลังจากการรัน

Custom Protocol v2

Custom Protocol v2 ถูกใช้งานเข้ารหัสลับข้อมูลของศัตรู แต่ทางหน่วยข่าวกรองได้เอกสาร Protocol Specification มา จงใช้ Gen AI ช่วยถอดรหัส Custom Protocol นี้ให้หน่อยนะ

สรุปผล

  • ไฟล์ที่ให้วิเคราะห์ thctt2025_open_netsec4_custom-protocol-v2.pcap
  • โปรโตคอล: STH v2 บน UDP:31337
  • กลไกสำคัญ: Header 23 ไบต์ + Options + Payload, CRC-32 ตรวจครบ, ข้อมูลแตกเป็นชิ้น DATA หลายชิ้นแล้วสลับลำดับด้วย LCG, มี reverse/base64 ตาม flags2, จากนั้น XOR ด้วย keystream (SHA-256 counter-mode) และสุดท้าย zlib

🔍 โครงสร้างโปรโตคอล (สรุปที่ใช้จริง)

STH packet (บน UDP)

'STH'(3) | ver(1=0x02) | type(1) | flags(1) | reserved(1)session_id(4, BE) | seq(4, BE) | payload_len(2, BE) | options_len(2, BE) | crc32(4, BE)[options (options_len)] [payload (payload_len)]
  • CRC-32 คำนวณจาก: header[:19] + options + payload

HELLO (payload)

client_nonce(8) | name_len(1) | name(name_len bytes)

WELCOME (payload)

server_nonce(8) | salt(8) | chunk_size(2) | total_chunks(2) |a(4) | c(4) | seed(4) | hint(2) | flags2(1) | reserved(1)
  • flags2:
  • bit0 (0x01) → มี reverse (กลับบัฟเฟอร์)
  • bit1 (0x02) → มี base64
  • hint ใช้เช็ค/กรอง session (เช่นค่าพิเศษที่คนเขียนโจทย์ตั้งไว้)

DATA

  • DATA แต่ละชิ้นอยู่ใน payload ตรง ๆ (ไม่มีส่วนหัวเพิ่ม)
  • ต้องเอาชิ้นที่มี seq = 0..n-1 มาเรียงใหม่ด้วย LCG

Reordering (LCG)

  • ให้ n = total_chunks
  • ลำดับจริงของ ตำแหน่ง ชิ้นคือ
x0 = seed % n x_{k+1} = (a * x_k + c) % n
  • จาก ชิ้นที่ส่งมาด้วย index/seq = j ให้ไปวางที่ตำแหน่ง i = x_j

Keystream & XOR

  • สร้าง keystream ยาวเท่าข้อมูล โดยวนคอนเตอร์ k = 0,1,2,...:
  • block_k = SHA256( session_id(4,BE) || client_nonce(8) || server_nonce(8) || salt(8) || k(4,BE) )
  • นำมาวางต่อกัน แล้ว XOR กับ ciphertext (หลัง undo base64/reverse)

Decompress

  • zlib.decompress(...) (ลอง MAX_WBITS / raw / gzip เผื่อ)

🧭 วิธีทำ (Step-by-Step)

  1. ดึงทุก UDP packet ที่พอร์ต 31337
  2. แยก STH packets ด้วยการเช็ค 'STH' + ver=0x02 + CRC-32 ถูกต้อง
  • จับคู่ session จาก session_id:
  • เก็บ HELLO (เอา client_nonce)
  • เก็บ WELCOME (ดึงพารามิเตอร์ server_nonce, salt, chunk_size, total_chunks, a, c, seed, flags2, hint)
  • เก็บ DATA ใส่ map ตาม seq

ประกอบข้อมูล:

  • ใช้ LCG (a,c,seed,n) สร้างลำดับตำแหน่ง x_j
  • วาง chunk[ x_j ] = DATA[ j ] แล้ว concat -> ciphertext

ถอด transform ตาม flags2:

  • ถ้ามี 0x02 → base64 decode
  • ถ้ามี 0x01 → reverse (กลับบัฟเฟอร์)

XOR ด้วย keystream (SHA-256 counter-mode) → ได้ compressed plaintext

zlib decompress → ได้ข้อความจริง แล้ว regex หา flag{...}

🛠️ โค้ดพร้อมรัน (Python 3, no external deps)

บันทึกเป็นไฟล์ sth_v2_decoder.py:

#!/usr/bin/env python3import struct, binascii, base64, zlib, re, sysfrom collections import defaultdict, namedtuple# ---------- PCAP (libpcap) reader (ipv4/ipv6 + udp) ----------def read_pcap(path):    data = open(path, "rb").read()    if len(data) < 24:        raise ValueError("PCAP too short")    magic = struct.unpack("<I", data[:4])[0]    if magic == 0xa1b2c3d4:   endian, ts_nsec = "<", False    elif magic == 0xd4c3b2a1: endian, ts_nsec = ">", False    elif magic == 0xa1b23c4d: endian, ts_nsec = "<", True    elif magic == 0x4d3cb2a1: endian, ts_nsec = ">", True    else:        raise ValueError("Unknown PCAP magic")off = 24    while off + 16 <= len(data):        ts_sec, ts_usec, incl_len, orig_len = struct.unpack(endian+"IIII", data[off:off+16])        off += 16        pkt = data[off:off+incl_len]        off += incl_len        yield ts_sec, ts_usec, pktdef parse_eth(pkt):    if len(pkt) < 14: return None    ether_type = struct.unpack("!H", pkt[12:14])[0]    return ether_type, pkt[14:]def parse_ipv4(buf):    if len(buf) < 20: return None    ihl = (buf[0] & 0x0F) * 4    if len(buf) < ihl: return None    proto = buf[9]    total_len = struct.unpack("!H", buf[2:4])[0]    payload = buf[ihl: total_len] if total_len <= len(buf) else buf[ihl:]    src = ".".join(map(str, buf[12:16]))    dst = ".".join(map(str, buf[16:20]))    return ("IPv4", proto, src, dst, payload)def parse_ipv6(buf):    if len(buf) < 40: return None    ver = (buf[0] >> 4) & 0xF    if ver != 6: return None    plen = struct.unpack("!H", buf[4:6])[0]    nh   = buf[6]    payload = buf[40:40+plen]    src = ":".join(f"{int.from_bytes(buf[8+i:10+i],'big'):x}" for i in range(0,16,2))    dst = ":".join(f"{int.from_bytes(buf[24+i:26+i],'big'):x}" for i in range(0,16,2))    return ("IPv6", nh, src, dst, payload)def parse_udp(payload):    if len(payload) < 8: return None    sport, dport, length, csum = struct.unpack("!HHHH", payload[:8])    data = payload[8:length] if length <= len(payload) else payload[8:]    return sport, dport, data# ---------- STH v2 ----------STH_MAGIC = b"STH"TYPE_HELLO   = 0x01TYPE_WELCOME = 0x02TYPE_DATA    = 0x10TYPE_ACK     = 0x11TYPE_BYE     = 0x20Welcome = namedtuple("Welcome",    "session_id server_nonce salt chunk_size total_chunks a c seed hint flags2")Hello = namedtuple("Hello", "client_nonce client_name")def crc32(buf): return binascii.crc32(buf) & 0xffffffffdef parse_sth(buf):    if len(buf) < 23: return None    if buf[:3] != STH_MAGIC or buf[3] != 0x02: return None    t     = buf[4]    flags = buf[5]    # buf[6] reserved    sid   = struct.unpack("!I", buf[7:11])[0]    seq   = struct.unpack("!I", buf[11:15])[0]    payL  = struct.unpack("!H", buf[15:17])[0]    optL  = struct.unpack("!H", buf[17:19])[0]    crc   = struct.unpack("!I", buf[19:23])[0]    need  = 23 + optL + payL    if len(buf) < need: return None    options = buf[23:23+optL]    payload = buf[23+optL:23+optL+payL]    if crc32(buf[:19] + options + payload) != crc:        return None    return {"type":t,"flags":flags,"session_id":sid,"seq":seq,            "options":options,"payload":payload, "raw":buf[:need]}def parse_tlv(options_bytes):    tlvs=[]; i=0    while i+2 <= len(options_bytes):        typ = options_bytes[i]; ln = options_bytes[i+1]; i+=2        tlvs.append((typ, options_bytes[i:i+ln])); i+=ln    return tlvsdef parse_hello(p):    if len(p) < 9: return None    client_nonce = p[:8]    name_len = p[8]    name = p[9:9+name_len].decode("utf-8","ignore")    return Hello(client_nonce, name)def parse_welcome(p, sid):    if len(p) < 36: return None    server_nonce = p[0:8]    salt         = p[8:16]    chunk_size   = struct.unpack("!H", p[16:18])[0]    total_chunks = struct.unpack("!H", p[18:20])[0]    a = struct.unpack("!I", p[20:24])[0]    c = struct.unpack("!I", p[24:28])[0]    seed = struct.unpack("!I", p[28:32])[0]    hint = struct.unpack("!H", p[32:34])[0]    flags2 = p[34]    # p[35] reserved    return Welcome(sid, server_nonce, salt, chunk_size, total_chunks, a, c, seed, hint, flags2)def lcg_order(a,c,seed,n):    order=[]; x = seed % n; seen=set()    for _ in range(n):        order.append(x); seen.add(x)        x = (a*x + c) % n    if len(set(order)) != n:        # fallback make unique        uniq=[]; seen=set()        for x in order:            if x not in seen: uniq.append(x); seen.add(x)        for i in range(n):            if i not in seen: uniq.append(i)        order = uniq[:n]    return orderdef keystream(session_id_be4, client_nonce, server_nonce, salt, L):    base = session_id_be4 + client_nonce + server_nonce + salt    out = bytearray(); k=0    import hashlib    while len(out) < L:        blk = base + struct.pack("!I", k)        out.extend(hashlib.sha256(blk).digest())        k += 1    return bytes(out[:L])def decode_session(sess):    hello = sess.get("hello")    wel   = sess.get("welcome")    if not hello or not wel: return None    n = wel.total_chunks    if len(sess["data"]) != n: return None    # Reorder by LCG    order = lcg_order(wel.a, wel.c, wel.seed, n)    chunks = [b""]*n    for j in range(n):        if j not in sess["data"]: return None        i = order[j]        chunks[i] = sess["data"][j]    ciphertext = b"".join(chunks)    # Undo transforms    buf = ciphertext    if (wel.flags2 & 0x02) != 0:  # base64        try:            buf = base64.b64decode(buf, validate=False)        except Exception:            try: buf = base64.b64decode(buf + b"==")            except: pass    if (wel.flags2 & 0x01) != 0:  # reverse        buf = buf[::-1]    # XOR keystream    sid_be4 = struct.pack("!I", wel.session_id)    ks = keystream(sid_be4, hello.client_nonce, wel.server_nonce, wel.salt, len(buf))    comp = bytes(a ^ b for a,b in zip(buf, ks))    # Decompress    plain = None    for w in (zlib.MAX_WBITS, -zlib.MAX_WBITS, 16+zlib.MAX_WBITS):        try:            plain = zlib.decompress(comp, w); break        except: pass    if plain is None: plain = comp  # fallback    m = re.search(rb'flag\{[^}]+\}', plain, re.I)    return m.group(0).decode() if m else Nonedef main(pcap_path):    sessions = defaultdict(lambda: {"hello":None, "welcome":None, "data":{}})    last_hello = None    for _,_,pkt in read_pcap(pcap_path):        L2 = parse_eth(pkt);         if not L2: continue        ether_type, l3 = L2        L3 = None        if ether_type == 0x0800:  L3 = parse_ipv4(l3)        elif ether_type == 0x86DD: L3 = parse_ipv6(l3)        if not L3: continue        proto = L3[1]        if proto != 17:  # UDP only            continue        udp = parse_udp(L3[4])        if not udp: continue        sport, dport, udp_payload = udp        if sport != 31337 and dport != 31337:            continue        # ภายใน UDP อาจมีหลาย STH frame (เผื่อคนส่ง pack)        i=0        while i + 23 <= len(udp_payload):            if udp_payload[i:i+3] != STH_MAGIC or udp_payload[i+3] != 0x02:                i += 1; continue            # peek length            payL = struct.unpack("!H", udp_payload[i+15:i+17])[0]            optL = struct.unpack("!H", udp_payload[i+17:i+19])[0]            need = 23 + optL + payL            if i + need > len(udp_payload): break            frame = udp_payload[i:i+need]            i += need            p = parse_sth(frame)            if not p: continue            sid = p["session_id"]; t = p["type"]            if t == TYPE_HELLO:                h = parse_hello(p["payload"])                if h: sessions[sid]["hello"] = h; last_hello = h            elif t == TYPE_WELCOME:                w = parse_welcome(p["payload"], sid)                if w:                    sessions[sid]["welcome"] = w                    if sessions[sid]["hello"] is None and last_hello:                        sessions[sid]["hello"] = last_hello            elif t == TYPE_DATA:                sessions[sid]["data"][p["seq"]] = p["payload"]            # ACK/BYE not needed for decode    # ลองถอดทุก session ที่ข้อมูลครบ    results=[]    for sid, S in sessions.items():        w = S.get("welcome")        if not w: continue        if S["data"] and len(S["data"]) == w.total_chunks:            flag = decode_session(S)            if flag:                results.append((sid, flag))    if not results:        print("No flag found.")        return 1    for sid, flag in results:        print(f"[session {sid}] {flag}")    return 0if __name__ == "__main__":    pcap = sys.argv[1] if len(sys.argv)>1 else "input.pcap"    sys.exit(main(pcap))

ใช้งาน

python3 test2.py thctt2025_open_netsec4_custom-protocol-v2.pcap
ถ้าต้องการรองรับไฟล์ pcapng ด้วย ให้แปลงเป็น pcap ก่อน (เช่น editcap -F pcap input.pcapng out.pcap) หรือเพิ่มตัวอ่าน pcapng เข้าไปตามต้องการ

🧪 เช็กลิสต์ดีบักเร็ว

  • CRC-32 ไม่ผ่าน → header ชำรุดหรืออ่าน options/payload ผิด offset
  • DATA ไม่ครบ total_chunks → dump รายการ seq ที่ได้มา, อาจมีชิ้นตกหล่น
  • ไม่พบ flag{…} → ลองดู plain ที่ได้ก่อน decompress เผื่อ flags2 สลับลำดับ (แต่จากสเปก: base64 → reverse → XOR → zlib)

ภาพรวมสั้น ๆ

สคริปต์นี้ทำ 6 ขั้นตอนหลัก:

  1. อ่าน PCAP (libpcap) → แยก L2/L3/L4 จนถึง UDP:31337
  2. สแกนและตัด STH v2 frame ออกจาก UDP payload (1 datagram อาจมีหลาย frame)
  3. ตรวจ CRC-32 และแยกตาม session_id + เก็บ HELLO / WELCOME / DATA
  4. ใช้พารามิเตอร์จาก WELCOME (a,c,seed,n) จัดเรียง DATA ด้วย LCG
  5. คลาย transform ตาม flags2 (base64, reverse) + สร้าง keystreamXOR
  6. zlib.decompress → หา flag{...}

ไล่ code ข้างต้น

1) ส่วน import และตัวช่วยอ่าน PCAP

import struct, binascii, base64, zlib, re, sysfrom collections import defaultdict, namedtuple
  • struct ใช้อ่านเลขแบบไบนารี (big-endian/ little-endian)
  • binascii.crc32 คำนวณ CRC-32
  • base64, zlib ถอดบีบอัด/คำรหัสตามที่โปรโตคอลกำหนด
  • re หาแพทเทิร์น flag{...}
  • defaultdict ช่วยเก็บแพ็กเก็ตแบ่งตาม session

ฟังก์ชันอ่าน PCAP (ไม่พึ่ง lib ภายนอก)

def read_pcap(path): ...
  • ตรวจ magic number เพื่อรู้ endianness ของไฟล์ pcap
  • loop อ่าน packet header (เวลา+ความยาว) แล้ว yield เนื้อแพ็กเก็ตดิบ
เลือกใช้ “reader แบบเบา” เพื่อเลี่ยง dependency — คุม offset เองทั้งหมด

แยกชั้น Ethernet / IPv4 / IPv6 / UDP

  • parse_eth → คืน ether_type และ payload ที่ชั้นบน
  • parse_ipv4 → คืน (type, proto, src, dst, payload) โดย proto==17 คือ UDP
  • parse_ipv6 → คล้ายกัน แต่ใช้โครง IPv6 (อ่าน plen, nh)
  • parse_udp → คืน (sport, dport, data) (ตัดหัว UDP 8 ไบต์ออก)
จุดสำคัญคือเราสนใจเฉพาะ UDP:31337 เท่านั้น

2) โครงสร้าง/พาร์เซ STH v2

คงค่าคงที่ประเภท frame:

STH_MAGIC = b"STH"TYPE_HELLO=0x01; TYPE_WELCOME=0x02; TYPE_DATA=0x10; ...

โครงสร้างผลพาร์เซ:

  • Welcome = namedtuple(...)
  • Hello = namedtuple(...)

parse_sth(buf)

  • ตรวจ 'STH' และ ver==0x02
  • ดึง type, flags, session_id, seq, payload_len, options_len, crc32
  • คำนวณ CRC-32 จาก header[:19] + options + payload
  • คืน dict สำหรับ frame นี้

parse_hello(payload)

  • ตัด client_nonce(8) และ name_len + name

parse_welcome(payload, sid)

  • ดึง server_nonce, salt, chunk_size, total_chunks, a, c, seed, hint, flags2
  • สร้าง Welcome(...)

3) Reordering ด้วย LCG

def lcg_order(a,c,seed,n):    x0 = seed % n    x_{k+1} = (a*x_k + c) % n
  • คืนรายการลำดับ ตำแหน่ง ของชิ้น (ยาว n)
  • ถ้าเจอกรณีวนลูปไม่ครบ n (เซฟตี้) จะ “บังคับ” ทำให้ครบ (fallback)

ประกอบข้อมูล

หลังเก็บ DATA เป็น sess["data"][seq] = payload:

order = lcg_order(...)chunks[i] = data[j]  # โดย i = order[j]ciphertext = b"".join(chunks)
ตรงตามสเปค: ค่า seq ที่ส่งมาเป็น “ดัชนี j” ของ LCG; ตำแหน่งที่ควรไปอยู่คือ i = x_j

4) Transforms + Keystream XOR

flags2

if flags2 & 0x02: base64.b64decode(...)if flags2 & 0x01: buf = buf[::-1]
  • bit1 (0x02) = มี base64
  • bit0 (0x01) = reverse

Keystream (SHA-256 counter)

def keystream(session_id_be4, client_nonce, server_nonce, salt, L):    base = sid||client||server||salt    for k=0.. : SHA256(base || k_be32) ต่อกันจนยาว >= L
  • เหมือน CTR-mode ง่าย ๆ → XOR กับบัฟเฟอร์หลัง transform

5) Decompress + หา flag

พยายาม zlib หลายโหมด:

for w in (zlib.MAX_WBITS, -zlib.MAX_WBITS, 16+zlib.MAX_WBITS):    try: plain = zlib.decompress(comp, w)
  • ถ้าไม่สำเร็จเลย → ใช้ comp ตรง ๆ (fallback)
  • re.search(rb'flag\{[^}]+\}', plain) แล้วดึงสตริง

6) main(): วนอ่าน pcap → รวมผล → ถอดทุก session

sessions = defaultdict(lambda: {"hello":None, "welcome":None, "data":{}})last_hello = None
  • ระหว่างเดินแพ็กเก็ต:
  • HELLO → เก็บ client_nonce; จำ last_hello เผื่อจับคู่ WELCOME ที่ส่งตามมา
  • WELCOME → เก็บพารามิเตอร์; ถ้ายังไม่มี hello ให้ผูก last_hello เป็น default
  • DATA → เก็บเป็น map ตาม seq

เงื่อนไขถอด:

  • ต้องมี welcome
  • จำนวน DATA ครบ total_chunks
  • จากนั้น decode_session(sess) → คืน flag หรือ None

พิมพ์ผล:

จุดที่ควรรู้ / ปรับแต่งได้

  • ถ้าไฟล์เป็น pcapng:
  • ทางลัด: editcap -F pcap in.pcapng out.pcap แล้วใช้สคริปต์นี้
  • หรือเพิ่มตัวอ่าน pcapng (ในเวอร์ชันก่อนหน้านี้ผมมีตัวอย่าง parser mini ๆ อยู่แล้ว)
  • ถ้าบาง session DATA ไม่ครบ:
  • ลองพิมพ์เช็ก len(sess["data"]) กับ total_chunks เพื่อวินิจฉัย
  • ถ้าไม่เจอ flag:
  • ดู plain[:200] ช่วยวิเคราะห์ว่าถูกขั้นไหน (base64/reverse/keystream/zlib)
  • ประสิทธิภาพ:
  • สคริปต์ทำ O(#packets) ในการ parse + O(n) สำหรับ LCG + O(len(buf)) สำหรับ keystream/XOR/decompress — ปกติเร็วมาก

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

ขอบคุณทุกคนที่เข้ามาอ่านผมทำ blog นี้เพื่อให้เห็นในอีกมุมนึงเท่านั่นว่าหากเรานำเทคโนโลยีเข้ามาช่วยเหลือมันก็จะช่วยเราว่ามารถผ่านปัญหาได้ โดยทั้ง blog 
นี้ผมก็ได้ให้ ChatGPT ช่วยเขียนมันขึ้นมาเช่นกัน

Back again with another blog post — this time I'm sharing my experience of taking the Network category challenges from the TCTT (Thailand Top Talent) 2025 competition and letting ChatGPT solve them.

The Network category had 4 challenges:

- Meet the New Router — 100 points
- Meet the Upgraded Router — 100 points
- Whispers in the Wire — 200 points
- Custom Protocol v2 — 300 points

Let's start with the first challenge.

Meet the New Router

  • File to analyze: thctt2025_open_netsec1_new-router.pcapng
  • Challenge type: Forensics / Network (PCAP)
  • Goal: Extract the Data field from every ICMPv6 Echo Request (type=128) packet, concatenate them into a message, then extract the flag in flag{32-hex} (MD5) format.

As you can see from the screenshots:

  • Protocol: ICMPv6
  • Type = 128 (Echo request)
  • Has a Data (1 byte) field, e.g., "Data: 66"
  • Identifier = 0x1337, Sequence increments by 1
    → Likely sending a message one byte at a time through multiple Echo Request packets

Solution (shortest: TShark one-liner)

Use tshark to extract raw byte data from all Echo requests and convert to text:

tshark -r thctt2025_open_netsec1_new-router.pcapng \  -Y 'icmpv6 && icmpv6.type==128 && data' \  -T fields -e data.data \| tr -d ':
' \| xxd -r -p \| grep -aoE 'flag\{[0-9a-f]{32}\}'

Explanation:

  • -Y 'icmpv6 && icmpv6.type==128 && data' filters only Echo Requests with a Data layer
  • -T fields -e data.data extracts the hex value of Data
  • tr -d ': ' removes : and newlines to get continuous hex
  • xxd -r -p converts hex → actual bytes
  • grep -aoE 'flag\{[0-9a-f]{32}\}' finds the flag{md5} pattern

Result:

Meet the Upgraded Router

It seems the hacker has upgraded their C2 (Command & Control) data exfiltration channel to a new version. The payload coming out might not be as simple as before... maybe some XOR is involved?

  • File to analyze: thctt2025_open_netsec2_upgraded-router.pcapng
  • Protocol: ICMPv6 Echo (type 128/129)
  • Technique: Combine payloads by Identifier/Sequence, try raw / zlib / gzip / XOR (1-byte)
  • Target stream: type = 128 (Echo Request), ident = 4919

Challenge Overview

The PCAP file hides data inside ICMPv6 echo (ping v6) traffic — a channel that looks fairly normal to most monitoring systems. The solver must:

  1. Separate flows by (ICMPv6 type, Identifier)
  2. Concatenate payloads by Sequence
  3. Try various decoding/decompression/XOR approaches
  4. Find the flag{...} pattern

Detailed Steps

1) Inspect traffic to identify where the data is

Open in Wireshark and filter:

icmpv6.type == 128 || icmpv6.type == 129

Add key filter icmpv6.echo.identifier == 4919 to narrow the suspicious flow:

icmpv6 && icmpv6.echo.identifier == 4919

Check Packet Details → ICMPv6 Echo — contains Identifier, Sequence number, and Data (payload).

2) Extract payload (tshark)

For CLI fans, use tshark to extract data from the target stream as hex:

# Filter echo req with ident=4919 and get data as hex lines
tshark -r capture.pcapng \  -Y "icmpv6.type == 128 && icmpv6.echo.identifier == 4919 && data" \  -T fields -e data > echo_hex.txt

Combine hex into a byte buffer:

tr -d '
' < echo_hex.txt > one_line_hex.txt
xxd -r -p one_line_hex.txt > echo_payload.bin

3) Quickly try multiple decoding approaches

Check plain first:

strings -n 6 echo_payload.bin | grep -i "flag{"

Try popular compression methods:

python3 - << 'PY'
import zlib,sys,re
b=open("echo_payload.bin","rb").read()
for w in (zlib.MAX_WBITS, -zlib.MAX_WBITS, 16+zlib.MAX_WBITS):
    try:
        out=zlib.decompress(b,w)
        m=re.search(rb'flag\{[^}]+\}', out)
        if m: print("hit zlib:", m.group(0).decode())
    except: pass
PY

Then try XOR with single byte keys (0..255):

python3 - << 'PY'
import re
data=open("echo_payload.bin","rb").read()
for k in range(256):
    dec=bytes(b ^ k for b in data)
    m=re.search(rb'flag\{[^}]+\}', dec, re.I)
    if m:
        print(f"Found with XOR key=0x{k:02x}:", m.group(0).decode())
PY
In this case, the flag appeared from stream (type=128, ident=4919) clearly without complex steps (visible in raw scan), but checking zlib/gzip/XOR acts as a safety net in case the data is compressed/encrypted.

4) Automated script (Scapy-style)

from scapy.all import PcapNgReader, IPv6, ICMPv6EchoRequest, ICMPv6EchoReply, raw
import re, zlib, sys
pcap = sys.argv[1] if len(sys.argv)>1 else "capture.pcapng"
target_ident = 4919
seq2data = {}
with PcapNgReader(pcap) as rd:
    for pkt in rd:
        if not pkt.haslayer(IPv6): continue
        if pkt.haslayer(ICMPv6EchoRequest) or pkt.haslayer(ICMPv6EchoReply):
            icmp = pkt.getlayer(ICMPv6EchoRequest) or pkt.getlayer(ICMPv6EchoReply)
            if getattr(icmp, "id", None) != target_ident: continue
            seq2data[getattr(icmp, "seq", 0)] = bytes(icmp.data)
buf = b"".join(v for k,v in sorted(seq2data.items()))
# ... hunt function omitted for brevity

Run:

python3 test1.py thctt2025_open_netsec2_upgraded-router.pcapng

Result:

Whispers in the Wire

A new cyber base has been established by a neighboring country, using gRPC over HTTP/2 to hide secret commands and important data between researchers and weapon test servers. Fortunately, our cyber unit managed to capture a PCAP file recording the communications. 🎧 At first it looks like unreadable data, possibly zlib-compressed too... However, there are rumors that the server's reply messages hide a flag! 🏴‍☠️ Your mission: trace through the PCAP file and extract the hidden flag.

Overview

  • Files provided:
    thctt2025_open_netsec3_whisper-in-the-wire.pcapng
    thctt2025_open_netsec3_whisper-in-the-wire.proto
  • Parse PCAPNG → TCP reassembly → HTTP/2 (cleartext)
  • Extract DATA frames (type=0x0) by stream_id
  • Unpack gRPC messages (format: compressed_flag(1B) + length(4B, BE) + body)
  • If compressed_flag==1, try zlib/gzip
  • Search for flag{...} in the payload

Step-by-Step Walkthrough

1) Verify traffic is cleartext HTTP/2 (h2c)

  • Open Wireshark and look for the Client Connection Preface: PRI * HTTP/2.0 SM
  • Or filter: http2

2) Collect TCP flows and reassemble streams

  • Match flows by (src, sport, dst, dport)
  • Sort by TCP sequence and reassemble buffers for both directions

3) Parse HTTP/2 frames

  • 9-byte header per frame: len(3B BE) | type(1B) | flags(1B) | stream_id(31b)
  • Keep only DATA frames (type=0x0) with stream_id != 0

4) Unpack gRPC message framing

  • gRPC on HTTP/2 adds 5 bytes per message: compressed_flag(1B) | msg_length(4B BE) | msg_body
  • If compressed_flag == 1, try zlib/gzip

5) Find the flag

  • Scan msg_body or decompressed_body with regex: flag\{[^}]+\}

Custom Protocol v2

Custom Protocol v2 is used by the enemy to encrypt secret data. However, intelligence has obtained the Protocol Specification document. Use Gen AI to help decode this custom protocol.

Summary

  • File to analyze: thctt2025_open_netsec4_custom-protocol-v2.pcap
  • Protocol: STH v2 over UDP:31337
  • Key mechanism: 23-byte Header + Options + Payload, CRC-32 verification, data split into multiple DATA chunks reordered with LCG, optional reverse/base64 per flags2, then XOR with keystream (SHA-256 counter-mode), finally zlib

Protocol Structure (Summary)

STH packet (over UDP)

'STH'(3) | ver(1=0x02) | type(1) | flags(1) | reserved(1)
session_id(4, BE) | seq(4, BE) | payload_len(2, BE) | options_len(2, BE) | crc32(4, BE)
[options (options_len)] [payload (payload_len)]
  • CRC-32 computed from: header[:19] + options + payload

HELLO (payload)

client_nonce(8) | name_len(1) | name(name_len bytes)

WELCOME (payload)

server_nonce(8) | salt(8) | chunk_size(2) | total_chunks(2) |
a(4) | c(4) | seed(4) | hint(2) | flags2(1) | reserved(1)
  • flags2: bit0 (0x01) → has reverse, bit1 (0x02) → has base64

Reordering (LCG)

x0 = seed % n
x_{k+1} = (a * x_k + c) % n

Each DATA chunk with index/seq = j goes to position i = x_j.

Keystream & XOR

Generate keystream by counter k = 0,1,2,...:

block_k = SHA256(session_id(4,BE) || client_nonce(8) || server_nonce(8) || salt(8) || k(4,BE))

Concatenate blocks and XOR with ciphertext (after undo base64/reverse).

Decompress

  • zlib.decompress(...) (try MAX_WBITS / raw / gzip)

Step-by-Step Solution

  1. Extract all UDP packets on port 31337
  2. Parse STH packets by checking 'STH' + ver=0x02 + valid CRC-32
  3. Match sessions by session_id: collect HELLO (get client_nonce), collect WELCOME (get all parameters), collect DATA indexed by seq
  4. Reassemble data: use LCG (a,c,seed,n) to build position order x_j, place chunk[x_j] = DATA[j] → concat → ciphertext
  5. Undo transforms per flags2: base64 decode if 0x02, reverse if 0x01
  6. XOR with keystream (SHA-256 counter-mode) → get compressed plaintext
  7. zlib decompress → get actual text, regex for flag{...}

Ready-to-Run Code (Python 3, no external deps)

Save as sth_v2_decoder.py and run with python3 sth_v2_decoder.py your.pcap. The script handles: PCAP reading → STH frame parsing → CRC-32 verification → session matching → LCG reordering → flags2 transforms → SHA-256 keystream XOR → zlib decompress → flag extraction.

Usage

python3 test2.py thctt2025_open_netsec4_custom-protocol-v2.pcap
For pcapng files, convert first with editcap -F pcap input.pcapng out.pcap or add a pcapng reader to the script.

Quick Debug Checklist

  • CRC-32 fails → corrupted header or wrong offset for options/payload
  • DATA chunks incomplete → dump the seq values received, some chunks may be missing
  • No flag{...} found → examine plain[:200] to diagnose which step failed

Brief Summary

The script performs 6 main steps:

  1. Read PCAP (libpcap) → parse L2/L3/L4 down to UDP:31337
  2. Scan and cut STH v2 frames from UDP payload (one datagram may have multiple frames)
  3. Verify CRC-32 and split by session_id; store HELLO / WELCOME / DATA
  4. Use WELCOME parameters (a,c,seed,n) to reorder DATA with LCG
  5. Undo transforms per flags2 (base64, reverse) + build keystreamXOR
  6. zlib.decompress → find flag{...}

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

Thank you to everyone who read this. I wrote this blog to show another perspective — that when we bring technology to help, it can help us overcome challenges. For this entire blog, I also had ChatGPT help write it.