Drunkmars's Blog

对抗无落地的shellcode注入

字数统计: 3.8k阅读时长: 14 min
2022/05/07

一般的shellcode加载到内存都是通过LoadLibraryGetProcAddress来获取函数进行shellcode加载,亦或是通过VirtualAllocEx远程申请一块空间来放入shellcode的地址进行加载。为了隐蔽,攻击者通常会通过PEB找到InLoadOrderModuleList链表,自己去定位LoadLibrary函数从而规避杀软对导入表的监控。攻击者先把shellcode加密,在写入时解密存放到内存空间,使用基于文件检测的方法,是无能为力的,那么这种无落地的方式,最终都会在内存中一览无余

视频演示请移步:https://www.bilibili.com/video/BV1o54y1f7J3

测试

首先我们测试一下dll注入在内存里面的情况,这里注入notepad.exe进程

image-20220507155011643

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

image-20220507155432177

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

image-20220507142253875

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

定位到exe

image-20220507153544539

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

image-20220507153614179

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

image-20220507153818274

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

image-20220507153655900

这里远程线程注入也是通过VirtualAllocEx申请空间,这里跟VitualAlloc的原理一样,这里就不演示了,也是申请的一块Private的空间,拥有EXECUTE_READWRITE权限

vad

对于内存空间有两种描述方式,一种是物理内存的角度,所有地址只分为两类,挂了物理页的地址与没有挂物理页的地址,其属性由PDE/PTE决定。另一种是线性地址的角度,分为私有内存与映射内存,这里我们暂时不提第一种方式,我们来说一下第二种方式

这里在上面其实我们已经了解到了两种内存的属性,其实在windows内存管理里面,也只有这两种属性,分别是PrivateMapped,即私有内存和映射内存

这两类内存的区别主要有2点不同:

  • 申请内存的方式不同:
    • 私有内存:通过VirtualAlloc/VirtualAllocEx申请的
    • 映射内存:通过CreateFileMapping映射的
  • 使用方式不同:
    • 私有内存:独享物理页
    • 映射内存:可能要与其它进程共享物理页

我们提到只有VirtualAllocCreateFileMapping这两个函数申请的内存,称为私有内存和映射内存,那我们之前使用的mallocnew申请的内存叫什么内存呢,难道他们就不分配空间了吗?

在C语言中使用malloc和在C++中使用new分配的堆空间并不是真正的内存,new其实就是调用malloc分配内存,而malloc的底层实现是HeapAlloc,但是这个HeapAlloc并没有进0环,而是通过在操作系统一开始用VirtualAlloc已经分配好的一大块空间里面取一块

限于篇幅,这里就不贴图逆向的过程了,这里我通过IDA跟踪mallocnew的调用过程,如下所示

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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

image-20220329185100666

image-20220329185134645

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

image-20220329185221622

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

image-20220329185318721

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

image-20220329185645999

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

image-20220329185813099

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

image-20220329200043202

无论是全局变量,局部变量,或者调用malloc函数,它都没有分配新的内存空间,只不过是使用了当前进程已有的内存空间

Mapped分配的内存分为两种,分别是共享物理页面和共享文件,类似于这种就是windows把自己的一些系统文件映射出来供所有进程使用

image-20220329200600029

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

image-20220329200838017

堆栈回溯

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

我们可以发现,在函数的调用过程中EBP寄存器总是保持不变,那么这里我们就可以通过逐级向调用函数、调用函数的调用函数进行遍历,向上回溯。根据堆栈结构和 CALL 指令的操作可知,在将属于调用函数的 EBP 的值压栈之前,ESP 指向的地址存储的是由 CALL 指令压栈的调用函数中调用位置的下一条指令的地址(原 EIP)。那么根据这个逻辑,可以通过上面回溯的各级 EBP 的值,并根据 EBP+sizeof(ULONG_PTR) 获取到函数调用者函数体中的地址(当前函数的返回地址)

一开始我的想法是基于TEB结构里面的StackBaseStackLimit的值进行判断,这两个值作为线程栈的范围存在,一般情况下StackBase在初始赋值之后就不会再改变,而 StackLimit 作为动态的成员域,根据当前线程函数调用层级的递进,以固定的长度向下扩展。根据规定,所属每个函数调用的 EBP ESP 寄存器所划定的空间,应该始终在当前线程的 StackLimit StackBase 的范围之间存在

image-20220507184741445

image-20220507184811029

但是经过实验后发现有一个需要注意的点就是,并不是所有的shellcode都会通过修改StackLimitStackBase使堆栈进行改变

这里经过查阅资料后发现栈信息的获取可以通过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
2
push ebp
mov ebp, esp

代码实现

那么这里我们了解了堆栈回溯的原理,我们来进行代码的编写,我们在前面分析了shellcode会通过VirtualAlloc/VirtualAllocEx去申请内存,得到一块private内存,具有可读可写可执行权限,那么我们就可以通过这个特征去定位vad树中的内存

这里就用到ZwQueryVirtualMemory 这个API,用来确定虚拟空间地址的状态保护和类型,结构如下

1
2
3
4
5
6
7
8
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

第三个参数MemoryInformationClass只能设置为MemoryBasicInformation,第四个参数指向MEMORY_BASIC_INFORMATION结构

那么这里我们首先定义MEMORY_BASIC_INFORMATION数组,在ntifs.h中导出,声明头文件即可

image-20220508093119299

1
MEMORY_BASIC_INFORMATION MBInformation[sizeof(MEMORY_BASIC_INFORMATION)] = { 0 };

通过NTSTATUS接收返回参数,成功则返回STATUS_SUCCESS,那么这里写一个判断

image-20220508093311789

1
2
3
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用来判断是否为可读可写可执行内存

1
2
3
4
5
6
7
8
9
10
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;

那么这里我们得出相应代码,首先判断是否为Mappedprivate,将写拷贝内存过滤掉

1
bool IsMemory = MBInformation->Type == MEM_PRIVATE || MBInformation->Type == MEM_MAPPED;

再判断是否为MEM_COMMIT

1
bool IsCommit = MBInformation->State == MEM_COMMIT;

然后判断具体为哪种权限的内存

1
2
bool IsExecute = MBInformation->Protect == PAGE_EXECUTE || MBInformation->Protect == PAGE_EXECUTE_READWRITE ||
MBInformation->Protect == PAGE_EXECUTE_READ || MBInformation->Protect == PAGE_EXECUTE_WRITECOPY;

然后整体相与,满足所有条件的内存才进行判断

1
2
bool IsResult = false;
IsResult = IsMemory && IsCommit && IsExecute;

我们在前面提到栈回溯是通过RtlWalkFrameChain这个函数实现的,我们先初始化一下

1
2
3
PVOID ary[MAX_PATH]={0}; 
ULONG StackCount;
StackCount = RtlWalkFrameChain(ary,MAX_PATH,1);

然后通过循环的方式遍历

1
2
3
4
5
6
7
8
9
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 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
2
if (KeGetCurrentIrql() != PASSIVE_LEVEL)
return;

然后调用栈回溯的检查函数来判断内存的栈是否被修改,如果修改则证明有shellcode的注入

1
if(stack_trace() == false)

判断出进程之后使用ZwTerminateProcess结束当前进程的所有线程并输出

1
2
3
DebugPrint("[!] Find shellcode inject , Process Name: %s\n",PsGetProcessImageFileName(PsGetCurrentProcess()));
ZwTerminateProcess(NtCurrentProcess(), 0);
DebugPrint("[√] Delete successfully\n");

使用PsSetLoadImageNotifyRoutine注册回调函数

实现效果

这里还是拿我们之前的exe进行测试,首先测试直接使用VirualAlloc申请的内存注入shellcode

image-20220508104440870

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

image-20220508104427058

然后加载驱动

image-20220508104538637

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

image-20220508104627657

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

image-20220508105326339

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

image-20220508105400309

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

image-20220508111739806

image-20220508111751801

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

image-20220508113005455

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

image-20220508113705476

这里再加载一下驱动

image-20220508115506314

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

image-20220508115540674

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

image-20220508115849524

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

image-20220508120025906

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

image-20220508193854117

执行一下

image-20220508194024229

同样被拦截,进程退出

image-20220508194001863

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

image-20220508210031815

运行仍然会被拦截

image-20220508210100209

卸载驱动后正常运行

image-20220508210306940

CATALOG
  1. 1. 测试
  2. 2. vad
  3. 3. 堆栈回溯
  4. 4. 代码实现
  5. 5. 实现效果