前言:决赛打完再回顾提前开好的香槟,觉得自己很懂虚拟化,结果被ACE团队爆杀,我还是孤陋寡闻了,判断出HYPER-V被劫持,但是不知道怎么题目是怎么实现的,我内核驱动遍历页表都读不出HYPER-V的内存(EFI的hv不会向Windows映射这段内存),想不通怎么从GUEST读到HOST内存(后面想明白应该在虚拟机里做这个题,可以直接秒杀),DDMA都没看出来,只做了一半,还是得好好沉淀好好学习。
这个初赛WrtiteUP是提交上去的版本稍微润色,第一次参加CTF,思路可能有不足,题解仅代表我自己的思路。
0. 题目总体分析与解题思路
打开压缩包一看,上来就是内核驱动。一个 4M VM 混淆的 sys,旁边站着一个体积还没它大的控制台程序。R3 发指令、驱动做读写。
本题核心考点:检测 IOCTL (我觉得对于写外挂,IOCTL通信是很原始的做法,真实反作弊会在IOCTL相关函数注册回调)
程序跑起来之后,老老实实告诉了我一切:

图 0-1: ShadowGateApp 运行界面 —— 题目把规则说得明明白白
关键信息提取:
· 驱动里藏了个 13×13 的加密迷阵,入口 (0,0),出口 (12,12)
· "The palace gives NO feedback" —— 表面上不给任何移动结果反馈
· "Five hidden flaws betray the result of every move" —— 但存在 5 个隐藏的信息泄露缺陷
· 每次 RESET 后,前 5 步成功移动依次暴露一种缺陷
· 终极目标:找到最短路径,走到终点提取内网凭证(Flag)
0.1 题目设计意图
题目的核心设计:驱动核心逻辑被 VMP保护,选手只能看到接口和调用规则。在"黑盒"条件下,通过分析 5 条侧信道来摸清迷宫布局,最终提取 Flag。
但文件到了选手的电脑上,自然是各显神通。除了题目设计的纯侧信道路线外,直读内核内存(甚至直接 kdmapper 加载一个读写驱动都行)、DMA(遍历 PTE 页表读物理内存)、池标签搜索……都可以直接拿到迷宫真值。
0.2 我的两条路线
路线 A — 纯传统逆向 + 侧信道探测:
IDA 逆向 IOCTL 协议 → 导入表发现 5 种侧信道 → 编写检测脚本 → EXIT_MARKER 验证法精确探测 → DFS探索地图+最短路径 → 取 Flag
路线 B — 自研 Hypervisor 牛刀杀鸡直接读内存:
Hypervisor → 读驱动 g_MazeContext 内核内存(实际上这一步既读了完整的地图,也读了最短路线)→ 把读出来的编码转换成地图(0=通道,1=墙)→ 输出完整 13×13 精确地图
0.3 关于我的自研 Hypervisor调试器
准确地说它的名字叫 HyperRay。稍后有一个AI用MCP调用它的示例,简要介绍它的架构:
整套工具解耦设计,模块化开发。驱动通过 WSK 网络栈通信(以原生支持双机调试),GUI 通过 DLL 后端抽象层连接。MCP 层让 AI 可以直接调用 VT 调试器的全部能力 , 包括本次比赛中用到的内核内存读取。
这个调试器过几天会开源,请关注后续看雪发帖
1. 驱动加载与正确通信(1.5 分)
1.1 驱动签名分析与加载
拿到驱动文件 ShadowGateSys.sys,甚至都没有签名,只好掏出我压箱底的过期EV签,给他签上加载了。 (常规操作应该是测试签+测试模式)
sc create + sc start 一步到位
sc create ShadowGate type=kernel binPath=C:\ShadowGateSys.sys
sc start ShadowGate
1.2 EXE 逆向:通信接口
IDA 打开 EXE,先看它怎么连驱动的。在 main 附近很快找到 CreateFileW 调用:

图 1-2: IDA 中 EXE 的 CreateFileW —— 打开 \\.\ShadowGate
代码里连提示都帮你写好了:驱动没加载就告诉你用 sc create,权限不够就告诉你 Run as Administrator。出题人很贴心。
1.3 驱动逆向:DriverEntry
IDA 打开 sys,直奔 DriverEntry。位于 INIT 段,没被 VMP 保护——VMP不保护 INIT 段,因为系统初始化完成后这段内存会被释放

图 1-3: DriverEntry —— IoCreateDevice + IoCreateSymbolicLink
DriverEntry 做了几件事:
1. ExAllocatePool2 分配迷宫上下文(后面详说)
2. 调用 MazeInit() 初始化迷宫(在 VMProtect 段内,看不到细节)
3. IoCreateDevice 创建 \Device\ShadowGate
4. IoCreateSymbolicLink 创建符号链接 \??\ShadowGate
5. 注册 IRP handler:CREATE / CLOSE / DEVICE_CONTROL
1.4 导入表:最大的情报源
对于 VMP保护的驱动,大部分时候可以从导入表入手。IAT 必须保留明文函数指针,VMP 拿它没办法。翻一遍导入表,几乎所有侧信道的函数签名都暴露了:

