使用AFL对《反恐精英:全球攻势》地图文件进行模糊测试

本文详细介绍了如何使用AFL(American Fuzzy Lop)对《反恐精英:全球攻势》(CS:GO)的BSP地图文件进行模糊测试,发现了多个内存损坏漏洞,包括堆和栈溢出问题,并分享了测试设置、代码分析和漏洞示例。

使用AFL模糊测试《反恐精英:全球攻势》地图文件

RealWorldCTF 2018有一个非常有趣的挑战叫做“P90 Rush B”,这是对Valve游戏《反恐精英:全球攻势》(CS:GO)中一种绝望战术的引用。该挑战涉及发现并利用CS:GO服务器使用的地图文件加载器中的一个漏洞。

在CTF期间,我利用了一个栈缓冲区溢出漏洞,后来另一个团队在一篇报告中很好地描述了它。由于这个漏洞也影响了官方的CS:GO Windows客户端,它符合Valve的漏洞赏金计划,并且实际上只是一个旧报告的小变种,因此我在CTF后迅速报告了它,并很快得到了修复。

我获得了一笔可观的赏金,并决定花一些时间在这个组件中寻找类似的问题,同时学习一些关于黑盒模糊测试的知识,这是我过去没有机会做的。本文应该作为我自己和其他人使用AFL在QEMU模式下的经验记录,以及我模糊测试BSP文件的方法。它帮助我在大约3天内发现并分析了csgo.exe中的3个远程可触发的基于栈的内存损坏和5个基于堆的内存损坏问题。

我应该提到,Valve认为我的基于堆的漏洞(线性溢出和一些半可控的越界写入)不值得修复,除非我提供完整的利用代码,但由于ASLR,这将非常困难。尽管如此,我认为这些漏洞作为利用链的一部分会非常有用。因此,如果你决定复制我的研究,你可以找到真正的0day漏洞。

请注意,我在黑盒模糊测试方面是个新手,愿意学习,所以如果我的某些决定不好,或者我错过了一些可以让我生活更轻松的工具,请指出。我对此项目的任何反馈都表示赞赏。

BSP文件格式与攻击面

CS:GO(可能所有Source引擎游戏)中使用的地图文件格式称为BSP,是二进制空间分割的缩写,这是一种方便的n维空间对象表示方式。然而,该格式支持的远不止3D信息。BSP文件由服务器和客户端处理,因为两者都需要地图信息的某个子集来执行各自的任务。它是远程攻击面,因为客户端会在服务器发起的地图更改时从服务器下载未知地图。

从安全研究的角度来看,有趣的是最外层的解析代码在客户端和服务器之间是共享的,并且大部分对应于我们在2007年泄露的Source引擎源代码中可以找到的内容。据我所知,代码库自那时以来没有太大变化,并且几乎没有对BSP解析器应用任何安全漏洞修复。解析器的入口点是函数CModelLoader::Map_LoadModel。

模糊测试设置

TL;DR 按照https://github.com/niklasb/bspfuzz 的说明进行复制。

为简单起见,我决定模糊测试Linux服务器二进制文件,而不是实际的客户端(它也可以在Linux上运行)。模糊测试CLI应用程序比完整的3D游戏更自然。通过这种方法,我显然无法找到任何客户端特定的问题,但我希望在共享代码中找到低挂的果实。共享解析代码已经相当复杂,并且我对覆盖更多地图加载过程的任何体性能没有太高期望。我的目标是每个核心至少100次执行/秒。

要安装专用服务器,您可以按照官方说明操作。

我咨询了一个YouTube教程,在Hammer中创建一个非常简单的地图,并惊讶地发现它已经有300KB的大小。大尺寸主要是由于包含的模型数据大部分以未压缩形式存储。因此,我写了一个脏小脚本,剥离了一些不必要的数据,同时保持文件结构完整。具体来说,它将最大块中包含的数据缩减到原始大小的约5%。结果是一个16KB的文件,可能无法在客户端完全加载,但被Map_LoadModel无错误地消耗。这是我目前的整个语料库。

这是您在服务器中加载此地图的方式:

1
2
3
$ 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秒或更长时间,因此绝对不适合直接用于模糊测试。

