由于去年很多分析的不到位的地方,故今年想详细分析一遍整体逻辑。

本篇帖子基于赛后复盘的角度去分析,相比之前发文会有更多详细且有理有据的逻辑分析,少很多暴力和猜测的结果(虽然一些部分还是需要靠猜),旨在真正学习其中的技术。

题目描述

(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 是列不出所有模块的,因为列模块的行为需要依赖 ntpdb

然后直接 dump 蓝屏的加载好的驱动,IDA 打开之后发现连 text 段都没有被正确映射。

但是去查看对应的内存肯定有,原因是题目加了 vmp 壳,它破坏了 PE 头,导致 IDA 按照错误的 section 去映射了文件内容,因此我们需要修一下。

可以看到,SizeOfRawData 值被置为 0 了,当该值被置 0 时,PE 文件认为这个 section 没有映射任何文件内存,因此需要修复这个 dump。

修 dump 的手段很简单:令所有节区的 section[i].PointerToRawData = section[i].VirtualAddresssection[i].SizeOfRawData = (section[i].Misc.VirtualSize + FileAlign - 1) & ~(FileAlign - 1)

这里的 FileAlgin 是 PE 的一个字段,通常是 0x200,把它对齐即可。

这里给出一下我修复的代码

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
71
72
73
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 的宏复制解密代码就行。

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
#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 进行拦截操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 例程在 +0x78c0context 就的内容直接指向 KeBugCheckEx 函数,但是为什么交叉引用没有找到函数呢?

很棒,原来蓝屏之后还把调用入口给放了个 C3 导致反编译没成功。。

这个蓝屏方式有点似曾相识,遥想当年 hook 蓝屏延时一年的操作,当时看出题人发的文章说,该用 DPC 的方式去蓝屏的,没想到第二年的题真就做了。

蓝屏之后的代码就是无限循环输出 You Found This

是的,unicorn 上大分,只要能模拟,绝对不调试。。

这就解释了为什么它防住了我 hook 蓝屏的方法,直接 hook KeBugCheckEx 肯定不行,因为它清栈了,但是我把它清栈的函数 hook 了 ret 呢?也不行,因为hook了这里后面的代码就直接死循环,DPC 死循环,系统直接卡死,所以直接给 16C4 ret 的后果就是卡死系统,不会蓝屏,但是系统也不会有响应,调试器也不会断开,可以正常中断。

如果让 DPC 例程直接 ret 会怎么样呢,还是直接蓝屏,蓝屏代码 50。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,这两个干掉之后,题目就可以正常调试了,也能正常加载。

下面给出关键代码:

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
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();
}
}

KeSetSystemAffinityThreadIATHook,因为调用到这个 API 通常意味着 Vmp 解密代码已经完成,这个时机对代码做 inline hook 是最好的。

优化算法

如果 IDA 反编译的时候出现了这样的情况

通常是调用的函数被声明为 noreturn,直接到该函数修改属性即可。

调试问题解决了,不难推出,下面的代码

通过一个函数把 flagkeyflag 的长度都读取到了全局变量中再进行判断。

还是那句话,遇事不决,模拟就完了。

如果 Key 给了 0,则调用算法生成,那么主要逆的就是这个函数了。函数里面告诉了你一句话

1
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=a1y=a2

对第一个遇到的函数分析,模拟一遍发现类似 new 关键字分配了内存,然后来到第二个函数里面。

第二个参数指向的内存的前 8 个字节是 xy,那么猜测这个数据结构应该是 32 字节的,还有剩下的 24 字节被置 0 了。

先预设一个结构体

1
2
3
4
5
struct data{
int x;
int y;
char pad[24];
}

随后敏锐地发现了,a1[1] 指向的内存在存放 data 类型的指针。

不难得到这是一个 40 大小的结构体,其中 +8 指向一个指针数组,+32 指向了数量(因为最后的 ++a[4]),先用下面的结构体试试看

1
2
3
4
5
6
7
8
9
struct data2
{
PVOID unknown;
data **arr;
PVOID unknown2;
PVOID unknown3;
size_t mysize;
};

得到结果

一目了然,这是一个类似 vectorpush_back 操作(当然你看到了字符串就直接知道是 deque 了)。

deque 的内部实现用了一个环形队列,环形队列通常有取余的操作,为了优化,它的 MaxSize 通常是 2 的整数倍次方,因为如果这样的话取余的操作可以化简成 &(MaxSize - 1),也就是我们代码看到的样子。

最终的结构体变成:

1
2
3
4
5
6
7
8
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 的地方存放了 v5v5 是这个函数运算的结果。

所以可以解出整个结构体:

1
2
3
4
5
6
7
8
9
struct data
{
int x;
int y;
unsigned __int64 value;
unsigned __int64 acc;
unsigned __int32 flag;
};

改上之后,整个函数就非常清楚了。

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
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 - 1newdata.y = y,其它字段清空,并压入 deque 中 (注意,此时原来的元素并未弹出,而是将元素的 flag 置为 1)。

flag == 1 时:会令 newdata.x = x - 1newdata.y = y - 1,其它字段清空,并压入 deque 中 (同理可得)。

flag == 2 时:累加前两个状态计算的值,并额外加上 x % 5 的值。

写成递归形式也不难:

1
2
3
4
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,如果已经计算了值,那么直接返回这个值即可。

1
2
3
4
5
6
7
8
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,可以使用我搭建的服务器

剩余的可能需要对源码进行调试,当然还是先看看外面的逻辑。

第一步就是简单的用 Key 转成字符串之后进行异或加密

但是实际调试的时候发现并不是这样,调试器来到这里,观察一个神奇的 mov 指令。

惊不惊喜,意不意外?是不是瞬间感觉自己见到了假的 mov 指令,这个时候是不是不甘心认为输出的是十六进制,就算是十六进制偶数不可能变奇数的(狗头。

实则不要慌,当年我半夜从一点按调试器按到四点一个一个按完了才出来的 flag。这个地方必然被 VT 下了 hook,篡改了这个寄存器的值,首先找到 vmlauch 指令,交叉引用来到加载的地方。

看不懂没关系,根据这个 rdmsr 指令搜 486

然后就可以恢复这个结构体,这里浇一点小技巧,为了导入 hv 的结构体,可以直接加载 hv 的 pdb,选择只加载结构即可。

加载完成之后,根据这个偏移,就能大概恢复一下它自己的结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 然后进行硬编码判断,硬编码结果为

1
41 8A 04 0F 88 45 A0 49

刚好对应读取的指令

随后读取了 GUEST 的寄存器,找到了 r15+rcx 指向的内存(flag 字符),并且提取了 r8 寄存器的值,回到原来加密地方的上下文不难看出这个 r8 就是读取的下标。

随后将 flag 字符和下标变量放入一个函数进行加密替换,想要验证怎么办,那还是模拟执行,把刚刚 mov 第一条指令的结果输入进去,第一个参数给 0x66,第二个参数给 0

这就完美了,跟刚刚调试器看到的结果是一致的,这里给一个不吃操作也不吃经济的打法,直接复制出来,引用 defs.h 即可。

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
#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 异或解密最后爆破最开始的单字节加密即可。

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
// 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 模拟执行的相关代码:

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
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 有多离谱,基本上就秉持着“暴力出奇迹”的原则,也是因为这样,当时比赛也只是获得了一个优秀奖,遂希望自己重振旗鼓,蓄势待发,争取今年能够获得更好的奖项。

关于本篇文章的视频讲解:https://www.bilibili.com/video/BV1fYzzB6Ede