腾讯游戏安全技术竞赛 PC决赛 分析报告

今年终于是完整的都做出来了,想到去年连让驱动成功运行的任务都做不到,真是感慨。

结算画面,擦边第五名

alt text

任务一:成功运行程序

检测Hyper-V环境

部署失败会输出Code…之类的错误信息,搜索字符串找到该处,是通过一个间接Call返回数据来输出Code的。

alt text

dbg动调发现会调用到如下Call,可以比较容易看出这是在检测系统是否启用了Hyper-V功能。

alt text

禁用物理机的Hyper-V,然后给虚拟机启用虚拟化功能,再在虚拟机Powershell运行Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -All安装Hyper-V,即可通过phase 0。

alt text

alt text

2. 判断驱动是否写出并加载成功

通过上面函数里用到的全局状态值,这边可以找到共四个。

alt text

找交叉调用可以找到以下函数,是写出了一个驱动文件,然后启动服务,最后判断是否写出&启动成功。

alt text

3. 判断驱动可通信成功

可以看到这边使用cpuid通信,根据上文大概就能知道加载的驱动是hypervisor之类的,是判断是否通信成功。

alt text

alt text

任务二:分析程序对操作系统底层攻击

1. 获取隐藏sys文件

通过上文有看到写出一个sys,在此处可以断点拿到隐藏的驱动文件。

alt text

断点在CloseHandle后,在C:\Windows\System32\drivers底下可以找到随机名称的sys。

alt text

2. 初步分析sys文件

2.1 间接Call地址还原

存在大量ollvm平坦化,不考虑全部分析。

这边发现许多地方都有这种计算间接跳转Call地方,可以看到内存中存在大量的这种数据,猜测是程序内函数地址或API地址。

alt text

alt text

借助AI帮忙一键计算分析所有的Call地址。

alt text

同时AI帮忙命名了几个全局变量和函数指针具体的意义,有助于后续的代码分析。

alt text

alt text

alt text

以上AI记录截图见AI记录\记录1.pngAI记录\记录2.png

注释函数地址idapython脚本见annotate_apis.py

2.2 Pattern

sub_140018260,该函数可以看到是根据系统版本,选择不同的特征字节返回。

alt text

我系统使用的特征字节转汇编如下,是hvix64.exe的VMExit handler特征字节。

1
2
3
4
5
65 C6 04 25 6D 00 00 00 00   →  mov byte ptr gs:[6Dh], 0
48 8B 4C 24 ?? → mov rcx, [rsp+??]
48 8B 54 24 ?? → mov rdx, [rsp+??]
E8 ?? ?? ?? ?? → call dispatcher
E9 ... → jmp ...

2.3 Dma

sub_140009530这边看到是和设备通信的函数。

alt text

断点开头,看第一个参数,也就是设备的handle,windbg查到的设备是\Driver\disk

alt text

查看交叉调用一共有两个函数调用了(sub_140003E10/sub_140004FB0),从第一个函数分析,其中有一个PreparePageAccess(sub_140003EB0)函数调用,对a2地址做了什么。

alt text

alt text

sub_140003EB0分析,总体功能就是获取传入的虚拟地址的物理地址,然后设置权限位允许设备访问。

alt text

alt text

回到上一层,根据上面搜到的handle设备等线索,可以知道就是vhdmp虚拟磁盘设备,通过SCSI通信直接读写,可以绕过EPT保护。

下面是将a2物理页内容写入磁盘,然后再将磁盘里的内容页读入到a1,这样达到了copy a2地址内存到a1的效果,然后最后再恢复磁盘。

alt text

alt text

2.4 ManualMapPE

翻到一个ManualMapPE(sub_14000E630)函数,也就是手动映射加载PE文件,比较普通的映射加载,这里多了将内存映射到0x327fffE00000,也就是重定位的时候将基址设置为了0x327fffE00000。

alt text

2.5 扫描特征获取VmExit handler地址

找CopyPage函数的交叉调用,有且只有一个sub_14000BEB0。

这边是先遍历物理页,必须内存全都为0xff才符合条件,其实就是在搜hypervisor的保护页。

alt text

然后映射保护页,用Disk读取出被保护的内存,这样能够读取到hv相关的代码。

alt text

判断了一下CPU厂商

alt text

这边可以看到调用了上面分析到的BuildSigPattern,然后在刚刚读取的hv代码页里面去扫VmExit Handler的特征。

alt text

扫到后通过加上BuildSigPattern提供的一个固定偏移量,得到了目标Hook地址。

alt text

2.6 Hook VmExit handler

在ManualMapPE函数下方会找到一个函数,sub_14000FC40,被ollvm混淆了,但是可以看到代码量很大,而且存在下图类似Hook一样的代码块,可以直接使用D810去除控制流平坦化。

alt text

alt text

开头先在目标地址后面找连续的0xCC内存段,用来放shellcode。

alt text

申请了内存,将shellcode模板复制到内存中。

alt text

alt text

然后搜索shellcode中的两个占位符,替换成映射加载驱动的payload地址,以及a5参数。

alt text

alt text

再把shellcode复制到目标padding地址。

alt text

然后这边是在shellcode结尾写入执行原代码以及jmp回原始代码下一条,也就是标准inline hook的处理方式,然后调用CopyPage,写入hv物理页,然后再写一个jmp到shellcode的字节。

alt text

最后底下又写了一个跳板,然后在目标Hook点写入Jmp,jmp到跳板处执行,可以看到0x327FFFE00000,就是上面ManualMapPE驱动映射的内存地址。

alt text

3. 获取手动映射的PE文件

由于看到了ManualMapPE,就先想着给他加载的payload dump出来看看是什么。

直接断点sys+0xe630,rcx就能看到是MZ头的PE文件,直接.writemem导出。

alt text

IDA加载可以看到是一个驱动

alt text

分析可以发现以下关键函数:

  • sub_1400017A0 (DriverEntry):入口点,初始化payload函数指针
  • sub_140001000 (密码验证):8轮XXHash变体算法,seed来自dispatcher页面+0x500,异或”SHAD0WNT”/“HYPERVMX”常量后混合计算
    alt text
  • sub_1400013A0 (VMCALL状态机):处理通信协议,0xDEADBEEF+0x114514→返回0x19198100xDEADBEEF+0x191A710+pw→密码验证
    alt text
  • payload_2 (CPUID拦截):使用vmread/vmwrite操作VMCS,拦截guest的CPUID指令实现通信
    alt text
  • payload_3 (VMCALL处理):拦截VMCALL hypercall,转发到状态机处理
    alt text

这个PE就是核心payload,通过inline hook劫持了VMExit dispatcher后,所有VMExit都先经过它处理,拦截特定的CPUID/VMCALL请求实现与shadow_panel.exe的通信和密码验证,其余VMExit透传给原始handler。

4. 攻击流程总结

  • shadow_panel.exe释放驱动到drivers目录加载服务

  • 被加载的驱动:

    • ManualMapPE手动映射加载了个驱动到0x327FFFE00000地址。

    • 遍历物理内存,筛选出全0xFF的页面(Hyper-V保护的hypervisor代码页)

    • 利用vhdmp虚拟磁盘设备发起读写,借助磁盘DMA绕过Hyper-V的EPT保护,读取到hypervisor真实代码。

    • 根据系统版本特征定位VMExit handler。

    • 将其用同样的方式对VMExit handler的dispatcher call进行inline hook替换成jmp到跳板地址,最终会跳转到手动映射加载的那个驱动入口,劫持VMExit流程。

任务三:编写检测工具

直接模仿原驱动的流程,去通过dma方案暴力扫描所有的物理页,找到VmExit Handler,然后看看是不是E8被改成E9,也就是被Hook了,则检测成功。

alt text

代码见detector项目文件夹

AI记录见AI记录\检测

任务四:得到正确终止密码

上文可以知道被加载的驱动就是核心密码验证的地方,代码验证算法可以整理成如下代码,最主要的就是得拿到两个8字节seed,就可以计算出本机的密码。

