Massaging your CLR: Preventing Environment.Exit in In-Process .NET Assemblies


At MDSec it is not uncommon to need to develop custom post-exploitation tooling to meet the requirements of an engagement; this is especially true for the red team where the techniques employed for tasks such as information gathering and lateral movement may often need to be adapted to the target environment.

Much of the post-exploitation tooling used during these engagements is developed in C# and executed for example, using Cobalt Strike’s execute-assembly capability. In the past, we’ve discussed some of the limitations of this feature. To address these, we spent some time creating our own post-exploitation tooling that allows a .NET assembly to be executed, a custom inproc-execute-assembly extension to execute the CLR in-process and reduce the visible footprint on the host.

One of the benefits of bringing your own CLR harness is that you can massage it in to a state that best suits your needs, this might include disabling things like System.Management.Automation‘s Tracing.PSEtwLogProvider or AmsiUtils. During testing of our CLR harness, an assembly was executed within the beacon process which caused the beacon to stop responding. Investigation revealed that the assembly had exited on an error condition by calling System.Environment.Exit which had terminated the beacon process. This post discusses the approach used to massage the CLR in to a desired runtime state, using the example of preventing an assembly from exiting. This may also be helpful for anyone else trying to create similar tooling.


Replicating the issue was straightforward; creating a console application in C# which called Enviroment.Exit and then invoking that from a simple native .NET hosting harness was sufficient to reproduce the premature exit of the hosting application.

The console application contained the following code:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace PrematureExit
	class Program
		static void Main(string[] args)
			Console.WriteLine("About to call Environment.Exit");
			Console.WriteLine("Survived exit");

Running the application yielded a predictable result:

To understand what was happening Environment.Exit at the point of exit it made sense to turn to ILSpy (a .NET decompiler) and to examine the code for the method in question. The System.Environment class exists within the mscorlib.dll file (of which there are several, depending on the version of the .NET framework being used).

Decompiling mscorlib.dll for CLR v4.0.30319 and browsing the System namespace led to the quick discovery of the Environment class and the associated Exit method:

[SecurityPermission(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
public static void Exit(int exitCode)

Exit appeared to be a thin wrapper around a native function _Exit internal to the mscorlib assembly:

[DllImport("QCall", CharSet = CharSet.Unicode)]
internal static extern void _Exit(int exitCode);

It was not possible to disassemble this native method using ILSpy and so further investigation was performed using WinDBG suitably configured with symbols. A breakpoint was placed on ntdll!NtTerminateProcess to ensure that we would catch execution right at the point of process termination, allowing us to examine the call stack.

Opening the PrematureExit.exe binary in WinDBG, placing the breakpoint, and allowing execution to proceed resulted in the debugger breaking in as expected:

0:000> k
 # ChildEBP RetAddr  
00 006fec78 77aa145d ntdll!NtTerminateProcess
01 006fed50 76835902 ntdll!RtlExitUserProcess+0x6d
02 006fed64 71bd4dab KERNEL32!ExitProcessImplementation+0x12
03 006fefe4 71bd4f13 mscoreei!RuntimeDesc::ShutdownAllActiveRuntimes+0x34c
04 006feff0 6f4a36ef mscoreei!CLRRuntimeHostInternalImpl::ShutdownAllRuntimesThenExit+0x13
05 006ff028 6f4a365a clr!EEPolicy::ExitProcessViaShim+0x79
06 006ff25c 6f4dc594 clr!SafeExitProcess+0x137
07 006ff26c 6f4dc5db clr!HandleExitProcessHelper+0x63
08 006ff280 6f4efe89 clr!EEPolicy::HandleExitProcess+0x50
09 006ff290 6f91b07b clr!ForceEEShutdown+0x31
0a 006ff2c8 6ea69faf clr!SystemNative::Exit+0x4f
0b 006ff300 0097085d mscorlib_ni!System.Environment.Exit(Int32)$##6000E33+0x43
WARNING: Frame IP not in any known module. Following frames may be wrong.
0c 006ff308 6f32f066 0x97085d
0d 006ff314 6f33231a clr!CallDescrWorkerInternal+0x34
0e 006ff368 6f3385bb clr!CallDescrWorkerWithHandler+0x6b
0f 006ff3d8 6f4db08b clr!MethodDescCallSite::CallTargetWorker+0x16a
10 006ff4fc 6f4db76a clr!RunMain+0x1b3
11 006ff768 6f4db697 clr!Assembly::ExecuteMainMethod+0xf7
12 006ffc4c 6f4db818 clr!SystemDomain::ExecuteMainMethod+0x5ef
13 006ffca4 6f4db93e clr!ExecuteEXE+0x4c
14 006ffce4 6f4d7275 clr!_CorExeMainInternal+0xdc
15 006ffd20 71bcfa84 clr!_CorExeMain+0x4d
16 006ffd58 72f8e80e mscoreei!_CorExeMain+0xd6
17 006ffd68 72f94338 MSCOREE!ShellShim__CorExeMain+0x9e
18 006ffd70 76826359 MSCOREE!_CorExeMain_Exported+0x8
19 006ffd80 77ab7c24 KERNEL32!BaseThreadInitThunk+0x19
1a 006ffddc 77ab7bf4 ntdll!__RtlUserThreadStart+0x2f
1b 006ffdec 00000000 ntdll!_RtlUserThreadStart+0x1b

The stack trace indicated a large number of functions are implicated in the CLR shutdown and process exit. Of particular interest were the mscorlib_ni!System.Environment.Exit(Int32)$##6000E33 and clr!SystemNative::Exit functions as these were executed in close proximity to our jitted code (frame 0xc). Although neither of these appeared to be the _Exit function expected it would be reasonable to assume that patching or hooking either of these to prevent process execution would be equally good for our requirements.

The easiest approach to prevent premature termination appeared therefore to be to determine whether either of the above functions were DLL exports which could be identified by name within the respective modules and patched or hooked using a library such as Microsoft Detours.

To determine this we could examine the export table for each module using a program such as CFF Explorer however an easier approach would be to unresolve the symbols and take the stack trace again; if the names remained then we would have a strong indication that these were exports:

0:000> .sympath "c:\\null"
Symbol search path is: c:\\null
Expanded Symbol search path is: c:\\null
Error: Execute .sympath(+) command attempts to access 'c:\\null' failed: 0x2 - The system cannot find the file specified.

************* Path validation summary **************
Response                         Time (ms)     Location
Error                                          c:\\null
0:000> !reload /f
Reloading current modules
.*** WARNING: Unable to verify checksum for PrematureExit.exe

************* Symbol Loading Error Summary **************
Module name            Error            The system cannot find the file specified
clr                    The system cannot find the file specified
You can troubleshoot most symbol related issues by turning on symbol loading diagnostics (!sym noisy) and repeating the command that caused symbols to be loaded.
You should also verify that your symbol search path (.sympath) is correct.
0:000> k
 # ChildEBP RetAddr  
WARNING: Stack unwind information not available. Following frames may be wrong.
00 006fed50 76835903 ntdll!NtTerminateProcess
01 006fed64 71bd4dab KERNEL32!ExitProcess+0x13
02 006fefe4 71bd4f13 mscoreei!ND_WU1+0x75b
03 006feff0 6f4a36ef mscoreei!ND_WU1+0x8c3
04 006ff028 6f4a365a clr!ClrCreateManagedInstance+0xb06f
05 006ff25c 6f4dc594 clr!ClrCreateManagedInstance+0xafda
06 006ff26c 6f4dc5db clr!CorExeMain+0x5344
07 006ff2c8 6ea69faf clr!CorExeMain+0x538b
08 006ff300 0097085d mscorlib_ni+0xb59faf
09 006ff308 6f32f066 0x97085d
0a 006ff314 6f33231a clr+0xf066
0b 006ff368 6f3385bb clr!LogHelp_TerminateOnAssert+0x93a
0c 006ff3d8 6f4db08b clr!LogHelp_TerminateOnAssert+0x6bdb
0d 006ff4fc 6f4db76a clr!CorExeMain+0x3e3b
0e 006ff768 6f4db697 clr!CorExeMain+0x451a
0f 006ffc4c 6f4db818 clr!CorExeMain+0x4447
10 006ffca4 6f4db93e clr!CorExeMain+0x45c8
11 006ffce4 6f4d7275 clr!CorExeMain+0x46ee
12 006ffd20 71bcfa84 clr!CorExeMain+0x25
13 006ffd58 72f8e80e mscoreei!CorExeMain+0x64
14 006ffd68 72f94338 MSCOREE!DllUnregisterServer+0x14e
15 006ffd80 77ab7c24 MSCOREE!CorExeMain+0x8
16 006ffddc 77ab7bf4 ntdll!RtlGetAppContainerNamedObjectPath+0xe4
17 006ffdec 00000000 ntdll!RtlGetAppContainerNamedObjectPath+0xb4

The call stack appeared to be completely devoid of names between the jitted program code and the eventual call to kernel32!ExitProcess indicating that neither of the functions of interest were exported.

An alternate approach of hooking ntdll!NtTerminateProcess was considered but investigation quickly determined that this would not be a suitable option as by the time the NtTerminateProcess function is invoked the .NET CLR shutdown has been irreversibly initiated and returning from NtTerminateProcess led to the CLR becoming deadlocked. Other approaches such as terminating the associated thread or raising an exception led to a similar result.

The absence of an easy hooking point indicated that an alternative approach would be preferable; one which could be executed from within the CLR environment rather than outside of it would provide an easier method of locating the methods associated with Environment.Exit through the use of .NET reflection.

The following modification was made to the PrematureExit program to locate the Exit method through the use of reflection and to obtain a pointer to the underlying native code method:

static void Main(string[] args)
	var methods = new List<MethodInfo>(typeof(Environment).GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic));
	var exitMethod = methods.Find((MethodInfo mi) => mi.Name == "Exit");

	Console.WriteLine("exitMethod = {0}, param count = {1}", exitMethod.Name, exitMethod.GetParameters().Length);

	var exitMethodPtr = exitMethod.MethodHandle.GetFunctionPointer();

	Console.WriteLine("exitMethodPtr = 0x{0}", exitMethodPtr.ToString("x"));


	Console.WriteLine("About to call Environment.Exit");
	Console.WriteLine("Survived exit");

In the above code the RuntimeHelpers.PrepareMethod function was used to ensure that the underlying method was jitted (if required) and the call to exitMethod.MethodHandle.GetFunctionPointer retrieves a pointer to the jitted code.

The following output was observed:

WinDBG was then attached to inspect the code at the address given by exitMethodPtr:

0:005> u 0x6ea69f6c
6ea69f6c 55              push    ebp
6ea69f6d 8bec            mov     ebp,esp
6ea69f6f 57              push    edi
6ea69f70 56              push    esi
6ea69f71 53              push    ebx
6ea69f72 83ec20          sub     esp,20h
6ea69f75 33d2            xor     edx,edx
6ea69f77 8955f0          mov     dword ptr [ebp-10h],edx

The method was immediately recognisable as being the implementation of the System.Environment.Exit method observed in the stack trace produced earlier. This appeared to represent an ideal candidate for patching to prevent application termination.

Implementing a patch was as straight forward as the following:

static void Main(string[] args)
	var methods = new List<MethodInfo>(typeof(Environment).GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic));
	var exitMethod = methods.Find((MethodInfo mi) => mi.Name == "Exit");

	Console.WriteLine("exitMethod = {0}, param count = {1}", exitMethod.Name, exitMethod.GetParameters().Length);
	var exitMethodPtr = exitMethod.MethodHandle.GetFunctionPointer();

	Console.WriteLine("exitMethodPtr = 0x{0}", exitMethodPtr.ToString("x"));


		IntPtr target = exitMethod.MethodHandle.GetFunctionPointer();


		if (VirtualQueryEx((IntPtr)(-1), target, out mbi, (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION))) != 0)
			if (mbi.Protect == AllocationProtectEnum.PAGE_EXECUTE_READ)
				// seems to be executable code
				uint flOldProtect;

				if (VirtualProtectEx((IntPtr)(-1), (IntPtr)target, (IntPtr)1, (uint)AllocationProtectEnum.PAGE_EXECUTE_READWRITE, out flOldProtect))
					*(byte*)target = 0xc3; // ret

					VirtualProtectEx((IntPtr)(-1), (IntPtr)target, (IntPtr)1, flOldProtect, out flOldProtect);

	Console.WriteLine("About to call Environment.Exit");
	Console.WriteLine("Survived exit");

