腾讯游戏安全技术竞赛2026决赛题解

驱动分析

从三环程序中可以直接提取出驱动,我个人比较习惯先分析驱动,因此直接开始

image-20260418162653451

发现没有导入表,膨胀严重且驱动入口加了虚拟化,先选择hook一下常见api跑一下,但是导入表无了,这说明驱动是自己解析内核导出来调用的,不过一般驱动都会自己用MmGetSystemRoutineAddress解析几个导出来用,所以先hook一下跑一下日志来收集证据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
t!DbgBreakPointWithStatus:
fffff805`47c28610 cc int 3
0: kd> bp MmGetSystemRoutineAddress
0: kd> g
Breakpoint 0 hit
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx ss:0018:fffff88c`37a5f5a0=0000000000040286
1: kd> dt _UNICODE_STRING fffff88c37a5f5d0
nt!_UNICODE_STRING
"KdDebuggerEnabled"
+0x000 Length : 0x22
+0x002 MaximumLength : 0x24
+0x008 Buffer : 0xfffff88c`37a5f614 "KdDebuggerEnabled"
1: kd> g
Breakpoint 0 hit
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx ss:0018:fffff88c`37a5f5a0=9be7f70164f3dbf0
1: kd> dt _UNICODE_STRING fffff88c37a5f5d0
nt!_UNICODE_STRING
"KdDebuggerNotPresent"
+0x000 Length : 0x28
+0x002 MaximumLength : 0x2a
+0x008 Buffer : 0xfffff88c`37a5f63a "KdDebuggerNotPresent"
1: kd> g
Breakpoint 0 hit
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx ss:0018:fffff88c`37a5f5a0=9be7f70164f3dbf0
1: kd> dt _UNICODE_STRING fffff88c37a5f5d0
nt!_UNICODE_STRING
"KdDisableDebugger"
+0x000 Length : 0x22
+0x002 MaximumLength : 0x24
+0x008 Buffer : 0xfffff88c`37a5f5ee "KdDisableDebugger"
1: kd> g
Breakpoint 0 hit
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx ss:0018:fffff88c`37a5f5a0=9be7f70164f3dbf0
1: kd> dt _UNICODE_STRING fffff88c37a5f5d0
nt!_UNICODE_STRING
"KdEnableDebugger"
+0x000 Length : 0x20
+0x002 MaximumLength : 0x22
+0x008 Buffer : 0xfffff88c`37a5f666 "KdEnableDebugger"

1: kd> g
Breakpoint 0 hit
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx
nt!MmGetSystemRoutineAddress:
fffff805`47f56d20 48895c2408 mov qword ptr [rsp+8],rbx ss:0018:fffff88c`37a5f530=0000000000040282
1: kd> dt _UNICODE_STRING fffff88c37a5f5d0
nt!_UNICODE_STRING
"IoDriverObjectType"
+0x000 Length : 0x24
+0x002 MaximumLength : 0x26
+0x008 Buffer : 0xfffff88c`37a5f5f2 "IoDriverObjectType"

[HyperCharge-debug]:D:\UGit\HyperCharge2\src\kernelsys\ddma.cpp:ddma::Init:91 :failed to find disk,status : 0xc0000225
[HyperCharge-error]:D:\UGit\HyperCharge2\src\kernelsys\main.cpp:DriverEntry:36 :#1,status : 0xc0000225

对这里面和反调试相关的api下断点再跑一遍

