整理笔记发现之前整理的一些内存隐藏方式,考虑到现在用这些方法的外挂已经很少了(全是VT、UEFI起手),所以小发一手可以一起学习一下
VAD节点合并
VAD(Virtual Address Descriptor)虚拟地址描述符,是用来管理Windows进程地址空间的,其可以描述一段连续的地址范围。Windows用一颗平衡二叉树(AVL树)管理VAD对象,每个进程拥有一颗VAD树,其树的根节点指针(VadRoot)保存在进程的EPROCESS中,而进程的每一个内存块(包含VirtualAlloc申请的私有的和Mapping共享的)都对应一个VAD树结点(_MMVAD)。可以通过对进程的VAD树结构进行调整以达到隐藏某块内存的目的。
可以将待隐藏的VAD节点融合到前一个节点上,其属性将显示为前一个节点的属性。常规手段是向前找一个NoAccess属性的父节点,并将待隐藏节点及其到目标父节点之间的所有节点均融合进去,这样就可以将待隐藏内存块显示为No_Access属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | nStatus = BBFindVAD(pProcess, parent_address, &pVadShort_parent);
nStatus |= BBFindVAD(pProcess, address, &pVadShort_target);
if (!NT_SUCCESS(nStatus))
return nStatus;
if (pVadShort_parent && pVadShort_target && pVadShort_parent != pVadShort_target)
{
KIRQL Irql;
HIDE_VAD vad = { 0 };
vad.pid = pid;
vad.address = address;
vad.pVadShort = pVadShort_parent;
vad.StartingVpn = pVadShort_parent->StartingVpn;
{
combined_vad_list.emplace_back(vad);
pVadShort_parent->StartingVpn = pVadShort_target->EndingVpn;
}
nStatus = STATUS_SUCCESS;
}
|

这种方式需要在进程退出时,将融合的节点还原(不然会出现内存管理错误而BSOD)
VAD节点挂靠不存在的地址范围
VAD节点中,最关键的两个字段是StartingVpn和EndingVpn,分别指示该VAD节点所指示的地址空间范围。在VAD节点合并中,是将父节点的StartingVpn修改成子节点(待隐藏节点)的EndingVpn以实现合并,而挂靠不存在地址范围的意思是将待隐藏节点的VAD.StartingVpn和VAD.EndingVpn修改成一个不存在的虚拟地址,比如修改成0。这样的话,本来这个VAD节点是管理我们待隐藏的那块虚拟地址内存的,修改后就变成管理一个不存在的地址了,而我们那块待隐藏的节点因为没有VAD结构指向它了,也就无法被常规的API内存枚举遍历到了,从而实现了内存隐藏。这种方式有点类似于VAD Unlink,都是目标内存区域不再有VAD节点管理,但是又不太一样,因为我们并没有对VAD树上的节点做摘除,只是修改了其字段值导致其管理的目标区域被改变。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | if (KernelOffsets::BuildNumber != 18362 && KernelOffsets::BuildNumber != 18363) {
KIRQL Irql;
HIDE_VAD vad = { 0 };
vad.pid = pid;
vad.address = address;
vad.pVadShort = pVadShort;
vad.StartingVpn = pVadShort->StartingVpn;
vad.EndingVpn = pVadShort->EndingVpn;
vad.Protection = pVadShort->u.VadFlags.Protection;
KeAcquireSpinLock(&unlink_vad_list_spin_lock, &Irql);
{
unlink_vad_list.emplace_back(vad);
pVadShort->StartingVpn = 0;
pVadShort->EndingVpn = 0;
pVadShort->u.VadFlags.Protection = MM_ZERO_ACCESS;
kprintf("[QSafe] : Unlink VAD : 0x%llx Success!\n", address);
}
KeReleaseSpinLock(&unlink_vad_list_spin_lock, Irql);
}
|

