【CTF对抗-2025腾讯游戏安全技术竞赛决赛题解】此文章归类为:CTF对抗。
(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分)
驱动带反调,且目测有 VMP 壳,于是选择 dump+Fix,由于驱动带反调,会蓝屏,于是 hook 蓝屏代码,选择该时机去 dump 内存,找到 Entry。
随后跟进,遇到一些立即数的赋值,且有函数加密,直接选择模拟执行。
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 68 69 70 | import idaapi import idc from unicorn import * from unicorn.x86_const import * import ida_name import mmap import sys import idautils import struct base_addr = idaapi.get_imagebase() fix_function_start = 0xFFFFF806FF8D9F0C fix_function_end = 0xFFFFF806FF8DA05F PAGE_SIZE = 0x1000 RSP = 0xdead0000 RBP = 0xdead0000 map_addr = idaapi.get_imagebase() offset = base_addr - map_addr def hook_mem_unmapped(uc, access, address, size, value, user_data): aligned_addr = address& 0xFFFFFFFFFFFFF000 try : uc.mem_map(aligned_addr, PAGE_SIZE) data = idaapi.get_bytes(aligned_addr,PAGE_SIZE) uc.mem_write(aligned_addr,data) return True # 表示错误已处理,继续执行 except Exception as e: print (f "[-] 动态映射内存页失败: {e}" ) return False instr_count = 0 cnt = 0 def hook_code(uc, address, size, user_data): global instr_count,cnt instr_count + = 1 rax = uc.reg_read(UC_X86_REG_RAX) rcx = uc.reg_read(UC_X86_REG_RCX) rdx = uc.reg_read(UC_X86_REG_RDX) r8 = uc.reg_read(UC_X86_REG_R8) r9 = uc.reg_read(UC_X86_REG_R9) r10 = uc.reg_read(UC_X86_REG_R10) rbp = uc.reg_read(UC_X86_REG_RBP) rsp = uc.reg_read(UC_X86_REG_RSP) rip = uc.reg_read(UC_X86_REG_RIP) if rip = = 0xFFFFF806FF8DA05C : st = b'' offset = 0xE0 while True : if uc.mem_read(rsp + offset, 2 ) = = b '\x00\x00' : break st + = uc.mem_read(rsp + offset, 1 ) offset + = 2 print (st) #print(uc.mem_read(rsp+offset)) mu = Uc(UC_ARCH_X86, UC_MODE_64) mu.reg_write(UC_X86_REG_RIP, fix_function_start) # 设置执行起始地址 mu.reg_write(UC_X86_REG_R13, 0xFF ) mu.reg_write(UC_X86_REG_RSP, RSP) mu.reg_write(UC_X86_REG_RBP, RBP) mu.mem_map(RSP - PAGE_SIZE,PAGE_SIZE * 2 ) mu.hook_add(UC_HOOK_MEM_FETCH_UNMAPPED, hook_mem_unmapped) mu.hook_add(UC_HOOK_MEM_READ_UNMAPPED, hook_mem_unmapped) mu.hook_add(UC_HOOK_MEM_WRITE_UNMAPPED, hook_mem_unmapped) mu.hook_add(UC_HOOK_CODE, hook_code) instr_count = 0 print ( hex (mu.reg_read(UC_X86_REG_RIP))) try : mu.emu_start(fix_function_start,fix_function_end) except UcError as e: print (e) pass |
最后得到一个注册表字符串 \\Machine\\System\\CurrentControlSet\\Services\\ACEDriver\\2025ACECTF
正常直接加载驱动会返回 31 错误,猜测判定了注册表的某些东西,继续往下模拟可得一个字符串 Key。
模拟执行可以尽量挑不依赖外部函数,且立即数比较多的片段,这样可以省略计算的过程。
下面也可以模拟,但是根据题目描述也能猜个大概,有一个 Key
,有一个 Flag
。再结合该函数的定义和调用
不难得到 Key 应该是一个 __int64
的值,Flag 是一个字符串,保存到全局变量当中,创建对应的注册表项,成功加载驱动。
前面说过,有反调试,观察导入表遍历了 NtQuerySystemInformation
,于是想到可能是检测到了 kdcom.dll
模块(因为之前有游戏做过类似的检测),那么直接 hook 把 kdcom.dll
改名。
因为保护了 IAT,因此不能使用常规的 IAT hook,还是选择使用 inline hook
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | NTSTATUS gh_NtQuerySystemInformation(...) { unhook(); auto ret = ((NtQuerySystemInformation_t)(TargetFunction))(...); if (SystemInformationLength&& SystemInformationClass== SystemModuleInformation) { PSYSTEM_MODULE_INFORMATION pModInfo = (PSYSTEM_MODULE_INFORMATION)SystemInformation; for ( int i = 0; i < pModInfo->ModulesCount; i++) { PSYSTEM_MODULE_INFORMATION_ENTRY pEntry = &pModInfo->Modules[i]; if ( strcmp (pEntry->Name + pEntry->NameOffset, "kdcom.dll" )) { (pEntry->Name + pEntry->NameOffset)[0] = 'x' ; } } } rehook(); return ret; } |
绕过之后加载驱动不会蓝屏,但是会出现另一个错误。
随后查看 DbgView 发现似乎是 vmp 自带的,手上有 3.8 版本,尝试编译放进去加载,果然如此,一摸一样的错误代码。
这个可以通过字符串定位,也可以由上面注册表继续往后分析得到。
当输入的 Key
为 0
时,尝试使用算法生成。通过分析该函数,结合一些一些字符串可知,该算法自己实现了一个双端队列(deque),但是实际使用的时候是把它当成栈来用了,实现了一个深度优先搜索算法。
第一步恢复 deque
结构体,第一个 8 字节是一个指向自身的指针,但是似乎没有用过,正常来说应该是虚表。双向队列会有全队列大小(队列最多容纳的元素个数),头指针还有尾指针,而通常情况下,后两者可以使用头指针 + 有效元素个数来实现,因此最后得到以下定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | struct deque { void *vtable; data **map; __int64 MAX_SIZE; __int64 begin_idx; __int64 size; }; struct data { int x1; int y1; _QWORD data2; _QWORD data3; int x4; int y4; }; |
应用到 IDA 之后,配合注释,算法一目了然。
深入阅读它实现 deque 的源码其实可以明白,第一,它的 MAX_SIZE
一定是 2 的整数幂,并且它是环形队列。第二,在取模的时候更加高效(即 &(MAX_SIZE-1)
)。
循环开头压入了 (44,22)
元素。
每次循环开始,取得尾部的元素,判断 x1
是否为 0,或者说 x==y
,如果是则删除该元素。
否则尝试先往左走(即 x-1
)并立刻将往左走的点压入栈中重新循环,经典的 DFS。
往左走之后会将当前点标记为已经往左走过,这里 x4 的值有以下三种情况:
0
:还没走过。1
:已经往左走过。2
:已经往左走过,且已经往左下走过。当 x4==2
时,该点也会被删除,并将,结合图中的注释大概也能看懂这个算法了,这里画了一个图更好理解
从黑色格子出发,只能向左或者向左上(y轴往下的情况下)。红色格子不能继续走,价值为1,同样在 y 0层也有一行红色格子价值为 1
,其余格子价值均为 x%5
,最后应该是计算黑色格子到红色格子的所有不同的路径的价值之和。
优化可以使用记忆化搜索,或者直接使用动态规划,记忆化搜索简单无脑,三行搞定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #include<stdio.h> #include<string.h> int v[50][50]; long long f[50][50]; long long dfs( int x, int y) { if (x == y || !y) return 1; if (f[x][y] != -1) return f[x][y]; return f[x][y] = x%5+dfs(x-1,y)+dfs(x-1,y-1); } int main() { memset (f, -1, sizeof (f)); printf ( "%lld\n" , dfs(44,22)); } //7039739125714 |
反调试检测:
kdcom.dll
模块,检测到则直接 'ACE'
蓝屏。KdDisableDebugger
绕过:
尝试 hook NtQuerySystemInformation
,KeBugCheckEx
,找到蓝屏的函数在 0x74F0
,于是考虑在 hook NtQuerySystemInformation
的某个节点,把该函数 hook 直接返回,不会蓝屏,但是调试器被剥离。
调试发现是调用了 KdDisableDebugger
函数。
同样也是直接返回,操作完成后,可以发现驱动已经可以正常运行,且调试器正常工作。
这里代码实现仅仅变动了 hook 的 NtQuerySystemInformation
函数,因为有 vmp
壳,所以在加载的时候去 hook 是不明智的,直接在调用 NtQuerySystemInformation
的某一刻过掉即可。
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 | NTSTATUS gh_NtQuerySystemInformation(...) { unhook(); //... PVOID stack[MAX_BACKTRACE_DEPTH+1] = { 0 }; RtlWalkFrameChain(stack, MAX_BACKTRACE_DEPTH,0); bool flag = 0; static int cnt = 0; for ( int i = 0; i < MAX_BACKTRACE_DEPTH; i++) { if (stack[i]>= Hooks::Base && stack[i] <= ( PVOID )(( UINT64 )Hooks::Base + Hooks::Size)) { flag = 1; break ; } } if (flag) { char code = 0xC3; cnt += 1; if (cnt == 3) { DriverUtil::MDLWriteMemory(( PVOID )(( UINT64 )Hooks::Base + 0x74f0), &code, 1); DriverUtil::MDLWriteMemory(( PVOID )(KdDisableDebugger), &code, 1); return ret; } } rehook(); return ret; } |
因为壳似乎有 API 防 hook 的检测,如果不及时下掉钩子则会加载失败,因此选择在第三次调用之后下掉钩子并做反调试的相关 hook。
结论:
flag
之后,先做一次单表映射的替换,这里是由 VT 实现的,hook点在(+0x95DF
)key
对输入的 flag
进行异或加密。rdmsr(0xE8)
在 check
之前异或了一个密钥,这个密钥由输入的 flag
长度决定。从后续的逻辑来看,生成的 key
就是 flag
做某种加密的密钥。
这里的 v10
,经过动态调试,记录了最高有效位,例如我现在输入的 key=0x25312620c4fe
,占用 6 字节,所以最高有效位为第五位(从零开始),如图所示
因此第一步就是实现一个简单的异或加密,根据密钥的长度而定。
紧随其后的是 TEA
加密,和初赛一样,每两个字符零扩展成 int
之后放入 TEA
加密。
乍一看这里居然用了 key
的地址进行运算,实则不需要被他吓到,这么玩确实会导致每次加密的结果不一样,但是不代表就不可逆(后来嘎嘎被打脸),逆了一下发现逆推到第一个式子的时候推不动了。
经过调试,发现是代码被 VT hook 了,联想到之前要求一定是 Intel CPU
。
可以看到单步执行得到的指令结果不符合预期,题目在此处开启 VT 环境。
在 +0x5150
处的函数实现 hook
的分发。
答案:flag: flag{ACE_C0n9raTs0nPA55TheZ02S9AmeScTf#}
由于分发函数过于庞大,且 VT 的hook是无痕的,因此考虑能否使用加密的弱点去实现 flag 的解密,由于 TEA 加密的输入是被零扩展的,因此实际 8 字节的分组只有 2 字节是有效的。
可以计算两字节的所有组合,获得它的密文结果,实施这个方法之前,需要确定,相同的密文,相同的 key
,得到的一定是相同的输出,断 TEA
加密的 call
,选中 RCX
的内存,改成全 0,得到 A9 59 CF AB EB 9D A3 0A
,多个位置尝试发现得到的始终是这个结果,因此判定该方法可行。
这里方法就多起来了,第一可以把注册表写满 0000-FFFF
,然后指定 Key
为 0xFF
,就可以 dump 得到一份表,或者可以直接写一个驱动去调用那个功能,这里我选择了后者。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | typedef VOID (*TEAEnc)(unsigned int *, unsigned int *); extern "C" NTSTATUS DriverEntry( _In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath ) { DriverObject->DriverUnload = DriverUnload; TEAEnc teaEnc = (TEAEnc)0xFFFFF8032AC51560; unsigned int key[4] = { 0x00000089, 0x000000FE, 0x00000076, 0x000000A0 }; unsigned int data[2] = { 0x00000000, 0x00000000 }; teaEnc((unsigned int *)data, (unsigned int *)key); for ( int i = 0; i < 8; i++) { DBG_PRINT( "data[%d]=%02x\n" , i,((unsigned char *)data)[i]); } return STATUS_SUCCESS; } |
观察 windbg 的输出,得到了正确的运行结果:
理论可行,那就直接 for 爆一遍,然后存到内存里面,最后 windbg 直接 dump 出来。
但是发现直接 dump
无法直接查找得到,经检查,原来是 rdmsr
被 VT hook
,做了一次异或加密,并且根据长度生成异或的密钥,很简单,直接把内存都置 0 就能直接拿到异或的密钥,并且密钥由输入的 flag
长度决定,34 长度的密钥为 03 39 49 26 2F F6 F8 4E
。
做完异或加密之后,就可以配合 dump
的密文查表。
用如下脚本查表:
1 2 3 4 5 6 7 | target=b '\xc0\x6d\xda\x3f\xc9\x8d\x05\xff\x56\x62\x69\x55\x6c\xbb\xa4\x90\x57\xa0\xa6\x82\xfb\xa7\x8e\xe7\x69\x52\xf3\xc9\xf1\xd7\x1a\x88\xfd\x7b\xea\xa9\x91\x95\xe4\x02\xc5\xdd\xe7\xf6\x64\xcb\x0e\x88\xd9\xd2\x4e\x1e\x3b\xae\x27\x64\x2d\xfd\xca\x5c\x1c\x7b\xcc\xd0\xe7\x40\x6e\x5e\xef\xa9\x5d\x8a\xd3\x5d\x42\xfa\x72\x9a\x1b\x30\x2f\xdd\x72\xc2\xe5\xf3\x1c\x9b\xa4\xf0\x3b\x91\xe8\x3b\xc8\x4e\x1f\x8a\x40\x09\xf8\x6b\x7b\xb4\x8b\x42\x1d\x71\x81\x43\xac\xb9\x76\x42\x56\x05\x5f\xf1\xee\x5d\x7a\x2c\xfe\x48\x92\x7d\x5a\x41\x93\xd1\x4a\x47\x32\xb8\x98\xa1' table=open( "./enc.bin" , "rb" ).read() for i in range(0,136,8): num = table.find(target[i:i+8]) //8 x=num //256 y=num%256 print(f "{x:02x} {y:02x}" ,end= " " ) |
得到结果
1 | f6 59 ab d7 ea 18 94 ab d4 57 b1 d4 84 c6 f0 d4 53 4e 32 81 bc 86 c3 32 1b 5b f5 67 84 c3 05 96 c6 01 |
为了验证 TEA
,选择在做完异或加密之后直接把以上密文贴到 check
的内存中,结果返回正确。
随后拿 Key
异或还是无法得到正确的结果,经查在 +0x95DF
处的指令,读取 flag
的时候存在 VT hook
。
可以发现内存实际是 A
但是读取结果为 0x24
,存在类似的单表替换,而刚刚好,TEA 解密得到的信息异或 key
之后得到的值刚好是 0x24
结合初赛的 flag
格式,A
应该是正确的明文了。
这里就是动调大法,按了三个小时调试器,在取内存的地方下断点,然后每次给内存自增 1,观察 ax
寄存器是否符合预期,最终得到正确的结果
题目明显使用 VT 技术实现对内存某些部分的无痕 hook
,因此我们的做法就是去检测自身运行是否处于 VT
环境。
1 2 3 4 5 6 7 8 9 10 11 12 | bool is_vt_enabled1() { const unsigned int IA32_FEATURE_CONTROL = 0x3A; unsigned long long msr_val = __readmsr(IA32_FEATURE_CONTROL); return (msr_val & (1 << 2)) != 0; // VMX outside SMX enabled } if (is_vt_enabled1()) { DBG_PRINT( "VT1 is enabled\n" ); } else { DBG_PRINT( "VT1 is not enabled\n" ); } |
但是题目做了 hook
,题目运行的时候,我们读取的 MSR
显示是没开启 VT 的。
如图所示,前者开启题目驱动,VT1
提示 not enable
,卸载题目驱动后又能够检测到 VT
处于开启状态。
于是这里我想到,如果题目要 hook,那么在读取 MSR 的时候必然要运行很多额外代码,这里选择对比开启题目驱动和关闭题目驱动之间的时间差。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | bool is_vt_enabled1() { LARGE_INTEGER freq; // 获取频率 LARGE_INTEGER start = KeQueryPerformanceCounter(&freq); const unsigned int IA32_FEATURE_CONTROL = 0x3A; unsigned long long msr_val; for ( int i = 0; i < 100000; i++){ msr_val = __readmsr(IA32_FEATURE_CONTROL); } LARGE_INTEGER end = KeQueryPerformanceCounter(NULL); LONGLONG delta = end.QuadPart - start.QuadPart; LONGLONG elapsed_us = (delta * 1000) / freq.QuadPart; DBG_PRINT( "Elapsed time: %lld ms\n" , elapsed_us); // 打印经过的时间 return (msr_val & (1 << 2)) != 0; // VMX outside SMX enabled } |
运行结果:
可以发现,我测试了 100000 条 MSR 指令,正常虚拟机运行 132ms,而开启题目驱动之后来到了 2079ms
,接近 20 倍的差距。那么第一种方法可以是找一条跟 MSR 指令差不多周期的指令,但是这个指令不会被 VT 影响,最后计算两者的偏差率,超过很多则时间判定不通过,当然它如果不 hook MSR,就可以选择直接判定对应的 VT 位是否处于开启状态,两者联合检测怎么都是可以检测到的。
这里我使用 inc
指令去比较。
1 2 3 4 5 6 7 8 9 10 | start = KeQueryPerformanceCounter(NULL); int a = 1; int * k = &a; for ( int i = 0; i < 100000000; i++) { *k *= 3; } end = KeQueryPerformanceCounter(NULL); delta = end.QuadPart - start.QuadPart; LONGLONG elapsed_us2 = (delta * 1000) / freq.QuadPart; DBG_PRINT( "Elapsed time: %lld ms\n" , elapsed_us2); // 打印经过的时间 |
这里使用指针来保证每次循环的结果均写入内存中。
结果也很完美,那么最终检测 VT 环境可以判断 elapsed_us2/elapsed_us1>5
,如果是则说明处于 VT 环境中被 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 | bool is_vt_enabled1() { LARGE_INTEGER freq; // 获取频率 LARGE_INTEGER start = KeQueryPerformanceCounter(&freq); const unsigned int IA32_FEATURE_CONTROL = 0x3A; unsigned long long msr_val; for ( int i = 0; i < 100000; i++){ msr_val = __readmsr(IA32_FEATURE_CONTROL); } LARGE_INTEGER end = KeQueryPerformanceCounter(NULL); LONGLONG delta = end.QuadPart - start.QuadPart; LONGLONG elapsed_us1 = (delta * 1000) / freq.QuadPart; DBG_PRINT( "Elapsed time: %lld ms\n" , elapsed_us1); // 打印经过的时间 start = KeQueryPerformanceCounter(NULL); int a = 1; int * k = &a; for ( int i = 0; i < 100000000; i++) { *k *= 3; } end = KeQueryPerformanceCounter(NULL); delta = end.QuadPart - start.QuadPart; LONGLONG elapsed_us2 = (delta * 1000) / freq.QuadPart; DBG_PRINT( "Elapsed time: %lld ms\n" , elapsed_us2); // 打印经过的时间 return elapsed_us1 / elapsed_us2 > 5; } |
运行效果:
能够在开启题目驱动的情况下检测到。
伪造虚假的 cpuid 参数,通常情况下真机会返回 0,而开启了 VT 则会接管返回正常的值。参考文章:f06K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6k6h3y4J5k6i4c8Q4x3X3g2U0L8s2g2T1i4K6u0r3x3U0l9J5x3q4)9J5c8U0l9@1i4K6u0r3x3e0y4Q4x3V1k6Z5L8%4N6Q4x3X3c8S2L8Y4c8A6i4K6u0V1j5$3S2W2j5i4c8K6i4K6u0V1k6r3g2@1k6h3y4@1i4K6u0V1M7%4W2K6N6r3g2E0i4K6u0V1k6h3#2#2L8r3q4@1K9h3!0F1i4K6u0W2K9s2c8E0L8q4!0q4x3#2)9^5x3q4)9^5x3R3`.`.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | bool check_invalid_leaf() { constexpr unsigned int invalid_leaf = 0x04201337; constexpr unsigned int valid_leaf = 0x40000000; _cpuid_buffer_t InvalidLeafResponse = {}; _cpuid_buffer_t ValidLeafResponse = {}; __cpuid( reinterpret_cast <int32_t*>(&InvalidLeafResponse), invalid_leaf); __cpuid( reinterpret_cast <int32_t*>(&ValidLeafResponse), valid_leaf); if ((InvalidLeafResponse.EAX != ValidLeafResponse.EAX) || (InvalidLeafResponse.EBX != ValidLeafResponse.EBX) || (InvalidLeafResponse.ECX != ValidLeafResponse.ECX) || (InvalidLeafResponse.EDX != ValidLeafResponse.EDX)) return true ; return false ; } |
该代码可以运行在用户层,经测试,该代码在开启题目驱动的真机上返回为 true
,未运行题目的真机返回为 false
,虚拟机中则一律返回 true
。
更多【CTF对抗-2025腾讯游戏安全技术竞赛决赛题解】相关视频教程:www.yxfzedu.com