在RealWorldCTF2018中有一个非常有意思的题叫“P90 Rush B”,名字本意是说在Valve的游戏“CS:GO”中的一种极限战术。这个题主要是考挖掘和利用CS:GO游戏服务器中地图文件加载部分的一个漏洞。在CTF期间,我利用了一个另外一个队伍在writeup中提到的一个栈溢出漏洞。
因为这个漏洞影响了CS:GO官方的windows客户端,所以其实是可以有资格被Valve的漏洞赏金程序接受的,其实这个洞是以前一个报告的一个小变种。于是我在CTF之后很快就把他报给了官方,之后很快就被patch了。
于是我得到了不错的报酬,之后我就决定花点时间来找找这个目标里的类似情况,并且在这个过程当中学了点黑盒模糊测试的东西,以前一直没机会去学。这篇文章是用来给我和其他人当一篇记录用的,主要记一下我用AFL的QEMU模式的一些经验以及我用来模糊测试BSP文件的方法。用这些方法帮我在3天内找到并且分析了csgo.exe中的3个远程可以用的栈相关和5个堆相关的内存损坏问题。
我觉得有必要提一下Valve认为我的堆相关漏洞(线性溢出和一些半控制的溢出写)有必要修复,在我没有提供完整的利用程序之前,而利用程序会由于aslr而非常难写。无论如何,我觉得这些漏洞作为一个利用链中一部分会非常有用。所以,如果你决定复制一下我的工作,你是可以找到一些0day的。
请务必记住,在黑盒测试方面我还是个新手,很乐意去学习,所以如果我的一些决策不太好,或者我没找到一些可以让事情简单点的工具的话,请务必告诉我,我会非常感激的。
CS:GO当中用到的地图文件格式(也可能是所有使用Source游戏引擎的游戏)叫做BSP,是binary space partition
(二进制空间分区)的缩写,也就是一种简单快捷的把对象在n维空间中表示的方法。另外,这个格式还不止能够表示3D的信息。BSP文件在服务器和客户端都有处理,因为两者都需要地图信息的一部分来完成他们自己那块的逻辑。由于客户端初始化了的地图改变的时候,客户端是会从服务器下载一份位置地图的,所以是一个远程攻击面。
从安全研究的角度来讲,我们感兴趣的是客户端和服务器共享的最外层解析代码,大多数在2007年Source游戏引擎源代码泄露中我们都能找到。至少在我看来,代码总体上没有变的太大,BSP解析器也几乎没有做任何安全方面的漏洞修补。解析器的入口函数是CModeLoader::Map_LoadModel
。
太长不看版:跟着https://github.com/niklasb/bspfuzz
复现。
简单的说,我决定对linux服务器二进制文件进行模糊测试,而不是客户端(虽然在linux上也能跑)。对一个命令行程序做模糊测试感觉还是比一整个3D游戏正常一点。这个方法显然没法让我找到任何客户端相关的问题,但是我想的是在共享的代码部分找点容易发现的东西,所以还行。共享的解析代码其实已经够复杂的了,所以我对地图加载过程的模糊测试在性能上没什么太高的预期。我的目标是一个核心每秒100次执行。安装服务器部分你可以参考一下官方的教程。
我看了一个youtube上的一个教程,主要是教怎么用Hammer去做一张非常简单的地图,但是没想到的是这就已经300k大小了。这巨大的尺寸其实主要是因为里边的模型数据是没经过压缩存储的,所以我随手写了份脚本把一些没啥用的数据都扔了,但是依然保持文件结构完整性。这份脚本可以把在最大的lumps里的数据扒到原来尺寸的不到5%,这样就可以得到一个不到16K的文件。但是这文件没办法再完全被客户端加载了,不过可以正常的被Map_LoadModel
加载。
你可以这样把这个地图加到服务器里:
$ LD_LIBRARY_PATH=`pwd`/bin ./srcds_linux -game csgo -console -usercon \
+game_type 0 +game_mode 0 +mapgroup mg_active +map test \
-nominidumps -nobreakpad
这样会把位于csgo/maps/test.bsp
里的地图加载进去。加载过程大概需要15秒多,所以这肯定不能用来直接进行模糊测试。于是我决定自己用服务器二进制文件用到的共享库来写个wrapper,其中比较重要的(从我的目的来看)是:
engine.so
- 主要的Source游戏引擎代码(包括BSP解析)dedicated.so
- 专用服务器实现(包括应用程序入口)libtier0.so
- 大概和Steam或者应用程序管理相关于是这个wrapper主要干了这么几件事:
DedicatedMain
(srcds_linux
二进制文件也这么干的)来启动一个服务器。engine.so
中的NET_CloseAllSockets
patch掉,来让他重新跳到startpoint()
函数来重新获取权限。forkserver()
函数(这儿一会我们会让AFL来fork)CModelLoader::GetModelForName
来从制定地图文件名加载地图。这里需要对engine.so
和libtier0.so
打几个patch,用一个python脚本就可以了。wrapper和patch脚本都要根据服务器的版本进行调整,主要是针对偏移量改变。
我对AFL做了一点改动:
.bsp
结尾这样才能被GetModelForName
正常解析。AFL_ENTRY_POINT
环境变量,在AFL的QEMU部分进行了解析。根据QEMU做重编译的过程,我们大概需要指明基本块的开头指令。这些都patch了之后,跑fuzzer就很简单了:
$ export AFL_ENTRY_POINT=$(nm bspfuzz |& grep forkserver | cut -d' ' -f1)
$ export AFL_INST_LIBS=1
$ afl-fuzz -m 2048 -Q -i fuzz/in -o fuzz/out -- ./bspfuzz @@
最好是用多进程,如果你直接用我的wrapper脚本的话,默认就可以。这是我用8核进行了5分钟fuzz之后的情况:
在我的Ryzen 7 1800X上平均有每秒每线程50次执行。一周以后(虚拟机在之后被停了两周):
显然我们得找个办法把“好的”bug和没啥意思的bug分开(比如把纯粹的越界读)。我用了一个简单的基于调用栈的去重,然而在Valgrind里边跑了每一个样例。然后我grep了Invalid write
,非常的精妙。
$ sudo sysctl -w kernel.randomize_va_space=0
$ cd /path/to/bspfuzz/triage
$ ./triage.sh
$ ./valgrind.sh
$ egrep 'Invalid write' -A1 valgrind/* | egrep at | perl -n -e '/.*at (0x[^:]+)/ && print "$1\n";'
这得花点时间,我关了ASLR,所以这的crash位置都是唯一的。之后我又开了valgrind,然后手动把库的基地址都记下来,然后找到了每个"invalid write"的位置的库和偏移地址。
之后对于每个地址,我根据泄露的源码手动逆向了函数。有的地方是新的代码,但是前后的部分由于泄露的代码,极大的帮助了我逆向。我慢慢把大部分BSP解析代码都标上了符号,这也用到了从leak的文件中拿出来的类型。
对于每个poc,我验证了他们在windows的客户端上也可以触发。我发现所有bug在linux服务器和windows客户端上都有问题。
从这个小项目中我个人学到的经验:
(这就是那个我发给Valve的报告。但是是个WONTFIX,也就是说,只要没人拿出exp,这个就一直是个0day)
在CVirtualTerrain::LevelInit
里有个堆溢出,因为dphysdisp_t::numDisplacements
变量的值可以比g_DispCollTreeCount
大,在release版本里没有assert检查。一个比较老的版本代码可以在https://github.com/VSES/SourceEngine2007/blob/master/se2007/engine/cmodel_disp.cpp#L256里找到:
void LevelInit( dphysdisp_t *pLump, int lumpSize )
{
if ( !pLump )
{
m_pDispHullData = NULL;
return;
}
int totalHullData = 0;
m_dispHullOffset.SetCount(g_DispCollTreeCount);
// [[ 1 ]]
Assert(pLump->numDisplacements==g_DispCollTreeCount);
// 计算lump的大小
unsigned short *pDataSize = (unsigned short *)(pLump+1);
for ( int i = 0; i < pLump->numDisplacements; i++ )
{
if ( pDataSize[i] == (unsigned short)-1 )
{
m_dispHullOffset[i] = -1;
continue;
}
// [[ 2 ]]
m_dispHullOffset[i] = totalHullData;
totalHullData += pDataSize[i];
}
在[[1]]位置的assert在release版本中没有,所以在[[2]]位置有一个溢出。需要注意的是g_DispCollTreeCount
和numDisplacements
值,也就是pDataSize
的内容是一个一个从BSP文件里得到的,所以攻击者可以利用m_dispHullOffset
缓冲区去尝试获得控制。所以,利用是很有可能的,特别是在Windows 7里边很多模块都没有打开ASLR。
[我还附上了numDisplacements = 0xffff
以及g_DispCollTreeCount = 2
的BSP文件,可以有效的把csgo.exe
搞崩掉。]