一般的shellcode加载到内存都是通过LoadLibrary和GetProcAddress来获取函数进行shellcode加载,亦或是通过VirtualAllocEx远程申请一块空间来放入shellcode的地址进行加载。为了隐蔽,攻击者通常会通过PEB找到InLoadOrderModuleList链表,自己去定位LoadLibrary函数从而规避杀软对导入表的监控。攻击者先把shellcode加密,在写入时解密存放到内存空间,使用基于文件检测的方法,是无能为力的,那么这种无落地的方式,最终都会在内存中一览无余
视频演示请移步:https://www.bilibili.com/video/BV1o54y1f7J3
测试
首先我们测试一下dll注入在内存里面的情况,这里注入notepad.exe进程

动静很大,几乎一眼就能够发现可疑的内存

然后我们再尝试shellcode加载,这里我就直接使用VitualAlloc申请一块地址查看效果

代码如下
1 | void shellcode() |
这里我们在加载之前暂停一下看下vad树的情况

定位到exe

在64位下,vad树位于7d8偏移处,如果是32位则位于11c偏移,这里可以看到基本上在没有使用函数之前,一般都是可读或可写,没有可执行的内存,再就是dll基本都是写拷贝状态

然后执行一下,可以看到cs已经上线

这里我地址输出得有点问题,应该定位是264e0bd0,这里我们可以看到这是一块Private内存,且是EXECUTE_READWRITE权限

这里远程线程注入也是通过VirtualAllocEx申请空间,这里跟VitualAlloc的原理一样,这里就不演示了,也是申请的一块Private的空间,拥有EXECUTE_READWRITE权限
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已经批来的货里面(堆)买一部分走
我们试着分别在全局、堆、栈里面分配空间
1 | // malloc1.cpp : Defines the entry point for the console application. |
首先执行分配空间之前看一下vad



malloc成功之后再去看一下vad树

没有任何变化,证明malloc并不分配内存空间

堆里面的地址为3807b8,对应的是Private内存

栈的空间是12ff7c,栈是从大地址往小地址写

全局变量为424d8c,全局变量内存在运行的时候就是以映射的方式

无论是全局变量,局部变量,或者调用malloc函数,它都没有分配新的内存空间,只不过是使用了当前进程已有的内存空间
而Mapped分配的内存分为两种,分别是共享物理页面和共享文件,类似于这种就是windows把自己的一些系统文件映射出来供所有进程使用

还有一些Mapped内存就是物理页

堆栈回溯
那么这里我们如果想要检测不落地的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出栈

我们可以发现,在函数的调用过程中EBP寄存器总是保持不变,那么这里我们就可以通过逐级向调用函数、调用函数的调用函数进行遍历,向上回溯。根据堆栈结构和 CALL 指令的操作可知,在将属于调用函数的 EBP 的值压栈之前,ESP 指向的地址存储的是由 CALL 指令压栈的调用函数中调用位置的下一条指令的地址(原 EIP)。那么根据这个逻辑,可以通过上面回溯的各级 EBP 的值,并根据 EBP+sizeof(ULONG_PTR) 获取到函数调用者函数体中的地址(当前函数的返回地址)
一开始我的想法是基于TEB结构里面的StackBase和StackLimit的值进行判断,这两个值作为线程栈的范围存在,一般情况下StackBase在初始赋值之后就不会再改变,而 StackLimit 作为动态的成员域,根据当前线程函数调用层级的递进,以固定的长度向下扩展。根据规定,所属每个函数调用的 EBP 和 ESP 寄存器所划定的空间,应该始终在当前线程的 StackLimit 到 StackBase 的范围之间存在


