Skip to content

Commit 548b70d

Browse files
authored
Fix DNS cancellation deadlock (#63904)
Avoid taking a lock, and address the use-after-free race condition by guarding GetAddrInfoExContext with a SafeHandle.
1 parent 135e566 commit 548b70d

File tree

3 files changed

+113
-53
lines changed

3 files changed

+113
-53
lines changed

src/libraries/System.Net.NameResolution/src/System/Net/NameResolutionPal.Windows.cs

Lines changed: 68 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Threading;
99
using System.Threading.Tasks;
1010
using System.Diagnostics;
11+
using Microsoft.Win32.SafeHandles;
1112

1213
namespace System.Net
1314
{
@@ -138,17 +139,14 @@ public static unsafe string GetHostName()
138139
{
139140
Interop.Winsock.EnsureInitialized();
140141

141-
GetAddrInfoExContext* context = GetAddrInfoExContext.AllocateContext();
142-
143-
GetAddrInfoExState state;
142+
GetAddrInfoExState? state = null;
144143
try
145144
{
146-
state = new GetAddrInfoExState(context, hostName, justAddresses);
147-
context->QueryStateHandle = state.CreateHandle();
145+
state = new GetAddrInfoExState(hostName, justAddresses);
148146
}
149147
catch
150148
{
151-
GetAddrInfoExContext.FreeContext(context);
149+
state?.Dispose();
152150
throw;
153151
}
154152

@@ -158,6 +156,8 @@ public static unsafe string GetHostName()
158156
hints.ai_flags = AddressInfoHints.AI_CANONNAME;
159157
}
160158

159+
GetAddrInfoExContext* context = state.Context;
160+
161161
SocketError errorCode = (SocketError)Interop.Winsock.GetAddrInfoExW(
162162
hostName, null, Interop.Winsock.NS_ALL, IntPtr.Zero, &hints, &context->Result, IntPtr.Zero, &context->Overlapped, &GetAddressInfoExCallback, &context->CancelHandle);
163163

@@ -172,7 +172,7 @@ public static unsafe string GetHostName()
172172
// and final result would be posted via overlapped IO.
173173
// synchronous failure here may signal issue when GetAddrInfoExW does not work from
174174
// impersonated context. Windows 8 and Server 2012 fail for same reason with different errorCode.
175-
GetAddrInfoExContext.FreeContext(context);
175+
state.Dispose();
176176
return null;
177177
}
178178
else
@@ -194,10 +194,10 @@ private static unsafe void GetAddressInfoExCallback(int error, int bytes, Native
194194

195195
private static unsafe void ProcessResult(SocketError errorCode, GetAddrInfoExContext* context)
196196
{
197+
GetAddrInfoExState state = GetAddrInfoExState.FromHandleAndFree(context->QueryStateHandle);
198+
197199
try
198200
{
199-
GetAddrInfoExState state = GetAddrInfoExState.FromHandleAndFree(context->QueryStateHandle);
200-
201201
CancellationToken cancellationToken = state.UnregisterAndGetCancellationToken();
202202

203203
if (errorCode == SocketError.Success)
@@ -222,7 +222,7 @@ private static unsafe void ProcessResult(SocketError errorCode, GetAddrInfoExCon
222222
}
223223
finally
224224
{
225-
GetAddrInfoExContext.FreeContext(context);
225+
state.Dispose();
226226
}
227227
}
228228

@@ -360,18 +360,21 @@ private static unsafe IPAddress CreateIPv6Address(ReadOnlySpan<byte> socketAddre
360360
return new IPAddress(address, scope);
361361
}
362362

363-
private sealed unsafe class GetAddrInfoExState : IThreadPoolWorkItem
363+
// GetAddrInfoExState is a SafeHandle that manages the lifetime of GetAddrInfoExContext*
364+
// to make sure GetAddrInfoExCancel always takes a valid memory address regardless of the race
365+
// between cancellation and completion callbacks.
366+
private sealed unsafe class GetAddrInfoExState : SafeHandleZeroOrMinusOneIsInvalid, IThreadPoolWorkItem
364367
{
365-
private GetAddrInfoExContext* _cancellationContext;
366368
private CancellationTokenRegistration _cancellationRegistration;
367369

368370
private AsyncTaskMethodBuilder<IPHostEntry> IPHostEntryBuilder;
369371
private AsyncTaskMethodBuilder<IPAddress[]> IPAddressArrayBuilder;
370372
private object? _result;
373+
private volatile bool _completed;
371374

372-
public GetAddrInfoExState(GetAddrInfoExContext *context, string hostName, bool justAddresses)
375+
public GetAddrInfoExState(string hostName, bool justAddresses)
376+
: base(true)
373377
{
374-
_cancellationContext = context;
375378
HostName = hostName;
376379
JustAddresses = justAddresses;
377380
if (justAddresses)
@@ -384,6 +387,10 @@ public GetAddrInfoExState(GetAddrInfoExContext *context, string hostName, bool j
384387
IPHostEntryBuilder = AsyncTaskMethodBuilder<IPHostEntry>.Create();
385388
_ = IPHostEntryBuilder.Task; // force initialization
386389
}
390+
391+
GetAddrInfoExContext* context = GetAddrInfoExContext.AllocateContext();
392+
context->QueryStateHandle = CreateHandle();
393+
SetHandle((IntPtr)context);
387394
}
388395

389396
public string HostName { get; }
@@ -392,52 +399,62 @@ public GetAddrInfoExState(GetAddrInfoExContext *context, string hostName, bool j
392399

393400
public Task Task => JustAddresses ? (Task)IPAddressArrayBuilder.Task : IPHostEntryBuilder.Task;
394401

402+
internal GetAddrInfoExContext* Context => (GetAddrInfoExContext*)handle;
403+
395404
public void RegisterForCancellation(CancellationToken cancellationToken)
396405
{
397406
if (!cancellationToken.CanBeCanceled) return;
398407

399-
lock (this)
408+
if (_completed)
400409
{
401-
if (_cancellationContext == null)
410+
// The operation completed before registration could be done.
411+
return;
412+
}
413+
414+
_cancellationRegistration = cancellationToken.UnsafeRegister(static o =>
415+
{
416+
var @this = (GetAddrInfoExState)o!;
417+
if (@this._completed)
402418
{
403-
// The operation completed before registration could be done.
419+
// Escape early and avoid ObjectDisposedException in DangerousAddRef
404420
return;
405421
}
406422

407-
_cancellationRegistration = cancellationToken.UnsafeRegister(o =>
423+
bool needRelease = false;
424+
try
408425
{
409-
var @this = (GetAddrInfoExState)o!;
410-
int cancelResult = 0;
426+
@this.DangerousAddRef(ref needRelease);
411427

412-
lock (@this)
413-
{
414-
GetAddrInfoExContext* context = @this._cancellationContext;
415-
416-
if (context != null)
417-
{
418-
// An outstanding operation will be completed with WSA_E_CANCELLED, and GetAddrInfoExCancel will return NO_ERROR.
419-
// If this thread has lost the race between cancellation and completion, this will be a NOP
420-
// with GetAddrInfoExCancel returning WSA_INVALID_HANDLE.
421-
cancelResult = Interop.Winsock.GetAddrInfoExCancel(&context->CancelHandle);
422-
}
423-
}
428+
// If DangerousAddRef didn't throw ODE, the handle should contain a valid pointer.
429+
GetAddrInfoExContext* context = @this.Context;
424430

425-
if (cancelResult != 0 && cancelResult != Interop.Winsock.WSA_INVALID_HANDLE && NetEventSource.Log.IsEnabled())
431+
// An outstanding operation will be completed with WSA_E_CANCELLED, and GetAddrInfoExCancel will return NO_ERROR.
432+
// If this thread has lost the race between cancellation and completion, this will be a NOP
433+
// with GetAddrInfoExCancel returning WSA_INVALID_HANDLE.
434+
int cancelResult = Interop.Winsock.GetAddrInfoExCancel(&context->CancelHandle);
435+
if (cancelResult != Interop.Winsock.WSA_INVALID_HANDLE && NetEventSource.Log.IsEnabled())
426436
{
427437
NetEventSource.Info(@this, $"GetAddrInfoExCancel returned error {cancelResult}");
428438
}
429-
}, this);
430-
}
439+
}
440+
finally
441+
{
442+
if (needRelease)
443+
{
444+
@this.DangerousRelease();
445+
}
446+
}
447+
448+
}, this);
431449
}
432450

433451
public CancellationToken UnregisterAndGetCancellationToken()
434452
{
435-
lock (this)
436-
{
437-
_cancellationContext = null;
438-
_cancellationRegistration.Unregister();
439-
}
453+
_completed = true;
440454

455+
// We should not wait for pending cancellation callbacks with CTR.Dispose(),
456+
// since we are in a completion routine and GetAddrInfoExCancel may get blocked until it's finished.
457+
_cancellationRegistration.Unregister();
441458
return _cancellationRegistration.Token;
442459
}
443460

@@ -479,15 +496,22 @@ void IThreadPoolWorkItem.Execute()
479496
}
480497
}
481498

482-
public IntPtr CreateHandle() => GCHandle.ToIntPtr(GCHandle.Alloc(this, GCHandleType.Normal));
483-
484499
public static GetAddrInfoExState FromHandleAndFree(IntPtr handle)
485500
{
486501
GCHandle gcHandle = GCHandle.FromIntPtr(handle);
487502
var state = (GetAddrInfoExState)gcHandle.Target!;
488503
gcHandle.Free();
489504
return state;
490505
}
506+
507+
protected override bool ReleaseHandle()
508+
{
509+
GetAddrInfoExContext.FreeContext(Context);
510+
511+
return true;
512+
}
513+
514+
private IntPtr CreateHandle() => GCHandle.ToIntPtr(GCHandle.Alloc(this, GCHandleType.Normal));
491515
}
492516

493517
[StructLayout(LayoutKind.Sequential)]
@@ -498,21 +522,15 @@ private unsafe struct GetAddrInfoExContext
498522
public IntPtr CancelHandle;
499523
public IntPtr QueryStateHandle;
500524

501-
public static GetAddrInfoExContext* AllocateContext()
502-
{
503-
var context = (GetAddrInfoExContext*)Marshal.AllocHGlobal(sizeof(GetAddrInfoExContext));
504-
*context = default;
505-
return context;
506-
}
525+
public static GetAddrInfoExContext* AllocateContext() => (GetAddrInfoExContext*)NativeMemory.AllocZeroed((nuint)sizeof(GetAddrInfoExContext));
507526

508527
public static void FreeContext(GetAddrInfoExContext* context)
509528
{
510529
if (context->Result != null)
511530
{
512531
Interop.Winsock.FreeAddrInfoExW(context->Result);
513532
}
514-
515-
Marshal.FreeHGlobal((IntPtr)context);
533+
NativeMemory.Free(context);
516534
}
517535
}
518536
}

src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Collections.Generic;
5+
using System.Linq;
56
using System.Net.Sockets;
67
using System.Threading;
78
using System.Threading.Tasks;
@@ -170,10 +171,13 @@ public async Task DnsGetHostAddresses_PreCancelledToken_Throws()
170171
OperationCanceledException oce = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Dns.GetHostAddressesAsync(TestSettings.LocalHost, cts.Token));
171172
Assert.Equal(cts.Token, oce.CancellationToken);
172173
}
174+
}
173175

174-
[OuterLoop]
176+
// Cancellation tests are sequential to reduce the chance of timing issues.
177+
[Collection(nameof(DisableParallelization))]
178+
public class GetHostAddressesTest_Cancellation
179+
{
175180
[Fact]
176-
[ActiveIssue("https://github.com/dotnet/runtime/issues/43816")] // Race condition outlined below.
177181
[ActiveIssue("https://github.com/dotnet/runtime/issues/33378", TestPlatforms.AnyUnix)] // Cancellation of an outstanding getaddrinfo is not supported on *nix.
178182
public async Task DnsGetHostAddresses_PostCancelledToken_Throws()
179183
{
@@ -188,5 +192,35 @@ public async Task DnsGetHostAddresses_PostCancelledToken_Throws()
188192
OperationCanceledException oce = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
189193
Assert.Equal(cts.Token, oce.CancellationToken);
190194
}
195+
196+
// This is a regression test for https://github.com/dotnet/runtime/issues/63552
197+
[Fact]
198+
[ActiveIssue("https://github.com/dotnet/runtime/issues/33378", TestPlatforms.AnyUnix)] // Cancellation of an outstanding getaddrinfo is not supported on *nix.
199+
public async Task DnsGetHostAddresses_ResolveParallelCancelOnFailure_AllCallsReturn()
200+
{
201+
string invalidAddress = TestSettings.UncachedHost;
202+
await ResolveManyAsync(invalidAddress);
203+
await ResolveManyAsync(invalidAddress, TestSettings.LocalHost)
204+
.WaitAsync(TestSettings.PassingTestTimeout);
205+
206+
static async Task ResolveManyAsync(params string[] addresses)
207+
{
208+
using CancellationTokenSource cts = new();
209+
Task[] resolveTasks = addresses.Select(a => ResolveOneAsync(a, cts)).ToArray();
210+
await Task.WhenAll(resolveTasks);
211+
}
212+
213+
static async Task ResolveOneAsync(string address, CancellationTokenSource cancellationTokenSource)
214+
{
215+
try
216+
{
217+
await Dns.GetHostAddressesAsync(address, cancellationTokenSource.Token);
218+
}
219+
catch (Exception)
220+
{
221+
cancellationTokenSource.Cancel();
222+
}
223+
}
224+
}
191225
}
192226
}

src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,21 @@ public async Task DnsGetHostEntry_PreCancelledToken_Throws()
309309
OperationCanceledException oce = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Dns.GetHostEntryAsync(TestSettings.LocalHost, cts.Token));
310310
Assert.Equal(cts.Token, oce.CancellationToken);
311311
}
312+
}
312313

314+
// Cancellation tests are sequential to reduce the chance of timing issues.
315+
[Collection(nameof(DisableParallelization))]
316+
public class GetHostEntryTest_Cancellation
317+
{
313318
[OuterLoop]
314-
[ActiveIssue("https://github.com/dotnet/runtime/issues/43816")] // Race condition outlined below.
315319
[ActiveIssue("https://github.com/dotnet/runtime/issues/33378", TestPlatforms.AnyUnix)] // Cancellation of an outstanding getaddrinfo is not supported on *nix.
316320
[Fact]
317321
public async Task DnsGetHostEntry_PostCancelledToken_Throws()
318322
{
323+
// Windows 7 name resolution is synchronous and does not respect cancellation.
324+
if (PlatformDetection.IsWindows7)
325+
return;
326+
319327
using var cts = new CancellationTokenSource();
320328

321329
Task task = Dns.GetHostEntryAsync(TestSettings.UncachedHost, cts.Token);

0 commit comments

Comments
 (0)