Debugger.godMode() – 用调试器破解JVM应用程序

阅读其他语言: English Español Português

在过去,电脑游戏是另一番景象。 不仅图像和机制随着时间演变, 还有一个在当今游戏中似乎不太常见的特点: 几乎所有的游戏都有作弊码。

作弊码是一系列按键,能赋予你非凡的能力, 比如无限弹药或穿墙术。 其中最常见且强大的就是“上帝模式” ——它让你变得无敌。

当输入 IDDQD 后,《毁灭战士》中陆战队员的截图

当你输入 IDDQD ——这是在《毁灭战士》中的“上帝模式”按键组合时, 你的角色就会变成这样。实际上,这个特定的按键序列非常流行,以至于它 成为了一个meme,并超越了游戏本身获得知名度。

尽管“上帝模式”不像过去那样在游戏中普遍,IDDQD 热潮似乎也在消退,但人们或许会好奇 是否有一个现代的等价物存在。就我个人而言,我有自己对 IDDQD 的现代诠释。 虽然它不一定与游戏相关,但它确实唤起了拥有超能力的相似感受。

太空侵略者

为了阐述我的观点,我想引入一个有趣的场景。即使你不熟悉《毁灭战士》,你也可能见过 更古老的游戏《太空侵略者》。 和《毁灭战士》一样,它的主题也是围绕在太空中对抗侵略者。

我的朋友兼同事,尤金, 编写了一个IntelliJ IDEA插件, 让你直接在编辑器里玩这个游戏——这是在等待 索引完成时消磨时间的好方法。

IntelliJ IDEA 编辑器中的太空侵略者 IntelliJ IDEA 编辑器中的太空侵略者

这个游戏没有上帝模式,但如果决心足够大, 我们自己能加上去吗?让我们重拾使用调试器破解程序的经典传统来寻找答案吧!

Info icon

请负责任! 我在摆弄他的程序之前得到了尤金的同意。 如果你在使用调试器处理非自己的代码,请确保这是道德的。 否则,请不要这样做。

准备工具

为一次元体验做好准备——我们将使用 IntelliJ IDEA 自带的调试器来调试它。

这里有一个我们需要解决的小问题: 为了调试 IntelliJ IDEA,我们需要暂停它。 这将导致 IDE 无响应。 因此,我们需要一个额外的 IDE 实例, 它将保持功能完整,并作为我们的调试工具。

为了管理多个 IDE 实例,我将使用 JetBrains Toolbox。 这是一个管理已安装的 JetBrains IDE 的实用程序。 通过它,你可以安装多个版本或创建带有不同 VM 选项集运行它们的快捷方式。

让我们安装两个 IntelliJ IDEA 实例:

JetBrains Toolbox 显示了多个 JetBrains IDE,其中包括两个名为 Space Invaders 和 Debug 的 IntelliJ IDEA 实例 JetBrains Toolbox 显示了多个 JetBrains IDE,其中包括两个名为 Space Invaders 和 Debug 的 IntelliJ IDEA 实例

如果你对两个实例使用相同的 IDE 版本,请确保在 Tool actions | Settings | Configuration 中指定不同的 系统、配置和日志目录。在此页面上,你也可以为 IDE 实例分配名称以方便区分。

为了能够调试 ‘Space Invaders’ 实例,点击它旁边的 More,然后进入 Settings | Edit JVM options。在打开的文件中,粘贴以下行:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
包含将传递给 IDE 实例的 VM 选项的文件 包含将传递给 IDE 实例的 VM 选项的文件

这将使目标 JVM 带着调试代理运行,并监听端口 5005 上的传入调试器连接。

运行游戏

运行 Space Invaders 实例, 安装游戏, 并通过运行 Space Invaders 动作来启动它。要找到该动作,连按两次 Shift 键,然后开始输入 Space Invaders

通过双击 Shift 键打开的对话框来运行 Space Invaders 动作 通过双击 Shift 键打开的对话框来运行 Space Invaders 动作

让我们玩一会儿并观察我们想要修复的行为: 当敌人的导弹击中太空飞船时,屏幕左上角的生命条会下降。

连接并挂起

我们的调试之旅从打开“调试”IDE实例并设置一个新的Kotlin项目开始。 我们需要这个项目的主要原因是,没有项目就无法启动调试器。

