目标网络设计配置的直接系统调用的原理

编程语言及工具

105人已加入

描述

本环境是蛇矛实验室基于"火天网演攻防演训靶场"进行搭建,通过火天网演中的环境构建模块,可以灵活的对目标网络进行设计和配置,并且可以快速进行场景搭建和复现验证工作。

背景概述

在安全研发的过程中,难免会遇到在用户模式对抗 AV/EDR 的挂钩,应对的方法有很多,比如可以使用直接系统调用,还可以将杀软的所挂的钩子解除,还有一种就是寻找具有相同功能未被挂钩的 API。本文将讲述直接系统调用的原理,并对一些相关的项目进行分析。

API 的调用过程分析

首先,我们编写一段测试程序(x64)并使用相关工具对其调用流程进行分析。

#include 
#include 
#include 

int main()
{
    PROCESS_INFORMATION pi{};
    STARTUPINFO si{ sizeof(si) };
    CreateProcess(nullptr, nullptr, nullptr, nullptr, 0, 0, 0, nullptr, &si, &pi);

    system("pause");
    return 0;
}

使用 Process Monitor 工具进行过滤监测,可以看到 CreateProcess API 调用流程。

IDA

从上图可以看到,当我们在程序中调用 CreateProcess 之后,实际上是调用了用户层下 kernel32.dll 这个 DLL 中的 CreateProcessW ,从这个调用堆栈可以看出,在用户模式下最终会调用 ntdll.dll 中的 NtCreateUserProcess 函数,并通过这个函数进入内核。

使用 IDA 对这个函数进行查看。

IDA

通过 IDA 中查看这个函数的实现可以看到,NtCreateUserProcess 函数的主体,函数以 rcx 寄存器作为参数,然后将其值复制到 r10 寄存器中,由于在函数主体中后续没有用到 r10 寄存器,所以这句没有实际的作用,接下来,将 0C8h 赋值给 eax 寄存器,其中 0C8h 为系统调用号,在 Windows 中,系统调用通常使用特定的调用号来标识,在这个例子中,调用号 0C8h 就表示 NtCreateUserProcess 系统调用,随后执行一条测试指令,检查 ds:7FFE0308h 位置处的字节值是否为 1,这条指令是用来判断 CPU 是否支持快速调用(即是否支持 syscall 指令),如果支持,则会使用 syscall 指令来执行系统调用,否则会通过中断调用(int 2eh) 指令来执行系统调用进入内核。

使用 Native API

Native API 是一种用于访问 Windows 操作系统内部功能的 API。它位于高于应用程序级别和内核级别之间,可以让开发人员访问操作系统的一些底层功能。上文通过对用户层的 API 的调用流程进行分析,可以发现,用户层的大多数 API 调用最终都会转到 ntdll.dll 中去执行,并通过相对应的 Native API 中转最终进入内核层执行,通过 ntoskrnl.exe 实现具体的功能。

熟悉 Windows 编程的人应该知道,Native API 一般是不直接对外公开的,它通常作为操作系统内部的一个组件,并且只能由操作系统内部的组件或应用程序调用,所以在使用一些未文档的化的 Native API 时,需要通过网上公开的资料或者通过逆向取获取其函数原型和相关参数。

下面使给出一段使用 Native API 的代码(x64),注意 NtCreateThreadEx 32 位和 64 位函数原型不同,具体差别可自行上网查阅或者逆向。

#include 
#include 

// 定义 NtCreateThreadEx 函数指针
using NtCreateThreadExT = NTSTATUS(NTAPI*)(
    OUT PHANDLE ThreadHandle,
    IN ACCESS_MASK DesiredAccess,
    IN LPVOID ObjectAttributes OPTIONAL,
    IN HANDLE ProcessHandle,
    IN PVOID StartRoutine,
    IN PVOID Argument OPTIONAL,
    IN ULONG CreateFlags,
    IN SIZE_T ZeroBits,
    IN SIZE_T StackSize,
    IN SIZE_T MaximumStackSize,
    IN LPVOID AttributeList OPTIONAL);

EXTERN_C

DWORD WINAPI ThreadProc(LPVOID param)
{
    std::cout << GetCurrentThreadId() << std::endl;

    return 0;
}

int main()
{
    // 获取 NtCreateThreadEx 函数的地址
    auto NtCreateThreadEx = (NtCreateThreadExT)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtCreateThreadEx");
    if (NtCreateThreadEx == NULL)
    {
        std::cout << "get NtCreateThreadEx func address failed!" << std::endl;
        return -1;
    }

    HANDLE thread = NULL;
    NTSTATUS status = NtCreateThreadEx(
        &thread, // 输出线程句柄
        GENERIC_EXECUTE, // 指定线程的访问权限
        NULL, // 指定线程安全描述符
        GetCurrentProcess(), // 指定线程所在的进程句柄
        ThreadProc, // 指定线程函数
        NULL, // 指定线程函数的参数
        FALSE, // 指定是否在创建时挂起线程
        0, // 指定堆栈中保留的字节数
        0, // 指定堆栈中分配的字节数
        0, // 指定堆栈中总共保留的字节数
        NULL                 // 指定附加的参数
    );
    if (status != 0)
    {
        printf("thread create failed: %d
", status);
        return -1;
    }

    std::cout << "thread created successfully
" << std::endl;

    // 等待线程结束
    WaitForSingleObject(thread, INFINITE);

    // 关闭线程句柄
    CloseHandle(thread);

    system("pause");
    return 0;
}

上述代码先定义了 NtCreateThreadEx 类型的函数指针,并通过 GetModuleHandleA + NtCreateThreadEx 来获取 NtCreateThreadEx 的函数地址,之后通过使用 NtCreateThreadEx 创建一个线程,在创建的线程回调函数中,输出了创建线程的 ID 号,最终待线程执行完毕后,关闭了线程句柄。

执行的结果如下:IDA

在本节演示程序中,我们通过从已加载的 ntdll.dll 内存中获取NtCreateThreadEx 的函数地址,这能有效的绕过 AV/EDR 关于 Kernel32、KernelBase 的挂钩,但是却不能绕过 AV/EDR 对 ntdll的挂钩。目前来说,大多数 AV/EDR 在用户模式下都是对 ntdll 中的相关 API 进行挂钩了,所以接下来还需要了解直接系统调用,也就是通常说的重写 R3 下的 API。

直接系统调用

前文中对 CreateProcess 的调用流程进行了分析介绍了如何使用 Native API ,下面将解释一下系统调用,在理解系统调用之前,首先需要了解一下现代操作系统的基础结构,用户模式(空间)和内核模式(空间)。在 Windows 中,操作系统提供了一个内核空间,用于处理所有的底层系统事务,并提供一些系统服务,而应用程序运行在用户空间,并且不能直接访问内核空间的内容。当用户空间的应用程序需要访问内核空间的系统服务时,就需要通过系统调用来实现。

系统调用是一种特殊的函数,允许应用程序访问内核空间。应用程序通过调用系统调用函数,将参数传递给内核空间,内核空间处理该请求并返回结果。

在 Windows 中,系统调用是通过向特定的内核空间地址发送特殊的中断来实现的。具体实现细节取决于 Windows 版本。例如,在 Windows 10 中,系统调用可以通过快速系统调用 (syscall) 指令或者传统的中断方式来实现。

综上,系统调用是一种能够让用户空间应用程序访问内核空间系统服务的机制。通过系统调用,用户空间应用程序可以获得更多的系统功能,例如读写磁盘、访问网络等。在 Windows 中,系统调用的实现方式为直接系统调用,它是通过执行特殊的汇编指令来直接调用内核空间的系统服务的。对于直接系统调用,与其它系统调用不同的是,它需要在编译时预知调用的系统服务。换句话说,在编译时就需要确定具体的系统服务的调用号,并在汇编代码中显式地使用它。例如在 NtCreateThreadEx 函数的汇编代码中,我们可以看到如下代码(win11):

IDA

在这段代码中,mov 指令将 0C7h 的值存储到了 eax 寄存器中,这个值就是调用号。然后 syscall 指令会使用 eax 寄存器中的值作为系统服务的调用号,从而调用内核空间的系统服务。

总之,系统调用是用户空间应用程序访问内核空间系统服务的机制,而直接系统调用是 Windows 中系统调用的实现方式。

现如今,AV/EDR 在用户层下通常通过挂钩 Native API 用以监控程序的执行流程,进而来判断执行的代码是否是恶意的。为了绕过这些安全产品在用户层的 API 挂钩,可以采用直接系统调用,因为直接系统调用是指直接通过汇编指令调用内核空间的系统服务,而不会经过用户层的 API 函数,所以能够绕过一些在用户层的 API 挂钩的安全产品。

那如何实现直接系统调用呢?这里使用 Visual Studio 进行直接系统调用的测试。

编写的测试代码:

首先新建汇编文件(.asm),并在其中编写需要进行直接系统调用的汇编代码。

.code

NtCreateThreadEx proc
    mov r10, rcx
    mov eax, 0C7h
    syscall
    ret
NtCreateThreadEx endp

end

如果想要 .asm 文件参与编译,需要设置一下项目属性,在 Build Customizations 中勾选 masm 的支持。

IDA

之后在 asm 文件 上右键设置其属性 --- General --- Item Type --- Mircrosoft Macro Assembler

IDA

设置完毕后,开始编写主程序的调用代码。

#include 
#include 

EXTERN_C NTSTATUS NtCreateThreadEx(
    OUT PHANDLE ThreadHandle,
    IN ACCESS_MASK DesiredAccess,
    IN LPVOID ObjectAttributes OPTIONAL,
    IN HANDLE ProcessHandle,
    IN PVOID StartRoutine,
    IN PVOID Argument OPTIONAL,
    IN ULONG CreateFlags,
    IN SIZE_T ZeroBits,
    IN SIZE_T StackSize,
    IN SIZE_T MaximumStackSize,
    IN LPVOID AttributeList OPTIONAL);

DWORD WINAPI ThreadProc(LPVOID prarm)
{
    std::cout << "thead id:" << GetCurrentThreadId() << std::endl;

    return 0;
}

int main()
{
    HANDLE hproc = GetCurrentProcess();
    HANDLE hthread = nullptr;
    // hthread = CreateThread(nullptr, 0, ThreadProc, nullptr, 0, nullptr);
    NtCreateThreadEx(&hthread, GENERIC_EXECUTE, nullptr, hproc, ThreadProc, nullptr, FALSE, 0, 0, 0, nullptr);

    WaitForSingleObject(hthread, INFINITE);
    CloseHandle(hthread);

    system("pause");
    return 0;
}

在汇编文件中下断点,调试运行可以发现,程序能够走到我们自己编写的直接系统调用中,并没有走原始的 Native API。

IDA

执行完毕后,程序运行的结果如下。

IDA

使用重写的 NtCreateThreadEx 创建的线程代码被正常执行了。

如果将上述代码中 NtCreateThreadEx 函数换成 CreateThread ,通过 Process Monitor 工具对该程序 Thread Create 操作进行监控查看其调用堆栈。

IDA

可以看到,最终还是会转到 NtCreateThreadEx 函数。

查看 NtCreateThreadEx 创建线程下的调用堆栈。

IDA

在这个调用堆栈中,并没有发现 NtCreateThreadEx 被调用,这是因为此时调用的是程序中实现的 NtCreateThreadEx。

上述代码通过对 NtCreateThreadEx 进行直接系统调用,从而绕过了 AV/EDR 在用户模式下对 Native API (这里是 NtCreateThreadEx )的挂钩。

但需要说明的是在不同的 Windows 操作系统版本之间,系统调用号可能会有所不同。

例如上述代码是在 win11 中实现的,自己查看 NtCreateThreadEx 的调用号为 0C7h,当将其放到其他操作系统中,可能存在不同,比如在 win10 中测试发现,NtCreateThreadEx 的调用号为 0C1h。

下图为 win11(版本号22621.819) 中 NtCreateThreadEx 的调用号。

IDA

下图为 win10(版本号19043.1110) 中 NtCreateThreadEx 的调用号。

IDA

不同操作系统间调用号的不同详情可参考:

https://j00ru.vexillium.org/syscalls/nt/32/

https://j00ru.vexillium.org/syscalls/nt/64/

通过上面自己编写个 API 的直接系统调用可以看到,这个过程还是比较繁琐,需要区分不同操作系统之间的 API 的调用号的不同,并且还需要去获取 API 的函数原型。于是网上出现了各种方便用户使用系统调用的优秀项目,从本质来说,要想使用直接系统调用,就是想办法获取相关 Native API 的 stubs,我根据其实现方式的不同进行了分类:

动态 SSN 号获取

二次加载 ntdll 获取 stubs(Dual-load ntdll)

读取内存中的 KnownDlls

从磁盘读取 ntdll

下面介绍几个比较优秀的项目,并对其进行简单分析。

SysWhispers

SysWhispers 项目可以通过 python 脚本自动生成 x64 版本系统调用 stubs。

项目地址:https://github.com/jthuraisamy/SysWhispers

使用介绍:根据项目说明文档进行测试,该项目最终会生成两个文件 xxx.h 和 xxx.asm ,将其拷贝到项目中即可使用,具体在项目中使用这些函数参见上文 API 的调用过程。

这里以 NtCreateFile 作为演示。

py .syswhispers.py --functions NtCreateFile -o syscalls

运行之后发现,同级目录下发现:

IDA

查看头文件,发现其中声明了调用相关 Native API 使用到的数据结构。

IDA

查看其生成 asm 文件内容,可以发现其中是具体 API 的直接系统调用代码 stubs,生成的汇编代码首先会判断系统版本进而去选择内置的函数系统调用号,之后在进行系统调用。

IDA

这段汇编代码通过 PEB 检查系统的主要版本、次要版本和构建号,之后根据这些信息获取正确的系统调用,如下图所示。

IDA

Syswhispers2

在使用 Syswhispers 过程中可以发现,该项目需要提前知道相关函数系统版本调用号进行编写,一般未将所有系统版本情况包含进去,就会调用失败,不具有通用性,所以原作者对其进行改进,形成了 Syswhispers2 。

项目地址:https://github.com/jthuraisamy/SysWhispers2

作者在项目介绍文档中指出了与 Syswhispers 项目的不同,其中最大的区别是不需要指定要支持哪个版本的 Windows 了。

IDA

Syswhispers2 项目的用法与 Syswhispers 一致,以生成 NtCreateUserProcess 的系统 stubs 来说明。

py .syswhispers.py --functions NtCreateThreadEx -o syscalls -a x64 -l masm

下面对生成的关键代码进行分析。

生成的 syscallsstubs.std.x64.asm 中的代码如下图所示。

IDA

上述的代码片段的主要功能是通过内置预定义的 hash 值来获取系统调用号,进而进行相关函数的系统调用。

其中关键的函数为 SW2_GetSyscallNumber ,该函数在 syscalls.c 文件中被定义与实现。

IDA

该函数中又调用了另一个关键函数 SW2_PopulateSyscallList ,该函数将 Zw 开头 Native API 名称的 hash 值按照升序排序保存到 SW2_SyscallList.Entries 这个全局数组中。其中获取 Zw 开头的 API 是通过 peb 的到 ntdll 基址后,遍历其导出表得到的,排序算法使用的冒泡排序。

通过 peb 获取已加载的 ntdll 在内存中的基址。

IDA

遍历内存中 ntdll 的导出表,获取 Zw 开头的函数名称,存储到全局数组 SW2_SyscallList.Entries 中。

IDA

对 SW2_SyscallList.Entries 这个全局数组进行冒泡升序排序。

IDA

生成的文件中还有一个汇编文件(syscallsstubs.rnd.x64.asm),其代码片段如下。

IDA

上述代码片段与 syscallsstubs.std.x64.asm 中代码不同点在于,该段汇编代码隐藏了 syscall 指令的出现,Syswhispers2 项目使用 SW2_GetRandomSyscallAddress 函数生成了随机的 syscall 指令地址,用于防止 syscall 指令在汇编代码片段中出现。

在使用这个文件时需要注意,需要 #define RANDSYSCALL 声明宏,以开启 SW2_GetRandomSyscallAddress 。

其中 SW2_GetRandomSyscallAddress 函数在 syscalls.c 文件中定义与实现。

IDA

该段代码通过特征码定位的形式来获取随机一个 Native API 的 syscall 指令地址,之后通过 call qword ptr [syscallAddress] 替换代码中的 syscall 指令,从而绕过了 AV/EDR 对 syscall 指令的标记。

SysWhispers3

SysWhispers3 项目在项目说明文档中解释了与 Syswhispers2 项目的不同之处。

IDA

下面对 SysWhispers3 项目生成的文件进行简单分析。

IDA

主要分析生成的汇编代码文件和 syscalls.c 文件。

分析使用的生成脚本命令

py .syswhispers.py --functions NtCreateThreadEx -o syscalls -a x64 -c msvc -m jumper_randomized

对生成的代码文件分析,一共生成了 3 个文件。

生成的 syscalls-asm.x64.asm 文件如下。

IDA

该段汇编代码主要是隐藏了 syscall 指令的出现,并随机获取其他 API stubs 中的 syscall 指令,之后通过使用 jmp syscall 地址对其进行转换,从而绕过了部分 AV/EDR 对 syscall 指令的标记。

其中函数的调用地址是 SW3_GetRandomSyscallAddress 函数实现的,该函数在 syscalls.c 文件中定义并实现。

IDA

SW3_GetRandomSyscallAddress 函数的主要作用是得到一个随机的 Native API 的     syscall 指令地址。剩下的几个函数与 Syswhispers2 项目中大体类似,便不在这里分析了。

HellsGate

项目地址:https://github.com/am0nsec/HellsGate

HellsGate 主要思路是通过 PEB 获取已加载的 ntdll 在内存中的基地址,随后通过解析其导出表,来定位 API 地址,之后通过特征码来获取函数的系统调用号,进而实现系统调用。

下面分析一些关键代码。

IDA

这段代码片段就是 HellsGate 用来定位系统调用号的特征码。

mov r10,rcx // 0x4c 0x8b 0xd1
mov eax,  // 0xb8 xx xx 0x00 0x00

虽然 HellsGate 通过特征码能够准确定位获取函数的系统调用号,但也存在一定的局限性,使用它的前提是内存中的 ntdll 必须是“干净”的 ntdll ,也就是不能被 AV/EDR 挂钩,一旦被挂钩,比如 mov r10,rcx 被挂钩了,那么其字节码就变了,就找不到想要的函数系统调用号了。

Halo’s Gate

后续也出现了一些改进方案,比如 Halo’s Gate,详情可参考:https://blog.sektor7.net/#!res/2021/halosgate.md ,基本思路就是由于相邻的系统调用有一定规律性(如下图所示),所以只要定位相邻的系统调用,就可以推导出想要函数的系统调用号。

IDA

HellsGatePoC

从 HellsGate 之门项目可以看到,它的局限性在于需要有一块“干净”的 ntdll ,不然,它无法获取到想要的系统调用号。所以,又一个思路诞生了,该项目的主要思路是从磁盘中中获取干净的 ntdll,并将其映射到内存中,进而获取到系统调用号,从而使用系统调用。

项目地址:https://github.com/N4kedTurtle/HellsGatePoC

更多的项目

当然有关使用直接系统调用绕过用户层挂钩的项目远远不止这些,由于篇幅有限,本文并没有将其全部都介绍一遍,下面我推荐2个我认为优秀的项目。

https://github.com/JustasMasiulis/inline_syscall

https://github.com/crummie5/FreshyCalls

有兴趣的读者可以去了解下。

经过前文的分析,可以知道使用直接系统调用大致就是获取 Native API 的 stubs 或者动态的获取系统调用号去构造 stubs,虽然使用直接系统调用能够有效的避免 AV/EDR 在用户模式下的挂钩,但是去获取这个 stubs 或者调用号的过程还是会被 AV/EDR 监控的,并且在 Window Vista 之后,在内核模式中,安全厂商的研发人员可以利用微软提供的现成的内核通知回调很轻松的监控用户模式下程序的各种动作,所以在进行防御规避的过程中,需要不断去发掘出新的思路方法,或者将已知的各种规避技术相互结合来进行欺骗和绕过。

并且随着 Windows 版本的提升,微软也已经开始也有趋势去处理调用号的问题,从下图(x86)可以发现,系统调用号并不一定是稳定递增的,所以从这个方面想,那种基于排序动态获取系统调用号的方法是否在未来还可用?以及是否有对应的缓解措施去应对这些变化,是作为一个安全研究员需要去不断探索的事。

IDA

参考文献

https://j00ru.vexillium.org/syscalls/nt/32/

https://j00ru.vexillium.org/syscalls/nt/64/

https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams/

https://blog.sektor7.net/#!res/2021/halosgate.md

https://teamhydra.blog/2020/09/18/implementing-direct-syscalls-using-hells-gate/

编辑:黄飞

 

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分