通过不落地实现反射注入shellcode到内存似乎是规避杀软hook api的一种好方式,但是无论如何某块内存的详细信息最终都会在vad里一览无余,那么vad作为内存管理的制高点,杀软自然也不会轻易放过。那么当我们反射注入执行的时候,如果杀软监控了vad树,就会捕捉到可疑内存从而查杀,那么我们要做的就是通过了解vad的机制来绕过杀软检测
vad
VAD是管理虚拟内存的,每一个进程有自己单独的一个VAD树,使用VirtualAlloc
申请一个内存,则会在VAD树上增加一个结点,其是_MMVAD
结构体
1 | dt _MMVAD |
这里找一个进程,因为是根节点所以没有父节点
然后往左遍历二叉树,在下一个节点处的父节点指向了上一个二叉树
注意StartingVpn
和EndingVpn
这两个结构,描述了当前页的位置,以4kb为单位,即0x400000到0x488000这一块内存空间已经被占用了
在0x18有一个ControlArea
结构,描述了这块结构体到底被谁占用,这里跟进去看0x24有一个FilePointer
结构,如果这里的值为0就是一个真正的物理页,如果有值继续往里面找
这里对应了Dbgview.exe
在操作系统里面分配的内存只可能有两种类型,一种是VirtualAlloc
自己分配的内存,一种是文件映射使用CreateFileMapping
的内存,当ControlArea
的FilePointer
值为空的时候则是我们自己用VirtualAlloc
分配的内存,还没有对应,如果值不为空则是文件映射的内存
分页机制
在32位里面有2-9-9-12
、10-10-12
两种分页模式,而在64位下只有一种分页模式,即9-9-9-9-12
分页模式
随着计算机技术的发展,64位系统逐渐占据主流地位,那么也就表示CPU的最大寻址范围为64位。但实际上,CPU只使用了其中的48位用于寻址,并使用9-9-9-9-12
分页模式。即便如此,在未来较长一段时间里,48位寻址范围也足够大部分人的日常使用了
9-9-9-9-12
分页表示物理地址拥有四级页表,在Intel开发手册中,将这四级页表分别称为PML4E
、PDPTE
、PDE
、PTE
,但微软的命名方式略有不同,将这四级页表分别称为PXE
、PPE
、PDE
、PTE
,WinDbg
中也是如此
启用分页模式条件:cr0.PG = 1
且 cr0.PE = 1
根据不同CPU架构及特性主要分为三种模式,处于哪种模式视寄存器属性不同:
- 32-bit paging(32位OS):
cr0.PG = 1
、cr4.PAE = 0
- PAE paging(32位OS且开启了PAE):
cr0.PG = 1
、cr4.PAE = 1
、IA32_EFER.LME = 0
- IA-32e paging(64位OS):
cr0.PG = 1
、cr4.PAE = 1
、IA32_EFER.LME = 1
需要注意的是:
- 32bit下,每个entry(表项)是4字节大小;而在PAE和IA-32e下,每个entry是8字节大小
- 在x64体系中只实现了48位的
virtual address
,高16位被用作符号扩展,这高16位要么全是0,要么全是1。所以在讨论64bit地址的时候,高16位不使用
我们主要研究的是IA-32e
模式下的内存,这里IA-32e
提供了三种页转换模型:
- 4k:PML4t,PDPT,PDT和PT
- 2M:PML4T,PDPT和PDT
- 1G:PML4T和PDPT
在4kb小页的情况下,64位可以拆分为一下几段,即9-9-9-9-9-12分页
sign extended – 符号扩展位 — 在线性地址48~63bit
PML4 entry – 在线性地址39~47bit用于索引PML4 entry,指向PDP
PDP entry – 在线性地址的30~38bit用来索引PDP entry,指向PDE
PDE entry – 在线性地址的21~29bit用来索引PDEentry,指向PTE
PTE entry – 在线性地址的12~20bit用来索引PTE entry,指向page offset
page offse t – 在线性地址的0~11bit提供在页中的offset
这里我们手动去找一下,前3位为符号扩展位,直接去掉
可以看到PXE
、PPE
、PDE
、PTE
都是能够对应上的
页表基址
- 一个进程该如何访问自己的物理页呢?可以通过读取Cr3的值进行访问吗?
答案是不行,Cr3中保存的页表基址是物理地址,程序如果直接访问这个地址,虽然看上去值是一样的,但实际上访问的是一个线性地址,会被虚拟内存管理器解析成另一个地址
实际上,操作系统会将当前进程的物理页映射在某个线性地址中,以供程序读取自己的页表内容
在x86系统中,页表基址是固定的,位于0xC0000000
,将这个线性地址进行解析,访问其物理页的内容,会发现从这个地址开始,里面保存的数据为当前程序的所有物理页地址
而在x64系统中,页表基址不再是固定的值,而是每次系统启动后随机生成的可以在WinDbg中查看0地址对应的线性地址来确定当前的页表基址
可以看到,当前系统的页表基址的线性地址为0xFFFFF38000000000
,注意,只有后48位才是有效地址
其中,每个物理页占8个字节,例如,第一个物理页地址位于线性地址0xFFFFF38000000000
,第二个物理页地址位于线性地址0xFFFF800000000008
,每个物理页中包含1024个字节的数据
MiIsAddressValid
我们在这里初步了解了windows的内存管理,那么这里我们去看一下windows是如何实现分页机制的,这里使用到MiIsAddressValid
这个API
我们首先看一下win7下MiIsAddressValid
的实现
shr eax, 14h
和and eax, 0FFC
相当于eax右移16位再乘以4,然后判断PS
位是否为0,如果为0则不合法,则将al清零
在64位下,存在三种不同大小的页面,分别为大页、中页、小页。其大小分别为1GB、2MB、4KB。这里判断al
不等于0则继续向下执行,这里jns
是通过判断SF=0
,如果SF=0
成立则跳转
这里仍然后一个右移的操作,是为了将段选择子分为9-9-9-9-12
五部分,然后判断P位是否有效和PAT
是否为1
这里其实看伪代码逻辑会更清晰一点,我们可以可以发现通过一系列的移位操作得到对应的PXE
、PPE
、PDE
、PTE
并判断P位验证是否有效
那么这里我们就可以通过减去的数值取反,然后加1即可得到对应基址,通过计算得到win7 64位下的PTE_Base = fffff68000000000
我们再去看一下win10下的``MiIsAddressValid
函数
1 | .text:00000001400AD930 _MmIsAddressValid proc near .text:000000014000FB6E |
在win10 1607
版本以后,微软更改了策略,将页目录基址更改为了随机地址,那么我们之前在win7里面直接定位PTE_Base
的方法就不可用,那么我们就可以使用提取特征码的方式去定位内核模块的地址
首先在WinDbg中定位内核模块的地址
然后在内核模块中搜索与当前页表基址相同的值出现的位置,当前页表基址为0xFFFF800000000000
接着,在IDA中定位到数据所在的位置,可以看到是某行代码引用了这个值的硬编码
在WinDbg中查看这段代码,能够识别到位于CcUnpinFileDataEx
函数。那么,由于系统每次启动时基址是不固定的,因此这些值也不可能是固定的硬编码,肯定对这些值进行了修改,在需要使用时,可以通过固定的偏移量提取硬编码,从而得到页表基址,但要注意不同版本的内核文件的偏移量可能是不同的
代码实现
那么这里我们首先编写4个函数分别定位PTE
、PDE
、PPE
、PXE
,这里g_PTE_BASE
就分为两种情况
1 | PULONG64 GetPteAddress(PVOID addr) |
当系统为win7或者win10 1607
以下版本的时候就可以直接将g_PTE_BASE
定义成固定的地址
1 | if (versionNumber == 7600 || versionNumber < 14393) |
如果为win10 1607
以上的版本就需要自己通过逆向的方式提取硬编码进行定位,这里我通过MmGetVirtualForPhysical
函数加偏移的方式进行定位
1 | UNICODE_STRING Name = { 0 }; |
那么这里要得到系统版本,就需要用到RtlGetVersion
进行判断,这里注意,在win7以后如果直接使用GetVersion
会失败,必须调用更底层的RtlGetVersion
才能得到具体的版本
1 | NTSTATUS status = RtlGetVersion(&version); |
这里我们明确一下思路,我们想要隐藏可执行内存,那么就可以首先申请一块可读可写的内存,然后通过修改PXE的最高位为0即可达到可执行的效果
例如下面的程序,PXE的最高位为8,则内存是没有可执行权限的
那么这里我们找到目标进程,然后通过KeStackAttachProcess
函数实现进程挂靠,即把自己的cr3换成目标进程的cr3
1 | NTSTATUS status = PsLookupProcessByProcessId(pid, &Process); |
我们将cr3切换为目标进程的cr3之后就可以使用ZwAllocateVirtualMemory
先分配一块可读可写的内存
1 | status = ZwAllocateVirtualMemory(NtCurrentProcess(), &BaseAddress, 0, &size, MEM_COMMIT, PAGE_READWRITE); |
通过RtlMoveMemory
写入shellcode并修改内存为可执行权限,这里我们直接定位到pte和pde修改即可将pxe的最高位置0
首先将前3位符号位去掉得到内存的起始地址和结束地址
1 | ULONG64 startAddress = VirtualAddress & (~0xFFF); |
这里写一个循环判断,必须每一块内存都需要修改
1 | for (ULONG64 i = startAddress; i <= endAddress; i += PAGE_SIZE) |
结合MmIsAddressValid
并判断valid
是否为1,这里如果valid
为0则该块内存无效,然后将no_execute
置0即可获得可执行权限
1 | PHardwarePte pde = GetPdeAddress(i); |
那么这里我们调用SetExecute
函数将我们之前分配的可读可写内存修改为可读可写可执行权限
1 | SetExecute(BaseAddress, size); |
然后使用KeUnstackDetachProcess
还原cr3
1 | KeUnstackDetachProcess(&kapc_state); |
实现效果
在64位下VadRoot
位于EPROCESS
结构体的7d8
偏移处
起一个notepad.exe
进程定位到vad
树
然后这里可以看到有97块内存
我们加载一下驱动,可以看到修改了pte
的值,将最高位的8改为了0,分配的这块内存地址为222F5EB0000
我们看下没有加载驱动之前vad树里面是没有这块内存的
加载驱动之后可以看到这是一块READWRITE
内存
这里定位到地址可以看到shellcode执行成功,证明这块内存已经修改为可执行内存,但是在vad树里面仍然显示为可读可写内存