图 1-4: 导入表全貌 —— ntoskrnl 导入函数列表

导入表一读完,心里就有数了:5 个侧信道对应 5 组可疑的 API 调用。接下来只需要对每个 API 做交叉引用(XREF),找到调用点,看看具体怎么用的。
1.5 ExAllocatePool2 —— 迷宫上下文
这个调用值得单独拿出来说。导入表的 ExAllocatePool2 XREF 指向 DriverEntry:
图 1-5: ExAllocatePool2(64, 472, 0x657A614D)
参数拆解:
· 64 = POOL_FLAG_NON_PAGED
· 472 = 0x1D8 字节 = 迷宫上下文结构体大小
· 1702519117 = 0x657A614D = ASCII 'Maze'(池标签)
13×13 = 169 字节迷宫 + 3 字节对齐 = 172 字节在上下文头部,剩余 300 字节存坐标、状态、计数器等。
返回值存入 .data 段全局变量,偏移 0x50B8 —— 这就是 VT 路线的定位点。
1.6 IOCTL 协议
IrpDeviceControl 入口虽然跳到 VMProtect thunk,但 switch-case 框架还是看得到三个码:

图 1-6: IOCTL 分发 —— 三码 switch-case

1.7 方向编码与防篡改
MOVE 输入 12 字节:QWORD(编码后方向)+ DWORD(校验值)。编码算法从 EXE 里逆出来:
encode(raw) = ((raw ^ 0x40) >> 5) | (8 * (raw ^ 0xFA))
tamper = LOBYTE(encoded) ^ 0xDEAD1337

图 1-7: MOVE handler 伪代码 —— 方向编码 + 校验

图 1-8: 0xDEAD1337 防篡改校验逻辑

1.8 输出缓冲区:噪声 + EXIT_MARKER
MOVE 的 132 字节输出几乎全是 LCG 伪随机垃圾(种子 = KeSystemTime ^ 0xBAADF00D,乘子 1103515245)。纯干扰,完全无意义。
唯一例外:到达终点时,offset 60 = EXIT_MARKER (1464421921),offset 64 开始存 Flag 明文,offset 128 = Flag 长度。这是整个输出缓冲区里唯一有价值的数据 —— 也是后面精确探测法的基石。
2. 发现隐匿通信手段并编写检测工具(5.0 分)
导入表已经把 5 个侧信道的线索摆在桌面上了。接下来的工作:对每个可疑 API 做 XREF,定位调用点,逆向具体逻辑,编写 R3 检测脚本。
2.1 侧信道 #1:命名事件
线索:ZwOpenEvent + ZwSetEvent
推断:驱动根据移动结果打开并触发不同的命名事件。
XREF 定位到一个 .text 段小函数 SignalMoveResult(RVA 0x22B0,213 字节)。没被 VMProtect 保护,伪代码一目了然:

图 2-1: SignalMoveResult —— 命名事件侧信道
// 核心逻辑
if (result == 0 || result == 2) // 成功
eventName = L"\\BaseNamedObjects\\MazeMoveOK";
else // 撞墙
eventName = L"\\BaseNamedObjects\\MazeMoveWall";
ZwOpenEvent(&hEvent, EVENT_MODIFY_STATE, &oa);
ZwSetEvent(hEvent, NULL);
ZwClose(hEvent);
检测方式:R3 预先 CreateEventW 创建同名事件,MOVE 后 WaitForSingleObject(50ms) 检测哪个被触发。最稳定的侧信道,约 40-50% 步骤会触发。
2.2 侧信道 #2-3:命名信号量
线索:KeReleaseSemaphore + ObReferenceObjectByName
推断:通过命名信号量传递信号。问题是名字是什么?
XREF 定位到一个 VMProtect 段内的函数,里面有 XOR 0x4B 解密 GUID 字符串的逻辑:

图 2-2: 信号量 GUID 解密 —— XOR 0x4B 解密 + ObReferenceObjectByName
// 解密后的两个信号量
Global\{A7F3B2C1-9E4D-4C8A-B5D6-1F2E3A4B5C6D} → 成功
Global\{B8E2C3D0-0F5A-5D9B-C6E7-2A3F4B5C6D7E} → 撞墙
调用链:RtlInitUnicodeString → ObReferenceObjectByName → KeReleaseSemaphore