1
2
3
Breakpoint 0 hit
nt!KdDisableDebugger:
fffff801`1f1673b0 4883ec28 sub rsp,28h

发现驱动确实在调用 KdDisableDebugger

将其ret后发现驱动调用 IoDriverObjectType ,因此可能是在和驱动对象通信

hook RtlInitUnicodeString 找一下字符串

1
2
3
4
5
6
7
8
ffff980b`b99975b6  5c 00 44 00 72 00 69 00-76 00 65 00 72 00 5c 00  \.D.r.i.v.e.r.\.
ffff980b`b99975c6 44 00 69 00 73 00 6b 00-00 00 20 00 22 00 00 00 D.i.s.k... ."...
ffff980b`b99975d6 00 00 66 76 99 b9 0b 98-ff ff 18 00 1a 00 00 00 ..fv............
ffff980b`b99975e6 00 00 b6 75 99 b9 0b 98-ff ff 37 44 49 00 6f 00 ...u......7DI.o.
ffff980b`b99975f6 44 00 72 00 69 00 76 00-65 00 72 00 4f 00 62 00 D.r.i.v.e.r.O.b.
ffff980b`b9997606 6a 00 65 00 63 00 74 00-54 00 79 00 70 00 65 00 j.e.c.t.T.y.p.e.
ffff980b`b9997616 00 00 a2 95 45 ac d9 23-28 7f 00 00 00 00 00 00 ....E..#(.......
ffff980b`b9997626 00 00 e2 05 00 00 00 00-00 00 a2 95 45 ac d9 23 ............E..#

很可能是在用 IoEnumerateDeviceObjectList 扫其下的设备

image-20260418164536002

果然发现命中,执行完就返回了失败逻辑,猜测其是做了某些校验

hook RtlCompareMemory 发现了比较逻辑一共比较两次,综合起来预期的返回值应当是Msft Virtual Disk

image-20260418164721418

伪造返回值,让其成功通过校验试试

image-20260418165523367
大量MmCopy 和 MmMapVideoDisplay(其实就是MmMapIOSpace) 后蓝屏了,传入的地址可以看出是物理地址,参数不断+0x1000扫页,驱动在拿到合适的磁盘设备后,进行了爆搜物理地址的操作

总结一下

驱动手动解析出一堆api,尝试获取一个 Msft Virtual Disk 的磁盘设备,成功后进行爆搜内核,现在给了驱动的一个fake返回值而蓝屏,目前不清楚驱动真正想要的设备是什么样的

根据以上的证据我找到了一个老项目

https://github.com/btbd/ddma

和 在运行时修改 Hyper-V 和SLAT绕过有关,先把题目往这方面想

获取 Msft Virtual Disk 的磁盘设备 可能是为了 访问/修改 Hyper-V 的某些函数,劫持某些功能,结合后面爆搜内核地址的逻辑来看,很可能存在特征码,因为固定偏移就不需要爆扫内核了。

在驱动中找相关的逻辑
image-20260418170520573

发现明显的特征码扫描特征
分析一下看看命中的是什么组件

直接用我的分析系统 22631

pattern

1
66 83 FE 01 75 0A E8 00 00 00 00 48 8B 4C 24 00 FB 8B D6 0B 54 24 00 E8 00 00 00 00 E9

mask

1
xxxxxxx????xxxx?xxxxxx?x????x 

patch_offset

1
0x17

命中了 hvix64 .text 的 RVA 0x23E407

image-20260418164000958

image-20260418171800519

里面似乎是VmExithandle

结合ida中有关扫内核和windbg的hook日志

image-20260418172653466

不难判断出驱动使用了一种类似 https://github.com/btbd/ddma 的技术来绕过 Hyper-v SLAT 搜索并定位相关 VmExithandle,因为现在还没有分析三环程序 我只猜测可能改了 VmExithandle 的hypercall之类的把校验逻辑藏在里面

既然大概的逻辑可以分析出来,接下来就是定位它往VmExithandle里面到底改了什么

尝试伪造磁盘信息骗过驱动

1
eb  56 69 72 74 75 61 6c 20 44 69 73 6b 20 20 20 20

之后却发现驱动总是失败退出或蓝屏,定位不到目标区域,在这里卡了好长一段时间

后来想到既然三环程序总归是能独立加载并拉起驱动的,说明三环程序有着让驱动能真正找到patch点的能力

因此转到三环进行分析

三环程序分析

从驱动入手可以基本分析得到可能是一个 Hyper-v SLAT bypass 因此首先想到三环程序可能是检测了 Microsoft Hv

搜一下 cpuid 果然如此

image-20260418181135416

sub_144A06650

image-20260418181209978

xor恢复结果

1
2
ModuleHandleA = GetModuleHandleA("ntdll.dll");
result = GetProcAddress(ModuleHandleA, "NtQuerySystemInformation");

逻辑如下

通过动态解密字符串后获取 firmware table 相关 API

枚举 ACPI / firmware table

扫描表项签名

若发现:

DMAR

IVRS
则返回成功

上述的两个函数检查了

是否运行在 Microsoft Hyper-V

是否存在 IOMMU / VT-d / AMD-Vi 相关 ACPI 表

(1)成功运行shadow_panel.exe

开启 Hyper-V 关掉 iommu 虚拟化 ,程序运行成功且成功加载了驱动

image-20260418181807913

根据前面的分析 三环程序大概率 是有创建 VHD 的功能,不过既然能成功加载驱动并正确使用其功能,我们可以继续进行驱动的分析

image-20260418182446076

字符串定位到加载驱动的逻辑,这里再system32/driver下面落地了一个随机名字的驱动,给他patch成固定的ACEDriver.sys方便我们在windbg调试时少打一条lm

驱动分析续

能成功启动三环程序说明对 Hyper-V 的修改是已经发生了,我们逆向一下对应的 读/写 函数所在位置,准备打断点分析

image-20260418184010674

image-20260418184027994

通过查找手动解析的发io请求的导入的调用位置 我们可以恢复出未被虚拟化的驱动读写函数

地址位于140003E60

其中 ddma_sptd_transfer_page 是底层函数,其调用的api如下图所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[I][GenericMonitorHandler():2948] Function: IoEnumerateDeviceObjectList
Hooked Address: fffff8011efc64b0
RCX: ffffaf0a39f9f960, RDX: ffffaf0a425fd2d0, R8: 8, R9: ffff980bb82371e4
Return Address: fffff8012de91bca

[I][GenericMonitorHandler():2948] Function: KeInitializeEvent
Hooked Address: fffff8011eeaf840
RCX: ffff980bb8236fb8, RDX: 0, R8: 0, R9: 67b35c77cbf19e2b
Return Address: fffff8012dea8788

[I][GenericMonitorHandler():2948] Function: IoBuildDeviceIoControlRequest
Hooked Address: fffff8011ee51430
RCX: 4d014, RDX: ffffaf0a39fa1060, R8: ffff980bb8236fd0, R9: 60
Return Address: fffff8012deaa6f3

[I][GenericMonitorHandler():2948] Function: IofCallDriver
Hooked Address: fffff8011ee2ef10
RCX: ffffaf0a39fa1060, RDX: ffffaf0a46767080, R8: ffffaf0a42469040, R9: f9ba63ab8ae24c95
Return Address: fffff8012deaaf53

[I][GenericMonitorHandler():2948] Function: KeWaitForSingleObject

其中构造

1
2
3
4
5
IOCTL_SCSI_PASS_THROUGH_DIRECT = 0x4D014
TransferLength = 0x1000
opcode:
0x28 = READ(10)
0x2A = WRITE(10)

发 IOCTL_SCSI 请求虚拟磁盘按页读写,加载驱动后发现多了 vhdmp 设备对象

image-20260419005729553

设备是 vhdmp 驱动就是用这个设备对象发 IOCTL_SCSI_PASS_THROUGH_DIRECT 进行类ddma读写

image-20260418184817939

驱动爆搜内核找 hvix64的 vmexithandle

如果 MmCopyMemory 读不到 就走 MmMapIoSpace+

ddma_disk_copy_page 的类ddma读写

查引用发现还有一个使用ddma_disk_copy_page 类ddma读写 的函数

image-20260418185420115

一共找到如下的ddma写入函数

1
2
3
bp ACEDriver+0x3E4C(单页读)
bp ACEDriver+0x500110页写)
bp ACEDriver+0x96BB (底层)

之后对于驱动的ddma读写函数都进行了hook来分析驱动是怎样劫持vmexit的

image-20260418200410783

dump出来一堆看似有用的垃圾数据
既然驱动要劫持vmexit 必然会写hyper-v地址空间

因此hook全部的ddma写函数来分析驱动ddma写入行为

image-20260418191156764

找到一个看起来像是pe的页写入,先保存下来

image-20260418200808247

经过长时间的调试 终于在 HyperCharge-debug 释放完日志后的一次ddma写入函数调用中找到了目标hyperv进程

image-20260418200940489

原本是0xcc的代码洞被驱动写入了一个stub

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ffff9600`8ff78406 50                   push    rax
ffff9600`8ff78407 53 push rbx
ffff9600`8ff78408 48bb00e0dfbf7fffffff mov rbx, 0FFFFFF7FBFDFE000h
ffff9600`8ff78412 48b80030150001000000 mov rax, 100153000h
ffff9600`8ff7841c 4883c803 or rax, 3
ffff9600`8ff78420 48898320030000 mov qword ptr [rbx+320h], rax
ffff9600`8ff78427 0f20db mov rbx, cr3
ffff9600`8ff7842a 0f22db mov cr3, rbx
ffff9600`8ff7842d 48bb0000e0ff7f320000 mov rbx, 327FFFE00000h
ffff9600`8ff78437 488b03 mov rax, qword ptr [rbx]
ffff9600`8ff7843a e800000000 call FFFF96008FF7843F
ffff9600`8ff7843f 58 pop rax
ffff9600`8ff78440 482500f0ffff and rax, 0FFFFFFFFFFFFF000h
ffff9600`8ff78446 4805a038fdff add rax, 0FFFFFFFFFFFD38A0h
ffff9600`8ff7844c 4881c380400000 add rbx, 4080h
ffff9600`8ff78453 488903 mov qword ptr [rbx], rax
ffff9600`8ff78456 5b pop rbx
ffff9600`8ff78457 58 pop rax
ffff9600`8ff78458 e84334fdff call FFFF96008FF4B8A0
ffff9600`8ff7845d e982ffffff jmp FFFF96008FF783E4

对比winddbg中原逻辑 发现他 call FFFF96008FF4B8A0 后 jmp FFFF96008FF783E4 jmp到了

FFFF96008FF4B8A0 的下一条指令处

这说明 call FFFF96008FF4B8A0 应该会被修改为 jmp ffff9600`8ff78406

