首页
社区
课程
招聘
7
[原创] 腾讯2025游戏安全PC方向决赛题解
发表于: 2025-4-14 01:30 5173

[原创] 腾讯2025游戏安全PC方向决赛题解

2025-4-14 01:30
5173

(1)在intel CPU/64位Windows10系统上运行sys,成功加载驱动(0.5分)
(2)能在双机环境运行驱动并调试(1分)
(3)优化驱动中的耗时算法,并给出demo能快速计算得出正确的key(1分)
(4)分析并给出flag的计算执行流程(1.5分),能准确说明其串联逻辑(0.5分)
(5)正确解出flag(1分)
(6)该题目使用了一种外挂常用的隐藏手段,请给出多种检测方法,要求demo程序能在题目驱动运行的环境下进行精确检测,方法越多分数越高(3分)
(7)文档编写,详细描述解题过程,详述提供的解题程序的演示方法。做到清晰易懂,操作可以复现结果;编码工整风格优雅、注释详尽(1.5分)

很棒开局寄了,比赛开始的前两天想玩hyperdbg来着,发现电脑是AMDcpu没法玩,当时直接赌用不到。

结果一下子考了VT,天崩开局。但是呢也不都是祸,前几周给XYCTF2025出题的时候呢出了一道VMP的驱动题,这回正好考了VMP。
那怎么玩?翻遍家里就发现一个”田“牌平板(挂不了调试器)和一个N年前的笔记本能用。
于是激情下单了电脑,然后周一才能到。。
只能嗯调了,就有了这种old school逆向风,开着老版windbg,trace一下卡1s的电脑开始了。

首先dump 被VMP保护的驱动,并同时获取当前时刻系统API的导出表作为IAT修复用。

通过对于VMP的了解可以知道,VMP在最后的时候会调用API函数KeRevertToUserAffinityThread来完成最后的操作,此时VMP是已经把资源解密、修复完成的,直接IAThook + dump就行了。

dump出来后,使用特征码直接定位__security_init_cookie,进而定位入口点。

48 8B 05 ?? ?? ?? ?? 48 85 C0 74 1B

有了入口点,FxDriverEntryWorker就出来了,进而DiverMain就出来了。

DriverMain经过分析如下:

该函数就是单纯初始化一些东西,包括获取CPU型号信息、电脑基础信息、初始化Table。

查询了CPU的厂商、版本、指令集,并把他们的数据保存起来

先查版本和cpu厂商

然后查指令集,并把他们存起来

通过NtQuerySystemInformation获取电脑信息

M_GetNtdll

则通过API获取了其中的SECTION_IMAGE_INFORMATION并保存为全局变量。

其他函数则是初始化一些表什么的。

initterm_e与initterm的特征过于明显不再赘述

转回主函数我们来看关键的函数


在该函数中,把从注册表输入的Key与Flag读取出来,进行检测。

使用CPUid读出来CPU厂商信息,使用std::string把他们组合起来,并判断是不是名字为GenuineIntel

通过读取注册表

\\Machine\\System\\CurrentControlSet\\\Services\\ACEDriver\\2025ACECTF

下的项来获取Flag和Key,并返回是否获取到。


该函数为生成Key与CheckFlag的关键函数。

而在这步只需要发现当Key不是0的时候不会进入到非常耗时的生成key的函数中即可。

根据经验,这种反调试一般都是通过创建线程或者各种各样的回调函数来完成的,一开始试了各种各样的api,包括但不限于:PsCreateSystemThreadEx、KeInitializeDpc、ExQueueWorkItem等,最终在ExQueueWorkItem中找到了回调函数。

该函数是被vmp变异了的,但是可以在里面发现另一个调用。

跟进去后发现调用了蓝屏函数(包装过的),这里面还有个字符串(其实一开始就找到过这个函数但是没以为有什么用)

继续跟进去发现调用了一个用于安全调用蓝屏函数的函数和字符串。

跟进去可以发现,他把栈清了防止栈回溯找到他。

那么思路很明确了,把从Workitem里面调用一连串蓝屏函数的地方给他ret就行了。

之所以ret这个,是因为交叉引用会发现其还在一个别的地方被调用,是循环的。

