Skip to content

IPv4-mapped IPv6 addresses split standard-driver identity and admission #170

Description

@al8n

IPv4-mapped IPv6 addresses split standard-driver identity and admission

Audit scope, pinned commit, duplicate exclusions

  • Repository: /Users/al/Developer/memberlist
  • Pinned HEAD: aeef956bc0bdf0ed21ba386b329dab6aae64b47e
  • Worktree: clean before and after review.
  • Mode: fresh, adversarial, read-only architecture/correctness/security review.
  • Inspection bound used: 15 focused source-inspection commands and 3 focused validation commands.
  • Reviewed:
    • Compio resolution, advertise construction, bind/readback, join resolution, stream/QUIC CIDR gates, and address validation.
    • Reactor resolution, advertise construction, bind/readback, join resolution, stream/QUIC CIDR gates, and address validation.
    • Proto SocketAddr wire representation and CidrPolicy only where required to interpret driver behavior.
    • Focused tests and locked ipnet behavior.
  • No repository files were edited and no issue was created.

GitHub issues #157#169 were screened. This report does not restate:

No issue in that range mentions IPv4-mapped IPv6 canonicalization or the driver-level identity split below.

Findings

F1 — IPv4-mapped IPv6 advertise addresses pass validation but are not consistently reachable

Severity: Medium
Confidence: High on the audited macOS socket stack; Medium for identical behavior on every supported OS.

Classification: Proven correctness bug. The related CIDR consequence is an architecture/security risk whose exact exploitability depends on policy and socket-family configuration.

Location

Advertise resolution and binding:

  • memberlist-compio/src/tcp/mod.rs:168-182,197-235
  • memberlist-compio/src/tls/mod.rs:232-246,261-301
  • memberlist-compio/src/quic/mod.rs:188-218
  • memberlist-reactor/src/memberlist.rs:757-763,1098-1135,1296-1313

Validators that omit mapped-address handling:

  • memberlist-compio/src/options/mod.rs:788-817
  • memberlist-reactor/src/memberlist.rs:1568-1578

Mapped IPv6 remains IPv6 on the wire:

  • memberlist-proto/src/data/primitives.rs:192-245,248-305
  • memberlist-proto/src/convert/mod.rs:178-205

Representation-sensitive CIDR paths:

  • memberlist-proto/src/cidr/mod.rs:135-145,210-220,241-248
  • memberlist-reactor/src/cidr.rs:22-40
  • memberlist-reactor/src/driver/stream/mod.rs:1154-1170,1721-1726,1744-1761
  • memberlist-reactor/src/driver/quic/mod.rs:1090-1096
  • memberlist-compio/src/driver/stream/mod.rs:2162-2192,3171-3194

Locked family-matching dependency:

  • Cargo.lock:2308-2314
  • ipnet-2.12.0/src/ipnet.rs:1419-1425

Violated invariants

  • The advertised address must be a concrete, canonical endpoint reachable by both gossip and reliable transports.
  • Equivalent IPv4 and IPv4-mapped representations must not key the same physical endpoint differently.
  • CIDR policy must not change solely because an IPv4 endpoint is written as IPv4-mapped IPv6.
  • A validated advertise address must not let TCP/TLS connectivity mask an unreachable UDP identity.

Concrete execution trace

  1. Configure a standard TCP/TLS node with:

    [::ffff:127.0.0.1]:0
    

    either directly as MaybeResolved::Resolved or as a resolver candidate.

  2. The TCP listener bind succeeds on the audited host. local_addr() retains the mapped address and replaces port zero with a concrete port.

  3. Both validators accept it:

    • ::ffff:127.0.0.1 is not Ipv6Addr::UNSPECIFIED;
    • it is not IPv6 multicast;
    • the IPv4 broadcast check runs only for SocketAddr::V4;
    • the read-back port is nonzero.
  4. The endpoint, snapshot, and Alive state therefore use the mapped SocketAddr::V6. Proto encoding emits the V6 tag and the original 16 address octets; it does not canonicalize the value to 127.0.0.1.

  5. A normal peer bound to native IPv4 owns an IPv4 gossip socket. In the focused experiment:

    • an IPv6 TCP client connecting to ::ffff:127.0.0.1 reached a native IPv4 listener;
    • an IPv4 UDP socket could not address the mapped-V6 destination;
    • targeting 127.0.0.1 instead reached the same mapped-bound UDP server.
  6. Consequently, TCP/TLS reliable exchanges can reach the node while UDP probes and gossip from a native-IPv4 peer cannot use the gossiped address. The peer can falsely suspect and reap a node whose reliable listener remains reachable.

  7. QUIC has no TCP escape hatch: its reliable and gossip traffic share the UDP socket, so the same representation mismatch can make the advertised node wholly unreachable from the native-IPv4 peer.

