Custom API Hooking to Bypass Static Detection

Background

Assembly.Load() has become very popular in offensive C# tooling in recent years. This method is quite easy to use to load .NET assemblies directly from memory without touching disk. One of the main drawbacks, however, is that a .NET assembly loaded via the Assembly.Load() method does not have a path backed by a file on disk. We can verify this by starting Process Hacker and displaying the “.NET assemblies” page for the corresponding process:

Security solutions such as EDRs could see this behavior as suspicious and trigger an alarm or even block the execution of the process.

To solve this problem, a method has been published that uses transactional NTFS and API Hooking to make the CLR believe that the assembly has been loaded from disk. Details can be found at the following link:

https://blog.redxorblue.com/2021/05/assemblylie-using-transactional-ntfs.html

The blog post describes in detail how Assembly.Load() works under the hood and which API calls are made by the CLR when Assembly.Load() is executed. It is shown that by sending fake responses to the API calls during the loading process, it is possible to trick the CLR into believing that a non-existent file has actually been loaded from disk. The authors have implemented their method using the EasyHook library for hooking API calls. Although the EasyHook library is quite powerful, it can also be easily signatured by EDRs when used in combination with the Assembly.Load() method. If you were to replace EasyHook with other common hooking libraries such as Detours or MinHook for .NET, you would probably run into the same issue with static detection by EDRs.

So I decided to invest some time to write my own implementation in C# for hooking API calls.

Custom Hooking Class

Inline hooking is a common technique for hooking a function. It works by overwriting the first bytes in an API function to redirect the code flow. The injected bytes are sometimes referred to as a trampoline. The following example will use this technique to create a custom hooking class for a 64-bit application.

Writing the Trampoline

The trampoline code is responsible for replacing the first few instructions of the function that should be hooked with a jump (more precisely, an unconditional jump) to the function that is to be run instead. In order to achieve this, we need to write a small shellcode that saves the address of the replacement function in a register and executes a jmp instruction to it. The shellcode looks like this:

mov r10, addressReplacementFunction
jmp r10

You can use one of the available online tools to convert these assembly instructions into opcode:

0x49, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r10, addressReplacementFunction
0x41, 0xFF, 0xE2                                            // jmp r10 

Keep in mind to set addressReplacementFunction to NULL when converting the above shellcode to opcode. The 0x00 opcodes will be overwritten by our code at runtime.

I then used BitConverter.GetBytes() and Array.Copy() to insert the address of the replacement function into the trampoline:

var funcToRunAsByte = BitConverter.GetBytes((long)funcToRun);
Array.Copy(funcToRunAsByte, 0, trampoline, 2, 8);

The full code of the trampoline can be seen below:

public const int trampolineSize = 13;
public byte[] trampoline = new byte[trampolineSize];
 
public IntPtr funcToRun;
 
trampoline[0] = 0x49;   
trampoline[1] = 0xBA;
 
var funcToRunAsByte = BitConverter.GetBytes((long)funcToRun);
Array.Copy(funcToRunAsByte, 0, trampoline, 2, 8);
 
trampoline[10] = 0x41;  
trampoline[11] = 0xFF;
trampoline[12] = 0xE2; 

Installing the Hook

We need to mark the memory region to which the trampoline is written as writable. Otherwise we would run into an access violation exception, as most memory regions are not writable by default. Please note that changing the memory region can be done via the VirtualProtect WinAPI. After we have placed our trampoline code, we will also reset the memory permission back to the original value.

API.VirtualProtect(funcToHook, trampolineSize, API.Protection.PAGE_READWRITE, out oldProtection);
 
// placing the trampoline function
 Marshal.Copy(trampoline, 0, funcToHook, trampolineSize);
 
API.VirtualProtect(funcToHook, trampolineSize, oldProtection, out _);

Removing the Hook

The aim of the unhooking function is to restore the original bytes that were overwritten by the trampoline code. To do this, it is necessary to save the original bytes in a variable before the trampoline code is installed.

public byte[] originalBytes = new byte[trampolineSize];
 
Marshal.Copy(funcToHook, originalBytes, 0, trampolineSize);

To restore the actual original bytes, the Marshal.Copy() function can be used again. However, it is also important with this function that the memory region has been set to writable.

// ensuring we can write to the buffer 
API.VirtualProtect(funcToHook, trampolineSize, API.Protection.PAGE_READWRITE, out oldProtection);
 
// copying the original bytes over
Marshal.Copy(originalBytes, 0, funcToHook, originalBytes.Length);
 
// setting the old memory protection back to what it was before hooking
API.VirtualProtect(funcToHook, trampolineSize, oldProtection, out _);

Building the Constructor

A constructor in our hooking class is required to initialize the necessary information. It makes sense that our constructor also contains our trampoline code.