FFFFFF7FBFDFE000当前没法访问 stub把

生成出来的 PDPT 物理地址 0x100153000 写到它的 +0x320

FFFFFF7FBFDFE000 无法访问,可能是hyper-v私有页表之类的

mov cr3后立刻去访问 0x327FFFE00000 这块隐藏虚拟窗口

0x327FFFE00000
= PML4[100] / PDPT[511] / PD[511] / PT[0]

0x320 / 8 = 100是 PML4 第 100 项

这说明 327FFFE00000 很可能就是这段stub带起来的逻辑,解题的关键就在 327FFFE00000 附近的可执行逻辑里面

1
2
3
4
5
call    FFFF96008FF7843F
pop rax
and rax, 0FFFFFFFFFFFFF000h
add rax, 0FFFFFFFFFFFD38A0h

随后解析了 原来Hyper-v的handle所在的地址 传到 0x327FFFE00000+0x4080

image-20260418202527640

继续找下一个写入点 果然stub被改了,这说明我们之前的分析是正确的

image-20260418203410189

FFFFFF7FBFDFE000这个地址我识别,在 Voyager 这个项目中也有使用
Voyager 是一个旨在为 AMD 和 Intel 版本的 Hyper-V 提供模块注入和 vmexit 钩子的项目

这说明我们分析的驱动逻辑基本上正确

