{"@attributes":{"version":"2.0"},"channel":{"title":"DEV Community: zerox80","description":"The latest articles on DEV Community by zerox80 (@zerox80).","link":"https:\/\/dev.to\/zerox80","image":{"url":"https:\/\/media2.dev.to\/dynamic\/image\/width=90,height=90,fit=cover,gravity=auto,format=auto\/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4229178%2F887d0b8a-3a57-45e5-8458-c350bc76884c.jpg","title":"DEV Community: zerox80","link":"https:\/\/dev.to\/zerox80"},"language":"en","item":[{"title":"Before authentication: three bugs in Mullvad's gotatun","pubDate":"Thu, 05 Nov 2026 20:10:45 +0000","link":"https:\/\/dev.to\/zerox80\/before-authentication-three-bugs-in-mullvads-gotatun-25ih","guid":"https:\/\/dev.to\/zerox80\/before-authentication-three-bugs-in-mullvads-gotatun-25ih","description":"<p>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.<\/p>\n\n<p>I found three such bugs in <a href=\"https:\/\/github.com\/mullvad\/gotatun\" rel=\"noopener noreferrer\">gotatun<\/a>, 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.<\/p>\n\n<p>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.<\/p>\n\n<div class=\"table-wrapper-paragraph\"><table>\n<thead>\n<tr>\n<th>Finding<\/th>\n<th>Relevant path or condition<\/th>\n<th>Advisory severity<\/th>\n<th>First patched release<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Empty datagram causes a panic<\/td>\n<td>Linux batched\/GRO receive path<\/td>\n<td>High, CVSS 7.5<\/td>\n<td>0.10.1<\/td>\n<\/tr>\n<tr>\n<td>Oversized datagram stops reception<\/td>\n<td>Windows built-in transport without <code>windows-gro<\/code>\n<\/td>\n<td>High, CVSS 7.5<\/td>\n<td>0.10.0<\/td>\n<\/tr>\n<tr>\n<td>Source-IP counters accumulate<\/td>\n<td>Listening device with no known peer endpoints<\/td>\n<td>Moderate<\/td>\n<td>0.10.1<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/div>\n\n<p>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.<\/p>\n\n<h2>\n  \n  \n  Where the authentication boundary actually sits\n<\/h2>\n\n<p>It helps to separate accepting a datagram from accepting a WireGuard peer. The implementation first receives the datagram through its platform-specific socket code. A buffered receive task passes it through a channel to the device handler, which then runs packet verification and handshake processing.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h2>\n  \n  \n  1. An empty datagram kills the Linux receive task\n<\/h2>\n\n<p><a href=\"https:\/\/github.com\/mullvad\/gotatun\/security\/advisories\/GHSA-6gxq-8xp9-742r\" rel=\"noopener noreferrer\">GHSA-6gxq-8xp9-742r<\/a> \u00b7 High, CVSS 7.5 \u00b7 fixed in 0.10.1<\/p>\n\n<h3>\n  \n  \n  The assumption behind the panic\n<\/h3>\n\n<p>The affected Linux implementation receives datagrams in batches using <code>recvmmsg<\/code>. In <code>gotatun\/src\/udp\/socket\/linux.rs<\/code>, it obtains the payload for each result through an iterator over the received I\/O slices:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"n\">result<\/span><span class=\"nf\">.iovs<\/span><span class=\"p\">()<\/span><span class=\"nf\">.next<\/span><span class=\"p\">()<\/span><span class=\"nf\">.expect<\/span><span class=\"p\">(<\/span><span class=\"s\">\"we create exactly one IoSlice\"<\/span><span class=\"p\">)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The comment embedded in the panic message explains the assumption: one slice was supplied, so one slice must be returned.<\/p>\n\n<p>With the <code>nix<\/code> 0.31.2 behavior described in the advisory, an empty received datagram makes this iterator return <code>None<\/code>. The buffer arrangement used to request a receive does not guarantee a nonempty result iterator for every possible datagram. Calling <code>expect<\/code> turns that mismatch into a panic.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  How a packet-level edge case becomes device-wide\n<\/h3>\n\n<p>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.<\/p>\n\n<p>The resulting failure is therefore larger than a failed handshake:<\/p>\n\n<ol>\n<li>An unauthenticated sender delivers an empty datagram to the listening UDP port.<\/li>\n<li>The batched receiver reaches the <code>expect<\/code> while extracting its payload.<\/li>\n<li>The receive task panics and its channel closes.<\/li>\n<li>Further inbound traffic cannot reach the device's peers through that task.<\/li>\n<\/ol>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  The upstream fix\n<\/h3>\n\n<p>The <a href=\"https:\/\/github.com\/mullvad\/gotatun\/pull\/204\" rel=\"noopener noreferrer\">fixing pull request, #204<\/a>, changes the empty iterator result into a skip instead of a panic. This is the replacement statement inside the loop over receive results:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">let<\/span> <span class=\"nf\">Some<\/span><span class=\"p\">(<\/span><span class=\"n\">iov<\/span><span class=\"p\">)<\/span> <span class=\"o\">=<\/span> <span class=\"n\">result<\/span><span class=\"nf\">.iovs<\/span><span class=\"p\">()<\/span><span class=\"nf\">.next<\/span><span class=\"p\">()<\/span> <span class=\"k\">else<\/span> <span class=\"p\">{<\/span>\n    <span class=\"nf\">cold_path<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">if<\/span> <span class=\"nd\">cfg!<\/span><span class=\"p\">(<\/span><span class=\"n\">debug_assertions<\/span><span class=\"p\">)<\/span> <span class=\"p\">{<\/span>\n        <span class=\"nn\">tracing<\/span><span class=\"p\">::<\/span><span class=\"nd\">trace!<\/span><span class=\"p\">(<\/span><span class=\"s\">\"Received empty datagram from IoSlice\"<\/span><span class=\"p\">);<\/span>\n    <span class=\"p\">}<\/span>\n    <span class=\"k\">continue<\/span><span class=\"p\">;<\/span>\n<span class=\"p\">};<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h2>\n  \n  \n  2. An oversized datagram stops reception on Windows\n<\/h2>\n\n<p><a href=\"https:\/\/github.com\/mullvad\/gotatun\/security\/advisories\/GHSA-fc57-wfhg-7c2w\" rel=\"noopener noreferrer\">GHSA-fc57-wfhg-7c2w<\/a> \u00b7 High, CVSS 7.5 \u00b7 fixed in 0.10.0<\/p>\n\n<p>This failure starts with a socket error rather than a panic. The affected configuration is the built-in Windows transport without the optional <code>windows-gro<\/code> feature. In the reviewed path, the device supplies a default 4096-byte packet buffer to the buffered receiver.<\/p>\n\n<h3>\n  \n  \n  What Windows reports\n<\/h3>\n\n<p>When an incoming datagram is larger than the supplied buffer, Windows can return <code>WSAEMSGSIZE<\/code>, error 10040. Microsoft's <a href=\"https:\/\/learn.microsoft.com\/en-us\/windows\/win32\/api\/winsock2\/nf-winsock2-recvfrom\" rel=\"noopener noreferrer\"><code>recvfrom<\/code> documentation<\/a> describes the truncation and explains that excess UDP data is lost.<\/p>\n\n<p>That describes a problem with this message. In the native reproduction, the socket remained usable: a subsequent direct receive obtained the next, shorter message.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  Following the error through the task boundary\n<\/h3>\n\n<p>The receive producer in <a href=\"https:\/\/github.com\/mullvad\/gotatun\/blob\/3e25b63e197d2754f7ca37d55faf1980e033b80a\/gotatun\/src\/udp\/buffer.rs\" rel=\"noopener noreferrer\"><code>BufferedUdpReceive::new<\/code><\/a> exits on a failed receive. This statement sits inside its packet-processing loop:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">let<\/span> <span class=\"nf\">Ok<\/span><span class=\"p\">(())<\/span> <span class=\"o\">=<\/span> <span class=\"n\">udp_rx<\/span>\n    <span class=\"nf\">.recv_many_from<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">recv_many_buf<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">recv_pool<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">packet_bufs<\/span><span class=\"p\">)<\/span>\n    <span class=\"k\">.await<\/span>\n<span class=\"k\">else<\/span> <span class=\"p\">{<\/span>\n    <span class=\"c1\">\/\/ TODO<\/span>\n    <span class=\"k\">return<\/span><span class=\"p\">;<\/span>\n<span class=\"p\">};<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>That <code>return<\/code> drops the channel's sender. After already queued packets drain, the buffered consumer encounters the closed channel and the device's receive loop ends.<\/p>\n\n<p>The last step made the problem harder to notice. <code>DeviceState::handle_incoming<\/code> ended with <code>Ok(())<\/code>, 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.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  What I tested\n<\/h3>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  Recover at the point where the error is understood\n<\/h3>\n\n<p>The patch I included with the report handles <code>WSAEMSGSIZE<\/code> locally, discards the offending message, and continues receiving. Here is the complete <code>UdpSocket::recv_datagram<\/code> method from <a href=\"https:\/\/github.com\/mullvad\/gotatun\/blob\/v0.10.0\/gotatun\/src\/udp\/socket\/windows.rs\" rel=\"noopener noreferrer\">gotatun 0.10.0<\/a>:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">async<\/span> <span class=\"k\">fn<\/span> <span class=\"nf\">recv_datagram<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">self<\/span><span class=\"p\">,<\/span> <span class=\"n\">pool<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">PacketBufPool<\/span><span class=\"p\">)<\/span> <span class=\"k\">-&gt;<\/span> <span class=\"nn\">io<\/span><span class=\"p\">::<\/span><span class=\"nb\">Result<\/span><span class=\"o\">&lt;<\/span><span class=\"p\">(<\/span><span class=\"n\">Packet<\/span><span class=\"p\">,<\/span> <span class=\"n\">SocketAddr<\/span><span class=\"p\">)<\/span><span class=\"o\">&gt;<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">buf<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pool<\/span><span class=\"nf\">.get<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">loop<\/span> <span class=\"p\">{<\/span>\n        <span class=\"k\">match<\/span> <span class=\"k\">self<\/span><span class=\"nf\">.socket<\/span><span class=\"p\">()<\/span><span class=\"nf\">.recv_from<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">buf<\/span><span class=\"p\">)<\/span><span class=\"k\">.await<\/span> <span class=\"p\">{<\/span>\n            <span class=\"nf\">Ok<\/span><span class=\"p\">((<\/span><span class=\"n\">n<\/span><span class=\"p\">,<\/span> <span class=\"n\">src<\/span><span class=\"p\">))<\/span> <span class=\"k\">=&gt;<\/span> <span class=\"p\">{<\/span>\n                <span class=\"n\">buf<\/span><span class=\"nf\">.truncate<\/span><span class=\"p\">(<\/span><span class=\"n\">n<\/span><span class=\"p\">);<\/span>\n                <span class=\"k\">return<\/span> <span class=\"nf\">Ok<\/span><span class=\"p\">((<\/span><span class=\"n\">buf<\/span><span class=\"p\">,<\/span> <span class=\"k\">self<\/span><span class=\"nf\">.map_src<\/span><span class=\"p\">(<\/span><span class=\"n\">src<\/span><span class=\"p\">)));<\/span>\n            <span class=\"p\">}<\/span>\n            <span class=\"c1\">\/\/ Winsock discards the remainder of an oversized datagram. The socket<\/span>\n            <span class=\"c1\">\/\/ remains usable; never pass its truncated contents to the protocol.<\/span>\n            <span class=\"nf\">Err<\/span><span class=\"p\">(<\/span><span class=\"n\">err<\/span><span class=\"p\">)<\/span> <span class=\"k\">if<\/span> <span class=\"n\">err<\/span><span class=\"nf\">.raw_os_error<\/span><span class=\"p\">()<\/span> <span class=\"o\">==<\/span> <span class=\"nf\">Some<\/span><span class=\"p\">(<\/span><span class=\"nn\">WinSock<\/span><span class=\"p\">::<\/span><span class=\"n\">WSAEMSGSIZE<\/span><span class=\"p\">)<\/span> <span class=\"k\">=&gt;<\/span> <span class=\"p\">{<\/span>\n                <span class=\"nn\">tokio<\/span><span class=\"p\">::<\/span><span class=\"nn\">task<\/span><span class=\"p\">::<\/span><span class=\"nf\">yield_now<\/span><span class=\"p\">()<\/span><span class=\"k\">.await<\/span><span class=\"p\">;<\/span>\n            <span class=\"p\">}<\/span>\n            <span class=\"nf\">Err<\/span><span class=\"p\">(<\/span><span class=\"n\">err<\/span><span class=\"p\">)<\/span> <span class=\"k\">=&gt;<\/span> <span class=\"k\">return<\/span> <span class=\"nf\">Err<\/span><span class=\"p\">(<\/span><span class=\"n\">err<\/span><span class=\"p\">),<\/span>\n        <span class=\"p\">}<\/span>\n    <span class=\"p\">}<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h2>\n  \n  \n  3. A rate limiter that never forgets\n<\/h2>\n\n<p><a href=\"https:\/\/github.com\/mullvad\/gotatun\/security\/advisories\/GHSA-m2rr-7rr7-vmp7\" rel=\"noopener noreferrer\">GHSA-m2rr-7rr7-vmp7<\/a> \u00b7 Moderate \u00b7 fixed in 0.10.1<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  A valid MAC1 is not peer authentication\n<\/h3>\n\n<p>The counter insertion happens after MAC1 validation. At first glance, that can look like a useful barrier against untrusted senders allocating state.<\/p>\n\n<p>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.<\/p>\n\n<p>The resource that matters here is the number of distinct source IPs, because those addresses become map keys. The <a href=\"https:\/\/github.com\/mullvad\/gotatun\/blob\/v0.9.2\/gotatun\/src\/noise\/rate_limiter.rs\" rel=\"noopener noreferrer\">0.9.2 rate-limiter implementation<\/a> inserts or increments the entry before comparing its count to the per-IP limit:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">fn<\/span> <span class=\"nf\">is_under_load<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">self<\/span><span class=\"p\">,<\/span> <span class=\"n\">src_addr<\/span><span class=\"p\">:<\/span> <span class=\"n\">IpAddr<\/span><span class=\"p\">)<\/span> <span class=\"k\">-&gt;<\/span> <span class=\"nb\">bool<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">ip_counts<\/span> <span class=\"o\">=<\/span> <span class=\"k\">self<\/span><span class=\"py\">.ip_counts<\/span><span class=\"nf\">.lock<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">count<\/span> <span class=\"o\">=<\/span> <span class=\"n\">ip_counts<\/span><span class=\"py\">.counts<\/span><span class=\"nf\">.entry<\/span><span class=\"p\">(<\/span><span class=\"n\">src_addr<\/span><span class=\"p\">)<\/span><span class=\"nf\">.or_insert<\/span><span class=\"p\">(<\/span><span class=\"mi\">0<\/span><span class=\"p\">);<\/span>\n    <span class=\"o\">*<\/span><span class=\"n\">count<\/span> <span class=\"o\">+=<\/span> <span class=\"mi\">1<\/span><span class=\"p\">;<\/span>\n    <span class=\"o\">*<\/span><span class=\"n\">count<\/span> <span class=\"o\">&gt;<\/span> <span class=\"k\">self<\/span><span class=\"py\">.limit<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  Cleanup depended on peer activity\n<\/h3>\n\n<p>The reset method in that same file checks whether enough time has passed, clears the entries, and updates its timestamp:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">pub<\/span> <span class=\"k\">fn<\/span> <span class=\"nf\">try_reset_count<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">self<\/span><span class=\"p\">)<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">current_time<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">Instant<\/span><span class=\"p\">::<\/span><span class=\"nf\">now<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">ip_counts<\/span> <span class=\"o\">=<\/span> <span class=\"k\">self<\/span><span class=\"py\">.ip_counts<\/span><span class=\"nf\">.lock<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">if<\/span> <span class=\"n\">current_time<\/span><span class=\"nf\">.duration_since<\/span><span class=\"p\">(<\/span><span class=\"n\">ip_counts<\/span><span class=\"py\">.last_reset<\/span><span class=\"p\">)<\/span> <span class=\"o\">&gt;=<\/span> <span class=\"n\">RESET_PERIOD<\/span> <span class=\"p\">{<\/span>\n        <span class=\"n\">ip_counts<\/span><span class=\"py\">.counts<\/span><span class=\"nf\">.clear<\/span><span class=\"p\">();<\/span>\n        <span class=\"n\">ip_counts<\/span><span class=\"py\">.last_reset<\/span> <span class=\"o\">=<\/span> <span class=\"n\">current_time<\/span><span class=\"p\">;<\/span>\n    <span class=\"p\">}<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>It has to be called for any of that to happen. The caller was reached through the peer timer path, which 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.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  Emptying a map and releasing its allocation are different\n<\/h3>\n\n<p>There was another relevant detail: <a href=\"https:\/\/doc.rust-lang.org\/std\/collections\/struct.HashMap.html#method.clear\" rel=\"noopener noreferrer\"><code>HashMap::clear()<\/code><\/a> removes entries while retaining allocated memory for reuse.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h3>\n  \n  \n  What the fix changes\n<\/h3>\n\n<p>The <a href=\"https:\/\/github.com\/mullvad\/gotatun\/pull\/205\" rel=\"noopener noreferrer\">upstream fix, #205<\/a>, adds a reset attempt to the device's periodic maintenance, independently of the per-peer update path. The added call is straightforward:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">if<\/span> <span class=\"k\">let<\/span> <span class=\"nf\">Some<\/span><span class=\"p\">(<\/span><span class=\"n\">rate_limiter<\/span><span class=\"p\">)<\/span> <span class=\"o\">=<\/span> <span class=\"n\">device<\/span><span class=\"py\">.rate_limiter<\/span><span class=\"nf\">.as_ref<\/span><span class=\"p\">()<\/span> <span class=\"p\">{<\/span>\n    <span class=\"n\">rate_limiter<\/span><span class=\"nf\">.try_reset_count<\/span><span class=\"p\">();<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>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.<\/p>\n\n<h2>\n  \n  \n  Testing the receive path after the bad input\n<\/h2>\n\n<p>These findings suggest a more useful test question than whether a malformed packet gets rejected: can the service still do useful work afterward?<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<p>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.<\/p>\n\n<h2>\n  \n  \n  Updating and reviewing downstream use\n<\/h2>\n\n<p>Update gotatun to <strong>0.10.1 or a later release containing these fixes<\/strong>. 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.<\/p>\n\n<p>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.<\/p>\n\n<p>When reviewing a similar UDP service, I would start with a few concrete questions:<\/p>\n\n<ul>\n<li>Can an empty datagram reach an <code>expect<\/code>, an assertion, or an unchecked assumption about a returned buffer?<\/li>\n<li>Which receive errors describe one bad message, and which require replacing the socket?<\/li>\n<li>If a producer task exits, does its consumer report a failure, restart, or quietly stop?<\/li>\n<li>How much state can an unauthenticated sender create by varying addresses?<\/li>\n<li>Does device-wide cleanup still run with zero peers or no known peer endpoints?<\/li>\n<\/ul>\n\n<p>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.<\/p>\n\n<p>Thanks to the Mullvad team for addressing the reports and publishing the advisories and fixes.<\/p>\n\n","category":["security","rust","wireguard","networking"]},{"title":"Two ways to stall quinn, Rust's QUIC library","pubDate":"Thu, 05 Nov 2026 20:10:34 +0000","link":"https:\/\/dev.to\/zerox80\/two-ways-to-stall-quinn-rusts-quic-library-3150","guid":"https:\/\/dev.to\/zerox80\/two-ways-to-stall-quinn-rusts-quic-library-3150","description":"<p>An oversized UDP datagram and an ordinary-looking pair of transport parameters were enough to expose two very different denial-of-service bugs in <a href=\"https:\/\/github.com\/quinn-rs\/quinn\" rel=\"noopener noreferrer\">quinn<\/a>, a QUIC implementation in Rust.<\/p>\n\n<p>I reported both to the maintainers, and both are fixed. The first lives below QUIC in <code>quinn-udp<\/code>: a receive fallback can keep retrying a nonblocking socket instead of returning control to the runtime. The second lives in <code>quinn-proto<\/code>: a peer-supplied minimum ACK delay can violate the precondition of <code>Duration::clamp<\/code> and cause a panic.<\/p>\n\n<p>Their prerequisites are different, so it is useful to keep them separate from the start:<\/p>\n\n<div class=\"table-wrapper-paragraph\"><table>\n<thead>\n<tr>\n<th>Finding<\/th>\n<th>Component<\/th>\n<th>Required condition<\/th>\n<th>First patched release<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Receive loop spins after truncation<\/td>\n<td><code>quinn-udp<\/code><\/td>\n<td>Unix <code>recv_single<\/code> fallback and a datagram larger than its receive buffer<\/td>\n<td>0.5.16 in the 0.5 line; 0.6.3 in the 0.6 line<\/td>\n<\/tr>\n<tr>\n<td>ACK-delay calculation panics<\/td>\n<td><code>quinn-proto<\/code><\/td>\n<td>Sending ACK_FREQUENCY enabled, plus peer parameters that invert the calculated bounds<\/td>\n<td>0.11.18<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/div>\n\n<p>The UDP bug happens before QUIC packet authentication. The ACK-delay bug is reached while processing a connection with the relevant extension enabled; sending ACK_FREQUENCY is disabled by default. Neither finding should be read as a claim that every quinn endpoint has the same exposure.<\/p>\n\n<h2>\n  \n  \n  1. recv_single() spins after a truncated datagram\n<\/h2>\n\n<p><a href=\"https:\/\/github.com\/quinn-rs\/quinn\/security\/advisories\/GHSA-6pp4-4cxf-xf88\" rel=\"noopener noreferrer\">GHSA-6pp4-4cxf-xf88<\/a> \u00b7 High \u00b7 <code>quinn-udp<\/code> \u00b7 fixed in 0.5.16 and 0.6.3<\/p>\n\n<h3>\n  \n  \n  Why returning WouldBlock matters\n<\/h3>\n\n<p>The affected function is the Unix <code>recv_single<\/code> fallback in <code>quinn-udp\/src\/unix.rs<\/code>. It receives one datagram at a time using <code>recvmsg<\/code>, rather than the ordinary Linux batched receive path.<\/p>\n\n<p>Production callers include OpenBSD and other Unix\/Apple fallback configurations. Windows uses a different implementation. A test that explicitly calls the fallback on Linux is therefore evidence about that function, not proof that Linux's normal production path is vulnerable.<\/p>\n\n<p>The socket is nonblocking. When there is no datagram available, the receive call should return an error such as <code>EAGAIN<\/code> or <code>EWOULDBLOCK<\/code>, which the Rust layer exposes as <code>WouldBlock<\/code>. That is how the receiver tells its caller to stop trying immediately and wait for readiness again.<\/p>\n\n<p>If the function keeps retrying without receiving data, reaching an await point, or returning that signal, it can occupy the runtime thread that is driving the endpoint. The connection logic above it cannot fix the problem because control has not returned there.<\/p>\n\n<h3>\n  \n  \n  Two reasonable-looking decisions combine badly\n<\/h3>\n\n<p>The old code had two relevant properties:<\/p>\n\n<ul>\n<li>It inspected <code>MSG_TRUNC<\/code> before handling a negative <code>recvmsg<\/code> result.<\/li>\n<li>It initialized the <code>msghdr<\/code> once, outside the retry loop.<\/li>\n<\/ul>\n\n<p><code>msghdr<\/code> contains both input information and output fields. Among the outputs, <code>msg_flags<\/code> can indicate that a received datagram was truncated because the payload did not fit in the buffer.<\/p>\n\n<p>Discarding such a datagram is reasonable. Reusing a structure can also look harmless when the next successful call is expected to replace its output fields. The problem appears when the next call fails and the code still reads those fields as fresh output.<\/p>\n\n<p>Here is the vulnerable <code>recv_single<\/code> function from the <a href=\"https:\/\/github.com\/quinn-rs\/quinn\/blob\/65a87d40c22e0818e50adfa277611610d792a43d\/quinn-udp\/src\/unix.rs\" rel=\"noopener noreferrer\">reviewed source revision<\/a>. The imports and crate-local helper definitions live in the same module:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">pub<\/span><span class=\"p\">(<\/span><span class=\"k\">crate<\/span><span class=\"p\">)<\/span> <span class=\"k\">fn<\/span> <span class=\"nf\">recv_single<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">io<\/span><span class=\"p\">:<\/span> <span class=\"n\">SockRef<\/span><span class=\"o\">&lt;<\/span><span class=\"nv\">'_<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">bufs<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"p\">[<\/span><span class=\"n\">IoSliceMut<\/span><span class=\"o\">&lt;<\/span><span class=\"nv\">'_<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">],<\/span>\n    <span class=\"n\">meta<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"p\">[<\/span><span class=\"n\">RecvMeta<\/span><span class=\"p\">],<\/span>\n<span class=\"p\">)<\/span> <span class=\"k\">-&gt;<\/span> <span class=\"nn\">io<\/span><span class=\"p\">::<\/span><span class=\"nb\">Result<\/span><span class=\"o\">&lt;<\/span><span class=\"nb\">usize<\/span><span class=\"o\">&gt;<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">name<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">MaybeUninit<\/span><span class=\"p\">::<\/span><span class=\"o\">&lt;<\/span><span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"n\">sockaddr_storage<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">::<\/span><span class=\"nf\">uninit<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">ctrl<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">cmsg<\/span><span class=\"p\">::<\/span><span class=\"nf\">Aligned<\/span><span class=\"p\">(<\/span><span class=\"nn\">MaybeUninit<\/span><span class=\"p\">::<\/span><span class=\"o\">&lt;<\/span><span class=\"p\">[<\/span><span class=\"nb\">u8<\/span><span class=\"p\">;<\/span> <span class=\"nn\">cmsg<\/span><span class=\"p\">::<\/span><span class=\"n\">LEN<\/span><span class=\"p\">]<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">::<\/span><span class=\"nf\">uninit<\/span><span class=\"p\">());<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">hdr<\/span> <span class=\"o\">=<\/span> <span class=\"k\">unsafe<\/span> <span class=\"p\">{<\/span> <span class=\"nn\">mem<\/span><span class=\"p\">::<\/span><span class=\"nn\">zeroed<\/span><span class=\"p\">::<\/span><span class=\"o\">&lt;<\/span><span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"n\">msghdr<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">()<\/span> <span class=\"p\">};<\/span>\n    <span class=\"nf\">prepare_recv<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">bufs<\/span><span class=\"p\">[<\/span><span class=\"mi\">0<\/span><span class=\"p\">],<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">name<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">ctrl<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">hdr<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">n<\/span> <span class=\"o\">=<\/span> <span class=\"k\">loop<\/span> <span class=\"p\">{<\/span>\n        <span class=\"k\">let<\/span> <span class=\"n\">n<\/span> <span class=\"o\">=<\/span> <span class=\"k\">unsafe<\/span> <span class=\"p\">{<\/span> <span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"nf\">recvmsg<\/span><span class=\"p\">(<\/span><span class=\"n\">io<\/span><span class=\"nf\">.as_raw_fd<\/span><span class=\"p\">(),<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">hdr<\/span><span class=\"p\">,<\/span> <span class=\"mi\">0<\/span><span class=\"p\">)<\/span> <span class=\"p\">};<\/span>\n\n        <span class=\"k\">if<\/span> <span class=\"n\">hdr<\/span><span class=\"py\">.msg_flags<\/span> <span class=\"o\">&amp;<\/span> <span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"n\">MSG_TRUNC<\/span> <span class=\"o\">!=<\/span> <span class=\"mi\">0<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">continue<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n\n        <span class=\"k\">if<\/span> <span class=\"n\">n<\/span> <span class=\"o\">&gt;=<\/span> <span class=\"mi\">0<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">break<\/span> <span class=\"n\">n<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n\n        <span class=\"k\">let<\/span> <span class=\"n\">e<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">io<\/span><span class=\"p\">::<\/span><span class=\"nn\">Error<\/span><span class=\"p\">::<\/span><span class=\"nf\">last_os_error<\/span><span class=\"p\">();<\/span>\n        <span class=\"k\">match<\/span> <span class=\"n\">e<\/span><span class=\"nf\">.kind<\/span><span class=\"p\">()<\/span> <span class=\"p\">{<\/span>\n            <span class=\"c1\">\/\/ Retry receiving<\/span>\n            <span class=\"nn\">io<\/span><span class=\"p\">::<\/span><span class=\"nn\">ErrorKind<\/span><span class=\"p\">::<\/span><span class=\"n\">Interrupted<\/span> <span class=\"k\">=&gt;<\/span> <span class=\"k\">continue<\/span><span class=\"p\">,<\/span>\n            <span class=\"n\">_<\/span> <span class=\"k\">=&gt;<\/span> <span class=\"k\">return<\/span> <span class=\"nf\">Err<\/span><span class=\"p\">(<\/span><span class=\"n\">e<\/span><span class=\"p\">),<\/span>\n        <span class=\"p\">}<\/span>\n    <span class=\"p\">};<\/span>\n    <span class=\"n\">meta<\/span><span class=\"p\">[<\/span><span class=\"mi\">0<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"nf\">decode_recv<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"n\">name<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"n\">hdr<\/span><span class=\"p\">,<\/span> <span class=\"n\">n<\/span> <span class=\"k\">as<\/span> <span class=\"nb\">usize<\/span><span class=\"p\">)<\/span><span class=\"o\">?<\/span><span class=\"p\">;<\/span>\n    <span class=\"nf\">Ok<\/span><span class=\"p\">(<\/span><span class=\"mi\">1<\/span><span class=\"p\">)<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The error branch is unreachable whenever the old truncation flag remains set. A flag describing the previous successful receive takes priority over the current call's failure.<\/p>\n\n<h3>\n  \n  \n  The sequence that gets stuck\n<\/h3>\n\n<p>Suppose a datagram is larger than the receive buffer. The first <code>recvmsg<\/code> succeeds with a truncated message and sets <code>MSG_TRUNC<\/code>. The receiver discards that message and retries.<\/p>\n\n<p>Now the queue is empty. The next nonblocking <code>recvmsg<\/code> fails with <code>EAGAIN<\/code>. Under the relevant semantics, that failed call can leave the previous output flags unchanged. Since the code checks <code>MSG_TRUNC<\/code> first, it interprets the stale flag as another truncated receive and immediately loops again.<\/p>\n\n<div class=\"table-wrapper-paragraph\"><table>\n<thead>\n<tr>\n<th>Attempt<\/th>\n<th>Current receive result<\/th>\n<th>Flag observed by the old code<\/th>\n<th>Decision<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>First<\/td>\n<td>Oversized datagram received<\/td>\n<td>\n<code>MSG_TRUNC<\/code> set<\/td>\n<td>Discard and retry<\/td>\n<\/tr>\n<tr>\n<td>Second<\/td>\n<td>No queued data; <code>EAGAIN<\/code>\n<\/td>\n<td>Previous <code>MSG_TRUNC<\/code> still set<\/td>\n<td>Retry instead of returning <code>WouldBlock<\/code>\n<\/td>\n<\/tr>\n<tr>\n<td>Following attempts<\/td>\n<td>Still no queued data<\/td>\n<td>Same stale flag<\/td>\n<td>Keep retrying<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/div>\n\n<p>A later successful, non-truncated receive can release the loop. That limits the claim: this is not necessarily an irreversible endpoint failure. It can still prevent useful runtime work while the loop is spinning, and its trigger occurs before any QUIC authentication.<\/p>\n\n<p>The receive buffer must be smaller than the incoming datagram. Platform selection and buffer sizing are therefore part of the exposure assessment, rather than incidental details to leave out of the advisory.<\/p>\n\n<h3>\n  \n  \n  How I reproduced it without hanging the test suite\n<\/h3>\n\n<p>I used a bounded localhost regression test that calls <code>recv_single<\/code> directly. It queues a 16-byte datagram for an 8-byte receive buffer and expects the function to discard the truncated message, observe the empty queue, and return <code>WouldBlock<\/code>.<\/p>\n\n<p>The unpatched function does not finish that sequence in time. The test sends a valid cleanup message if the call has not finished within one second, allowing the stuck receiver to escape before the test reports failure. This is useful when testing a synchronous busy loop: the regression must detect the lack of progress without leaving the test process spinning indefinitely.<\/p>\n\n<p>A second test covers truncation followed by a valid message and verifies both the payload and the source address. This catches a related class of mistakes where the function returns but carries stale receive metadata forward.<\/p>\n\n<p>Those tests ran under Linux\/WSL with the fallback explicitly enabled for unit testing. I also checked the relevant OpenBSD kernel receive semantics in source. I did not run the reproduction on native OpenBSD, and the test setup should not be presented as native platform confirmation.<\/p>\n\n<p>The <a href=\"https:\/\/github.com\/quinn-rs\/quinn\/security\/advisories\/GHSA-6pp4-4cxf-xf88\" rel=\"noopener noreferrer\">public advisory<\/a> records the before-and-after result: the regression fails before the fix and passes after it. It also records passing <code>quinn-udp<\/code> suites on Linux and Windows.<\/p>\n\n<h3>\n  \n  \n  Fix the ordering and reset the whole header\n<\/h3>\n\n<p>The fix I proposed returns receive errors before inspecting the output flags. It also initializes the receive header inside the <code>retry_if_interrupted<\/code> closure, before each syscall attempt.<\/p>\n\n<p>The <a href=\"https:\/\/github.com\/quinn-rs\/quinn\/commit\/462865a6c33039c26076f7077c7cd7e2f997b9cd\" rel=\"noopener noreferrer\">fix commit<\/a> replaces it with this function:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">pub<\/span><span class=\"p\">(<\/span><span class=\"k\">crate<\/span><span class=\"p\">)<\/span> <span class=\"k\">fn<\/span> <span class=\"nf\">recv_single<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">io<\/span><span class=\"p\">:<\/span> <span class=\"n\">SockRef<\/span><span class=\"o\">&lt;<\/span><span class=\"nv\">'_<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">bufs<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"p\">[<\/span><span class=\"n\">IoSliceMut<\/span><span class=\"o\">&lt;<\/span><span class=\"nv\">'_<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">],<\/span>\n    <span class=\"n\">meta<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"p\">[<\/span><span class=\"n\">RecvMeta<\/span><span class=\"p\">],<\/span>\n<span class=\"p\">)<\/span> <span class=\"k\">-&gt;<\/span> <span class=\"nn\">io<\/span><span class=\"p\">::<\/span><span class=\"nb\">Result<\/span><span class=\"o\">&lt;<\/span><span class=\"nb\">usize<\/span><span class=\"o\">&gt;<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">name<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">MaybeUninit<\/span><span class=\"p\">::<\/span><span class=\"o\">&lt;<\/span><span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"n\">sockaddr_storage<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">::<\/span><span class=\"nf\">uninit<\/span><span class=\"p\">();<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">ctrl<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">cmsg<\/span><span class=\"p\">::<\/span><span class=\"nf\">Aligned<\/span><span class=\"p\">(<\/span><span class=\"nn\">MaybeUninit<\/span><span class=\"p\">::<\/span><span class=\"o\">&lt;<\/span><span class=\"p\">[<\/span><span class=\"nb\">u8<\/span><span class=\"p\">;<\/span> <span class=\"nn\">cmsg<\/span><span class=\"p\">::<\/span><span class=\"n\">LEN<\/span><span class=\"p\">]<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">::<\/span><span class=\"nf\">uninit<\/span><span class=\"p\">());<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">hdr<\/span> <span class=\"o\">=<\/span> <span class=\"k\">unsafe<\/span> <span class=\"p\">{<\/span> <span class=\"nn\">mem<\/span><span class=\"p\">::<\/span><span class=\"nn\">zeroed<\/span><span class=\"p\">::<\/span><span class=\"o\">&lt;<\/span><span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"n\">msghdr<\/span><span class=\"o\">&gt;<\/span><span class=\"p\">()<\/span> <span class=\"p\">};<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">n<\/span> <span class=\"o\">=<\/span> <span class=\"k\">loop<\/span> <span class=\"p\">{<\/span>\n        <span class=\"k\">let<\/span> <span class=\"n\">n<\/span> <span class=\"o\">=<\/span> <span class=\"nf\">retry_if_interrupted<\/span><span class=\"p\">(||<\/span> <span class=\"p\">{<\/span>\n            <span class=\"nf\">prepare_recv<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">bufs<\/span><span class=\"p\">[<\/span><span class=\"mi\">0<\/span><span class=\"p\">],<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">name<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">ctrl<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">hdr<\/span><span class=\"p\">);<\/span>\n            <span class=\"k\">unsafe<\/span> <span class=\"p\">{<\/span> <span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"nf\">recvmsg<\/span><span class=\"p\">(<\/span><span class=\"n\">io<\/span><span class=\"nf\">.as_raw_fd<\/span><span class=\"p\">(),<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">hdr<\/span><span class=\"p\">,<\/span> <span class=\"mi\">0<\/span><span class=\"p\">)<\/span> <span class=\"p\">}<\/span>\n        <span class=\"p\">})<\/span><span class=\"o\">?<\/span><span class=\"p\">;<\/span>\n        <span class=\"k\">if<\/span> <span class=\"n\">hdr<\/span><span class=\"py\">.msg_flags<\/span> <span class=\"o\">&amp;<\/span> <span class=\"nn\">libc<\/span><span class=\"p\">::<\/span><span class=\"n\">MSG_TRUNC<\/span> <span class=\"o\">==<\/span> <span class=\"mi\">0<\/span> <span class=\"p\">{<\/span>\n            <span class=\"k\">break<\/span> <span class=\"n\">n<\/span><span class=\"p\">;<\/span>\n        <span class=\"p\">}<\/span>\n    <span class=\"p\">};<\/span>\n    <span class=\"n\">meta<\/span><span class=\"p\">[<\/span><span class=\"mi\">0<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"nf\">decode_recv<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"n\">name<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"n\">hdr<\/span><span class=\"p\">,<\/span> <span class=\"n\">n<\/span> <span class=\"k\">as<\/span> <span class=\"nb\">usize<\/span><span class=\"p\">)<\/span><span class=\"o\">?<\/span><span class=\"p\">;<\/span>\n    <span class=\"nf\">Ok<\/span><span class=\"p\">(<\/span><span class=\"mi\">1<\/span><span class=\"p\">)<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The <code>?<\/code> after <code>retry_if_interrupted<\/code> returns a receive error before the code can inspect <code>hdr.msg_flags<\/code>. The helper retries <code>Interrupted<\/code> errors but returns other errors, including <code>WouldBlock<\/code>.<\/p>\n\n<p>Resetting the complete header matters because the flags are not its only reusable fields. Address and control-buffer lengths also participate in the input\/output contract. A fix that only clears <code>MSG_TRUNC<\/code> risks preserving a similar assumption somewhere else in the structure.<\/p>\n\n<p>The result is a receive loop with an explicit progress rule: after successfully receiving a truncated message it may try again, but an empty nonblocking socket returns control to the caller.<\/p>\n\n<h2>\n  \n  \n  2. A peer can make clamp() panic\n<\/h2>\n\n<p><a href=\"https:\/\/github.com\/quinn-rs\/quinn\/security\/advisories\/GHSA-3g6h-r5qx-f6hq\" rel=\"noopener noreferrer\">GHSA-3g6h-r5qx-f6hq<\/a> \/ <a href=\"https:\/\/rustsec.org\/advisories\/RUSTSEC-2026-0340.html\" rel=\"noopener noreferrer\">RUSTSEC-2026-0340<\/a> \u00b7 High, CVSS 7.5 \u00b7 <code>quinn-proto<\/code> \u00b7 fixed in 0.11.18<\/p>\n\n<p>The second bug does not need a malformed UDP receive. It comes from combining peer transport parameters with a locally calculated round-trip-time bound.<\/p>\n\n<p>QUIC endpoints acknowledge received packets. When quinn is configured to send ACK_FREQUENCY frames, it calculates a delay to request from the peer. That calculation needs to account for the minimum delay the peer advertises as well as the local policy for choosing a useful delay.<\/p>\n\n<h3>\n  \n  \n  The interval that stops being an interval\n<\/h3>\n\n<p>In <code>AckFrequencyState::candidate_max_ack_delay<\/code>, the <a href=\"https:\/\/github.com\/quinn-rs\/quinn\/blob\/d6acf44667c06334530e9d4d1fb8511fd499b575\/quinn-proto\/src\/connection\/ack_frequency.rs\" rel=\"noopener noreferrer\">vulnerable implementation<\/a> converts the peer minimum to a <code>Duration<\/code> and uses it as the lower bound of <code>clamp<\/code>:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">pub<\/span><span class=\"p\">(<\/span><span class=\"k\">super<\/span><span class=\"p\">)<\/span> <span class=\"k\">fn<\/span> <span class=\"nf\">candidate_max_ack_delay<\/span><span class=\"p\">(<\/span>\n    <span class=\"o\">&amp;<\/span><span class=\"k\">self<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">rtt<\/span><span class=\"p\">:<\/span> <span class=\"n\">Duration<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">config<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"n\">AckFrequencyConfig<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">peer_params<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"n\">TransportParameters<\/span><span class=\"p\">,<\/span>\n<span class=\"p\">)<\/span> <span class=\"k\">-&gt;<\/span> <span class=\"n\">Duration<\/span> <span class=\"p\">{<\/span>\n    <span class=\"c1\">\/\/ Use the peer's max_ack_delay if no custom max_ack_delay was provided in the config<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">min_ack_delay<\/span> <span class=\"o\">=<\/span>\n        <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_micros<\/span><span class=\"p\">(<\/span><span class=\"n\">peer_params<\/span><span class=\"py\">.min_ack_delay<\/span><span class=\"nf\">.map_or<\/span><span class=\"p\">(<\/span><span class=\"mi\">0<\/span><span class=\"p\">,<\/span> <span class=\"p\">|<\/span><span class=\"n\">x<\/span><span class=\"p\">|<\/span> <span class=\"n\">x<\/span><span class=\"nf\">.into<\/span><span class=\"p\">()));<\/span>\n    <span class=\"n\">config<\/span>\n        <span class=\"py\">.max_ack_delay<\/span>\n        <span class=\"nf\">.unwrap_or<\/span><span class=\"p\">(<\/span><span class=\"k\">self<\/span><span class=\"py\">.peer_max_ack_delay<\/span><span class=\"p\">)<\/span>\n        <span class=\"nf\">.clamp<\/span><span class=\"p\">(<\/span><span class=\"n\">min_ack_delay<\/span><span class=\"p\">,<\/span> <span class=\"n\">rtt<\/span><span class=\"nf\">.max<\/span><span class=\"p\">(<\/span><span class=\"n\">MIN_AUTOMATIC_ACK_DELAY<\/span><span class=\"p\">))<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The candidate itself comes from an explicitly configured maximum delay, or from the peer's maximum ACK delay when no override is configured.<\/p>\n\n<p>The missing invariant is that the lower bound must not exceed the upper bound. <code>clamp<\/code> does not resolve contradictory bounds by choosing a compromise: it panics when <code>min &gt; max<\/code>.<\/p>\n\n<p>The peer can advertise these values and pass the transport-parameter validator:<\/p>\n\n<div class=\"table-wrapper-paragraph\"><table>\n<thead>\n<tr>\n<th>Input<\/th>\n<th>Value<\/th>\n<th>Converted duration<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>min_ack_delay<\/code><\/td>\n<td>100,000 microseconds<\/td>\n<td>100 ms<\/td>\n<\/tr>\n<tr>\n<td><code>max_ack_delay<\/code><\/td>\n<td>100 milliseconds<\/td>\n<td>100 ms<\/td>\n<\/tr>\n<tr>\n<td>Current RTT used by the calculation<\/td>\n<td>5 milliseconds<\/td>\n<td>5 ms<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/div>\n\n<p>The unit conversion is worth making explicit. These two transport parameters use different units, but the example minimum and maximum both represent 100 ms. Their relationship is acceptable to the validator.<\/p>\n\n<p>The upper bound comes from the current RTT and <code>MIN_AUTOMATIC_ACK_DELAY<\/code>, which is 25 ms. At a 5 ms RTT, that produces a 25 ms upper bound against the peer's 100 ms lower bound.<\/p>\n\n<p>The values have passed one validation boundary and still violate a precondition at another. Checking each field, or even the relationship between the two advertised parameters, is insufficient when a later calculation combines them with local state.<\/p>\n\n<h3>\n  \n  \n  A small illustration of the panic\n<\/h3>\n\n<p>The following standalone example demonstrates the violated Rust precondition. It is an arithmetic illustration, not a network reproduction:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">use<\/span> <span class=\"nn\">std<\/span><span class=\"p\">::<\/span><span class=\"nn\">time<\/span><span class=\"p\">::<\/span><span class=\"n\">Duration<\/span><span class=\"p\">;<\/span>\n\n<span class=\"k\">fn<\/span> <span class=\"nf\">main<\/span><span class=\"p\">()<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">peer_minimum<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">100<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">rtt<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">5<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">automatic_floor<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">25<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">local_upper<\/span> <span class=\"o\">=<\/span> <span class=\"n\">rtt<\/span><span class=\"nf\">.max<\/span><span class=\"p\">(<\/span><span class=\"n\">automatic_floor<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">candidate<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">100<\/span><span class=\"p\">);<\/span>\n\n    <span class=\"c1\">\/\/ Panics because the minimum is greater than the maximum.<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">_<\/span> <span class=\"o\">=<\/span> <span class=\"n\">candidate<\/span><span class=\"nf\">.clamp<\/span><span class=\"p\">(<\/span><span class=\"n\">peer_minimum<\/span><span class=\"p\">,<\/span> <span class=\"n\">local_upper<\/span><span class=\"p\">);<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>Changing the candidate alone cannot repair an inverted interval. This explains why both automatic and explicitly configured maximum ACK delays could reach the bug: the problematic relationship belongs to the bounds.<\/p>\n\n<h3>\n  \n  \n  The configuration and impact limits\n<\/h3>\n\n<p>Reaching this path requires <code>TransportConfig::ack_frequency_config(Some(...))<\/code>. Sending ACK_FREQUENCY is disabled by default, so an endpoint that has not enabled it does not reach this finding's affected calculation through that feature.<\/p>\n\n<p>The demonstrated failure is a panic while driving the connection. What happens to the embedding application depends on its panic strategy and runtime behavior. I did not verify a process-wide crash, and the advisory explicitly preserves that limitation.<\/p>\n\n<p>That distinction matters operationally. A panic in a connection-driving task can be serious, but it should not automatically be reported as every quinn deployment terminating its process on receipt of these parameters.<\/p>\n\n<h3>\n  \n  \n  The fix defines which bound wins\n<\/h3>\n\n<p>The <a href=\"https:\/\/github.com\/quinn-rs\/quinn\/pull\/2868\" rel=\"noopener noreferrer\">published fix in PR #2868<\/a> replaces the <code>clamp<\/code> with explicit minimum and maximum operations. This is the corrected method from the <a href=\"https:\/\/github.com\/quinn-rs\/quinn\/blob\/981d88eccb36ae5e8585bcd3145debb00969a462\/quinn-proto\/src\/connection\/ack_frequency.rs\" rel=\"noopener noreferrer\">merged source<\/a>:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"k\">pub<\/span><span class=\"p\">(<\/span><span class=\"k\">super<\/span><span class=\"p\">)<\/span> <span class=\"k\">fn<\/span> <span class=\"nf\">candidate_max_ack_delay<\/span><span class=\"p\">(<\/span>\n    <span class=\"o\">&amp;<\/span><span class=\"k\">self<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">rtt<\/span><span class=\"p\">:<\/span> <span class=\"n\">Duration<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">config<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"n\">AckFrequencyConfig<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">peer_params<\/span><span class=\"p\">:<\/span> <span class=\"o\">&amp;<\/span><span class=\"n\">TransportParameters<\/span><span class=\"p\">,<\/span>\n<span class=\"p\">)<\/span> <span class=\"k\">-&gt;<\/span> <span class=\"n\">Duration<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">min_ack_delay<\/span> <span class=\"o\">=<\/span>\n        <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_micros<\/span><span class=\"p\">(<\/span><span class=\"n\">peer_params<\/span><span class=\"py\">.min_ack_delay<\/span><span class=\"nf\">.map_or<\/span><span class=\"p\">(<\/span><span class=\"mi\">0<\/span><span class=\"p\">,<\/span> <span class=\"p\">|<\/span><span class=\"n\">x<\/span><span class=\"p\">|<\/span> <span class=\"n\">x<\/span><span class=\"nf\">.into<\/span><span class=\"p\">()));<\/span>\n\n    <span class=\"c1\">\/\/ Use the peer's max_ack_delay if no custom max_ack_delay was provided in the config<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">candidate<\/span> <span class=\"o\">=<\/span> <span class=\"n\">config<\/span><span class=\"py\">.max_ack_delay<\/span><span class=\"nf\">.unwrap_or<\/span><span class=\"p\">(<\/span><span class=\"k\">self<\/span><span class=\"py\">.peer_max_ack_delay<\/span><span class=\"p\">);<\/span>\n\n    <span class=\"c1\">\/\/ Must be no smaller than the `min_ack_delay` advertised by the peer<\/span>\n    <span class=\"nn\">Ord<\/span><span class=\"p\">::<\/span><span class=\"nf\">max<\/span><span class=\"p\">(<\/span>\n        <span class=\"c1\">\/\/ Should be less than the RTT (itself at least `MIN_AUTOMATIC_ACK_DELAY`)<\/span>\n        <span class=\"nn\">Ord<\/span><span class=\"p\">::<\/span><span class=\"nf\">min<\/span><span class=\"p\">(<\/span><span class=\"n\">candidate<\/span><span class=\"p\">,<\/span> <span class=\"nn\">Ord<\/span><span class=\"p\">::<\/span><span class=\"nf\">max<\/span><span class=\"p\">(<\/span><span class=\"n\">rtt<\/span><span class=\"p\">,<\/span> <span class=\"n\">MIN_AUTOMATIC_ACK_DELAY<\/span><span class=\"p\">)),<\/span>\n        <span class=\"n\">min_ack_delay<\/span><span class=\"p\">,<\/span>\n    <span class=\"p\">)<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The ordering expresses the policy. Apply the local preferred upper bound first, then ensure the result is at least the minimum advertised by the peer. If the peer's minimum exceeds that local preference, the peer minimum wins. The function can return a duration without requiring the two bounds to form a valid <code>clamp<\/code> interval.<\/p>\n\n<p>For the example above, the result is 100 ms. This is more precise than saying the fix simply removes a panic: it defines the behavior for a combination of valid inputs that the old expression could not represent.<\/p>\n\n<p>The public patch also includes regression tests that encode and read the transport parameters before calling the calculation. Here is the first test, from the same module:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight rust\"><code><span class=\"nd\">#[test]<\/span>\n<span class=\"k\">fn<\/span> <span class=\"nf\">peer_min_ack_delay_can_exceed_rtt<\/span><span class=\"p\">()<\/span> <span class=\"p\">{<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">params<\/span> <span class=\"o\">=<\/span> <span class=\"n\">TransportParameters<\/span> <span class=\"p\">{<\/span>\n        <span class=\"n\">min_ack_delay<\/span><span class=\"p\">:<\/span> <span class=\"nf\">Some<\/span><span class=\"p\">(<\/span><span class=\"mi\">100_000u32<\/span><span class=\"nf\">.into<\/span><span class=\"p\">()),<\/span>\n        <span class=\"n\">max_ack_delay<\/span><span class=\"p\">:<\/span> <span class=\"mi\">100u32<\/span><span class=\"nf\">.into<\/span><span class=\"p\">(),<\/span>\n        <span class=\"o\">..<\/span><span class=\"nn\">TransportParameters<\/span><span class=\"p\">::<\/span><span class=\"nf\">default<\/span><span class=\"p\">()<\/span>\n    <span class=\"p\">};<\/span>\n    <span class=\"k\">let<\/span> <span class=\"k\">mut<\/span> <span class=\"n\">encoded<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">Vec<\/span><span class=\"p\">::<\/span><span class=\"nf\">new<\/span><span class=\"p\">();<\/span>\n    <span class=\"n\">params<\/span><span class=\"nf\">.write<\/span><span class=\"p\">(<\/span><span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">encoded<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">params<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">TransportParameters<\/span><span class=\"p\">::<\/span><span class=\"nf\">read<\/span><span class=\"p\">(<\/span><span class=\"nn\">Side<\/span><span class=\"p\">::<\/span><span class=\"n\">Client<\/span><span class=\"p\">,<\/span> <span class=\"o\">&amp;<\/span><span class=\"k\">mut<\/span> <span class=\"n\">encoded<\/span><span class=\"nf\">.as_slice<\/span><span class=\"p\">())<\/span>\n        <span class=\"nf\">.expect<\/span><span class=\"p\">(<\/span><span class=\"s\">\"peer parameters must pass wire-level validation\"<\/span><span class=\"p\">);<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">state<\/span> <span class=\"o\">=<\/span> <span class=\"nn\">AckFrequencyState<\/span><span class=\"p\">::<\/span><span class=\"nf\">new<\/span><span class=\"p\">(<\/span><span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">100<\/span><span class=\"p\">));<\/span>\n    <span class=\"k\">let<\/span> <span class=\"n\">delay<\/span> <span class=\"o\">=<\/span> <span class=\"n\">state<\/span><span class=\"nf\">.candidate_max_ack_delay<\/span><span class=\"p\">(<\/span>\n        <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">5<\/span><span class=\"p\">),<\/span>\n        <span class=\"o\">&amp;<\/span><span class=\"nn\">AckFrequencyConfig<\/span><span class=\"p\">::<\/span><span class=\"nf\">default<\/span><span class=\"p\">(),<\/span>\n        <span class=\"o\">&amp;<\/span><span class=\"n\">params<\/span><span class=\"p\">,<\/span>\n    <span class=\"p\">);<\/span>\n    <span class=\"nd\">assert_eq!<\/span><span class=\"p\">(<\/span><span class=\"n\">delay<\/span><span class=\"p\">,<\/span> <span class=\"nn\">Duration<\/span><span class=\"p\">::<\/span><span class=\"nf\">from_millis<\/span><span class=\"p\">(<\/span><span class=\"mi\">100<\/span><span class=\"p\">));<\/span>\n<span class=\"p\">}<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>A second test uses the same minimum with a 200 ms maximum. At a 5 ms RTT, both expect a 100 ms result.<\/p>\n\n<p>Passing the values through the wire-level validator is useful evidence. A test that only invents an impossible internal state might protect a helper function while saying little about remote reachability. Here the tests preserve the connection between accepted input and the arithmetic that consumes it.<\/p>\n\n<h2>\n  \n  \n  Reviewing the two failures together\n<\/h2>\n\n<p>Both bugs appear at boundaries where one part of the system assumes another has established more than it actually has.<\/p>\n\n<p>In the receive loop, the code treats an output flag as current before establishing that the syscall succeeded. The missing question is whether that output belongs to this attempt.<\/p>\n\n<p>In the ACK-delay calculation, the code treats accepted parameters as safe bounds for a later operation. The missing question is whether they still satisfy the required relationship after combining them with RTT and local policy.<\/p>\n\n<p>These suggest two concrete review techniques. For syscall wrappers, trace a successful unusual result followed by a failure, including all reused output fields. For protocol arithmetic, trace accepted extreme values all the way to the operation that uses them, including relationships with changing local state.<\/p>\n\n<p>The tests should follow the same distinction. A receive regression needs to prove that control returns or a later valid packet is delivered. An arithmetic regression needs to assert the intended result for contradictory preferences, rather than only asserting that execution avoids a panic.<\/p>\n\n<h2>\n  \n  \n  Updating downstream applications\n<\/h2>\n\n<p>For these findings, the patched component versions are:<\/p>\n\n<ul>\n<li>\n<strong><code>quinn-udp<\/code> 0.5.16 or later in the 0.5 line, or 0.6.3 or later in the 0.6 line.<\/strong> Version 0.6.0 is newer than 0.5.16 but is still within the affected 0.6 range.<\/li>\n<li>\n<strong><code>quinn-proto<\/code> 0.11.18 or later containing the ACK-delay fix.<\/strong> RustSec lists versions below 0.11.0 as unaffected by this particular finding.<\/li>\n<\/ul>\n\n<p>Check the resolved versions of those components, including transitive dependencies and duplicate versions, rather than relying only on the top-level <code>quinn<\/code> declaration. The platform receive implementation, buffer size, and ACK-frequency configuration then determine which of the described paths a deployed application can reach.<\/p>\n\n<p>Disabling ACK_FREQUENCY transmission removes the prerequisite for the second finding while an update is being prepared. It does not address the UDP receive loop, which sits below that protocol feature. Each fix belongs to its own component and needs to reach the build that is actually deployed.<\/p>\n\n<p>Thanks to the quinn maintainers for handling the reports, shipping the fixes, and crediting the work in the advisories.<\/p>\n\n","category":["security","rust","quic","networking"]}]}}