This is the twentieth ninth part of the .NET Inside Out series. For your convenience you can find other parts in the table of contents in Part 1 – Virtual and non-virtual calls in C#
Last time we discussed ways to terminate a thread. We mentioned a method based on unwinding the stack and preserving the registers. Today we’re going to implement this solution. I’m using .NET 5 on Windows 10 x64.
We start with the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; namespace RerouteThreadWithDebugger { class Program { static long[] registersHolderArray = new long[9]; static int[] entryPointHolderArray = new int[3]; static void Main(string[] args) { HackEntryPoint(); var infinite = new Thread(InfiniteThread); infinite.Start(); Thread.Sleep(2000); Console.WriteLine("Doing magic"); TerminateThread(infinite); infinite.Join(); Console.WriteLine("Finished!"); } static void InfiniteThread() { while (true) { Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - Still looping"); Thread.Sleep(1000); } } //... } } |
We create a thread which runs forever and we want to terminate it.
First, we need to modify the entry point of the thread and capture registers once it starts. Let’s do it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
static void HackEntryPoint() { // Get address of array to store registers var registersArrayAddress = GetArrayPosition(registersHolderArray); // Get address of calling the function var sourceMethod = typeof(Program).GetMethod(nameof(InfiniteThread), BindingFlags.Static | BindingFlags.NonPublic); RuntimeHelpers.PrepareMethod(sourceMethod.MethodHandle); IntPtr entryPointAddress = Marshal.ReadIntPtr(sourceMethod.MethodHandle.Value, 2 * IntPtr.Size); UnlockPage(entryPointAddress); // Preserve preamble of the entry point Marshal.Copy(entryPointAddress, entryPointHolderArray, 0, entryPointHolderArray.Length); // Create a code for new entry point var newConstructor = new byte[0] // -------------------------------------------- .Concat(new byte[] { 0x48, 0xB8, // movabs rax, address_of_array }).Concat(BitConverter.GetBytes((long)registersArrayAddress)).Concat(new byte[] { 0x48, 0x89, 0x20, // mov QWORD PTR [rax], rsp 0x48, 0x89, 0x68, 0x8, // mov QWORD PTR [rax+0x8], rbp 0x48, 0x89, 0x70, 0x10, // mov QWORD PTR [rax+0x10], rsi 0x48, 0x89, 0x78, 0x18, // mov QWORD PTR [rax+0x18], rdi 0x48, 0x89, 0x58, 0x20, // mov QWORD PTR [rax+0x20], rbx 0x4C, 0x89, 0x60, 0x28, // mov QWORD PTR [rax+0x28], r12 0x4C, 0x89, 0x68, 0x30, // mov QWORD PTR [rax+0x30], r13 0x4C, 0x89, 0x70, 0x38, // mov QWORD PTR [rax+0x38], r14 0x4C, 0x89, 0x78, 0x40, // mov QWORD PTR [rax+0x40], r15 0x48, 0xB8 // movabs rax, address_of_thread_constructor }).Concat(BitConverter.GetBytes((long)entryPointAddress)).Concat(new byte[] { 0xC7, 0x00 // mov DWORD PTR [rax], 0-4 bytes of constructor function }).Concat(BitConverter.GetBytes((int)entryPointHolderArray[0])).Concat(new byte[] { 0xC7, 0x40, 0x04, // mov DWORD PTR [rax+0x4], 5-8 bytes of constructor function }).Concat(BitConverter.GetBytes((int)entryPointHolderArray[1])).Concat(new byte[] { 0xC7, 0x40, 0x08, // mov DWORD PTR [rax+0x8], 9-12 bytes of constructor function }).Concat(BitConverter.GetBytes((int)entryPointHolderArray[2])).Concat(new byte[] { 0x50, // push rax 0xC3 // ret }).ToArray(); var newEntryPointAddress = GetArrayPosition(newConstructor); Console.WriteLine($"Holder addres {registersArrayAddress.ToString("X")}\n" + $"Entrypoint code {entryPointAddress.ToString("X")}\n" + $"New entrypoint code {newEntryPointAddress.ToString("X")}"); HijackMethod(entryPointAddress, newEntryPointAddress); } private static IntPtr GetArrayPosition(object array) { GCHandle handle = GCHandle.Alloc(array, GCHandleType.Pinned); return handle.AddrOfPinnedObject(); } public static void HijackMethod(IntPtr sourceAddress, IntPtr targetAddress) { byte[] instruction = new byte[] { 0x48, 0xB8 // mov rax <value> } .Concat(BitConverter.GetBytes((long)targetAddress)) .Concat(new byte[] { 0x50, // push rax 0xC3 // ret }).ToArray(); UnlockPage(sourceAddress); UnlockPage(targetAddress); Marshal.Copy(instruction, 0, sourceAddress, instruction.Length); } [DllImport("kernel32.dll", SetLastError = true)] static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); private static void UnlockPage(IntPtr address) { uint pageSize = 4096; uint pageExecuteReadWrite = 0x40; VirtualProtect(address, pageSize, pageExecuteReadWrite, out _); } static string DBG_PATH = @"PATH TO CDB"; static string LOAD_SOS = @".load PATH TO SOS"; |
First, we use a helper method GetArrayPosition
to extract the address of array’s content. We first use it to get the address of an array to store registers which need to be preserved in x64 calling convention (these are rsp, rbp, rsi, rdi, rbx, r12, r13, r14, r15).
Next, we get the address of the entrypoint of the thread (lines 6-11). This is user’s code.
Now, we need to modify the entrypoint. We want to make it jump to our code so we need an array to hold the preamble. 12 bytes are enough (line 14).
Next, we craft the machine code to create a set jump callsite. Let’s go through it line by line:
Lines 20-21: we store the address of the array holding registers in the rax register so we can use it.
Lines 22-30: we copy registers one by one to the array.
Lines 31-32: we copy the address of the original entrypoint to the rax register so we can restore the entrypoint.
Lines 33-38: we copy 12 bytes from the array to the original entrypoint.
Lines 39-40: we jump back to the original (now restored) entrypoint.
Where do these 12 bytes come from? It’s in the HijackMethod
which pushes address of the jump target (lines 62-65) and then jumps (lines 67-68). This is 12 bytes in total.
Okay, we have the entrypoint. We now go to the termination method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
static void TerminateThread(Thread thread) { var processId = Environment.ProcessId; var threadsCommand = @$"-p {processId} -noio -logo cdb.log -c """ + string.Join("; ", LOAD_SOS, $@"!threads", $@"qd" ) + @$""""; var process = Process.Start( DBG_PATH, threadsCommand ); process.WaitForExit(); var threadsOutput = File.ReadAllText("cdb.log"); var nativeThreadToKill = threadsOutput .Substring(threadsOutput.IndexOf("Hosted Runtime")) .Split("\r", StringSplitOptions.RemoveEmptyEntries).Where(line => { var parts = line.Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries); return parts.Length > 2 && parts[1] == $"{thread.ManagedThreadId}"; }).FirstOrDefault().Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries)[0]; var stackCommand = @$"-p {processId} -noio -logo cdb.log -c """ + string.Join("; ", LOAD_SOS, $@"~{nativeThreadToKill}s", $@"!clrstack", $@"qd" ) + @$""""; process = Process.Start( DBG_PATH, stackCommand ); process.WaitForExit(); var stackOutput = File.ReadAllText("cdb.log"); var stackReturnAddress = stackOutput .Split("\r", StringSplitOptions.RemoveEmptyEntries) .Where(line => line.Contains("System.Threading.ThreadHelper.ThreadStart_Context")) .FirstOrDefault() .Trim() .Split(" ", StringSplitOptions.RemoveEmptyEntries)[1]; var rerouteCommand = @$"-p {processId} -noio -c """ + string.Join("; ", $@"~{nativeThreadToKill}s", $@"r rip=0x{stackReturnAddress}", $@"r rsp=0x{(registersHolderArray[0] + IntPtr.Size).ToString("X")}", $@"r rbp=0x{registersHolderArray[1].ToString("X")}", $@"r rsi=0x{registersHolderArray[2].ToString("X")}", $@"r rdi=0x{registersHolderArray[3].ToString("X")}", $@"r rbx=0x{registersHolderArray[4].ToString("X")}", $@"r r12=0x{registersHolderArray[5].ToString("X")}", $@"r r13=0x{registersHolderArray[6].ToString("X")}", $@"r r14=0x{registersHolderArray[7].ToString("X")}", $@"r r15=0x{registersHolderArray[8].ToString("X")}", $@"qd" ) + @$""""; Process.Start( DBG_PATH, rerouteCommand ); } |
First, we attach the debugger and dump threads (lines 5-17). We then extract the native thread id of the thread we want to kill (lines 21-27).
Next, we want to dump the stack to extract the return address (lines 29-51). What we do is we dump the output of !clrstack
command, get line showing the ThreadHelper.ThreadStart_Context
method which calls our entrypoint, and get its return address.
Finally, we create a command which restores all the addresses. Note that we can hardcode values in the command as we can read them from the array just like that. Notice also that we need to increase the stack pointer to drop the return address of the original entrypoint from it (line 57)
Code works and makes the thread to terminate gracefully. However, it doesn’t call any finally blocks. It just disappears. Combine this with our code for handling stack overflow issue and we now have a pretty robust test runner which can run your unit tests without risking StackOverflowException
or other infinite loops.