CIDR consequence

CidrPolicy passes the raw IpAddr to ipnet. Locked ipnet deliberately returns false for cross-family comparisons:

IpNet::V4 versus IpAddr::V6 => false
IpNet::V6 versus IpAddr::V4 => false

Therefore:

  • 10.0.0.0/8 allows 10.1.2.3 but rejects ::ffff:10.1.2.3, although the latter routes through IPv4.
  • An IPv6 policy broad enough to include the mapped prefix—most obviously ::/0—admits a mapped destination that the socket stack routes to IPv4, despite native IPv4 being excluded.

The drivers apply CIDR checks directly to raw seed, datagram-source, accepted-peer, and self-advertised addresses, so no later layer repairs this distinction. This is a policy-representation risk rather than proof of a remotely exploitable bypass under every bind configuration.

Impact

  • Accepted configuration can create an Alive identity that is not reachable on the mandatory UDP plane.
  • TCP/TLS success and UDP failure disagree about whether the same member is reachable.
  • Membership can oscillate through false suspicion/death despite working reliable connections.
  • QUIC nodes may be completely unreachable.
  • CIDR admission and outbound filtering can false-reject or unintendedly admit the same underlying IPv4 route depending on textual/address-family representation.
  • Member keys, resolved seeds, and reported addresses can distinguish native and mapped forms of one physical endpoint.

The unusual mapped-address input limits severity, but construction currently declares it valid and publishes it as a usable contact.

Remediation

Choose and enforce one policy across both drivers:

  1. Preferably canonicalize every IPv4-mapped IPv6 SocketAddr to SocketAddr::V4 before it enters driver identity:

    • advertise candidates;
    • post-bind local_addr;
    • resolved seeds;
    • accepted peer and datagram source addresses;
    • CIDR lookup;
    • endpoint and wire identity.
  2. Alternatively, reject mapped addresses with a typed advertise/seed error before binding or dispatch. This is simpler but removes otherwise usable input.

  3. Define handling for mapped IPv6 CIDRs. Either normalize exactly representable mapped networks to IPv4 or reject/document them so policy meaning cannot depend on representation.

  4. Preserve the original unresolved/user input separately if diagnostic visibility is desired; do not use it as membership identity.

Acceptance tests

  1. For Compio TCP, TLS, and QUIC, construct with [::ffff:127.0.0.1]:0. Require either:

    • typed rejection before the driver starts, or
    • successful construction advertising native 127.0.0.1:<actual-port>.
  2. Repeat for Reactor TCP, TLS, and QUIC.

  3. With canonicalization, run two real nodes:

    • one configured with native IPv4;
    • one given the mapped equivalent.

    Require join, UDP probing, gossip, and reliable exchange to succeed, and require snapshots to contain only the canonical address.

  4. Resolve a seed to both native and mapped forms and prove CIDR decisions are identical while preserving the documented per-exchange duplicate counting policy.

  5. Assert that 10.0.0.0/8 treats 10.1.2.3 and ::ffff:10.1.2.3 according to the chosen canonical policy.

  6. Assert that an IPv6-only policy cannot reach IPv4 through the mapped prefix unless that behavior is explicitly configured and documented.

  7. Run the socket-family tests on each supported OS because bind/readback normalization differs across kernels.

Cross-driver architecture assessment

The defect has the same root in both standard drivers: address resolution yields a SocketAddr, bind readback makes the port concrete, and validation classifies the address using surface-level IpAddr predicates without first canonicalizing mapped IPv4.