但是经过实验后发现有一个需要注意的点就是,并不是所有的shellcode都会通过修改StackLimit和StackBase使堆栈进行改变
这里经过查阅资料后发现栈信息的获取可以通过RtlWalkFrameChain这个函数实现,代码如下
第一个参数Callers是一个数组,保存栈中retaddr值,第二个参数Count表示数组大小,第三个参数Flags=0则获取内核层栈信息,Flags=1则获取应用层栈信息
1 | ULONG RtlWalkFrameChain(OUT PVOID *Callers, IN ULONG Count, IN ULONG Flags); |
在32位系统上,我通过IDA发现关键代码为_asm mov FramePointer, EBP;,说明RtlWalkFrameChain这个函数就是通过EBP寄存器一步一步得到每个栈的信息
在得到EBP内容之后,我们需要计算当前内核栈的范围,这是因为我们在计算数据时不能跑出一个范围,否则会有蓝屏的危险。栈的开始地址就设置为EBP指针指向的地址,而终止范围是比较难确定的,这个地址可以使用我们上面提到的StackBase的值
我们知道在函数开始处都有以下作为函数的最开始两句代码,这样根据EBP就可以找到所有的函数地址
1 | push ebp |
代码实现
那么这里我们了解了堆栈回溯的原理,我们来进行代码的编写,我们在前面分析了shellcode会通过VirtualAlloc/VirtualAllocEx去申请内存,得到一块private内存,具有可读可写可执行权限,那么我们就可以通过这个特征去定位vad树中的内存
这里就用到ZwQueryVirtualMemory 这个API,用来确定虚拟空间地址的状态保护和类型,结构如下
1 | NTSYSAPI NTSTATUS ZwQueryVirtualMemory( |

第三个参数MemoryInformationClass只能设置为MemoryBasicInformation,第四个参数指向MEMORY_BASIC_INFORMATION结构
那么这里我们首先定义MEMORY_BASIC_INFORMATION数组,在ntifs.h中导出,声明头文件即可

1 | MEMORY_BASIC_INFORMATION MBInformation[sizeof(MEMORY_BASIC_INFORMATION)] = { 0 }; |
通过NTSTATUS接收返回参数,成功则返回STATUS_SUCCESS,那么这里写一个判断

1 | NTSTATUS nt_status = ZwQueryVirtualMemory(NtCurrentProcess(), (PVOID)pAddress, MemoryBasicInformation, MBInformation, sizeof(MEMORY_BASIC_INFORMATION), (PSIZE_T)&RetLength); |
然后我们再看MEMORY_BASIC_INFORMATION结构,state参数判断页面是否为MEM_COMMIT 状态,Type参数有三个值来判断是否为private内存,Protect用来判断是否为可读可写可执行内存
1 | typedef struct _MEMORY_BASIC_INFORMATION { |
那么这里我们得出相应代码,首先判断是否为Mapped或private,将写拷贝内存过滤掉
1 | bool IsMemory = MBInformation->Type == MEM_PRIVATE || MBInformation->Type == MEM_MAPPED; |
再判断是否为MEM_COMMIT
1 | bool IsCommit = MBInformation->State == MEM_COMMIT; |
然后判断具体为哪种权限的内存
1 | bool IsExecute = MBInformation->Protect == PAGE_EXECUTE || MBInformation->Protect == PAGE_EXECUTE_READWRITE || |
然后整体相与,满足所有条件的内存才进行判断
1 | bool IsResult = false; |
我们在前面提到栈回溯是通过RtlWalkFrameChain这个函数实现的,我们先初始化一下
1 | PVOID ary[MAX_PATH]={0}; |
然后通过循环的方式遍历
1 | for (ULONG i = StackCount; i > 0; i--) |
实现了判断内存和堆栈回溯的代码之后,我们就可以判断内存是否被无落地的shellcode注入,我们再写一个回调函数,这里注意要判断一下IRQL的等级
IRQL全称Interrupt Request Level。一个由windows虚拟出来的概念,划分在windows下中断的优先级,这里中断包括了硬中断和软中断,硬中断是由硬件产生,而软中断则是完全虚拟出来的。
#define PASSIVE_LEVEL 0
#define APC_LEVEL 1
#define DISPATCH_LEVEL 2
#define PROFILE_LEVEL 27
#define CLOCK1_LEVEL 28
#define CLOCK2_LEVEL 28
#define IPI_LEVEL 29
#define POWER_LEVEL 30
#define HIGH_LEVEL 31
假设现在有一个中断等级为PASSIVE_LEVEL ,正在被执行,此时产生了一个中断DISPATCH_LEVEL,那么中断等级为DISPATCH_LEVEL的程序异常处理将会被执行。反之则不然,这也是为什么众多内核api要求中断等级的原因,一个不注意将会导致蓝屏
所以这里我们要判断IRQL是否为PASSIVE_LEVEL ,如果不等于则直接退出判断,使用KeGetCurrentIrql获取当前的IRQL
1 | if (KeGetCurrentIrql() != PASSIVE_LEVEL) |
然后调用栈回溯的检查函数来判断内存的栈是否被修改,如果修改则证明有shellcode的注入
1 | if(stack_trace() == false) |
判断出进程之后使用ZwTerminateProcess结束当前进程的所有线程并输出
1 | DebugPrint("[!] Find shellcode inject , Process Name: %s\n",PsGetProcessImageFileName(PsGetCurrentProcess())); |
使用PsSetLoadImageNotifyRoutine注册回调函数
实现效果
这里还是拿我们之前的exe进行测试,首先测试直接使用VirualAlloc申请的内存注入shellcode

在没有加载驱动的时候正常上线

然后加载驱动

被我们的检测程序检测到,直接将进程退出,cs没有上线

然后我们再进行dll注入的尝试

可以看到也是被我们的检测程序捕捉到,注入没有成功

这里我先换一台有360的主机测试一下,拿一个远程线程注入的shellcode程序,使用分离的方式,扫描一下没有报毒


执行也是可以正常上线,可以看到注入了lsass.exe进程,360无感

然后我们再回到原主机上对lsass.exe注入shellcode,也是能够成功上线的

这里再加载一下驱动

可以看到注入成功,但是被我们的检测驱动捕捉到,直接kill掉了lsass进程,导致系统崩了

重启之后这里我再换一个普通程序进行测试,这里选择notepad

可以看到当创建远程线程之后,被我们的检测驱动捕捉到,进程退出

这里我们再试一下落地的powershell加载

执行一下

同样被拦截,进程退出

再看一下不落地的powershell加载,这里使用mimikatz.ps1脚本为例

运行仍然会被拦截

卸载驱动后正常运行