两个seed值都是从payload1+0x500和0x508得到的。

alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint64_t rol64(uint64_t v, int n) {
return (v << n) | (v >> (64 - n));
}

void compute_password(uint64_t seed_lo, uint64_t seed_hi, uint64_t *out_r8, uint64_t *out_r9)
{
uint64_t r8 = seed_lo ^ 0x5348414430574E54ULL; // "SHAD0WNT"
uint64_t r9 = seed_hi ^ 0x4859504552564D58ULL; // "HYPERVMX"
uint64_t C1 = 0x9E3779B97F4A7C15ULL;
uint64_t C2 = 0x40A7B892E31B1A47ULL;
for (int i = 0; i < 8; i++) {
r8 = r8 + C1 * rol64(r9, 13);
r9 = r9 ^ (rol64(r8, 29) - C2);
r8 = r8 ^ (r9 >> 17);
r9 = r9 + (r8 << 7);
}
*out_r8 = r8;
*out_r9 = r9;
}

在原dump文件中payload3函数最底下是作为回调调用,其实就是相当于原VmExit dispatcher地址,因为hvix64的某个VmExit handler被inline hook了,而inline hook劫持的函数最后是需要运行原函数的。

所以这个payload1就应该是原dispatcher call的地址。

alt text

1. 找到被Hook的Handler

由于从ManualMapPE处Dump的是原文件,payload1都是空的,没办法拿到seed值计算密码,需要dump运行时的内存。

但遇到一个问题,该驱动运行在host层,guest层是根本没办法找到该内存,windbg是没办法找到的。

但是有个办法,也就是虚拟机里面运行驱动是在Vmware的host,物理机是可以通过Vmware的文件读到host层代码—-虚拟机的内存都会存在文件夹下的.vmem文件中,可以直接读取该文件扫描到host层的代码。

.vmem中查驱动里面提供的特征字节,确实发现其中一个handler的call被inline hook成jmp了,验证了上面inline hook代码分析。

find_handler_pattern.py

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
import struct, sys
from capstone import Cs, CS_ARCH_X86, CS_MODE_64

with open(sys.argv[1], "rb") as f:
data = f.read()

md = Cs(CS_ARCH_X86, CS_MODE_64)

pattern = bytes([0x65,0xC6,0x04,0x25,0x6D,0x00,0x00,0x00,
0x00,0x48,0x8B,0x4C,0x24])
off = 0

while True:
idx = data.find(pattern, off)

if idx == -1:
break

chunk = data[idx:idx+30]

print(f"\nPhys 0x{idx:X}:")

for insn in md.disasm(bytes(chunk), idx):
print(f" {insn.mnemonic:8s} {insn.op_str}")

off = idx + 1

alt text

2. 计算Flag

由于知道了这个Handler的偏移是3E3CC,所以可以直接去hvix64.exe该偏移处找到原dispatcher的call地址,

alt text

将地址后三位清零然后再加0x500,得到对应seed。

alt text

get_password.cpp

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
#include <windows.h>
#include <iostream>

uint64_t rol64(uint64_t v, int n)
{
return (v << n) | (v >> (64 - n));
}

void compute_password(uint64_t seed_lo, uint64_t seed_hi, uint64_t *out_r8, uint64_t *out_r9)
{
uint64_t r8 = seed_lo ^ 0x5348414430574E54ULL; // "SHAD0WNT"
uint64_t r9 = seed_hi ^ 0x4859504552564D58ULL; // "HYPERVMX"
uint64_t C1 = 0x9E3779B97F4A7C15ULL;
uint64_t C2 = 0x40A7B892E31B1A47ULL;
for (int i = 0; i < 8; i++)
{
r8 = r8 + C1 * rol64(r9, 13);
r9 = r9 ^ (rol64(r8, 29) - C2);
r8 = r8 ^ (r9 >> 17);
r9 = r9 + (r8 << 7);
}
*out_r8 = r8;
*out_r9 = r9;
}

