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:


<span class="function">HRESULT <span class="title">AmsiScanBuffer</span><span class="params">(
  HAMSICONTEXT amsiContext,
  PVOID        buffer,
  ULONG        length,
  LPCWSTR      contentName,
  HAMSISESSION amsiSession,
  AMSI_RESULT  *result
)</span></span>;

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.

AMSI_RESULT_CLEAN

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 {
  AMSI_RESULT_CLEAN,
  AMSI_RESULT_NOT_DETECTED,
  AMSI_RESULT_BLOCKED_BY_ADMIN_START,
  AMSI_RESULT_BLOCKED_BY_ADMIN_END,
  AMSI_RESULT_DETECTED
} ;

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 = { <span class="number">0x31</span>, <span class="number">0xff</span>, <span class="number">0x90</span> };
IntPtr unmanagedPointer = Marshal.AllocHGlobal(<span class="number">3</span>);
Marshal.Copy(Patch, <span class="number">0</span>, unmanagedPointer, <span class="number">3</span>);
MoveMemory(ASBPtr + <span class="number">0x001b</span>, unmanagedPointer, <span class="number">3</span>);

Which we modified to:


Byte[] Patch = { <span class="number">0xB8</span>, <span class="number">0x57</span>, <span class="number">0x00</span>, <span class="number">0x07</span>, <span class="number">0x80</span>, <span class="number">0xC3</span> };
IntPtr unmanagedPointer = Marshal.AllocHGlobal(<span class="number">6</span>);
Marshal.Copy(Patch, <span class="number">0</span>, unmanagedPointer, <span class="number">6</span>);
MoveMemory(ASBPtr, unmanagedPointer, <span class="number">6</span>);

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.