image-20260418203641056

从voyager的源码来看 驱动应该也有一个映射器,这和我们前面观察到的一个类pe页相符合,驱动很可能早就将最终payload映射到了内存中

image-20260418203918417重新分析下驱动 果然在 14000E630 找到了对应的映射函数此函数 构造了新的 PT/PD/PDPT 三页页表,有明显识别pe的特征,并且映射后抹去了pe头,这解释了之前为什么只看到像pe但没找到pe头

image-20260418204302069

也能通过地址确定这就是我们找的释放最终payload的函数

image-20260418204059708

image-20260418191156764

打断点dump下来分析一下,可以看dump的驱动和我们在运行态抓到的写入操作完全匹配

image-20260418204347561
payload1偏移是 4080 与 前面提到过的 handle所在的地址 传到 0x327FFFE00000+0x4080 符合,确实有这么一个全局变量

驱动入口

只拦 CPUID VM-exit

命中 magic leaf 就自己处理

不命中就回原始 handler

image-20260418205352750

分析下140001000 逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
orig = payload_1; //前面stub传的原Hyper-V的vmexit handle指针
page = orig & ~0xFFF;

q500 = *(u64 *)(page + 0x500);
q508 = *(u64 *)(page + 0x508);

r8 = q500 ^ 0x5348414430574E54;
r9 = q508 ^ 0x4859504552564D58;

