This page looks best with JavaScript enabled

腾讯游戏安全竞赛 2020 WriteUp

 ·  ☕ 10 min read · 👀... views

没错,在2021年腾讯游戏安全竞赛即将开始之际,我在写去年的WP 至于为什么时隔一年…原因很简单,去年不会啊。去年初赛看到VMP的驱动直接人傻了。今年再来复盘回头看初赛的题目发现并没有觉得有多困难了,因此有此文。

初赛

Ring3

因为题目明确说明了有两处指令篡改,既然是指令篡改那就把内存镜像的硬编码代码段dump出来和源文件进行比较就可以了。因为程序运行后内存相较于源程序会有伸缩。因此我dump了从0x100140C到0x01004006的内存(脚本在dump.idc中),然后写一个python脚本(compare_file.py)对比两个结果文件就可以得到被篡改的两条指令的地址。

第一处指令修改:0x1002ff4 inc被nop了

ring3_1
ring3_2

通过分析可知,dword_100579C这一变量保存的是当前游戏运行时间,patch掉inc后,游戏时间将不会增加,也就是不会再有999秒的限制

第二处指令修改:0x1003590 push 0 被patch成了 jmp short loc_10035B0

ring3_3
ring3_4

这里做了一个跳转,当判断玩家点中炸弹时,会jmp到 0x10035AB 去执行一些结束这局游戏的操作,但是外挂patch了push 0并修改成了jmp short loc_10035B0 等于劫持了流程,使得点中炸弹也不会结束这句游戏,即等于将炸弹视为了普通格子,使得游戏呈现出这种效果

ring3_5

Ring0

题目只给了一个驱动文件,要求在不patch驱动的前提下让驱动成功加载并且输出Hello World。然后这个驱动是加了VMP壳的,(后来我才知道这个驱动的VMP上的是不完整的并且没开虚拟化,应该是为了降低难度),去年不会双击动调,看到VMP直接死亡。

题目第一个要求是要不patch驱动的情况下让驱动成功加载,我尝试启动加载驱动

ring0_1

可以看到驱动是不能成功加载的。但是驱动上了VMP,题目又规定不能patch(其实上了VMP也没法Patch了),那不难猜到题目是要求我们的计算机达到某个条件,驱动内部会判断这个条件从而决定是否加载成功。 所以,总而言之必须要通过动调之类的方法知道这个条件是什么。IDA 打开驱动文件,可以看到有部分导入表未被加密,看到其中有个非常熟悉的函数,MmGetSystemRoutineAddress,这个函数可以通过函数名获得函数地址,也就是所谓的暗桩调用。直接对其下API断点

ring0_2

程序断下来了,并且可以看到暗桩调用了KdDisableDebugger 函数,对其patch以后就可以从这里开始快乐调试了。后面的动调就不说了,就是硬拼调试能力,下面介绍另一种方法。

有大佬写了个项目叫Unicorn_PE,这个项目是基于Unicorn+capstone+Blackbone用于对PE文件做模拟执行后分析的一个超级好用的项目。从Git上拖下源码编译后直接对驱动模拟执行并Dump

  • ./unicorn_pe.exe DriverDemo.sys -dump -k

模拟执行并Dump的过程大约会持续5-10分钟,之后会在同目录下生成 DriverDemo.sys.dump 文件,直接拖入IDA分析

ring0_3

可以看到IDA自动识别出了DriverEntry函数,并且伪代码也分析的非常好,我对其稍作重命名注释后逻辑如下

ring0_4

可以看到,逻辑非常清楚了, CheckRegedit函数返回True导致DriverEntry函数返回加载失败是驱动未加载成功的唯一原因。跟入该函数看一下

ring0_5

我们创建该键再加载驱动可以看到驱动成功加载了

ring0_6

题目第二个要求是要使驱动输出HelloWorld,我们继续往下看逻辑。驱动入口函数通过注册表check函数后创建了一个名为 "\BaseNamedObjects\tp2020" 的通知事件,并对这个事件调用了KeClearEvent函数对其清空,然后调用了 CreateThreadWaitEvent 函数,这个函数内创建了一个线程,跟进线程函数看一下

