Antivirus and EDR Bypass Techniques

Antivirus, anti-malware and EDR are tools commonly used to prevent attacks.

However, these solutions can be bypassed. In this article, we take a closer look at the various antivirus and EDR bypass techniques that can be implemented in a loader: a program whose aim is to execute a malicious payload on a machine by bypassing the various protections in place.

Using concrete examples, we will show how the development of such a tool can be useful for a team of penetration testers. We will also look at the various security measures that can be encountered and the techniques that can be used to bypass them.

Detailed Plan:

What is the purpose of a loader?

During our audits, particularly infrastructure and network penetration tests, our pentesters may be confronted with the need to run tools on machines equipped with antivirus software.

Such tools are also used during real cyber attacks and are therefore logically blocked by the security solutions in place. This is the case, for example, with tools such as Rubeus and SharpHound, which are used to assess Active Directory security.

Nevertheless, these tools are necessary for the proper conduct of an intrusion test. It is therefore important to be able to bypass the protections in place.

This is where the use of a loader becomes interesting. A loader can take a computer programme detected by the antivirus software and transform it into an undetected version.

How does an antivirus/EDR work?

Before even starting to create a loader, it is important to understand why a computer program is considered malicious or not by security solutions.

Antivirus/EDR software has a number of tools for classifying computer programs:

Any computer program can be passed through a hash function, for example SHA-256, to produce a unique signature.

Signature-based analysis involves storing a list of all signatures known to belong to malicious programs, and then comparing each new signature with this list.

Although this approach is interesting, it is not sufficient on its own to guarantee the effectiveness of an antivirus. In fact, it is relatively easy to bypass. The slightest change, however minor, in a program will completely alter its signature.

Furthermore, this security method does not protect against new threats whose signatures are not yet listed.

For example, the SHA256 hash corresponding to release 2.2.0 of Mimikatz on Github is:

If a program with a similar hash is downloaded onto a machine, it will be immediately detected by most antivirus software.

Static analysis consists of searching a program for one or more strings of characters known to belong to a malicious program.

Matt Hand’s DefenderCheck tool, for example, can be used to determine which character strings in a given executable are detected by Windows Defender.

Below is an example using the Mimikatz program:

Mimikatz is detected using Windows Defender static analysis

We can see that Defender qualifies the program as malicious on the basis of a character string containing the word “mimikatz”.

We can also observe this phenomenon by using a YARA rule. For example, let’s use a YARA rule that detects the presence of the following character string in a “Portable Executable” executable file:

## / \ ##  /*** Benjamin DELPY `gentilkiwi` ( [email protected] )

We can see that the “Windows_Hacktool_Mimikatz_1388212a” alert is triggered when we use our YARA rule on “mimikatz.exe”, which is not the case with “notepad.exe” (because the latter does not contain the string in question):

Yara rule to detect Mimikatz

If we run the “mimikatz.exe” program, we see that the character string is indeed present:

The Yara rule has correctly detected the string

It is therefore clear that static analysis of a program can be used to characterise it as a threat.

The aim of heuristic detection is to understand how a program works and to determine what actions it is about to perform on a system.

This can be achieved by using a sandbox: an isolated virtual machine in which the potentially dangerous program can run. The antivirus software can then check the actions taken by the program during execution and look for indicators of malicious action.

Similarly, behavioural detection consists of observing the actions performed by a program during execution in order to spot any suspicious activity.

For example, certain calls to the Windows API made in a certain order are known to be typical malware patterns.

When writing a computer program, a developer can call on libraries.

On a Windows system, this is done via DLLs (Dynamic Link Libraries). A DLL is a program that exports ready-to-use functions. A developer can then choose to load certain DLLs into their program to take advantage of the functions they export. A certain number of DLLs (for example “user32.dll” or “kernel32.dll”) are present as standard on all Windows systems and export functions that are useful to developers.

These functions are documented by Microsoft and form what is commonly known as the Windows API.

For example, a developer can use the ‘MessageBoxA’ function exported by the ‘user32.dll’ DLL to display a dialog box:

Opening a dialog box using MessageBoxA

The Import Address Table is a table relating to each Portable Executable (contained in the import directory of the PE’s optional header) which contains a list of loaded DLLs and their exported functions used by the program.

The example below shows the start of the Import Address Table for the “notepad.exe” program:

Start of Notepad.exe IAT content

This table is observed by most antivirus software, and the presence of certain functions can trigger an alert.

For example, the presence of the functions “OpenProcess”, “VirtualAllocEx”, “WriteProcessMemory” and “CreateRemoteThreadEx” (all exported by “kernel32.dll”) together in the same program will make it highly suspect. These 4 functions used together enable process injection techniques often used by malicious programs.

AMSI is an interface provided by the Windows operating system that any developer can use to integrate antivirus protection into their program.

More specifically, developers can choose to load the “AMSI.dll” DLL into their program and use the functions exported by this DLL. For example, the “AmsiScanString” function takes a string of characters as input and returns “AMSI_RESULT_CLEAN” if no threat is detected and “AMSI_RESULT_DETECTED” otherwise.

AMSI therefore acts as a bridge between a given program and an antivirus (“amsi.dll” works by default with Windows Defender, but antivirus vendors can create their own “amsi.dll” to work with their products).

Not all programs require the use of AMSI. For example, writing a character string in Notepad on Windows poses no particular risk. As a result, “amsi.dll” is not loaded by “notepad.exe”:

AMSI.dll is not loaded by Notepad.exe

On the other hand, certain PowerShell commands can clearly compromise the integrity of a system. It is therefore logical that Microsoft’s developers have chosen to integrate AMSI into powershell.exe:

AMSI.dll is loaded by Powershell.exe

ETW is a mechanism for tracking and logging a large number of events triggered by applications and drivers.

Historically, ETW was mainly used for debugging purposes. Over time, the large amount of data reported by this system became of interest to vendors of protection solutions, who saw an opportunity to detect malicious activity by analysing the flows reported by the ETW.

ETW is made up of three distinct components:

Providers

Various system components or third-party applications within the Windows operating system can instrument their code to send events to a provider. For example, the Event Tracing for Windows – Threat Intelligence provider.

In several places in the Windows code associated with critical functionalities, function calls associated with the Event Tracing for Windows – Threat Intelligence provider are observed. For example, the “MiReadWriteVirtualMemory” function makes a call to “EtwTiLogReadWriteVm”.

We can observe this using IDA on “ntoskrnl.exe” (the Windows kernel component):

EtwTiLogReadWriteVm uses EtwProviderEnabled

If we look at how the “EtwTiLogReadWriteVm” function works, we see that it uses the “EtwProviderEnabled” function to check whether a specific provider is enabled to log events:

EtwProviderEnabled is used to check that a given provider has been properly activated

If we consult the Microsoft documentation for this function, we can see that the first argument corresponds to a pointer to the handle of the provider for which we want to check whether logging is correctly activated:

If we focus on this parameter in particular, we understand that it can give us an indication of the provider used:

Here, the argument in question is “EtwThreatIntProvRegHandle”:

EtwThreatIntProvRegHandle is passed as an argument to EtwProviderEnabled

We can therefore conclude that every time “NtReadVirtualMemory” is used, events will be sent to the “Event Tracing for Windows – Threat Intelligence” provider.

Consumers

Consumers are the various programs that will use the logs provided by the suppliers to act accordingly.

Let’s take the example of the “Event Tracing for Windows – Threat Intelligence” provider. It is highly likely that an antivirus program will consume the event logs it provides.

In fact, as we have seen, this provider collects a great deal of information relating to the use of critical functions. Antivirus software could therefore have access to certain information indicating a compromise and act accordingly.

Controllers

In ETW, controllers are software components responsible for managing the event tracing process. Their main role is to initiate, monitor and control tracing sessions.

It should therefore be noted that for most of the actions we perform on a Windows system, event logs are sent back to the antivirus/EDR, which adds another method of detecting suspicious actions.

To fully grasp the concept of API hooking, we first need to look at syscalls.

As mentioned earlier, most Windows API functions are exported by “kernel32.dll”. These functions do not communicate directly with the kernel; to do so, they must use syscalls.

These system calls act as an interface enabling programs to interact with the Windows operating system. Most of them are exported as ‘ntdll.dll’, and the naming convention is that they begin with the letters ‘Nt’ (although not all NT API functions are syscalls).

For example, if a developer wants to use the ‘OpenProcess’ function in the Windows API, this function will actually call the ‘NtOpenProcess’ function in ‘ntdll.dll’.

This phenomenon can be observed in ‘x64dbg’:

The OpenProcess function calls NtOpenProcess

Now let’s see what “NtOpenProcess” does in IDA :

NtOpenProcess performs a syscall

As you can see, very few actions are performed inside ‘NtOpenProcess’. This is because, like most functions starting with Nt, ‘NtOpenProcess’ is actually in the kernel. The ‘ntdll’ versions of these functions simply perform syscalls to invoke their kernel-mode counterparts.

We have therefore seen how functions in ‘kernel32.dll’ can interact with the system.

Let’s now try to understand how antivirus software can use this principle to detect malicious actions.

When a security solution (such as an EDR) is installed on a machine, it may seek to perform API hooking. To do this, the security solution will monitor the machine to detect the creation of new processes.

When a new process is launched, the EDR will inject its own DLL into it. The EDR will look for the memory addresses of other DLLs whose functions it wishes to monitor. For example, an EDR wishing to monitor “NtProtectVirtualMemory” of “ntdll.dll” will first find the base address of “ntdll.dll” in the injected process, then the address of the “NtProtectVirtualMemory” function.

Once these actions have been carried out, the EDR will replace the first bytes at the base address of the targeted function (responsible for executing the syscall) with bytes corresponding to a jump instruction (jmp) to the code of its own DLL.

So, before hooking:

After hooking:

In this way, the EDR is free to perform the security tests it deems necessary and is able to monitor any calls to Windows API functions.

If we go back to the Address Import Table (IAT), which we discussed earlier in this article, an attacker who managed not to make a suspicious function appear in the IAT would still be detected by the API hooking technique! This method is therefore highly effective against malicious programs.

Finally, some security solutions may monitor the connections made by the machine and block a threat based on certain indicators.

For example, a block might be decided if a program initiates a connection to an IP address known to be associated with malicious servers. This strengthens security by preventing malicious software from communicating with dangerous servers.

What are the different antivirus and EDR bypass techniques?

As we have seen, security solutions have many techniques for detecting malicious activity. In this second part, we will explore different ways of bypassing these protections.

Signature-based detection is based on the digital signature generated by a hash function on a given program. If we change even a single bit of data in our program, its signature will be completely different. So it’s quite easy to get round this protection.

For example, we can change the name of one of the variables in our program, which will result in a different signature and thus avoid detection based on this signature.

Evading static detection of malware is not necessarily technically complex, but it can be time-consuming.

The aim is to modify certain elements that could be detected, such as function names, for example.

Take the following example of a program in Go:

We can compile this program using the following command: $ go build helloWorld.go.

Once this is done, let’s use the strings command on Linux :

$ strings helloWorld | grep myHello
main.myHelloWorldFunc
main.myHelloWorldFunc

As we can see, the name of our “myHelloWorldFunc” function is clearly visible. To avoid this, one solution would be to manually change the name of this function. However, on a large programme, renaming each function would take time. To avoid wasting time, we can try to automate this task. Using the Go language, the excellent “garble” library can be used to obfuscate Go binaries.

Let’s take our “helloWorld” program and use “garble” to compile it:

We can see that the name of the “myHelloWorldFunc” function is no longer visible in plain text. Garble has made the following changes.

It replaces:

  • As many identifiers as possible with hashes.
  • Package paths with hashes.
  • File names and position information with hashes.

And:

  • Deletes all build and module information.
  • Deletes debugging information and symbol tables via the -ldflags=”-w -s” option.
  • Obfuscates literals, if the -literals flag is given.

Moreover, in a loader designed to load shellcode (a string of characters representing executable binary code, in our case the tool we want to make undetectable previously put in shellcode form), it is crucial that this shellcode is not clearly visible in our Portable Executable (PE).

So we can encrypt our shellcode (using AES, for example).

This will only be decrypted when our program is run. In this way, a simple static analysis of our programme will not find our shellcode in clear text and trigger an alert.

Several techniques are available to bypass heuristic and behavioural detection. One effective approach might be to avoid loader analysis in a sandbox environment.

To do this, we could look for signs that our program is running in a sandbox environment.

For example, one indicator could be whether or not the machine on which our program is running is in an Active Directory domain.

To do this, we could use the following code:

In this code, we first use the ‘NetGetJoinInformation’ function, which returns, among other things, the ‘status’ parameter.

This is structured as follows:

The value of “NetSetupDomainName” can tell us whether or not the machine we are on belongs to a domain.

We could also carry out additional tests, such as checking the amount of RAM memory, the number of CPU cores, the available disk space, or the presence of virtualisation drivers such as “C:\Windows\System32\drivers\VBoxGuest.sys” or “C:\Windows\System32\drivers\VBoxMouse.sys”:

With this code, our loader will stop its normal execution if it detects the presence of “C:\Windows\System32\drivers\VBoxMouse.sys” on the machine. In addition to the anti-sandbox tests, we can also add benign features to our code. This will add confusion about the real purpose of our program and reduce its resemblance to other malware.

Finally, it’s important to bear in mind that anti-sandbox tests are a double-edged sword: if we are indeed hiding the true nature of our program, running this kind of test can already give an indication of its malicious nature.

As our loader is in Go, we load the DLLs and functions as follows:

This approach involves retrieving a handle on the DLL during execution. This means that the functions and DLLs used are not listed in the IAT:

Our functions are not included in the IAT

As we can see, the ‘VirtualAllocEx’ function does not appear in the IAT despite its use in the code. Another approach would be to recode an implementation of the ‘GetProcAddress’ function.

However, it is important to note that the ‘GetProcAddress’ function appears in the IAT anyway for Go programs. Consequently, reimplementing this function would not necessarily be useful in our case.

We have already seen that the “amsi.dll” DLL is loaded by certain programs, and that its functions, such as “AmsiScanString” or “AmsiScanBuffer” can be used to check whether certain entries are suspicious.

This is particularly true of PowerShell, which uses AMSI. In the following scenario, an attacker has opened a PowerShell console and knows the PID (e.g. “4552”) of the corresponding process. Let’s explore how we could disable AMSI using Go code.

We start by defining a variable for the PID “4552”, then we look for the address of the “AmsiScanString” function:

We also create the “patch” variable with byte C3. In assembler, C3 corresponds to a “ret” instruction, which means an exit from the current procedure.

Next, we retrieve a handle to the PowerShell process via its PID:

Then we use the “WriteProcessMemory” function in the “sys/windows” library:

In this way, at the address of the “AmsiScanBuffer” function for the PID 4552 process, we write a “ret” instruction.

In this way, each time this function is called, a procedure exit will be performed immediately, effectively disabling this AMSI functionality.

After a few modifications to patch the other AMSI functions, we can test our program. Before running it, we notice that AMSI blocks a command containing the string ‘amsiscanstring’:

We are blocked by AMSI

After running our program, AMSI is patched and no longer blocks commands containing potentially malicious strings:

AMSI is patched

The approach to patching ETW follows a similar pattern to that of AMSI. The steps involved are as follows:

  1. Acquire a handle to our own process.
  2. Identify the addresses of the functions linked to the ETW (in this example, “EtwEventWrite”).
  3. For each address, use the ‘WriteProcessMemory’ function to add the ‘ret’ assembly instruction.

This technique triggers a procedure return each time the patched functions are called, which prevents log data from being sent back to the ETW.

In this example we obtain a handle to the process (here with PID 9368) that we want to patch for the ETW:

We retrieve the address of the “EtwEventWrite” function and create the “patch” variable with byte C3, corresponding to the “ret” instruction:

Finally, we use the “WriteProcessMemory” function to patch “EtwEventWrite”:

Before applying the patch, we can see that the “EtwEventWrite” function works normally:

The EtwEventWrite function before patching

Once the patch has been applied, all that remains is the “ret” instruction, resulting in a procedure exit:

We have therefore managed to patch the “EtwEventWrite” function correctly. However, when we inspect ‘ntdll.dll’, we find that ‘EtwEventWrite’ can possibly call ‘EtwEventWriteFull’, which in turn can call ‘NtTraceEvent’ and perform a syscall:

A more effective method of patching ETW would therefore be to place a ‘ret’ instruction directly at the ‘NtTraceEvent’ level.

It should also be noted that this technique is not functional for the specific case of the ETWTI (ETW Threat Intelligence provider), which could be the subject of a future article.

In the first part, we explored how certain security solutions can hook into certain Windows API functions in order to analyse programme behaviour in greater depth.

When we develop a programme, we want to avoid this analysis as much as possible. To do this, we try to bypass this function hooking, because we want to use the functions necessary for our programme without undergoing this analysis.

There are several ways of doing this, and here we present the “Indirect Syscalls” technique. However, it is essential to note that this technique is only one of many for bypassing API hooking.

Before explaining what ‘Indirect Syscalls’ are, it’s important to understand the principle behind ‘Direct Syscalls’.

As mentioned above, Windows API functions end up calling functions starting with ‘Nt’ (or ‘Zw’ – for simplicity’s sake, in this article we’ll consider ‘Nt’ and ‘Zw’ to be analogous) to interact with the Windows kernel.

These “Nt” functions can make “syscalls”, i.e. system calls that allow their counterparts in the Windows kernel to be called. When a new process starts, the first DLL loaded is generally “ntdll.dll”, which exports most of the “Nt” functions.

Then, any EDR can load its own DLL and hook the functions exported by ‘ntdll.dll’, as mentioned above.

The principle behind ‘Direct Syscalls’ is as follows: instead of searching for the address of ‘ntdll.dll’ (using ‘GetModuleHandle’, for example) and then the address of the ‘Nt’ function we want to use (using ‘GetProcAddress’, for example), we will directly implement the assembly code corresponding to the desired ‘Nt’ function in our code.

In this way, we no longer need the functions in ‘ntdll.dll’ to perform syscalls.

However, there is a difficulty in implementing this technique: each function responsible for a syscall is associated with an SSN (Syscall Service Number). This SSN is transmitted to the kernel, enabling it to identify the function it needs to execute.

An ‘Nt’ function in assembler generally takes the following form:

Let’s take the example of “NtOpenProcess”:

SSN from NtOpenProcess

Here, we see that the SSN of NtOpenProcess is “26” (the value “0x26” is placed in the “eax” register via the “mov eax, 26” instruction to be passed as an argument to the kernel).

The main difficulty in implementing our own assembly code for the “Nt” functions lies in the fact that the SSNs for the various functions can change from one Windows system to another. Statically writing the different SSNs in our code is therefore a risky option, as we could run into correspondence problems. A more interesting solution is to dynamically calculate the SSNs of the different syscalls.

The danger of direct syscalls

One problem with the use of direct syscalls is that some syscalls instructions exist outside “ntdll.dll”, which is unusual behaviour.

A security solution could look for syscalls outside ‘ntdll.dll’ and trigger an alert if they occur. An implementation that is less likely to be detected is to use indirect syscalls.