Relevant parity strengths:

  • TCP/TLS bind the reliable listener and gossip UDP socket to the same actual port.
  • QUIC reads back the actual UDP port.
  • Port-zero identities are not published with port zero.
  • Unspecified, multicast, and IPv4 broadcast advertise addresses are rejected.
  • LocalAddrResolver preserves the requested IPv4/IPv6 family and excludes loopback, unspecified, and link-local addresses in its All scope.
  • Both drivers gate inbound datagrams before machine ingress and inbound streams before bridge/membership allocation.
  • The same CIDR policy separately gates transport source and peer self-advertised membership address.
  • Empty seed lists, nonempty lists resolving to no addresses, and duplicate seed exchanges have explicit, matching semantics.

Notable non-finding differences and risks:

  • Compio exposes an explicit AdvertiseAddrResolver; Reactor always uses the resolver’s first candidate. No silent successful bind to an invalid address was established, but resolver ordering is a stronger API responsibility in Reactor.
  • Both drivers intentionally fail the entire join-resolution batch on the first resolver error. This sacrifices partial bootstrap progress but is consistent across drivers and is covered by explicit error-propagation behavior, so it was not classified as a defect.
  • Compio stringifies resolver errors into io::ErrorKind::Other at memberlist-compio/src/memberlist/mod.rs:1190-1194 and the transport constructor equivalents, while Reactor preserves the boxed original error at memberlist-reactor/src/memberlist.rs:361-364,1306-1312. This loses type/source diagnostics in Compio, but no control-flow or security consequence was proven in this scope.

Validation performed/results

All validation was read-only with build output under /tmp.

  • git rev-parse HEAD:

    aeef956bc0bdf0ed21ba386b329dab6aae64b47e
    
  • git status --short: empty before and after.

  • Duplicate keyword screening across [Adversarial review] Correctness, lifecycle, and architecture findings across proto/compio/reactor #157Vacuous simulation and fuzz oracles, plus missing regression gates, weaken test soundness #169 found no existing IPv4-mapped/CIDR-family finding.

  • Focused TCP socket experiment:

    • bind [::ffff:0.0.0.0]:0 succeeded but read back as [::]:port; the existing unspecified-address validator therefore rejects this narrower wildcard candidate.
    • bind [::ffff:127.0.0.1]:0 succeeded and retained the mapped address.
    • connecting through [::ffff:127.0.0.1] reached a native IPv4 listener.
  • Focused UDP experiment:

    • mapped-loopback UDP bind succeeded and retained the mapped address;
    • a native IPv4 UDP socket could not use the mapped-V6 destination;
    • the native IPv4 destination reached the same mapped-bound socket.
  • Locked ipnet 2.12.0 source was checked directly; IpNet::contains(&IpAddr) matches only equal enum families.

  • Focused command issued with isolated target:

    CARGO_TARGET_DIR=/tmp/memberlist-scope4-target \
      cargo test -p memberlist-proto --features cidr \
      cidr::tests:: -- --test-threads=1
    

    Result: 6 CIDR unit tests passed, 0 failed; the other test binaries selected 0 tests under the filter.

Non-findings and positive controls

  • Port-zero bind/readback handling is correct for ordinary native IPv4 and IPv6 addresses.
  • Wildcard advertise addresses are rejected unless a local-interface resolver substitutes a concrete address.
  • Compio advertise selection rejects empty candidate sets; Reactor returns NoAddresses.
  • Scoped/flow-labelled IPv6 addresses are rejected as non-encodable rather than silently losing scope.
  • Resolved seed duplicates intentionally create and count independent exchanges; existing tests cover this.
  • A nonempty seed list resolving to zero addresses returns JoinFailed, not false success.
  • Resolver errors do not collapse into successful joins.
  • CIDR-blocked inbound streams are dropped before bridge registration, and blocked datagrams are dropped before endpoint ingress.
  • Membership admission correctly checks the peer’s self-advertised address separately from transport-source filtering.
  • No distinct defect was found involving port sharing, self-seed accounting, join cancellation, persistent allocation after ordinary CIDR rejection, or TCP/TLS/QUIC peer attribution beyond the excluded existing issues.

No production patch in this issue

This was a read-only audit. No production code, tests, generated files, or repository metadata were changed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions