|
本帖最后由 luozhenni 于 2022-6-29 23:44 编辑
对抗无落地的shellcode注入
原文链接:对抗无落地的shellcode注入
红队蓝军 2022-06-24 09:00 发表于湖北
以下文章来源于跳跳糖社区 ,作者Drunkmars
点击蓝字 / 关注我们
0x00 前言
一般的shellcode加载到内存都是通过LoadLibrary和GetProcAddress来获取函数进行shellcode加载,亦或是通过VirtualAllocEx远程申请一块空间来放入shellcode的地址进行加载。为了隐蔽,攻击者通常会通过PEB找到InLoadOrderModuleList链表,自己去定位LoadLibrary函数从而规避杀软对导入表的监控。攻击者先把shellcode加密,在写入时解密存放到内存空间,使用基于文件检测的方法,是无能为力的,那么这种无落地的方式,最终都会在内存中一览无余。
0x01 测试
首先我们测试一下dll注入在内存里面的情况,这里注入notepad.exe进程
编辑
image-20220507155011643.png
动静很大,几乎一眼就能够发现可疑的内存
编辑
image-20220507155432177.png
然后我们再尝试shellcode加载,这里我就直接使用VitualAlloc申请一块地址查看效果
编辑
image-20220507142253875.png
代码如下
- void shellcode()
- {
- PVOID p = NULL;
- p = VirtualAlloc(NULL, sizeof(buf), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
- if (p == NULL)
- printf("VirtualAlloc error : %d\n", GetLastError());
- else
- printf("VirtualAlloc successfully , address : %x\n", p);
- if (!memcpy(p, buf, sizeof(buf)))
- printf("Write shellcode failed\n");
- else
- printf("Write shellcode successfully\n");
- ((void(*)())p)();
- }
-
复制代码
这里我们在加载之前暂停一下看下vad树的情况
编辑
image-20220507153458036.png
定位到exe
编辑
image-20220507153544539.png
在64位下,vad树位于7d8偏移处,如果是32位则位于11c偏移,这里可以看到基本上在没有使用函数之前,一般都是可读或可写,没有可执行的内存,再就是dll基本都是写拷贝状态
编辑
image-20220507153614179.png
然后执行一下,可以看到cs已经上线
编辑
image-20220507153818274.png
这里我地址输出得有点问题,应该定位是264e0bd0,这里我们可以看到这是一块Private内存,且是EXECUTE_READWRITE权限
编辑
image-20220507153655900.png
这里远程线程注入也是通过VirtualAllocEx申请空间,这里跟VitualAlloc的原理一样,这里就不演示了,也是申请的一块Private的空间,拥有EXECUTE_READWRITE权限
0x02 vad
对于内存空间有两种描述方式,一种是物理内存的角度,所有地址只分为两类,挂了物理页的地址与没有挂物理页的地址,其属性由PDE/PTE决定。另一种是线性地址的角度,分为私有内存与映射内存,这里我们暂时不提第一种方式,我们来说一下第二种方式
这里在上面其实我们已经了解到了两种内存的属性,其实在windows内存管理里面,也只有这两种属性,分别是Private、Mapped,即私有内存和映射内存
这两类内存的区别主要有2点不同:
- • 申请内存的方式不同:
- • 私有内存:通过VirtualAlloc/VirtualAllocEx申请的
- • 映射内存:通过CreateFileMapping映射的
- • 使用方式不同:
- • 私有内存:独享物理页
- • 映射内存:可能要与其它进程共享物理页
我们提到只有VirtualAlloc和CreateFileMapping这两个函数申请的内存,称为私有内存和映射内存,那我们之前使用的malloc和new申请的内存叫什么内存呢,难道他们就不分配空间了吗?
在C语言中使用malloc和在C++中使用new分配的堆空间并不是真正的内存,new其实就是调用malloc分配内存,而malloc的底层实现是HeapAlloc,但是这个HeapAlloc并没有进0环,而是通过在操作系统一开始用VirtualAlloc已经分配好的一大块空间里面取一块
限于篇幅,这里就不贴图逆向的过程了,这里我通过IDA跟踪malloc和new的调用过程,如下所示
malloc -> _nh_malloc_dbg -> _heap_alloc_dbg -> _heap_alloc_base -> HeapAlloc
new -> _nh_malloc -> _nh_malloc_dbg -> _heap_alloc_dbg -> _heap_alloc_base -> HeapAlloc
这里要了解一下堆的概念,什么是堆呢?堆其实就是操作系统通过调用VirtualAlloc函数预先分配好的一大块内存。HeapAlloc的作用就是在这一大块已经预先分配好的内存里面,分一些小份出来用。作个比喻,可以认为VirtualAlloc就是批发市场,一次必须批量从操作系统那里购买内存,必须是4KB的整数倍才可以;而HeapAlloc就是零售商,从VirtualAlloc已经批来的货里面(堆)买一部分走
我们试着分别在全局、堆、栈里面分配空间
-
- // malloc1.cpp : Defines the entry point for the console application.
- //
- #include "stdafx.h"
- #include <windows.h>
- #include <stdio.h>
- #include <stdlib.h>
- int x = 0x1234;
- int main(int argc, char* argv[])
- {
- printf("Before malloc");
- getchar();
- int y = 0x5678;
- int* z = (int*)malloc(sizeof(int)*128);
- printf("Golbal x : %x\n", &x);
- printf("Heap y : %x\n", &y);
- printf("Stack z : %x\n", z);
- getchar();
- return 0;
- }
-
复制代码
首先执行分配空间之前看一下vad
编辑
image-20220329185008191.png
编辑
image-20220329185100666.png
编辑
image-20220329185134645.png
malloc成功之后再去看一下vad树
编辑
image-20220329185221622.png
没有任何变化,证明malloc并不分配内存空间
编辑
image-20220329185318721.png
堆里面的地址为3807b8,对应的是Private内存
编辑
image-20220329185645999.png
栈的空间是12ff7c,栈是从大地址往小地址写
编辑
image-20220329185813099.png
全局变量为424d8c,全局变量内存在运行的时候就是以映射的方式
编辑
image-20220329200043202.png
无论是全局变量,局部变量,或者调用malloc函数,它都没有分配新的内存空间,只不过是使用了当前进程已有的内存空间
而Mapped分配的内存分为两种,分别是共享物理页面和共享文件,类似于这种就是windows把自己的一些系统文件映射出来供所有进程使用
编辑
image-20220329200600029.png
还有一些Mapped内存就是物理页
编辑
image-20220329200838017.png
0x03 堆栈回溯
那么这里我们如果想要检测不落地的shellcode注入,肯定重点盯防的就是vad树中是private内存,且位READWRITE_EXECUTE权限的内存,那么我们该如何定位呢?这里就需要用到堆栈回溯技术
堆栈回溯顾名思义,就是查看没有更改的堆栈,更通俗点来说就是查看ebp跟esp来确认堆栈的起始位置和结束位置。我们知道c语言里面有好几种调用约定,如:cdecl、fastcall、stcall等,每种调用约定的压参顺序是不同的,有些是内平栈,有些是外平栈,这里我们不单独讨论某种调用约定的方式,我们只关注堆栈指针的改变
在汇编中CALL指令用来调用某个其他地址的函数,其实这个指令可以拆分成:1.将下一条指令的EIP压入堆栈,2.再进行跳转
我们知道在3环层面EIP为堆栈的最顶端,而发生切换时windows首先会将线程的CONTEXT结构先保存,然后再切换EIP跳转
简单来说,堆栈就是利用 EBP寄存器访问栈内部局部变量、参数、函数返回地址等的手段。程序运行中,ESP寄存器的值随时变化,访问栈中函数的局部变量、参数时,若以 ESP值为基准编写程序会十分困难,并且也很难使 CPU 引用到正确的地址
所以,调用某函数时,先要把用作基准点(函数起始地址)的 ESP值保存到 EBP,并维持在函数内部。这样,无论 ESP的值如何变化,以 EBP的值为基准能够安全访问到相关函数的局部变量、参数、返回地址,这就是 EBP寄存器作为堆栈指针的作用
这里我们写一个简单的test()函数打印出hello world,可以看到首先将ebp压栈,然后将esp的值赋给ebp,通过sub esp,40h将栈顶提升0x40个字节,操作完成之后,通过add esp,40h将栈顶恢复,然后将ebp即提栈之前esp的值还原,再让ebp出栈
编辑
image-20220507183640506.png
我们可以发现,在函数的调用过程中EBP寄存器总是保持不变,那么这里我们就可以通过逐级向调用函数、调用函数的调用函数进行遍历,向上回溯。根据堆栈结构和 CALL 指令的操作可知,在将属于调用函数的 EBP的值压栈之前,ESP指向的地址存储的是由 CALL指令压栈的调用函数中调用位置的下一条指令的地址(原 EIP)。那么根据这个逻辑,可以通过上面回溯的各级 EBP的值,并根据 EBP+sizeof(ULONG_PTR) 获取到函数调用者函数体中的地址(当前函数的返回地址)
一开始我的想法是基于TEB结构里面的StackBase和StackLimit的值进行判断,这两个值作为线程栈的范围存在,一般情况下StackBase在初始赋值之后就不会再改变,而 StackLimit作为动态的成员域,根据当前线程函数调用层级的递进,以固定的长度向下扩展。根据规定,所属每个函数调用的 EBP和 ESP寄存器所划定的空间,应该始终在当前线程的 StackLimit到 StackBase的范围之间存在
编辑
image-20220507184741445.png
编辑
image-20220507184811029.png
但是经过实验后发现有一个需要注意的点就是,并不是所有的shellcode都会通过修改StackLimit和StackBase使堆栈进行改变
这里经过查阅资料后发现栈信息的获取可以通过RtlWalkFrameChain这个函数实现,代码如下
第一个参数Callers是一个数组,保存栈中retaddr值,第二个参数Count表示数组大小,第三个参数Flags=0则获取内核层栈信息,Flags=1则获取应用层栈信息
ULONG RtlWalkFrameChain(OUT PVOID *Callers, IN ULONG Count, IN ULONG Flags);
在32位系统上,我通过IDA发现关键代码为_asm mov FramePointer, EBP;,说明RtlWalkFrameChain这个函数就是通过EBP寄存器一步一步得到每个栈的信息
在得到EBP内容之后,我们需要计算当前内核栈的范围,这是因为我们在计算数据时不能跑出一个范围,否则会有蓝屏的危险。栈的开始地址就设置为EBP指针指向的地址,而终止范围是比较难确定的,这个地址可以使用我们上面提到的StackBase的值
我们知道在函数开始处都有以下作为函数的最开始两句代码,这样根据EBP就可以找到所有的函数地址
0x04 代码实现
那么这里我们了解了堆栈回溯的原理,我们来进行代码的编写,我们在前面分析了shellcode会通过VirtualAlloc/VirtualAllocEx去申请内存,得到一块private内存,具有可读可写可执行权限,那么我们就可以通过这个特征去定位vad树中的内存
这里就用到ZwQueryVirtualMemory这个API,用来确定虚拟空间地址的状态保护和类型,结构如下
- NTSYSAPI NTSTATUS ZwQueryVirtualMemory(
- [in] HANDLE ProcessHandle,
- [in, optional] PVOID BaseAddress,
- [in] MEMORY_INFORMATION_CLASS MemoryInformationClass,
- [out] PVOID MemoryInformation,
- [in] SIZE_T MemoryInformationLength,
- [out, optional] PSIZE_T ReturnLength
- );
复制代码
编辑
image-20220508092858302.png
第三个参数MemoryInformationClass只能设置为MemoryBasicInformation,第四个参数指向MEMORY_BASIC_INFORMATION结构
那么这里我们首先定义MEMORY_BASIC_INFORMATION数组,在ntifs.h中导出,声明头文件即可
编辑
image-20220508093119299.png
MEMORY_BASIC_INFORMATION MBInformation[sizeof(MEMORY_BASIC_INFORMATION)] = { 0 };
通过NTSTATUS接收返回参数,成功则返回STATUS_SUCCESS,那么这里写一个判断
编辑
image-20220508093311789.png
NTSTATUS nt_status = ZwQueryVirtualMemory(NtCurrentProcess(), (PVOID)pAddress, MemoryBasicInformation, MBInformation, sizeof(MEMORY_BASIC_INFORMATION), (PSIZE_T)&RetLength);if (NT_SUCCESS(nt_status))
然后我们再看MEMORY_BASIC_INFORMATION结构,state参数判断页面是否为MEM_COMMIT状态,Type参数有三个值来判断是否为private内存,Protect用来判断是否为可读可写可执行内存
- typedef struct _MEMORY_BASIC_INFORMATION {
- PVOID BaseAddress;
- PVOID AllocationBase;
- ULONG AllocationProtect;
- USHORT PartitionId;
- SIZE_T RegionSize;
- ULONG State;
- ULONG Protect;
- ULONG Type;
- } MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
复制代码
那么这里我们得出相应代码,首先判断是否为Mapped或private,将写拷贝内存过滤掉
bool IsMemory = MBInformation->Type == MEM_PRIVATE || MBInformation->Type == MEM_MAPPED;
再判断是否为MEM_COMMIT
bool IsCommit = MBInformation->State == MEM_COMMIT;
然后判断具体为哪种权限的内存
bool IsExecute = MBInformation->Protect == PAGE_EXECUTE || MBInformation->Protect == PAGE_EXECUTE_READWRITE ||MBInformation->Protect == PAGE_EXECUTE_READ || MBInformation->Protect == PAGE_EXECUTE_WRITECOPY;
然后整体相与,满足所有条件的内存才进行判断
bool IsResult = false;IsResult = IsMemory && IsCommit && IsExecute;
我们在前面提到栈回溯是通过RtlWalkFrameChain这个函数实现的,我们先初始化一下
PVOID ary[MAX_PATH]={0}; ULONG StackCount;StackCount = RtlWalkFrameChain(ary,MAX_PATH,1);
然后通过循环的方式遍历
- for (ULONG i = StackCount; i > 0; i--)
- {
- if (CheckVAD((PVOID)ary[i]))
- {
- DebugPrint("Stack : %d Address : %p \n", i, ary[i]);
- bResult = false;
- break;
- }
- }
复制代码
实现了判断内存和堆栈回溯的代码之后,我们就可以判断内存是否被无落地的shellcode注入,我们再写一个回调函数,这里注意要判断一下IRQL的等级
IRQL全称Interrupt Request Level。一个由windows虚拟出来的概念,划分在windows下中断的优先级,这里中断包括了硬中断和软中断,硬中断是由硬件产生,而软中断则是完全虚拟出来的。
define PASSIVE_LEVEL 0define APC_LEVEL 1define DISPATCH_LEVEL 2define PROFILE_LEVEL 27define CLOCK1_LEVEL 28define CLOCK2_LEVEL 28define IPI_LEVEL 29define POWER_LEVEL 30define HIGH_LEVEL 31 假设现在有一个中断等级为PASSIVE_LEVEL,正在被执行,此时产生了一个中断DISPATCH_LEVEL,那么中断等级为DISPATCH_LEVEL的程序异常处理将会被执行。反之则不然,这也是为什么众多内核api要求中断等级的原因,一个不注意将会导致蓝屏
所以这里我们要判断IRQL是否为PASSIVE_LEVEL,如果不等于则直接退出判断,使用KeGetCurrentIrql获取当前的IRQL
- if (KeGetCurrentIrql() != PASSIVE_LEVEL)
- return;
复制代码
然后调用栈回溯的检查函数来判断内存的栈是否被修改,如果修改则证明有shellcode的注入
if(stack_trace() == false)
判断出进程之后使用ZwTerminateProcess结束当前进程的所有线程并输出
- DebugPrint("[!] Find shellcode inject , Process Name: %s\n",PsGetProcessImageFileName(PsGetCurrentProcess()));
- ZwTerminateProcess(NtCurrentProcess(), 0);
- DebugPrint("[√] Delete successfully\n");
复制代码
使用PsSetLoadImageNotifyRoutine注册回调函数
0x05 实现效果
这里还是拿我们之前的exe进行测试,首先测试直接使用VirualAlloc申请的内存注入shellcode
编辑
image-20220508104440870.png
在没有加载驱动的时候正常上线
编辑
image-20220508104427058.png
然后加载驱动
编辑
image-20220508104538637.png
被我们的检测程序检测到,直接将进程退出,cs没有上线
编辑
image-20220508104627657.png
然后我们在进行dll注入的尝试
编辑
image-20220508105326339.png
可以看到也是被我们的检测程序捕捉到,注入没有成功
编辑
image-20220508105400309.png
这里我先换一台有360的主机测试以下,拿一个远程线程注入的shellcode程序,使用分离的方式,扫描一下没有报毒
编辑
image-20220508111739806.png
编辑
image-20220508111751801.png
执行也是可以正常上线,可以看到注入了lsass.exe进程,360无感
编辑
image-20220508113005455.png
然后我们再回到原主机上对lsass.exe注入shellcode,也是能够成功上线的
编辑
image-20220508113705476.png
这里再加载一下驱动
编辑
image-20220508115506314.png
可以看到注入成功,但是被我们的检测驱动捕捉到,直接kill掉了lsass进程,导致系统崩了
编辑
image-20220508115540674.png
重启之后这里我再换一个普通程序进行测试,这里选择notepad
编辑
image-20220508115849524.png
可以看到当创建远程线程之后,被我们的检测驱动捕捉到,进程退出
编辑
image-20220508120025906.png
这里我们再试一下落地的powershell加载
编辑
image-20220508193854117.png
执行一下
编辑
image-20220508194024229.png
同样被拦截,进程退出
编辑
image-20220508194001863.png
再看一下不落地的powershell加载,这里使用mimikatz.ps1脚本为例
编辑
image-20220508210031815.png
运行仍然会被拦截
编辑
image-20220508210100209.png
卸载驱动后正常运行
编辑
|
|