Fix bounds, memory protection, and completion race in core helpers

- AllocatedMemory.Read<T>/Write<T>/ReadBytes/WriteBytes now validate that
  the requested byte range stays within the allocated block before calling
  into the memory accessor.
- Patch.Apply/Remove temporarily changes the target page to read-write and
  restores the original protection, mirroring the Detour behavior.
- MainThreadPump.WorkItem uses TrySetResult/TrySetException and swallows the
  InvalidOperationException raised when a completion source is already
  completed, preventing Dispose from failing during concurrent pump drainage.

Regression tests added for all three fixes.

Tests: 206 passing, 4 skipped.
This commit is contained in:
kbe
2026-07-22 02:16:23 +02:00
parent ffd72b37ed
commit 1911514120
5 changed files with 261 additions and 16 deletions
+91
View File
@@ -91,6 +91,46 @@ public class HookingTests
}
}
[Fact]
public void Patch_apply_on_execute_only_memory_succeeds()
{
using var reader = CreateReader();
byte[] code = [0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3]; // mov eax, 42; ret
IntPtr alloc = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
code.Length,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, alloc);
try
{
reader.WriteBytes(alloc, code);
// Remove write access. Without VirtualProtectEx in Patch.Apply,
// applying a patch would fail because the page is read-only for writes.
Assert.True(NativeMethods.VirtualProtectEx(
reader.Handle,
alloc,
code.Length,
MemoryProtectionType.ExecuteRead,
out MemoryProtectionType _));
Patch patch = reader.PatchManager.Create("nop-ret", alloc, [0x90, 0x90]);
patch.Apply();
Assert.True(patch.IsApplied);
patch.Remove();
Assert.False(patch.IsApplied);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, alloc, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_apply_redirects_callOriginal_remove_restores()
{
@@ -335,4 +375,55 @@ public class HookingTests
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_concurrent_dispose_and_pump_does_not_throw()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
var tasks = new List<Task<int>>();
for (int i = 0; i < 50; i++)
{
int value = i;
tasks.Add(pump.ExecuteAsync(() => value));
}
// Drive the detoured frame while disposing from another thread.
// This stresses the race between PumpHook completing work and
// Dispose faulting still-queued work.
Task drive = Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
try { routed(); } catch { }
}
});
await Task.Delay(10);
pump.Dispose();
await drive;
// Any faulted task must have been cancelled by Dispose; no
// unhandled exceptions should escape from the pump itself.
foreach (Task<int> task in tasks)
{
if (task.IsFaulted)
{
Assert.IsType<ObjectDisposedException>(task.Exception!.InnerException);
}
}
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
}