使用afl对CS:GO进行模糊测试

在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的。

请务必记住,在黑盒测试方面我还是个新手,很乐意去学习,所以如果我的一些决策不太好,或者我没找到一些可以让事情简单点的工具的话,请务必告诉我,我会非常感激的。

BSP 文件格式和攻击面

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,其中比较重要的(从我的目的来看)是:

于是这个wrapper主要干了这么几件事:

  1. 调用DedicatedMain(srcds_linux二进制文件也这么干的)来启动一个服务器。
  2. 通过将engine.so中的NET_CloseAllSocketspatch掉,来让他重新跳到startpoint()函数来重新获取权限。
  3. 调用forkserver()函数(这儿一会我们会让AFL来fork)
  4. 调用CModelLoader::GetModelForName来从制定地图文件名加载地图。
  5. 以最快速度退出。

这里需要对engine.solibtier0.so打几个patch,用一个python脚本就可以了。wrapper和patch脚本都要根据服务器的版本进行调整,主要是针对偏移量改变。

AFL

我对AFL做了一点改动:

  1. 输入文件必须由.bsp结尾这样才能被GetModelForName正常解析。
  2. 我得能自己指定fork服务器在哪儿启动。我加了一个AFL_ENTRY_POINT环境变量,在AFL的QEMU部分进行了解析。根据QEMU做重编译的过程,我们大概需要指明基本块的开头指令。
  3. 在等fork的时候,加大超时时间的乘数。

这些都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客户端上都有问题。

一点经验

从这个小项目中我个人学到的经验:

示例bug:在CVirtualTerrain::LevelInit里的堆溢出

(这就是那个我发给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_DispCollTreeCountnumDisplacements值,也就是pDataSize的内容是一个一个从BSP文件里得到的,所以攻击者可以利用m_dispHullOffset缓冲区去尝试获得控制。所以,利用是很有可能的,特别是在Windows 7里边很多模块都没有打开ASLR。

[我还附上了numDisplacements = 0xffff以及g_DispCollTreeCount = 2的BSP文件,可以有效的把csgo.exe搞崩掉。]

原文链接

https://phoenhex.re/2018-08-26/csgo-fuzzing-bsp

源链接

Hacking more

...