相反,我决定为自己编写一个包装器,围绕服务器二进制文件使用的共享库,其中最重要的(对于我的目的)是:

  • engine.so – 主要的Source引擎代码(包括BSP解析器)
  • dedicated.so – 专用服务器实现(包括应用程序入口点)
  • libtier0.so – 可能与Steam /应用程序管理相关

包装器执行以下操作:

  • 调用DedicatedMain(就像srcds_linux二进制文件那样)启动服务器。
  • 在初始化完成后通过修补engine.so中的NET_CloseAllSockets来重新获得控制权,使其跳转到startpoint()函数。
  • 调用forkserver()函数(这是我们将告诉AFL稍后分叉的地方)。
  • 调用CModelLoader::GetModelForName,从给定的文件名加载地图。
  • 尽可能快地退出。

这需要对engine.so和libtier0.so进行一些修补,这些修补由Python脚本应用。包装器和修补脚本都需要为每个新版本的服务器进行调整,以包含更改的偏移量。

AFL

我在AFL本身中必须进行一些简单的更改:

  • 输入文件必须以.bsp结尾,以便被GetModelForName正确解析。
  • 我需要能够指定一个自定义点来启动分叉服务器。我为此引入了环境变量AFL_ENTRY_POINT,它由AFL的QEMU部分解析。从QEMU进行重新编译的方式来看,它可能必须指定基本块开头的指令。
  • 在等待分叉时增加超时乘数。

应用这些更改后,运行模糊测试器变得非常简单:

1
2
3
$ 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 @@

推荐使用多处理,如果您使用我的包装脚本,这是默认的。这是在8个核心上模糊测试5分钟后的状态:

我在我的Ryzen 7 1800X上平均每个线程获得约50次执行/秒。并且经过1周(VM自那时起暂停了另外2周):

分类与根本原因分析

显然,我们需要一些方法来区分“好”漏洞和无趣的漏洞(例如纯越界读取)。我基于调用堆栈进行了简单的去重,然后在Valgrind中运行每个唯一样本。然后我搜索了Invalid write…我知道,这是精细的工作。

1
2
3
4
5
$ 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,以便崩溃位置是唯一的。然后我再次手动启动valgrind,记下库的基地址,并为每个“无效写入”位置找到正确的库和偏移量。

然后对于每个位置,我手动逆向工程二进制文件,以找出它是否对应于泄露源代码树中的函数。在某些情况下,它是新代码,但可用源代码提供的上下文极大地帮助了逆向工程。我逐渐符号化了许多BSP解析器代码,还使用了从泄露头文件中收集的类型集合。

对于每个PoC,我验证了它是否也在Windows客户端上触发。我没有发现任何在Linux服务器中存在但不在Windows客户端中的有趣漏洞。

经验教训

这是我从这个小项目中获得的个人经验:

  • 如果稍微修改并使用包装二进制文件,AFL在QEMU模式下对于攻击特定代码片段非常灵活。
  • 输入文件大小非常重要。通过从300KB减少到16KB,我获得了至少5倍的性能提升。可能更小会更好。
  • 在筛选以前未经过模糊测试的代码库时,分类非常重要。
  • 堆上的内存损坏不是安全问题 :)

示例漏洞:CVirtualTerrain::LevelInit中的堆缓冲区溢出

[这只是我发送给Valve的报告。它是一个WONTFIX,即只要没有人提供利用代码,它可能仍然是0day。]

在CVirtualTerrain::LevelInit中发生堆缓冲区溢出,因为dphysdisp_t::numDisplacements变量的值可能大于g_DispCollTreeCount,并且检查这种情况的断言在发布版本中不存在。旧版本的代码可以在https://github.com/VSES/SourceEngine2007/blob/master/se2007/engine/cmodel_disp.cpp#L256找到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);
        // count the size of the 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 ]]处的断言在发布版本中不存在,因此可以在[[ 2 ]]处发生溢出。值得注意的是,g_DispCollTreeCount和numDisplacements的值以及pDataSize的内容都是从BSP文件中逐字获取的,因此攻击者可以对m_dispHullOffset缓冲区之后的堆内容进行大量控制。因此,可利用性非常可能,特别是在Windows 7上许多模块未启用ASLR的情况下。

[我附上了一个BSP文件,其中numDisplacements = 0xffff且g_DispCollTreeCount = 2,它可以可靠地使csgo.exe崩溃。]

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计