ring0_7

可以看到,里面就是一个While循环并调用KeWaitForMutexObject等待前面创建的事件进入信号态,若事件进入信号态则会输出HelloWorld。并且这个驱动内再没有其他对于该事件对象的交叉引用了。 至此,题意非常明确了,要求我们写一个驱动将该事件设置为信号态,使得该驱动输出HelloWorld

驱动代码如下:

 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
#include "ntifs.h"   

VOID PrintHelloWorld() {

	UNICODE_STRING usEventName = { 0 };
	HANDLE EventHandle = NULL;

	RtlInitUnicodeString(&usEventName, L"\\BaseNamedObjects\\tp2020");
	PRKEVENT pEvent = IoCreateNotificationEvent(&usEventName, &EventHandle);
	if (!pEvent) {
		DbgPrint("IoCreateNotificationEvent Error\n");
		return;
	}
	KeSetEvent(pEvent, 0, FALSE);

}

VOID DriverUnload(PDRIVER_OBJECT DriverObject) {
	if (NULL != DriverObject)
		DbgPrint("[%ws]Driver Upload, Driver Object Address:%p", __FUNCTIONW__, DriverObject);
	return;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {

	KdBreakPoint();
	UNREFERENCED_PARAMETER(RegistryPath);


	PrintHelloWorld();

	DriverObject->DriverUnload = DriverUnload;

	return STATUS_SUCCESS;
	
}

驱动加载后,输出HelloWorld,解题完毕

ring0_8

决赛

Ring3

复赛ring3题目:(本题共5分) winmine.exe是一个扫雷游戏程序,winmine.dmp是该程序的一份进程dump, 在这份dump中,有一个DLL作弊程序。

  1. 请找到该作弊程序,给出模块名;(1分)
  2. 并分析它所包含的4个作弊功能,给出实现作弊功能的函数的偏移,并说明其作弊功能是什么。(4分)

又是分析内存的dump。IDA加载dmp文件,可以在模块列表里找到 D:\Temp\bin\CheatTools.dll 6E220000 00046000 这样一个模块,非常明显,这就是需要分析的外挂模块。第一题解决。然后把这个DLL文件dump出来。看到有师傅是修复了dump后的文件 这个怎么修复研究了好久,不修复拖入IDA就是一团奥里给,后来问了pizza大佬才知道,要把SizeOfHeader从0x400修复成0x1000,然后段头部的Raw Address字段值修复成Virtual Address 拖入IDA 符号表这些什么都在 开始分析

很明显,这是一个MFC写的DLL外挂模块。MFC封装的太严重了,导致很难定位外挂功能函数。这里我是用了Resource Hacker工具,该工具可以从资源段中分析出窗体控件和对应ID,然后去IDA中找对应的handler

决赛_ring3_1

可以看到四个作弊功能对应的4个ID,比如暂停时间的id为1005 = 0x3ed,IDA直接 search->immediate value 搜索0x3ED

决赛_ring3_2

可以直接从该结构体确定sub_6E221660为暂停时间作弊函数。用同样的方法找到其他作弊函数

sub_6E221660	暂停时间
sub_6E2216D0    遇雷不爆
sub_6E221820	一键游戏
sub_6E221AC0    地雷分布

Ring0

复赛ring0题目:(本题共10分) 本题分为2个部分,共10分,每部分总计5分。驱动加载环境为WIN10(推荐1803~1903范围内的系统)。

Part1:

  1. 成功加载Driver.drv至驱动模块链表(0.5分)
  2. 自编写驱动加载程序,使用非服务方式加载Driver.drv驱动,加载后需要保证驱动路径为C:\Driver.drv(0.5分)
  3. 分析驱动接口,给出每个功能的调用控制码,输入,输出数据的结构(0.5分)
  4. 分析驱动接口,分析每个接口的作用(0.5分)
  5. 调用驱动接口,使Driver.drv在驱动模块中断链隐藏(只允许在应用层调用Driver.drv接口实现)(2分)
  6. 从Flag.fg中分析出Flag,此flag用于解密Part2.7z(1分)
    以详细文档、源代码、构建好的bin的方式提供答案。

Part2:
运行RunGame.bat将启动一个游戏demo, 以管理员权限运行外挂程序DemoEsp.exe, 外挂程序将对游戏产生透视效果。
请编写驱动程序阻止外挂透视行为,按照符合以下(1)~(5)的条件限制来给分。
按照反外挂的思路实现,不可直接攻击外挂进程(例如结束进程、结束线程等),可从内存/文件/绘制等方面入手。

  1. 在未开启外挂的情况下游戏正常运行;
    开启外挂后,游戏也正常运行;
    开启游戏,开启外挂,开启编写的驱动程序后,游戏正常运行,但是外挂失效;(1分)
  2. 该驱动程序可以做到,非句柄回调保护,非句柄降权,非特征码定位,非文件样本;(1分)
  3. 该驱动程序可以做到,非内核函数代码段hook; (1分)
  4. 可以运行在WIN10 X64(1909-20H1范围);(1分)
  5. 可以兼容(1903~20H1范围)内的HVCI. (1分)
    以详细文档、源代码(part2可选,建议提供)、构建好的bin的方式提供答案。

题目是真的多,一个个来

Part1

尝试加载驱动失败

决赛_ring0_1

动调驱动DriverEntry函数,发现一定会进入 sub_14000244C 并在里面返回 0xC0000906(STATUS_VIRUS_INFECTED),直接对其patch,使其返回STATUS_SUCCESS,再次尝试加载,驱动加载成功

第二小题,非服务加载驱动,这也太简单了,直接NtLoadDriver

 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
void requestPrivilege()
{
	HMODULE hNtdll = LoadLibrary(L"ntdll.dll");
	RtlAdjustPrivilege = (RTLADJUSTPRIVILEGE)GetProcAddress(hNtdll, "RtlAdjustPrivilege");
	BOOLEAN Enabled;
	NTSTATUS nStatus = RtlAdjustPrivilege(SeLoadDriverPrivilege, TRUE, FALSE, &Enabled);
	// printf("RtlAdjustPrivilege:%X", nStatus);
	if (nStatus == STATUS_PRIVILEGE_NOT_HELD) {
		printf("Please Run as administrator.\n");
		exit(0);
	}
}
void loadDriver(char* path, DWORD pathlength)
{
	HKEY hk;
	CHAR szBuf[] = "SYSTEM\\CurrentControlSet\\Services\\Driver";
	CHAR objName[] = "Driver";
	DWORD start = 3;
	DWORD type = 1;
	DWORD errorControl = 1;
	UNICODE_STRING Name;
	NTSTATUS nStatus;
	HMODULE hNtdll = LoadLibraryW(L"ntdll.dll");
	RtlInitUnicodeString = (RTLINITUNICODESTRING)GetProcAddress(hNtdll, "RtlInitUnicodeString");
	NtLoadDriver = (NTLOADDRIVER)GetProcAddress(hNtdll, "NtLoadDriver");
	RegDeleteKeyA(HKEY_LOCAL_MACHINE, szBuf);
	RegCreateKeyA(HKEY_LOCAL_MACHINE, szBuf, &hk);
	RegSetValueExA(hk, "ImagePath", 0, REG_EXPAND_SZ, (LPBYTE)path, pathlength);
	RegSetValueExA(hk, "Start", 0, REG_DWORD, (LPBYTE)&start, sizeof(DWORD));
	RegSetValueExA(hk, "Type", 0, REG_DWORD, (LPBYTE)&type, sizeof(DWORD));
	RegSetValueExA(hk, "ErrorControl", 0, REG_DWORD, (LPBYTE)&errorControl, sizeof(DWORD));
	RtlInitUnicodeString(&Name, (PVOID)L"\\Registry\\Machine\\System\\CurrentControlSet\\Services\\Driver");
	nStatus = NtLoadDriver(&Name);
	if (nStatus == STATUS_SUCCESS)
		printf("LoadDriver Success");
	else if (nStatus == STATUS_IMAGE_ALREADY_LOADED)
		printf("NtLoadDriver:IMAGE_ALREADY_LOADED\n");
	else
		printf("NtLoadDriver:%X\n", nStatus);

}

Flag.fg分析可以说是去年决赛最有名的东西了,比赛结束就看到群里不断有人问这个Flag.fg是什么东西,为什么可以同时在x32和x64的情况下运行。复盘时我也是带着敬畏来研究这个文件,到后面直接跪在键盘上动调((( 操作实在太骚,shellcode同时兼容x32和x64,不需要导入任何函数,所有外部函数直接从TEB怼,不愧是腾讯,这个壳我直接膜拜 下面记录一下复盘分析过程

起初以为是一个DLL或者sys之类的东西通过一些奇怪的方法加载然后输出flag。然后IDA一看发现PE结构和导入表结构都没有。想起来有些游戏通过服务器动态分发payload,游戏加载进去以实现反作弊。写了一个加载器加载payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <Windows.h>

int main()
{
    HANDLE hFile = CreateFileW(L"C:\\Flag.fg", FILE_GENERIC_READ | FILE_GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    auto size = 0;
    BYTE* buff = nullptr;

    if (hFile != INVALID_HANDLE_VALUE) {
        size = GetFileSize(hFile, NULL);
        buff = (BYTE*)VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
        DWORD bytes = 0;
        ReadFile(hFile, buff, size, &bytes, NULL);
        CloseHandle(hFile);
    }
    else {
        printf("CreateFileW Error: %X\n", GetLastError());
        exit(0);
    }
    auto result = ((void* (*)())(buff /*+0x29EB1*/))();
}

因为之前就看到好多人问为什么这东西可以同时兼容x32和x64,我直接编译了x32和x64两个加载器对着动调。

决赛_ring0_2

看了这张图就知道为什么同时兼容两个架构了。在开头处做了一个判断,原因是x32和x64字节码对应的含义不同,导致了x64和x32会出现js跳转和不跳转两种情况。

然后就要来看看怎么分析这玩意了。因为没有任何导入表,里面也存在代码压缩等待处理,但是分析功能可以发现shellcode里面弹了5个消息框,前4个框都是Hello,最后一个框的标题是Your flag,但内容也是0。认为可以通过MessageBoxA的API断点来定位关键位置。这里我在外面加载器调用了一次MessageBoxA API,来让我的导入表里面存在这个函数,然后对它下API断点,成功定位到了shellcode第一次弹窗的位置偏移0x3741fd4

决赛_ring0_3

跟入后,可以看到相关的逻辑代码,显示尝试打开了符号链接 \\.\Hello123 这个符号链接就是前面那个驱动所创建的,然后便调用DeviceIoControl向驱动发出了一些东西,最后调用MessageBox输出“Hello 1”,说明这个shellcode是需要驱动的。

将之前patch好的驱动加载进去,再运行这段shellcode,可以看到,Hello后开始输出了一些数字,并在最后这个Your Flag框中输出了一串数字,结合前面的驱动派遣函数和伪代码,不难联想到是将shellcode中的一些东西发送到驱动去做哈希,再将返回回来的结果作为flag输出
决赛_ring0_4

果断将这一串数字 “16447126361811417937” 作为压缩包密码输入进行解压——密码错误((( 人傻了,难道shellcode里还有什么操作吗。然后又进去xjb跟了一通,完全没什么发现 完全看不懂 √。一直到最后的MessageBox,才发现不对劲,这前后两个0x20(空格)是什么鬼! 输入“ 16447126361811417937 ”,压缩包解压成功,确定flag{ 16447126361811417937 }

后面的部分不想写了,没有很有意思的东西。驱动接口是加了OLLVM的MurmurHash,还有一个接口可以驱动跑应用层函数,后面应用层断链那题也是依赖这个接口做的。反挂部分,可以游戏窗口强制置顶,设置进程EPROCESS->Protection位等方法实现。

Share on

Qfrost
WRITTEN BY
Qfrost
CTFer, Anti-Cheater, LLVM Committer