DripLoader项目解析

文摘   2024-10-08 17:39   陕西  

欢迎加入我的知识星球,目前正在跟更免杀相关的东西,129/永久,每100人加29。

加好友备注星球!!!

一些资源的截图:

等等....

好了废话不多说。

简介

根据Github作者介绍这个项目是一个规避型 shellcode 加载器,用于绕过基于事件的注入检测,而不必抑制事件收集。该项目旨在强调事件驱动注入识别的局限性,并表明 EDR 中需要更高级的内存扫描和更智能的本地代理软件清单。

我们可以通过Driploader这个项目,攻击者可以在不触发可疑进程注入事件的情况下来加载shellcode,使得基于此类事件的检测系统无法识别或响应。这种绕过技术的有效性表明,EDR 系统可能仅凭事件识别进行检测是不够的,需要增加更高级的内存扫描和更智能的本地代理机制,以识别未被事件触发的潜在威胁。

现代EDR系统主要依赖于监控系统事件,这里的系统事件,指的是ETW这样的机制,很多现代的EDR都依赖于ETW来监控系统中的各种事件,比如进程创建,线程的操作,模块的加载,内存的分配等等。这些事件可以用来检测异常行为,尤其是与进程注入、shellcode 加载等攻击yder这个项目的关键点就是避免触发这些通过ETW收集的关键事件,常见的基于事件的注入检测依赖于 ETW 来捕获注入的过程、加载的 DLL、线程的行为等。然而,DripLoader 通过采用一些规避技术,例如异步或分散的加载方式、利用系统中已有的合法进程、或通过非标准的方式进行内存注入,从而避免触发这些 ETW 事件,进而绕过基于此类事件的检测。

项目下载地址如下:

https://github.com/xuanxuan0/DripLoader
DripLoader绕过EDR的方式

DripLoader绕过EDR的方式分为3点:

  1. 通过syscall来调用相关的函数,比如申请内存的函数,NtAllocateVirtualMemory,或创建线程的函数,NtCreateThreadEx,这些函数都是系统调用的。这种可以有效的规避掉一些依赖于高层API的检测机制。

  2. DripLoader通过调用这些低级的API时随机或有意混合,修改参数,从而产生大量看似合法或低优先级的事件,由于生成的事件过多,EDR 供应商很难有效区分哪些事件是真正的威胁,导致这些事件要么被忽略,要么被记录但不做处理。

  3. 引入延迟以避免事件的关联,。例如,它可能在内存分配与线程创建之间插入不明显的延迟,从而打乱事件之间的时间顺序,导致 EDR 系统无法正确关联这些事件,进而无法有效识别攻击行为。

DripLoader项目是如何实现的
  1. DripLoader首先会去查找一块合适的基地址,这个基地址是它将要注入并执行shellcode的目标位置,一般会通过VirtualQuery或NtQueryVirtualMemory函数来扫描进程的虚拟内存空间,找到可用的内存段作为 shellcode 注入的目标位置。

  2. 保留 64KB 内存段并设置为 NO_ACCESS:在找到合适的基地址后,DripLoader 使用 VirtualAlloc 函数保留一块大小为 AllocationGranularity 的内存段(通常是 64KB)。在初始阶段,将这块内存段的访问权限设置为 NO_ACCESS,确保没有进程可以访问这个区域。这是为了防止内存扫描工具检测到可疑的写操作。

  3. 遍历并分配 4KB 内存段:在 64KB 内存段中,DripLoader 会逐页(以 PageSize 为单位,通常是 4KB)遍历该区域。当需要将 shellcode 写入内存时,DripLoader 通过 VirtualAllocNtAllocateVirtualMemory 分配单个 4KB 的内存块,并将该内存块的权限设置为 可写PAGE_READWRITE),这样 DripLoader 可以将 shellcode 写入内存。。

  4. 写入 shellcode:在 4KB 的可写内存段中,DripLoader 将 shellcode 写入。这是实际注入的过程。由于只分配小块内存,而不是大块连续的内存区域,这样的操作更加隐蔽,降低了检测的风险。

  5. 重新保护内存为 PAGE_EXECUTE_READ:在 shellcode 写入内存之后,DripLoader 会使用 VirtualProtectNtProtectVirtualMemory 将这个 4KB 内存段的权限从可写更改为可执行和可读(PAGE_EXECUTE_READ)。这允许 shellcode 被执行,同时保护 shellcode 不被修改。

  6. 挂钩Ntdll.dll中的函数 DripLoader会将 ntdll.dll 模块中的某个目标函数的前几个字节覆盖为跳转指令,例如 jmp shellcode_address,这个指令会将调用该函数的执行流程转向 shellcode 的内存地址。

  7. 执行 shellcode:当目标进程调用这个被劫持的 ntdll 函数时,程序实际上不会按预期的执行原函数的逻辑,而是会被跳转到恶意代码(shellcode)所在的内存中,并开始执行 shellcode。

  8. 蹦床机制:为了不影响系统调用的正常行为,DripLoader 会在 shellcode 执行结束后,将执行流程恢复到被挂钩函数的原始序言之后的指令,确保系统调用继续执行。这就是所谓的“蹦床”机制,即它负责在 shellcode 执行完毕后跳回原函数的执行路径,继续执行剩余的代码。

我们来跟一下这个代码。

代码解析

首先在Main函数这里,在这里它调用了一个EnableAnsiSupport函数,这个函数主要用于获取标准输入,输出的。

我们跟进这个函数,在这个函数中,首先会通过GetStdHandle函数是用来获取标准输入,输出或错误设备的句柄。

而这里给定的STD_OUTPUT_HANDLE表示的是标准输出的句柄。

获取到标准输出的句柄之后,调用GetConsoleMode函数来获取控制台输入或输出缓冲区的当前模式,一般这个函数和SetConsoleMode函数来配合使用。获取到控制台的模式之后,使用位操作符 |=,将 ENABLE_VIRTUAL_TERMINAL_PROCESSING 标志加到 dwMode 中。这个标志启用虚拟终端处理模式,允许控制台处理 ANSI 转义序列,例如颜色控制、光标移动等。

最后调用SetConsoleMode 将修改后的dwMode应用于控制台。

紧接着会判断当前系统是64位的还是32位的。这里是通过计算void*指针的大小来判断的,因为不同操作系统的指针大小是不同的。

64位操作系统中,void*指针大小为8个字节,而在32位操作系统中,指针的大小为4个字节。

这里判断如果不是64位操作系统的话,那么就打印Error。

然后定义了两个变量,分别代表进程ID和延迟的变量。

紧接着这里定义了一个header头部打印的信息。

紧接着这里会判断传递过来的参数是否等于1,因为我们是直接运行该程序的,没有传递任何参数,所以肯定是等于1的。

这里调用了一个PlayHeader函数用于打印一些字符串。我们可以跟进去。

在这个函数中,会打印一些字符串,进入for循环,会循环3次,并使用system函数来清空控制台,并使用Sleep函数来进行延时操作。

紧接着等待用户从控制台输入进程的PID。

紧接着再去提示用户去从控制台输入要延迟的时间是多少。

这里会判断延迟的时间是否大于0,并且小于100,如果在这个区间的话会打印出警告信息。

紧接着通过OpenProcess函数来获取到进程的句柄,这里的PROCESS_ALL_ACCESS表示请求对目标进程的所有访问权限。

接下来这里调用了GetSystemInfo函数来获取到当前系统的信息,GetSystemInfo函数中的参数指向了SYSTEM_INFO结构体的指针。

调用GetSystemInfo函数之后会填充SYSTEM_INFO结构。

SYSTEM_INFO结构体如下:

typedef struct _SYSTEM_INFO {    union {        DWORD dwOemId;                  // 保留,用于 OEM 识别        struct {            WORD wProcessorArchitecture; // 处理器架构            WORD wReserved;              // 保留字段        };    };    DWORD dwPageSize;                   // 页面大小    LPVOID lpMinimumApplicationAddress;  // 应用程序可用的最小地址    LPVOID lpMaximumApplicationAddress;  // 应用程序可用的最大地址    DWORD_PTR dwActiveProcessorMask;     // 活动处理器的掩码    DWORD dwNumberOfProcessors;          // 逻辑处理器的数量    DWORD dwProcessorType;               // 处理器类型    DWORD dwAllocationGranularity;       // 内存分配粒度    DWORD dwReserved[4];                 // 保留字段} SYSTEM_INFO;

如下代码调用了GetSystemInfo函数并填充了GetSystemInfo结构。再将当前页面的大小和当前内存分配的粒度赋值给了page_size和alloc_gran这两个变量。

然后判断如果没有获取到当前页面的大小和当前内存分配的粒度的话,就对赋默认的值。

紧接着这里会调用run函数,将其进程的pid,进程的句柄,当前页面的大小,内存分配的粒度,shellcode的名称(这里shellcode的名称为blob.bin)以及延迟的时间传递进去。

在run函数这里,首先会调用ReadProcessBlob函数来读取文件并获取shellcode,最后返回其大小,存储在szSc变量中。

ReadProcessBlob函数如下:

获取到shellcode以及大小之后,这里声明了一个SIZE_T类型变量szVmResv,表示虚拟内存的保留大小,这个值是从szAllocGran中获取的。

声明并初始化szVmCmn,表示虚拟内存的提交大小。它来自 szPage,通常是页面大小,默认为 4 KB。

然后计算为shellcode保留的虚拟内存块数量cVmResv,这个公式 (szSc / szVmResv) + 1 的意思是,shellcode 需要多少个 szVmResv 大小的块来容纳。

然后计算每个虚拟内存保留块中的页面数量,这里是通过保留快的大小除以页面的大小来计算的。

紧接着这里调用了一个GetSuitableBaseAddress函数,这个函数用于为进程hProc来查找合适的虚拟内存基址,以便来分配内存存储shellcode。这里将进程的句柄,提交内存的大小,保留内存的大小以及需要保留的内存块数传递进去。

跟进这个函数。这个函数的主要目的就是为了寻找一块连续的,未使用的内存区域,这个区域需要足够大,可以容纳shellcode,这里是通过VirtualQueryEx函数来判断内存的状态,并确保连续的多个内存块都是空闲的,适合进行内存分配。

LPVOID GetSuitableBaseAddress(HANDLE hProc, DWORD szPage, DWORD szAllocGran, DWORD cVmResv){    MEMORY_BASIC_INFORMATION mbi;
for (auto base : VC_PREF_BASES) { VirtualQueryEx( hProc, base, &mbi, sizeof(MEMORY_BASIC_INFORMATION) );
if (MEM_FREE == mbi.State) { uint64_t i; for (i = 0; i < cVmResv; ++i) { LPVOID currentBase = (void*)((DWORD_PTR)base + (i * szAllocGran)); VirtualQueryEx( hProc, currentBase, &mbi, sizeof(MEMORY_BASIC_INFORMATION) ); if (MEM_FREE != mbi.State) break; } if (i == cVmResv) { // found suitable base return base; } } } return nullptr;}

msTimeReq 通过计算保留和提交内存块的次数,并结合每个步骤的延迟时间,得出执行这些操作所需的总时间。

接下来实现了一个for循环,其中变量di是从0开始的,按照0.33的增量逐步增加,直到接近0.99,在每次循环中都会调用DelayShowProgress函数,在这个函数中会显示相关任务的信息。这个函数其实就是延迟的过程。

紧接着定义了如下的变量。

  //用于存储函数调用的状态返回值(例如 ANtAVM 返回的状态)。    NTSTATUS  status{ 0 };    //未在当前代码片段中使用,但通常用于表示某个循环计数或进程内存块的索引。    DWORD     cmm_i;    //当前虚拟内存基址,初始值为 vmBaseAddress,该基址是之前通过 GetSuitableBaseAddress 获取的。    LPVOID    currentVmBase{ vmBaseAddress };    //用于存储所有已成功保留的虚拟内存地址。    vector<LPVOID>  vcVmResv;

紧接着这里会调用ANtAVM函数,ANtAVM函数我们点进去会发现只有他的定义。

它的实现其实是在汇编中通系统调用来获取的。

这里首先将r10的值移动的r8寄存器中,这里的r8寄存器其实是传递的第三个参数,我们之前说到过在x64架构中传递参数,一般用到RCX,RDX,R8,R9 前四个参数是用到这四个寄存器,后面的参数使用堆栈来传递。

紧接着将1赋值给r10寄存器,使用xor指令清空r10寄存器中的值,然后将0xA给到r10寄存器,将rcx中的值,也就是我们传递的第一个参数给到r10寄存器,然后清空eax寄存器中的值,使用sub指令,将r8中减去r10。

最后通过add指令,将 18h(即 24)加到 eax。这个值看起来像是 syscall 的调用号,eax 中保存的值会决定实际调用的系统函数。

然后将r8寄存器清零,最后进行系统调用,这里的系统调用会根据eax中的调用号来调用相关的函数。

这里其实调用的就是NtAllocateVirtualMemory函数。

那么我们再来看这个代码的话,其实就是在目标进程中保留一块虚拟内存地址的空间,这里的currentVmBase指向保留的虚拟内存区域的起始地址。

需要注意的是MEM_RESERVER标记,这个标记表示只会保留地址空间,而不会去申请一块内存。我们之前使用的都是MEM_COMMIT来申请一块内存的。

内存保护标志位PAGE_NOACCESS。

然后更新新的基地址,通过将当前基地址增加 szVmResv(即之前请求的内存大小)来为下一次内存分配准备新的基地址。这样可以确保下次调用时将从新的地址开始分配,避免重叠。

紧接着逐块提交已经保留的虚拟内存区域,并且在每次提交后展示进度条。

这里的prcDone表示当前内存分配的进度百分比,初始化为0。

这个循环迭代 cVmResv 次,表示每次处理一个保留的内存块。

cVmCmm 表示每个保留的内存块可以划分为多个页面(通常是以操作系统的页面大小划分),该循环在每个保留块内,处理逐页的内存提交。

计算每次分配完成后,已完成的百分比。1.0 / cVmResv / cVmCmm 表示在总任务中的一小部分进度,这样可以跟踪整个内存提交过程的完成度。

prcDone += 1.0 / cVmResv / cVmCmm;

然后计算页面的偏移量。

offset 计算的是当前要提交的页面在整个保留块中的偏移量,szVmCmm 是每个页面的大小,cmm_i 是当前页面的索引。

最后更新当前内存的基址。

DWORD offset = (cmm_i * szVmCmm);currentVmBase = (LPVOID)((DWORD_PTR)vcVmResv[i] + offset);

这里再次调用ANtAVM函数来提交该内存区域,第一次调用的时候,是保留的状态,这次从MEM_RESERVER更改为了MEM_COMMIT,也就是说从保留状态变成了读写的状态了。

接下来就可以将shellcode写入到该内存区域了。

这里调用了一个AntWvm的函数,其实本质上调用了ZwWriteVirtualMemory函数。

紧接着调用AntPvm函数,这里本质上调用了NtProtectVirtualMemory函数,将其内存保护权限更改为可读可执行的权限。

在这里调用了一个PrepEntry函数,这个函数是一个挂钩函数。将其进程的句柄以及虚拟内存的基址。

我们跟进去这个函数。

这里构造一个汇编指令。

它等效于:

MOV EAX, vm_base  ; 将地址放入 EAXJMP EAX           ; 跳转到该地址

然后通过loadLibraryExA函数去加载ntdll.dll。LoadLibraryExA和LoadlibraryA函数是不同的,LoadLibraryExA函数提供了更多的选项来加载DLL的方式,比如这里指定的标记为DONT_RESOLVE_DLL_REFERENCES,这个标记表示加载DLL,但是不解析导入表,就比如说我们一般去加载Dll的时候会调用DllMain函数,那么我们给定这个标记之后,就不会调用DllMain函数了,只会将其dll映射到进程的地址空间中。

当我们使用了DONT_RESOLVE_DLL_REFERENCES标记之后,我们只能访问DLL的静态数据和资源,因为导入表没有被解析,所以我们无法使用DLL中的导出函数,除非我们去手动解析,比如调用GetProcAddress函数。

接下来其实就是调用了GetProcAddress函数来获取到RtlpWow64CtxFromAmd64函数的基址。

获取到该函数的基址之后,计算出该函数相对于DLL的偏移量,通过该函数的地址减去DLL的起始地址就得到了该函数相对于DLL的偏移。

获取到该偏移量之后,使用EnumProcessModules函数来获取到指定进程中加载的所有模块,这个函数需要传递进去进程的句柄,存储模块句柄的数组,以及数组的大小和模块所需的大小。

紧接着就是通过For循环进行遍历了,主要来枚举进程中的所有模块,在For循环这里cbNeeded是EnumProcessModules函数返回的所需的字节的大小。它表示所有枚举到的模块的总数。

每一个模块的句柄大小为sizeof(HMODULE) 所以cbNeeded / sizeof(HMODULE) 就是模块数量。

然后调用GetModuleFileNameExA函数来获取每一个模块的名字,如果成功获取到文件名,就返回TRUE,紧接着进入IF。

在If判断里面,通过strcmp函数来判断,我们找到的模块是否是ntdll.dll模块,如果是的话,就表示我们已经找到了。

最后将模块的基址保存在lpRemFuncEp变量中,这个变量中存储的就是目标进程中模块的基址。

我们现在已经获取到了目标进程中模块的基址了,我们现在需要知道目标函数具体的地址在哪,就需要用到我们之前计算的目标函数相对于DLL的偏移地址了。

使用获取到的目标进程中的基地址加上我们的偏移地址就是目标函数的地址了。

那么获取到目标函数的地址之后,将我们的跳床代码写入进去即可。

当我们去调用该函数的时候,就会执行跳床代码,跳转到我们的shellcode去执行。最后可以调用ANtCTE函数,本质上调用了NtCreateThreadEx函数,这个函数是用于创建一个远程线程来执行。

最后期待和您的下次相遇!!!

Relay学安全
这是一个纯分享技术的公众号,只想做安全圈的一股清流,不会发任何广告,不会接受任何广告,只会分享纯技术文章,欢迎各行各业的小伙伴关注。让我们一起提升技术。
 最新文章