循环 8 轮 mixer

return (in1 == r8 && in2 == r9);

image-20260418205530419

sub_1400013A0 分析出来是 cpuid handle

0x114514分支

直接返回:

1
*a1 = 0x01919810; return 1; 

0x1919810分支

调 sub_140001000(*a2, *a4)

成功返回 [OK]

失败返回 [!!]

0xb16b00b5分支

调 sub_140001000(*a2, *a4)

成功时输出一个解码后的 token

失败时输出 “Access Denied”

成功 token 是:

1
ShadowCore Destroyed 

0xCAFED00D分支

调 sub_140001000(*a2, *a4)

失败时输出 “TRY AGAIN”

成功 token 是:

1
MISSION COMPLETE

虽然现在还没分析到对应三环最终校验证据,但是可以先保存下来,之后找三环逻辑会轻松不少

(2)「根」使用特殊方法,对操作系统底层进行了攻击,并借此将关键核心代码隐藏了起来,分析其完整实现流程。

现在已经能完整解答题目 (2) 了

「根」的实现不是把核心逻辑直接放在 Windows 驱动里,而是先由shadow_panel.exe加载驱动,驱动先找shadow_panel.exe创建的vhd之后用vhdmp进行读写,然后驱动解析内核导出并扫描 hvix 隐藏页,依据版本寻找不同特征码,在我分析机上通过vhdmp进行类 DDMA 的 SCSI 读过掉slat爆扫内核物理地址定位到hvix64!0x23E41E 这一 VM-exit 关键 call 点;

随后驱动通过vhdmp进行类 DDMA 的 SCSI 读写链,搬运隐藏页正文与payload代码,并由在相同函数中构造新的PT/PD/PDPT三页页表。之后驱动在 hvix 目标页中植入 stub,并再次使用通过vhdmp进行的类 DDMA写入把原始call改成jmp stub。当 Hyper-V 后续自然执行到该 VM-exit 路径时,执行流即被导向 stub;

stub 再将0xFFFFFF7FBFDFE000+0x320(是Hyper-v在510处的自映射)处的 PML4 项改为驱动准备好的 PDPT,刷新 CR3,使0x327FFFE00000隐藏映射窗口生效,并进一步call原handle把执行流切到真正的payload。最终,核心功能不再依赖原始驱动,而是在 hvix / VMX-root 的匿名驻留页中长期存在并运行,因此驱动卸载后功能仍在,模块枚举也难以发现,从而完成了对操作系统底层的攻击与关键核心代码的隐藏。

三环程序分析续

分析驱动已经知道是用的特殊cpuid做的隐蔽通信,因此直接找就行

image-20260418212050094

cpuid(EAX=0xDEADBEEF, ECX=0x114514)EAX 必须等于:0x01919810

剩下两个cpuid似乎在虚拟化路径中,但是不影响解题,因为驱动失败提示已知 第一次 Access Denied对应B16B00B5 第二次自然对应0xCAFED00D

因此恢复处三环程序cpuid校验过程

0x114514 -> 0x01919810 -> 0xB16B00B5 -> 0xCAFED00D

(1)成功部署条件

这里三环程序的成功部署条件也全部分析出来了

1.运行在 Microsoft Hyper-V

