@samitouri / QOSAMI-WSL / commits / ef8e1c8d

Track `bind` syscall when port is 0 (#14333)

* Initial work * . * pr feedback and add unit test * minor tweaks an fix use after free in logging statement * implement PR feedback * hopefully final pr feedback * pr feedback in test function * Address PR feedback: add try/catch to TrackPort and PortZeroBind queue push --------- Co-authored-by: Ben Hillis <benhill@ntdev.microsoft.com>

Daman Mulye committed Mar 13, 2026 at 17:00 UTC ef8e1c8dba101a25d05d6e1a5d94b01bfa1ac395
3 files changed +400 -18
src/linux/init/GnsPortTracker.cpp
+211 -16
@@ -2,12 +2,12 @@
2
3 #include <filesystem>
4 #include <optional>
5 -#include <regex>
5 #include <iostream>
6 #include <linux/audit.h> /* Definition of AUDIT_* constants */
7 #include <linux/sock_diag.h>
8 #include <linux/inet_diag.h>
9 #include <linux/net.h>
10 +#include <sys/syscall.h>
11 #include <sys/xattr.h>
12 #include "common.h" // Needs to be included before sal.h before of __reserved macro
13 #include "NetlinkTransactionError.h"
@@ -73,6 +73,7 @@ void GnsPortTracker::Run()
73 // for port deallocation
74
75 std::thread{std::bind(&GnsPortTracker::RunPortRefresh, this)}.detach();
76 + std::thread{std::bind(&GnsPortTracker::RunDeferredResolve, this)}.detach();
77
78 auto future = std::make_optional(m_allocatedPortsRefresh.get_future());
79 std::optional<PortRefreshResult> refreshResult;
@@ -98,7 +99,7 @@ void GnsPortTracker::Run()
99 result = HandleRequest(allocationRequest);
100 if (result == 0)
101 {
101 - m_allocatedPorts.emplace(std::make_pair(allocationRequest, std::make_optional(time(nullptr) + c_bind_timeout_seconds)));
102 + TrackPort(allocationRequest);
103 GNS_LOG_INFO(
104 "Tracking bind call: family ({}) port ({}) protocol ({})",
105 allocationRequest.Family,
@@ -115,6 +116,20 @@ void GnsPortTracker::Run()
116 {
117 GNS_LOG_ERROR("Failed to complete bind request, {}", e.what());
118 }
119 +
120 + if (bindCall->PortZeroBind.has_value())
121 + {
122 + try
123 + {
124 + std::lock_guard lock(m_deferredMutex);
125 + m_deferredQueue.push_back(std::move(bindCall->PortZeroBind.value()));
126 + m_deferredCv.notify_one();
127 + }
128 + catch (const std::exception& e)
129 + {
130 + GNS_LOG_ERROR("Failed to queue port-0 bind for deferred resolution, {}", e.what());
131 + }
132 + }
133 }
134
135 // If bindCall is empty, then the read() timed out. Look for any closed port
@@ -133,12 +148,39 @@ void GnsPortTracker::Run()
148 }
149 }
150
151 + // Process any port-0 binds that the background thread has resolved.
152 + std::deque<PortAllocation> resolved;
153 + {
154 + std::lock_guard lock(m_resolvedMutex);
155 + resolved.swap(m_resolvedQueue);
156 + }
157 + for (auto& allocation : resolved)
158 + {
159 + const auto result = HandleRequest(allocation);
160 + if (result == 0)
161 + {
162 + TrackPort(std::move(allocation));
163 + }
164 + else
165 + {
166 + GNS_LOG_ERROR(
167 + "Failed to register resolved port-0 bind: family ({}) port ({}) protocol ({}), error {}",
168 + allocation.Family,
169 + allocation.Port,
170 + allocation.Protocol,
171 + result);
172 + }
173 + }
174 +
175 // Only look at bound ports if there's something to deallocate to avoid wasting cycles
137 - if (refreshResult.has_value() && !m_allocatedPorts.empty())
176 + if (refreshResult.has_value())
177 {
139 - future = m_allocatedPortsRefresh.get_future();
140 - refreshResult->Resume(); // This will resume the sock_diag thread
141 - refreshResult.reset();
178 + if (!m_allocatedPorts.empty())
179 + {
180 + future = m_allocatedPortsRefresh.get_future();
181 + refreshResult->Resume(); // This will resume the sock_diag thread
182 + refreshResult.reset();
183 + }
184 }
185 }
186 }
@@ -227,6 +269,7 @@ void GnsPortTracker::OnRefreshAllocatedPorts(const std::set<PortAllocation>& Por
269 it->first.Family,
270 it->first.Port,
271 it->first.Protocol);
272 +
273 it = m_allocatedPorts.erase(it);
274 continue;
275 }
@@ -299,8 +342,8 @@ std::optional<GnsPortTracker::BindCall> GnsPortTracker::ReadNextRequest()
342 }
343 catch (const std::exception& e)
344 {
302 - GNS_LOG_ERROR("Fetch to read bind() call info with ID {}lu for pid {}, {}", callInfo.id, callInfo.pid, e.what());
303 - return {{{}, callInfo.id}};
345 + GNS_LOG_ERROR("Failed to read bind() call info with ID {} for pid {}, {}", callInfo.id, callInfo.pid, e.what());
346 + return {{{}, {}, callInfo.id}};
347 }
348 }
349
@@ -310,14 +353,14 @@ std::optional<GnsPortTracker::BindCall> GnsPortTracker::GetCallInfo(
353 auto ParseSocket = [&](int Socket, size_t AddressPtr, size_t AddressLength) -> std::optional<BindCall> {
354 if (AddressLength < sizeof(sockaddr))
355 {
313 - return {{{}, CallId}}; // Invalid sockaddr. Let it go through.
356 + return {{{}, {}, CallId}}; // Invalid sockaddr. Let it go through.
357 }
358
359 auto networkNamespace = std::filesystem::read_symlink(std::format("/proc/{}/ns/net", Pid)).string();
360 if (networkNamespace != m_networkNamespace)
361 {
362 GNS_LOG_INFO("Skipping bind() call for pid {} in network namespace {}", Pid, networkNamespace.c_str());
320 - return {{{}, CallId}}; // Different network namespace. Let it go through.
363 + return {{{}, {}, CallId}}; // Different network namespace. Let it go through.
364 }
365
366 auto processMemory = m_seccompDispatcher->ReadProcessMemory(CallId, Pid, AddressPtr, AddressLength);
@@ -331,7 +374,7 @@ std::optional<GnsPortTracker::BindCall> GnsPortTracker::GetCallInfo(
374 if ((address.sa_family != AF_INET && address.sa_family != AF_INET6) ||
375 (address.sa_family == AF_INET6 && AddressLength < sizeof(sockaddr_in6)))
376 {
334 - return {{{}, CallId}}; // This is a non IP call, or invalid sockaddr_in6. Let it go through
377 + return {{{}, {}, CallId}}; // This is a non IP call, or invalid sockaddr_in6. Let it go through
378 }
379
380 // Read the port. The port *happens* to be in the same spot in memory for both sockaddr_in
@@ -343,7 +386,28 @@ std::optional<GnsPortTracker::BindCall> GnsPortTracker::GetCallInfo(
386 in_port_t port = ntohs(inAddr->sin_port);
387 if (port == 0)
388 {
346 - return {{{}, CallId}}; // If port is 0, just let the call go through
389 + // Port 0 means the kernel will assign an ephemeral port. We can't know
390 + // the port until after the bind() completes, so duplicate the socket fd
391 + // now (while the process is still stopped by seccomp) and defer the
392 + // getsockname() lookup to after CompleteRequest() unblocks it.
393 + try
394 + {
395 + const int protocol = GetSocketProtocol(Pid, Socket);
396 + auto dupFd = DuplicateSocketFd(Pid, Socket);
397 + if (!dupFd)
398 + {
399 + return {{{}, {}, CallId}};
400 + }
401 + if (!m_seccompDispatcher->ValidateCookie(CallId))
402 + {
403 + return {{{}, {}, CallId}};
404 + }
405 + return {{{}, DeferredPortLookup{Pid, std::move(dupFd), protocol}, CallId}};
406 + }
407 + catch (const std::exception&)
408 + {
409 + return {{{}, {}, CallId}}; // Can't determine protocol, just let it through
410 + }
411 }
412
413 in6_addr storedAddress = {};
@@ -370,7 +434,7 @@ std::optional<GnsPortTracker::BindCall> GnsPortTracker::GetCallInfo(
434 throw RuntimeErrorWithSourceLocation(std::format("Invalid call id {}", CallId));
435 }
436
373 - return {{{PortAllocation(port, address.sa_family, protocol, storedAddress)}, CallId}};
437 + return {{{PortAllocation(port, address.sa_family, protocol, storedAddress)}, {}, CallId}};
438 };
439 #ifdef __x86_64__
440 if (Arch & __AUDIT_ARCH_64BIT)
@@ -384,7 +448,7 @@ std::optional<GnsPortTracker::BindCall> GnsPortTracker::GetCallInfo(
448 {
449 if (Arguments[0] != SYS_BIND)
450 {
387 - return {{{}, CallId}}; // Not a bind call, just let the call go through
451 + return {{{}, {}, CallId}}; // Not a bind call, just let the call go through
452 }
453 // Grab the first 3 parameters
454 auto processMemory = m_seccompDispatcher->ReadProcessMemory(CallId, Pid, Arguments[1], sizeof(uint32_t) * 3);
@@ -410,7 +474,7 @@ int GnsPortTracker::GetSocketProtocol(int pid, int fd)
474 {
475 const auto path = std::format("/proc/{}/fd/{}", pid, fd);
476
413 - // Because there's a race between the time where the buffer size is determined and
477 + // Because there's a race between the time where the buffer size is determined
478 // and the actual getxattr() call, retry until the buffer size is big enough
479 std::string protocol;
480 int result = -1;
@@ -424,7 +488,7 @@ int GnsPortTracker::GetSocketProtocol(int pid, int fd)
488
489 if (result < 0)
490 {
427 - RuntimeErrorWithSourceLocation(std::format("Failed to read protocol for socket: {}, {}", path, errno));
491 + throw RuntimeErrorWithSourceLocation(std::format("Failed to read protocol for socket: {}, {}", path, errno));
492 }
493
494 // In case the size of the attribute shrunk between the two getxattr calls
@@ -442,6 +506,137 @@ int GnsPortTracker::GetSocketProtocol(int pid, int fd)
506 throw RuntimeErrorWithSourceLocation(std::format("Unexpected IP socket protocol: {}", protocol));
507 }
508
509 +wil::unique_fd GnsPortTracker::DuplicateSocketFd(pid_t Pid, int SocketFd)
510 +{
511 + // Duplicate the socket fd from the target process into our address space.
512 + // We cannot use open("/proc/pid/fd/N") for sockets because the symlink target
513 + // (socket:[inode]) is not a valid filesystem path. Use pidfd_getfd() instead.
514 + wil::unique_fd pidFd(static_cast<int>(syscall(SYS_pidfd_open, Pid, 0u)));
515 + if (!pidFd)
516 + {
517 + GNS_LOG_INFO("Port-0 bind: pidfd_open failed for pid {} (errno {})", Pid, errno);
518 + return {};
519 + }
520 +
521 + wil::unique_fd dupFd(static_cast<int>(syscall(SYS_pidfd_getfd, pidFd.get(), SocketFd, 0u)));
522 + if (!dupFd)
523 + {
524 + GNS_LOG_INFO("Port-0 bind: pidfd_getfd failed for pid {} fd {} (errno {})", Pid, SocketFd, errno);
525 + }
526 +
527 + return dupFd;
528 +}
529 +
530 +void GnsPortTracker::TrackPort(PortAllocation allocation)
531 +try
532 +{
533 + // Use insert_or_assign so the deallocation timeout is refreshed if the same
534 + // port key is already present (emplace would silently keep the old entry).
535 + m_allocatedPorts.insert_or_assign(std::move(allocation), std::make_optional(time(nullptr) + c_bind_timeout_seconds));
536 +}
537 +catch (const std::exception& e)
538 +{
539 + GNS_LOG_ERROR("Failed to track port allocation, {}", e.what());
540 +}
541 +
542 +void GnsPortTracker::RunDeferredResolve()
543 +{
544 + UtilSetThreadName("GnsPortZero");
545 +
546 + for (;;)
547 + {
548 + DeferredPortLookup lookup{0, {}, 0};
549 + {
550 + std::unique_lock lock(m_deferredMutex);
551 + m_deferredCv.wait(lock, [&] { return !m_deferredQueue.empty(); });
552 + lookup = std::move(m_deferredQueue.front());
553 + m_deferredQueue.pop_front();
554 + }
555 +
556 + const auto pid = lookup.Pid;
557 + try
558 + {
559 + ResolvePortZeroBind(std::move(lookup));
560 + }
561 + catch (const std::exception& e)
562 + {
563 + GNS_LOG_ERROR("Failed to resolve port-0 bind for pid {}, {}", pid, e.what());
564 + }
565 + }
566 +}
567 +
568 +void GnsPortTracker::ResolvePortZeroBind(DeferredPortLookup lookup)
569 +{
570 + // The socket fd was already duplicated (via pidfd_getfd) while the target process
571 + // was stopped by seccomp, so it remains valid even if the process has closed or
572 + // reused the original fd number.
573 +
574 + // The bind() syscall is being completed asynchronously on the seccomp dispatcher
575 + // thread after CompleteRequest() unblocks it. Poll getsockname() briefly until
576 + // the kernel assigns a port.
577 + constexpr int maxRetries = 25;
578 + constexpr auto retryDelay = std::chrono::milliseconds(100);
579 +
580 + in_port_t port = 0;
581 + in6_addr address = {};
582 + int resolvedFamily = 0;
583 +
584 + for (int attempt = 0; attempt < maxRetries; ++attempt)
585 + {
586 + if (attempt > 0)
587 + {
588 + std::this_thread::sleep_for(retryDelay);
589 + }
590 +
591 + sockaddr_storage storage{};
592 + socklen_t addrLen = sizeof(storage);
593 + if (getsockname(lookup.DuplicatedSocketFd.get(), reinterpret_cast<sockaddr*>(&storage), &addrLen) != 0)
594 + {
595 + GNS_LOG_ERROR("Port-0 bind: getsockname failed for pid {} (errno {})", lookup.Pid, errno);
596 + return;
597 + }
598 +
599 + resolvedFamily = static_cast<int>(storage.ss_family);
600 +
601 + if (storage.ss_family == AF_INET)
602 + {
603 + const auto* sin = reinterpret_cast<const sockaddr_in*>(&storage);
604 + port = ntohs(sin->sin_port);
605 + address.s6_addr32[0] = sin->sin_addr.s_addr;
606 + }
607 + else if (storage.ss_family == AF_INET6)
608 + {
609 + const auto* sin6 = reinterpret_cast<const sockaddr_in6*>(&storage);
610 + port = ntohs(sin6->sin6_port);
611 + memcpy(address.s6_addr32, sin6->sin6_addr.s6_addr32, sizeof(address.s6_addr32));
612 + }
613 + else
614 + {
615 + GNS_LOG_ERROR("Port-0 bind: unexpected address family ({}) for pid {}", resolvedFamily, lookup.Pid);
616 + return;
617 + }
618 +
619 + if (port != 0)
620 + {
621 + break;
622 + }
623 + }
624 +
625 + if (port == 0)
626 + {
627 + GNS_LOG_ERROR("Port-0 bind: kernel did not assign a port for pid {} after retries", lookup.Pid);
628 + return;
629 + }
630 +
631 + PortAllocation allocation(port, resolvedFamily, lookup.Protocol, address);
632 + GNS_LOG_INFO(
633 + "Port-0 bind resolved: family ({}) port ({}) protocol ({}) for pid {}", resolvedFamily, port, lookup.Protocol, lookup.Pid);
634 + {
635 + std::lock_guard lock(m_resolvedMutex);
636 + m_resolvedQueue.push_back(std::move(allocation));
637 + }
638 +}
639 +
640 std::ostream& operator<<(std::ostream& out, const GnsPortTracker::PortAllocation& entry)
641 {
642 return out << "Port=" << entry.Port << ", Family=" << entry.Family << ", Protocol=" << entry.Protocol;
src/linux/init/GnsPortTracker.h
+37 -2
@@ -1,7 +1,9 @@
1 // Copyright (C) Microsoft Corporation. All rights reserved.
2
3 #pragma once
4 +#include <deque>
5 #include <map>
6 +#include <mutex>
7 #include <set>
8 #include <utility>
9 #include <optional>
@@ -92,9 +94,27 @@ public:
94 }
95 };
96
97 + struct DeferredPortLookup
98 + {
99 + pid_t Pid;
100 + wil::unique_fd DuplicatedSocketFd; // Duplicated via pidfd_getfd while process was stopped
101 + int Protocol;
102 +
103 + DeferredPortLookup(pid_t Pid, wil::unique_fd DuplicatedSocketFd, int Protocol) :
104 + Pid(Pid), DuplicatedSocketFd(std::move(DuplicatedSocketFd)), Protocol(Protocol)
105 + {
106 + }
107 +
108 + DeferredPortLookup(DeferredPortLookup&&) = default;
109 + DeferredPortLookup& operator=(DeferredPortLookup&&) = default;
110 + DeferredPortLookup(const DeferredPortLookup&) = delete;
111 + DeferredPortLookup& operator=(const DeferredPortLookup&) = delete;
112 + };
113 +
114 struct BindCall
115 {
116 std::optional<PortAllocation> Request;
117 + std::optional<DeferredPortLookup> PortZeroBind;
118 std::uint64_t CallId;
119 };
120
@@ -118,14 +138,20 @@ private:
138
139 int RequestPort(const PortAllocation& Port, bool Allocate);
140
121 - int ClosePort(const PortAllocation& Port);
122 -
141 int HandleRequest(const PortAllocation& Request);
142
143 void CompleteRequest(uint64_t Id, int Result);
144
145 static int GetSocketProtocol(int Pid, int Fd);
146
147 + static wil::unique_fd DuplicateSocketFd(pid_t Pid, int SocketFd);
148 +
149 + void ResolvePortZeroBind(DeferredPortLookup lookup);
150 +
151 + void RunDeferredResolve();
152 +
153 + void TrackPort(PortAllocation allocation);
154 +
155 std::map<PortAllocation, std::optional<time_t>> m_allocatedPorts;
156 std::shared_ptr<wsl::shared::SocketChannel> m_hvSocketChannel;
157 NetlinkChannel m_channel;
@@ -137,6 +163,15 @@ private:
163 std::shared_ptr<SecCompDispatcher> m_seccompDispatcher;
164
165 std::string m_networkNamespace;
166 +
167 + std::mutex m_deferredMutex;
168 + std::condition_variable m_deferredCv;
169 + std::deque<DeferredPortLookup> m_deferredQueue;
170 +
171 + // Resolved port-0 allocations posted by the background RunDeferredResolve thread
172 + // for the main Run() loop to process (keeps SocketChannel access single-threaded).
173 + std::mutex m_resolvedMutex;
174 + std::deque<PortAllocation> m_resolvedQueue;
175 };
176
177 std::ostream& operator<<(std::ostream& out, const GnsPortTracker::PortAllocation& portAllocation);
test/windows/NetworkTests.cpp
+152
@@ -2023,6 +2023,134 @@ class NetworkTests
2023 return std::tuple(std::move(process), std::move(read));
2024 }
2025
2026 + // Bind port 0 in the guest and return the process handle and the kernel-assigned port.
2027 + // Uses socat's -dd output to extract the actual port from the "listening on" line.
2028 + static std::tuple<unique_kill_process, uint16_t> BindGuestPortZero(bool Ipv6 = false)
2029 + {
2030 + auto [stdErrRead, stdErrWrite] = CreateSubprocessPipe(false, true);
2031 + const std::wstring protocol = Ipv6 ? L"TCP6-LISTEN:0" : L"TCP4-LISTEN:0";
2032 + const std::wstring wslCmd = L"socat -dd " + protocol + L" STDOUT";
2033 + auto cmd = LxssGenerateWslCommandLine(wslCmd.data());
2034 +
2035 + auto process = LxsstuStartProcess(cmd.data(), nullptr, nullptr, stdErrWrite.get());
2036 + stdErrWrite.reset();
2037 +
2038 + // Parse the assigned port from socat's debug output.
2039 + // socat -dd prints a line like: "... listening on AF=2 0.0.0.0:PORT"
2040 + std::string output(512, '\0');
2041 + DWORD writeOffset = 0;
2042 + uint16_t assignedPort = 0;
2043 + bool found = false;
2044 +
2045 + while (!found)
2046 + {
2047 + // Grow the buffer if full to avoid zero-byte reads and infinite loops.
2048 + if (writeOffset == output.size())
2049 + {
2050 + output.resize(output.size() * 2);
2051 + }
2052 +
2053 + DWORD bytesRead = 0;
2054 + if (!ReadFile(stdErrRead.get(), output.data() + writeOffset, static_cast<DWORD>(output.size() - writeOffset), &bytesRead, nullptr))
2055 + {
2056 + break;
2057 + }
2058 +
2059 + if (bytesRead == 0)
2060 + {
2061 + break;
2062 + }
2063 +
2064 + writeOffset += bytesRead;
2065 + LogInfo("output %hs", output.c_str());
2066 + std::string_view outputView(output.data(), writeOffset);
2067 + auto pos = outputView.find("listening on");
2068 + if (pos != std::string_view::npos)
2069 + {
2070 + // Limit the search to just the "listening on" line to avoid
2071 + // matching colons in subsequent debug lines socat may emit.
2072 + auto lineEnd = outputView.find('\n', pos);
2073 + auto line = outputView.substr(pos, lineEnd != std::string_view::npos ? lineEnd - pos : std::string_view::npos);
2074 +
2075 + // Find the last ':' before the port digits. For IPv6, socat outputs
2076 + // "listening on AF=10 :::PORT", so using find() would match the
2077 + // first colon in the address instead of the port separator.
2078 + auto colonPos = line.rfind(':');
2079 + if (colonPos != std::string_view::npos)
2080 + {
2081 + auto portStr = line.substr(colonPos + 1);
2082 + auto end = portStr.find_first_not_of("0123456789");
2083 + if (end != std::string_view::npos)
2084 + {
2085 + portStr = portStr.substr(0, end);
2086 + }
2087 +
2088 + if (portStr.empty())
2089 + {
2090 + continue;
2091 + }
2092 +
2093 + assignedPort = static_cast<uint16_t>(std::stoi(std::string(portStr)));
2094 + found = true;
2095 + }
2096 + }
2097 + }
2098 +
2099 + VERIFY_IS_TRUE(found);
2100 + VERIFY_IS_TRUE(assignedPort > 0);
2101 + LogInfo("Port-0 bind resolved to port %u", assignedPort);
2102 +
2103 + return {std::move(process), assignedPort};
2104 + }
2105 +
2106 + static void VerifyPortZeroBindIsTracked(bool verifyRelease = true)
2107 + {
2108 + // Make sure the VM doesn't time out while we wait for async port resolution
2109 + WslKeepAlive keepAlive;
2110 +
2111 + // Bind port 0 in the guest - the kernel assigns an ephemeral port.
2112 + // The port tracker intercepts the bind() via seccomp and defers lookup
2113 + // to a background thread that resolves the actual port via getsockname().
2114 + auto [guestProcess, assignedPort] = BindGuestPortZero();
2115 +
2116 + // The port-0 resolution is asynchronous (deferred to a background thread).
2117 + // Retry until the host port tracker registers the port, blocking the host bind.
2118 + VERIFY_NO_THROW(wsl::shared::retry::RetryWithTimeout<void>(
2119 + [&assignedPort]() {
2120 + wil::unique_socket sock(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP));
2121 + THROW_LAST_ERROR_IF(!sock);
2122 +
2123 + SOCKADDR_IN addr{};
2124 + addr.sin_family = AF_INET;
2125 + addr.sin_port = htons(assignedPort);
2126 + THROW_HR_IF(E_FAIL, bind(sock.get(), reinterpret_cast<SOCKADDR*>(&addr), sizeof(addr)) != SOCKET_ERROR);
2127 + },
2128 + std::chrono::seconds(1),
2129 + std::chrono::seconds(30)));
2130 +
2131 + if (!verifyRelease)
2132 + {
2133 + return;
2134 + }
2135 +
2136 + // Kill the guest process so the port tracker releases the port.
2137 + guestProcess.reset();
2138 +
2139 + // Retry until the host can bind the port again, confirming it was released.
2140 + VERIFY_NO_THROW(wsl::shared::retry::RetryWithTimeout<void>(
2141 + [&assignedPort]() {
2142 + wil::unique_socket sock(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP));
2143 + THROW_LAST_ERROR_IF(!sock);
2144 +
2145 + SOCKADDR_IN addr{};
2146 + addr.sin_family = AF_INET;
2147 + addr.sin_port = htons(assignedPort);
2148 + THROW_HR_IF(E_FAIL, bind(sock.get(), reinterpret_cast<SOCKADDR*>(&addr), sizeof(addr)) == SOCKET_ERROR);
2149 + },
2150 + std::chrono::seconds(1),
2151 + std::chrono::minutes(2)));
2152 + }
2153 +
2154 template <typename T>
2155 static void VerifyNotBound(T& Address, int AddressFamily, int Protocol)
2156 {
@@ -3920,6 +4048,21 @@ class MirroredTests
4048 auto udpPort = NetworkTests::BindGuestPort(L"UDP4-LISTEN:0", true);
4049 }
4050
4051 + TEST_METHOD(PortZeroBindIsTracked)
4052 + {
4053 + MIRRORED_NETWORKING_TEST_ONLY();
4054 +
4055 + m_config->Update(LxssGenerateTestConfig({.networkingMode = wsl::core::NetworkingMode::Mirrored}));
4056 + WaitForMirroredStateInLinux();
4057 +
4058 + // Skip port-release verification in mirrored mode. The host reserves a contiguous
4059 + // ephemeral port range via HcnReserveGuestNetworkServicePortRange that no Windows
4060 + // process can bind for the lifetime of the VM. Port-0 binds resolve to ports within
4061 + // this range, so even after the guest releases the port the host still cannot bind
4062 + // it — the range-level reservation remains, making release unverifiable.
4063 + NetworkTests::VerifyPortZeroBindIsTracked(false);
4064 + }
4065 +
4066 TEST_METHOD(ExplicitEphemeralBind)
4067 {
4068 MIRRORED_NETWORKING_TEST_ONLY();
@@ -4752,6 +4895,15 @@ class VirtioProxyTests
4895 NetworkTests::VerifyDnsResolutionRecordTypes();
4896 }
4897
4898 + TEST_METHOD(PortZeroBindIsTracked)
4899 + {
4900 + VIRTIOPROXY_TEST_ONLY();
4901 +
4902 + m_config->Update(LxssGenerateTestConfig({.networkingMode = wsl::core::NetworkingMode::VirtioProxy}));
4903 +
4904 + NetworkTests::VerifyPortZeroBindIsTracked();
4905 + }
4906 +
4907 TEST_METHOD(HttpProxySimple)
4908 {
4909 VIRTIOPROXY_TEST_ONLY();