TL;DR
When a Bedrock client joins a NetherNet server, the server replies with an SDP answer over HTTP signaling. That answer contains a=candidate lines and a c= connection line. Geyser / CloudburstMC enumerate every local interface address (IPv4 and global IPv6) into host candidates, and the SDP rewriter that is supposed to hide the origin behind a proxy only touches a=candidate lines of the advertised set — it never rewrites c=, o=, raddr, or the original host candidates. Result: the real origin IPv4 and global IPv6 reach the player in plaintext, in both the default and the advertise-addresses configurations. Because host firewalls and DDoS proxies are frequently IPv4-only, the leaked IPv6 is often a directly reachable, unfiltered path to the origin.
1 · What a player actually receives
NetherNet replaced RakNet as the Bedrock transport (clients 1.21.60 / protocol 2060+ drop RakNet). Connection setup is WebRTC: the client POSTs an offer to the server's HTTP signaling endpoint (/v1/join/<id>) and the server returns an SDP answer. Everything in that answer is visible to the player — it is delivered to their machine before any media flows.
A NetherNet answer is datachannel-only, so it has a single media section and a single connection line. The address-bearing lines are:
# the lines that carry an IP o=rtc <id> 0 IN IP4 <addr> origin line c=IN IP4 <addr> connection line a=candidate:<foundation> 1 UDP <pri> <addr> <port> typ host a=candidate:<foundation> 1 UDP <pri> <addr> <port> typ srflx raddr <base> rport <port>
WebRTC does not use c= for connectivity (ICE candidates do that), so a wrong or internal c= does not break the connection — but it is still disclosed to the peer. The same is true of the raddr (related address) field on a server-reflexive candidate: it names the base the reflexive mapping was derived from, i.e. the origin.
2 · Reproduction
Clean Debian 13 VPS, stock Geyser-Standalone 2.12.1-b1302 (git-master-cbd42b8), transport: nethernet, built-in signaling. The box has a public IPv4 and a routable global IPv6 directly on eth0 — the normal situation for a VPS:
inet <v4-origin>/25 scope global eth0
inet6 <v6-origin>::a/64 scope global eth0 # 2000::/3, globally routable
A real Bedrock client join was performed with a Microsoft-authenticated token (a signed offer is required — the server rejects unsigned joins), and the raw SDP answer was captured straight off the HTTP response. Two configurations were tested.
2a · Default config — advertise-addresses: []
Empty advertise-addresses means "use this machine's own addresses". The answer handed to the player:
c=IN IP4 <v4-origin> a=candidate:2 1 UDP 2116026111 <v6-origin>::a 19132 typ host <- global IPv6 a=candidate:1 1 UDP 2114977791 <v4-origin> 19132 typ host
Both the origin IPv4 and the global IPv6 are emitted as host candidates. Nothing is hidden — expected for a default install, but note the IPv6 is in there by default.
2b · Behind a proxy front — advertise-addresses: ["<front>:19132"]
This is the knob operators set to put a reverse-proxy / DDoS front in front of the origin. Setting a real global-unicast front and rejoining, the answer is:
c=IN IP4 <v4-origin> <- origin, NOT rewritten a=candidate:2 1 UDP 2116026111 <v6-origin>::a 19132 typ host <- origin IPv6, still here a=candidate:1 1 UDP 2114977791 <v4-origin> 19132 typ host <- origin IPv4, still here a=candidate:80000000 1 UDP 1694498815 <front> 19132 typ srflx raddr <v4-origin> rport 19132
The front is added as a srflx candidate; the original host candidates are not removed, c= is not rewritten, and the new reflexive candidate's raddr field itself leaks the IPv4 origin. One answer, four separate disclosures of the origin:
| SDP field | Default | With advertise-addresses |
|---|---|---|
c= connection | origin v4 | origin v4 |
a=candidate … typ host (v4) | origin v4 | origin v4 |
a=candidate … typ host (v6) | origin v6 | origin v6 |
a=candidate … typ srflx raddr | — | origin v4 |
| advertised front | — | front only (srflx addr) |
advertise-addresses does not hide the origin. It only appends the front alongside the real addresses.
3 · Root cause
The SDP rewriting lives in CloudburstMC/Network, SdpUtil. Every rewrite entry point — advertised, relayed, added-endpoints — funnels through one rewrite() helper that filters and appends lines, and the only line type it recognizes is the candidate prefix:
private static final String CANDIDATE_PREFIX = "a=candidate:"; // SdpUtil.java:42 // rewrite(): SdpUtil.java:370 — for every line: if (line.startsWith(CANDIDATE_PREFIX)) { /* filter / replace */ } else out.append(line); // c=, o=, m=, raddr: passed through verbatim
So c=, o=, m= and the raddr token inside a candidate are never touched. And withAdvertisedCandidates() appends the advertised address as an extra candidate rather than dropping the host candidates it was meant to replace, so the origin's own host candidates survive too.
The relay-mode path even documents a guarantee it does not keep:
SdpUtil.java:302 — "So players only learn the relays, and neither reach nor learn this host's own addresses." The captured answers contradict this: the host's own IPv4 and IPv6 are present in c= and in host candidates regardless of the advertised / relay set.
Source references (pinned to the analyzed commit)
- SdpUtil.rewrite() — L370–L392 (the pass-through)
- CANDIDATE_PREFIX — L42 (only line type rewritten)
- withAdvertisedCandidates — L271 · withRelayedCandidates — L309 · withAddedEndpoints — L329
- Relay-mode javadoc claim — L302
- NetherNetHTTPServerSignaling.announced() — L589–L603 (caller)
Line numbers are pinned to commit 2e86340; verify against current branch HEAD if it has moved.
4 · Why the IPv6 leak is worse than the IPv4 leak
Operators who hide their origin typically firewall the backend so only the proxy's IPs can reach the game port. In practice that firewall is IPv4-only:
iptablesandip6tablesare separate tables. A rule set written for the v4 origin does nothing for v6, and the v6 table is frequently left empty (default ACCEPT).- Many DDoS-protection fronts proxy IPv4 only. The origin's global IPv6 never goes through the proxy at all.
- IPv6 hosts are effectively un-scannable (264 per subnet), so operators assume the v6 address is "secret". NetherNet hands it out directly, erasing that assumption.
So the leaked <v6-origin>::a is commonly a directly reachable, unfiltered path straight to the origin — bypassing both the IPv4 firewall and the proxy. ICE can even select it as the active pair, connecting the player to the real box over v6 while the operator believes all traffic flows through the front.
5 · Impact
- De-anonymization. Any player can read the origin's real IPv4 and global IPv6 out of the SDP answer with an authenticated join — no special tooling beyond a signed token.
- DDoS-protection bypass. The whole point of a reverse-proxy front (DonutShield, TCPShield-style setups, etc.) is to keep the origin hidden. The origin is disclosed anyway, and the v6 path usually isn't even firewalled, so it can be attacked directly.
- Silent. Nothing in the operator's logs shows the disclosure; it is in every answer to every join.
What was observed to be safe: backends bound to a private address (e.g. a container/LAN IP behind the proxy) only enumerate private host candidates, so the public origin is not disclosed. And a protected setup whose game IP is the mitigation endpoint itself (network-level / IP-transit DDoS protection, not hidden-origin) is unaffected by design — there is no hidden origin to leak.
6 · Mitigations & fix
For operators (now)
- Bind the backend to a private address. If Geyser's
bedrock.address(and the host) only has a LAN/container IP, interface enumeration can't leak a public origin. Terminate the public side at the proxy. - Firewall IPv6 too. Mirror your v4 origin rules into
ip6tables/ nftables, or drop the global IPv6 from the game host entirely if you don't need it. Do not assume v6 is hidden. - Audit what your players actually receive — join your own server with an authenticated client and read the raw
/v1/joinanswer; check for anyc=, host candidate, orraddrthat isn't your front.
For CloudburstMC / Geyser
- Rewrite
c=ando=inSdpUtil.rewrite(), not onlya=candidatelines. - In
withAdvertisedCandidates(), actually drop the host candidates being replaced (or gate interface enumeration) so the origin's own addresses don't survive alongside the advertised set. - Strip or rewrite the
raddr/rportrelated-address fields, which currently carry the base (origin) address. - Treat IPv6 interface enumeration with the same hidden-origin policy as IPv4; make advertised/relay modes family-complete.
- Either honor the relay-mode guarantee at
SdpUtil.java:302or correct the documentation.
7 · Disclosure
This write-up documents behavior reproduced on a disposable test VPS running stock, unmodified Geyser. All identifying addresses of the test box and of any production servers are redacted — publishing a live server's origin would be the exact harm described here. The purpose is to let operators check their own exposure and to give the Geyser / CloudburstMC maintainers a precise, reproducible report.
No exploit tooling is published. The reproduction uses a normal authenticated Bedrock join and reads the SDP answer the server already sends to every player.