Archive of https://www.contextis.com/en/blog/bring-your-own-.net-core-garbage-collector from 19 JUN 2020.

Table of contents

Introduction

This blog post explains how it is possible to abuse a legitimate feature of .Net Core, and exploit a directory traversal bug to achieve application whitelisting bypass.

The .NET Core is an open-source software framework based on the .Net Standard, which was released for the first time in 2016. Contrary to the .NET Framework, .NET Core is cross-platform and therefore can be used to develop and deploy applications written in C# or F# in Windows, Linux and Mac OS operating systems. Further information about the technology itself can be found on the official Microsoft .NET Core documentation.

Examples throughout this blog are based on Window OS but Linux and MacOS are also affected.

The issue mentioned above was reported Thursday, 19th March 2020 to Microsoft Security Response Center (MSRC), however they did not deem this as a security issue. An issue was opened on the .NET Core runtime GitHub repository Thursday 18th June 2020.

Finally, the custom GC code and a basic .NET Core application can be found here on GitHub.

.NET Core Configuration Knobs

Since its first version, .NET Core has exposed multiple knobs (Microsoft’s term for runtime configuration settings) that can be used to modify the default runtime configuration options. These knobs, amongst other things, are used to modify garbage collection (hereby referenced as ‘GC’) settings. In most cases, changing these configuration options is unnecessary and may lead to unintended behaviour, and therefore should be used only in specific situations.

Knobs can be defined in three different ways:

  • Via the configProperties node of the runtimeconfig.json file. This JSON file is created during compilation time and should be prepended by the name of the application, for example: ConsoleApp.runtimeconfig.json;
  • Via MSBuild properties, which will take precedence over options in the runtimeconfig.json file. The JSON file will be overwritten after each build of the application during compilation time; and
  • Via environment variables. Environment variables are, most of the time, prefixed with COMPlus_.

Multiple examples can be found in the official Microsoft documentation. One of the more interesting examples being the COMPlus_GCName, allowing the use of custom garbage collector.

Standalone Garbage Collector

The .NET’s GC, like for any GC from other programming languages, is responsible for the allocation and release of the memory used by an application during runtime. The virtual private memory of a process is not infinite and it is necessary to free the memory that is no longer needed by the application.

As mentioned previously, .NET Core allows the use of custom GC via the COMPlus_GCName environment variable. A custom GC takes the form of an unmanaged C++ Dynamic-Link Library (DLL). Depending on the .NET Core version, the value of the environment variable will be interpreted differently:

  • Versions prior to 3.0, the value will be the path of the DLL to load. For example: C:\Users\Public\CustomGC.dll; and
  • Versions 3.0 onwards, the value will the name of the DLL to load from the .NET Core installation path. For example: CustomGC.dll

Environment Variable Path Traversal

As .NET Core is open-source it is possible to look at the source code in GitHub. Doing this we can understand how the Common Language Runtime (CLR) and, more specifically, the Execution Engine (EE) is probing and loading the custom GC.

First, the value of the environment variable is retrieved in the GCHeapUtilities::LoadAndInitialize function from the gcheaputilities.cpp file:

If the standaloneGcLocation variable is not null, the LoadAndInitializeGC function is called:

The name of the custom GC is then passed to the LoadStandaloneGc function.

As shown in the code above, the code retrieves the directory path where the CLR binary is located by calling GetInternalSystemDirectory. The CLR binary is located in the .NET Core installation path.

Finally, once the directory path is retrieved, the value is appended to the libFileName variable and finally passed to the CLRLoadLibrary function, which returns the base address of the DLL (i.e. HMODULE).

Knowing that the value from the environment variable is not sanitised, it is possible to leverage a directory traversal vulnerability in order to load a custom GC from an arbitrary location. Subsequently, local administrator privileges are no longer needed to load a GC and execute arbitrary code.

Shown below are three executions of the ConsoleApp .NET Core application. The first execution is a normal execution without any modification. For the second execution, the COMPLUS_GCName environment variable is set to a location of our choice, and the first line of the procmon output shows the DLL being probed for under the .Net Core installation path. Finally, for the third execution, the COMPLUS_GCName environment variable is set to a location of our choice leveraging the directory traversal attack, and the remaining procmon lines show the execution successfully probing for the DLL in this location.

Building a Custom GC