这个偏移是0x74f0,直接ret就行了。

ret之后会发现调试器无了,这很明显调用了KdDisableDebugger,同样的给他ret就行。

转到intel的物理机中就可以正常加载调试了。

首先可以很轻松找到Key生成算法上文已经说过了,稍微整理一下,然后给deepseek。

这里需要不断的重复,然后直到他说出来正确的算法。那么怎么测试呢?直接在windbg里面修改寄存器,让小的数进入计算函数,获取正确的结果。

比如这里算的是16,8。他结果应该跑出来是0xA949才对。当然前面也算过其他的小的数。然后会给我一个正确,但是没有优化的算法。此时继续调教他发现给我的算法不再正确,小的数据都是错的。

果断使用chatgpt,然后他告诉我需要用这个带记忆缓存的。

然后把他给我的代码再次测试跑(循环几次)直到给我个对的。


故此结果出来了。

代码如下:

我觉得完美的展现了VT的强大

*前提:必须识别出是hv库,这样就能对着源码进行分析,也省事。

https://github.com/jonomango/hv

以下内容按照逆向分析时的顺序编写,个人认为是最简单能看出来的方法。

首先有VT可以想到经典的EPT hook,那么若要安装EPT hook,在hv框架中则需要通过 hv::vmx_vmcall 来调用。

于是问题变成了找vmx_vmcall,我们可以直接编译一份hv库,然后bindiff,也可以手动匹配字节码。

我这里是两者结合,先bindiff把一些识别出来的给自动重命名,有些没识别的就手动搞一下了。

无论如何我们都可以找到他,在Base+0x153C处。

我们下个断点就可以发现第一次竟然就上了EPT hook。

结合hv库的结构我们可以轻松发现首先上了EPThook。因为其调用代码为5,对应hv::emulate_vmcall的安装EPThook。

然后翻阅hv源码可以发现其hook的页和被替换的页存的结构,把被替换的页抠出来,就可以找到一个类似xtea的东西。

在调用加密函数时候可以发现有2次readmsr的指令请求,这很奇怪,通过调试可以发现第二次readmsr的时候flag被改了。

那么应该是在readmsr模拟函数中做了操作。

我们可以从hv::rdmsr_safe来定位,其中使用了readmsr,直接特征码搜索就行了,然后往上跟一下就到了hv::emulate_rdmsr。

转到hv::emulate_rdmsr,可以发现其在传入E8的时候对寄存器做了一些操作。

首先通过读取栈里面有没有这个魔术数字来判断是不是自己驱动调用的,如果是的话则进行了一个加密,这个只是异或,我们只要抄出来保证参数正确就可以解密。

此处CPU寄存器结构如下,是从hv库中抄出来的。

这个是最不容易看出来的,调试了半天才发现被改了。

经过反复的调试呢,发现在一开始对输入的flag与key异或的时候呢flag突然被改了


这就很邪门了,EPT hook没拦下来,但是怎么变了呢?一开始根本没思路,但是后面有个想法。

首先,VT是在虚拟机管理层(host),而windbg在guest的内核层,根本调不到,读不到,这是正常现象。

那么好,怎么访问host的内存呢?总不能上硬件调试器对吧。

所以可以借用VMware的host进而把ACEDriver的VT给嵌套进去,变成guest。

此时嵌套关系变成了:

物理机的windbg(内核层) > VMware(host) > ACEDriver (guest同时是虚拟机系统的host) > 虚拟机windbg (guest)

这样我们就把ACEDriver给嵌套进去了,VMware外面就能读ACEDriver搞的鬼了。

于是,馊主意就是直接拿CE搜特征码,把VMware物理内存上的数据给搞出来。

一看就会发现其真实代码竟然被清空了,这里必然引发异常,转到hv::dispatch_vm_exit去详细查看,关于怎么找这个函数,对着hv::emulate_vmcall交叉引用即可。

进到hv::dispatch_vm_exit之后呢就得去找hv::handle_ept_violation函数了,因为他处理了内存违例的情况。

很显然这个函数在这,这个可以通过是不是vmread VMCS_EXIT_QUALIFICATION(0x6400)来判断,因为在hv库中只有2次引用,一个是咱们要找的,一个是handle_mov_cr。