public Hook(IntPtr funcToHook, IntPtr funcToRun)
{
    this.funcToHook = funcToHook;
 
    // Ensures funcToHook can be read 
    API.VirtualProtect(funcToHook, trampolineSize, API.Protection.PAGE_EXECUTE_READ, out oldProtection);
    Marshal.Copy(funcToHook, originalBytes, 0, trampolineSize);
 
    trampoline[0] = 0x49;
    trampoline[1] = 0xBA;
 
    var funcToRunAsByte = BitConverter.GetBytes((long)funcToRun);
    Array.Copy(funcToRunAsByte, 0, trampoline, 2, 8);
 
    trampoline[10] = 0x41;  
    trampoline[11] = 0xFF;
    trampoline[12] = 0xE2;
}

We also need to create a delegate for our detour function and use Marshal.GetFunctionPointerForDelegate() to convert our delegate into a function pointer. The following code does all the magic and makes our class ready for use.

private Delegate functionDelegate;
 
public Hook(IntPtr source, Delegate destination) : this(source, Marshal.GetFunctionPointerForDelegate(destination))
{
    // Prevent the delegate from being garbage collected
    functionDelegate = destination;
}

Putting it Together

The full code of our hooking class looks as follows:

public class Hook
{
    public const int trampolineSize = 13;
 
    public IntPtr funcToHook;
    public IntPtr funcToRun;
    public byte[] originalBytes = new byte[trampolineSize];
    public API.Protection oldProtection;
    public byte[] trampoline = new byte[trampolineSize];
 
    private Delegate functionDelegate;
     
    public Hook(IntPtr source, Delegate destination) : this(source, Marshal.GetFunctionPointerForDelegate(destination))
    {
        // Prevent the delegate from being garbage collected
        functionDelegate = destination;
    }
 
 
    public Hook(IntPtr funcToHook, IntPtr funcToRun)
    {
        this.funcToHook = funcToHook;
 
        // Ensures funcToHook can be read 
        API.VirtualProtect(funcToHook, trampolineSize, API.Protection.PAGE_EXECUTE_READ, out oldProtection);
        Marshal.Copy(funcToHook, originalBytes, 0, trampolineSize);
 
        trampoline[0] = 0x49;   
        trampoline[1] = 0xBA;
 
        var funcToRunAsByte = BitConverter.GetBytes((long)funcToRun);
        Array.Copy(funcToRunAsByte, 0, trampoline, 2, 8);
 
        trampoline[10] = 0x41;  
        trampoline[11] = 0xFF;
        trampoline[12] = 0xE2;
    }
 
     
    public void InstallHook()
    {
        API.VirtualProtect(funcToHook, trampolineSize, API.Protection.PAGE_READWRITE, out oldProtection);
 
        // placing the trampoline function
        Marshal.Copy(trampoline, 0, funcToHook, trampolineSize);
         
        API.VirtualProtect(funcToHook, trampolineSize, oldProtection, out _);
    }
  
 
    public void RemoveHook()
    {
        API.VirtualProtect(funcToHook, trampolineSize, API.Protection.PAGE_READWRITE, out oldProtection);
 
        // copying the original bytes over
        Marshal.Copy(originalBytes, 0, funcToHook, originalBytes.Length);
 
        functionDelegate = null;
 
        //setting the old memory protection back to what it was before hooking
        API.VirtualProtect(funcToHook, trampolineSize, oldProtection, out _);
    }
}

Using our Custom Hooking Class

We are now ready to replace the EasyHook library with our custom API hooking class. You can easily set the hooks in the following way:

// set hooks
getFileAttributesWHooked = new Hook(API.GetProcAddress(API.LoadLibrary("Kernel32"), "GetFileAttributesW"), 
    (API.FakeGetFileAttributesW)GetFileAttributesWDetour);
 
getFileAttributesExWHooked = new Hook(API.GetProcAddress(API.LoadLibrary("Kernel32"), "GetFileAttributesExW"), 
    (API.FakeGetFileAttributesExW)GetFileAttributesExWDetour);
 
createFileWHooked = new Hook(API.GetProcAddress(API.LoadLibrary("Kernel32"), "CreateFileW"), 
    (API.FakeCreateFileW)CreateFileWDetour);
 
getFileInformationByHandleHooked = new Hook(API.GetProcAddress(API.LoadLibrary("Kernel32"), "GetFileInformationByHandle"), 
    (API.FakeGetFileInformationByHandle)GetFileInformationByHandleDetour);

Removing the hooks works in the same way:

// remove hooks
getFileAttributesWHooked.RemoveHook();
getFileAttributesExWHooked.RemoveHook();
createFileWHooked.RemoveHook();
getFileInformationByHandleHooked.RemoveHook();

If we check our loaded assembly in Process Hacker again, it shows that our payload was called with a non-existent path as expected:

Conclusion

Writing a custom method for API hooking can provide additional protection against modern EDR solutions. It also reduces the overall size of the binary, making it more suitable for offensive operations during an assessment.