int main()
{
uint64_t password[2] = {0};

compute_password(0x247C8B4C00008B0C, 0x00005714003D8070, &password[0], &password[1]);

printf("%llX%llX\n", password[0], password[1]);
return 0;
}

得到密码675A4ACF5B1867E85EE7F7226032411A

验证成功

alt text

任务五:编写Keygen

从上文得到的目标Handler地址,以及在驱动里面的特征字节搜索到的结果,可以对比看到,实际就是一个地方。

alt text

alt text

所以可以得到以下密码生成流程:

  1. 读取C:\Users\Windows\System32\hvix64.exe,通过版本号选择不同的特征字节,搜索到Handler。

  2. 读取到Handler下面的Dispatcher Call的rel32

  3. 计算RVA,取page+0x500的16字节seed

  4. 计算出16字节密码

版本大于22621的特征码掩码是被异或加密了,需要动调取一下,在构建特征码的地方这边下段,设置rdx也就是版本号大于22621。

alt text

然后在箭头处也就是异或解密的Call后面这个mov下断,然后[rdi+8]就是解密出来的掩码了。xxxxxxx????xxxx?xxxxxx?x????x

alt text

keygen.cpp

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
#include <windows.h>
#include <iostream>

uint64_t rol64(uint64_t v, int n)
{
return (v << n) | (v >> (64 - n));
}

// 计算密码
void compute_password(uint64_t seed_lo, uint64_t seed_hi, uint64_t *out_r8, uint64_t *out_r9)
{
uint64_t r8 = seed_lo ^ 0x5348414430574E54ULL; // "SHAD0WNT"
uint64_t r9 = seed_hi ^ 0x4859504552564D58ULL; // "HYPERVMX"
const uint64_t C1 = 0x9E3779B97F4A7C15ULL;
const uint64_t C2 = 0x40A7B892E31B1A47ULL;

for (int i = 0; i < 8; i++)
{
r8 = r8 + C1 * rol64(r9, 13);
r9 = r9 ^ (rol64(r8, 29) - C2);
r8 = r8 ^ (r9 >> 17);
r9 = r9 + (r8 << 7);
}
*out_r8 = r8;
*out_r9 = r9;
}

// PE解析

typedef struct
{
uint32_t va, vs, ro, rs;
} Section;
Section sections[64];
int num_sections;

void parse_pe(uint8_t *pe)
{
uint32_t e = *(uint32_t *)(pe + 0x3C); // e_lfanew
num_sections = *(uint16_t *)(pe + e + 6);
uint16_t opt_size = *(uint16_t *)(pe + e + 20);
uint32_t sec_off = e + 24 + opt_size;
for (int i = 0; i < num_sections; i++)
{
uint8_t *s = pe + sec_off + i * 40;
sections[i].va = *(uint32_t *)(s + 12); // VirtualAddress
sections[i].vs = *(uint32_t *)(s + 8); // VirtualSize
sections[i].ro = *(uint32_t *)(s + 20); // PointerToRawData
sections[i].rs = *(uint32_t *)(s + 16); // SizeOfRawData
}
}

// 文件偏移 → RVA
int64_t file_to_rva(uint32_t fo)
{
for (int i = 0; i < num_sections; i++)
if (fo >= sections[i].ro && fo < sections[i].ro + sections[i].rs)
return sections[i].va + (fo - sections[i].ro);
return -1;
}

// RVA → 文件偏移
int64_t rva_to_file(uint32_t rva)
{
for (int i = 0; i < num_sections; i++)
if (rva >= sections[i].va && rva < sections[i].va + sections[i].vs)
return sections[i].ro + (rva - sections[i].va);
return -1;
}

/*
BuildSigPattern (sub_140018260)
特征字节结构体
*/
typedef struct
{
const char *name; // 系统版本
uint8_t pattern[32]; // 特征字节
char mask[32]; // 掩码
int len; // 特征字节长度
int call_offset; // call相对于特征搜索到的内存地址的偏移
} Signature;

