原文链接: https://sensepost.com/blog/2019/hacking-doom-for-fun-health-and-ammo/
翻译: 看雪翻译小组 - Nxe
校对: lipss
还记得 iddqd
和 idkfa
吗? 这两个字符串自我年幼的时候就深深刻入我的脑海, 那时的我特别喜欢玩共享版的毁灭战士(Doom). 在 SenseCon'19 上, 我和 Lauren 以及 Reino 三人逆向研究了 chocolate-doom , 从而重现相似的作弊效果. 结果如何呢? 下面有一个展示视频. 我们设法做到了以下作弊功能:
如果你想使用我们的作弊代码, 在这里 下载, 你也可以贡献新的代码 :)
原本的毁灭战士发布于1993 #Main_series), 运行系统和我们现在用的有些不同. chocolate-doom 项目旨在我们现代的操作系统上尽可能贴近原版地正常运行游戏. 完美怀旧. 我们从这里 下载 chocolate-doom, 解压并获取一个共享的 WAD 文件 来使用.
我们也给我们的项目订了些规矩. chocolate-doom 是开源的, 然而我们一点也不想参考. 解压之后, chocolate-doom.exe
是一个 stripped PE32 可执行文件. 这意味着逆向工程可能会更难一些, 但这正是我们想要的学习和挑战中的一部分. 使用诸如 IDA 免费版 , CheatEngine 和 WindDBG 一类的工具完成这个挑战应该算是公平. 不过任何补丁或者二进制修改是使用 Frida 实现的, 而不是手动给 chocolate-doom.exe
打补丁.
万事开头难, 我们打算从使用 CheatEngine 开始, 根据游戏的界面找到一些有趣的代码位置.
首先是找出负责弹药数的代码. CheatEngine 是一款特别适合这个任务的内存扫描器和调试器. 你可以将 CheatEngine 附加到某个运行的进程上, 然后扫描这个进程的内存, 从而发现特定数值的所有实例. 如果我们目前的弹药数是49,我们可以在内存中搜索值是49的所有实例。不过进程内存中有可能扫出许多跟数值相关的实例 - 一次扫描通常会返回许多个. 不过不是所有实例都与弹药数有关。
搜索值 49 返回了许多内存位置
为了找出正确的位置, 我们可以对该值稍作变更, 然后使用 CheatEngine 重新扫描, 查看之前找到的实例的值是否有随之发生相同变化的. 我们可以在游戏中用手枪射击几次, 注意弹药量的变化. 然后在 CheatEngine 使用 "下一次搜索(Next Scan)" 功能和 "减少数值(Decreased value by)" 扫描类型选项来找到发生相同变化的值. 如此操作之后, 可能负责弹药量的位置减少到仅 3 处了.
仅有3处可能负责弹药量的位置
此时所有可能的实例可能是弹药量的原始值, 或是该原始值的拷贝. 我们可以观察这些内存位置, 来确定哪些指令对这些地址进行了写操作, 从而尝试找出负责减少弹药量的代码. 为了用 CheatEngine 做到这点, 你只需右键点击观察的内存位置, 然后选择 "找出何指令写入该地址(Find out what writes to this address)".
观察对内存位置的更新
当观察我们找到的前两个位置时, 我们发现即使在游戏暂停时, 这些指令也会快速地写入目标指针. 而且这些指令也没有减去弹药量, 这意味着这些指令可能不是我们所寻找的.
观察的内存位置上不断快速运行的指令
第三个匹配仅在手枪射击时进行写入. 该指令进行减一操作, 因此它可能是我们感兴趣的进行减少弹药量的指令. CheatEngine 可以反编译发现的指令周围的代码. 利用这个功能, 我们得到了在手枪射击时负责减少弹药量的指令的位置 0x0430A2c
.
减少1发手枪弹药的指令
尽管不是我们项目的主要目标, 我们也尝试了对 chocolate-doom ELF 文件打补丁. 我们使用了类似于上文提到的工具, 然而 CheatEngine 在 Linux 上用不了. 所以我们使用了类似 CheatEngine 功能的scanmem 来代替它来找到有趣的代码位置, 但是 scanmem 只能给出某个值在内存中的地址, 无法给出修改某个值的指令. 在运行 scanmem 并多次输入改变的弹药量的值后, 它也筛选出了3个可能的弹药量地址. 因为 ASLR 机制, 每次运行 chocolate-doom 时这些地址可能会有所改变.
Scanmem 显示保存现在弹药量的值的地址
为了找出哪个指令向找到的指针有写入操作, 我们使用了 gdb
并在 scanmem 找到了每个地址处都设置了断点.
设置的弹药量减少的 Watchpoints
我们之后继续游戏, 用手枪射击一次来触发 watchpoint. 下图中你可以看到之前和现在的弹药量的值.
在手枪射击后触发的 Watchpoint
下一步是检查指令指针在哪里, 然后检查它周围的指令, 看能否找到使每次射击后弹药量值减一的确切指令.
rip 和周围的指令
你可以看到, 在当前所停的指令指针的前一个位置正好是一个 sub
指令. 为了在 IDA 中搜索确切指令, 我们查看 gdb 中该指令的16进制值.
以16进制查看 sub 指令
因为下面的值是小端序的, 我们在 IDA 中搜索操作码83 ac 83 a8
然后得到了负责减少弹药量的指令位置的偏移量, 也就是0x49F28
.
有了一些目标的偏移量以后, 我们可以使用 Frida 进行实时的观察. Frida 有 Interceptor API , 允许你"拦截"函数调用以及像函数前导指令(prolog)和后导指令(epilog)一样运行代码. 使用 拦截器(Interceptor)
可以访问到参数和返回值, 使得能够记录这些数值, 需要的话还可以进行修改.
因为我们知道哪个指令正在写入包含手枪弹药值的内存区域, 我们使用 IDA 分析这个该指令周围的函数来找到入口点. 这样我们就能够知道搭配拦截器使用的内存地址.
影响手枪弹药的函数入口点
在上面的截图中可以看到, 函数起始于0x4309f0
. 基地址0x400000
意味着该函数偏移量为0x309f0
(这个值一会很重要). 知道了偏移量, 我们的第一个 Frida 脚本就可以开工了. 这个脚本仅用来找到 chocolate-doom
模块的基地址, 计算我们感兴趣的函数的偏移量并附加拦截器.
附加在手枪射击函数的入口点的拦截器
附加到 chocolate-doom 以后, 我们可以在下面视频中看到该脚本的运行情况.
当我们用手枪射击时触发了函数, 证实了我们没走错路
我们想先写一个简单的修补开始, 它仅将弹药量减1的指令替换为 NOP
指令. Frida 的 Code writer 模块可以帮我们做到这一点. 原本的递减指令 sub dword ptr [ebx+edx*4+0A4h], 1
以十六进制来看由8个操作码组成.
递减手枪弹药量指令的16进制操作码
可以使用 code writer 实例中的 putNopPadding(8)
模式在0x430a2c
位置填充8个 NOP
指令.
Frida code writer 用来将弹药递减指令替换为8个 NOP.
应用这个修补意味着我们再也用不完手枪的弹药了.
为了测试我们基于 NOP
的作弊是否有效, 我们使用了原来的作弊方式("idkfa")来得到所有能用的枪和弹药, 来查看是否对这些枪都有效. 结果是否定的, 经过调查, 每种枪都有自己弹药递减的函数. 所有函数最终都会调用一个操作码, 该操作码会执行弹药总量 SUB
0x1
操作(除了机枪是SUB
0x2
). 因此有必要对之前的作弊进行改进.
我们不想硬编码所有找到的指令, 想要找到其他的办法. 使用 IDA 搜索操作码0x83 0xac
(sub dword ptr [ebx+edx*4+0A4h], 1
中的 弹药 SUB
操作码), 我们发现仅有的匹配项是减少弹药函数的组成部分. Frida 有内存扫描的功能, 因此我们可以用它来动态匹配这些我们感兴趣的函数的位置 (比如0x83 0xac
), Memory.scanSync()
这样使用. 然后我们再次使用 Memory.patchCode()
函数将 ADD
的操作码覆盖为 SUB
, 以此作为一个简单的2字节的修补 :)
对每个搜索到的操作码匹配项应用弹药修补, 用来增加而不是减少弹药
这样修补更具有通用性, 不需要任何硬编码的偏移量即可生效. 一种更好的搜索方式是使用 Memory.scanSync()
的通配符功能, 这样你可以找到更精确的匹配项.
应用修补之后, 所有武器现在射击时会增加弹药.
在修改了弹药的规则之后, 现在我们来关注血量. 我们使用和前文一样的 CheatEngine 方法来找到血量数据保存的位置以及谁向这些位置进行写入. 但是发现血量位置要复杂一些, 因为经过多次搜索会得到大概四到五个不同的位置. 其中有些位置和之前弹药的例子相同, 有指令进行频繁的写入操作, 因此可以忽略. 在玩家血量减少之后, 有三个位置周围的指令被触发.
保存玩家血量数据的位置
这些指令不是 sub 指令, 而是 mov 指令. 然而通过观察反汇编代码, 我们会发现 sub
指令在 mov
指令前几行的位置.
当玩家血量减少时触发的一组指令之一. 注意 sub 指令在 mov 指令上面, 该指令执行了实际的减操作.
要注意的是, 该指令是两个寄存器之间的减操作, 即 sub eax, esi
. 这是一条相当普遍的指令, 意味着我们不能像在弹药增加的修补中那样, 仅在内存扫描所有实例并将其替换为 add
指令. 相反, 我们需要在每个 sub
指令的位置手动修改其为 add
指令. 查看操作码, sub eax, esi
是 0x29 0xF0
, 而 add eax, esi
是 0x01 0xF0
. 因此补丁仅需将0x29
换成0x01
. sub
指令的三个位置分别是0x3DEEC
, 0x2C385
, 和0x2c39
.
然而对0x3DEEC
进行修补经常会造成游戏崩溃, 所以之后移除了它. 对0x2C385
, 特别是0x2c39
进行修补使得玩家被攻击后血量会增加. 不过这样做也有副作用, 所有怪物被攻击时它们的血量也会增加 - 这可能因为游戏中使用了同样的逻辑对玩家和怪物进行血量的减少. ¯\_(?)_/¯
以 ADD 替换 SUB 以修补血量
应用了修补之后, 可以在下面的视频中看到血量增加作弊的效果.
到目前为止, 我们对 chocolate-doom 进行的作弊修补是在 Frida 注入以及运行着我们的脚本的情况下进行的. 我们很想做到和原版类似的作弊用法, 仅需在游戏中输入 "iddqd" 和 "idkfa" 这样.
为了实现这个想法, 我们分析了 chocolate-doom 的二进制程序, 来找到处理当前作弊用法的逻辑. 我们知道, 如果你输入了 "idkfa", 游戏会弹出一条信息说 "VERY HAPPY AMMO ADDED".
使用了 idkfa 作弊后的信息
IDA 中对这条信息进行文本搜索, 得到了使用它的位置.
IDA 中搜索 Very Happy Ammo Added
我们关注了下引用这个字符串的函数, 发现这个函数难以置信得长. 实际上, 似乎所有的作弊都是由这单个函数来处理的, 每种作弊都有一堆分支. IDA 图形视图(Graph View)中可以看到代码路径, 在复杂性上给我们了一个合理的解释.
IDA 中作弊函数的图形视图
所有不同的作弊分支都调用了一个位于0x040FE90
的函数. 这个函数有两个参数, 一个使是字符数组, 另一个是整型, 该函数似乎是将一个字符和一个字符串作比较.
之前的流程中每个作弊都调用的比较函数
我们决定在运行时看看这个作弊比较函数的调用(以下称为 cheat_compare
), 转储它收到的参数. 就像我们前面使用 Frida Interceptor
来附加到函数上一样, 我们只需计算 cheat_compare
的偏移量并记录其接收的参数即可. 这也使我们有机会来尝试发现如何在游戏中触发此功能.
从 IDA 中我们知道了第一个参数是字符数组, 所以我们只需使用 Frida 的 readCString()
方式来转储原始字符串. 对于第二个参数, 我们还不完全确定它会是什么, 暂时先不处理.
使用 Frida Interceptor 来转储参数
脚本钩取之后, 结果令人惊讶...
游戏接收的每一次按键似乎都传进了我们称为 cheat_compare
的比较函数, 甚至方向键! 在上面的视频演示中, 我们缓慢输入了作弊码"iddqd", 你可以在其中看到, 游戏将我们输入的ASCII字符的十六进制值与许多可能的作弊码进行了比较. 一旦作弊码匹配, 我们就使用方向键将游戏中的家伙向左移动了几次, 诸如"0xffffffac"之类的值也进入了字符串匹配的过程. 虽然我们还不了解完整的作弊流程, 但我们也能确定这些值永远不会匹配到合法的字符串, 所以我们怀疑能在这里找到优化的机会 :)
cheat_compare
接收到的两个参数足够让我们开始实现自己的作弊. 实际上, 接收输入的键码是我们唯一要做的. 我们本来可以尝试修补原始流程来匹配一些新的字符串以触发我们的补丁, 但我们选择了一种更简单的办法. 我们可以读取 cheat_compare
接收到的键码, 对我们的作弊进行一些测试, 然后让原始函数照常运行.
其中有一个重要的概念, 我怀疑许多人在使用 Frida 时不会立即注意到. 尽管 Frida 是一个出色的运行时插桩库, 它也可以被用来在任何函数中执行 JavaScript 逻辑. 换句话说, 我们可以在二进制程序中引入并运行不是 该二进制文件中的代码. 我们不需要修补代码来跳转到我们写的操作码, 而是运行纯 JavaScript.
cheat_compare
方法的确有点让人迷惑. 我们决定使用获得的键码当作参数, 但是必须解决以下事实: 由于针对相同键码的不同作弊方法被反复调用, 我们会多次收到同一个键码. 所以我们决定只记录唯一的键码. 这导致了一个限制: 我们的作弊码不能有重复的字符. 最后的方法就是, 检查输入的字符, 如果该字符是不同的, 就附加在字符串后. 当添加了新字符后, 返回完整的记录缓存.
记录 cheat_compare() 接收的键码的方法
接下来, 我们附加到 cheat_compare
以触发新的 getCheatFromBufWithChar()
功能, 从而获取到目前为止输入的字符缓冲区. 如果缓冲区以我们的任一字符串结尾, 我们将触发相关补丁以激活作弊! 为了稍微优化相关流程, 如果输入的键码不在 ASCII 可见字符范围内, 我们会提前退出.
用来匹配新的自定义作弊码的 cheat_compare 入口点
该脚本将读取的任意不同的ASCII字符进行比较以切换作弊状态. 这也意味着我们必须撤销我们之前的补丁, 然后编写较小的流程, 但由于我们已经知道偏移量和原始操作码, 因此相对容易完.
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
最后于 2019-12-20 16:33
被Nxe编辑
,原因: 更换了图床