对抗无落地的shellcode注入


0x00 前言

一般的shellcode加载到内存都是通过LoadLibraryGetProcAddress来获取函数进行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内存管理里面,也只有这两种属性,分别是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已经批来的货里面(堆)买一部分走

我们试着分别在全局、堆、栈里面分配空间

// 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结构里面的StackBaseStackLimit的值进行判断,这两个值作为线程栈的范围存在,一般情况下StackBase在初始赋值之后就不会再改变,而 StackLimit作为动态的成员域,根据当前线程函数调用层级的递进,以固定的长度向下扩展。根据规定,所属每个函数调用的 EBPESP寄存器所划定的空间,应该始终在当前线程的 StackLimitStackBase的范围之间存在

image-20220507184741445.png

image-20220507184811029.png

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

这里经过查阅资料后发现栈信息的获取可以通过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就可以找到所有的函数地址

push ebp
mov ebp, esp

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;

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

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 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

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

卸载驱动后正常运行

image-20220508210306940.png

评论

W

wonderkun 2022-05-11 11:13:59

很强,记得huoji写过这个代码:https://github.com/huoji120/CobaltStrikeDetected/

W

wonderkun 2022-05-11 11:15:07

cs在堆上调用loadlibrary是硬伤啊。

Drunkmars

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

Exploit 文章:40 篇
数据分析与机器学习 文章:12 篇
网络协议 文章:18 篇
安全开发 文章:83 篇
业务安全 文章:29 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Yukong

🐮皮

H

HHHeey

好的,谢谢师傅的解答

Article_kelp

a类中的变量secret_class_var = "secret"是在merge

H

HHHeey

secret_var = 1 def test(): pass

H

hgsmonkey

tql!!!

目录