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. Three things combine:
- ICE gathers a host candidate for every local interface, public IPv4 and global IPv6 included, and nothing filters them by scope.
- The SDP rewriting only ever touches
a=candidatelines, soc=is never rewritten. In every answer captured — default,advertise-addresses, and relay mode — it named the public IPv4 origin. advertise-addressesis built for NAT port-forwarding, not for hiding an origin. It only narrows host candidates to addresses this host itself holds. Point it at a proxy front on another machine and every host candidate stays — IPv4 and IPv6 — with the front added on top, carrying the origin in itsraddrfield.
Relay mode (webrtc-proxy-protocol-relays) does strip the candidates, but still leaks c=. Because host firewalls and DDoS proxies are frequently IPv4-only, a 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; recent clients (the 26.x line, observed here as protocol 2225 / version 26.63) drop RakNet, leaving NetherNet as the only way in. 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. (In the build tested, o= carried 127.0.0.1, so it is not a disclosure.)
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 when the front lives on another machine. Section 3 explains why this is the function's documented behaviour rather than a slip.
3 · Root cause
The SDP rewriting lives in CloudburstMC/Network, SdpUtil. When signaling sends an answer, NetherNetHTTPServerSignaling.announced() picks one of three paths:
advertise-addressesset andwebrtc-proxy-protocol-relaysset →withRelayedCandidates()(relay mode)advertise-addressesset, no relays →withAdvertisedCandidates()advertise-addressesempty →withAddedEndpoints(), which adds STUN mappings and the endpoint from the player'sHostheader and removes nothing
3a · c= is never rewritten
All three paths funnel through one rewrite() helper, 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)) { /* drop or keep */ } else out.append(line); // c=, o=, m=: passed through verbatim
The c= line is written before any rewriting, so whatever host address it carries reaches the player unchanged, whichever path ran. In every answer captured, that was the public IPv4 origin. No setting rewrites it.
3b · advertise-addresses only narrows to addresses this host holds
withAdvertisedCandidates() sorts every advertised address into one of two groups:
- held — an address a gathered candidate already names, i.e. one of this host's own interfaces
- foreign — anything else, such as a proxy front on another machine
Host candidates are dropped only when at least one advertised address is held. Each foreign address is announced as a srflx candidate based on a host candidate of the same family, with that host in raddr. The function's own javadoc states the intent: the list "only ever narrows the host candidates, and only to endpoints this host holds: one naming none of them leaves every candidate in place."
So a front that lives elsewhere is foreign, nothing is held, and every host candidate stays — exactly what the VM showed. The feature is designed for a NAT that forwards a public address to this machine, not for hiding the machine.
3c · Relay mode strips candidates but not c=
withRelayedCandidates() drops every gathered candidate and announces only the relays, with the related address blanked to 0.0.0.0 rport 0 (or ::). Geyser also disables STUN in this mode. The candidate lines are clean — but c= still names the origin. That matches what production servers behind a hidden-origin proxy showed: candidates pointing at the front, c= pointing at the origin.
SdpUtil.java:302 — "So players only learn the relays, and neither reach nor learn this host's own addresses." True of the candidate lines, but the answer's c= line still names this host, so players do learn its address.
3d · bedrock.address matters in one combination
With advertise-addresses empty, stun-discovery: false and no relays, Geyser advertises bedrock.address itself, unless it is the wildcard 0.0.0.0 / :: (NetherNetServer.boundAddress()). That address is held, so host candidates are narrowed to it. Binding a specific address therefore drops the other interfaces — including the global IPv6 when you bind an IPv4.
3e · How IPv6 is handled
- Nothing filters this host's own candidates by scope. A global IPv6 is gathered and announced like any other interface.
- Translations match family by address length: an IPv4 front is based only on an IPv4 host candidate, an IPv6 front only on an IPv6 one. A front with no host of its family is not announced at all.
- So an IPv4-only front that is not held leaves the IPv6 host candidate in place. The IPv6 host candidate drops only in relay mode, or when a held IPv4 is advertised (or bound, per 3d).
What each configuration exposes
| Configuration | IPv4 host | IPv6 host | srflx raddr | c= | Basis |
|---|---|---|---|---|---|
| Default (empty, STUN on) | origin | origin | — | origin | tested |
| Front only (not held) | origin | origin | origin v4 | origin | tested |
| Front + a held IPv4 | origin | dropped | held v4 | origin | source |
Empty, STUN off, specific bedrock.address | bound address only | dropped | — | untested | source |
Relays set, advertise-addresses empty | origin | origin | — | origin | source (Geyser warns) |
| Relay mode (relays + advertised relays) | dropped | dropped | 0.0.0.0 / :: | origin | source + production |
"Tested" rows were reproduced on the VM. "Source" rows follow from the code at the pinned commit; "production" means seen on live servers behind a hidden-origin proxy.
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 (picks the path)
- Geyser NetherNetServer — L271–L281 (STUN off when advertised or relayed; bound address advertised)
- Geyser boundAddress() — L454–L464
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 address out of the SDP answer with an authenticated join — no special tooling beyond a signed token. The IPv4 origin is in
c=in every mode; outside relay mode the host candidates add the global IPv6 too. - DDoS-protection bypass. The whole point of a reverse-proxy / hidden-origin front — the kind of service commonly used to protect Minecraft servers — 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: hosts whose network namespace has no public IP at all (a container / Wings / NAT'd LAN box behind the proxy) only gather private host candidates, so c= and the candidates can only name private addresses and the public origin is not disclosed — as long as STUN discovery is not announcing the public mapping. 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)
On a VPS or dedicated server whose NIC holds the public IP, no configuration hides the origin completely, because c= is never rewritten and carries a host address (3a). What configuration can do is shrink the leak:
- Use relay mode if your provider supports it.
webrtc-proxy-protocol-relaysplusadvertise-addressesnaming the relays removes every host candidate — IPv4 and IPv6 — and blanksraddr. Onlyc=remains. Setting the relays without advertising them does the opposite: answers carry all of this host's addresses. - Do not rely on
advertise-addressesalone to hide anything. A front on another machine leaves every host candidate in place and puts the origin inraddr(3b). - Keep the IPv6 out of the candidates if you cannot use relay mode: advertise a held IPv4 alongside the front, or leave
advertise-addressesempty withstun-discovery: falseand bindbedrock.addressto a specific IPv4 (3d). Both still expose the IPv4 origin, but no longer the IPv6. - Run Geyser where its network namespace has no public IP. A Docker bridge container (sees only
172.x), Pterodactyl Wings, or a dedicated netns, with the public side port-forwarded or proxied in. Then the candidates andc=can only name private addresses. - In that container, keep STUN from announcing the public mapping. With
advertise-addressesempty and STUN on, the discovered public address is added as asrflxcandidate — and that address is the host's public origin. Settingadvertise-addressesalready turns STUN off; otherwise setstun-discovery: false. - Firewall IPv6. Mirror your v4 origin rules into
ip6tables/ nftables, or drop the global IPv6 from the game host if you don't need it. This does not stop the disclosure, but it blocks the direct-connect path a leaked v6 opens. - Audit what your players actually receive — join your own server with an authenticated client and read the raw
/v1/joinanswer; flag anyc=, host candidate, orraddrthat isn't your front.
For CloudburstMC / Geyser
- Rewrite
c=inSdpUtil.rewrite()— to an announced address, or the unspecified address — so it never names a host address the candidates no longer announce. This closes the leak relay mode still has. - Give
advertise-addressesa hide-origin behaviour, or document clearly that it does not hide the origin: when only foreign addresses are listed, drop the host candidates and hideraddrthe way relay mode does. - Do not announce a global IPv6 host candidate when the operator has advertised only IPv4 addresses, or make the advertised set family-complete.
- Correct the javadoc at
SdpUtil.java:302, which holds for candidate lines but not forc=.
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.