2.关掉 iommu 虚拟化,因为如果启用了 IOMMU,Hyper-V 也会通过它隐藏自身,使其免受运行时 DMA 攻击。

3.管理员运行以加载驱动

4.cpuid(EAX=0xDEADBEEF, ECX=0x114514)EAX 必须等于:0x01919810

(4)计算出正确的终止密码,输入到shadow_panel.exe中,使得其返回成功。

1
2
3
4
5
6
7
8
9
10
11
12
orig = payload_1;
page = orig & ~0xFFF;

q500 = *(u64 *)(page + 0x500);
q508 = *(u64 *)(page + 0x508);

r8 = q500 ^ 0x5348414430574E54;
r9 = q508 ^ 0x4859504552564D58;

循环 8 轮 mixer

return (in1 == r8 && in2 == r9);

自己手动按分析出的逻辑从hvix64提出payload_1

1
2
handle地址 + 0x500 q500 = 0x33574D8B48000000ULL;
handle地址 + 0x508 q508 = 0x8B4C2824448948D2ULL;

写个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
MASK = (1 << 64) - 1

q500 = 0x33574D8B48000000
q508 = 0x8B4C2824448948D2

X1 = 0x5348414430574E54
X2 = 0x4859504552564D58
K1 = 0x9E3779B97F4A7C15
K2 = 0x40A7B892E31B1A47

def rol64(x, n):
return ((x << n) | (x >> (64 - n))) & MASK

print(f"q500 = 0x{q500:016X}")
print(f"q508 = 0x{q508:016X}")

r8 = q500 ^ X1
r9 = q508 ^ X2

print(f"init r8 = q500 ^ X1 = 0x{r8:016X}")
print(f"init r9 = q508 ^ X2 = 0x{r9:016X}")
print()

for i in range(8):
t1 = (rol64(r9, 13) * K1) & MASK
r8 = (r8 + t1) & MASK

t2 = (rol64(r8, 29) - K2) & MASK
r9 ^= t2

r8 ^= (r9 >> 17)
r9 = (r9 + ((r8 << 7) & MASK)) & MASK

print(f"round {i+1}:")
print(f" r8 = 0x{r8:016X}")
print(f" r9 = 0x{r9:016X}")
print()

password = f"{r8:016X}{r9:016X}"

print(f"secret1 = 0x{r8:016X}")
print(f"secret2 = 0x{r9:016X}")
print(f"PASSWORD = {password}")

成功得到密码

1
PASSWORD = C453CA26984755FD16FA037F03D99807

成功截图

image-20260418225825913

(5) 编写keygen,使得在任意机器,任何一次运行shadow_panel.exe,都可以正确计算出终止密码。

先把驱动中全部特征码匹配逻辑 找出来,然后写keygen先获取版本号,匹配特征码匹配逻辑,寻找磁盘上hvix64.exe并处理偏移问题,之后计算出正确终止密钥。

image-20260419161900092

后面还有一段amd的 ida没识别出来

image-20260419162942723

特征码逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
intelcpu hvix64.exe
1)build >= 22621
分支入口:
xxxxxxx????xxxx?xxxxxx?x????x
对应 pattern bytes:66 83 FE 01 75 0A E8 00 00 00 00 48 8B 4C 24 00 FB 8B D6 0B 54 24 00 E8 00 00 00 00 E9

2)19041 <= build < 22621
pattern:0x140021070
长度:0x19
patch offset:0x13
mask:
xxxxxxxxxxxxx?xxxx?x????x
pattern:
65 C6 04 25 6D 00 00 00 00 48 8B 4C 24 00 48 8B 54 24 00 E8 00 00 00 00 E9

3)17763 <= build < 19041
pattern:0x140021090
长度:0x19
patch offset:0x13
mask:
xxxx?xxx????xxxxxx?x????x
pattern:
48 8B 4C 24 00 EB 07 E8 00 00 00 00 EB F2 48 8B 54 24 00 E8 00 00 00 00 E9

