使用基于快照的模糊器对现代 UDP 游戏协议进行模糊测试

2021-07-22 21:52:32

Axel '0vercl0k' Souchet 最近开源了一个很有前途的新的基于快照的模糊器。用他自己的话说:“fuzz 或 wtf 是一种分布式、代码覆盖引导、可定制、基于快照的跨平台模糊器,旨在攻击在 Microsoft Windows 上运行的用户和/或内核模式目标。”在这篇文章中,我们将介绍为什么创建模糊器模块的过程,允许我们对数百万活跃玩家喜欢的流行 3A 多人游戏标题的数据包解析代码进行模糊测试。在 Tenet 的补充下,我们展示了如何使用这两种技术来发现和分析关键的现实世界漏洞。模糊器在发现软件错误方面变得越来越有效。基于快照的模糊器构成了一个高级类别的模糊测试,它使用模拟器(或其他虚拟化技术)在利用强大的内省功能的同时,有效且确定性地模糊测试“难以到达”的代码。这些类型的模糊器通常在执行研究人员对模糊测试感兴趣的代码之前,通过从实时系统(或 VM)捕获的“快照”进行播种。快照通常包含完整的系统内存、CPU 寄存器或在模拟环境中忠实地恢复执行所需的任何运行时信息。通过管理自己的完整系统模拟器,基于快照的模糊器可以在执行期间有效地跟踪“脏”内存页面,随时将内存和 CPU 寄存器重置为“干净”状态(快照)。为了在这种基于快照的架构下进行模糊测试,模糊器将向模拟系统注入一个错误的测试用例并开始向前执行。如果模拟系统崩溃,模糊器会将当前测试用例保存到磁盘并为下一个测试用例重置模拟器。在本文的其余部分,我们将介绍利用零售 PC 游戏进行模糊测试的过程,以便我们可以对其传入 UDP 游戏数据包的处理执行基于快照的模糊测试。

第一步是确定游戏二进制文件中的代码位置,我们希望从该位置开始进行模糊测试。通过一些逆向,我们确定了一个合适的位置来收集来自网络的 UDP 数据包在重新组装和解密以供游戏客户端处理后收集快照:通过在 ProcessMessages(...) 开始时拍摄系统快照,我们的fuzzer 将能够注入损坏的数据包数据并开始向前执行到此函数调用的 50 多个消息解析器中。按照 fuzz 中包含的说明,我们在为内核调试配置的 Hyper-V VM 上使用 WinDbg,并有 4GB 的内存。在游戏过程中点击我们选择的断点后,我们按照说明使用 bdump.js 在这个确切时刻创建系统的“快照”: ...kd> !bdump "C:\\fuzz\\dump"[ bdump] 创建目录...[bdump] 正在保存 regs...[bdump] 寄存器修正...[bdump] 不知道如何获得 mxcsr_mask 或 fpop,设置为零...[bdump][bdump] 不'不知道如何获取 avx 寄存器,跳过...[bdump][bdump] tr.base 不是规范的...[bdump] 旧 tr.base: 0x7fe30000[bdump] 新 tr.base: 0xfffff8067fe30000[bdump][ bdump] 在 cs.attr 上设置标志 0x2000...[bdump] old cs.attr: 0x29b[bdump] new cs.attr: 0x229b[bdump][bdump] 保存内存,喝杯咖啡或抽烟,这可能会大约需要 10-15 分钟...[bdump] 创建 C:\fuzz\dump\mem.dmp - 活动内核和用户内存位图转储 [bdump] 收集页面以写入转储。这可能需要一段时间。[bdump] 0% 已写入。[bdump] 5% 已写入。还剩 42 秒。[bdump] 已写入 10%。剩余 45 秒。- 剪辑-[bdump] 95% 已写入。剩余 2 秒。[bdump] 在 39 秒内写入 2.9 GB。[bdump] 平均传输速率为 74.4 MB/s。[bdump] 转储成功写入 [bdump] 完成!@$bdump("C:\\fuzz\\ dump") 收集了位于 ProcessMessages(...) 入口点的游戏的完整系统快照,我们不再需要“实时”(Hyper-V 来宾)系统。实际的模糊测试将在基于快照的模糊测试器管理的模拟环境中“离线”进行。下一步将是为我们的快照创建一个模糊器模块(或线束)。在这种情况下,harness 是我们必须编写的代码,它告诉模糊器如何初始化我们的快照,它应该在何处为每次执行注入模糊测试用例,以及在模糊测试时它可以忽略哪些类型的事件。我们可以从制作 fuzzer_hevd.cc 的副本开始,这是 fuzzer 附带的示例工具。使用我们自己的名为 fuzzer_game.cc 的副本,需要填写三个主要接口:

Init(Options, CpuState) – 对模拟系统执行任何一次性 mem/reg 调整,定义“目标”Restore() – 在执行每个测试用例后恢复由线束实现的任何“外部”状态以实现 Init(.. .) 接口,我们首先要为模糊器定义一个“停止点”以停止执行和恢复。在不崩溃的情况下到达 ParseMessages(...) 的返回指令是停止执行的好地方,因为我们假设模糊消息被“正确”处理: bool Init ( const Options_t & Opts , const CpuState_t & CpuState ) { // 停止执行如果我们到达 ParseMessages(...) 中的 ret 指令 if (!g_Backend -> SetBreakpoint ( Gva_t ( 0x1401F66C5 ), []( Backend_t * Backend ) { DebugPrint ( "Reached function end \n "); Backend -> Stop ( Ok_t ()); })) { return false ; } // 检测 Windows 用户模式异常调度器以捕获访问违规 SetupUsermodeCrashDetectionHooks ();返回真;这很重要,因为我们现在只对模糊游戏的一小部分感兴趣,即网络消息解析例程。虽然游戏可能会在稍后崩溃,但我们限制了开始的范围。接下来,我们必须为模糊测试工具实现 InsertTestcase(...) 接口。这将在每次执行之前调用,并且必须用于将模糊器提供的模糊测试用例注入模拟系统: bool InsertTestcase ( const uint8_t * Buffer , const size_t BufferSize ) { // 我们逆向工程的“位缓冲区”结构来自游戏可执行文件 bf_read 缓冲区; // 从快照内存中读取原始网络消息位缓冲区对象 if ( !g_Backend -> VirtReadStruct ( Gva_t ( g_Backend -> Rdx ()), & buffer )) { DebugPrint ( "Failed to read bitbuf during testcase injection!" ) ;返回假; } // 针对这个模糊测试用例缓冲区,相应地修改网络消息位缓冲区。 m_nCurDword = 0 ;缓冲 。 m_nNumBitsLeft = 0 ;缓冲 。 m_nDataBytes = 缓冲区大小;缓冲 。 m_nDataBits = BufferSize * 8 ;缓冲 。 m_pDataCur = 缓冲区。 m_pData;缓冲 。 m_pDataEnd = 缓冲区。 m_pData + 缓冲区大小; // 将修改后的位缓冲区结构写回快照 if ( !g_Backend -> VirtWriteStruct ( Gva_t ( g_Backend -> Rdx ()), & buffer )) { DebugPrint ( "Failed to write modified bitbuf during testcase injection!" );返回假; } // 将经过模糊处理的消息数据注入快照以用于此执行 if (!g_Backend -> VirtWrite ( Gva_t (( uint64_t ) buffer . m_pData ), Buffer , BufferSize , true )) { DebugPrint ( "Failed to write next testcase!" );返回假;返回真; }

我们的用例只需要实现这两个线束功能。在执行每个模糊测试用例后,模糊器会自动为我们重置 CPU 和任何脏内存页。要开始模糊测试,我们首先必须创建一些文件夹,如模糊测试自述文件的使用部分所述。这个层次结构对于模糊测试爱好者来说应该有些熟悉:因为我正在对游戏的网络消息解析器进行模糊测试,所以我使用动态二进制检测来嗅探和转储在正常游戏过程中流入我们的目标 ParseMessages(...) 的消息。提供“好的”测试用例自然会帮助模糊器更好地覆盖目标。为方便起见,我创建了两个 .bat 文件,以便更轻松地启动/配置 fuzzer 的 master(服务器)和 fuzz(worker)节点。首先我们启动master.bat来启动fuzzing服务器:C:\fuzz\src\build\RelWithDebInfo\wtf.exe ^ fuzz ^ --name GameFuzz ^ --backend=bochscpu ^ --max_len 1024 ^ --limit 500000 ^ --target C:\fuzz\targets\game 如果一切设置正确,模糊器将开始运行。对于这项工作,我启动了 8 个 fuzz 节点(每个 CPU 核心大约一个),并让 fuzzer 发挥它的魔力:在运行时,fuzzer 会将所有产生新覆盖的测试用例保存到输出文件夹中。导致唯一崩溃的测试用例将被复制到 crashes 文件夹。在让 fuzzer 运行三个小时并观察其覆盖率 % 增长(覆盖率引导的模糊测试!)后,我们可以看到它已经产生了几个有趣的崩溃:

针对零售游戏测试这些格式错误的数据包需要对游戏的 UDP 网络堆栈(数据包加密、解密、分片、重组、排序等)进行广泛的逆向工程,以便干净地发送。正常情况下也可能难以调试流经应用程序的畸形数据包。在下一节中,我们将演示如何使用模糊器的 bochs 后端来跟踪这些崩溃,以便我们可以验证它们的影响并消除这些情况在未来针对此目标的模糊作业中出现。评估基于快照的模糊器产生的崩溃是 Tenet 的完美用例。 Tenet 是一个永恒的跟踪浏览器,在 IDA Pro 中呈现为类似调试器的体验。通过拉取请求,我使用其内置的 bochs 后端扩展了模糊以生成 Tenet 跟踪。创建以下 trace.bat 文件,我们能够生成所有崩溃输入的 Tenet 跟踪: C:\fuzz\src\build\RelWithDebInfo\wtf.exe ^ run ^ --name GameFuzz ^ --backend bochscpu ^ -- state "C:\fuzz\targets\game\state" ^ --input "C:\fuzz\targets\game\crashes" ^ --trace-path "C:\fuzz\targets\game\traces" ^ --跟踪型原则 Tenet 的导航有一点学习曲线(好吧,也许是学习悬崖),但是一旦您熟悉了它的潮起潮落,只需几分钟就可以找出未知代码崩溃的根源。此跟踪以崩溃和 RIP 设置为 0x7676767 结束。很快我们就可以看到这个模糊器生成的测试用例导致了某种类型的基于堆栈的缓冲区溢出。使用 Tenet,我们可以在对损坏的返回地址进行的内存读/写之间滚动,或者在断点之间来回滚动以观察导致溢出的循环。

清理与此错误相关的反编译,我们留下以下内容:在处理此恰好与游戏微交易相关的网络消息时,它将从消息(位缓冲区)中读取一个 8 位值 num_ids。它使用此值来确定从消息中读出多少个 16 位“id”,并将它们存储在基于堆栈的数组 id_array 中。由于 id_array 只有 16 个插槽,因此 8 位长度值 num_ids 不得大于 16,否则代码将继续写入超过数组末尾的任意 16 位值。由于所有这些都是远程来源的数据(即攻击者可控)并且看不到堆栈cookie,这被认为是一个严重的漏洞。找到一个漏洞的根源后,我们现在可以修改模糊器以精确检测这种畸形的执行状态并实时忽略它。在这种情况下,如果从消息中解析出的 8 位 num_ids 大于 16,我们希望终止执行。 回顾易受攻击代码的程序集,我们可以看到 num_ids 值应该在 r14 中的指令 0x140244C1B 紧随其后从消息位缓冲区解析:在模糊器中,我们可以向我们之前实现的 Init(...) 接口添加逻辑,以检测和忽略将显式满足写入基于堆栈的数组末尾的条件的测试用例: bool Init ( const Options_t & Opts , const CpuState_t & CpuState ) { // 如果到达 ParseMessages(...) 中的 ret 指令,则停止执行 if ( !g_Backend -> SetBreakpoint ( Gva_t ( 0x1401F66C5 ), []( Backend_t * Backend ) { DebugPrint ( "Reached function end \n "); Backend -> Stop ( Ok_t ()); })) { return false ; } // 如果测试用例将触发 MTX 堆栈粉碎,则停止执行 if ( !g_Backend -> SetBreakpoint ( Gva_t ( 0x140244C1B ), []( Backend_t * Backend ) { if ( Backend -> R14 () > 16 ) { DebugPrint ( "忽略 MTX 漏洞 \n " ); 后端 -> 停止 ( Ok_t ()); } })) { return false ; } // ... }

这将防止错误产生不必要的“噪音”(源自同一错误的崩溃),同时使模糊器能够将时间花在更有趣的测试用例上。如果我们重建模糊测试并恢复模糊测试,我们应该不会再遇到此问题导致的崩溃。在这篇文章中,我们演示了如何使用 fuzz(一种用于 Windows 软件的基于开源快照的新模糊器)来利用和模糊当代 PC 游戏的数据包解析代码。在三个小时的过程中,fuzzer 发现了几个严重程度令人担忧的独特崩溃。通过扩展基于快照的模糊器以生成 Tenet 跟踪,我们仅对其中一个崩溃进行了根本原因分析,确认其影响是游戏客户端中的关键远程代码执行漏洞。