( Original text by @_RastaMouse )
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:
-
powershell.exe
starts and
amsi.dllis loaded into its memory space.
- We type something into the console.
- The
AmsiScanBuffer
function is called.
- 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
of
, that when scanned, returns a
. To help visualise the bypass, let’s throw PowerShell into a debugger.
We’ll set a breakpoint on the
function and type something into the console.

We step down to the
instruction — because we know from CyberArk that
contains the
of the
. We can also see that in Binary Ninja.


After the instruction, both
and
contain
— which in decimal is
. Our string
is
characters, so this checks out (bits and bytes, amirite). In the context of
, it’s saying “scan 22 bytes of this buffer”.
The bypass works by slightly patching this instruction — changing
to
. Because if you
two identical values, i.e. the current value of
(whatever it happens to be) with itself, the result is always
. So if we run the bypass and look at the instructions again…


is now zero — i.e. “scan 0 bytes of this buffer”. So if
scans
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
AmsiScanBufferfunction 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
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
?
Revisiting the
function in Binary Ninja, we can see there are a whole bunch of instructions followed by conditional jumps, but all to the same address:
.

The content of which is a
instruction, which we guessed meant
.

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
are the (hex) opcodes for
; and
is a
. 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
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
, immediately set
and
.

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
by Microsoft.