此外,IntelliJ IDEA在新的项目模板中包含了Java/Kotlin标准库, 我们可能在后面会用到它。 我将在后面的章节解释标准库的使用。

创建项目后,转到主菜单并选择 运行 (Run) | 附加到进程 (Attach to Process)。 这将显示正在监听调试器附加请求的本地JVM列表。 从列表中选择另一个正在运行的IDE。

显示本地运行JVM列表的弹出窗口 显示本地运行JVM列表的弹出窗口

我们应该在控制台看到以下确认消息,表明调试器已成功连接到目标VM。

Connected to the target VM, address: 'localhost:5005', transport: 'socket'

我们即将进入有趣的部分:如何挂起应用程序?

通常,人们会在应用程序代码中设置断点,但在这个情况下,我们缺少 IntelliJ IDEA和Space Invaders插件的源代码。 这不仅阻止了我们设置断点,也使我们理解程序如何运作变得更加复杂。 乍一看,似乎没有什么可以检查或逐步执行的。

幸运的是,IntelliJ IDEA有一个称为暂停程序 (Pause Program) 的功能。 它允许你在任意时间点暂停程序, 而无需指定相应的代码行。 你可以在调试器工具栏中或主菜单中找到它: 运行 (Run) | 调试操作 (Debugging Actions) | 暂停程序 (Pause Program)。

已暂停的Space Invaders实例的调试工具窗口 已暂停的Space Invaders实例的调试工具窗口

应用程序被挂起。这给了我们调试的起点。

Tip icon

暂停程序 (Pause Program) 是一个非常强大的技术,尤其在几种高级场景下特别有用。 了解更多,请查阅相关文章:

寻找相关对象

如果我们从编程的角度看待我们的目标,归根结底就是要防止宇宙飞船的生命值下降。让我们找到持有相应状态的对象。

由于我们对插件代码一无所知,可以直接使用IntelliJ IDEA调试器的内存 (Memory) 视图进行堆检查:

点击调试工具窗口右上角的布局设置时出现的菜单 点击调试工具窗口右上角的布局设置时出现的菜单

此功能提供了当前所有存活对象的信息。让我们输入invaders,看看是否能找到什么:

在内存视图的搜索栏中输入'invaders',显示属于'spaceinvaders'包的类的对象 在内存视图的搜索栏中输入'invaders',显示属于'spaceinvaders'包的类的对象

显然,插件类都位于com.github.nizienko.spaceinvaders包下。在这个包内,有一个GameState类,有几个活动实例。看起来这就是我们需要的。

双击GameState会展示这个类的所有实例:

弹出对话框显示GameState的活动实例 弹出对话框显示GameState的活动实例

原来它是一个枚举——这并不是我们直接想要的。继续搜索,我们发现了一个Game的单例。

展开节点后,我们可以检查实例的字段:

内存视图中展开的对象节点,显示对象的字段 内存视图中展开的对象节点,显示对象的字段

health属性在这里显得特别重要。在其字段中,我们可以找到_value。在我的情况下,它是100,这与我暂停游戏时生命条满格的情况相符。因此,这很可能是我们要关注的正确字段,其值似乎在0100之间变化。

让我们验证一下这个假设。右键点击_value,然后选择设置值 (Set Value)。选择一个与当前值不同的值,比如我选择了50

内存视图中针对'health'字段显示带有用户输入值50的文本字段 内存视图中针对'health'字段显示带有用户输入值50的文本字段

在此步骤,我们遇到了一个错误提示无法在暂停操作后对方法求值 (Cannot evaluate methods after Pause action):

错误信息显示'暂停后无法评估方法' 错误信息显示'暂停后无法评估方法'

这个问题出现是因为我们使用了暂停程序 (Pause Program) 而非断点,且此功能附带一些限制。但是,我们可以通过一个小技巧来解决这个问题。

我在之前的帖子之一中介绍了关于暂停程序 (Pause Program) 的这个技巧。如果你之前错过了,这里简述一下操作:一旦应用程序暂停,执行一个步进动作,如步入 (Step Into) 或步过 (Step Over)。这样做将启用设置值 (Set Value) 和对表达式求值 (Evaluate Expression) 等高级功能。

应用上述技巧后,我们应该能够为health设值了。尝试修改该值,然后恢复应用程序,查看生命条是否有变化。确实有变化!

这样一来,我们就找到了持有相关状态的对象。至少,我们现在可以手动时不时地补充生命值。虽然这还不是一个完全的胜利,但已经是向前迈出的重要一步。