于是我们可以快速的找到。

把这个函数往下拉,就会发现一番天地。



在其底下获得了程序指针,通过判断硬编码是否一致的方式来选择性的执行加密。

直接问ai加密函数是什么

然后让他写解密函数就行了。

老实说这个是最不容易被看出来的,如果是逆向的话从后往前解,这个是最后遇到的。

这里猜测其想说EPT Hook,其可以用于监控和拦截虚拟机中的内存访问操作,由于内核也算guest,所以依然可以被拦截,同时其对guest是完全透明的,所以可以作为隐藏真实行为来使用。

直接检测EPT hook我认为是一件很难的事情,但是可以从其根源上来检测,检测其是否使用VT技术把系统放到了guest里。

使用__rdtsc指令检测__cpuid指令的运行时间,在没有开启ACEDriver的情况下,该指令平均耗时0x100左右,而开启后则是10倍的差距。

通过调用cpuid一行命令并计算msr寄存器IA32_APERF_MSR的差,该寄存器应该是递增的才对,如果差为0则应该是在VT。通过上面的代码可以发现readmsr模拟的时候直接写了0

#include <iostream>
#include <deque>
#include <cstdint>
#include <unordered_map>
#include <utility>
 
struct Node {
    int x;
    int y;
    int state; // 0, 1, or 2
    int64_t step;
    int64_t value;
};
 
struct PairHash {
    size_t operator()(const std::pair<int, int>& p) const {
        return std::hash<int>()(p.first) ^ (std::hash<int>()(p.second) << 1);
    }
};
 
int64_t calculateValue(int x, int y) {
    std::deque<Node*> stack;
    int64_t value = 0;
 
    std::unordered_map<std::pair<int, int>, int64_t, PairHash> cache;
 
    Node* firstNode = new Node{ x, y, 0, 0, 0 };
    stack.push_back(firstNode);
 
    while (!stack.empty()) {
        Node* current = stack.back();
        std::pair<int, int> key = { current->x, current->y };
 
        // Check cache first
        if (cache.find(key) != cache.end()) {
            value = cache[key];
            delete current;
            stack.pop_back();
            continue;
        }
 
        // Base case
        if (current->y == 0 || current->x == current->y) {
            value = 1;
            cache[key] = value;
            delete current;
            stack.pop_back();
            continue;
        }
 
        switch (current->state) {
        case 0:
            current->state = 1;
            stack.push_back(new Node{ current->x - 1, current->y, 0, 0, 0 });
            break;
        case 1:
            current->state = 2;
            current->step = value;
            stack.push_back(new Node{ current->x - 1, current->y - 1, 0, 0, 0 });
            break;
        case 2:
            current->value = value;
            value = current->step + value + (current->x % 5);
            cache[key] = value;
            delete current;
            stack.pop_back();
            break;
        }
    }
 
    return value;
}
 
int main() {
    int64_t result = calculateValue(44, 22);
    std::cout << std::hex << "Result: 0x" << result  << std::endl;
 
    return 0;
}
#include <iostream>
#include <deque>
#include <cstdint>
#include <unordered_map>
#include <utility>
 
struct Node {
    int x;
    int y;
    int state; // 0, 1, or 2
    int64_t step;
    int64_t value;
};
 
struct PairHash {
    size_t operator()(const std::pair<int, int>& p) const {
        return std::hash<int>()(p.first) ^ (std::hash<int>()(p.second) << 1);
    }
};
 
int64_t calculateValue(int x, int y) {
    std::deque<Node*> stack;
    int64_t value = 0;
 
    std::unordered_map<std::pair<int, int>, int64_t, PairHash> cache;
 
    Node* firstNode = new Node{ x, y, 0, 0, 0 };
    stack.push_back(firstNode);
 
    while (!stack.empty()) {
        Node* current = stack.back();
        std::pair<int, int> key = { current->x, current->y };
 
        // Check cache first
        if (cache.find(key) != cache.end()) {
            value = cache[key];
            delete current;
            stack.pop_back();
            continue;
        }
 
        // Base case
        if (current->y == 0 || current->x == current->y) {
            value = 1;
            cache[key] = value;
            delete current;
            stack.pop_back();
            continue;
        }
 
        switch (current->state) {
        case 0:
            current->state = 1;
            stack.push_back(new Node{ current->x - 1, current->y, 0, 0, 0 });
            break;
        case 1:
            current->state = 2;
            current->step = value;
            stack.push_back(new Node{ current->x - 1, current->y - 1, 0, 0, 0 });
            break;
        case 2:
            current->value = value;
            value = current->step + value + (current->x % 5);
            cache[key] = value;
            delete current;
            stack.pop_back();
            break;
        }
    }
 
    return value;
}
 
