一般的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
脚本为例
运行仍然会被拦截
卸载驱动后正常运行