使用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无错误地消耗。这是我目前的整个语料库。
这是您在服务器中加载此地图的方式:
|
|
它将从文件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进行重新编译的方式来看,它可能必须指定基本块开头的指令。
- 在等待分叉时增加超时乘数。
应用这些更改后,运行模糊测试器变得非常简单:
|
|
推荐使用多处理,如果您使用我的包装脚本,这是默认的。这是在8个核心上模糊测试5分钟后的状态:
我在我的Ryzen 7 1800X上平均每个线程获得约50次执行/秒。并且经过1周(VM自那时起暂停了另外2周):
分类与根本原因分析
显然,我们需要一些方法来区分“好”漏洞和无趣的漏洞(例如纯越界读取)。我基于调用堆栈进行了简单的去重,然后在Valgrind中运行每个唯一样本。然后我搜索了Invalid write…我知道,这是精细的工作。
|
|
这将需要一段时间。我在这里禁用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 ]]处发生溢出。值得注意的是,g_DispCollTreeCount和numDisplacements的值以及pDataSize的内容都是从BSP文件中逐字获取的,因此攻击者可以对m_dispHullOffset缓冲区之后的堆内容进行大量控制。因此,可利用性非常可能,特别是在Windows 7上许多模块未启用ASLR的情况下。
[我附上了一个BSP文件,其中numDisplacements = 0xffff且g_DispCollTreeCount = 2,它可以可靠地使csgo.exe崩溃。]