图 2-3: KeReleaseSemaphore 调用
这里有一个踩坑:信号量有最大计数限制。探索脚本若不及时消费,KeReleaseSemaphore 返回 STATUS_SEMAPHORE_LIMIT_EXCEEDED → 蓝屏。解决方法:每次 MOVE 后立刻 drain 掉信号量。
2.3 侧信道 #4:时序差异
线索:KeDelayExecutionThread
推断:成功移动时加延迟,撞墙时不加。R3 测时间就行。

图 2-4: KeDelayExecutionThread —— 延时参数
延时计算:Interval = -10000 * (SharedUserData!SystemTime % 50 + 10),即 10-60ms 随机延迟。
检测:IOCTL 前后取 QPC,> 8ms 判成功,< 8ms 判撞墙。
缺点:受系统负载影响大,误判率高。适合当兜底。(需要强调时序检测并不十分可靠,不仅仅是针对这个题目,其实决赛检测hyperV的劫持,也不一定可靠,而且十分依赖启发式的方法)
2.4 侧信道 #5:TEB/PEB 修改
线索:PsLookup + KeStackAttachProcess + ProbeForWrite 三件套
推断:驱动跨进程附加到调用者地址空间,在 TEB/PEB 写入结果码。
图 2-5: TEB/PEB 修改全链路 —— PsLookup → KeStackAttach → ProbeForWrite
完整调用链:
1. PsLookupThreadByThreadId → 拿调用者线程对象
2. PsLookupProcessByProcessId → 拿调用者进程对象
3. KeStackAttachProcess → 附加到调用者进程
4. ProbeForWrite → 安全写入结果码
5. KeUnstackDetachProcess → 分离
结果码:PEB+0x68 = 0xC0DE0001(成功)/ 0xC0DE0002(撞墙)
2.5 多通道投票
既然有五个侧信道,说明无论哪一个单一侧信道都不是 100% 可靠的。所以我选择多通道优先级链式投票:
优先级:
event_ok → 成功 (最高)
event_wall → 撞墙
sem_ok → 成功
sem_wall → 撞墙
peb_code → 成功/撞墙
timing>8ms → 成功 (兜底)
otherwise → 撞墙 (默认)
3.探索完整迷宫布局(1.5 分)
3.1 路线 A:DFS 侧信道探索
有了五个侧信道检测工具,接下来的问题是:怎么系统性地把整张 13×13 迷宫摸清楚?
答案是 DFS(深度优先搜索)+ 原地回溯。核心发现:撞墙不改变玩家位置。这意味着不需要 RESET+replay 的传统 BFS 方式,而是可以直接在迷宫中行走与回溯——每次尝试一个方向,用五通道投票判断成功还是撞墙。如果成功,前进到新格子继续探索;如果撞墙,位置不变,试下一个方向。所有方向都试完了就走反方向回溯到上一个格子。
DFS 的天然优势:不需要 RESET,回溯只需走反方向。每条通道只需 2 次 IOCTL(前进+回退),每堵墙只需 1 次。全 13×13 迷宫约 500 次 IOCTL,30 秒跑完。
探索完成后,在 DFS 建立的邻接表上跑 BFS 求解最短路径。当 BFS 找到从 (0,0) 到 (12,12) 的路径时,即为最短路径。RESET 后沿该路径走到终点,从输出缓冲区提取 Flag。
RRRRRRDDRRRRUURRDDDDDDDDLLDDDDRR(32 步)
得到 flag{SHAD0WNT_HY PERVMX}
3.2 路线 B:Hypervisor内核内存直读
先从 IDA 确定读哪里:
定位过程:
1. DriverEntry 的 ExAllocatePool2 → 池标签 'Maze',大小 0x1D8
2. 返回值存入 .data 段,偏移 0x50B8
3. R3 用 EnumDeviceDrivers 找到 ShadowGateSys.sys 基址
4. HyperRay MCP:vt_kernel_read(DriverBase + 0x50B8, 8) → 拿 g_MazeContext 指针
5. vt_kernel_read(g_MazeContext, 0x1D8) → 拿完整 172 字节上下文

