本文由团队成员 ryze-t 投稿给安全客,链接如下:
https://www.anquanke.com/post/id/261582
点击阅读原文可跳转至作者博客
0x00 syscall 基础概念
Windows下有两种处理器访问模式:用户模式(user mode)和内核模式(kernel mode)。用户模式下运行应用程序时,Windows 会为该程序创建一个新进程,提供一个私有虚拟地址空间和一个私有句柄表,因为私有,一个应用程序无法修改另一个应用程序的私有虚拟地址空间的数据;内核模式下,所有运行的代码都共享一个虚拟地址空间, 因此内核中驱动程序可能还会因为写入错误的地址空间导致其他驱动程序甚至系统出现错误。
内核中包含了大部分操作系统的内部数据结构,所以用户模式下的应用程序在访问这些数据结构或调用内部Windows例程以执行特权操作的时候,必须先从用户模式切换到内核模式,这里就涉及到系统调用。
x86 windows 使用 sysenter 实现系统调用。
x64 windows 使用 syscall 实现系统调用。
0x01 syscall 运行机制
以创建线程的函数 CreateThread() 举例,函数结构如下:
HANDLE WINAPI CreateThread( _In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes, _In_ SIZE_T dwStackSize, _In_ LPTHREAD_START_ROUTINE lpStartAddress, _In_opt_ LPVOID lpParameter, _In_ DWORD dwCreationFlags, _Out_opt_ LPDWORD lpThreadId );示例代码如下:
#include<Windows.h>DWORD WINAPI Thread(LPVOID p){ MessageBox(0, 0, 0, 0); return 0;}void main(){ //DebugBreak(); CreateThread(NULL, 0, Thread, 0, 0, 0); // 创建线程 Sleep(1000);}使用 Procmon 查看创建线程的堆栈:
可以看到在用户层 CreateThread的调用栈为:
kernel32.dll!CreateThread → KernelBase.dll!CreateRemoteThreadEx → ntdll.dll!ZwCreateThreadEx
其本质是 ntdll.dll中保存着执行功能的函数以及系统服务调用存根,ntdll.dll导出了Windows Native API,其具体实现其实在 ntoskrnl.exe 中。
IDA 查看 ntdll.dll!ZwCreateThreadEx:
可以看到,调用 ZwCreateThreadEx,实际上调用的是 NtCreateThreadEx,然后通过判断机器是否支持 syscall 后,会执行 syscall 或 int 2E。
如果熟悉 ntdll.dll 的话会知道,ntdll.dll 中的一部分导出函数都是采用这种形式,如 NtCreateProcess:
代码几乎一样,区别在于 mov eax 0B4h
,也就是在执行syscall 前,传入 eax 的值不同。即 eax 中存储的是系统调用号,基于 eax 所存储的值的不同,syscall 进入内核调用的内核函数也不同。
0x02 NtCreateThreadEx
2.1 CreateThread 调用流程
跟随调用栈来解析一下 CreateThread() 真实运行流程。
首先是 Kernel32.dll!CreateThread,直接在 IDA function 窗口并不能搜到这个函数,查看导出表:
进入后知道,CreateThread 实际上是 CreateThreadStub:
CreateThreadStub 会调用 Kernelbase!CreateRemoteThreadEx
CreateRemoteThread 结构如下:
HANDLE CreateRemoteThreadEx( [in] HANDLE hProcess, [in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes, [in] SIZE_T dwStackSize, [in] LPTHREAD_START_ROUTINE lpStartAddress, [in, optional] LPVOID lpParameter, [in] DWORD dwCreationFlags, [in, optional] LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList, [out, optional] LPDWORD lpThreadId);IDA 查看该函数找到 NtCreateThreadEx:
v13 = NtCreateThreadEx( &ThreadHandle, 0x1FFFFFi64, v39, hProcess, v37, v38, v14, 0i64, v34 & -(__int64)((dwCreationFlags & 0x10000) == 0), v15, v46);2.2 NtCreateThread 参数结构
解析一下 NtCreateThread 参数结构,先看一下定义过的变量:
跟进该函数
由于原程序中代码为 CreateThread(NULL, 0, Thread, 0, 0, 0)
,因此 lpThreadAttributes = NULL,所以传到 BaseFormatObjectAttributes 中的参数 (int)a2 = 0,a3 = 0。
因此根据程序逻辑 v39 = *a4 = 0
第四个参数是 hProcess
第五个参数是 lpStartAddress
第六个参数是 lpParameter
第七个参数是 v14,代码逻辑如下:
因此要找到程序运行逻辑,是进入哪一个 LABEL。
结合 Windbg 查看,在 ntdll!NtCreateThreadEx 处下断点,根据 fastcall 调用约定,前四个参数由寄存器传递( RCX、RDX、R8、R9),其他参数由 RSP+0x20 开始压栈,因此第七个参数位置应是 RSP+0x30,由于此时已经执行 CALL 指令,由于返回地址入栈,RSP-8,因此参数应该由 RSP+0x28 开始:
可以看到第五个参数为 0007ff6`2b8e4383,这也就是创建的线程的起始地址,第六个参数为0,第七个参数也为0
这里附上其完整结构:
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.3 直接调用 NtCreateThreadEx
根据分析参数得到的结果来看,如果想直接调用 NtCreateThreadEx,代码应为:
#include<Windows.h>#include <stdio.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);DWORD WINAPI Thread(LPVOID p){ return 0;}pfnNtCreateThreadEx NtCreateThreadExFunc(){ HMODULE hNtdll = GetModuleHandle(L"ntdll.dll"); if (hNtdll == NULL) { printf("Load Ntdll.dll error:%d\n", GetLastError()); return FALSE; } pfnNtCreateThreadEx NtCreateThreadEx = (pfnNtCreateThreadEx)GetProcAddress(hNtdll, "NtCreateThreadEx"); if (NtCreateThreadEx == NULL) { printf("Load NtCreateThreadEx error:%d \n", GetLastError()); return FALSE; } return NtCreateThreadEx;}int main(){ //DebugBreak(); HANDLE ProcessHandle = NULL; HANDLE ThreadHandle = NULL; ProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 6396); // 6396是计算器的pid if (ProcessHandle == NULL) { printf("OpenProcess error:%d\n",GetLastError()); return FALSE; } pfnNtCreateThreadEx NtCreateThreadEx = NtCreateThreadExFunc(); NtCreateThreadEx(&ThreadHandle, 0x1FFFFF, NULL, ProcessHandle, Thread, NULL, FALSE, NULL, NULL, NULL, NULL); if (ThreadHandle != NULL) { printf("Success! ThreadHandle=%d\n", GetThreadId(ThreadHandle)); } Sleep(10000); CloseHandle(ThreadHandle); CloseHandle(ProcessHandle); return TRUE;}运行结果如下:
0x03 syscall 调用
在 VS2019 中新建一个文件为 syscall.asm,右键解决方案生成自定义依赖性:
选择 masm:
右键 syscall.asm → 属性,选择 Microsoft Macro Assembler
在 syscall.asm 中写入如下(win10 1511):
.codeNtCreateThreadEx procmov r10,rcxmov eax,0B4hsyscallretNtCreateThreadEx endpend保存后新建 main.c
#include<Windows.h>#include <stdio.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);DWORD WINAPI Thread(LPVOID p){ return 0;}int main(){ //DebugBreak(); HANDLE ProcessHandle = NULL; ProcessHandle = GetCurrentProcess(); // 获取当前进程句柄 if (ProcessHandle == NULL) { printf("OpenProcess error:%d\n", GetLastError()); return FALSE; } HANDLE ThreadHandle = NULL; NtCreateThreadEx(&ThreadHandle, 0x1FFFFF, NULL, ProcessHandle, Thread, NULL, FALSE, NULL, NULL, NULL, NULL); if (ThreadHandle != NULL) { printf("Success! ThreadId=%d\n", GetThreadId(ThreadHandle)); } else { printf("Fail!"); } if (WaitForSingleObject(ThreadHandle, INFINITE) == WAIT_FAILED) { printf("[!]WaitForSingleObject error\n"); return FALSE; } CloseHandle(ThreadHandle); CloseHandle(ProcessHandle); return TRUE;}这里注入的是自身进程,所以看起来更加直观,在用户层调用栈中并未出现 CreateThread 相关API:
0x04 Syscall 项目
由上述可知, syscall 这种方法主要可以应对 EDR 对 Ring3 API 的 HOOK,主要的问题是不同版本的 Windows Ntxxx 函数的系统调用号不同,且调用时需要逆向各 API 的结构方便调用。于是 github 上陆续出现了一些项目,持续更新 syscall table,如 syscalls 或 system Call tables,同时对于未公开 API 的结构就需要通过查找或自己逆向了。
4.1 Syswhispers
基于 Syscall 出现了一个非常方便的项目—Syswhispers,Syswhispers 的原理与上述大致相同,它更加方便的生成 asm 文件以及一个头文件,通过包含头文件就可以进行 syscall。
用法如下:
可以看到生成了两个文件,在解决方案资源管理器中的头文件中导入这两个文件:
与之前对 asm 的操作一样,生成 masm 依赖项,然后更改属性→项类型选择 Microsoft Macro Assembler。
在C文件中加上 #include "syscall.h"
示例代码如下:
#include<Windows.h>#include <stdio.h>#include "syscall.h"DWORD WINAPI Thread(LPVOID p){ return 0;}int main(){ //DebugBreak(); HANDLE ProcessHandle = NULL; ProcessHandle = GetCurrentProcess(); if (ProcessHandle == NULL) { printf("OpenProcess error:%d\n", GetLastError()); return FALSE; } HANDLE ThreadHandle = NULL; NtCreateThreadEx(&ThreadHandle, 0x1FFFFF, NULL, ProcessHandle, Thread, NULL, FALSE, NULL, NULL, NULL, NULL); if (ThreadHandle != NULL) { printf("Success! ThreadId=%d\n", GetThreadId(ThreadHandle)); } else { printf("Fail!"); } if (WaitForSingleObject(ThreadHandle, INFINITE) == WAIT_FAILED) { printf("[!]WaitForSingleObject error\n"); return FALSE; } CloseHandle(ThreadHandle); CloseHandle(ProcessHandle); return TRUE;}编译通过后执行:
查看 asm 文件:
在不指定版本的情况下,Syswhispers 会导出指定函数的所有已知版本的系统调用号,根据版本的不同再来指定调用。
4.2 Syswhispers2
在今年原作者对 Syswhispers 进行了些许改进,更新成 Syswhispers2。
Syswhispers2 与 Syswhispers 最大的不同在于 Syswhispers2 不再需要指定 Windows 版本,也不再依赖于以往的系统调用表,而是采用了系统调用地址排序的方法,也就是这篇 Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams。其具体含义是先解析 Ntdll.dll 的 导出地址表 EAT,定位所有以 “Zw” 开头的函数,将开头替换成 “Nt”,将 Code stub 的 hash 和地址存储在 SYSCALL_ENTRY 结构的表中,存储在表中的系统调用的索引是SSN(System Service Numbers,系统服务编号)。
用法与 Syswhispers 大致相同,不同的点在于,在使用时会生成三个文件:
在导入时要将 syscall.c 也导入到源代码中
syscall.c 中存储着系统调用地址排序和哈希比较的功能。
编译运行后: