安全矩阵

 找回密码
 立即注册
搜索
查看: 1743|回复: 0

对抗无落地的shellcode注入

[复制链接]

260

主题

275

帖子

1065

积分

金牌会员

Rank: 6Rank: 6

积分
1065
发表于 2022-6-29 23:44:56 | 显示全部楼层 |阅读模式
本帖最后由 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

代码如下
  1. ​void shellcode()
  2. {
  3.     PVOID p = NULL;
  4.     p = VirtualAlloc(NULL, sizeof(buf), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
  5.     if (p == NULL)
  6.         printf("VirtualAlloc error : %d\n", GetLastError());
  7.     else
  8.         printf("VirtualAlloc successfully , address : %x\n", p);

  9.     if (!memcpy(p, buf, sizeof(buf)))
  10.         printf("Write shellcode failed\n");
  11.     else
  12.         printf("Write shellcode successfully\n");

  13.     ((void(*)())p)();
  14. }

复制代码



这里我们在加载之前暂停一下看下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已经批来的货里面(堆)买一部分走
我们试着分别在全局、堆、栈里面分配空间


  1. // malloc1.cpp : Defines the entry point for the console application.
  2. //

  3. #include "stdafx.h"
  4. #include <windows.h>
  5. #include <stdio.h>
  6. #include <stdlib.h>

  7. int x = 0x1234;

  8. int main(int argc, char* argv[])
  9. {
  10.     printf("Before malloc");
  11.     getchar();

  12.     int y = 0x5678;
  13.     int* z = (int*)malloc(sizeof(int)*128);

  14.     printf("Golbal x : %x\n", &x);
  15.     printf("Heap y : %x\n", &y);
  16.     printf("Stack z : %x\n", z);

  17.     getchar();

  18.     return 0;
  19. }

复制代码



首先执行分配空间之前看一下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就可以找到所有的函数地址
  1. push ebp
  2. mov ebp, esp
复制代码


0x04 代码实现
那么这里我们了解了堆栈回溯的原理,我们来进行代码的编写,我们在前面分析了shellcode会通过VirtualAlloc/VirtualAllocEx去申请内存,得到一块private内存,具有可读可写可执行权限,那么我们就可以通过这个特征去定位vad树中的内存
这里就用到ZwQueryVirtualMemory这个API,用来确定虚拟空间地址的状态保护和类型,结构如下
  1. ​NTSYSAPI NTSTATUS ZwQueryVirtualMemory(
  2.   [in]            HANDLE                   ProcessHandle,
  3.   [in, optional]  PVOID                    BaseAddress,
  4.   [in]            MEMORY_INFORMATION_CLASS MemoryInformationClass,
  5.   [out]           PVOID                    MemoryInformation,
  6.   [in]            SIZE_T                   MemoryInformationLength,
  7.   [out, optional] PSIZE_T                  ReturnLength
  8. );
复制代码

编辑
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用来判断是否为可读可写可执行内存
  1. ​typedef struct _MEMORY_BASIC_INFORMATION {
  2.   PVOID  BaseAddress;
  3.   PVOID  AllocationBase;
  4.   ULONG  AllocationProtect;
  5.   USHORT PartitionId;
  6.   SIZE_T RegionSize;
  7.   ULONG  State;
  8.   ULONG  Protect;
  9.   ULONG  Type;
  10. } 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);
然后通过循环的方式遍历
  1. ​for (ULONG i = StackCount; i > 0; i--)
  2.     {
  3.         if (CheckVAD((PVOID)ary[i]))
  4.         {
  5.             DebugPrint("Stack : %d Address : %p \n", i, ary[i]);
  6.             bResult = false;
  7.             break;
  8.         }
  9.     }
复制代码

实现了判断内存和堆栈回溯的代码之后,我们就可以判断内存是否被无落地的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
  1. ​if (KeGetCurrentIrql() != PASSIVE_LEVEL)
  2.     return;
复制代码

然后调用栈回溯的检查函数来判断内存的栈是否被修改,如果修改则证明有shellcode的注入
if(stack_trace() == false)
判断出进程之后使用ZwTerminateProcess结束当前进程的所有线程并输出
  1. ​DebugPrint("[!] Find shellcode inject , Process Name: %s\n",PsGetProcessImageFileName(PsGetCurrentProcess()));
  2. ZwTerminateProcess(NtCurrentProcess(), 0);
  3. 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

卸载驱动后正常运行

编辑

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2024-3-28 21:28 , Processed in 0.018342 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表