Indirect syscalls work in the same way, with one difference: instead of performing the syscall directly from the assembly code we are implementing, we perform a jump instruction (jmp) to the memory address of the syscall we are interested in in ‘ntdll.dll’.

Instead of having:

We will have:

Another advantage of indirect syscalls is that as the SSN of our syscall has been placed in the ‘eax’ register with the instruction ‘mov eax, SSN’, we can perform a jump (jmp) to the address of any syscall in ‘ntdll.dll’. Ideally, we’ll use the address of a syscall belonging to a function other than the one we actually want to use.

For example, if we want to make an indirect syscall for the ‘NtOpenProcess’ function:

NtOpenProcess syscall address

Our jump instruction may not point to the address of the “NtOpenProcess (0x00007FFCD824D522)” syscall, but rather to the “NtAllocateVirtualMemory (0x00007FFCD824D362)” syscall:

NtAllocateVirtualMemory syscall address

Solving SSNs dynamically with the Hell’s Gate technique

As we have seen, ideally we want to be able to resolve SSNs dynamically. Before explaining how ‘Hell’s Gate’ works, it is important to specify that the SSNs associated with each syscall are incremental.

To be clearer, let’s take the example below:

SSNs are incremental

We can see that the SSN of each “Nt” (or Zw) function is equivalent to the SSN of the previous +1 function.

Now let’s explain how Hell’s Gate works. This technique works as follows:

  • We define two structures:
    • _VX_TABLE_ENTRY: contains a function address, the hash of the corresponding function name, and its SSN.
    • _VX_TABLE: contains the list of all _VX_TABLE_ENTRY (one per Nt function).
  • For each “Nt” function name, we apply a hash function (djb2). The list of “hash -> function name Nt” matches is kept for future use.
  • We access the TEB (thread environment block) via RtlGetThreadEnvironmentBlock, which contains the PEB (process environment block).
  • From the PEB, we can access the EAT (export address table: list of all exported functions) of ‘ntdll.dll’.
  • For each function name found in the EAT of ntdll.dll, we apply a hash function (djb2).
  • We compare these hashes with those contained in our list of matches established in the second step.
  • If two hashes match, we initialise the address and hashed name elements for a _VX_TABLE_ENTRY. This _VX_TABLE_ENTRY is then added to the _VX_TABLE.
  • For each _VX_TABLE_ENTRY in the _VX_TABLE, we go to the function address. We then look for the sequence of bytes: 0x4c, 0x8b, 0xd1, 0xb8, corresponding to the instructions “mov r10, rcx” and “mov eax, SSN”. If this sequence of bytes is not found (indicating the probable presence of a hook), we move on to the next address until we find the right pattern. Once found, we initialise the last SSN element for the current _VX_TABLE_ENTRY. If we reach the sequence of bytes 0x4c, 0x8b, 0xd1, 0xb8 corresponding to the “syscall” and “ret” instructions, this means that we have passed the SSN without finding it. Resolution of the SSN in question has therefore failed, and we move on to the next _VX_TABLE_ENTRY.

After performing these operations for each _VX_TABLE_ENTRY, we obtain a “function name Nt -> SSN” mapping table that we have calculated dynamically.

Conclusion

In conclusion, it is important to note that the field of antivirus evasion is constantly evolving.

Currently effective techniques can quickly become obsolete in the face of advances in security solutions. Antivirus/EDR evasion remains complex, requiring a high level of customisation and the combined use of several techniques. The strategies discussed in this article represent only part of the range of methods available and are not necessarily the most effective in 2024.

More recent concepts and techniques may be explored in subsequent articles. These include:

  • How kernel callbacks work.
  • The EtwTI patch, which may require a vulnerable driver to be exploited (BYOVD technique – Bring Your Own Vulnerable Driver) and how Patchguard works.
  • More advanced versions for dynamically resolving SSNs, such as HalosGate, an evolution of Hell’s Gate.

Author: Arthur LE FAOU – Pentester @Vaadata