AmsiScanBuffer Bypass - Part 3

In Part 2, we engineered a delivery method for the AmsiScanBuffer Bypass discussed in Part 1. In this post, we’ll make some modifications to the bypass itself.

If you read Part 1 and the original posts from CyberArk, you will know that the bypass works by patching the AMSI DLL in memory. But before we make any modifications to the bypass - let’s explore that in some additional detail, so we all have a clear baseline understanding.

Bypass Primer

We can use API Monitor to have a peak at what’s going on.

To summerise what we’re looking at:

  1. powershell.exe starts and amsi.dll is loaded into its memory space.
  2. We type something into the console.
  3. The AmsiScanBuffer function is called.
  4. Where our input is passed into.

This is the AmsiScanBuffer function as documented by Microsoft:

HRESULT AmsiScanBuffer(
  HAMSICONTEXT amsiContext,
  PVOID        buffer,
  ULONG        length,
  LPCWSTR      contentName,
  HAMSISESSION amsiSession,
  AMSI_RESULT  *result

We won’t worry about all of this - just the idea that we have a buffer of length, that when scanned, returns a result. To help visualise the bypass, let’s throw PowerShell into a debugger.

We’ll set a breakpoint on the AmsiScanBuffer function and type something into the console.

We step down to the mov edi, r8d instruction - because we know from CyberArk that r8d contains the length of the buffer. We can also see that in Binary Ninja.

After the instruction, both edi and r8d contain 2c - which in decimal is 44. Our string "this is some garbage" is 22 characters, so this checks out (bits and bytes, amirite). In the context of AmsiScanBuffer, it’s saying “scan 22 bytes of this buffer”.

The bypass works by slightly patching this instruction - changing mov edi, r8d to xor edi, edi. Because if you xor two identical values, i.e. the current value of edi (whatever it happens to be) with itself, the result is always 0. So if we run the bypass and look at the instructions again…

edi is now zero - i.e. “scan 0 bytes of this buffer”. So if AmsiScanBuffer scans 0 bytes, it will not actually scan anything at all.


So the whole reason for this post, is that I was talking to Kuba Gretzky about the bypass after I’d posted my Part 1. He said:

the risky part with the bypass is that it uses a fixed offset from the start of the function AmsiScanBufferPtr + 0x001b. MS can just slightly modify the AmsiScanBuffer function and the bypass will result in a crash. It would be wiser to do hotpatching at the beginning of the function to return a result that would say that nothing was found.

If we have have a look at the AMSI_RESULT details that we glossed over previously - there are different results that can be returned.

typedef enum AMSI_RESULT {
} ;

So could we just patch the function so that it always returns AMSI_RESULT_CLEAN?

Revisiting the AmsiScanBuffer function in Binary Ninja, we can see there are a whole bunch of instructions followed by conditional jumps, but all to the same address: 0x180024f5.

The content of which is a mov eax, 0x80070057 instruction, which we guessed meant AMSI_RESULT_CLEAN.

The original bypass was:

Byte[] Patch = { 0x31, 0xff, 0x90 };
IntPtr unmanagedPointer = Marshal.AllocHGlobal(3);
Marshal.Copy(Patch, 0, unmanagedPointer, 3);
MoveMemory(ASBPtr + 0x001b, unmanagedPointer, 3);

Which we modified to:

Byte[] Patch = { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 };
IntPtr unmanagedPointer = Marshal.AllocHGlobal(6);
Marshal.Copy(Patch, 0, unmanagedPointer, 6);
MoveMemory(ASBPtr, unmanagedPointer, 6);

Where 0xB8, 0x57, 0x00, 0x07, 0x80 are the (hex) opcodes for mov eax, 0x80070057; and 0xC3 is a retn. And notice there is no offset - we are patching the first two instructions in the function.

Before we carry out this patch, we can verify those first two instructions at the AmsiScanBuffer pointer.

They match what we expect from Binary Ninja. If we implement our new patch and look again…

The rest of the instructions become a bit munged, but that doesn’t matter. Hopefully we’ll just enter AmsiScanBuffer, immediately set eax and return.

Which seems to work just fine.

This is no “better” than the previous bypass, but hopefully will be a little more resilient against future modifications to amsi.dll by Microsoft.