首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
4
7
[原创]腾讯游戏安全竞赛2025复赛题解(加强版)
发表于: 2026-1-24 12:15
6863
[原创]腾讯游戏安全竞赛2025复赛题解(加强版)
xi@0ji233
16
2026-1-24 12:15
6863
由于去年很多分析的不到位的地方,故今年想详细分析一遍整体逻辑。 <!--more--> 本篇帖子基于赛后复盘的角度去分析,相比之前发文会有更多详细且有理有据的逻辑分析,少很多暴力和猜测的结果(虽然一些部分还是需要靠猜),旨在真正学习其中的技术。 ## 题目描述 >(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分) ## 加载驱动 开双机调试,加载,驱动直接蓝屏,不要慌。  先 `.reload` 加载符号,再 `lm` 列出所有模块。如果 `NT` 的符号没有加载上 `lm` 是列不出所有模块的,因为列模块的行为需要依赖 `nt` 的 `pdb`。  然后直接 dump 蓝屏的加载好的驱动,IDA 打开之后发现连 `text` 段都没有被正确映射。  但是去查看对应的内存肯定有,原因是题目加了 vmp 壳,它破坏了 PE 头,导致 IDA 按照错误的 section 去映射了文件内容,因此我们需要修一下。  可以看到,`SizeOfRawData` 值被置为 `0` 了,当该值被置 `0` 时,PE 文件认为这个 section 没有映射任何文件内存,因此需要修复这个 dump。 修 dump 的手段很简单:令所有节区的 `section[i].PointerToRawData = section[i].VirtualAddress` 且 `section[i].SizeOfRawData = (section[i].Misc.VirtualSize + FileAlign - 1) & ~(FileAlign - 1)` 这里的 `FileAlgin` 是 PE 的一个字段,通常是 `0x200`,把它对齐即可。 这里给出一下我修复的代码 ```C++ void FixDump(BYTE* base, DWORD fileSize) { if (fileSize < sizeof(IMAGE_DOS_HEADER)) { printf("[-] File too small\n"); return; } IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base; if (dos->e_magic != IMAGE_DOS_SIGNATURE) { printf("[-] Invalid DOS signature\n"); return; } IMAGE_NT_HEADERS64* nt = (IMAGE_NT_HEADERS64*)(base + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) { printf("[-] Invalid NT signature\n"); return; } auto& opt = nt->OptionalHeader; IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt); UINT64 StartAddress = 0x1000; DWORD FileAlign = opt.FileAlignment; DWORD MemoryAlign = opt.SectionAlignment; for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) { char name[9] = { 0 }; memcpy(name, sec[i].Name, 8); if ((sec[i].SizeOfRawData == 0 || sec[i].PointerToRawData != sec[i].VirtualAddress) && sec[i].Misc.VirtualSize != 0) { printf("[-] %-8s Section Corruption Detected or not DumpFile !!\n", name); sec[i].PointerToRawData = sec[i].VirtualAddress; sec[i].SizeOfRawData = (sec[i].Misc.VirtualSize + FileAlign - 1) & ~(FileAlign - 1); } } DWORD epFoa = RvaToFoa(opt.AddressOfEntryPoint, nt); printf("\nEntryPoint FOA : 0x%X\n", epFoa); if (epFoa == 0) printf("[!] EntryPoint not mapped in file\n"); // save HANDLE hFile = CreateFileW( L"fixed.dump", GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr ); if (hFile == INVALID_HANDLE_VALUE) { printf("[-] Failed to create output file\n"); return; } DWORD written = 0; if (!WriteFile(hFile, base, fileSize, &written, nullptr) || written != fileSize) { printf("[-] Failed to write output file\n"); CloseHandle(hFile); return; } CloseHandle(hFile); printf("[+] Fixed dump saved to fixed.dump\n"); } ``` 这样就修上了,ida 打开可以发现映射的内存和调试器中的内存是可以一一对应的。  下面需要去分析 dump,由于 PE 头被破坏,因此得到的入口点也是错误的,这里还是 Lumina 大法。  因为 WDF 框架的 Entry 基本都一致,所以不用记特征码,直接 lumina 识别肯定能识别出来。 找到 Entry 之后需要小修一下。  由于 VMP 的导入表保护,导致出现了一些小花指令,这里给出修的经验:如果熟悉,直接把可能的CALL API指令后一个字节 NOP,然后重新生成指令,就像下面一样。  假设你不熟悉,那么就要请本期的真神了,一把梭的利器 —— `unicorn`,直接对 API 模拟一遍就能发现指令序列。  模拟跑一遍就会发现几乎所有 API 调用之后都会放一个无用字节干扰反汇编器,所以熟悉了直接 NOP,不熟悉模拟一遍也能得到这个结论。  成功识别出 Entry。 再跟进去找到真实的逻辑  然后还是开始模拟大法,这里就五个调用的函数,都模拟一遍给出结论: 第一个函数  没有进行任何的 API 调用,只有一个 cpuid 指令引起了兴趣,再加上题目要求是 Intel CPU,基本可以断定是进行一定的环境判断。 第二个函数模拟了 10000 条指令都没结束,且没有遇到任何 API 调用,是被 vm 保护的关键逻辑,这里我们不去硬刚 vm,绕道而行,来看看第三个函数。 模拟第三个函数基本上就是一眼丁真  这要还不能分析出怎么加载驱动那是真没招了。 那么第一问到这里就结束了,在驱动注册表目录下新建一个表项 `2025ACECTF`,里面建两个字段,一个是 `Flag` 一个是 `Key`,有了这些表项,驱动就能直接加载成功(不加调试器)。 ## 调试驱动 要做到调试驱动,就需要过反调,需要明白它具体反调的措施在哪里。经过调试可以发现刚没有分析出的第二个函数就是反调试的主要逻辑,这一块被 vm 了怎么办呢?抽丝剥茧找线索,第一搜索蓝屏代码,0x414345。 可以找到两个位置出现了这样的字节序列  其中 `0x16C4` 的逻辑就是清栈加调用蓝屏函数。  交叉引用找不下去了,这个时候需要注意到另一个函数,也就是 `0x74F0`。基本上,它把一切东西设置成跟自身相关的,都是在引导我们去看的。就比如这里蓝屏代码的 `0x414345`,如果它不想我们找到,理论上可以设置成一个正常的蓝屏代码干扰我们分析,所以我们去分析另一个跟 `ACE` 相关的 `magic number`。  这里打印了一句话,懒得写模拟执行了,直接用 defs 的宏复制解密代码就行。 ```C++ #include "defs.h" #include <stdio.h> unsigned char enc[]={ 0x39, 0xF8, 0x92, 0xBD, 0x67, 0xE4, 0xDF, 0xDC, 0x75, 0xFF, 0x9C, 0x95, 0x6F, 0xEB, 0xD3, 0x08, 0x49, 0xE0, 0x9B, 0x80, 0x53, 0xE9, 0x93, 0x8B, 0xF0 }; _BYTE *__fastcall sub_FFFFF8080AFC79C8(_BYTE *a1) { int i; // [rsp+8h] [rbp+8h] for ( i = 0; i < 0x19; ++i ) a1[i] ^= 0; a1[24] += 16; a1[23] = __ROL1__(a1[23], 2); a1[22] = ~a1[22]; a1[21] ^= 0x80u; a1[20] += 28; a1[19] = __ROL1__(a1[19], 6); a1[18] = ~a1[18]; a1[17] ^= 0x84u; a1[16] += 24; a1[15] = __ROL1__(a1[15], 2); a1[14] = ~a1[14]; a1[13] ^= 0x98u; a1[12] += 4; a1[11] = __ROL1__(a1[11], 6); a1[10] = ~a1[10]; a1[9] ^= 0x9Cu; a1[7] = __ROL1__(a1[7], 2); a1[6] = ~a1[6]; a1[5] ^= 0x90u; a1[4] += 12; a1[3] = __ROL1__(a1[3], 6); a1[2] = ~a1[2]; a1[1] ^= 0x94u; *a1 += 8; return a1; } int main(){ sub_FFFFF8080AFC79C8(enc); printf("%s\n", enc); // Almost success, add oil. } ``` `defs.h` 的定义 ida 里面有,直接找就行。然后果然输出了一句话,告诉我们几乎成功了,加油。。 作为出题人,能给我们这么多提示已经很够意思了,那么我们开始去调这个函数,写一个 hook 框架,这里抄网上开源的就行。总的来说就是注册回调,加载内核模块的时候匹配 `ACE` 进行拦截操作。 ```C++ VOID LoadImageNotifyRoutine( PUNICODE_STRING FullImageName, HANDLE ProcessId, PIMAGE_INFO ImageInfo ) { if (!ProcessId && FullImageName && wcsstr(FullImageName->Buffer, L"ACE")) { DBG_PRINT("\n> ============= Driver %ws ================\n", FullImageName->Buffer); Hooks::Base = ImageInfo->ImageBase; Hooks::Size = ImageInfo->ImageSize; DBG_PRINT("ImageBase: 0x%p\n", (UINT64)ImageInfo->ImageBase); DBG_PRINT("Breakpoint: 0x%p\n", (UINT64)ImageInfo->ImageBase + 0x74f0); } } ``` 断点可以断到,但是由于开头的 vm 导致后续 RIP 落点不确定,因此从头开始调试不太现实,但是模拟执行输出那句话的时候还是发现了蛛丝马迹。  可以发现它一直在死循环输出这句话,从这里就可以得到一个结论了:它肯定创建了线程去反调试的。 当然在它分配内存的时候,还是看到了几个函数调用,由于模拟执行不能完全仿真上下文,所以还是跑调试器里面去查会比较好。 直接断开头的 ExAllocateWithTag 就行,然后断第一个 call,一眼盯真了。  那么直接在第二个 `call` 下断,不出意外的话应该是插入 DPC 的 API 了。  没问题,太完美了。 DPC 例程在 `+0x78c0`,`context` 就的内容直接指向 KeBugCheckEx 函数,但是为什么交叉引用没有找到函数呢?  很棒,原来蓝屏之后还把调用入口给放了个 `C3` 导致反编译没成功。。 这个蓝屏方式有点似曾相识,遥想当年 hook 蓝屏延时一年的操作,当时看出题人发的文章说,该用 DPC 的方式去蓝屏的,没想到第二年的题真就做了。 蓝屏之后的代码就是无限循环输出 `You Found This`。  是的,unicorn 上大分,只要能模拟,绝对不调试。。 这就解释了为什么它防住了我 hook 蓝屏的方法,直接 hook KeBugCheckEx 肯定不行,因为它清栈了,但是我把它清栈的函数 hook 了 ret 呢?也不行,因为hook了这里后面的代码就直接死循环,DPC 死循环,系统直接卡死,所以直接给 `16C4` `ret` 的后果就是卡死系统,不会蓝屏,但是系统也不会有响应,调试器也不会断开,可以正常中断。 如果让 DPC 例程直接 `ret` 会怎么样呢,还是直接蓝屏,蓝屏代码 50。 ``` KDTARGET: Refreshing KD connection *** Fatal System Error: 0x00000050 (0xFFFFF80743600000,0x0000000000000003,0xFFFFF8085CC81230,0x0000000000000002) Driver at fault: *** ACEDriver.sys - Address FFFFF8085CC81230 base at FFFFF8085C520000, DateStamp 67f67836 . Break instruction exception - code 80000003 (first chance) A fatal system error has occurred. Debugger entered on first try; Bugcheck callbacks have not been invoked. A fatal system error has occurred. For analysis of this file, run !analyze -v ``` 这里要研究原因估计得更深入的知识了,但是我们都已经清楚了它的调用逻辑了,为什么不直接阻止它插入 DPC 呢? 初始化,插入,交付,这三个执行完了之后还会执行一个清零内存操作,经过调试发现是 nt 模块,而且长度为 `0x1046000`,这个数值有点大了。。  然后继续运行,发现 nt 的内存一旦被写就直接蓝屏了,蓝屏代码就是我们刚刚看到的那样,看起来刚刚的蓝屏与DPC无关。  那么就清晰了,还需要把这里写 nt 的逻辑跳过去才能往后,直接写个 jmp 过去就行。  好,继续,进入死循环,但是系统依然卡死,只能动一点点。这里逻辑发现挖不下去了,因此最后选择去把 `+0x74f0` 的函数 `ret` 掉。虽然结论与前面一致,但是完整地分析了整个流程。 最后就是耳熟能详的,`KdDisableDebugger` 这个 `API`。完成调试要求,要么干检测,要么干反调试。 这里选择干反调试,一个是防起线程,一个就是内核 API,通常也就是这个 `KdDisableDebugger`,这两个干掉之后,题目就可以正常调试了,也能正常加载。  下面给出关键代码: ```C++ VOID gh_KeSetSystemAffinityThread( KAFFINITY Affinity ) { KeSetSystemAffinityThread(Affinity); char code = 0xC3; // ret DriverUtil::MDLWriteMemory((PVOID)((UINT64)Hooks::Base + 0x74f0), &code, 1); UNICODE_STRING KdDisableDebuggerStr = RTL_CONSTANT_STRING(L"KdDisableDebugger"); auto KdDisableDebugger = MmGetSystemRoutineAddress(&KdDisableDebuggerStr); DriverUtil::MDLWriteMemory((PVOID)(KdDisableDebugger), &code, 1); return; } VOID LoadImageNotifyRoutine( PUNICODE_STRING FullImageName, HANDLE ProcessId, PIMAGE_INFO ImageInfo ) { if (!ProcessId && FullImageName && wcsstr(FullImageName->Buffer, L"ACE")) { DBG_PRINT("\n> ============= Driver %ws ================\n", FullImageName->Buffer); HANDLE hThread; Hooks::Base = ImageInfo->ImageBase; Hooks::Size = ImageInfo->ImageSize; DriverUtil::IATHook(Hooks::Base, "KeSetSystemAffinityThread", (PVOID)gh_KeSetSystemAffinityThread); DBG_PRINT("ImageBase: 0x%p\n", (UINT64)ImageInfo->ImageBase); DBG_PRINT("Breakpoint1: 0x%p\n", (UINT64)ImageInfo->ImageBase + 0x74f0); DBG_PRINT("Breakpoint2: 0x%p\n", (UINT64)ImageInfo->ImageBase + 0x761190); // DbgBreakPoint(); } } ``` 给 `KeSetSystemAffinityThread` 作 `IATHook`,因为调用到这个 API 通常意味着 Vmp 解密代码已经完成,这个时机对代码做 inline hook 是最好的。 ## 优化算法 如果 IDA 反编译的时候出现了这样的情况  通常是调用的函数被声明为 `noreturn`,直接到该函数修改属性即可。  调试问题解决了,不难推出,下面的代码  通过一个函数把 `flag`,`key` 和 `flag` 的长度都读取到了全局变量中再进行判断。 还是那句话,遇事不决,模拟就完了。  如果 Key 给了 0,则调用算法生成,那么主要逆的就是这个函数了。函数里面告诉了你一句话 ``` This step will take a long time to run, maybe 12 hours (depending on your machine), maybe you have a better way? ``` 稍微往下翻你还会翻到一个字符串,紧挨着的,`deque<T> too long`。 对于这个函数,我们先来如梦令一下,令 `x=a1`,`y=a2`。 对第一个遇到的函数分析,模拟一遍发现类似 `new` 关键字分配了内存,然后来到第二个函数里面。  第二个参数指向的内存的前 8 个字节是 `x` 和 `y`,那么猜测这个数据结构应该是 32 字节的,还有剩下的 `24` 字节被置 0 了。 先预设一个结构体 ```C++ struct data{ int x; int y; char pad[24]; } ``` 随后敏锐地发现了,a1[1] 指向的内存在存放 `data` 类型的指针。  不难得到这是一个 40 大小的结构体,其中 +8 指向一个指针数组,+32 指向了数量(因为最后的 `++a[4]`),先用下面的结构体试试看 ```C++ struct data2 { PVOID unknown; data **arr; PVOID unknown2; PVOID unknown3; size_t mysize; }; ``` 得到结果  一目了然,这是一个类似 `vector` 的 `push_back` 操作(当然你看到了字符串就直接知道是 deque 了)。 `deque` 的内部实现用了一个环形队列,环形队列通常有取余的操作,为了优化,它的 `MaxSize` 通常是 `2` 的整数倍次方,因为如果这样的话取余的操作可以化简成 `&(MaxSize - 1)`,也就是我们代码看到的样子。 最终的结构体变成: ```C++ struct data2 { PVOID unknown; data **arr; size_t MaxSize; size_t begin; size_t mysize; }; ``` 如果对 `C++` 熟悉的同学可以知道 +0 的位置就是虚表,识别不出来也没关系,这个字段用不到。  配合上注释之后,一目了然,里面修好之后,外面也是一篇天地。  这里还有一点强调的就是,`begin` 是它指向数组的起始位置的下标,`mysize` 是队列中元素的个数,`(begin + Mysize -1) % MaxSize` 就是末尾元素的下标。 显然,`pad[16]` 的地方是一个 `flag` 标志位字段,`pad` 的地方存放了 `v5`,`v5` 是这个函数运算的结果。 所以可以解出整个结构体: ```C++ struct data { int x; int y; unsigned __int64 value; unsigned __int64 acc; unsigned __int32 flag; }; ``` 改上之后,整个函数就非常清楚了。 ```C++ if ( x ) { v21.x = x; v21.y = y; LABEL_4: memset(&v21.value, 0, 20); deque::push_back(&v20, &v21); mysize = v20.mysize; while ( mysize ) { v7 = v20.arr[(v20.MaxSize - 1) & (mysize + v20.begin - 1)]; py = v7->y; if ( !py || (px = v7->x, py == v7->x) ) { v5 = 1; v20.mysize = --mysize; v20.begin &= -(__int64)(mysize != 0); } else { flag = v7->flag; if ( !flag ) // flag == 0 { v7->flag = 1; v21.x = px - 1; v21.y = py; goto LABEL_4; } v11 = flag - 1; if ( !v11 ) // flag == 1 { v7->value = v5; v7->flag = 2; v21.x = px - 1; v21.y = py - 1; goto LABEL_4; } if ( v11 == 1 ) // flag == 2 { v7->acc = v5; // unused v5 += v7->value + px % 5; mysize = --v20.mysize; if ( !v20.mysize ) v20.begin = 0; } } } } ``` 这个函数,如果你想借助 AI 的力量,这个直接扔给它就能用了,如果你不想借助 AI 的力量,那么继续往下分析。 当 `flag == 0` 时:会令 `newdata.x = x - 1`,`newdata.y = y`,其它字段清空,并压入 deque 中 (注意,此时原来的元素并未弹出,而是将元素的 `flag` 置为 1)。 当 `flag == 1` 时:会令 `newdata.x = x - 1`,`newdata.y = y - 1`,其它字段清空,并压入 deque 中 (同理可得)。 当 `flag == 2` 时:累加前两个状态计算的值,并额外加上 `x % 5` 的值。 写成递归形式也不难: ```C++ int f(x, y) { if (x == y || y == 0)return 1; return f(x - 1, y) + f(x - 1, y - 1) + x % 5; } ``` 这个时间复杂度就是指数级的了,事实上,这个可以用动态规划优化到二次方的复杂度。 但是动态规划太难了怎么办,记忆化搜索即可,对于确定的 `x, y`,如果已经计算了值,那么直接返回这个值即可。 ```C++ long long ff[54][54]; uint64_t f(int x, int y) { if (y == 0 || x == y) return 1; if (ff[x][y] != 0) return ff[x][y]; return ff[x][y] = f(x - 1, y) + f(x - 1, y - 1) + (x % 5); } // f(44, 22) == 0x66711265fd2 ``` 谁人不晓 “十年OI一场空,不开 long long 见祖宗”,要没开 `long long` 你就调吧,一调一个不吱声。 要是还是不确定怎么办?答:模拟执行。 这个函数只要把 `ExAllocatePoolWithTag` 函数实现了,随便模拟,在函数开始的时候设置上下文,`RCX = x, RDX = y` 即可,函数返回的时候把 `RAX` 输出。 以 `10, 5` 为例,我的算法跑出来是 `0x356`。  那么来观察模拟执行的结果。  这就完美了,优化算法这一问到这里也是满分了。 ## 说明Flag的计算流程 这一关最难的是 VT 的分析,但是,如果你有强大的 lumina,那么识别这个库就是轻而易举。  lumina 可以快速识别出这是 `hv` 的库编译的,如果还没用上 lumina,可以使用我搭建的服务器 - <mark class="encrypted">769K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0S2Q4x3X3f1I4y4e0y4Q4x3X3f1%4x3g2)9J5k6e0t1@1y4#2)9K6b7e0R3H3x3o6m8Q4x3V1k6D9N6h3#2A6L8X3q4Q4x3V1k6A6L8X3k6G2i4K6u0W2K9s2c8E0L8l9`.`.</mark> 剩余的可能需要对源码进行调试,当然还是先看看外面的逻辑。 第一步就是简单的用 Key 转成字符串之后进行异或加密  但是实际调试的时候发现并不是这样,调试器来到这里,观察一个神奇的 `mov` 指令。  惊不惊喜,意不意外?是不是瞬间感觉自己见到了假的 `mov` 指令,这个时候是不是不甘心认为输出的是十六进制,就算是十六进制偶数不可能变奇数的(狗头。 实则不要慌,当年我半夜从一点按调试器按到四点一个一个按完了才出来的 flag。这个地方必然被 VT 下了 hook,篡改了这个寄存器的值,首先找到 vmlauch 指令,交叉引用来到加载的地方。  看不懂没关系,根据这个 `rdmsr` 指令搜 `486`。  然后就可以恢复这个结构体,这里浇一点小技巧,为了导入 hv 的结构体,可以直接加载 hv 的 pdb,选择只加载结构即可。  加载完成之后,根据这个偏移,就能大概恢复一下它自己的结构体。 ```C++ struct VCPUContext { char s[2572384]; hv::vcpu_cached_data cache; guest_context *context; unsigned int queued_nmis; unsigned __int64 tsc_offset; unsigned __int64 preemption_timer; unsigned __int64 vm_exit_tsc_overhead; unsigned __int64 vm_exit_mperf_overhead; unsigned __int64 vm_exit_ref_tsc_overhead; char pad[3856]; }; ``` 然后再看看  对应上了,主要的分发逻辑,需要在 `vm_exit` 中看,`vm_exit` 就是在 `guest` 触发需要 `host` 接管时由 `host` 处理的代码,这种页面的替换通常是下了 EPT hook, 先在项目里找到各种的 `vm_exit` 代码,这个代码是 `Intel` 规定的,是改不了的。  EPT 的页的访问违例的代码是 0x30。  `vmread r1, 0x4402` 可以将 `VM_EXIT_REASON` 读取到 `r1` 寄存器中。 知道这些之后,就可以看懂 `vm_exit` 中的主要处理函数了,`vm_exit` 这个函数是可以被 lumina 识别到的,你甚至不需要特意去寻找,如图绿色底的函数就是 lumina 识别到的。  可以看到,读取 `VM_EXIT_REASON` 到寄存器 `RCX` 之后进行了一定的加密,加密方法是:`(reason << 8 | 0x58) ^ 0x7655`,那么 0x30 对应的加密结果就是 0x460D。  进入函数可以看到关键点:  这里读取了 GUEST 的 RIP 然后进行硬编码判断,硬编码结果为 ``` 41 8A 04 0F 88 45 A0 49 ``` 刚好对应读取的指令  随后读取了 GUEST 的寄存器,找到了 `r15+rcx` 指向的内存(`flag` 字符),并且提取了 r8 寄存器的值,回到原来加密地方的上下文不难看出这个 r8 就是读取的下标。 随后将 `flag` 字符和下标变量放入一个函数进行加密替换,想要验证怎么办,那还是模拟执行,把刚刚 mov 第一条指令的结果输入进去,第一个参数给 `0x66`,第二个参数给 `0`。  这就完美了,跟刚刚调试器看到的结果是一致的,这里给一个不吃操作也不吃经济的打法,直接复制出来,引用 defs.h 即可。 ```C++ #include "defs.h" char __fastcall sub_FFFFF8080AFC300C(char a1, char a2) { char v2; // r10 bool v3; // zf unsigned __int8 v4; // r11 unsigned __int8 v5; // dl unsigned __int8 v6; // cl __int64 v7; // rbx __int64 v8; // rcx unsigned int v9; // edi char v10; // r9 __int64 v11; // rdx int v12; // eax _BYTE *v13; // r8 char v14; // dl char v15; // dl _DWORD v17[2]; // [rsp+0h] [rbp-18h] BYREF v2 = 0; v4 = a2 ^ a1; v3 = a2 == a1; v5 = 0; v6 = v4; if ( !v3 ) { do { v5 += v6 & 1; v6 >>= 1; } while ( v6 ); } v7 = 7; v8 = v5; v17[0] = 50462976; v17[1] = 117835012; v9 = 8; do { v10 = *((_BYTE *)v17 + v7); v8 = 0x3F5713FCCC7C79AALL * v8 + 0x3A7D9E5B36F498B2LL; v11 = HIDWORD(v8) % v9--; *((_BYTE *)v17 + v7--) = *((_BYTE *)v17 + v11); *((_BYTE *)v17 + v11) = v10; } while ( v7 > 0 ); v12 = 0; v13 = v17; do { v14 = v4 >> *v13++; v15 = (v14 & 1) << v12++; v2 |= v15; } while ( v12 < 8 ); return v2; } ``` 反正单字节加密直接爆破就行,随后跟到校验的逻辑里面,发现了保留的 TEA 加密:  但是这个 TEA 是有乘法的,显然不可逆,还是猜想它做了 EPT hook,这里吃点操作了,我们想要找到被替换的原页是几乎不可能的,因为外部的 windbg 是通过 kdcom.dll 去通信的,当开启 VT 之后,kdcom.dll 是跑在 GUEST 层的,我们想从 GUEST 层去读 HOST 的代码在设计上就不可能,所以我们需要考虑在 hook 的时候找到被替换的页。 幸运的是,这个是可行的,因为翻看 hv 的代码:  它安装 hook 的行为是通过 guest 调用 vmcall 去实现的,vmcall 是跑在 guest 层的,我们可以中断到,但是这个 input 的 code 是 hv 库自定义的,所以不好说它的值有没有被修改。 找到处理 vmcall 的 `exit_reason`,代码是 0x12,计算得到加密的 reason 是 `0x640D`,回到 `vm_exit` 的分发找到对应的处理例程。  明显它需要让 `(RAX >> 8) ==0xE56C`,然后低八位表示了 vmcall 的调用号。 找到对应的源码:  我们前面也提到过,这个数值是 hv 自己定义的,出题人拿到这个代码很容易改,但是有些东西只要是 Intel 的 CPU,它的代码就是固定的,谁来了都改不了(例如前面的这些宏),所以我们不一定找 8 对应的例程,还是要稍微翻一下。 这里也很简单,我们直接找 HOST 对应处理的例程。  很快就能找到与之相似的调用例程,枚举值被改成了 5。  所以我们重新加载驱动,去断 vmcall 指令,这个指令可以找 parttern 或者还是用 lumina 秒,找到指令位于 `+0x1557`。 发现第一次断住就是在下 ept hook 了。  那么就找 rcx 和 rdx 的值了,RCX 指向原页,RDX 指向目标页,那么把目标页 dump 下来。 +0x560 的地址找到目标的 TEA 加密函数  这个 TEA 加密就是可逆的了,相信在座的各位在这时候已经可以随便秒了。 似乎之后没有什么逻辑了,就剩一个 flag 的校验  但是走到 `rdmsr(0xE8)` 之后发现 flag 被修改了,那么去找对应的 rdmsr 的 `exit_reason = 0x0000001F`,加密的结果为 `0x690D`,找到关键位置。  它根据 flag 的长度对 flag 再次进行异或加密,并且除了调用 `rdmsr(0xE8)` 之外,还要 `[rsp+0x20] == 0x32A48680` 才会执行加密,那么调试跟过来看看,因为程序调用了两次 `rdmsr(0xE8)`。 第一次 rdmsr 的时候是不符合要求的。  第二次 rdmsr 的时候,符合要求  那么这个 rdmsr 执行完成之后,也就成功进行了异或操作。  这个算法也不难,还是 defs.h 直接抄就行。 最后总结一下 flag 的执行逻辑: - 输入的 flag 先用 VT 的 EPT hook 执行单字节加密。 - 输入的 key 根据起最高有效字节的位数对 flag 执行异或加密。 - EPT hook 替换了一个页,使用 TEA 加密 flag。 - 通过 rdmsr 指令对上一步的密文进行又一次异或加密,异或的密文取决于 flag 的长度。 - 最后进行比较。 ## 解密 flag 那么拿到比较的字节,先进行异或解密,然后 TEA 解密,再根据 key 异或解密最后爆破最开始的单字节加密即可。 ```C++ // Solve.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。 // #include <iostream> #include "defs.h" unsigned char EncText[] = { 0xC3, 0x54, 0x93, 0x19, 0xE6, 0x7B, 0xFD, 0xB1, 0x55, 0x5B, 0x20, 0x73, 0x43, 0x4D, 0x5C, 0xDE, 0x54, 0x99, 0xEF, 0xA4, 0xD4, 0x51, 0x76, 0xA9, 0x6A, 0x6B, 0xBA, 0xEF, 0xDE, 0x21, 0xE2, 0xC6, 0xFE, 0x42, 0xA3, 0x8F, 0xBE, 0x63, 0x1C, 0x4C, 0xC6, 0xE4, 0xAE, 0xD0, 0x4B, 0x3D, 0xF6, 0xC6, 0xDA, 0xEB, 0x07, 0x38, 0x14, 0x58, 0xDF, 0x2A, 0x2E, 0xC4, 0x83, 0x7A, 0x33, 0x8D, 0x34, 0x9E, 0xE4, 0x79, 0x27, 0x78, 0xC0, 0x5F, 0xA5, 0xC4, 0xD0, 0x64, 0x0B, 0xDC, 0x5D, 0x6C, 0xE3, 0x7E, 0x2C, 0xE4, 0x3B, 0xE4, 0xCA, 0x05, 0xE4, 0xD5, 0xA7, 0xC9, 0x72, 0xB7, 0xC7, 0xCD, 0x30, 0x00, 0x1C, 0xB3, 0x09, 0x2F, 0xD7, 0x9D, 0x83, 0xFA, 0x88, 0x7B, 0x54, 0x57, 0xAE, 0xB5, 0x54, 0xF7, 0x75, 0x7B, 0x1F, 0x23, 0x70, 0x07, 0x16, 0x13, 0x79, 0x15, 0xB7, 0x6E, 0xBD, 0x8B, 0xA2, 0x0F, 0x90, 0xE8, 0x03, 0x61, 0x1D, 0x4E, 0x60, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; int flag_len = 34; unsigned char __fastcall doenc(char chr, char idx) { char v2; // r10 bool v3; // zf unsigned __int8 xorvalue; // r11 unsigned __int8 acc; // dl unsigned __int8 v6; // cl __int64 v7; // rbx __int64 onecount; // rcx unsigned int j; // edi char v10; // r9 __int64 v11; // rdx int i; // eax _BYTE* v13; // r8 char v14; // dl char v15; // dl _BYTE v17[8]; // [rsp+0h] [rbp-18h] BYREF v2 = 0; xorvalue = idx ^ chr; v3 = idx == chr; acc = 0; // account number of 1 bit v6 = xorvalue; if (!v3) // if char != idx enter { do { acc += v6 & 1; v6 >>= 1; } while (v6); } v7 = 7; onecount = acc; *(_DWORD*)v17 = 0x3020100; *(_DWORD*)&v17[4] = 0x7060504; j = 8; do { v10 = v17[v7]; onecount = 0x3F5713FCCC7C79AALL * onecount + 0x3A7D9E5B36F498B2LL; v11 = HIDWORD(onecount) % j--; // random value v17[v7--] = v17[v11]; v17[v11] = v10; } while (v7 > 0); i = 0; v13 = v17; do { v14 = xorvalue >> *v13++; v15 = (v14 & 1) << i++; v2 |= v15; } while (i < 8); return v2; } unsigned char xorkey[] = { 0xd2, 0x5f, 0x26, 0x11, 0x67, 0x06 }; void XorEnc(char* input, int len){ for(int i = 0; i < flag_len; i++) { input[i] = input[i] ^ xorkey[i % 6]; } } void LastXorEnc(char* input, int len) { unsigned int v7; // esi char* v8; // r11 unsigned __int64 v9; // r10 unsigned int i; // r14d unsigned __int64 j; // rdi char v12; // al unsigned __int64 v13; // rax _BYTE v18[24]; // [rsp+28h] [rbp-38h] BYREF char v19[5]; // [rsp+40h] [rbp-20h] char v20[7]; // [rsp+48h] [rbp-18h] *(_DWORD*)v19 = 315469193; v19[4] = 86; *(_DWORD*)v20 = -279418536; *(_WORD*)&v20[4] = 18099; v20[6] = 35; v7 = len; v8 = input; if (v7) { v9 = (((unsigned __int64)v7 - 1) >> 1) + 1;// 计算向上整除结果 do { i = 0; j = 0; do // 每8字节为一组循环 { v12 = i++ + v7 + v19[j % 5] + v20[j % 7]; v8[j++] ^= v12; } while (i < 8); v8 += 8; --v9; } while (v9); } } __int64 __fastcall TEA_Enc(unsigned int* a1, __int32* a2) { int v3; // [rsp+0h] [rbp-58h] unsigned int v4; // [rsp+4h] [rbp-54h] unsigned __int32 v5; // [rsp+8h] [rbp-50h] unsigned int rounds; // [rsp+Ch] [rbp-4Ch] v3 = 0; rounds = 0; v4 = *a1; v5 = a1[1]; do { v4 += (v3 + a2[v3 & 3]) ^ (v5 + ((v5 >> 5) ^ (16 * v5))); v3 -= 0x788EEF63; v5 = v5 + 0x7C77AF7C + ((v3 + v4) ^ v4 ^ (a2[2] + 16 * v4) ^ (a2[3] + (v4 >> 5)) ^ 0x4321) - 0x5901181B; ++rounds; } while (rounds < 60); *a1 = v4; a1[1] = v5; return v4; } __int64 __fastcall TEA_Dec(unsigned int* a1, __int32* a2) { unsigned int v4; // left unsigned int v5; // right unsigned int v3; // delta unsigned int rounds; v4 = a1[0]; v5 = a1[1]; // delta 初始化为加密结束态 v3 = (unsigned int)(-60 * 0x788EEF63); rounds = 0; do { // 1. 先逆 v5(因为加密里 v5 最后更新) v5 = v5 - 0x7C77AF7C - ((v3 + v4) ^ v4 ^ (a2[2] + 16 * v4) ^ (a2[3] + (v4 >> 5)) ^ 0x4321) + 0x5901181B; // 2. 逆 delta v3 += 0x788EEF63; // 3. 再逆 v4 v4 -= (v3 + a2[v3 & 3]) ^ (v5 + ((v5 >> 5) ^ (16 * v5))); ++rounds; } while (rounds < 60); a1[0] = v4; a1[1] = v5; return v4; } unsigned char TeaDecData[50]; int main() { unsigned int key[4]; // [rsp+30h] [rbp-20h] BYREF LastXorEnc((char*)EncText, flag_len); key[0] = 0x89; key[1] = 0xFE; key[2] = 0x76; key[3] = 0xA0; for(int i = 0; i < flag_len; i+=2) { TEA_Dec((unsigned int*)&EncText[i * 4], (int *)key); if(*(unsigned int*)&EncText[i * 4] >= 0x100){ printf("Tea Decrypt Error!\n"); return -1; } TeaDecData[i] = EncText[i * 4]; TeaDecData[i + 1] = EncText[i * 4 + 4]; } XorEnc((char*)TeaDecData, flag_len); for (int i = 0; i < flag_len; i++) { for (int j = 0; j < 256; j++) { if (doenc(j, i) == TeaDecData[i]) { putchar(j); break; } } } } // ACE_C0n9raTs0nPA55TheZ02S9AmeScTf# ``` 那么到这里基本就接近分析的尾声了,最后一问思维比较发散,可以利用 VT 的各个特性甚至题目的特性去检验,就不再过多啰嗦了。 unicorn 模拟执行的相关代码: ```python import idaapi import ida_bytes import ida_ua import ida_funcs from unicorn import * from unicorn.x86_const import * exports = {} def GetNTExport(): lines = open("./ntExport.txt").readlines() for line in lines: items = line.strip().split() exports[int(items[2], 16)] = items[3] # ====================== # 配置区 # ====================== MAX_INSTR = 500000 PAGE_SIZE = 0x1000 fix_function_start = 0xFFFFF8080AFC300C RSP = 0x00000000DEAD0000 RBP = 0x00000000DEAD0000 base_addr = idaapi.get_imagebase() GetNTExport() brk = 0xc0de0000 # ====================== # 工具函数 # ====================== def align_down(x, a): return x & ~(a - 1) def disasm(ea): insn = ida_ua.insn_t() if ida_ua.decode_insn(insn, ea): return ida_ua.print_insn_mnem(ea) + " " + ida_ua.print_operand(ea, 0) return "???" def AllocMemory(uc): global brk addr = brk brk += PAGE_SIZE uc.mem_map(addr, PAGE_SIZE) return addr # ====================== # Hook: 未映射内存 # ====================== def hook_mem_unmapped(uc, access, address, size, value, user_data): page = align_down(address, PAGE_SIZE) try: uc.mem_map(page, PAGE_SIZE) data = ida_bytes.get_bytes(page, PAGE_SIZE) if data == b'\xff' * PAGE_SIZE: # print("Out of bound!") data = b'\xC3' * PAGE_SIZE if data: uc.mem_write(page, data) else: uc.mem_write(page, b"\xC3" * PAGE_SIZE) # print(f"[+] mapped data {data}") # print(f"[+] mapped page @ {hex(page)}") return True except Exception as e: print(f"[-] map failed @ {hex(page)} : {e}") return False # ====================== # Hook: 指令执行 # ====================== instr_count = 0 def read_UnicodeString(uc, address): s = '' bytes = b'\xFF\xFF' while bytes != b'\x00\x00': bytes = uc.mem_read(address, 2) s += chr(bytes[0]) address += 2 return s def read_String(uc, address): s = '' bytes = b'\xFF' while bytes != b'\x00': bytes = uc.mem_read(address, 1) s += chr(bytes[0]) address += 1 return s def hook_code(uc, address, size, user_data): global instr_count instr_count += 1 rip = uc.reg_read(UC_X86_REG_RIP) rsp = uc.reg_read(UC_X86_REG_RSP) 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) if rip in exports: print(f"Try to execute nt!{exports[rip]}") uc.reg_write(UC_X86_REG_RAX, 0) if exports[rip] == 'RtlInitUnicodeString': s = read_UnicodeString(uc, rdx) print("str ", s) elif exports[rip] == 'ExAllocatePoolWithTag': addr = AllocMemory(uc) uc.reg_write(UC_X86_REG_RAX, addr) print("Allocate Memory @ ", hex(addr)) elif exports[rip] == 'DbgPrint': s = read_String(uc, rcx) print(hex(rcx)) print("DbgPrint ", s) # emu ret else: raw = idaapi.generate_disasm_line(rip, 0) text = ida_lines.tag_remove(raw) # print(f"[{instr_count:05d}] {rip:016X} {text}") if instr_count >= MAX_INSTR: print("[*] instruction limit reached, stopping emulation") uc.emu_stop() # ====================== # Unicorn 初始化 # ====================== mu = Uc(UC_ARCH_X86, UC_MODE_64) # 寄存器 mu.reg_write(UC_X86_REG_RIP, fix_function_start) mu.reg_write(UC_X86_REG_RSP, RSP) mu.reg_write(UC_X86_REG_RBP, RBP) mu.reg_write(UC_X86_REG_RCX, 0) # argument 1 mu.reg_write(UC_X86_REG_RDX, 0) # argument 2 # 栈 mu.mem_map(RSP - PAGE_SIZE, PAGE_SIZE * 2) # 堆 mu.mem_map(0xc0de000, PAGE_SIZE * 2) # Hooks 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) print("[*] start RIP =", hex(fix_function_start)) try: mu.emu_start(fix_function_start, 0) except UcError as e: print("[-] emu error:", e) rip = mu.reg_read(UC_X86_REG_RIP) print("Last RIP=", hex(rip)) print("[*] RAX =", hex(mu.reg_read(UC_X86_REG_RAX))) print("[*] finished, total instr =", instr_count) ``` ## 写在最后 重新温习一遍去年的题目才发现这个题目蕴含了这么多的知识点,同时也发现自己上一次写的 wp 有多离谱,基本上就秉持着“暴力出奇迹”的原则,也是因为这样,当时比赛也只是获得了一个优秀奖,遂希望自己重振旗鼓,蓄势待发,争取今年能够获得更好的奖项。 关于本篇文章的视频讲解:<mark class="encrypted">740K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2T1K9h3I4A6j5X3W2D9K9g2)9J5k6h3y4G2L8g2)9J5c8Y4k6A6k6r3g2G2i4K6u0r3b7W2j5I4k6W2W2*7P5V1t1$3c8h3c8W2</mark>
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
#Reverse
收藏
・
4
点赞
・
7
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
非常支持你的观点!
2026-7-16 08:42
mb_xvrniwqz
+1
你的分享对大家帮助很大,非常感谢!
2026-4-16 21:05
n00bzx
+5
你的分享对大家帮助很大,非常感谢!
2026-3-6 20:21
git_39918alloneis
你的分享对大家帮助很大,非常感谢!
2026-2-4 10:20
P.Z
+1
非常支持你的观点!
2026-1-29 09:44
我的小拇指啊
你的分享对大家帮助很大,非常感谢!
2026-1-24 12:53
Saileaxh
+1
你的帖子非常有用,感谢分享!
2026-1-24 12:29
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
5
)
Saileaxh
雪 币:
3110
活跃值:
(2919)
能力值:
( LV6,RANK:80 )
在线值:
发帖
18
回帖
49
粉丝
108
关注
私信
Saileaxh
1
2
楼
tql
2026-1-24 12:29
0
Imxz
雪 币:
112
活跃值:
(9435)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
754
粉丝
9
关注
私信
Imxz
3
楼
tql
2026-1-25 21:26
0
n00bzx
雪 币:
2703
活跃值:
(3859)
能力值:
( LV12,RANK:286 )
在线值:
发帖
7
回帖
52
粉丝
10
关注
私信
n00bzx
2
4
楼
Make ctf great again!
2026-3-6 20:17
0
n00bzx
雪 币:
2703
活跃值:
(3859)
能力值:
( LV12,RANK:286 )
在线值:
发帖
7
回帖
52
粉丝
10
关注
私信
n00bzx
2
5
楼
不小心多发了,请巨佬帮我删个楼,对不起
最后于
2026-3-6 20:20 被n00bzx编辑 ,原因:
2026-3-6 20:18
0
n00bzx
雪 币:
2703
活跃值:
(3859)
能力值:
( LV12,RANK:286 )
在线值:
发帖
7
回帖
52
粉丝
10
关注
私信
n00bzx
2
6
楼
又想打ctf了...
2026-3-6 20:18
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
xi@0ji233
16
32
发帖
53
回帖
833
RANK
关注
私信
他的文章
[原创][原创]2026腾讯游戏安全PC决赛Writeup
12372
[原创]2026腾讯游戏安全PC初赛Writeup
2406
[原创]腾讯游戏安全竞赛2025复赛题解(加强版)
6863
[原创]强网杯2025决赛复现
14337
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部