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();