前言:决赛打完再回顾提前开好的香槟,觉得自己很懂虚拟化,结果被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)
题目的核心设计:驱动核心逻辑被 VMP保护,选手只能看到接口和调用规则。在"黑盒"条件下,通过分析 5 条侧信道来摸清迷宫布局,最终提取 Flag。
但文件到了选手的电脑上,自然是各显神通。除了题目设计的纯侧信道路线外,直读内核内存(甚至直接 kdmapper 加载一个读写驱动都行)、DMA(遍历 PTE 页表读物理内存)、池标签搜索……都可以直接拿到迷宫真值。
路线 A — 纯传统逆向 + 侧信道探测:
IDA 逆向 IOCTL 协议 → 导入表发现 5 种侧信道 → 编写检测脚本 → EXIT_MARKER 验证法精确探测 → DFS探索地图+最短路径 → 取 Flag
路线 B — 自研 Hypervisor 牛刀杀鸡直接读内存:
Hypervisor → 读驱动 g_MazeContext 内核内存(实际上这一步既读了完整的地图,也读了最短路线)→ 把读出来的编码转换成地图(0=通道,1=墙)→ 输出完整 13×13 精确地图
准确地说它的名字叫 HyperRay。稍后有一个AI用MCP调用它的示例,简要介绍它的架构:
整套工具解耦设计,模块化开发。驱动通过 WSK 网络栈通信(以原生支持双机调试),GUI 通过 DLL 后端抽象层连接。MCP 层让 AI 可以直接调用 VT 调试器的全部能力 , 包括本次比赛中用到的内核内存读取。
这个调试器过几天会开源,请关注后续看雪发帖
拿到驱动文件 ShadowGateSys.sys,甚至都没有签名,只好掏出我压箱底的过期EV签,给他签上加载了。 (常规操作应该是测试签+测试模式)
sc create + sc start 一步到位
sc create ShadowGate type=kernel binPath=C:\ShadowGateSys.sys
sc start ShadowGate
IDA 打开 EXE,先看它怎么连驱动的。在 main 附近很快找到 CreateFileW 调用:

图 1-2: IDA 中 EXE 的 CreateFileW —— 打开 \\.\ShadowGate
代码里连提示都帮你写好了:驱动没加载就告诉你用 sc create,权限不够就告诉你 Run as Administrator。出题人很贴心。
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
对于 VMP保护的驱动,大部分时候可以从导入表入手。IAT 必须保留明文函数指针,VMP 拿它没办法。翻一遍导入表,几乎所有侧信道的函数签名都暴露了:

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

导入表一读完,心里就有数了:5 个侧信道对应 5 组可疑的 API 调用。接下来只需要对每个 API 做交叉引用(XREF),找到调用点,看看具体怎么用的。
这个调用值得单独拿出来说。导入表的 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 路线的定位点。
IrpDeviceControl 入口虽然跳到 VMProtect thunk,但 switch-case 框架还是看得到三个码:

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

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

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

图 1-8: 0xDEAD1337 防篡改校验逻辑
MOVE 的 132 字节输出几乎全是 LCG 伪随机垃圾(种子 = KeSystemTime ^ 0xBAADF00D,乘子 1103515245)。纯干扰,完全无意义。
唯一例外:到达终点时,offset 60 = EXIT_MARKER (1464421921),offset 64 开始存 Flag 明文,offset 128 = Flag 长度。这是整个输出缓冲区里唯一有价值的数据 —— 也是后面精确探测法的基石。
导入表已经把 5 个侧信道的线索摆在桌面上了。接下来的工作:对每个可疑 API 做 XREF,定位调用点,逆向具体逻辑,编写 R3 检测脚本。
线索: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% 步骤会触发。
线索: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 掉信号量。
线索:KeDelayExecutionThread
推断:成功移动时加延迟,撞墙时不加。R3 测时间就行。

图 2-4: KeDelayExecutionThread —— 延时参数
延时计算:Interval = -10000 * (SharedUserData!SystemTime % 50 + 10),即 10-60ms 随机延迟。
检测:IOCTL 前后取 QPC,> 8ms 判成功,< 8ms 判撞墙。
缺点:受系统负载影响大,误判率高。适合当兜底。(需要强调时序检测并不十分可靠,不仅仅是针对这个题目,其实决赛检测hyperV的劫持,也不一定可靠,而且十分依赖启发式的方法)
线索: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(撞墙)
既然有五个侧信道,说明无论哪一个单一侧信道都不是 100% 可靠的。所以我选择多通道优先级链式投票:
优先级:
event_ok → 成功 (最高)
event_wall → 撞墙
sem_ok → 成功
sem_wall → 撞墙
peb_code → 成功/撞墙
timing>8ms → 成功 (兜底)
otherwise → 撞墙 (默认)
有了五个侧信道检测工具,接下来的问题是:怎么系统性地把整张 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}
先从 IDA 确定读哪里:
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 6天前
被XieCZ1337编辑
,原因: 调整排版