图 3-1: VT MCP 内核内存读取 —— 从定位基址到读出完整迷宫
编码:每字节一个格子,0 = 通道,1 = 实体墙。
统计:97 个通道格 + 72 个墙格 = 169 格。
+---+---+---+---+---+---+---+---+---+---+---+---+---+
| S * * * * * * |###| * * * |
+---+---+---+---+---+---+ +---+---+---+ +---+ +
|###|###|###|###|###|###| * |###|###|###| * |###| * |
+---+---+---+---+---+---+ +---+---+---+ +---+ +
| |###| * * * * * |###| * |
+ +---+---+---+ +---+---+---+---+---+---+---+ +
| |###|###|###| |###|###|###|###|###|###|###| * |
+ +---+---+---+ +---+---+---+---+---+---+---+ +
| |###| |###| * |
+ +---+ +---+ +---+---+---+---+---+ +---+ +
| |###| |###| |###|###|###|###|###| |###| * |
+ +---+ +---+ +---+---+---+---+---+ +---+ +
| |###| |###| |###| |###| |###| * |
+ +---+ +---+---+---+ +---+ +---+---+---+ +
| |###| |###|###|###| |###| |###|###|###| * |
+ +---+ +---+---+---+ +---+ +---+---+---+ +
| |###| |###| |###| * * * |
+ +---+---+---+---+---+---+---+ +---+ +---+---+
| |###|###|###|###|###|###|###| |###| * |###|###|
+ +---+---+---+---+---+---+---+ +---+ +---+---+
| |###| |###| |###| * |###| |
+---+---+ +---+ +---+ +---+ +---+ +---+ +
|###|###| |###| |###| |###| |###| * |###| |
+---+---+ +---+ +---+ +---+ +---+ +---+ +
| |###| |###| * * E |
+---+---+---+---+---+---+---+---+---+---+---+---+---+
(内存读出来的完整地图:S=起点 E=终点 *=最短路径 ###=实体墙)
原始数据(0=通道, 1=墙):
x: 0 1 2 3 4 5 6 7 8 9 10 11 12
y= 0: 0 0 0 0 0 0 0 1 0 0 0 0 0
y= 1: 1 1 1 1 1 1 0 1 1 1 0 1 0
y= 2: 0 0 0 0 0 1 0 0 0 0 0 1 0
y= 3: 0 1 1 1 0 1 1 1 1 1 1 1 0
y= 4: 0 1 0 0 0 0 0 0 0 0 0 1 0
y= 5: 0 1 0 1 0 1 1 1 1 1 0 1 0
y= 6: 0 1 0 1 0 1 0 0 0 1 0 1 0
y= 7: 0 1 0 1 1 1 0 1 0 1 1 1 0
y= 8: 0 1 0 0 0 0 0 1 0 1 0 0 0
y= 9: 0 1 1 1 1 1 1 1 0 1 0 1 1
y=10: 0 0 0 1 0 0 0 1 0 1 0 1 0
y=11: 1 1 0 1 0 1 0 1 0 1 0 1 0
y=12: 0 0 0 0 0 1 0 1 0 0 0 0 0
4. 最短路径求解(0.5 分)
拿到地图,建邻接表,跑 BFS。13×13 无权图,标准操作。
from collections import deque
def bfs(adj, start, goal):
queue = deque([(start, [])])
visited = {start}
while queue:
pos, path = queue.popleft()
for nb in adj[pos]:
if nb not in visited:
visited.add(nb)
d = direction(pos, nb)
if nb == goal:
return path + [d]
queue.append((nb, path + [d]))
return None

路径:右6 → 下2 → 右4 → 上2 → 右2 → 下8 → 左2 → 下4 → 右2,从 (0,0) 到 (12,12)。
传统探测和 内存直读的 BFS 结果完全一致,交叉验证确认为最短路径。
5. 最终凭证 Flag(1.5 分)
走完 32 步,最后一步 MOVE 的输出缓冲区:

\import struct
marker = struct.unpack_from("<I", out, 60)[0]
if marker == 1464421921:
length = struct.unpack_from("<I", out, 128)[0]
flag = out[64:64 + length].decode("utf-8")
Flag: flag{SHAD0WNT_HYPERVMX}
多次实测验证,稳定可复现。

6. 决赛展望
Flag 是 flag{SHAD0WNT_HYPERVMX}。
按照这个比赛的老传统,初赛flag=决赛技术栈,决赛该考VT了,考前的赛事群里,考官还信誓旦旦地说今年不会考VT,但是说实话,在Windows对抗的领域,不引入外部硬件的情况下,R-1层已经是极限了,去年已经考了VT,今年不可能倒转回去做R0的对抗(如果是的话,也会被我的VT直接秒掉)。
附录 :关键常量
IOCTL_MOVE = 0x80012004
IOCTL_RESET = 0x80012008
IOCTL_GET_INFO = 0x8001200C
CHECKSUM_MAGIC = 0xDEAD1337
EXIT_MARKER = 1464421921
POOL_TAG = 'Maze' (0x657A614D)
CONTEXT_SIZE = 0x1D8 (472 bytes)
MAZE_DATA = 169 bytes (13x13) + 3 padding
MOVE_INPUT = 12 bytes (QWORD + DWORD)
MOVE_OUTPUT = 0x84 (132 bytes)
TEB_OFFSET = 0x1748
PEB_OFFSET = 0x68
LCG_XOR = 0xBAADF00D
最后于 7小时前
被XieCZ1337编辑
,原因: 调整排版