It would be foolish to think that creating a working GC from scratch would be an easy task. This requires a colossal amount of work and really niche knowledge in memory management and more generally in software engineering. Nevertheless, there are a few people with the knowledge of these areas, such as Konrad Kokosa and Maoni Stephens - thanks to both for the inspiration to research this area.

If you are not able to create a full GC easily, fortunately only two exported functions are required. These are GC_VersionInfo and GC_Initialize. Additionally, because GC_VersionInfo is called before GC_Initialize, only the first function has to be implemented.

This can be confirmed by having another look at the LoadAndInitializeGC function from the gcheaputilities.cpp file. After loading the DLL into the virtual private memory of the process, the address of the GC_VersionInfo function is dynamically retrieved using the GetProcAddress Windows API function:

As shown in the code above, it is possible to execute an arbitrary payload or code in the GC_VersionInfo function. The following C++ code is an example implementation of the aforementioned function. Payload being a simple message box.

// From coreclr gcinterface.h
struct VersionInfo {
	uint32_t MajorVersion;
	uint32_t MinorVersion;
	uint32_t BuildVersion;
	const char* Name;
};

extern "C" __declspec(dllexport) void GC_VersionInfo(VersionInfo * info) {
	info->MajorVersion = 6;
	info->MinorVersion = 6;
	info->BuildVersion = 6;
	info->Name = "Custom GC";

	// Your shellcode
	::MessageBox(NULL, L"From the DLL", L"This is fine", 4);
}

The following C++ code is a very basic example that can be compiled into a Windows DLL in order to execute arbitrary unmanaged code.

#pragma once
#include <Windows.h>
#include <stdint.h>
#include <stdio.h>

// From coreclr gcinterface.h
struct VersionInfo {
	uint32_t MajorVersion;
	uint32_t MinorVersion;
	uint32_t BuildVersion;
	const char* Name;
};

extern "C" __declspec(dllexport) void GC_VersionInfo(VersionInfo * info) {
	info->MajorVersion = 6;
	info->MinorVersion = 6;
	info->BuildVersion = 6;
	info->Name = "Custom GC";

	// Your shellcode
	::MessageBox(NULL, L"From the DLL", L"This is fine", 4);
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, LPVOID lpReserved) {
	switch (dwReason) {
	case DLL_PROCESS_ATTACH:
	case DLL_THREAD_ATTACH:
	case DLL_THREAD_DETACH:
	case DLL_PROCESS_DETACH:
		break;
	}

	return TRUE;
}

Application Whitelisting Bypass Scenario

As mentioned early in this blog post, having the ability to force any .NET Core application to load and call a function from an attacker controlled DLL, and from any location on a system, can be used as an application whitelisting bypass technique.

A scenario in which most of the well-known living off the land binaries and scripts (LOLBAS) and unsigned applications are blocked on a targeted system, it can be quite challenging to execute arbitrary code. However, if there is a whitelisted .NET Core application, it is possible to execute arbitrary code via the application.

The most efficient way to identify if an application is using .Net Core is to reflectively load the Assembly via PowerShell or C#. However, in restricted environment this is not possible. In this case, a good indicator is if the following configuration files are within the installation path of the application: .runtimeconfig.json and .deps.json.

First, add a new environment variable for the user with the location of the custom GC, leveraging the directory traversal.

Then, execute the .NET Core application, via command line or any other way.

As shown above, the message box is successfully executed. The payload does not necessarily need to be only a MessageBox, and can be modified with any unmanaged code. In this second example, the payload injected the Cobalt Strike staged shellcode into a new process.

Remediation

Having the ability to use a custom GC is a legitimate feature and should probably not be removed. However, the path traversal should be addressed in order to limit the use of a custom GC to only users with local administrator privileges, which should be the case for a server side application or in a development environment.

A quick fix would be to check if the value of the environment variable contains two successive dots, or as we have submitted to GithHub for a front slash and a back slash character. If this is the case, the code can assume that the user is trying to load the custom GC from a different directory than the .NET Core installation folder, and fail.

Timeline

The timeline was as follows:

  • Thursday, 19th March 2020 – Report submitted to Microsoft Security Response Center (MSRC). Case 57309 opened.
  • Thursday, 30th April 2020 – This paper was sent to MSRC to better explain the issue
  • Wednesday, 27th May 2020 – Final answer from MSRC. They did not deem this as a security issue. Case 57309 closed. Permission from MSRC to publish findings received.
  • Thursday 18th June 2020 – Issue open on GitHub and Pull request submitted with a simple fix.