同样这种方式需要在进程退出时还原被修改的VAD节点,否则会BSOD
PTE.USER(高位注入)
之前非常流行的注入方式。其核心原理是:驱动申请内核内存,设置PTE/PDE对某进程(游戏进程和外挂进程)可见(supervisor位),然后设置X位使其具有执行权限,再过掉各版本Windows对它的亿点点检测就好了。这里只介绍方法原理,具体的代码实现Github上有很多开源的仓库可以参考。
实现步骤
Alloc Kernel MDL
高位注入,shellcode/Dll是跑在内核空间的,所以需要先用驱动申请一块内核的内存然后MmMapLockedPagesSpecifyCache指定PreviousMode为KernelMode映射到内核空间。
关于内存申请的方法有两种,即申请分页内存和非分页内存,申请这两种内存也有各自的优缺点
申请非分页内存,过大可能因为内存不足而申请失败;隐匿性较差(扫系统具有us位的非分页内存就可以把这类内存抓出来)
申请分页内存,在换页时,us位和X位会被操作系统恢复。即当换页发生时,操作系统会根据VAD的属性为内存页还原正常的属性位(可以通过锁页和Hook换页相关的API解决
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | MDL_INFORMATION memory;
PHYSICAL_ADDRESS lower, higher;
lower.QuadPart = 0;
higher.QuadPart = 0xffff'ffff'ffff'ffffULL;
const auto pages = (size / PAGE_SIZE) + 1;
const auto mdl = MmAllocatePagesForMdl(lower, higher, lower, pages * (uintptr_t)0x1000);
if (!mdl)
return { 0, 0 };
const auto mapping_start_address = MmMapLockedPagesSpecifyCache(mdl, KernelMode, MmCached, NULL, FALSE, NormalPagePriority);
if (!mapping_start_address)
return { 0, 0 };
memory.mdl = mdl;
memory.va = reinterpret_cast<uintptr_t> (mapping_start_address);
return memory;
|
Attach Process & Set Supervisor
申请到内核内存后,需要将该内存页挂靠到用户态进程上,为了使用户态进程对其可见,需要对内存页的PTE/PDE设置supervisor(user)位
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | for (uintptr_t address = kernel_address; address <= kernel_address + size; address += 0x1000)
{
const PAGE_INFORMATION page_information = get_page_information((void*)address, cr3);
page_information.PDE->Supervisor = 1;
page_information.PDPTE->Supervisor = 1;
page_information.PML4E->Supervisor = 1;
if (!page_information.PDE || (page_information.PTE && !page_information.PTE->Present))
{
}
else
{
page_information.PTE->Supervisor = 1;
}
}
|
ByPass NX
这样申请到的内存不具有可执行权限,需要为其设置可执行权限。可以在申请内存的时候调用 MmProtectMdlSystemAddress(mdl, PAGE_EXECUTE_READWRITE); , 但似乎这个API经常不成功,比较稳妥的是直接为PTE/PDE的ExecuteDisable位置0
1 2 | page_information.PDE->ExecuteDisable = 0;
page_information.PTE->ExecuteDisable = 0;
|
并且保证CR3的63位为0
1 2 3 4 | CR3 cr3{ };
cr3.Flags = __readcr3();
cr3.Flags &= 0x7FFFFFFFFFFFFFFF;
__writecr3(cr3.Flags);
|
Flush TLB
TLB是一块高速缓存,其缓存虚拟地址和其映射的物理地址。硬件存在TLB后,虚拟地址到物理地址的转换过程发生了变化。虚拟地址首先发往TLB确认是否命中cache,如果cache hit直接可以得到物理地址。否则,一级一级查找页表获取物理地址。并将虚拟地址和物理地址的映射关系缓存到TLB中。 而我们对页表结构进行了更改,需要刷新TLB避免出现和缓存的不同步问题,刷新TLB实际就是使TLB无效化,下一次再进行虚拟地址到物理地址转化时需要从页表一级一级向下找并将结果重新填回缓存。
有一条特权级指令为我们实现了这个功能,即invlpg。再每次修改PDE/PTE属性后,调用一下该指令刷新TLB即可
1 | __invlpg((void*)address);
|
Lock MDL
如前面所说,申请分页内存,在换页时,us位和X位会被操作系统恢复。即当换页发生时,操作系统会根据VAD的属性为内存页还原正常的属性位。
需要对内存页进行锁页,并在进程结束前恢复。
Disable SMAP
通过前面的步骤,可以在Windows10 1809(17163)及之前的版本注入成功,但是从Windows10 1903(18362)开始多了很多保护
SMAP(Supervisor Mode Access Prevention,管理模式访问保护) : 禁止内核CPU访问用户空间的数据
SMEP(Supervisor Mode Execution Prevention,管理模式执行保护) : 禁止内核CPU执行用户空间的代码
设计这两种保护是为了防止恶意程序提权到0环后去执行布置在三环的恶意Shellcode。而实际上,在高位注入中,是从三环跳过来运行0环的代码,所以实际上只需要关闭SMAP而不需要关闭SMEP,因为我们没有在内核执行Ring3的代码,但是我们需要在内核访问Ring3的代码。
关SMAP有两种方法,一种直接暴力改CR4的SMAP_ENABLE_FLAG位
1 2 3 4 | CR4 cr4{};
cr4.Flags = __readcr4();
cr4.Flags &= ~CR4_SMAP_ENABLE_FLAG;
__writecr4(cr4.Flags);
|
还有一种标准关闭方法,即先用stac指令标准关闭,后续再通过clac标准打开

ByPass MiCheckProcessShadow
在Windows 10 1903(18362)+ 上运行注入,会马上出现这样的蓝屏

BSOD提示在MiCheckProcessShadow函数中发生了 KeBugCheckEx 0x1A, 0x3604 的错误,分析其报错检测,该函数会检测PXE,即,当前确实一定是在Kernel CR3而非用户CR3,目的是防止恶意代码对内核内存自己加us位。
对于这种检测有两种处理方式
Hook MiCheckProcessShadow :其实只需要 eb MiCheckProcessShadow C3 将其return就好了
管理员身份运行程序 : 在Windows10上以管理员身份运行程序,API便不会进行校验
ByPass KVAS
对Windows KVAS(Kernel Virtual Address Shadow,内核虚拟地址影子)机制的分析,参考资料
Windows KVAS
Disable VBS
基于虚拟化的安全性 (VBS) 使用 Windows 管理程序将主内存的一部分与操作系统的其余部分虚拟隔离。 Windows 使用这个隔离的、安全的内存区域来存储重要的安全解决方案,例如登录凭据和负责 Windows 安全的代码等。
这一安全机制的本意是防止内核级的恶意程序, 因为内核级的恶意程序可以访问操作系统的所有资源而使所有程序和操作系统本身均无感。所以这一安全机制通过Hypervisor实现更高的一层权限来阻止这一类操作。
高位注入无法在开启 基于虚拟化的安全性(VBS) 的计算机中使用。(在目前版本的Windows下,这个选项还是默认关闭的

Handle PF
高位注入后,在使用很多API时会出现PF。原因是很多API内会做一些检测,比如对参数地址的检测(是否在MmUserProbeAddress范围内),所以在release后需要进行单元测试,对可能的PF进行处理修复。
PS:还有个办法就是完全不用API,所有操作均由自己直接与内核通信实现
局限性
- 无法在32位机器上运行
- 被注入进程必须是特权级进程(以管理员身份运行的)
- 不支持大页
- 被注入Dll不可直接使用API:因为注入在高位,使外部依赖库的加载和导入表重定位的修正变的非常困难。难以像正常注入一样对外部依赖库调用LoadLibrary并修正IAT。可以使用PEB->ldr链并getProcAddrByHash直接获得API函数地址使用
- 资源、异常、TLS无法使用(内存加载Dll的通病)
- 全局变量不可使用 : 内存在内核地址里,其全局变量无法通过任何的NTAPI检查
- 注册异常处理函数处理可能发生的PF
Session Space
22年的时候发现一些样本申请的高位内存,对内核不可见(驱动直接访问该内存无法访问),需要挂靠到指定进程下,才可以访问该内存。分析发现一些样本利用了Session Space原理,实现了内存的隐藏。
Windows下虽然只使用了Ring0和Ring3,但是实际上也有任务会话层的概念在其中,并有会话隔离的概念。不同会话之间的会话内存不可共享,就像三环应用程序之间不可以共享私有内存一样。通常情况下,在单用户单桌面时,Windows下只有两个Session Id,也就是0和1。Session Id为0的进程是System和非GUI进程,而Session Id为1的进程则是lsass、csrss、svchost和GUI进程,这类进程又被称为Session进程。由于游戏进程通常是GUI进程,此时Session Id是1;而在内核扫描内存时是挂靠在System进程下的,此时Session Id为0,就无法扫描到这类挂靠在 Session Id = 1 下的内存,从而实现隐藏。
而同SessionId下的SessionSpace是共享的,故可以创建一个单独的SessionId,将外挂进程和游戏进程挂靠在该Session下,创建SessionSpace使得外挂于游戏共享该片区域,实现注入。同时申请的这个区段可以是高位的内核内存,并与PTE.USER注入相结合,实现出对内核(System进程)不可见的高位注入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | nStatus = Utils::AttachProcess(pid);
if (!NT_SUCCESS(nStatus))
return nStatus;
LARGE_INTEGER maximum_size;
maximum_size.QuadPart = size;
nStatus = MmCreateSection(§ion, SECTION_ALL_ACCESS, NULL, &maximum_size, PAGE_EXECUTE_READWRITE, NULL, NULL, NULL);
if (!NT_SUCCESS(nStatus))
return nStatus;
nStatus = MmMapViewInSessionSpace(section, reinterpret_cast<PVOID*>(mapped_base), &size);
if (!NT_SUCCESS(nStatus))
return nStatus;
memset((PVOID)*mapped_base, 0, size);
Utils::DetachProcess();
|
参考资料
VAD(Virtual Address Descriptor)虚拟地址描述符 学习笔记
x64下隐藏可执行内存
看雪-如何在驱动层完美隐藏内存
PTE.USER PresentInjector
invlpg windows 如何使用
X64内核 SMAP,SMEP
随手写:X86 PCID和TLB flush
PCID & 与PTI的结合
Windows 中基于虚拟化的安全性