The above code first determines that the function pointer obtained lies within a read-only executable region (e.g. a DLL) and if so the code patches the code to ret (0xc3) instead of proceed with the exit process.

The effect of this patch is observed below:

The process no longer terminates when Environment.Exit is called. The same assembly was compiled and tested for the .NET framework versions 2.0 – 3.5 to ensure that the desired behaviour remained, and was also tested between x86 and x64 hosting architectures.

To address the original problem of a rogue assembly terminating the Cobalt Strike beacon when executing from an in-process CLR the created PreventExit assembly was loaded and executed prior to loading and executing the assembly containing the application we intended to run. This had the effect of patching Environment.Exit in advance, to ensure that the latter assembly could not terminate the beacon through this method.

Allowing an assembly to continue executing after attempted termination is likely to result in the assembly encountering an unhandled error condition and throwing an exception, however the exception is handled by the CLR and would typically result in a COM error HRESULT being returned by the .NET reflection API (_MethodInfo::Invoke_*) used to execute the assembly, rather than any sort of crash or unstable condition.


Patching the Environment.Exit method is fairly straightforward and will prevent an assembly from accidentally terminating the hosting process. It appears to work well across .NET framework versions, and may help to reduce the risk with executing .NET post-exploitation tooling in-process.


This blog post was written by Peter Winter-Smith.

written by

MDSec Research

Ready to engage
with MDSec?

Copyright 2021 MDSec