标签和表达式

现在我们已经确定了要关注的对象,如果能给它打上标记就更方便了。 对于不熟悉调试标签的读者,被标记的对象看起来是这样的:

变量标签展示了一个User对象数组,其中一个被标记上了调试标签,内容为User_Charlie 变量标签展示了一个User对象数组,其中一个被标记上了调试标签,内容为User_Charlie

标签在很多方面都非常有用。 就本文的上下文而言,标记相关对象可以确保我们直接在诸如对表达式求值 (Evaluate Expression) 等功能中使用它, 而不依赖于当前的执行上下文。

遗憾的是,我们不能直接标记_value,但我们可以标记包含它的对象。 为此,右键点击health,选择标记对象 (Mark Object),然后给它命名。

选择对象标签对话框提示用户输入对象的名称 选择对象标签对话框提示用户输入对象的名称

我们现在可以在其他地方测试这个标签的用法。 打开评估表达式对话框, 并输入health_object_DebugLabel作为表达式。 如您所见,通过求值 (Evaluate) 对话框可以从程序的任何位置访问该对象:

评估对话框中输入了调试标签作为表达式 评估对话框中输入了调试标签作为表达式

那么,能否通过求值 (Evaluate) 改变宇宙飞船的生命值呢? health_object_DebugLabel._value = 100 并不起作用。

同时,_value似乎是一个Kotlin属性的后端字段。 如果这是真的,Kotlin应该会生成一个相应的getter:

health_object_DebugLabel.getValue()

求值 (Evaluate) 对话框认为这不是有效的代码,但我们不妨试一试:

在评估对话框中通过调试标签引用属性 在评估对话框中通过调试标签引用属性

该表达式返回了当前宇宙飞船的生命值,所以这种方法可行! 相应地,setter也应该有效:

health_object_DebugLabel.setValue(100)

评估setter之后,让我们继续运行应用程序并验证更改是否生效。 确实,生命条满了!

挂钩表达式

达到我们目标的最后一步是自动化状态修改过程, 以便“生命值补充”在后台默默地完成, 让我们能够不间断地享受游戏体验。

这可以通过使用 非挂起断点 来实现。这类断点常用于日志记录;然而,日志记录表达式 不必是纯函数。因此,我们可以在日志记录表达式中引入期望的副作用。 不过,尚不确定应在哪里设置这个断点, 因为我们没有应用程序的源代码。

记得我提到过,我们可能会用到 Java标准库的源码吗?这里就是这个想法的应用场景。 IntelliJ IDEA及其插件都是用Java/Kotlin编写的,并且它们使用Swing作为UI框架。 因此,《太空侵略者》肯定会调用这些依赖项中的代码。 这意味着我们可以利用它们的源码来设置断点。

Info icon

为了保持简单,我们没有指定特定的JDK版本, 并且使用了IntelliJ IDEA推荐的版本初始化项目。然而,为了获得最佳效果, 建议使用与运行时版本紧密匹配的源码。

有很多适合设置断点的位置。 我决定在java.awt.event.KeyListener::keyPressed方法上设置一个方法断点。 这将在每次按下按键时触发副作用:

断点对话框显示为java.awt.event.KeyListener::keyPressed设置的日志记录断点 断点对话框显示为java.awt.event.KeyListener::keyPressed设置的日志记录断点
Info icon

在热点代码中设置带有表达式的断点 可能会显著减慢 目标应用程序的速度。

让我们回到《太空侵略者》,看看我们自制的IDDQD是否有效。确实有效!

在IntelliJ IDEA中玩太空侵略者——每次飞船被击中时,其生命值条自动补满

结论

在这篇文章中,我们使用调试器了解了应用程序内部的工作原理。 弄清楚这一点后,我们能够在不访问应用程序源码的情况下, 在它的内存中导航并修改其功能! 我希望将调试器与IDDQD的比较没有显得太过大胆, 并且你学到了一些技巧,能在你的调试挑战中助你一臂之力。

我要特别感谢 Eugene Nizienko 制作了太空侵略者插件, 以及Egor Ushakov不断与我分享 他在调试和编程方面的丰富创意。 有了这样的人,计算机的乐趣翻倍。

如果你有想让我在接下来的文章中解决的调试挑战,请告诉我!

愉快地探索吧!

all posts ->