意外登顶(存 yyds
![result](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/result.jpg)
总得来说,这次比赛题目CTF比重较前两年大很多,但感觉区分度不高,特别是决赛的截图方案上,要么是大家都会的方法,要么是大家都不会的骚操作,不像去年百花齐放的各种外挂实现方案。(可能也正是因为这个才能让我这个半吊子CTFer以文档和提交时间偷冠成功
初赛
这里有一个画了flag的小程序,可好像出了点问题,flag丢失了,需要把它找回来。
题目:
![初赛题目](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B%E9%A2%98%E7%9B%AE.png)
样例:
![初赛样例](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B%E6%A0%B7%E4%BE%8B.png)
要求:
1、不得直接patch系统组件实现绘制(如:直接编写D3D代码绘制flag),只能对题目自身代码进行修改或调用。
2、找回的flag需要和预期图案(包括颜色)一致,如果绘制结果存在偏差会扣除一定分数。
3、赛后需要提交找回flag的截图和解题代码或文档进行评分。
评分标准:
1、根据提交截图和代码文档的时间作为评分依据。
外壳分析
运行程序,发现程序显示ACE的蓝色字符LOGO,但是经过数秒后LOGO消失
在WinMain中跟了一下,发现关键函数sub_1090,函数中调用GetModuleHandleA - ntdll.dll和GetProcAddress - ZwAllocateVirtualMemory 获取到了ZwAllocateVirtualMemory函数地址,并调用该函数申请了一块可读可写可执行的内存页,并将该页地址存到了一个全局变量上,然后将shellcode拷贝到了该页上并修复了shellcode头部
![初赛_1](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_1.jpg)
随后在shellcode之后又写了一段数据(暂时不知道有什么用)
![初赛_2](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_2.jpg)
然后程序调用了GetTickCount函数获取了一下时间,并保存在全局变量上。以上就完成了该函数的初始化。
之后程序call了shellcode+0x650的位置(可以认为这里是shellcode的入口点),后再调用一次GetTickCount与之前全局变量的值算时间差,如果超过4000(4秒),就通过ZwFreeVirtualMemory释放掉内存页并清空全局变量。并且之后程序循环运行sub_1090,但初始化部分不会重复运行,故直接运行程序数秒后LOGO会消失,认为绘制逻辑在shellcode中。
Shellcode分析
动调跟shellcode,发现调用了大量的DX函数,并且还JIT了一段代码进去,但是太菜了看不懂这是在干什么,只能往下找绘制逻辑。然后很明显的shellcode中调用了shellcode中0x420偏移处的一个函数,这个函数里面是一个VM,以shellcode[0x1301]偏移为opcode数组。然后回过头去看外壳,可以发现这里就是之前所说的“在Shellcode之后写入了一段不知道什么用的数据”。
分析一下这个函数,很容易发现这个函数是出题人做了手脚的函数,比如这个ACE(
![初赛_3](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_3.jpg)
当opcode为4的时候,会将下一个key取出来做一些神奇的运算(赛后才知道这里是GetUV,这个Key的具体值是无关的,后面会做互逆的运算),当opcode为5和6的时候会调用shellcode[0]偏移处的一个函数,并分别传入了黄色和蓝色的ARGB值。
而后去分析这个shellcode[0]偏移处的这个函数,这个函数很神奇,开头会对传入的前四个参数做一堆神奇运算,然后调用了*CContext::TID3D11DeviceContext_RSGetViewports(struct ID3D11DeviceContext5 * __ptr64,unsigned int * __ptr64,struct D3D11_VIEWPORT * __ptr64)*和CContext::TID3D11DeviceContext_Map(struct ID3D11DeviceContext5 * __ptr64,struct ID3D11Resource * __ptr64,unsigned int,enum D3D11_MAP,unsigned int,struct D3D11_MAPPED_SUBRESOURCE * __ptr64)
将D3D11_VIEWPORT和D3D11_MAPPED_SUBRESOURCE结构导入,发现这里是在填充constant_buffer,然后去imgui的代码里翻果然翻到了这一段代码。所以可以确定后面就是绘制了,要动手脚也只能在这之前动了
![初赛_4](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_4.png)
Hook这个shellcode[0]偏移处的这个函数,将参数值全部打印出来,发现总共调用了42次该函数,且前11次颜色是黄色,后31次颜色是蓝色,这正好对应上了样例中左边旗帜的11个点和右边蓝色LOGO的31个点。然后马上分析其他参数,
![初赛_5](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_5.jpg)
可以发现,右边ACE的格子四个一组四个一组,和第二个参数值正好能对应上,同时调节电脑缩放比例用截图工具对比可以确定第二个参数值就是Y坐标,那第一个参数值就是X坐标了。然后分析黄色的部分,发现黄色部分有不少坐标点是负数,也就是说被画到了屏幕外了;但是同时也发现一些坐标点看起来是正确的,但是并没有画出格子,这就很奇怪了,初步可以判定应该和第三第四个参数有关。那么问题来了,给出的坐标是负数,那我怎么知道真实的坐标应该是多少呢,虽说可以对照着样例图和已有的真实坐标把原坐标修复回去,但这样感觉不是一种很好的解法,有点猜的味道在里面了。没办法,只能写一个模拟器把虚拟机的流程跑出来,再来观察。
这里我写了一个IDAPython脚本,用来模拟虚拟机的运行,输出结果因为太长了在这里就不给出了:
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
|
opcode_table = 0x6350
regs = [0]*10
idx = 0
draw_count = 0
regs[8] = 50
regs[9] = 50
while True:
opcode = get_wide_dword(opcode_table+idx*4)
next_opcode = get_wide_dword(opcode_table+(idx+1)*4)
next_next_opcode = get_wide_dword(opcode_table+(idx+2)*4)
if opcode == 0:
print(f"[+]regs[0]:{regs[0]} += regs[1]:{regs[1]}")
regs[0] += regs[1]
print(f"regs[0]:{regs[0]}")
elif opcode == 1:
print(f"[+]regs[0]:{regs[0]} -= regs[1]:{regs[1]}")
regs[0] -= regs[1]
print(f"regs[0]:{regs[0]}")
elif opcode == 2:
print(f"[+]regs[{next_next_opcode}]:{regs[next_next_opcode]} = regs[{next_opcode}]:{regs[next_opcode]}")
regs[next_next_opcode] = regs[next_opcode]
print(f"regs[{next_next_opcode}]:{regs[next_next_opcode]}")
idx += 2
elif opcode == 3:
print(f"[+]regs[{next_next_opcode}]:{regs[next_next_opcode]} = {next_opcode}")
regs[next_next_opcode] = next_opcode
print(f"regs[{next_next_opcode}]:{regs[next_next_opcode]}")
idx += 2
elif opcode == 4:
print(f"[+]regs[0], regs[1] = decrypt(regs[0], regs[1], {next_opcode})")
regs[0] = "decrypt_key0"
regs[1] = "decrypt_key1"
idx += 1
elif opcode == 5:
draw_count += 1
print(f"[{draw_count}]Render( X: {regs[4]}, Y: {regs[5]}, {regs[6]}, {regs[7]}, Yellow )\n")
elif opcode == 6:
draw_count += 1
print(f"[{draw_count}]Render( X: {regs[4]}, Y: {regs[5]}, {regs[6]}, {regs[7]}, Blue )\n")
elif opcode == 7:
print("[-]Emulation End\n")
break
else:
pass
idx+=1
if idx < 4865:
continue
|
然后来分析输出结果,总体来说还是很清晰的,并且可以非常确定每一次render都是独立的,不会受到前后绘制的影响,但是也可以发现这个VM的指令流是混淆过的,可以看到模拟执行出来的代码还有很多无效的mov和多次对同一个寄存器写操作的这种局部混淆。观察可以发现,每一次render对应一次decrypt,并且decrypt的结果就是render函数的第三和第四个参数。然后来分析第一次render,第一次render绘制的是第一个黄色格子点,regs[8]和regs[9]在VM初始化阶段就被赋值成了50和50。
![初赛_6](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_6.jpg)
从后向前追踪X坐标的来源,可以找到,在指令流中,提取得到了一个值(这里是1000),其作为减数对regs[0]:50做了减法操作,故最后得到了错误的坐标X=-950
继续看第二个点:
![初赛_7](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_7.jpg)
指令流中得到了一个数(这里是500),其作为减数对被减数regs[0]:110做了减法操作,故最后得到了错误的坐标Y=-390
可以发现,所有的出现负数的坐标,均是将真实坐标减去了一个比它大的数从而导致其变成了负数,然后传入render函数以至于没有画出格子在屏幕上。降维一下,Flag的真实坐标点全部都已经给出了,但是由于一些坐标还给出了错误的指令流将其正确的坐标点改变了,从而导致了渲染时坐标点的错误
那么,我们就可以对照着模拟执行后的结果将所有正确的黄色坐标点写出来:
50, 50
50, 110
50, 170
50, 230
50, 290
50, 350
110, 230
170, 230
230, 230
110, 110
170, 170
现在就解决了黄色坐标点出现负数的情况,但是观察发现还有几个黄色坐标点虽然没有出现错误但是同样程序没有将他们画出来,比如第四个点。仔细对比各个点的render函数传参
![初赛_8](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_8.jpg)
发现传的第三第四个参数顺序还不一样,有的第三个参数是decrypt_key0,有的第四个参数是decrypt_key0。比如第四个点,可以发现在调用render函数之前会调换他们的顺序。
![初赛_9](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B_9.jpg)
动调对比,可以发现所有调换过顺序的点均没有被画出来。那么可以得出结论,指令流中存在错误的指令故意调换render函数第三第四个参数的顺序,从而导致了无法绘制的问题
外挂实现
分析到这一步其实非常清楚了,程序中给出了正确的坐标点,但是由于错误的指令将正确的坐标点破坏了;同时存在错误的指令会故意调换函数的传参顺序从而导致绘制失败。
那么,解决方案有两种:
- Patch掉错误的指令流,将错误的减法指令和调换参数的指令抹去,生成一套新的指令流Patch到程序当中
- 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
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
|
ULONG64 hk_sub(ULONG64 a1, ULONG64 a2, ULONG64 a3, ULONG64 a4, ULONG64 a5, ULONG64 a6, ULONG64 a7);
uintptr_t shellcode_addr = NULL; // 程序shellcode的起始地址
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
if (ul_reason_for_call == DLL_PROCESS_ATTACH)
{
uintptr_t mod = (uintptr_t)FindModule("2022游戏安全技术竞赛初赛.exe", NULL);
// 程序将shellcode的起始地址保存在 exe+0x8308 的全局变量中,读该变量可得shellcode基址
shellcode_addr = *reinterpret_cast<DWORD64*>(mod + 0x8308);
// mov rax, addr; jmp rax;
BYTE hook_shellcode[] = {
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0
};
*(DWORD64*)&hook_shellcode[2] = (DWORD64)hk_sub;
memcpy((LPVOID)(shellcode_addr + 0x420), hook_shellcode, sizeof(hook_shellcode));
}
return TRUE;
}
void decrypt_key(int x, int y, int key, int& key1, int& key2)
{
int v13 = x;
int v14 = x * (y + 1);
key1 = key ^ 'ACE';
key2 = ((key1 ^ (y + v13)) % 256
+ (((key1 ^ (v13 * y)) % 256 + (((key1 ^ (y + v14)) % 256) << 8)) << 8));
}
template<typename T, typename ...args>
inline T call(void* func, args&&... params)
{
typedef T(*fnCall)(...);
return reinterpret_cast<fnCall>(func)(std::forward<args>(params)...);
}
ULONG64 hk_sub(ULONG64 a1, ULONG64 a2, ULONG64 a3, ULONG64 a4, ULONG64 a5, ULONG64 a6, ULONG64 a7)
{
// pos为真实坐标点
// key_array为从指令流中dump得到的Key
for (auto idx = 0; idx < sizeof(pos) / 4; idx += 2)
{
int key1, key2;
decrypt_key(pos[idx], pos[idx + 1], key_array[idx /2], key1, key2);
call<ULONG64>((void*)shellcode_addr,
pos[idx],
pos[idx + 1],
key1,
key2,
idx < 22 ? 0xFFFFFF00 : 0xFF2DDBE7,
a3,
a4,
a5,
a6,
a7);
}
return 0;
}
|
注入Dll,得到Flag
![初赛答案](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%88%9D%E8%B5%9B%E7%AD%94%E6%A1%88.jpg)
决赛
这里有一个在屏幕上画flag的小程序,可好像出了点问题,flag丢失了,需要把它找回来,并尝试截图留念
![决赛题目](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%86%B3%E8%B5%9B%E9%A2%98%E7%9B%AE.png)
找回flag样例:
![决赛样例](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%86%B3%E8%B5%9B%E6%A0%B7%E4%BE%8B.png)
要求:
- 自行寻找办法加载驱动文件,再执行题目exe文件。
- 不得直接patch系统组件实现绘制(如:直接编写D3D代码绘制flag),只能对题目自身代码进行修改或调用。
- 找回的flag需要和预期图案(包括颜色)一致,如果绘制结果存在偏差会扣除一定分数。
- 修复后的flag截图操作必须在题目同一系统环境中进行(如:虚拟机运行题目则在虚拟机中截图,本机运行题目则在本机截图;不得拍照)。
- 赛后需要提交找回flag的截图、解题代码或文档和截图代码或文档进行评分,方法越多得分越高。
- 建议使用系统版本:Win10 1809、Win10 1903、Win10 1909、Win10 2004、Win10 20h1、Win10 20h2、Win10 21h1、Win10 21h2,在虚拟机中可能无法正常显示图形。
- 提交结果打包为XXX_writeup_A.zip,XXX为名称,A为提交序号,从1开始。
评分标准:
- 评分由解题提交时间和截图方式数量决定,截图实现方式数量越多分数越高,同等数量的情况下,提交时间靠前者分数更高。
- 每实现一种截图方式需提交一次,评分将以每次的提交时间为准。
驱动分析
这次的驱动没有加壳,直接IDA打开。DriverEntry函数中先注册了一个神奇回调,这个搜了下这个回调会在特定事件发生时执行;然后调用了sub_140001188根据操作系统版本填写偏移
关键函数就在回调函数(sub_1400014A0)中。分析该函数
函数首先获得了dwm.exe的EPROCESS,而后得到了D3DCOMPILER_47.dll-D3DCompile函数地址和dxgi.dll模块地址。随后对dxgi.dll模块进行特征码匹配,搜索得到了一个地址
![复赛_1.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_1.jpg)
![复赛_特征码搜索](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_%E7%89%B9%E5%BE%81%E7%A0%81%E6%90%9C%E7%B4%A2.jpg)
随后调用ZwAllocateVirtualMemory在dwm.exe上申请两块可读可写可执行的内存页,并将shellcode和opcode解密后写入到内存页上,之后将得到的函数地址修正到shellcode中
![复赛_2.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_2.jpg)
最后在目标地址上申请MDL,并写入。
![复赛_3.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_3.jpg)
之后使用KeDelayExecutionThread函数等待五秒,便会使用MDL将Hook恢复
![复赛_4.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_4.jpg)
再等待1.5秒后调用ZwFreeVirtualMemory释放申请的内存页
![复赛_5.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_5.jpg)
CE附加dwm.exe,在对应位置可以找到其写上的Hook。同时可以知道Shellcode的入口点是0x860
![复赛_6.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_6.jpg)
由此可知,绘制逻辑均在三环的shellcode中。在双机调试环境下用Windbg将Shellcode Dump出来。
EXE分析
交叉引用D3DCompile,回溯找到关键函数sub_1400035C0,先创建了一个互斥体防止重复打开,然后创建了一条线程用来Ioct,关键函数sub_140003530。分析函数可以看到构造了一个struct,将函数地址填入结构体中,然后调用NtQuerySystemInformation(SystemFirmwareTableInformation),去触发驱动注册的回调函数
![复赛_exe.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_exe.jpg)
然后就没有做其他事情了
ShellCode分析
IDA打开Shellcode,无壳无混淆,和初赛简直就是神似。入口在shellcode[0x860],熟悉的配方,调用shellcode[0x420],同样是一个VM,但VM相较于初赛有更多的opcode,并且还有opcode的解密操作。分析发现程序会一直调用shellcode[0x420],并且前几轮都是解密操作,跑到后面才会去调用shellcode[0x0]进行绘制。
同初赛方法一致,写Emulation分析
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
|
def decrypt(a,b,key):
result1=key^0x414345
result2=(result1^(b+a))%256+(result1^(a*b) )%256+(((result1^(b+a+a*b))%256)<<16)
return result1,result2
def reg_clear():
for i in range(8):
regs[i] = 0
regs[9]=50
regs[8]=50
regs = [0]*10
reg_clear()
idx = 0
draw_count = 0
round = 0
while True:
now_opcode = opcode[idx]
if now_opcode == 0x89657EAD:
print(f"[+]regs[0]:{regs[0]} += regs[1]:{regs[1]}")
regs[0] += regs[1]
print(f"regs[0]:{regs[0]}")
elif now_opcode == 0x9A8ECD52:
print(f"[+]regs[0]:{regs[0]} -= regs[1]:{regs[1]}")
regs[0] -= regs[1]
print(f"regs[0]:{regs[0]}")
elif now_opcode == 0x8E7CADF2:
print(f"[+]regs[{opcode[idx+2]}]:{regs[opcode[idx+2]]} = regs[{opcode[idx+1]}]:{regs[opcode[idx+1]]}")
regs[opcode[idx+2]] = regs[opcode[idx+1]]
print(f"regs[{opcode[idx+2]}]:{regs[opcode[idx+2]]}")
idx += 2
elif now_opcode == 0x1132EADF:
print(f"[+]regs[{opcode[idx+2]}]:{regs[opcode[idx+2]]} = {opcode[idx+1]}")
regs[opcode[idx+2]] = opcode[idx+1]
idx += 2
elif now_opcode == 0xEE2362FC:
print(f"[+]regs[0], regs[1] = decrypt(regs[0], regs[1], {opcode[idx+1]})")
regs[0], regs[1] = decrypt(regs[0], regs[1],opcode[idx+1])
idx += 1
elif now_opcode == 0x9645AAED:
if opcode[0] == 0xEE69624A and opcode[1] == 0x689EDC0A and opcode[2] == 0x98EFDBC9:
print(f"Render( X: {hex(regs[4])}, Y: {hex(regs[5])}, {regs[6]}, {regs[7]}, Yellow )\n")
elif now_opcode == 0x7852AAEF:
if opcode[0] == 0xEE69624A and opcode[1] == 0x689EDC0A and opcode[2] == 0x98EFDBC9:
print(f"Render( {hex(regs[4])}, {hex(regs[5])})")
elif now_opcode == 0x88659264:
count = opcode[idx+1]
xor_key = opcode[idx+2]
opcode[idx]=0xFFFFFFFF
opcode[idx+1]=0xFFFFFFFF
opcode[idx+2]=0xFFFFFFFF
for i in range(idx+3, idx+3+count):
opcode[i] = opcode[i]^xor_key
idx += 2
print("[+]Type 3 decrypt")
elif now_opcode == 0xEE69524A:
xor_key = opcode[idx+1]
opcode[idx] = 0xFFFFFFFF
opcode[idx+1] = 0xFFFFFFFF
if idx != 1:
for i in range(0, idx-1):
opcode[i]^=xor_key
idx += 1
print("[+]Type 1 decrypt")
elif now_opcode == 0xFF4578AE:
count = opcode[idx+1]
key = opcode[idx+2]
if count:
for i in range(idx+3,idx+3+count):
opcode[i]^=key
key=(opcode[i-1]+0x12345678*key) & 0xFFFFFFFF
opcode[idx]=0xFFFFFFFF
opcode[idx+1]=0xFFFFFFFF
opcode[idx+2]=0xFFFFFFFF
idx+=2
print("[+]Type 2 decrypt")
elif now_opcode == 0x9645AEDC:
print("[+]Emulation End : round :", round)
round += 1
if round>10:
exit(0)
idx = -1
reg_clear()
else:
# print("[-]Error!")
pass
idx+=1
|
将程序运行流程打印出来,发现同样的LOGO部分坐标正确,而绘制flag时坐标错误。对比了流程可知程序同样提供了正确坐标,但对于Flag的坐标也给出了错误的指令流从而破坏了正确的坐标。同初赛坐标做法一致,屏蔽掉错误的指令流(当 case 为0x1132EADF且要赋值的数值为500和1000时, 将其修正为0, 因为500和1000是出题人拿来干扰flag画出的错误指令流数值),即可得到42个正确的坐标。
找回Flag-方法1
用Python写一个VM Opcode生成器,使用正确的坐标生成出一张新的opcode表
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
|
def encode(a,b,key):
result1=key^0x414345
result2=((result1^(b+a))%256)+\
(((result1^(a*b))%256)<<8)+\
(((result1^(b+a+a*b))%256)<<16)
return result1,result2
opcode=[0xEE69624A, 0x689EDC0A, 0x98EFDBC9]
def render(x,y):
opcode.append(0x1132EADF)
opcode.append(x)
opcode.append(4)
opcode.append(0x1132EADF)
opcode.append(y)
opcode.append(5)
k1,k2=encode(x,y,0)
opcode.append(0x1132EADF)
opcode.append(k1)
opcode.append(6)
opcode.append(0x1132EADF)
opcode.append(k2)
opcode.append(7)
opcode.append(0x7852AAEF)
render(50 , 50)
render(50 , 122)
render(50 , 194)
render(50 , 266)
render(50 , 338)
render(50 , 410)
render(122 , 266)
render(194 , 266)
render(266 , 266)
render(122 , 122)
render(194 , 194)
render(770 , 50)
render(698 , 122)
render(626 , 194)
render(554 , 266)
render(482 , 338)
render(554 , 338)
render(626 , 338)
render(842 , 50)
render(914 , 50)
render(986 , 50)
render(842 , 122)
render(914 , 194)
render(986 , 266)
render(1058 , 338)
render(1130 , 338)
render(1202 , 338)
render(1274 , 338)
render(1130 , 50)
render(1202 , 50)
render(1274 , 50)
render(1346 , 50)
render(1202 , 122)
render(1274 , 194)
render(1346 , 194)
render(1418 , 194)
render(1490 , 194)
render(1346 , 266)
render(1418 , 338)
render(1490 , 338)
render(1562 , 338)
render(1634 , 338)
opcode.append(0x9645AEDC)
print(len(opcode))
import struct
with open('opcode.bin','wb') as f:
for i in opcode:
f.write(struct.pack("<I",i^0xcccccccc)) #驱动内会对shellcode异或0xCC解密
|
使用IDAPython脚本将改好的opcode表patch入sys文件
1
2
3
4
|
with open(r"D:\\opcode.bin", 'rb') as f:
opcode=f.read()
base=0x0140004030
[patch_byte(base+i,opcode[i]) for i in range(len(opcode))]
|
得到新的sys文件(提供在附件中),加载驱动,运行exe文件。即可在屏幕上绘制出样例图案。
找回Flag-方法2
Hook dwm.exe shellcode[0x420]函数,传入正确的坐标并Call Shellcode[0x0] 绘制出图案。核心代码如下,将其编译成DLL注入dwm.exe,即可在桌面显示出flag与logo完整图案。(完整代码见附件“找回Flag-方法2”)
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
|
ULONG64 hk_sub(ULONG64 a1, ULONG64 a2, ULONG64 a3, ULONG64 a4, ULONG64 a5, ULONG64 a6, ULONG64 a7);
uintptr_t shellcode_base;
uintptr_t shellcode_vm;
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
if (ul_reason_for_call == DLL_PROCESS_ATTACH)
{
uintptr_t dxgi = reinterpret_cast<uintptr_t>(LoadLibraryA("dxgi.dll"));
uintptr_t dxdi_offset = dxgi + 0x6EFC0;
uintptr_t part_1 = *reinterpret_cast<DWORD*>(dxdi_offset + 1);
uintptr_t part_2 = *reinterpret_cast<DWORD*>(dxdi_offset + 9);
uintptr_t target_addr = (part_2 << 32) + part_1;
shellcode_base = target_addr - 0x860;
shellcode_vm = shellcode_base + 0x420;
// mov rax, addr; jmp rax;
BYTE hook_shellcode[] = {
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0
};
*(DWORD64*)&hook_shellcode[2] = (DWORD64)hk_sub;
memcpy((LPVOID)(shellcode_base + 0x420), hook_shellcode, sizeof(hook_shellcode));
}
return TRUE;
}
void decrypt_key(int x, int y, int key, int& key1, int& key2)
{
int v13 = x;
int v14 = x * (y + 1);
key1 = key ^ 'ACE';
key2 = ((key1 ^ (y + v13)) % 256
+ (((key1 ^ (v13 * y)) % 256 + (((key1 ^ (y + v14)) % 256) << 8)) << 8));
}
template<typename T, typename ...args>
inline T call(void* func, args&&... params)
{
typedef T(*fnCall)(...);
return reinterpret_cast<fnCall>(func)(std::forward<args>(params)...);
}
ULONG64 hk_sub(ULONG64 a1, ULONG64 a2, ULONG64 a3, ULONG64 a4, ULONG64 a5, ULONG64 a6, ULONG64 a7)
{
for (auto idx = 0; idx < sizeof(pos) / 4; idx += 2)
{
int key1, key2;
decrypt_key(pos[idx], pos[idx + 1], key_array[idx /2], key1, key2);
call<ULONG64>((void*)shellcode_base,
pos[idx],
pos[idx + 1],
key1,
key2,
0xFF2DDBE7,
a3,
a4,
a5,
a6,
a7);
}
return 0;
}
|
找回Flag-方法3
修改表的方式如果patch回驱动本身,会破坏驱动的数字签名(虽然这个驱动没有数字签名),在真实环境种几乎不可行。故可编写驱动,枚举得到2022GameSafeRace.sys驱动模块基址,攻击其opcode的全局数组,通过MDL方式将正确的opcode表写入从而找回flag。
核心代码如下,完整项目见附件:找回Flag-方法3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
NTSTATUS nStatus = STATUS_UNSUCCESSFUL;
DriverObject->DriverUnload = DriverUnload;
uintptr_t driver_base = FindDrvs(DriverObject, L"2022GameSafeRace.sys");
kprintf("Find 2022GameSafeRace.sys Base: 0x%llx\n", driver_base);
if (driver_base == NULL) {
kprintf("Not find 2022GameSafeRace.sys!\n", driver_base);
return nStatus;
}
uintptr_t shellcode_addr = driver_base + 0x4030;
SIZE_T RegionSize = sizeof(opcode_table);
uintptr_t BytesRead = 0;
nStatus = WriteKernelMemory(shellcode_addr, opcode_table, RegionSize, &BytesRead);
return nStatus;
}
|
先加载2022GameSafeRace.sys,再加载EXP驱动,最后运行题目exe程序,屏幕上便会显示出FLag-Logo图案
找回Flag-方法4
分析shellcode[0x0]矩阵结构,可还原出如下结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
typedef struct _pos
{
float x, y, z;
}pos, * ppos;
typedef struct _ARGB
{
DWORD r, g, b, a;
}ARGB, * pARGB;
typedef struct _Info
{
pos a1; // 格子左上角
ARGB b1;
pos a2; // 格子右上角
ARGB b2;
pos a3; // 格子左下角
ARGB b3;
pos a4; // 格子右下角
ARGB b4;
} Info, * PInfo;
|
但是这个坐标值是经过屏幕坐标转换后的相对坐标,需计算得到格子长宽高,然后Hook shellcode[0x30E]处,在传给DX函数前将矩阵值(参数)进行修改
![复赛_7.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_7.jpg)
找回Flag-方法5
在不修改opcode表和hook+call sub_0 的情况下,可以通过Hook VM Handle的方式屏蔽错误指令流,从而得到flag。对于错误指令流有两种,一种是对正确的坐标值减去一个数,一种是交换第三第四个参数的值。
对于减去一个数的错误指令,因为减数是一个固定的值(为500或者1000),可以通过hook shellcode[0x627](VM Sub Handle)的位置,判断eax是否为500或者1000,若是则不执行减法操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
hook_sub_shellcode PROC
mov eax, [rbp-1Dh]
cmp eax, 1000
jz ret_handle
cmp eax, 500
jz ret_handle
sub [rbp-21h], eax
ret_handle:
nop
nop
nop
nop
nop
hook_sub_shellcode ENDP
|
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
26
|
void hook_sub() {
// 在目标shellcode周围申请空间
uintptr_t BaseAddress = target_base >> 16 << 16;
BaseAddress -= 0x10000;
uintptr_t page_addr = (uintptr_t)GameControler.GameVirtualAlloc(BaseAddress, 0x1000, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
printf("[+]Alloc Page Address:%llx\n", page_addr);
if (page_addr == NULL) {
return;
}
// 修复Shellcode
*(DWORD*)&sub_shellcode[21] = target_base + 0x62D - (page_addr + 20 + 5);
// 向内存写Shellcode
GameControler.Write(page_addr, sub_shellcode, sizeof(sub_shellcode));
// Hook
BYTE hook_shellcode[] = { 0xE9, 0, 0, 0, 0 };
*(DWORD*)&hook_shellcode[1] = page_addr - (target_base + 0x627 + 5);
if (GameControler.Write(target_base + 0x627, hook_shellcode, sizeof(hook_shellcode)) == FALSE) {
printf("Hook Failed! %p\n", target_base + 0x627);
exit(-1);
}
}
|
对于交换第三第四个参数的错误指令流,分析其混淆流程,发现在执行异或计算第三第四个参数后,均是以如下步骤进行交换
R3 = R0
R0 = R1
R1 = R3
R6 = R0
R7 = R1
Draw
故可Hook shellcode[0x5A3](VM Mov Handle),判断rcx和rdx的值,若当前进行的是 R0 = R1 (rcx=1 ,rdx=0)时,则将其变为 R3 = R1(给rdx赋值为3),从而破坏其交换过程
![复赛_8.jpg](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B_8.jpg)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
hook_mov_shellcode PROC
cmp rcx, 1
jnz ret_handle
cmp rdx, 0
jnz ret_handle
mov rdx,3
ret_handle:
mov eax, [rbp+rcx*4-21h]
mov [rbp+rdx*4-21h], eax
nop
nop
nop
nop
nop
hook_mov_shellcode ENDP
|
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
26
|
void hook_mov() {
// 在目标shellcode周围申请空间
uintptr_t BaseAddress = target_base >> 16 << 16;
BaseAddress -= 0x10000;
uintptr_t page_addr = (uintptr_t)GameControler.GameVirtualAlloc(BaseAddress, 0x1000, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
printf("[+]Alloc Page Address:%llx\n", page_addr);
if (page_addr == NULL) {
return;
}
// 修复Shellcode
*(DWORD*)&mov_shellcode[28] = target_base + 0x5AB - (page_addr + sizeof(mov_shellcode));
// 向内存写Shellcode
GameControler.Write(page_addr, mov_shellcode, sizeof(mov_shellcode));
// Hook
BYTE hook_shellcode[] = { 0xE9, 0, 0, 0, 0 };
*(DWORD*)&hook_shellcode[1] = page_addr - (target_base + 0x5A3 + 5);
if (GameControler.Write(target_base + 0x5A3, hook_shellcode, sizeof(hook_shellcode)) == FALSE) {
printf("Hook Failed! %p\n", target_base + 0x5A3);
exit(-1);
}
}
|
然后shellcode抹了VAD,不能用常规的方法写入内存,我使用了驱动MDL写入,而且可hook字节比较少,为了构造5字节跳转需要在shellcode附近遍历内存。核心代码如上,完整代码见附件 “找回Flag-方法五”
截图-方法一:N卡驱动截图
N卡的驱动可以截图截到dwm hook的绘制
![复赛-截图-方法1_1](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B-%E6%88%AA%E5%9B%BE-%E6%96%B9%E6%B3%951_1.jpg)
加载驱动启动程序,可成功使用该方法截图
![复赛-截图-方法1_2](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B-%E6%88%AA%E5%9B%BE-%E6%96%B9%E6%B3%951_2.png)
截图-方法二:注入DWM
核心思想是下载dxgi.dll的pdb,解析得到CDXGISwapChain::PresentFullscreenFlip,CDXGISwapChain::Present,CDXGISwapChain::PresentDWM,CDXGISwapChain::PresentMultiplaneOverlay四个函数的地址。对这四个函数执行Hook,劫持控制流到hk_sub上,hk_sub先执行原函数后,再执行我们的截图函数TakeDxgiCapture。在TakeDxgiCapture中通过交换链GetBuffer方法获得到图像数据并传递出来,从而实现截图。
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
|
/*
从交换链获取D3D设备、上下文、和后备缓冲区(纹理),
将 和后备缓冲区 拷贝至 pCaptureD3D11Texture2D(CPU可访问的)
map pCaptureD3D11Texture2D 然后将bitmap拷贝出来
*/
void TakeDxgiCapture(IUnknown *pDXGISwapChain) {
Microsoft::WRL::ComPtr<ID3D11Device> pD3D11Device;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> pID3D11DeviceContext;
Microsoft::WRL::ComPtr<ID3D11Texture2D> pD3D11Texture2D;
Microsoft::WRL::ComPtr<ID3D11Texture2D> pCaptureD3D11Texture2D;
D3D11_TEXTURE2D_DESC SwapChanDesc{};
_GUID(IID_ID3D11Device, 0xdb6f6ddb, 0xac77, 0x4e88, 0x82, 0x53, 0x81, 0x9d, 0xf9, 0xbb, 0xf1, 0x40);
auto hr = reinterpret_cast<IDXGISwapChain*>(pDXGISwapChain)
->GetDevice(IID_ID3D11Device, (void **)pD3D11Device.ReleaseAndGetAddressOf());
if (hr == S_OK) {
_GUID(IID_ID3D11Texture2D, 0x6f15aaf2, 0xd208, 0x4e89, 0x9a, 0xb4, 0x48, 0x95, 0x35, 0xd3, 0x4f, 0x9c);
hr = reinterpret_cast<IDXGISwapChain *>(pDXGISwapChain)
->GetBuffer(0,IID_ID3D11Texture2D, (void **)pD3D11Texture2D.ReleaseAndGetAddressOf());
if (hr == S_OK) {
pD3D11Texture2D->GetDesc(&SwapChanDesc);
SwapChanDesc.BindFlags = 0;
SwapChanDesc.MiscFlags = 0;
// CPU可访问的纹理
SwapChanDesc.CPUAccessFlags = 0x30000;
SwapChanDesc.Usage = D3D11_USAGE_STAGING;
hr = pD3D11Device->CreateTexture2D(&SwapChanDesc, 0, pCaptureD3D11Texture2D.ReleaseAndGetAddressOf());
if (hr == S_OK) {
pD3D11Device->GetImmediateContext(pID3D11DeviceContext.ReleaseAndGetAddressOf());
pID3D11DeviceContext->CopyResource(pCaptureD3D11Texture2D.Get(), pD3D11Texture2D.Get());
D3D11_MAPPED_SUBRESOURCE MappedResource{};
hr = pID3D11DeviceContext->Map(pCaptureD3D11Texture2D.Get(), 0, D3D11_MAP_READ_WRITE, 0,
&MappedResource);
if (hr == S_OK) {
LPVOID buffer =
LI_FN(VirtualAlloc)((LPVOID)0,
static_cast<SIZE_T>(sizeof(D3D11_TEXTURE2D_DESC) +
(static_cast<SIZE_T>(SwapChanDesc.Height) * SwapChanDesc.Width * 0x4)),
MEM_COMMIT,
PAGE_READWRITE);
memcpy(buffer, &SwapChanDesc, sizeof(D3D11_TEXTURE2D_DESC));
memcpy((char *)buffer + sizeof(D3D11_TEXTURE2D_DESC), MappedResource.pData,
(static_cast<SIZE_T>(SwapChanDesc.Height) * SwapChanDesc.Width * 0x4));
CaptureBitmapPointer = (__int64)buffer;
CaptureWidth = SwapChanDesc.Width;
CaptureHeight = SwapChanDesc.Height;
pID3D11DeviceContext->Unmap(pCaptureD3D11Texture2D.Get(), 0);
DbgPrint("Success at 0x%p [ %d * %d ]", CaptureBitmapPointer, CaptureWidth, CaptureHeight);
_InterlockedExchange(&hook_fun_done, 1);
return;
} else {
goto Fail;
}
} else {
goto Fail;
}
} else {
goto Fail;
}
} else {
goto Fail;
}
Fail:
DbgPrint("TakeDxgiCapture Fail !");
_InterlockedExchange(&hook_fun_done, 1);
}
|
完整代码与bin在 “./截图-方法2” 下,加载驱动与题目文件后,运行bin可获得截图
![复赛-截图-方法2](/images/CTF/%E8%85%BE%E8%AE%AF%E6%B8%B8%E6%88%8F%E5%AE%89%E5%85%A8_2022/%E5%A4%8D%E8%B5%9B-%E6%88%AA%E5%9B%BE-%E6%96%B9%E6%B3%952.jpg)
截图-方法三:NVFBC
NVFBC(NVIDIA Frame Buffer Capture)英伟达帧缓冲区捕获技术可以在Windows和Linux操作系统上快速、低延迟的捕获桌面,且适用于屏幕上所有类型的内容,无论内容是如何生成的。
https://developer.nvidia.com/capture-sdk-archive
它这个SDK似乎从8开始就不支持Windows了,挺离谱的,只能下了7.1的SDK
安装好SDK后开启NvFBC功能
PS C:\Windows\system32> cd "C:\Program Files (x86)\NVIDIA Corporation\NVIDIA Capture SDK\bin"
PS C:\Program Files (x86)\NVIDIA Corporation\NVIDIA Capture SDK\bin> .\NvFBCEnable.exe
NvFBC is enabled
(比赛时间太短,资料也太少,实在是没有写完,但这种思路应该是可行的