Before authentication: three bugs in Mullvad's gotatun
WireGuard is known for staying silent towards packets it cannot authenticate. A userspace implementation still has to receive a UDP datagram, interpret the result of a socket call, and move bytes through its internal tasks before it can reject that packet. Bugs in those steps can stop a tunnel without breaking any cryptography.
I found three such bugs in gotatun, Mullvad's userspace WireGuard implementation in Rust. One empty datagram could terminate a Linux receive task. An oversized datagram could stop reception in the default Windows transport. A separate issue let handshake rate-limiter state accumulate when no peer had an endpoint.
All three are fixed. This post walks through the failure paths, the evidence behind them, and what the fixes change. The scope is gotatun and the configurations described below; these findings do not establish the exposure of a particular deployed VPN product.
| Finding | Relevant path or condition | Advisory severity | First patched release |
| --- | --- | --- | --- |
| Empty datagram causes a panic | Linux batched/GRO receive path | High, CVSS 7.5 | 0.10.1 |
| Oversized datagram stops reception | Windows built-in transport without windows-gro | High, CVSS 7.5 | 0.10.0 |
| Source-IP counters accumulate | Listening device with no known peer endpoints | Moderate | 0.10.1 |
The advisories identify 0.9.2 as affected for the Linux and rate-limiter findings, and versions through 0.9.2 for the Windows finding. The older-version coverage was not exhaustively established in the original reports. For the issues covered here, 0.10.1 is the first release containing all three fixes.
Where the authentication boundary actually sits
It helps to separate accepting a datagram from accepting a WireGuard peer. The implementation has to do some work even when the sender is completely unknown:
UDP socket
-> platform-specific receive implementation
-> buffered receive task and channel
-> device packet handling
-> packet verification and handshake processing
The first two findings interrupt this path before packet verification. A valid handshake is unnecessary: the trigger is a property of the UDP datagram itself.
The rate-limiter finding is slightly different. It requires a handshake-shaped packet with a valid MAC1, but that check does not authenticate a configured peer. The distinction matters because a public-key-derived MAC can protect a different boundary from one that requires a peer's secret key.
These are availability failures. Their mechanisms do not demonstrate decryption, key disclosure, or code execution. Their reach comes from putting device-wide resources on a path that processes untrusted input.
1. An empty datagram kills the Linux receive task
GHSA-6gxq-8xp9-742r · High, CVSS 7.5 · fixed in 0.10.1
The assumption behind the panic
The affected Linux implementation receives datagrams in batches using recvmmsg. In gotatun/src/udp/socket/linux.rs, it obtains the payload for each result through an iterator over the received I/O slices:
result.iovs().next().expect("we create exactly one IoSlice")
The comment embedded in the panic message explains the assumption: one slice was supplied, so one slice must be returned.
With the nix 0.31.2 behavior described in the advisory, an empty received datagram makes this iterator return None. The buffer arrangement used to request a receive does not guarantee a nonempty result iterator for every possible datagram. Calling expect turns that mismatch into a panic.
A zero-length UDP datagram is a valid message. It is different from a stream reaching EOF: the sender has sent a datagram whose payload length happens to be zero. There is no reason for a WireGuard implementation to accept it as a protocol packet, but receiving it must remain a normal, discardable event.
How a packet-level edge case becomes device-wide
The panic does not stay attached to the offending packet. It terminates the shared UDP receive task, which serves all peers on that gotatun device.
The resulting failure is therefore larger than a failed handshake:
1. An unauthenticated sender delivers an empty datagram to the listening UDP port.
2. The batched receiver reaches the expect while extracting its payload.
3. The receive task panics and its channel closes.
4. Further inbound traffic cannot reach the device's peers through that task.
Restarting or reconfiguring the device is required to restore reception. The demonstrated scope is the device's receive path; describing it as an inevitable crash of the entire embedding process would go beyond that evidence.
The report's Linux loopback reproduction queues an empty datagram followed by a nonempty control message. It observes the buffered receiver returning a broken-pipe error after its producer task panics. No configured tunnel peer or secret key is needed. The control message is important: it distinguishes losing one useless packet from losing the ability to receive the next useful one.
The upstream fix
The fixing pull request, #204, changes the empty iterator result into a skip instead of a panic. Omitting tracing and cold-path annotations, the relevant control flow is:
let Some(iov) = result.iovs().next() else {
continue;
};
That is the right level at which to handle the condition: discard this receive result and continue processing the batch. There is no need to authenticate the empty packet, reconstruct the device, or make unrelated peers pay for it.
The PR also includes a regression test. For this kind of bug, a useful assertion is not merely that the receiver avoids panicking. It should verify that a normal message still gets through after the empty one.
2. An oversized datagram stops reception on Windows
GHSA-fc57-wfhg-7c2w · High, CVSS 7.5 · fixed in 0.10.0
This failure starts with a socket error rather than a panic. The affected configuration is the built-in Windows transport without the optional windows-gro feature. In the reviewed path, the device supplies a default 4096-byte packet buffer to the buffered receiver.
What Windows reports
When an incoming datagram is larger than the supplied buffer, Windows can return WSAEMSGSIZE, error 10040. Microsoft's recvfrom documentation describes the truncation and explains that excess UDP data is lost.
That describes a problem with this message. In the native reproduction, the socket remained usable: a subsequent direct receive obtained the next, shorter message.
Gotatun nevertheless propagated the first error out of the receive worker. A recoverable condition at the socket boundary became the end of the producer task.
Following the error through the task boundary
The failure passed through several layers:
datagram exceeds the 4096-byte buffer
-> Windows returns WSAEMSGSIZE
-> the buffered receive producer exits
-> already queued packets drain
-> the consumer encounters the closed channel
-> the device's receive loop ends
The last step made the problem harder to notice. DeviceState::handle_incoming ended with Ok(()), so that exit did not register as a fatal error and did not restart reception. The operating system had not permanently broken the socket; the library had stopped asking it for packets.
This also explains why a test that only checks the first receive error is incomplete. It proves that Windows reports an oversized datagram, but it does not establish what the application does after that error crosses its buffering and task boundaries.
What I tested
I tested this on native Windows 11, first at the socket and buffered-receiver layers, then with two peered gotatun devices using real loopback UDP sockets and in-process TUN channels.
Baseline traffic succeeded in both the patched and unpatched builds. After a single 8192-byte datagram was delivered to one device's UDP port, the unpatched build failed to deliver a subsequent packet to that device within five seconds. With only the Windows receive patch applied, traffic continued in both directions.
That comparison ties the observable outage to the receive error handling. It covers the library's device data path. A complete VPN product and recovery by rebuilding the device were not tested, so those remain separate deployment questions.
The configuration limit matters too. The optional Windows GRO path uses a different, larger batch buffer, and Linux has a different receive implementation. This finding should not be expanded into a claim that every platform or transport configuration stops on the same input.
Recover at the point where the error is understood
The patch I included with the report handles WSAEMSGSIZE locally, discards the offending message, and continues receiving. The essential branch structure is shown below; successful delivery is omitted:
match self.socket().recv_from(&mut buf).await {
Ok((n, src)) => {
// Deliver the complete datagram.
}
Err(err) if err.raw_os_error() == Some(WinSock::WSAEMSGSIZE) => {
tokio::task::yield_now().await;
// Continue the surrounding receive loop.
}
Err(err) => return Err(err),
}
The yield gives the executor an opportunity to run other work between retries. Other receive errors still propagate. Treating one understood, message-local error as recoverable is different from swallowing every socket failure.
The broader lesson is about ownership of recovery. The socket layer knows what this platform-specific error means. If it lets that error terminate a shared task, every layer above it has to reconstruct context that was available at the original boundary.
3. A rate limiter that never forgets
GHSA-m2rr-7rr7-vmp7 · Moderate · fixed in 0.10.1
The third finding concerns memory retained while processing handshake traffic. Gotatun counts handshake packets per source IP to help protect against floods. The vulnerable lifecycle allowed entries to accumulate when the device had no known peer endpoints.
A valid MAC1 is not peer authentication
The counter insertion happens after MAC1 validation. At first glance, that can look like a useful barrier against untrusted senders allocating state.
But the MAC1 key is derived from the device's public key. A sender who knows that public key can construct a packet that passes MAC1 without possessing a configured peer's secret key. The packet can therefore reach the rate-limiter accounting before the sender has authenticated as a peer.
The resource that matters here is the number of distinct source IPs, because those addresses become map keys. Sending more packets from one address and sending packets from many addresses exercise different limits. A per-IP packet threshold does not by itself bound the number of IP entries stored.
Cleanup depended on peer activity
The map's reset was reached through the peer timer path. That path skipped peers without an endpoint. If no peer had an endpoint, nothing reached the usual counter reset, even though the listening device could still process incoming handshake-shaped traffic.
The mismatch was between the lifecycle of the data and the lifecycle of its cleanup. The counters belonged to the device's exposed receive path, while their maintenance depended on the existence of a suitable peer.
The public advisory's test uses no sockets. It constructs packets with valid MAC1 values for 2,048 synthetic source IPs, separated into two groups across the normal reset interval. The entries remain until an explicit reset is invoked. Together with the device timer control flow, that demonstrates why elapsed time alone did not guarantee cleanup in the affected state.
This is evidence of retained growth, not a measurement of how quickly an attacker could exhaust a particular machine. A practical attack still requires traffic appearing from many distinct IPs. Filtering and anti-spoofing can limit that ability, which is one reason this finding needs more qualification than the single-datagram receive failures.
Emptying a map and releasing its allocation are different
There was another relevant detail: HashMap::clear() removes entries while retaining allocated memory for reuse.
That behavior is useful in a repeatedly reused collection. It also means that an empty map is not evidence that memory has returned to its earlier footprint. When reviewing a resource-exhaustion report, both entry count and retained capacity deserve attention.
Those are separate questions, though. Restoring periodic resets addresses the missing-cleanup lifecycle. Deciding whether and when to shrink an allocation is an additional resource-management policy, with its own allocation and performance tradeoffs.
What the fix changes
The upstream fix, #205, adds a reset attempt to the device's periodic maintenance, independently of the per-peer update path. The added call is straightforward:
if let Some(rate_limiter) = device.rate_limiter.as_ref() {
rate_limiter.try_reset_count();
}
The existing peer-side reset remains. The important change is that device-owned counters now have a cleanup opportunity even when peer state supplies none. This patch should not be described as a new shrinking policy: the published change fixes when resets run.
Testing the receive path after the bad input
These findings suggest a more useful test question than whether a malformed packet gets rejected: can the service still do useful work afterward?
For the Linux and Windows cases, a regression test should queue the trigger and then a normal control message. It should use a bounded wait and assert that the control message arrives. That exercises continuity across the receive loop, buffering, and task lifetime.
For the rate limiter, the relevant boundary is time and device state. Tests should cover a listening device without peer endpoints, advance beyond the reset interval, and inspect whether entries are actually removed. Retained allocation should be measured separately from logical entry count.
Platform coverage also needs to follow implementation paths. A passing Linux test cannot establish Windows receive behavior. A passing non-GRO test cannot establish what the batched/GRO implementation does. The operating-system wrapper is part of the behavior being tested.
Updating and reviewing downstream use
Update gotatun to 0.10.1 or a later release containing these fixes. Version 0.10.0 contains the Windows fix, but 0.10.1 is the first release that also covers the Linux panic and rate-limiter cleanup issue.
For an application embedding the library, inspect the resolved dependency and the deployed build, then check the actual target platform and transport features. A library advisory establishes a vulnerable path; determining whether a specific product exposes that path requires its own configuration and integration evidence.
When reviewing a similar UDP service, I would start with a few concrete questions:
- Can an empty datagram reach an expect, an assertion, or an unchecked assumption about a returned buffer?
- Which receive errors describe one bad message, and which require replacing the socket?
- If a producer task exits, does its consumer report a failure, restart, or quietly stop?
- How much state can an unauthenticated sender create by varying addresses?
- Does device-wide cleanup still run with zero peers or no known peer endpoints?
The cryptographic packet checks can be correct while these surrounding invariants fail. In gotatun, a handful of small control-flow changes restored the receive and cleanup behavior that those checks depended on.
Thanks to the Mullvad team for addressing the reports and publishing the advisories and fixes.
Mullvad spun their own implementation off of boringtun as well (called gotatun[0])
[0] mullvad/gotatun
VPN Kill Switch Test From a Browser Tab: What Polling Can and Can't Prove
TL;DR
- A browser can't see the firewall rules behind a VPN kill switch. It only sees answers: which address a server saw, or no reply at all.
- Sampling leaves gaps, and a leak shorter than the gap between two samples can vanish completely.
- No reply is not a pass. It only proves nothing came back.
You click Quit in the VPN app, and the tab that was supposed to record the leak is now behind it. That is the basic problem with any VPN kill switch test run from a web page: to test the kill switch you have to break the tunnel on purpose, and breaking it takes you away from the tab doing the measuring.
That detail shaped the VPN kill switch test on MyIPScan. I built MyIPScan, and this post covers how a browser-based recorder works and what it cannot tell you.
What a kill switch actually is (and why a browser can't see it)
A kill switch lives below the browser. Mullvad's open-source app is a good reference because its security document spells it out. The app integrates with the system firewall: "This means WFP on Windows, PF on macOS and nftables on Linux." When it cannot build a tunnel, it enters an error state that "locks the firewall so no traffic can flow (except the always active exceptions)."
The same document separates two things people often merge. The app's "kill switch" is "the fact that the connecting, disconnecting and error states prevent leaks via firewall rules." The "Lockdown mode" setting, which the document says is "regularly misunderstood as the kill switch," only decides whether the disconnected state blocks traffic too.
None of this is visible from JavaScript. A page can only send requests and record what happened to them.
How the VPN kill switch test records a drop
The test runs in three steps, and the first two exist to give the recorder something to compare against:
1. Capture your own network. Disconnect the VPN completely and record the address your ISP gives you.
2. Capture the tunnel. Connect again and record the VPN's exit address.
3. Force the drop. Start recording, then break the tunnel the way it breaks in real life: quit the VPN app or disable the network adapter. Run it on a desktop: a phone's background tab will likely stop sampling.
Each reading is one request to the site's own /api/ip-sample endpoint, which, in the page's words, "sees the address the request arrives from and sends it back with its AS number, provider name and country." The loop asks for a sample every 1.5 seconds, and every 0.6 seconds for twelve samples after anything changes, so transitions get finer resolution than the quiet stretches.
Every sample lands in one of four buckets:
| Outcome | Meaning | Counted as exposure? |
|---|---|---|
| No reply | Nothing came back | No |
| Tunnel address | Matches step 2 | No |
| Your own address | Matches step 1 | Yes |
| Some other address | Matches neither | No, timed separately |
Only "your own address" counts as a leak. A different VPN server after a reconnect is not your ISP, so it gets its own timer.
Below is my illustration of how such a loop can be written, not the site's code:
const samples = [];
async function takeSample() {
const t = performance.now();
try {
const res = await fetch('/api/ip-sample', {
cache: 'no-store', // never answer from cache
signal: AbortSignal.timeout(3000), // a hung request is a result too
});
const { ip } = await res.json();
samples.push({ t, ip, hidden: document.hidden });
} catch {
samples.push({ t, ip: null, hidden: document.hidden }); // "no reply"
}
}
A recorder should request with cache: 'no-store', which makes the browser fetch "from the remote server without first looking in the cache" (MDN, RequestInit); a cached answer could show the tunnel address after the tunnel is gone. It should also note document.hidden per sample, to explain later why the gaps grew.
Why the gap you ask for is not the gap you get
The loop asks for 1.5 seconds, or 0.6 seconds in fast mode. The browser decides what you actually get.
According to MDN's setTimeout page, "Firefox Desktop has a minimum timeout of 1 second for inactive tabs," and Firefox for Android has a minimum of 15 minutes for inactive tabs "and may unload them entirely." Chrome throttles hidden-tab timers too, and the MyIPScan page sums up the desktop case as browsers clamping "background timers to about one per second."
Chrome also has intensive throttling, introduced in Chrome 88, where timers are checked once per minute. MDN lists its conditions: nesting count of 5 or higher, the page invisible for more than 5 minutes, silent for more than 30 seconds, and WebRTC inactive. It only applies after 5 minutes hidden, so it is not relevant to a seconds-long test.
Put the one-second clamp next to step 3. Forcing a drop means switching to the VPN app, so the measuring tab is hidden at the moment you care about. The 0.6-second fast mode gets clamped to about 1 second while the tab is hidden, and slow requests stretch the spacing further. Each request's round-trip time also adds to the timing uncertainty.
So the page says the recorder reports "the gap it achieved rather than the gap it wanted, and says how long the tab was hidden." If the achieved gap was, say, 2.0 seconds, then any statement about leaks shorter than that is off the table.
Turning samples into a window with two bounds
For a run of consecutive "your own address" readings, there are two defensible numbers:
- Lower bound: the page defines it as "the span actually observed, from the first matching sample to the last." The leak lasted at least this long.
- Upper bound: the widest window the neighbouring samples still allow. The leak could have started just after the previous sample and ended just before the next one.
Again, my illustration, not the site's code:
function exposureBounds(samples, i, j) { // i..j = matching run
const lower = samples[j].t - samples[i].t;
const before = samples[i - 1]?.t ?? samples[i].t;
const after = samples[j + 1]?.t ?? samples[j].t;
return { lower, upper: after - before };
}
The page's own example output is "at least 3.6s, at most 4.8s". With 0.6-second fast-mode sampling on both sides of the run, 3.6 + 2 × 0.6 = 4.8, which is consistent with the page's example. A single number like "4.2 seconds" would look more precise and be less true.
What polling can't prove
Silence is not success. A "no reply" sample fits a working kill switch, but also Wi-Fi reassociating, DNS failing, or a timeout. The page puts it as: "Samples that get no reply show that traffic was not moving, not who stopped it."
Short leaks fall between samples. If the achieved gap is 1.5 seconds, a 400 ms window of unprotected traffic can sit entirely between two requests and never appear. The page says it directly: "A window shorter than that gap can be missed entirely." Zero matches means "none observed at this resolution", not "none occurred".
It sees HTTP from one tab, not the whole machine. Background apps, an OS update service, a torrent client, DNS through another resolver: any of that can leak without touching the tab's requests.
It errs toward under-reporting. If your own address changed after you captured it (the page names "a reconnected router, a mobile handover, carrier-grade NAT"), "a genuine exposure can land in the 'some other address' column instead." The error stays in the conservative direction.
One run is one run. The page: "It is one session, one device, one browser and one moment," and "It is not a test of any VPN application." Nobody should publish "Brand X has no kill switch" from a single recording.
On privacy, every sample is a request, so the server sees your address each time it answers. The page states that "Readings are not logged or stored on the server," that the recording "stays in this tab until you close it," and that to name the provider "only the AS number is looked up (RIPEstat), never your address." It also offers a switch to show full addresses instead of masked ones.
Where this leaves a browser-based test
Polling from a tab is a decent smoke test: it can catch a leak that lasts seconds and time it with error bars. It cannot certify a kill switch. For that, capture packets at the interface (Wireshark or tcpdump on the physical adapter while you kill the VPN), because that is where the firewall rules act.
If you build anything like this, keep one rule: report the resolution you achieved next to every result, and never let a missing answer count as a passing one.
Custom DNS DoH
Hi Mullvad Team,
I understand that custom DNS queries are already encrypted inside the VPN tunnel. However, that protection ends at the exit server, where traditional DNS traffic to external resolvers continues unencrypted.
This creates additional privacy and security risks:
- Traffic correlation: Services like Control D assign unique Legacy IPv6 DNS addresses to individual endpoints. These persistent identifiers can allow observers to link DNS activity across different VPN exit servers and sessions.
- DNS confidentiality: Network operators between the VPN exit and DNS provider can inspect DNS queries, even when Multihop and DAITA are enabled.
- DNS integrity: Unencrypted DNS traffic remains vulnerable to interception and manipulation.
An additional concern is the potential contradiction with DAITA’s privacy objectives.
DAITA is specifically designed to make traffic analysis and correlation more difficult. However, using a unique Legacy IPv6 DNS address introduces a persistent identifier at the VPN exit, potentially undermining part of that protection.
For example, if a Control D endpoint is associated with an identifiable user account, an observer with access to DNS traffic recordings from Mullvad exit servers could potentially link previously observed DNS queries to that account, even if Control D itself stores no DNS query logs.
This is particularly relevant to Multihop: while it separates the user’s real IP address from exit traffic, a persistent, endpoint-specific DNS address can still allow observers to link DNS activity across otherwise separate VPN exit servers and sessions.
This does not directly reveal the user’s real IP address, but it creates an avoidable correlation point that DAITA cannot protect against, since its traffic analysis defenses operate on the VPN entry connection.
Custom DoH would address these risks by encrypting queries to the DNS provider and protecting endpoint identifiers embedded in HTTPS URLs.
I understand that DoH cannot prevent all traffic analysis, but it would eliminate an unnecessary source of identifiable metadata.
Could you reconsider implementing optional custom DoH support, particularly on iOS, while preserving Multihop and DAITA?
This would meaningfully improve privacy for users who rely on external DNS providers with custom filtering rules.
mullvadvpn-app#9121
3 points · 1 comment
Mentions
Before authentication: three bugs in Mullvad's gotatun WireGuard is known for staying silent towards packets it cannot authenticate. A userspace implementation still has to receive a UDP datagram, interpret the result of a socket call, and move bytes through its internal tasks before it can reject that packet. Bugs in those steps can stop a tunnel without breaking any cryptography. I found three such bugs in gotatun, Mullvad's userspace WireGuard implementation in Rust. One empty datagram could terminate a Linux receive task. An oversized datagram could stop reception in the default Windows transport. A separate issue let handshake rate-limiter state accumulate when no peer had an endpoint. All three are fixed. This post walks through the failure paths, the evidence behind them, and what the fixes change. The scope is gotatun and the configurations described below; these findings do not establish the exposure of a particular deployed VPN product. | Finding | Relevant path or condition | Advisory severity | First patched release | | --- | --- | --- | --- | | Empty datagram causes a panic | Linux batched/GRO receive path | High, CVSS 7.5 | 0.10.1 | | Oversized datagram stops reception | Windows built-in transport without windows-gro | High, CVSS 7.5 | 0.10.0 | | Source-IP counters accumulate | Listening device with no known peer endpoints | Moderate | 0.10.1 | The advisories identify 0.9.2 as affected for the Linux and rate-limiter findings, and versions through 0.9.2 for the Windows finding. The older-version coverage was not exhaustively established in the original reports. For the issues covered here, 0.10.1 is the first release containing all three fixes. Where the authentication boundary actually sits It helps to separate accepting a datagram from accepting a WireGuard peer. The implementation has to do some work even when the sender is completely unknown: UDP socket -> platform-specific receive implementation -> buffered receive task and channel -> device packet handling -> packet verification and handshake processing The first two findings interrupt this path before packet verification. A valid handshake is unnecessary: the trigger is a property of the UDP datagram itself. The rate-limiter finding is slightly different. It requires a handshake-shaped packet with a valid MAC1, but that check does not authenticate a configured peer. The distinction matters because a public-key-derived MAC can protect a different boundary from one that requires a peer's secret key. These are availability failures. Their mechanisms do not demonstrate decryption, key disclosure, or code execution. Their reach comes from putting device-wide resources on a path that processes untrusted input. 1. An empty datagram kills the Linux receive task GHSA-6gxq-8xp9-742r · High, CVSS 7.5 · fixed in 0.10.1 The assumption behind the panic The affected Linux implementation receives datagrams in batches using recvmmsg. In gotatun/src/udp/socket/linux.rs, it obtains the payload for each result through an iterator over the received I/O slices: result.iovs().next().expect("we create exactly one IoSlice") The comment embedded in the panic message explains the assumption: one slice was supplied, so one slice must be returned. With the nix 0.31.2 behavior described in the advisory, an empty received datagram makes this iterator return None. The buffer arrangement used to request a receive does not guarantee a nonempty result iterator for every possible datagram. Calling expect turns that mismatch into a panic. A zero-length UDP datagram is a valid message. It is different from a stream reaching EOF: the sender has sent a datagram whose payload length happens to be zero. There is no reason for a WireGuard implementation to accept it as a protocol packet, but receiving it must remain a normal, discardable event. How a packet-level edge case becomes device-wide The panic does not stay attached to the offending packet. It terminates the shared UDP receive task, which serves all peers on that gotatun device. The resulting failure is therefore larger than a failed handshake: 1. An unauthenticated sender delivers an empty datagram to the listening UDP port. 2. The batched receiver reaches the expect while extracting its payload. 3. The receive task panics and its channel closes. 4. Further inbound traffic cannot reach the device's peers through that task. Restarting or reconfiguring the device is required to restore reception. The demonstrated scope is the device's receive path; describing it as an inevitable crash of the entire embedding process would go beyond that evidence. The report's Linux loopback reproduction queues an empty datagram followed by a nonempty control message. It observes the buffered receiver returning a broken-pipe error after its producer task panics. No configured tunnel peer or secret key is needed. The control message is important: it distinguishes losing one useless packet from losing the ability to receive the next useful one. The upstream fix The fixing pull request, #204, changes the empty iterator result into a skip instead of a panic. Omitting tracing and cold-path annotations, the relevant control flow is: let Some(iov) = result.iovs().next() else { continue; }; That is the right level at which to handle the condition: discard this receive result and continue processing the batch. There is no need to authenticate the empty packet, reconstruct the device, or make unrelated peers pay for it. The PR also includes a regression test. For this kind of bug, a useful assertion is not merely that the receiver avoids panicking. It should verify that a normal message still gets through after the empty one. 2. An oversized datagram stops reception on Windows GHSA-fc57-wfhg-7c2w · High, CVSS 7.5 · fixed in 0.10.0 This failure starts with a socket error rather than a panic. The affected configuration is the built-in Windows transport without the optional windows-gro feature. In the reviewed path, the device supplies a default 4096-byte packet buffer to the buffered receiver. What Windows reports When an incoming datagram is larger than the supplied buffer, Windows can return WSAEMSGSIZE, error 10040. Microsoft's recvfrom documentation describes the truncation and explains that excess UDP data is lost. That describes a problem with this message. In the native reproduction, the socket remained usable: a subsequent direct receive obtained the next, shorter message. Gotatun nevertheless propagated the first error out of the receive worker. A recoverable condition at the socket boundary became the end of the producer task. Following the error through the task boundary The failure passed through several layers: datagram exceeds the 4096-byte buffer -> Windows returns WSAEMSGSIZE -> the buffered receive producer exits -> already queued packets drain -> the consumer encounters the closed channel -> the device's receive loop ends The last step made the problem harder to notice. DeviceState::handle_incoming ended with Ok(()), so that exit did not register as a fatal error and did not restart reception. The operating system had not permanently broken the socket; the library had stopped asking it for packets. This also explains why a test that only checks the first receive error is incomplete. It proves that Windows reports an oversized datagram, but it does not establish what the application does after that error crosses its buffering and task boundaries. What I tested I tested this on native Windows 11, first at the socket and buffered-receiver layers, then with two peered gotatun devices using real loopback UDP sockets and in-process TUN channels. Baseline traffic succeeded in both the patched and unpatched builds. After a single 8192-byte datagram was delivered to one device's UDP port, the unpatched build failed to deliver a subsequent packet to that device within five seconds. With only the Windows receive patch applied, traffic continued in both directions. That comparison ties the observable outage to the receive error handling. It covers the library's device data path. A complete VPN product and recovery by rebuilding the device were not tested, so those remain separate deployment questions. The configuration limit matters too. The optional Windows GRO path uses a different, larger batch buffer, and Linux has a different receive implementation. This finding should not be expanded into a claim that every platform or transport configuration stops on the same input. Recover at the point where the error is understood The patch I included with the report handles WSAEMSGSIZE locally, discards the offending message, and continues receiving. The essential branch structure is shown below; successful delivery is omitted: match self.socket().recv_from(&mut buf).await { Ok((n, src)) => { // Deliver the complete datagram. } Err(err) if err.raw_os_error() == Some(WinSock::WSAEMSGSIZE) => { tokio::task::yield_now().await; // Continue the surrounding receive loop. } Err(err) => return Err(err), } The yield gives the executor an opportunity to run other work between retries. Other receive errors still propagate. Treating one understood, message-local error as recoverable is different from swallowing every socket failure. The broader lesson is about ownership of recovery. The socket layer knows what this platform-specific error means. If it lets that error terminate a shared task, every layer above it has to reconstruct context that was available at the original boundary. 3. A rate limiter that never forgets GHSA-m2rr-7rr7-vmp7 · Moderate · fixed in 0.10.1 The third finding concerns memory retained while processing handshake traffic. Gotatun counts handshake packets per source IP to help protect against floods. The vulnerable lifecycle allowed entries to accumulate when the device had no known peer endpoints. A valid MAC1 is not peer authentication The counter insertion happens after MAC1 validation. At first glance, that can look like a useful barrier against untrusted senders allocating state. But the MAC1 key is derived from the device's public key. A sender who knows that public key can construct a packet that passes MAC1 without possessing a configured peer's secret key. The packet can therefore reach the rate-limiter accounting before the sender has authenticated as a peer. The resource that matters here is the number of distinct source IPs, because those addresses become map keys. Sending more packets from one address and sending packets from many addresses exercise different limits. A per-IP packet threshold does not by itself bound the number of IP entries stored. Cleanup depended on peer activity The map's reset was reached through the peer timer path. That path skipped peers without an endpoint. If no peer had an endpoint, nothing reached the usual counter reset, even though the listening device could still process incoming handshake-shaped traffic. The mismatch was between the lifecycle of the data and the lifecycle of its cleanup. The counters belonged to the device's exposed receive path, while their maintenance depended on the existence of a suitable peer. The public advisory's test uses no sockets. It constructs packets with valid MAC1 values for 2,048 synthetic source IPs, separated into two groups across the normal reset interval. The entries remain until an explicit reset is invoked. Together with the device timer control flow, that demonstrates why elapsed time alone did not guarantee cleanup in the affected state. This is evidence of retained growth, not a measurement of how quickly an attacker could exhaust a particular machine. A practical attack still requires traffic appearing from many distinct IPs. Filtering and anti-spoofing can limit that ability, which is one reason this finding needs more qualification than the single-datagram receive failures. Emptying a map and releasing its allocation are different There was another relevant detail: HashMap::clear() removes entries while retaining allocated memory for reuse. That behavior is useful in a repeatedly reused collection. It also means that an empty map is not evidence that memory has returned to its earlier footprint. When reviewing a resource-exhaustion report, both entry count and retained capacity deserve attention. Those are separate questions, though. Restoring periodic resets addresses the missing-cleanup lifecycle. Deciding whether and when to shrink an allocation is an additional resource-management policy, with its own allocation and performance tradeoffs. What the fix changes The upstream fix, #205, adds a reset attempt to the device's periodic maintenance, independently of the per-peer update path. The added call is straightforward: if let Some(rate_limiter) = device.rate_limiter.as_ref() { rate_limiter.try_reset_count(); } The existing peer-side reset remains. The important change is that device-owned counters now have a cleanup opportunity even when peer state supplies none. This patch should not be described as a new shrinking policy: the published change fixes when resets run. Testing the receive path after the bad input These findings suggest a more useful test question than whether a malformed packet gets rejected: can the service still do useful work afterward? For the Linux and Windows cases, a regression test should queue the trigger and then a normal control message. It should use a bounded wait and assert that the control message arrives. That exercises continuity across the receive loop, buffering, and task lifetime. For the rate limiter, the relevant boundary is time and device state. Tests should cover a listening device without peer endpoints, advance beyond the reset interval, and inspect whether entries are actually removed. Retained allocation should be measured separately from logical entry count. Platform coverage also needs to follow implementation paths. A passing Linux test cannot establish Windows receive behavior. A passing non-GRO test cannot establish what the batched/GRO implementation does. The operating-system wrapper is part of the behavior being tested. Updating and reviewing downstream use Update gotatun to 0.10.1 or a later release containing these fixes. Version 0.10.0 contains the Windows fix, but 0.10.1 is the first release that also covers the Linux panic and rate-limiter cleanup issue. For an application embedding the library, inspect the resolved dependency and the deployed build, then check the actual target platform and transport features. A library advisory establishes a vulnerable path; determining whether a specific product exposes that path requires its own configuration and integration evidence. When reviewing a similar UDP service, I would start with a few concrete questions: - Can an empty datagram reach an expect, an assertion, or an unchecked assumption about a returned buffer? - Which receive errors describe one bad message, and which require replacing the socket? - If a producer task exits, does its consumer report a failure, restart, or quietly stop? - How much state can an unauthenticated sender create by varying addresses? - Does device-wide cleanup still run with zero peers or no known peer endpoints? The cryptographic packet checks can be correct while these surrounding invariants fail. In gotatun, a handful of small control-flow changes restored the receive and cleanup behavior that those checks depended on. Thanks to the Mullvad team for addressing the reports and publishing the advisories and fixes.
Mullvad spun their own implementation off of boringtun as well (called gotatun[0]) [0] mullvad/gotatun
VPN Kill Switch Test From a Browser Tab: What Polling Can and Can't Prove TL;DR - A browser can't see the firewall rules behind a VPN kill switch. It only sees answers: which address a server saw, or no reply at all. - Sampling leaves gaps, and a leak shorter than the gap between two samples can vanish completely. - No reply is not a pass. It only proves nothing came back. You click Quit in the VPN app, and the tab that was supposed to record the leak is now behind it. That is the basic problem with any VPN kill switch test run from a web page: to test the kill switch you have to break the tunnel on purpose, and breaking it takes you away from the tab doing the measuring. That detail shaped the VPN kill switch test on MyIPScan. I built MyIPScan, and this post covers how a browser-based recorder works and what it cannot tell you. What a kill switch actually is (and why a browser can't see it) A kill switch lives below the browser. Mullvad's open-source app is a good reference because its security document spells it out. The app integrates with the system firewall: "This means WFP on Windows, PF on macOS and nftables on Linux." When it cannot build a tunnel, it enters an error state that "locks the firewall so no traffic can flow (except the always active exceptions)." The same document separates two things people often merge. The app's "kill switch" is "the fact that the connecting, disconnecting and error states prevent leaks via firewall rules." The "Lockdown mode" setting, which the document says is "regularly misunderstood as the kill switch," only decides whether the disconnected state blocks traffic too. None of this is visible from JavaScript. A page can only send requests and record what happened to them. How the VPN kill switch test records a drop The test runs in three steps, and the first two exist to give the recorder something to compare against: 1. Capture your own network. Disconnect the VPN completely and record the address your ISP gives you. 2. Capture the tunnel. Connect again and record the VPN's exit address. 3. Force the drop. Start recording, then break the tunnel the way it breaks in real life: quit the VPN app or disable the network adapter. Run it on a desktop: a phone's background tab will likely stop sampling. Each reading is one request to the site's own /api/ip-sample endpoint, which, in the page's words, "sees the address the request arrives from and sends it back with its AS number, provider name and country." The loop asks for a sample every 1.5 seconds, and every 0.6 seconds for twelve samples after anything changes, so transitions get finer resolution than the quiet stretches. Every sample lands in one of four buckets: | Outcome | Meaning | Counted as exposure? | |---|---|---| | No reply | Nothing came back | No | | Tunnel address | Matches step 2 | No | | Your own address | Matches step 1 | Yes | | Some other address | Matches neither | No, timed separately | Only "your own address" counts as a leak. A different VPN server after a reconnect is not your ISP, so it gets its own timer. Below is my illustration of how such a loop can be written, not the site's code: const samples = []; async function takeSample() { const t = performance.now(); try { const res = await fetch('/api/ip-sample', { cache: 'no-store', // never answer from cache signal: AbortSignal.timeout(3000), // a hung request is a result too }); const { ip } = await res.json(); samples.push({ t, ip, hidden: document.hidden }); } catch { samples.push({ t, ip: null, hidden: document.hidden }); // "no reply" } } A recorder should request with cache: 'no-store', which makes the browser fetch "from the remote server without first looking in the cache" (MDN, RequestInit); a cached answer could show the tunnel address after the tunnel is gone. It should also note document.hidden per sample, to explain later why the gaps grew. Why the gap you ask for is not the gap you get The loop asks for 1.5 seconds, or 0.6 seconds in fast mode. The browser decides what you actually get. According to MDN's setTimeout page, "Firefox Desktop has a minimum timeout of 1 second for inactive tabs," and Firefox for Android has a minimum of 15 minutes for inactive tabs "and may unload them entirely." Chrome throttles hidden-tab timers too, and the MyIPScan page sums up the desktop case as browsers clamping "background timers to about one per second." Chrome also has intensive throttling, introduced in Chrome 88, where timers are checked once per minute. MDN lists its conditions: nesting count of 5 or higher, the page invisible for more than 5 minutes, silent for more than 30 seconds, and WebRTC inactive. It only applies after 5 minutes hidden, so it is not relevant to a seconds-long test. Put the one-second clamp next to step 3. Forcing a drop means switching to the VPN app, so the measuring tab is hidden at the moment you care about. The 0.6-second fast mode gets clamped to about 1 second while the tab is hidden, and slow requests stretch the spacing further. Each request's round-trip time also adds to the timing uncertainty. So the page says the recorder reports "the gap it achieved rather than the gap it wanted, and says how long the tab was hidden." If the achieved gap was, say, 2.0 seconds, then any statement about leaks shorter than that is off the table. Turning samples into a window with two bounds For a run of consecutive "your own address" readings, there are two defensible numbers: - Lower bound: the page defines it as "the span actually observed, from the first matching sample to the last." The leak lasted at least this long. - Upper bound: the widest window the neighbouring samples still allow. The leak could have started just after the previous sample and ended just before the next one. Again, my illustration, not the site's code: function exposureBounds(samples, i, j) { // i..j = matching run const lower = samples[j].t - samples[i].t; const before = samples[i - 1]?.t ?? samples[i].t; const after = samples[j + 1]?.t ?? samples[j].t; return { lower, upper: after - before }; } The page's own example output is "at least 3.6s, at most 4.8s". With 0.6-second fast-mode sampling on both sides of the run, 3.6 + 2 × 0.6 = 4.8, which is consistent with the page's example. A single number like "4.2 seconds" would look more precise and be less true. What polling can't prove Silence is not success. A "no reply" sample fits a working kill switch, but also Wi-Fi reassociating, DNS failing, or a timeout. The page puts it as: "Samples that get no reply show that traffic was not moving, not who stopped it." Short leaks fall between samples. If the achieved gap is 1.5 seconds, a 400 ms window of unprotected traffic can sit entirely between two requests and never appear. The page says it directly: "A window shorter than that gap can be missed entirely." Zero matches means "none observed at this resolution", not "none occurred". It sees HTTP from one tab, not the whole machine. Background apps, an OS update service, a torrent client, DNS through another resolver: any of that can leak without touching the tab's requests. It errs toward under-reporting. If your own address changed after you captured it (the page names "a reconnected router, a mobile handover, carrier-grade NAT"), "a genuine exposure can land in the 'some other address' column instead." The error stays in the conservative direction. One run is one run. The page: "It is one session, one device, one browser and one moment," and "It is not a test of any VPN application." Nobody should publish "Brand X has no kill switch" from a single recording. On privacy, every sample is a request, so the server sees your address each time it answers. The page states that "Readings are not logged or stored on the server," that the recording "stays in this tab until you close it," and that to name the provider "only the AS number is looked up (RIPEstat), never your address." It also offers a switch to show full addresses instead of masked ones. Where this leaves a browser-based test Polling from a tab is a decent smoke test: it can catch a leak that lasts seconds and time it with error bars. It cannot certify a kill switch. For that, capture packets at the interface (Wireshark or tcpdump on the physical adapter while you kill the VPN), because that is where the firewall rules act. If you build anything like this, keep one rule: report the resolution you achieved next to every result, and never let a missing answer count as a passing one.
Custom DNS DoH Hi Mullvad Team, I understand that custom DNS queries are already encrypted inside the VPN tunnel. However, that protection ends at the exit server, where traditional DNS traffic to external resolvers continues unencrypted. This creates additional privacy and security risks: - Traffic correlation: Services like Control D assign unique Legacy IPv6 DNS addresses to individual endpoints. These persistent identifiers can allow observers to link DNS activity across different VPN exit servers and sessions. - DNS confidentiality: Network operators between the VPN exit and DNS provider can inspect DNS queries, even when Multihop and DAITA are enabled. - DNS integrity: Unencrypted DNS traffic remains vulnerable to interception and manipulation. An additional concern is the potential contradiction with DAITA’s privacy objectives. DAITA is specifically designed to make traffic analysis and correlation more difficult. However, using a unique Legacy IPv6 DNS address introduces a persistent identifier at the VPN exit, potentially undermining part of that protection. For example, if a Control D endpoint is associated with an identifiable user account, an observer with access to DNS traffic recordings from Mullvad exit servers could potentially link previously observed DNS queries to that account, even if Control D itself stores no DNS query logs. This is particularly relevant to Multihop: while it separates the user’s real IP address from exit traffic, a persistent, endpoint-specific DNS address can still allow observers to link DNS activity across otherwise separate VPN exit servers and sessions. This does not directly reveal the user’s real IP address, but it creates an avoidable correlation point that DAITA cannot protect against, since its traffic analysis defenses operate on the VPN entry connection. Custom DoH would address these risks by encrypting queries to the DNS provider and protecting endpoint identifiers embedded in HTTPS URLs. I understand that DoH cannot prevent all traffic analysis, but it would eliminate an unnecessary source of identifiable metadata. Could you reconsider implementing optional custom DoH support, particularly on iOS, while preserving Multihop and DAITA? This would meaningfully improve privacy for users who rely on external DNS providers with custom filtering rules. mullvadvpn-app#9121