4)17134 <= build < 17763
pattern:0x1400210B0
长度:0x19
patch offset:0x13
mask:
xxxxxxx?xx????xxxx?x????x
pattern:
F2 80 3D FC 12 46 00 00 0F 84 00 00 00 00 48 8B 54 24 00 E8 00 00 00 00 E9

5)10586 <= build < 17134
pattern:0x1400210D0
长度:0x19
patch offset:0x13
mask:
xx????x?xx????xxxx?x????x
pattern:
D0 80 00 00 00 00 00 00 0F 84 00 00 00 00 48 8B 54 24 00 E8 00 00 00 00 E9

6)10240 <= build < 10586
pattern:0x1400210F0
长度:0x19
patch offset:0x13
mask:
xxxxxxxxxxxxxxx????x????x
pattern:
60 C0 0F 29 68 D0 80 3D 7E AF 49 00 01 0F 84 00 00 00 00 E8 00 00 00 00 E9
7)build < 10240

amd分支 hvax64.exe
E8 00 00 00 00 48 89 04 24 E9
"x????xxxxx"

直接用ai

prompt:

1
我找出了全部特征码匹配逻辑并落到了readme.md 将现在脚本改成根据版本进行特征码匹配找seed 要满足 使得在任意机器,任何一次运行shadow_panel.exe,都可以正确计算出终止密码。用py编写

完整代码见keygen.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def main(argv: Optional[Iterable[str]] = None) -> int:
args = parse_args(argv)

if args.cpu:
cpu = args.cpu
vendor_text = f"manual:{cpu}"
else:
cpu, vendor_text = detect_cpu_from_registry()

build = args.build if args.build is not None else detect_build_from_registry()

if args.file_path:
file_path = Path(args.file_path)
else:
file_path = get_windows_hv_file(cpu)

sig = select_signature(cpu, build)
try:
pe = parse_pe(file_path)
except ValueError:
print(f"failed to parse PE: {file_path}", file=sys.stderr)
return 1

hits = mask_search_all(pe.data, sig)
if not hits:
print(f"signature not found: {sig.name}", file=sys.stderr)
return 1

match_raw = hits[0]
patch_raw = match_raw + sig.patch_offset
if patch_raw + 5 > pe.size or pe.data[patch_raw] != 0xE8:
print("selected call site is not E8", file=sys.stderr)
return 1

try:
match_rva = pe.raw_to_rva(match_raw)
patch_rva = pe.raw_to_rva(patch_raw)
except ValueError:
print("failed to convert raw offset to RVA", file=sys.stderr)
return 1

rel32 = struct.unpack_from("<i", pe.data, patch_raw + 1)[0]
handler_rva = (patch_rva + 5 + rel32) & 0xFFFFFFFF
seed_page_rva = handler_rva & 0xFFFFF000

try:
q500_raw = pe.rva_to_raw(seed_page_rva + 0x500)
q508_raw = pe.rva_to_raw(seed_page_rva + 0x508)
except ValueError:
print("failed to read seed qwords", file=sys.stderr)
return 1

if q500_raw >= pe.size or q500_raw + 8 > pe.size or q508_raw >= pe.size or q508_raw + 8 > pe.size:
print("failed to read seed qwords", file=sys.stderr)
return 1

q500 = struct.unpack_from("<Q", pe.data, q500_raw)[0]
q508 = struct.unpack_from("<Q", pe.data, q508_raw)[0]
part1, part2 = derive_key_parts(q500, q508)

key_hex = f"{part1:016X}{part2:016X}"
key_le = (part1.to_bytes(8, "little") + part2.to_bytes(8, "little")).hex().upper()

运行结果 driver:19041+

image-20260419163627505

image-20260419163610537

不同机器运行结果

image-20260419163659635

确实能兼容驱动适配的对应版本

(3) 编写检测代码,检测(2)中「影」核心系统攻击操作系统底层的特殊方法。

是对类 DDMa 读写的检测,自然想到监控三环设备创建虚拟磁盘和驱动向虚拟磁盘发请求等

驱动侧检测

可在内核中监控以下行为:

枚举设备检测

Hook \Driver\Disk 的 IRP_MJ_DEVICE_CONTROL,识别:

IOCTL_SCSI_PASS_THROUGH_DIRECT (0x4D014),CDB0 == 0x28 / 0x2A,DataTransferLength == 0x1000,LBA == 0,blocks == 8

vhdmp读写检测

Hook \Driver\vhdmp 的 IRP_MJ_INTERNAL_DEVICE_CONTROL,识别:

vendor=’Msft’ , product=’Virtual Disk’,READ10 / WRITE10,xfer == 0x1000,LBA == 0,blocks == 8

告警规则

若短时间内在同一 Msft Virtual Disk 上出现大量:

READ10/WRITE10 4KB LBA=0 blocks=8

则可判定为样本使用虚拟盘执行页级运输/DDMA-like 访问。

测试结果

image-20260419024020259

1
identity via='vhdmp' dev=FFFFDC0C43028050 vendor='Msft' product='Virtual Disk' exactMsftVirtualDisk=1

可以发现探测到了Msft Virtual Disk 这一vhd的选中

在 nt!KdDisableDebugger 调用前还没进入爆扫内核路径,在调用后立马出现

1
[ALERT] DDMA-like translated 4KB page transport detected via='vhdmp' dev=FFFFDC0C43028050 vendor='Msft' product='Virtual Disk' exactMsftVirtualDisk=1

说明确实探测到了驱动发大量 IRP_MJ_INTERNAL_DEVICE_CONTROL来读写的操作

部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static VOID TryHookKnownStorageDrivers(VOID)
{
InstallHookByName(L"\\Driver\\Disk", TRUE, FALSE);
InstallHookByName(L"\\Driver\\vhdmp", FALSE, TRUE);
}

static VOID ImageLoadNotify(
_In_opt_ PUNICODE_STRING FullImageName,
_In_ HANDLE ProcessId,
_In_ PIMAGE_INFO ImageInfo
)
{
CHAR baseName[MAX_ID_LEN];
UNREFERENCED_PARAMETER(ProcessId);

if (!ImageInfo || !ImageInfo->SystemModeImage) {
return;
}

CopyUnicodeBaseNameToAnsi(baseName, sizeof(baseName), FullImageName);

if (_stricmp(baseName, "disk.sys") == 0) {
InstallHookByName(L"\\Driver\\Disk", TRUE, FALSE);
} else if (_stricmp(baseName, "vhdmp.sys") == 0) {
InstallHookByName(L"\\Driver\\vhdmp", FALSE, TRUE);
}
}

用户态检测

抓:

CreateVirtualDisk OpenVirtualDisk AttachVirtualDisk

方式是:

Frida 注入目标进程

监控 virtdisk.dll 加载对这 3 个导出函数挂钩,打印调用参数和返回值

结果

image-20260419033349607

可以看到确实有初始化 Virtual Disk 的api调用

提交文件说明

keygen.exe - (5)适配不同环境 计算出终止密码的程序 直接命令行运行即可

keygen.py - (5)适配不同环境 计算出终止密码的源码

demo.py - (4)计算出分析机正确的终止密码的脚本

sptd_disk_filter.sys - (3)问的检测驱动,开日志启动服务后运行shadow_panel.exe即可检测到Msft Virtual Disk的初始化,驱动密集读写操作,和类ddma发的ioctl请求

所用windbg命令

1
2
3
bp KdDisableDebugger
ed nt!Kd_DEFAULT_MASK 0xFFFFFFFF
ed Kd_IHVDRIVER_Mask 0xFFFFFFFF

sptd_disk_filter.c -(3)问检测驱动源码

monitor_virtdisk_api.py.exe -(3)问用户态检测程序 用法:管理员终端 .\monitor_virtdisk_api.py.exe --spawn ..\shadow_panel.exe --duration 0 --log .\virtdisk_api.log

monitor_virtdisk_api.py -(3)问用户态检测程序 源码


腾讯游戏安全技术竞赛2026决赛题解
http://relostaka.github.io/2026/04/24/tencent-game-security-competition-2026-finals-writeup/
作者
relost
发布于
2026年4月24日
许可协议