2025腾讯游戏安全技术竞赛-PC端决赛个人题解
强度很高的比赛,三天基本打满了。
写点关键词给自己引引流:腾讯游戏安全|2025腾讯游戏安全大赛|2025腾讯游戏安全决赛|腾讯游戏安全竞赛决赛
在intel CPU/64位Windows10系统上运行sys,成功加载驱动(0.5分)& 能在双机环境运行驱动并调试(1分)
双机调试
查壳VMP,首先得想办法能双机调试
利用工具查看驱动运行中调用的API:
此工具项目地址:https://github.com/Oxygen1a1/DrvMon
一些API应该是用来判断当前环境是否启用了Intel VT虚拟化。另外检测到双机环境直接蓝屏,bug_checkcode:0x414345(ACE)
选择一个较靠后执行的API,下断后dump驱动程序,考虑windbg的搜索功能搜PE头:
s -b fffff806`e3000000 fffff806`e302fa4c 4d 5a
一页一页的往前搜,搜到的地址如果恰好是内存对齐的,那么大概率就是PE头
然后就是dump驱动,SizeOfImage是大小,dump下来修SizeOfHeader、AddressOfEntryPoint、RawAddress后拖入IDA分析。
IDA中有很多类似这样的函数:
这是通过哈希值来寻找导出函数的地址。还有一种是这样的形式的:
逐一下断后看一下具体是什么函数:
ModuleBase + 31A8 PsGetProcessId
ModuleBase + 3268 PsGetProcessImageFileName
ModuleBase + 3328 ExAllocatePoolWithTag // PsGetCurrentThreadProcess
ModuleBase + 33E8 MmGetPhysicalAddress
ModuleBase + 34A8 MmGetVirtualForPhysical
ModuleBase + 3568 MmIsAddressValid
ModuleBase + 7A80 KdRefeshDebuggerNotPresent
ModuleBase + 85E4 KeQueryActiceProcessorCount
ModuleBase + 86A4 BgpFwGetCurrentIrql
ModuleBase + 8764 KeSetSystemAffinityThreadEx
ModuleBase + 8824 ExQueueWorkItem
ModuleBase + 88E4 KeBugCheckEx
ModuleBase + 89A4 KeDelayExecutionThread
这里能定位到一个反调函数KdRefreshDebuggerNotPresent
这是一个很经典的反调试函数,在ACE-BASE中早有应用,通常还搭配KdDisableDebugger一起使用,用来剥离调试器
下图源自:某CCC-BASE的逆向_ace-base.sys-CSDN博客
那这里就直接Hook这两个函数(完整代码见附件Tencent-Anti-AntiDebug
):
BOOLEAN __stdcall myKdRefreshDebuggerNotPresent()
{
return true;
}
NTSTATUS __stdcall myKdDisableDebugger()
{
return STATUS_DEBUGGER_INACTIVE;
}
成功绕过蓝屏:
成功加载驱动
在IDA中注意到这样一个函数
看样子是在初始化一个字符串,给这个函数下断看看是什么东西。
直接断到返回值,字符串是一个注册表:
\Registry\Machine\System\CurrentControlSet\Services\ACEDriver\2025ACECTF
不清楚注册表的键值结构,考虑HookZwQueryValueKey
,打印一下QueryValueName,得到以下输出:
因此得手动创建这两个键,key指的是问题中耗时算法计算出的key
驱动启动成功。
优化驱动中的耗时算法,并给出demo能快速计算得出正确的key(1分)
通过字符串定位到耗时函数
逆向算法:
大意是计算了一个类帕斯卡三角形:
f(n,k) = f(n-1,k) + f(n-1,k-1) + n % 5
未经优化的话这个算法的复杂度是:
$$
O(2^n)
$$
考虑dp优化:
import time
def solve_f(n, k):
dp = [[0] * (k + 1) for _ in range(n + 1)]
for i in range(n + 1):
for j in range(min(i, k) + 1):
if j == 0 or i == j:
dp[i][j] = 1
else:
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1] + i % 5
return dp[n][k]
n = 0x2C
k = 0x16
start_time = time.time()
result = solve_f(n, k)
end_time = time.time()
elapsed_time = end_time - start_time
print(f"f(0x{n:X}, 0x{k:X}) = {result} (hex: 0x{result:X})")
print(f"运行时间: {elapsed_time:.6f} 秒")
# 0x66711265FD2
# 0.000228
这边的输入值来自上层函数:
输出0x66711265FD2
,即key
另外,优化后的算法复杂度、运行时间(秒)数量级是:
$$
O(nk)、10^{-4}
$$
优化后的时间远小于原算法。
分析并给出flag的计算执行流程(1.5分),能准确说明其串联逻辑(0.5分)
1.1 EPT Hook
从Key的计算函数能交叉引用找到Flag的计算流程:
动调这一部分的时候,意外发现了一个事情:
输入的flag应为fake_flag,首位为0x66,经过mov指令读出赋值给al后,变成了95
结合到本题的环境,合理猜测:程序对这一内存页挂了Hook,读写内存时会触发EPT Violation由Host接管处理,具体怎么处理的还得逆对应的Handler。
容易在IDA找到一个简易的VM-Exit分发器:
逆向分发器:
AI能根据intel手册对对应的handler命名:
有些不太准确,大概都翻翻能看出来应该是这一个:
这里有两段硬编码:
41 8A F0 :mov cl, byte ptr [r8 - 0x10]
88 45 A0:mov byte ptr [rbp - 0x60], al
说明从这里挂入了Hook,在mov操作时做了一个Encrypt
这玩意我想了下应该是不可逆的,因为一个数对异或后进行接下来的操作,产生一个数,这个过程显然不可能是双射的。又因为是单字节的,所以考虑爆破处理。
uint8_t Encrypt(char a1, char a2)
{
uint8_t result = 0;
uint8_t xor_val = a1 ^ a2;
uint8_t bit_count = 0;
uint8_t tmp = xor_val;
while (tmp) {
bit_count += tmp & 1;
tmp >>= 1;
}
uint8_t table[8] = {0, 1, 2, 3, 4, 5, 6, 7};
uint64_t seed = bit_count;
int n = 8;
for (int i = 7; i > 0; i--) {
seed = seed * 0x3F5713FCCC7C79AA + 0x3A7D9E5B36F498B2;
int idx = ((uint32_t)(seed >> 32)) % n--;
uint8_t tmp = table[i];
table[i] = table[idx];
table[idx] = tmp;
}
for (int i = 0; i < 8; i++) {
uint8_t bit = (xor_val >> table[i]) & 1;
result |= (bit << i);
}
return result;
}
第一部分单字节加密处理完毕
1.2 HookedTea
保留节目之魔改TEA,没开始做这一问的时候就在IDA里找到一个TEA:
显然这大概率不会是真的加密函数,结合前一小问的EPT Hook,合理猜测:出题人给这一函数挂了Hook魔改某一部分。先看静态逻辑:
这里有个Key F5没显示出来:
key = {0x89,0xFE,0x76,0xA0}
逻辑比较简单,两两TEA后跟密文比较,动调发现这一条指令变动:
mov eax,5464AD1Fh => mov eax,8771109Dh
确认这一个函数也被挂上了Hook,这种赋值类型的汇编还能通过读寄存器判断出来,其他的变化则是windbg不可见的。
现在有两个思路:1.根据函数序言的特征,在内存中搜索实际执行的函数。2.直接在EPT交付Hook函数地址时下断,获得函数地址
尝试第一种,在内存中扫描TEA函数序言部分,EPT中肯定有这么一段字节码:
s -[1] ffff0000`00000000 L?(ffffff00`00000000 - ffff0000`00000000) 48 8b c4 48 89 58 08 48 89 68 10 48 89 70 18 48 89 78 20 41 55 48 83 ec 50 33 c0 89 04 24
但是由于不知道具体的地址范围,这里搜的地址接近1T了,过于庞大直接导致Windbg卡死。
这里我想了下应该可以用一些方法放缩范围,或者写个脚本查询,不过还是很麻烦而且不够优雅。
考虑第二种方法:
一个VT框架理应会通过VMCALL指令直接从Guest通知Host,发送Hook请求。(当然还有其他的通信手段,只不过我在IDA里翻到了vmcall所以比较肯定是这一种)
下断后翻一下地址:
rcx的地址与原地址相近,应该是一个模块,不用管
rdx的地址则存放着hook后的tea:
这里我之前分析有个误区,应该是直接hook了一整页而非一个函数。
dump下来拿到hook后的tea:
不难给出解密代码:
__int64 __fastcall TeaDecrypt(__int64 a1, __int64 a2, __int64 a3, unsigned int *a4)
{
__int64 result; // rax
int v5; // [rsp+0h] [rbp-58h]
unsigned int v6; // [rsp+4h] [rbp-54h]
unsigned int v7; // [rsp+8h] [rbp-50h]
unsigned int v8; // [rsp+Ch] [rbp-4Ch]
v5 = 0x9DDECF4C;
v8 = 0x3C;
v6 = *a4;
v7 = a4[1];
do
{
v7 = v7
- 0x7C77AF7C
- ((v5 + v6) ^ v6 ^ (*(_DWORD *)(a3 + 8) + 16 * v6) ^ (*(_DWORD *)(a3 + 12) + (v6 >> 5)) ^ 0x4321)
+ 1493243931;
v5 += 0x788EEF63; // 恢复v5的值(加回原来的delta)
v6 -= (v5 + *(_DWORD *)(a3 + 4LL * (v5 & 3))) ^ (v7 + ((v7 >> 5) ^ (16 * v7)));
--v8;
}
while (v8 > 0);
result = v6;
*a4 = v6;
a4[1] = v7;
return result;
}
1.3最后的异或
本来以为没了...结果调半天没弄出来,感觉还是有东西没看到。又翻了一遍代码:
因为比较前实在是没啥逻辑了,甚至怀疑了这个最后的校验函数也被Hook了(实际没有),因为被Hook的应该只有那一页。
最后感觉还是这个最不起眼的__readmsr在作妖,翻了一下Handler发现个这玩意:
这个handler恰好对应的就是readmsr
密钥初值:
说明最后一步还做了一个异或,代码抠出来:
void FinalXor(uint8_t* data, int len, const uint8_t* xor_key1, const uint8_t* xor_key2) {
uint8_t xor_table[8];
uint8_t* ptr = data;
int blocks = len / 2;
while (blocks--) {
for (int v10 = 0; v10 < 8; v10++) {
xor_table[v10] = v10 + len + xor_key1[v10 % 5] + xor_key2[v10 % 7];
}
for (int i = 0; i < 8; i++) {
ptr[i] ^= xor_table[i];
}
ptr += 8;
}
}
密文:
完整EXP:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <Windows.h>
#define HIDWORD(x) ((uint32_t)(((uint64_t)(x) >> 32) & 0xFFFFFFFF))
#define _BYTE uint8_t
#define _DWORD uint32_t
//异或加密中,取数时的EPT Hook Encrypt
uint8_t Encrypt(char a1, char a2)
{
uint8_t result = 0;
uint8_t xor_val = a1 ^ a2;
uint8_t bit_count = 0;
uint8_t tmp = xor_val;
while (tmp) {
bit_count += tmp & 1;
tmp >>= 1;
}
uint8_t table[8] = { 0, 1, 2, 3, 4, 5, 6, 7 };
uint64_t seed = bit_count;
int n = 8;
for (int i = 7; i > 0; i--) {
seed = seed * 0x3F5713FCCC7C79AA + 0x3A7D9E5B36F498B2;
int idx = ((uint32_t)(seed >> 32)) % n--;
uint8_t tmp = table[i];
table[i] = table[idx];
table[idx] = tmp;
}
for (int i = 0; i < 8; i++) {
uint8_t bit = (xor_val >> table[i]) & 1;
result |= (bit << i);
}
return result;
}
// 变种TEA加密函数
__int64 __fastcall TeaEncrypt(uint32_t* data, const uint32_t* key) {
uint32_t v0 = data[0];
uint32_t v1 = data[1];
uint32_t sum = 0;
uint32_t rounds = 0;
while (rounds < 0x3C) { // 60轮
v0 += (sum + key[sum & 3]) ^ (v1 + ((v1 >> 5) ^ (16 * v1)));
sum -= 0x788EEF63;
v1 = v1
+ 0x7C77AF7C
+ ((sum + v0) ^ v0 ^ (key[2] + 16 * v0) ^ (key[3] + (v0 >> 5)) ^ 0x4321)
- 1493243931;
++rounds;
}
data[0] = v0;
data[1] = v1;
return v0;
}
// 变种TEA解密函数
__int64 __fastcall TeaDecrypt(uint32_t* data, const uint32_t* key) {
uint32_t v0 = data[0];
uint32_t v1 = data[1];
uint32_t rounds = 0x3C; // 60轮
uint32_t sum = 0;
for (int i = 0; i < 60; i++) {
sum -= 0x788EEF63;
}
while (rounds > 0) {
v1 = v1
- 0x7C77AF7C
- ((sum + v0) ^ v0 ^ (key[2] + 16 * v0) ^ (key[3] + (v0 >> 5)) ^ 0x4321)
+ 1493243931;
sum += 0x788EEF63;
v0 -= (sum + key[sum & 3]) ^ (v1 + ((v1 >> 5) ^ (16 * v1)));
--rounds;
}
data[0] = v0;
data[1] = v1;
return v0;
}
//最后一轮,readmsr指令时handler做的异或
void FinalXor(uint8_t* data, int len, const uint8_t* xor_key1, const uint8_t* xor_key2) {
uint8_t xor_table[8];
uint8_t* ptr = data;
int blocks = len / 2;
while (blocks--) {
for (int v10 = 0; v10 < 8; v10++) {
xor_table[v10] = v10 + len + xor_key1[v10 % 5] + xor_key2[v10 % 7];
}
for (int i = 0; i < 8; i++) {
ptr[i] ^= xor_table[i];
}
ptr += 8;
}
}
int main() {
//密文
uint32_t encrypted[] = {
0x199354C3, 0xB1FD7BE6, 0x73205B55, 0xDE5C4D43, 0xA4EF9954, 0xA97651D4,
0xEFBA6B6A, 0xC6E221DE, 0x8FA342FE,0x4C1C63BE, 0xD0AEE4C6, 0xC6F63D4B,
0x3807EBDA, 0x2ADF5814, 0x7A83C42E,0x9E348D33, 0x782779E4, 0xC4A55FC0,
0x0DC0B64D0, 0x7EE36C5D, 0xE43BE42C, 0xD5E405CA, 0xB772C9A7, 0x30CDC7,
0x2F09B31C, 0xFA839DD7, 0x57547B88,0x0F754B5AE, 0x231F7B75, 0x13160770,
0x6EB71579, 0xFA28BBD, 0x6103E890,0xEF604E1D
};
uint32_t key[] = { 0x89, 0xFE, 0x76, 0xA0 };//TeaKey
uint8_t xor_key1[] = { 0x89, 0xAD, 0xCD, 0x12, 0x56 };//MSR Xor
uint8_t xor_key2[] = { 0x58, 0x69, 0x58, 0xEF, 0xB3, 0x46, 0x23 };
FinalXor((uint8_t*)encrypted, 34,xor_key1, xor_key2);
for (int i = 0; i < 34; i += 2) {
TeaDecrypt(&encrypted[i], key);
}
uint8_t xor_key3[6] = { 0xD2, 0x5F, 0x26, 0x11, 0x67, 0x06 };// 第三问计算出来的Key
char ans[35]{};
for (int i = 0; i < 34; i++) {
for (int j = 32; j < 128; j++) {
uint8_t res = Encrypt(j, i) ^ xor_key3[i % 6];
if (res == encrypted[i]) {
ans[i] = j;
break;
}
}
}
ans[34] = '\0';
printf("%s\n", ans);
}
// ACE_C0n9raTs0nPA55TheZ02S9AmeScTf#
成功解出Flag,输入进注册表中验证是正确的。
串联逻辑说明
最后按照题目要求串讲一遍Flag的加密逻辑:
1.取出注册表中明文后,取数时挂Hook触发一个单字节不可逆的加密,之后与密钥循环异或
2.进入一个魔改的Tea函数(也被挂上了Hook)
3.通过__readmsr指令进行最后一层异或加密
4.与预计算好的密文两两相比(因为Tea是两个两个加密的)
正确解出flag(1分)
在分析中已经给出EXP,解出FlagACE_C0n9raTs0nPA55TheZ02S9AmeScTf#
该题目使用了一种外挂常用的隐藏手段,请给出多种检测方法,要求demo程序能在题目驱动运行的环境下进行精确检测,方法越多分数越高(3分)
在做完上面的题目以后,已经明白赛题使用的外挂隐藏手段是VT,VT能够给函数挂上“无痕”Hook,若用于Hook系统函数,比如RtlWalkChain、MmIsAddressValid,能够在规避PG的同时规避反作弊的检测。
本题可以理解为,给出多种检测当前是否处于虚拟化环境的手段;更深一层地说,给出检测EPT Hook的手段。
注:由于调试需要,本次的检测环境都是在虚拟机下进行,同时赛题驱动也运行在虚拟机环境中,这导致了嵌套虚拟化。因此所给代码实质上是在检测嵌套虚拟化环境。对于利用时序检测的方式,若运行在物理机上,可能需要适当调小阈值。不过方法是通用的。
方法一:CPUID检测(时序检测)(3环)
原理:CPUID是一条特权指令,在虚拟机中,其会触发VM-EXIT,由Hypervisor接管执行,这样VM EXIT以及重新进入VM会引入额外的延迟。因此可以考虑故意去多次执行CPUID取执行时间,转化成一个指数来判断当前运行环境。在Win10 22H2上得到如下的效果:
void CheckByCPUID() {
const auto originalPriority = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL);
// 计算1000ms的CPU周期数
const auto cyclesStart = __rdtsc();
Sleep(1000);
const auto cyclesPerSecond = __rdtsc() - cyclesStart;
// 测量CPUID指令时间
uint64_t cpuidTotalCycles = 0;
for (size_t i = 0; i < 0x6ACE; i++) {
const auto beforeCpuid = __rdtsc();
int cpuidResult[4] = {};
__cpuid(cpuidResult, 0);
cpuidTotalCycles += __rdtsc() - beforeCpuid;
}
// 算出一个结果指数,其代数大小表明CPUID指令运行了多久
double vtDetectionScore = 10000000.0 * cpuidTotalCycles / cyclesPerSecond / 0x65;
// 还原线程优先级
SetThreadPriority(GetCurrentThread(), originalPriority);
// 输出结果
printf("CPUID执行时间指数 :%.2f\n", vtDetectionScore);
if (vtDetectionScore < 200.0) {
printf("运行在物理机上\n");
return;
}
else if (vtDetectionScore > 5000.0) {
printf("检测到嵌套虚拟化环境\n");
return;
}
printf("运行在虚拟机环境上\n");
}
在开启赛题驱动前后分别打开demo程序,看看效果:
对于不同的环境这个时间指数差异会较大,不过可以肯定的是,开启了虚拟化后执行指数在未开启的十倍以上。
方法二:同步线程计数(3环)
在虚拟化环境中,CPUID指令的执行时间会远大于普通指令的执行时间,可以考虑开两个同步线程,执行相同次数后测量时间比率,以此为基准判断当前环境。
void CheckByThread() {
if (std::thread::hardware_concurrency() < 2) {
throw std::runtime_error("至少需要2个CPU核心");
}
// 执行足够次数以平均开销
constexpr auto executionCount = 100000;
// 测量普通指令执行时间
const auto normalTime = MeasureExecutionTime([&]() {
ExecuteNormalInstructions(executionCount);
});
// 测量CPUID指令执行时间
const auto cpuidTime = MeasureExecutionTime([&]() {
ExecuteCpuidInstructions(executionCount);
});
// 计算时间比率
double ratio = static_cast<double>(cpuidTime) / normalTime;
printf("比率:%.2f", ratio);
if (ratio > 1.0) {
printf("检测到嵌套虚拟化环境\n");
return;
}
printf("运行在物理机上\n");
}
这个方法使用了比值处理,对于不同机器的移植性优于第一种。效果:
在物理机上,这个比率的数量级是
$$
10^{-3}
$$
方法三:主动进入VMX(0环)
Intel规定,每个处理器核心只能同时进入一次VMX root mode,也就是说,在赛题驱动正常运行时,其他驱动无法进入VMX。基于这一点,可以设计一个驱动程序,主动调用VMXON尝试进入VMX,若VMXON成功,则代表当前不在VT环境下;若失败,则可以判断已经有其他驱动开启了VT。
NTSTATUS TryVmxOn()
{
// 检查 CPUID 是否支持 VMX
int cpuInfo[4];
__cpuid(cpuInfo, 1);
if (!(cpuInfo[2] & (1 << 5))) {
DbgPrintEx(77,0,"VMX not supported by CPU.\n");
return STATUS_NOT_SUPPORTED;
}
// 设置 CR4.VMXE = 1
__writecr4(__readcr4() | (1 << 13));
// 分配一页物理对齐内存用于 VMXON 区域
PVOID vmxonRegion = ExAllocatePoolWithTag(NonPagedPoolNx, VMXON_REGION_SIZE, 'vmxT');
if (!vmxonRegion) {
DbgPrintEx(77, 0, "Failed to allocate VMXON region.\n");
return STATUS_INSUFFICIENT_RESOURCES;
}
RtlZeroMemory(vmxonRegion, VMXON_REGION_SIZE);
// IA32_VMX_BASIC MSR [0]–[30] 是 revision ID,需要放到 VMXON 区域前4字节
UINT64 vmxBasic = __readmsr(0x480); // IA32_VMX_BASIC
*(ULONG*)vmxonRegion = (ULONG)(vmxBasic & 0xFFFFFFFF);
// 获取物理地址
PHYSICAL_ADDRESS vmxonPhys = MmGetPhysicalAddress(vmxonRegion);
// 尝试执行 VMXON
ULONG64 result = 0;
__try {
__vmx_on(&vmxonPhys.QuadPart);
DbgPrintEx(77, 0, "VMXON succeeded.\n");
}
__except (EXCEPTION_EXECUTE_HANDLER) {
DbgPrintEx(77, 0, "VMXON failed with exception.\n");
result = GetExceptionCode();
}
// 清理
ExFreePoolWithTag(vmxonRegion, 'vmxT');
return result ? STATUS_UNSUCCESSFUL : STATUS_SUCCESS;
}
效果如下:当赛题驱动运行时,返回Failed;卸载赛题驱动后,调用VMXON成功
方法四:监控CR4.VMXE(0环)
驱动程序在调用VMXON进入VT-x前,首先要设置CR4的VMXE位为1,因此,可以持续监控这一位,一旦发现VMXE位被设为1,则可以判断有驱动程序正在开启VT。
VOID CheckCR4VMXE()
{
ULONG64 cr4 = __readcr4(); // 读取CR4寄存器
if (cr4 & (1ULL << 13)) {
DbgPrintEx(77,0,"有驱动程序正在开启VT.\n");
}
}
// 线程函数,每秒执行一次 CheckCR4VMXE
VOID CheckVmxThread(PVOID StartContext)
{
UNREFERENCED_PARAMETER(StartContext);
LARGE_INTEGER interval;
interval.QuadPart = SLEEP_INTERVAL; // 1秒
while (g_ShouldRun)
{
CheckCR4VMXE();
KeDelayExecutionThread(KernelMode, FALSE, &interval);
}
PsTerminateSystemThread(STATUS_SUCCESS);
}
先加载监控驱动,然后加载赛题驱动,赛题驱动在加载过程中将VMXE位置为1,会被监控到,效果:
方法五:invd指令触发VM-Exit(0环)
invd指令触发时,会引起VM-Exit进入Host处理,在规范的虚拟机比如Vmware中,正常模拟了这个指令就不会报错。但一些虚拟化框架如果没有处理这个指令,就会抛出异常(比如赛题)。因此,可以设计一个驱动程序,主动触发VM-Exit,若抛出异常,则认为在VT环境下。
#include <ntddk.h>
#include <intrin.h>
// 声明外部裸函数(汇编实现)
extern void Test_invd();
void DetectInstruction(const char* name, void (*func)())
{
__try {
func();
DbgPrintEx(77,0,"[+] %-8s 执行成功,未引发异常。\n", name);
}
__except (EXCEPTION_EXECUTE_HANDLER) {
NTSTATUS code = GetExceptionCode();
DbgPrintEx(77, 0, "[-] %-8s 引发异常:0x%08X\n", name, code);
DbgPrintEx(77, 0, "检测到虚拟化环境\n");
}
}
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
DbgPrintEx(77, 0, "驱动卸载完成。\n");
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = DriverUnload;
DetectInstruction("INVD", Test_invd);
return STATUS_SUCCESS;
}
Test_invd PROC
invd
ret
Test_invd ENDP
赛题未加载前,加载本驱动,invd成功执行;赛题驱动加载后,由于赛题没有模拟invd指令,所以会抛出异常,此时则检测到虚拟化环境:
方法六:未定义的vmcall触发VM-Exit(0环)
类似方法五,传一个空调用vmcall指令,vmware能正常处理,但赛题不行。
#include <ntddk.h>
// 汇编函数声明
extern void Test_invd();
extern void Test_xsetbv();
extern void Test_getsec();
extern void Test_vmcall();
extern void Test_vmread();
extern void Test_vmwrite();
extern void Test_vmlaunch();
extern void Test_rdmsr();
extern void Test_wrmsr();
typedef struct {
const char* Name;
void (*Func)();
} InstructionTest;
void DetectInstruction(const char* name, void (*func)())
{
__try {
func();
DbgPrintEx(77,0,"[+] %-10s 执行成功,未引发异常。\n", name);
}
__except (EXCEPTION_EXECUTE_HANDLER) {
NTSTATUS code = GetExceptionCode();
DbgPrintEx(77,0,"[-] %-10s 引发异常:0x%08X\n", name, code);
}
}
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
DbgPrintEx(77,0,"驱动卸载完成。\n");
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = DriverUnload;
InstructionTest tests[] = {
{"INVD", Test_invd},
{"VMCALL", Test_vmcall},
};
for (int i = 0; i < sizeof(tests) / sizeof(tests[0]); ++i) {
DetectInstruction(tests[i].Name, tests[i].Func);
}
return STATUS_SUCCESS;
}
Test_vmcall PROC
vmcall
ret
Test_vmcall ENDP
效果:
EPT Hook检测
这里给出一种与检测VT方法一相同原理的EPT Hook检测手段,即时序检测。
因为EPT Hook页在执行时需要通过VM-Exit进入进出,所以执行时间必然大;而由于只读页面时不会触发VM-Exit,所以读页面时的耗时应该是正常的。
同时又知道,赛题的Hook点是自身Tea函数,那么可以设计一个驱动函数,主动去读和执行Tea函数,若执行时间远大于读取时间,则判断为被EPT Hook了。当然,如果去读和执行正常函数,会发现相差无几,可以判断没有被Hook。
核心代码如下:
ULONGLONG MeasureExecuteTime(PVOID Address) {
g_TeaEncrypt = (fpTeaEncrypt)Address;
ULONGLONG t1 = __rdtsc();
DbgPrintEx(77, 0, "t1:%llu\n", t1);
__try {
// 随便传入参数,执行一次来获取执行时间
unsigned int data[2] = { 0x12345678, 0x9ABCDEF0 }; // 明文
unsigned int key[5] = { 0xA1B2C3D4, 0xB2C3D4E5, 0xC3D4E5F6, 0xD4E5F607, 0 }; // 密钥和附加值
ULONGLONG res = g_TeaEncrypt(data,(__int64)key);
}
__except (EXCEPTION_EXECUTE_HANDLER) {
return 0;
}
ULONGLONG t2 = __rdtsc();
DbgPrintEx(77, 0, "t2:%llu\n", t2);
// 返回执行时间差
return t2 - t1;
}
BOOLEAN GetModuleBaseFromDriver(PDRIVER_OBJECT drv, PCWSTR targetName, PVOID* baseOut, SIZE_T* sizeOut)
{
PLDR_DATA_TABLE_ENTRY ldr = (PLDR_DATA_TABLE_ENTRY)drv->DriverSection;
PLIST_ENTRY head = &ldr->InLoadOrderLinks;
PLIST_ENTRY current = head;
do {
ldr = CONTAINING_RECORD(current, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
if (ldr->BaseDllName.Buffer && _wcsicmp(ldr->BaseDllName.Buffer, targetName) == 0)
{
*baseOut = ldr->DllBase;
*sizeOut = ldr->SizeOfImage;
return TRUE;
}
current = current->Flink;
} while (current != head);
return FALSE;
}
VOID DetectEPT_ReadLatency(PVOID ModuleBase, SIZE_T ModuleSize)
{
ULONGLONG readtime = MeasureReadPageTime((ULONGLONG)ModuleBase + TEA_OFFSET);
ULONGLONG executetime = MeasureExecuteTime((ULONGLONG)ModuleBase + TEA_OFFSET);
DbgPrintEx(77, 0, "[EPT DETECT]readtime:%d,executetime:%d\n",readtime,executetime);
//经验阈值
if (executetime > readtime * 10) {
DbgPrintEx(77, 0, "[EPT DETECT]%p EPT Hook detected!\n",(ULONGLONG)ModuleBase + TEA_OFFSET);
}
}
效果如下:
另外,还有一种检测方式是,给模块中每页的unuse部分(即0xCC填充部分)修改为C3,即ret。如果没有EPT Hook的话,执行新的指令不会产生异常;如果某一页产生了异常,则说明实际执行的并非我们所修改的那一页,即这一页被挂上了EPT Hook,从而检测到。因比赛时间有限,暂时没有完成代码。