Debugger.godMode() – 用调试器破解JVM应用程序
阅读其他语言: English Español Português
在过去,电脑游戏是另一番景象。 不仅图像和机制随着时间演变, 还有一个在当今游戏中似乎不太常见的特点: 几乎所有的游戏都有作弊码。
作弊码是一系列按键,能赋予你非凡的能力, 比如无限弹药或穿墙术。 其中最常见且强大的就是“上帝模式” ——它让你变得无敌。
![当输入 IDDQD 后,《毁灭战士》中陆战队员的截图](/img/debugger-god-mode/iddqd.png)
当你输入 IDDQD
——这是在《毁灭战士》中的“上帝模式”按键组合时,
你的角色就会变成这样。实际上,这个特定的按键序列非常流行,以至于它
成为了一个meme,并超越了游戏本身获得知名度。
尽管“上帝模式”不像过去那样在游戏中普遍,IDDQD 热潮似乎也在消退,但人们或许会好奇 是否有一个现代的等价物存在。就我个人而言,我有自己对 IDDQD 的现代诠释。 虽然它不一定与游戏相关,但它确实唤起了拥有超能力的相似感受。
太空侵略者
为了阐述我的观点,我想引入一个有趣的场景。即使你不熟悉《毁灭战士》,你也可能见过 更古老的游戏《太空侵略者》。 和《毁灭战士》一样,它的主题也是围绕在太空中对抗侵略者。
我的朋友兼同事,尤金, 编写了一个IntelliJ IDEA插件, 让你直接在编辑器里玩这个游戏——这是在等待 索引完成时消磨时间的好方法。
![IntelliJ IDEA 编辑器中的太空侵略者](/img/debugger-god-mode/space-invaders.png)
![IntelliJ IDEA 编辑器中的太空侵略者](/img/debugger-god-mode/space-invaders-dark.png)
这个游戏没有上帝模式,但如果决心足够大, 我们自己能加上去吗?让我们重拾使用调试器破解程序的经典传统来寻找答案吧!
请负责任! 我在摆弄他的程序之前得到了尤金的同意。 如果你在使用调试器处理非自己的代码,请确保这是道德的。 否则,请不要这样做。
准备工具
为一次元体验做好准备——我们将使用 IntelliJ IDEA 自带的调试器来调试它。
这里有一个我们需要解决的小问题: 为了调试 IntelliJ IDEA,我们需要暂停它。 这将导致 IDE 无响应。 因此,我们需要一个额外的 IDE 实例, 它将保持功能完整,并作为我们的调试工具。
为了管理多个 IDE 实例,我将使用 JetBrains Toolbox。 这是一个管理已安装的 JetBrains IDE 的实用程序。 通过它,你可以安装多个版本或创建带有不同 VM 选项集运行它们的快捷方式。
让我们安装两个 IntelliJ IDEA 实例:
![JetBrains Toolbox 显示了多个 JetBrains IDE,其中包括两个名为 Space Invaders 和 Debug 的 IntelliJ IDEA 实例](/img/debugger-god-mode/jb-toolbox.png)
![JetBrains Toolbox 显示了多个 JetBrains IDE,其中包括两个名为 Space Invaders 和 Debug 的 IntelliJ IDEA 实例](/img/debugger-god-mode/jb-toolbox-dark.png)
如果你对两个实例使用相同的 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 选项的文件](/img/debugger-god-mode/toolbox-add-vm-options.png)
![包含将传递给 IDE 实例的 VM 选项的文件](/img/debugger-god-mode/toolbox-add-vm-options-dark.png)
这将使目标 JVM 带着调试代理运行,并监听端口 5005
上的传入调试器连接。
运行游戏
运行 Space Invaders
实例,
安装游戏,
并通过运行 Space Invaders 动作来启动它。要找到该动作,连按两次 Shift 键,然后开始输入 Space Invaders
:
![通过双击 Shift 键打开的对话框来运行 Space Invaders 动作](/img/debugger-god-mode/space-invaders-action.png)
![通过双击 Shift 键打开的对话框来运行 Space Invaders 动作](/img/debugger-god-mode/space-invaders-action-dark.png)
让我们玩一会儿并观察我们想要修复的行为: 当敌人的导弹击中太空飞船时,屏幕左上角的生命条会下降。
连接并挂起
我们的调试之旅从打开“调试”IDE实例并设置一个新的Kotlin项目开始。 我们需要这个项目的主要原因是,没有项目就无法启动调试器。
此外,IntelliJ IDEA在新的项目模板中包含了Java/Kotlin标准库, 我们可能在后面会用到它。 我将在后面的章节解释标准库的使用。
创建项目后,转到主菜单并选择 运行 (Run) | 附加到进程 (Attach to Process)。 这将显示正在监听调试器附加请求的本地JVM列表。 从列表中选择另一个正在运行的IDE。
![显示本地运行JVM列表的弹出窗口](/img/debugger-god-mode/attach.png)
![显示本地运行JVM列表的弹出窗口](/img/debugger-god-mode/attach-dark.png)
我们应该在控制台看到以下确认消息,表明调试器已成功连接到目标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实例的调试工具窗口](/img/debugger-god-mode/invaders-paused.png)
![已暂停的Space Invaders实例的调试工具窗口](/img/debugger-god-mode/invaders-paused-dark.png)
应用程序被挂起。这给了我们调试的起点。
暂停程序 (Pause Program) 是一个非常强大的技术,尤其在几种高级场景下特别有用。 了解更多,请查阅相关文章:
寻找相关对象
如果我们从编程的角度看待我们的目标,归根结底就是要防止宇宙飞船的生命值下降。让我们找到持有相应状态的对象。
由于我们对插件代码一无所知,可以直接使用IntelliJ IDEA调试器的内存 (Memory) 视图进行堆检查:
![点击调试工具窗口右上角的布局设置时出现的菜单](/img/debugger-god-mode/open-memory-view.png)
![点击调试工具窗口右上角的布局设置时出现的菜单](/img/debugger-god-mode/open-memory-view-dark.png)
此功能提供了当前所有存活对象的信息。让我们输入invaders
,看看是否能找到什么:
![在内存视图的搜索栏中输入'invaders',显示属于'spaceinvaders'包的类的对象](/img/debugger-god-mode/search-in-heap.png)
![在内存视图的搜索栏中输入'invaders',显示属于'spaceinvaders'包的类的对象](/img/debugger-god-mode/search-in-heap-dark.png)
显然,插件类都位于com.github.nizienko.spaceinvaders
包下。在这个包内,有一个GameState
类,有几个活动实例。看起来这就是我们需要的。
双击GameState
会展示这个类的所有实例:
![弹出对话框显示GameState的活动实例](/img/debugger-god-mode/instances-of-enum.png)
![弹出对话框显示GameState的活动实例](/img/debugger-god-mode/instances-of-enum-dark.png)
原来它是一个枚举——这并不是我们直接想要的。继续搜索,我们发现了一个Game
的单例。
展开节点后,我们可以检查实例的字段:
![内存视图中展开的对象节点,显示对象的字段](/img/debugger-god-mode/fields-in-memory-view.png)
![内存视图中展开的对象节点,显示对象的字段](/img/debugger-god-mode/fields-in-memory-view-dark.png)
health
属性在这里显得特别重要。在其字段中,我们可以找到_value
。在我的情况下,它是100
,这与我暂停游戏时生命条满格的情况相符。因此,这很可能是我们要关注的正确字段,其值似乎在0
到100
之间变化。
让我们验证一下这个假设。右键点击_value
,然后选择设置值 (Set Value)。选择一个与当前值不同的值,比如我选择了50
。
![内存视图中针对'health'字段显示带有用户输入值50的文本字段](/img/debugger-god-mode/memory-view-set-value.png)
![内存视图中针对'health'字段显示带有用户输入值50的文本字段](/img/debugger-god-mode/memory-view-set-value-dark.png)
在此步骤,我们遇到了一个错误提示无法在暂停操作后对方法求值 (Cannot evaluate methods after Pause action):
![错误信息显示'暂停后无法评估方法'](/img/debugger-god-mode/cannot-evaluate-after-pause.png)
![错误信息显示'暂停后无法评估方法'](/img/debugger-god-mode/cannot-evaluate-after-pause-dark.png)
这个问题出现是因为我们使用了暂停程序 (Pause Program) 而非断点,且此功能附带一些限制。但是,我们可以通过一个小技巧来解决这个问题。
我在之前的帖子之一中介绍了关于暂停程序 (Pause Program) 的这个技巧。如果你之前错过了,这里简述一下操作:一旦应用程序暂停,执行一个步进动作,如步入 (Step Into) 或步过 (Step Over)。这样做将启用设置值 (Set Value) 和对表达式求值 (Evaluate Expression) 等高级功能。
应用上述技巧后,我们应该能够为health
设值了。尝试修改该值,然后恢复应用程序,查看生命条是否有变化。确实有变化!
这样一来,我们就找到了持有相关状态的对象。至少,我们现在可以手动时不时地补充生命值。虽然这还不是一个完全的胜利,但已经是向前迈出的重要一步。
标签和表达式
现在我们已经确定了要关注的对象,如果能给它打上标记就更方便了。 对于不熟悉调试标签的读者,被标记的对象看起来是这样的:
![变量标签展示了一个User对象数组,其中一个被标记上了调试标签,内容为User_Charlie](/img/debugger-god-mode/labeled-object.png)
![变量标签展示了一个User对象数组,其中一个被标记上了调试标签,内容为User_Charlie](/img/debugger-god-mode/labeled-object-dark.png)
标签在很多方面都非常有用。 就本文的上下文而言,标记相关对象可以确保我们直接在诸如对表达式求值 (Evaluate Expression) 等功能中使用它, 而不依赖于当前的执行上下文。
遗憾的是,我们不能直接标记_value
,但我们可以标记包含它的对象。
为此,右键点击health
,选择标记对象 (Mark Object),然后给它命名。
![选择对象标签对话框提示用户输入对象的名称](/img/debugger-god-mode/mark-health-object.png)
![选择对象标签对话框提示用户输入对象的名称](/img/debugger-god-mode/mark-health-object-dark.png)
我们现在可以在其他地方测试这个标签的用法。
打开评估表达式对话框,
并输入health_object_DebugLabel
作为表达式。
如您所见,通过求值 (Evaluate) 对话框可以从程序的任何位置访问该对象:
![评估对话框中输入了调试标签作为表达式](/img/debugger-god-mode/evaluate-label.png)
![评估对话框中输入了调试标签作为表达式](/img/debugger-god-mode/evaluate-label-dark.png)
那么,能否通过求值 (Evaluate) 改变宇宙飞船的生命值呢?
health_object_DebugLabel._value = 100
并不起作用。
同时,_value
似乎是一个Kotlin属性的后端字段。
如果这是真的,Kotlin应该会生成一个相应的getter:
health_object_DebugLabel.getValue()
求值 (Evaluate) 对话框认为这不是有效的代码,但我们不妨试一试:
![在评估对话框中通过调试标签引用属性](/img/debugger-god-mode/evaluate-property.png)
![在评估对话框中通过调试标签引用属性](/img/debugger-god-mode/evaluate-property-dark.png)
该表达式返回了当前宇宙飞船的生命值,所以这种方法可行! 相应地,setter也应该有效:
health_object_DebugLabel.setValue(100)
评估setter之后,让我们继续运行应用程序并验证更改是否生效。 确实,生命条满了!
挂钩表达式
达到我们目标的最后一步是自动化状态修改过程, 以便“生命值补充”在后台默默地完成, 让我们能够不间断地享受游戏体验。
这可以通过使用 非挂起断点 来实现。这类断点常用于日志记录;然而,日志记录表达式 不必是纯函数。因此,我们可以在日志记录表达式中引入期望的副作用。 不过,尚不确定应在哪里设置这个断点, 因为我们没有应用程序的源代码。
记得我提到过,我们可能会用到 Java标准库的源码吗?这里就是这个想法的应用场景。 IntelliJ IDEA及其插件都是用Java/Kotlin编写的,并且它们使用Swing作为UI框架。 因此,《太空侵略者》肯定会调用这些依赖项中的代码。 这意味着我们可以利用它们的源码来设置断点。
为了保持简单,我们没有指定特定的JDK版本, 并且使用了IntelliJ IDEA推荐的版本初始化项目。然而,为了获得最佳效果, 建议使用与运行时版本紧密匹配的源码。
有很多适合设置断点的位置。
我决定在java.awt.event.KeyListener::keyPressed
方法上设置一个方法断点。
这将在每次按下按键时触发副作用:
![断点对话框显示为java.awt.event.KeyListener::keyPressed设置的日志记录断点](/img/debugger-god-mode/key-pressed-breakpoint.png)
![断点对话框显示为java.awt.event.KeyListener::keyPressed设置的日志记录断点](/img/debugger-god-mode/key-pressed-breakpoint-dark.png)
在热点代码中设置带有表达式的断点 可能会显著减慢 目标应用程序的速度。
让我们回到《太空侵略者》,看看我们自制的IDDQD是否有效。确实有效!
![在IntelliJ IDEA中玩太空侵略者——每次飞船被击中时,其生命值条自动补满](/img/debugger-god-mode/success.gif)
结论
在这篇文章中,我们使用调试器了解了应用程序内部的工作原理。 弄清楚这一点后,我们能够在不访问应用程序源码的情况下, 在它的内存中导航并修改其功能! 我希望将调试器与IDDQD的比较没有显得太过大胆, 并且你学到了一些技巧,能在你的调试挑战中助你一臂之力。
我要特别感谢 Eugene Nizienko 制作了太空侵略者插件, 以及Egor Ushakov不断与我分享 他在调试和编程方面的丰富创意。 有了这样的人,计算机的乐趣翻倍。
如果你有想让我在接下来的文章中解决的调试挑战,请告诉我!
愉快地探索吧!