int main() {

[注意]看雪招聘,专注安全领域的专业人才平台!

最后于 2025-4-14 01:38 被moshuiD编辑 ,原因:
收藏
免费 7
支持
分享
赞赏记录
参与人
雪币
留言
时间
Swizzer
+1
感谢你分享这么好的资源!
5天前
B.M.K
谢谢你的细致分析,受益匪浅!
2025-4-16 09:57
周旋久
非常支持你的观点!
2025-4-15 17:44
5m10v3
这个讨论对我很有帮助,谢谢!
2025-4-14 10:53
Bombs
你的帖子非常有用,感谢分享!
2025-4-14 10:39
TubituX
+10
非常支持你的观点!
2025-4-14 10:30
p34cd0wn
非常支持你的观点!
2025-4-14 01:37
最新回复 (12)
雪    币: 207
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
膜拜大佬!
2025-4-14 01:37
0
雪    币: 5377
活跃值: (5367)
能力值: ( LV10,RANK:160 )
在线值:
发帖
回帖
粉丝
3
写的很不错!cool wp!
2025-4-14 03:40
1
雪    币: 2127
活跃值: (5280)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
4
2025-4-14 06:13
0
雪    币: 393
活跃值: (1880)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
5
学习了
2025-4-14 08:43
0
雪    币: 2898
活跃值: (2947)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
6
神!
2025-4-14 10:30
0
雪    币: 3221
活跃值: (5809)
能力值: ( LV4,RANK:55 )
在线值:
发帖
回帖
粉丝
7
无敌哥,太强了
2025-4-14 12:07
0
雪    币: 257
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8
初级还能看懂,这个完全整不明白了
2025-4-14 12:11
0
雪    币: 14329
活跃值: (7382)
能力值: ( LV15,RANK:673 )
在线值:
发帖
回帖
粉丝
9
tql!
2025-4-14 17:11
0
雪    币: 105
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10
师傅写的很好。最后VT检测可以多考虑几种思路:比如利用常见引起VM-Exit的指令去看看赛题环境下会不会抛异常。另外EPT Hook也可以考虑利用时序的方法检测,原理就是被挂上Hook的函数读与执行的时间差距巨大
2025-4-15 17:43
0
雪    币: 3438
活跃值: (1836)
能力值: ( LV7,RANK:116 )
在线值:
发帖
回帖
粉丝
11
周旋久 师傅写的很好。最后VT检测可以多考虑几种思路:比如利用常见引起VM-Exit的指令去看看赛题环境下会不会抛异常。另外EPT Hook也可以考虑利用时序的方法检测,原理就是被挂上Hook的函数读与执行的 ...

很奇怪,我尝试用时序检测来检测EPT Hook,但是好像效果不好?可能是我代码的问题。确实我最后一天太困了状态不好,其实应该仔细再看一看的,感觉错失了很多点。

最后于 2025-4-15 22:05 被moshuiD编辑 ,原因: 打错字了
2025-4-15 22:02
0
雪    币: 85
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
12
问下他这个驱动前面获取各种信息的不加vmp吗?
2025-4-18 01:08
0
雪    币: 3438
活跃值: (1836)
能力值: ( LV7,RANK:116 )
在线值:
发帖
回帖
粉丝
13
yazigegeda 问下他这个驱动前面获取各种信息的不加vmp吗?
dump+修复iat,他里面很多函数没有加任何保护,反调试那部分加了变异而初始化VT的部分则是上了vm。
之所以能看到是有API都是我后面修的,最后呈现的结果
2025-4-18 07:13
0
游客
登录 | 注册 方可回帖
返回

账号登录
验证码登录

忘记密码?
没有账号?立即免费注册