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)