#5854·zeek

SOCKS: Type confusion in SOCKS5 IPv6 address handling leaks heap pointers into socks.log

Author: evantypanskiCreated Aug 28, 2026Updated Aug 28, 2026

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

Sinksrc/analyzer/protocol/socks/socks-analyzer.pac:99-102 (request path; identical pattern at 139-142 for the reply path):

https://github.com/zeek/zeek/blob/62443ad95d88c82ea5957176e113086fc99b5a05/src/analyzer/protocol/socks/socks-analyzer.pac#L100-L102

Path:

  1. src/analyzer/protocol/socks/socks-protocol.pac:112 declares 4 -> ipv6: uint32[4];. binpac parses the 16 wire bytes into a heap-allocated std::vector<uint32> (generated code: ipv6_ = new vector<uint32>; + 4 push_backs), and the generated accessor returns vector<uint32>* — a pointer to the vector object, not its element buffer.
  2. ${request.remote_name.ipv6} therefore expands to request->remote_name()->ipv6() of type std::vector<uint32>*. The reinterpret_cast silently converts the object pointer to const uint32_t* with no .data() dereference.
  3. IPAddr(IPv6, bytes, Network)src/IPAddr.h:397 — executes memcpy(in6.s6_addr, bytes, sizeof(in6.s6_addr)): a 16-byte read from the std::vector's object representation. On LP64 that is _M_start and _M_finish — two heap pointers, which differ by exactly 16 (4 elements x 4 bytes).
  4. The AddrVal is stored in the SOCKS::Address record and enqueued to socks_request/socks_reply (the construction happens before the if ( 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.

python
#!/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_RAW

Run:

zeek -r 022-socks5.pcap -C LogAscii::use_json=T
cat socks.log

Observed results

Release build (RelWithDebInfo), exit 0. Attacker sent 1111:2222:3333:4444:5555:6666:7777:8888 in both directions; socks.log instead contains:

json
{"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.host0x55555a7737b0, 0x55555a7737c0 (delta 16)
  • bound.host0x55555838e3a0, 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:

json
"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:

json
"request.host":"1.2.3.4","request_p":80,"bound.host":"5.6.7.8","bound_p":80

The 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:

cpp
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.