Signature sigs[] = {
// Win11 22H2+ (build >= 22621)
{"Win11 22H2+",
{0x66, 0x83, 0xFE, 0x01, 0x75, 0x0A, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x4C, 0x24, 0x00,
0xFB, 0x8B, 0xD6, 0x0B, 0x54, 0x24, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xE9},
"xxxxxxx????xxxx?xxxxxx?x????x",
29,
23},

// Win10 2004+ (build >= 19041)
{"Win10 2004+",
{0x65, 0xC6, 0x04, 0x25, 0x6D, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x4C, 0x24, 0x00, 0x48, 0x8B,
0x54, 0x24, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xE9},
"xxxxxxxxxxxxx?xxxx?x????x",
25,
19},

// Win10 1903 (build >= 18158)
{"Win10 1903",
{0x48, 0x8B, 0x4C, 0x24, 0x00, 0xEB, 0x07, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xEB, 0xF2, 0x48, 0x8B,
0x54, 0x24, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xE9},
"xxxx?xxx????xxxxxx?x????x",
25,
19},

// Win10 RS5 (build >= 17262)
{"Win10 RS5",
{0xF2, 0x80, 0x3D, 0xFC, 0x12, 0x46, 0x00, 0x00, 0x0F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B,
0x54, 0x24, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xE9},
"xxxxxxx?xx????xxxx?x????x",
25,
19},

// Win10 TH2 (build >= 10586)
{"Win10 TH2",
{0xD0, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B,
0x54, 0x24, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xE9},
"xx????x?xx????xxxx?x????x",
25,
19},

// Win10 TH1 (build >= 10240)
{"Win10 TH1",
{0x60, 0xC0, 0x0F, 0x29, 0x68, 0xD0, 0x80, 0x3D, 0x7E, 0xAF, 0x49, 0x00, 0x01, 0x0F, 0x84, 0x00,
0x00, 0x00, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xE9},
"xxxxxxxxxxxxxxx????x????x",
25,
19},
};

int main(int argc, char **argv)
{
const char *path = argc > 1 ? argv[1] : "C:\\Windows\\System32\\hvix64.exe";

// 读取hvix64.ex
FILE *f = fopen(path, "rb");
if (!f)
{
printf("Cannot open %s\n", path);
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *pe = (uint8_t *)malloc(size);
fread(pe, 1, size, f);
fclose(f);

// 解析PE
parse_pe(pe);

// 从高版本到低版本依次尝试每个签名
int nsigs = sizeof(sigs) / sizeof(sigs[0]);
for (int s = 0; s < nsigs; s++)
{
Signature *sig = &sigs[s];
for (long off = 0; off + sig->len <= size; off++)
{
// 带mask的pattern匹配
int ok = 1;
for (int j = 0; j < sig->len && ok; j++)
if (sig->mask[j] == 'x' && pe[off + j] != sig->pattern[j])
ok = 0;
if (!ok)
continue;

// 确认call_offset处是E8(CALL)
long call_fo = off + sig->call_offset;
if (pe[call_fo] != 0xE8)
continue;

// 从CALL rel32算出dispatcher的RVA
int64_t handler_rva = file_to_rva(off);
if (handler_rva < 0)
continue;

int32_t rel32;
memcpy(&rel32, pe + call_fo + 1, 4);
int64_t disp_rva = handler_rva + sig->call_offset + 5 + rel32;

// seed = dispatcher所在4KB页面 + 0x500
int64_t seed_rva = (disp_rva & ~0xFFF) + 0x500;
int64_t seed_fo = rva_to_file(seed_rva);
if (seed_fo < 0 || seed_fo + 16 > size)
continue;

// 读16字节seed
uint64_t seed_lo, seed_hi;
memcpy(&seed_lo, pe + seed_fo, 8);
memcpy(&seed_hi, pe + seed_fo + 8, 8);

// 算密码
uint64_t password[2]{};
compute_password(seed_lo, seed_hi, &password[0], &password[1]);

printf("%016llX%016llX\n", password[0], password[1]);
free(pe);
system("pause");
return 0;
}
}

printf("Pattern not found\n");
free(pe);

system("pause");
return 1;
}

在虚拟机运行keygen得到密码,输入验证成功

alt text