@samitouri / QOSAMI-WSL / commits / 86b51fcf

Cap guest usage of host ephemeral range to half of the ports in mirrored mode (#41085)

* cap host ephemeral range usage * cap host ephemeral port usage * fix format * pr feedback and add tests * pr review * remove extra state --------- Co-authored-by: Catalin-Emil Fetoiu <cfetoiu@microsoft.com>

FetoiuCatalin committed Jul 29, 2026 at 16:28 UTC 86b51fcfe32b50cb3898a40ee10f96e93c4a78a5
3 files changed +205 -62
src/windows/service/exe/WslCoreGuestNetworkService.cpp
+68 -15
@@ -57,6 +57,10 @@ void wsl::core::networking::GuestNetworkService::CreateGuestNetworkService(
57
58 m_hostTcpEphemeralPortRange = QueryHostEphemeralPortRange(L"MSFT_NetTCPSetting");
59 m_hostUdpEphemeralPortRange = QueryHostEphemeralPortRange(L"MSFT_NetUDPSetting");
60 +
61 + WI_ASSERT(m_hostTcpEphemeralPortRange.first <= m_hostTcpEphemeralPortRange.second);
62 + WI_ASSERT(m_hostUdpEphemeralPortRange.first <= m_hostUdpEphemeralPortRange.second);
63 +
64 WSL_LOG(
65 "GuestNetworkService::CreateGuestNetworkService - host ephemeral port ranges",
66 TraceLoggingValue(m_hostTcpEphemeralPortRange.first, "tcpStartPort"),
@@ -127,6 +131,11 @@ std::pair<uint16_t, uint16_t> wsl::core::networking::GuestNetworkService::Alloca
131
132 WI_ASSERT(m_reservedPortRange.endingPort - m_reservedPortRange.startingPort == c_ephemeralPortRangeSize);
133
134 + // Count the overlap of the guest's reserved ephemeral range with the host ephemeral range
135 + // and seed the in-use counters accordingly.
136 + m_hostTcpEphemeralPortsInUse = ComputeHostEphemeralOverlap(IPPROTO_TCP);
137 + m_hostUdpEphemeralPortsInUse = ComputeHostEphemeralOverlap(IPPROTO_UDP);
138 +
139 // setting the port to zero as we do not expect any bind requests to be sent to wslcore for ports in this range
140 m_reservedPorts.emplace(std::make_pair(HCN_PORT_PROTOCOL_TCP, static_cast<uint16_t>(0)), HcnPortReservation{port, 1});
141
@@ -225,6 +234,25 @@ bool wsl::core::networking::GuestNetworkService::IsPortInGuestEphemeralRange(uin
234 return PortNumber >= m_reservedPortRange.startingPort && PortNumber <= m_reservedPortRange.endingPort;
235 }
236
237 +uint16_t wsl::core::networking::GuestNetworkService::ComputeHostEphemeralPortCap(int Protocol) const noexcept
238 +{
239 + const auto& range = (Protocol == IPPROTO_UDP) ? m_hostUdpEphemeralPortRange : m_hostTcpEphemeralPortRange;
240 +
241 + // Cap the guest at half of the host ephemeral range so it can't exhaust the host's ports. The
242 + // host range is always at least 255, so the cap will fit in uint16_t, no risk of overflow.
243 + return static_cast<uint16_t>((range.second - range.first + 1) / 2);
244 +}
245 +
246 +uint16_t wsl::core::networking::GuestNetworkService::ComputeHostEphemeralOverlap(int Protocol) const noexcept
247 +{
248 + const auto& range = (Protocol == IPPROTO_UDP) ? m_hostUdpEphemeralPortRange : m_hostTcpEphemeralPortRange;
249 +
250 + // Number of guest reserved ports that fall within the host ephemeral range.
251 + const uint16_t overlapStart = std::max<uint16_t>(m_reservedPortRange.startingPort, range.first);
252 + const uint16_t overlapEnd = std::min<uint16_t>(m_reservedPortRange.endingPort, range.second);
253 + return (overlapStart <= overlapEnd) ? static_cast<uint16_t>(overlapEnd - overlapStart + 1) : 0;
254 +}
255 +
256 int wsl::core::networking::GuestNetworkService::OnPortAllocationRequest(const SOCKADDR_INET& Address, _In_ int Protocol, _In_ bool Allocate) noexcept
257 try
258 {
@@ -263,21 +291,6 @@ try
291
292 const auto lock = m_dataLock.lock_exclusive();
293
266 - // The guest's reserved ephemeral range can overlap with the host range. Ports in
267 - // the guest range are safe for the guest to use even if they fall within the host range.
268 - if (IsPortInHostEphemeralRange(PortNumber, Protocol) && !IsPortInGuestEphemeralRange(PortNumber))
269 - {
270 - const auto& range = (Protocol == IPPROTO_UDP) ? m_hostUdpEphemeralPortRange : m_hostTcpEphemeralPortRange;
271 - WSL_LOG(
272 - "GuestNetworkService::OnPortAllocationRequest - denying port in host ephemeral range",
273 - TraceLoggingValue(StringAddress.c_str(), "IP address"),
274 - TraceLoggingValue(Protocol == IPPROTO_TCP ? "TCP" : "UDP", "protocol"),
275 - TraceLoggingValue(PortNumber, "portNumber"),
276 - TraceLoggingValue(range.first, "hostEphemeralStart"),
277 - TraceLoggingValue(range.second, "hostEphemeralEnd"));
278 - return -LX_EADDRINUSE;
279 - }
280 -
294 if (IsPortInGuestEphemeralRange(PortNumber))
295 {
296 WSL_LOG(
@@ -290,6 +303,8 @@ try
303 return 0;
304 }
305
306 + const bool isHostEphemeralPort = IsPortInHostEphemeralRange(PortNumber, Protocol);
307 +
308 HRESULT result = E_UNEXPECTED;
309 const auto it = m_reservedPorts.find(std::make_pair(HnsProtocol, PortNumber));
310 if (Allocate)
@@ -307,6 +322,24 @@ try
322 return 0;
323 }
324
325 + // New reservation for a port in the host ephemeral range: enforce the cap.
326 + if (isHostEphemeralPort)
327 + {
328 + const auto cap = ComputeHostEphemeralPortCap(Protocol);
329 + const auto portsInUse = (Protocol == IPPROTO_UDP) ? m_hostUdpEphemeralPortsInUse : m_hostTcpEphemeralPortsInUse;
330 + if (portsInUse >= cap)
331 + {
332 + WSL_LOG(
333 + "GuestNetworkService::OnPortAllocationRequest - denying port in host ephemeral range, cap reached",
334 + TraceLoggingValue(StringAddress.c_str(), "IP address"),
335 + TraceLoggingValue(Protocol == IPPROTO_TCP ? "TCP" : "UDP", "protocol"),
336 + TraceLoggingValue(PortNumber, "portNumber"),
337 + TraceLoggingValue(portsInUse, "hostEphemeralPortsInUse"),
338 + TraceLoggingValue(cap, "hostEphemeralPortCap"));
339 + return -LX_EADDRINUSE;
340 + }
341 + }
342 +
343 HANDLE port{nullptr};
344 auto releasePortOnError = wil::scope_exit([&] {
345 if (port)
@@ -324,6 +357,12 @@ try
357 if (SUCCEEDED(result))
358 {
359 m_reservedPorts.emplace(std::make_pair(HnsProtocol, PortNumber), HcnPortReservation{port, 1});
360 +
361 + if (isHostEphemeralPort)
362 + {
363 + auto& portsInUse = Protocol == IPPROTO_UDP ? m_hostUdpEphemeralPortsInUse : m_hostTcpEphemeralPortsInUse;
364 + portsInUse++;
365 + }
366 }
367 // if the port was reserved, we successfully handed over ownership
368 releasePortOnError.release();
@@ -347,6 +386,17 @@ try
386 {
387 result = m_releasePort.value()(it->second.Handle);
388 m_reservedPorts.erase(it);
389 +
390 + // Only decrement the in-use counter when the release actually succeeded. If the release
391 + // failed the reservation may still exist on the host, and undercounting would let the
392 + // guest exceed the intended cap.
393 + if (isHostEphemeralPort && SUCCEEDED(result))
394 + {
395 + auto& portsInUse = (Protocol == IPPROTO_UDP) ? m_hostUdpEphemeralPortsInUse : m_hostTcpEphemeralPortsInUse;
396 + WI_ASSERT(portsInUse > 0);
397 + portsInUse--;
398 + }
399 +
400 WSL_LOG(
401 "GuestNetworkService::OnPortAllocationRequest - released port",
402 TraceLoggingValue(PortNumber, "Port"),
@@ -387,6 +437,9 @@ void wsl::core::networking::GuestNetworkService::Stop() noexcept
437 m_releasePort.value()(reservedPort.second.Handle);
438 }
439 m_reservedPorts.clear();
440 +
441 + m_hostTcpEphemeralPortsInUse = 0;
442 + m_hostUdpEphemeralPortsInUse = 0;
443 }
444
445 m_guestNetworkServiceCallback.reset();
src/windows/service/exe/WslCoreGuestNetworkService.h
+10 -1
@@ -64,6 +64,11 @@ private:
64 _Requires_lock_held_(m_dataLock)
65 bool IsPortInGuestEphemeralRange(uint16_t PortNumber) const noexcept;
66
67 + uint16_t ComputeHostEphemeralPortCap(int Protocol) const noexcept;
68 +
69 + _Requires_lock_held_(m_dataLock)
70 + uint16_t ComputeHostEphemeralOverlap(int Protocol) const noexcept;
71 +
72 static std::pair<uint16_t, uint16_t> QueryHostEphemeralPortRange(LPCWSTR WmiClassName) noexcept;
73
74 static std::optional<LxssDynamicFunction<decltype(HcnReserveGuestNetworkServicePortRange)>> m_allocatePortRange;
@@ -78,8 +83,12 @@ private:
83 _Guarded_by_(m_dataLock) std::map<std::pair<HCN_PORT_PROTOCOL, USHORT>, HcnPortReservation> m_reservedPorts;
84 _Guarded_by_(m_dataLock) HCN_PORT_RANGE_RESERVATION m_reservedPortRange {};
85
81 - // Host ephemeral port ranges can change. They are queried once at startup, if a change occurs, the service will need to be restarted.
86 + // Host ephemeral port ranges can change. They are queried once at startup, if a change occurs, the service will need to be
87 + // restarted. Note: The host ephemeral range will be the same for both IPv4 and IPv6, but can be different for TCP and UDP.
88 std::pair<uint16_t, uint16_t> m_hostTcpEphemeralPortRange{};
89 std::pair<uint16_t, uint16_t> m_hostUdpEphemeralPortRange{};
90 +
91 + _Guarded_by_(m_dataLock) uint16_t m_hostTcpEphemeralPortsInUse {};
92 + _Guarded_by_(m_dataLock) uint16_t m_hostUdpEphemeralPortsInUse {};
93 };
94 } // namespace wsl::core::networking
test/windows/NetworkTests.cpp
+127 -46
@@ -4420,64 +4420,145 @@ class MirroredTests
4420 VERIFY_IS_TRUE(canBindUdp);
4421 }
4422
4423 - void VerifyGuestBindToHostEphemeralRangeDenied(LPCWSTR ProtocolSettingClass, LPCWSTR SocatProtocolPrefix)
4423 + static std::pair<int, int> QueryHostEphemeralRange(LPCWSTR ProtocolSettingCmdlet)
4424 {
4425 - MIRRORED_NETWORKING_TEST_ONLY();
4426 -
4427 - m_config->Update(LxssGenerateTestConfig({.networkingMode = wsl::core::NetworkingMode::Mirrored}));
4428 - WaitForMirroredStateInLinux();
4429 -
4430 - // Query the host ephemeral port range via PowerShell.
4431 - auto startQuery =
4432 - std::wstring(L"(") + ProtocolSettingClass +
4425 + const auto startQuery =
4426 + std::wstring(L"(") + ProtocolSettingCmdlet +
4427 L" | Where-Object { $_.DynamicPortRangeStartPort -gt 0 } | Select-Object -First 1).DynamicPortRangeStartPort";
4434 - auto [startStr, _1] = LxsstuLaunchPowershellAndCaptureOutput(startQuery.c_str(), 0);
4435 - const auto hostEphemeralStart = std::stoi(startStr);
4428 + auto [startStr, _1] = LxsstuLaunchPowershellAndCaptureOutput(startQuery, 0);
4429 + const auto start = std::stoi(startStr);
4430
4437 - auto countQuery =
4438 - std::wstring(L"(") + ProtocolSettingClass +
4431 + const auto countQuery =
4432 + std::wstring(L"(") + ProtocolSettingCmdlet +
4433 L" | Where-Object { $_.DynamicPortRangeNumberOfPorts -gt 0 } | Select-Object -First 1).DynamicPortRangeNumberOfPorts";
4440 - auto [countStr, _2] = LxsstuLaunchPowershellAndCaptureOutput(countQuery.c_str(), 0);
4441 - const auto hostEphemeralEnd = hostEphemeralStart + std::stoi(countStr) - 1;
4434 + auto [countStr, _2] = LxsstuLaunchPowershellAndCaptureOutput(countQuery, 0);
4435 + const auto count = std::stoi(countStr);
4436
4443 - // Get the guest ephemeral port range.
4444 - auto [start, err1] = LxsstuLaunchWslAndCaptureOutput(L"cat /proc/sys/net/ipv4/ip_local_port_range | cut -f1", 0);
4445 - start.pop_back();
4446 - const auto guestEphemeralRangeStart = std::stoi(start);
4447 -
4448 - auto [end, err2] = LxsstuLaunchWslAndCaptureOutput(L"cat /proc/sys/net/ipv4/ip_local_port_range | cut -f2", 0);
4449 - end.pop_back();
4450 - const auto guestEphemeralRangeEnd = std::stoi(end);
4451 -
4452 - // Pick a port in the host ephemeral range but not in the guest's assigned range.
4453 - // The ranges may overlap
4454 - int testPort = 0;
4455 - if (hostEphemeralStart < guestEphemeralRangeStart || hostEphemeralStart > guestEphemeralRangeEnd)
4456 - {
4457 - testPort = hostEphemeralStart;
4458 - }
4459 - else if (guestEphemeralRangeEnd < hostEphemeralEnd)
4460 - {
4461 - testPort = guestEphemeralRangeEnd + 1;
4462 - }
4463 - else
4464 - {
4465 - VERIFY_FAIL(L"Guest ephemeral range fully covers the host ephemeral range, cannot find a test port");
4466 - }
4437 + return {start, count};
4438 + }
4439
4468 - auto socatArg = std::wstring(SocatProtocolPrefix) + std::to_wstring(testPort);
4469 - auto [listener, success, read] = NetworkTests::BindGuestPortHelper(socatArg);
4470 - VERIFY_IS_FALSE(success);
4440 + static void SetHostEphemeralRange(LPCWSTR Protocol, int Start, int NumberOfPorts)
4441 + {
4442 + // Note: setting the range for v4 also sets the same range for v6, so we only need to set one of them.
4443 + auto cmd = std::format(L"netsh int ipv4 set dynamicportrange {} startport={} numberofports={}", Protocol, Start, NumberOfPorts);
4444 + VERIFY_ARE_EQUAL(LxsstuRunCommand(cmd.data()), 0L);
4445 }
4446
4473 - WSL2_TEST_METHOD(GuestTcpBindToHostEphemeralRangeDenied)
4447 + // Attempt every host-ephemeral port from the guest and verify exactly the service-enforced cap
4448 + // (half of the host ephemeral range size) can be reserved.
4449 + static void VerifyHostEphemeralRangeCap(int Protocol, int HostEphemeralStart, int HostEphemeralEnd, int Cap)
4450 {
4475 - VerifyGuestBindToHostEphemeralRangeDenied(L"Get-NetTCPSetting", L"TCP4-LISTEN:");
4451 + WslKeepAlive keepAlive;
4452 +
4453 + auto [guestStartStr, err1] = LxsstuLaunchWslAndCaptureOutput(L"cat /proc/sys/net/ipv4/ip_local_port_range | cut -f1", 0);
4454 + guestStartStr.pop_back();
4455 + const auto guestStart = std::stoi(guestStartStr);
4456 +
4457 + auto [guestEndStr, err2] = LxsstuLaunchWslAndCaptureOutput(L"cat /proc/sys/net/ipv4/ip_local_port_range | cut -f2", 0);
4458 + guestEndStr.pop_back();
4459 + const auto guestEnd = std::stoi(guestEndStr);
4460 +
4461 + const int overlapStart = std::max(HostEphemeralStart, guestStart);
4462 + const int overlapEnd = std::min(HostEphemeralEnd, guestEnd);
4463 + const int overlap = (overlapStart <= overlapEnd) ? (overlapEnd - overlapStart + 1) : 0;
4464 +
4465 + const int expectedSuccesses = Cap - overlap;
4466 + VERIFY_IS_GREATER_THAN(expectedSuccesses, 0);
4467 +
4468 + // Repeat the pattern a couple of times: verify the cap, release every socket, then verify the
4469 + // reservations drain and the full capacity becomes available again.
4470 + constexpr int c_cycles = 2;
4471 +
4472 + for (int cycle = 0; cycle < c_cycles; cycle++)
4473 + {
4474 + // Bind every candidate from one guest process so the test does not launch wsl.exe once per port.
4475 + std::wstring perlCommand = L"perl -MSocket -MErrno=EADDRINUSE -e '";
4476 + perlCommand += L"$|=1;";
4477 + perlCommand += L"my @sockets;";
4478 + perlCommand += L"my $candidate=0;";
4479 + perlCommand +=
4480 + L"for my $port (" + std::to_wstring(HostEphemeralStart) + L".." + std::to_wstring(HostEphemeralEnd) + L"){";
4481 + perlCommand += L"next if $port>=" + std::to_wstring(guestStart) + L" && $port<=" + std::to_wstring(guestEnd) + L";";
4482 + perlCommand += L"my $family=(($candidate++ % 2)==0) ? AF_INET : AF_INET6;";
4483 + perlCommand += L"socket(my $socket,$family," + std::wstring(Protocol == IPPROTO_TCP ? L"SOCK_STREAM" : L"SOCK_DGRAM") +
4484 + L",0) or die \"socket port=$port: $!\\n\";";
4485 + perlCommand +=
4486 + L"my $address=$family==AF_INET ? sockaddr_in($port,INADDR_ANY) : "
4487 + L"Socket::sockaddr_in6($port,Socket::inet_pton(AF_INET6,\"::\"));";
4488 + perlCommand += L"if(bind($socket,$address)){";
4489 + perlCommand += L"push @sockets,$socket;";
4490 + perlCommand += L"}elsif($!{EADDRINUSE}){}else{die \"bind port=$port: $!\\n\";}";
4491 + perlCommand += L"}";
4492 + perlCommand += L"print \"successes=\",scalar(@sockets),\"\\nready\\n\";";
4493 + perlCommand += L"while(1){sleep 1000}'";
4494 +
4495 + auto cmd = LxssGenerateWslCommandLine(perlCommand.data());
4496 + auto [readPipe, writePipe] = CreateSubprocessPipe(false, true);
4497 + NetworkTests::unique_kill_process process(LxsstuStartProcess(cmd.data(), nullptr, writePipe.get(), writePipe.get()));
4498 + writePipe.reset();
4499 +
4500 + std::string output;
4501 + VERIFY_IS_TRUE(NetworkTests::FindSubstring(readPipe, "ready", output));
4502 +
4503 + constexpr std::string_view c_successPrefix = "successes=";
4504 + const auto successOffset = output.find(c_successPrefix);
4505 + THROW_HR_IF(E_FAIL, successOffset == std::string::npos);
4506 + const auto successEnd = output.find('\n', successOffset);
4507 + THROW_HR_IF(E_FAIL, successEnd == std::string::npos);
4508 + const auto successes =
4509 + std::stoi(output.substr(successOffset + c_successPrefix.size(), successEnd - successOffset - c_successPrefix.size()));
4510 + VERIFY_ARE_EQUAL(expectedSuccesses, successes, L"Expected exactly the service-enforced number of reservations");
4511 +
4512 + // Release every reservation so usage drops back below the cap.
4513 + process.reset();
4514 +
4515 + // The Linux port tracker only releases a reservation c_bind_timeout_seconds (60s) after the
4516 + // socket is closed (see GnsPortTracker.cpp), so wait for the reservations to drain before the
4517 + // next iteration reserves the same ports again.
4518 + if (cycle + 1 < c_cycles)
4519 + {
4520 + std::this_thread::sleep_for(std::chrono::seconds(90));
4521 + }
4522 + }
4523 }
4524
4478 - WSL2_TEST_METHOD(GuestUdpBindToHostEphemeralRangeDenied)
4525 + WSL2_TEST_METHOD(GuestBindToHostEphemeralRangeCapped)
4526 {
4480 - VerifyGuestBindToHostEphemeralRangeDenied(L"Get-NetUDPSetting", L"UDP4-LISTEN:");
4527 + MIRRORED_NETWORKING_TEST_ONLY();
4528 +
4529 + // The service caps the number of host-ephemeral ports the guest can reserve at half the host
4530 + // ephemeral range size, so it cannot exhaust the host's ephemeral ports. Shrink the host
4531 + // TCP/UDP ephemeral ranges to the smallest allowed size (255 ports) so the cap is a small,
4532 + // deterministic number (255 / 2 = 127), then verify the guest is denied once it reaches it.
4533 + constexpr int c_ephemeralRangeSize = 255;
4534 + constexpr int c_expectedCap = c_ephemeralRangeSize / 2;
4535 +
4536 + // Save the current host ephemeral ranges so they can be restored at the end of the test.
4537 + int originalTcpStart = 0, originalTcpCount = 0, originalUdpStart = 0, originalUdpCount = 0;
4538 + std::tie(originalTcpStart, originalTcpCount) = QueryHostEphemeralRange(L"Get-NetTCPSetting");
4539 + std::tie(originalUdpStart, originalUdpCount) = QueryHostEphemeralRange(L"Get-NetUDPSetting");
4540 +
4541 + auto restoreRanges = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&] {
4542 + SetHostEphemeralRange(L"tcp", originalTcpStart, originalTcpCount);
4543 + SetHostEphemeralRange(L"udp", originalUdpStart, originalUdpCount);
4544 + });
4545 +
4546 + // Use a low start port so the small host ephemeral window is less likely to overlap the
4547 + // guest's reserved ephemeral range, which HNS assigns from the high port space.
4548 + constexpr int c_hostEphemeralStart = 10000;
4549 + constexpr int c_hostEphemeralEnd = c_hostEphemeralStart + c_ephemeralRangeSize - 1;
4550 + SetHostEphemeralRange(L"tcp", c_hostEphemeralStart, c_ephemeralRangeSize);
4551 + SetHostEphemeralRange(L"udp", c_hostEphemeralStart, c_ephemeralRangeSize);
4552 +
4553 + // Force a restart of WSL so that it queries the new host ephemeral ranges.
4554 + m_config->Update(LxssGenerateTestConfig({.networkingMode = wsl::core::NetworkingMode::Mirrored}));
4555 + RestartWslService();
4556 +
4557 + WaitForMirroredStateInLinux();
4558 +
4559 + // TCP and UDP are capped independently, but each cap is shared by IPv4 and IPv6.
4560 + VerifyHostEphemeralRangeCap(IPPROTO_TCP, c_hostEphemeralStart, c_hostEphemeralEnd, c_expectedCap);
4561 + VerifyHostEphemeralRangeCap(IPPROTO_UDP, c_hostEphemeralStart, c_hostEphemeralEnd, c_expectedCap);
4562 }
4563
4564 WSL2_TEST_METHOD(NonRootNamespaceEphemeralBind)