游戏黑客指南-全-

游戏黑客指南(全)

原文:zh.annas-archive.org/md5/36a4b6462fb9f483df652a561782ba20

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

在网络游戏的世界中,一个常见的误解是,认为你只能玩标题中提到的游戏。实际上,游戏黑客们更喜欢玩隐藏在幕后、他们与游戏开发者之间的智力博弈:这是一场猫鼠游戏。游戏黑客们通过逆向工程游戏二进制文件、自动化游戏玩法的某些部分以及修改游戏环境来进行工作,而游戏开发者则通过反逆向技术、机器人检测算法和启发式数据挖掘来与这些黑客设计的工具(通常被称为机器人)作斗争。

随着游戏黑客和开发者之间的斗争不断发展,双方实施的技术方法——许多方法与恶意软件开发者和杀毒软件供应商使用的技术相似——也不断演化,变得更加复杂。本书重点介绍了游戏黑客们如何与游戏开发者展开斗争,以及他们设计的高级方法,如何在操控游戏的同时巧妙地躲避游戏开发者在自己软件中的防范。

尽管本书的重点是教你开发那些游戏公司可能认为是麻烦甚至恶意的工具,但你会发现,许多技术对于开发完全无害且中立的工具也非常有用。此外,了解这些技术如何实现对于游戏开发者来说至关重要,因为他们需要防止这些技术的使用。

读者先决条件

本书并不旨在教授软件开发,因此假定你至少具备扎实的软件开发背景。这个背景应包括对基于 Windows 的本地开发的了解,以及一定的游戏开发和内存管理经验。虽然这些技能足以让你跟上本书的内容,但如果你有 x86 汇编和 Windows 内部机制的经验,将确保你不会错过更高级实现的细节。

此外,由于本书中讨论的所有高级技巧都依赖于代码注入,因此掌握像 C 或 C++这样的原生语言编程能力是必不可少的。本书中的所有示例代码均使用 C++编写,并可以通过 Microsoft Visual C++ Express Edition 进行编译。(你可以从www.visualstudio.com/en-US/products/visual-studio-express-vs下载 MSVC++ Express Edition。)

注意

其他能够编译为原生代码的语言,如 Delphi,也可以进行注入,但我在本书中不会讨论这些语言。

简短的游戏黑客历史

自 1980 年代初期在线 PC 游戏的诞生以来,游戏黑客与游戏开发者之间的智力较量便一直在进行。这场看似没有尽头的斗争促使游戏开发者投入了无数的时间和精力,致力于防止黑客拆解他们的游戏并在其中寻求漏洞。这些反击的黑客,通过复杂的隐身技术,动机多种多样:定制的图形、更好的性能、易于使用、自动化游戏、游戏内资产的获取,当然还有现实中的利润。

1990 年代末和 2000 年代初是游戏破解的黄金时代,那时在线 PC 游戏足够先进,可以吸引大量玩家,但又足够简单,容易被逆向工程和操控。在这一时期推出的在线游戏,如提比亚(1997 年 1 月)、鲁尼斯凯普(2001 年 1 月)和奥特玛在线(1997 年 9 月),都成为了机器人开发者的主要目标。这些游戏及类似游戏的开发者至今仍在为控制庞大的机器人开发者和用户社区而苦苦挣扎。游戏开发者缺乏有效的应对措施,而黑客的顽强斗志,不仅彻底破坏了游戏内的经济体系,还催生了一个围绕机器人开发和机器人防御的盈利性行业。

自黄金时代以来,越来越成熟的游戏公司开始非常认真地对待机器人防御。这些公司现在有专门的团队致力于开发反机器人系统,许多公司还将机器人视为法律问题,毫不犹豫地禁止使用机器人玩家,并起诉提供机器人程序的开发者。因此,许多游戏黑客被迫开发先进的隐身技术,以保障他们的用户安全。

这场战争依然在继续,随着在线游戏在未来几年变得更加普及,双方的战斗人数将持续增长。主要的游戏开发商正以无尽的决心追踪黑客,甚至用数百万美元的诉讼打击一些游戏黑客巨头。这意味着那些真正从事游戏破解的黑客,必须要么瞄准规模较小的游戏公司,要么在幕后匿名推销他们的产品,以逃避起诉。在可预见的未来,游戏破解和机器人开发将继续发展,成为一个更大且更有利可图的行业,吸引那些敢于冒险的游戏黑客。

为什么要破解游戏?

除了显而易见的吸引力和挑战性,游戏黑客还具有一些实际和盈利的目的。每天,成千上万的初学者程序员通过小规模的游戏黑客实验,来自动化单调的任务或执行琐碎的动作。这些“脚本小子”会使用像 AutoIt 这样的自动化工具进行相对无害的小型黑客行为。另一方面,拥有强大工具包和多年编程经验的专业游戏黑客,会投入数百小时开发高级游戏黑客。这些类型的游戏黑客是本书的重点,通常是为了赚取大量的钱而创建的。

根据娱乐软件协会的数据,游戏产业是一个庞大的行业,2014 年创造了 224 亿美元的销售额。在每天玩游戏的数千万玩家中,20%的玩家玩的是大型多人在线角色扮演游戏(MMORPG)。这些 MMORPG 通常有成千上万的玩家,他们在繁荣的游戏内经济中进行虚拟物品交易。玩家通常需要游戏内的资产,并愿意用真实货币购买这些资产。因此,MMORPG 玩家最终会形成庞大的社区,提供金币换现金的服务。这些服务通常会涉及从游戏内金币到现实货币的汇率管理。

为了利用这一点,游戏黑客们会创建能够自动化金币农场和角色升级的机器人。然后,根据他们的目标,黑客们要么建立巨大的金币农场并出售游戏内的利润,要么完善并出售他们的程序软件给那些希望以最小干扰获得等级和金币的玩家。由于流行的 MMORPG 周围有庞大的社区,这些游戏黑客每年可以赚取六位数到七位数的收入。

虽然 MMORPG 提供了最大的攻击面给黑客,但总体上它们的观众群体相对较小。大约 38%的玩家偏爱即时战略(RTS)和大型在线战斗竞技场(MOBA)游戏,另外 6%的玩家主要玩第一人称射击(FPS)游戏。这些竞争性的玩家对玩家(PvP)游戏共同占据了游戏市场的 44%,并为决心坚定的游戏黑客提供了丰厚的回报。

PvP 游戏通常具有情节化的特点;每一场比赛都是一个独立的游戏,通常没有太多有利可图的挂机(AFK)进展。这意味着,与其运行金币农场或创建自动化的机器人来提升角色等级,黑客们更倾向于创建能够在战斗中协助玩家的反应型机器人。

这些高度竞争的游戏讲求技巧和战术,大多数玩家参与其中是为了证明自己的能力。因此,寻求用于 PvP 类型游戏的机器人数量比在需要大量刷怪的 MMORPG 世界中要少得多。然而,黑客们仍然可以通过出售 PvP 机器人赚取相当可观的收入,这些机器人通常比全面自主的机器人更容易开发。

本书的组织结构

本书分为四个部分,每个部分都聚焦于游戏黑客的不同核心方面。在第一部分:行业工具中,你将获得一整盒工具,帮助你破解游戏。

第一章:使用 Cheat Engine 扫描内存 将教你如何使用 Cheat Engine 扫描游戏内存中的重要值。

• 在第二章:使用 OllyDbg 调试游戏中,你将获得 OllyDbg 调试和逆向工程的速成课程。你在这里学到的技能,在你开始制作高级机器人和注入代码时将非常有用。

• 最后,第三章:使用进程监视器和进程资源管理器进行侦察 将教你如何使用两个侦察工具,检查游戏如何与文件、其他进程、网络和操作系统互动。

第一部分中的每一章节都有在线资源,包括我为你创建的自定义二进制文件,提供一个安全的地方来测试和磨练你新学到的技能。

一旦你熟悉了每个工具,第二部分:游戏剖析 将教你如何深入了解并弄清楚游戏的工作原理。

• 在第四章:从代码到内存:通用入门中,你将学习游戏的源代码和数据在编译成游戏二进制文件后的样子。

第五章:高级内存取证 基于你在第四章获得的知识进行扩展。你将学会如何扫描内存,并利用调试技术无缝定位棘手的内存值,剖析复杂的类和结构。

• 最终,第六章:从游戏内存中读取和写入数据 将展示如何读取和修改正在运行的游戏中的数据。

这些章节提供了大量的深入概念验证示例代码,你可以用它来验证你所阅读的内容。

第三部分:进程操控中,你将成为一名木偶师,学习如何将任何游戏变成一个提线木偶。

• 基于第一部分和第二部分的技能,第七章:代码注入 描述了如何将自己的代码注入并在游戏的地址空间中执行。

• 一旦你掌握了注入技巧,第八章:操控游戏中的控制流将教你如何使用注入拦截、修改或禁用游戏中的任何函数调用,并通过一些实用的真实世界示例来结束,涵盖常用库如 Adobe AIR 和 Direct 3D。

为了补充你的操控课程,这些章节配有成千上万行生产就绪的代码,你可以将其用作未来机器人项目的模板库。

第四部分:创建机器人中,你将看到如何将你的工具箱、解构能力、操控技巧和软件工程背景结合起来,创造强大的机器人。

第九章:利用超感官知觉驱散战争迷雾探索了如何让游戏显示默认未暴露的有用信息,比如隐藏敌人的位置和每小时获得的经验值。

第十章:响应式黑客展示了你可以用来检测游戏内事件(例如生命值减少)的代码模式,并制作出比人类玩家更快速反应的机器人。

第十一章:整合一切:编写自主机器人揭示了没有人工干预的游戏机器人是如何工作的。自动化机器人结合了控制理论、状态机、搜索算法和数学模型,本章是这些主题的速成课程。

• 在第十二章:保持隐蔽中,你将学习一些高级技巧,用来逃避和躲避任何可能干扰你机器人的系统。

正如你可能已经预料到的,这些章节包含了大量示例代码。本部分展示的一些黑客技巧建立在前面章节中的示例代码基础上,其他则探索了简洁直接的设计模式,你可以用来创建自己的机器人。一旦你完成了本书的四个部分,你将带着你的新超能力进入虚拟世界。

关于在线资源

你可以在www.nostarch.com/gamehacking/找到许多本书的附加资源。这些资源包括编译好的二进制文件以测试你的技能、大量的示例代码,以及一些生产就绪的游戏黑客代码片段。这些资源与本书相辅相成,没有它们本书就不算完整,所以在继续之前务必下载它们。

如何使用本书

本书应首先作为游戏黑客入门的指南来使用。内容的推进方式是,每一章都介绍新的技能和能力,这些内容会在前面所有章节的基础上进行扩展。在完成各章节后,我鼓励你通过实践示例代码,并在继续阅读之前在真实游戏中测试你的技能。这一点非常重要,因为某些覆盖的主题,其实际应用场景可能在你深入研究时才会显现出来。

完成本书后,我希望它仍然能作为一本实用的现场手册。如果你遇到某个不确定的数据结构,也许第五章中的内容能为你提供帮助。如果你逆向工程一个游戏的地图格式,并准备创建路径查找器,你可以随时翻到第十一章,研究相关内容,并将一些示例代码作为起点。虽然无法预见你在黑客工作中可能遇到的所有问题,但我已尽力确保你能在这些页面中找到一些答案。

出版商的提醒

本书不支持盗版、不违反《数字千年版权法案》(DMCA)、不侵犯版权,也不违反游戏的服务条款。游戏黑客因其工作曾被终身禁止进入游戏,起诉赔偿数百万美元,甚至因此入狱。

第一部分

行业工具

第一章:1

使用作弊引擎扫描内存

image

世界上最顶尖的游戏黑客花费数年时间,个性化定制庞大的工具箱。如此强大的工具包使这些黑客能够无缝地分析游戏,轻松地原型化黑客攻击,并有效地开发机器人。然而,在核心上,每个独特的工具包都由同样的四个核心组成:一个内存扫描器、一个汇编级调试器、一个进程监控器和一个十六进制编辑器。

内存扫描是游戏黑客的入口,本章将教你如何使用作弊引擎——一个强大的内存扫描器,它搜索游戏的运行内存(也就是 RAM)中的值,比如玩家的等级、血量或游戏中的货币。首先,我会介绍基础的内存扫描、内存修改和指针扫描。接着,我们将深入了解作弊引擎强大的嵌入式 Lua 脚本引擎。

注意

你可以从 www.cheatengine.org/ 下载作弊引擎。安装时要注意,因为它会尝试安装一些工具栏和其他垃圾软件。如果你愿意,可以禁用这些选项。

为什么内存扫描器很重要

了解游戏的状态对于智能地与游戏互动至关重要,但与人类不同,软件不能仅仅通过看屏幕上的内容来确定游戏的状态。幸运的是,在游戏产生的所有刺激背后,计算机的内存包含了该游戏状态的纯数字表示——而程序能够轻松理解数字。黑客使用内存扫描器来找到这些内存中的值,然后在他们的程序中读取这些位置的内存,以了解游戏的状态。

例如,一个当玩家血量低于 500 时自动治疗玩家的程序需要做两件事:追踪玩家当前的血量和施放治疗法术。前者需要访问游戏的状态,而后者可能只需要按下一个按钮。给定玩家血量存储的位置和读取游戏内存的方式,程序大致的伪代码可能如下所示:

// do this in some loop
health = readMemory(game, HEALTH_LOCATION)
if (health < 500)
    pressButton(HEAL_BUTTON)

内存扫描器让你可以找到HEALTH_LOCATION,以便你的软件稍后可以查询它。

基础内存扫描

内存扫描器是对有抱负的游戏黑客来说最基础也是最重要的工具。和任何程序一样,游戏中的所有数据都存在一个叫做内存地址的绝对位置。如果你把内存看作一个非常大的字节数组,那么内存地址就是指向该数组中某个值的索引。当内存扫描器被告知要在游戏内存中找到某个值x(称为扫描值,因为它是你正在扫描的值)时,扫描器会循环遍历字节数组,寻找任何等于x的值。每次找到匹配的值时,它会将该匹配项的索引添加到结果列表中。

然而,由于游戏内存的庞大,x 的值可能出现在数百个位置。假设 x 是玩家的生命值,目前是 500。我们的 x 唯一持有 500,但 500 并非仅由 x 独占,因此扫描 x 会返回所有值为 500 的变量。任何与 x 无关的地址最终都是杂乱无章的;它们仅仅是偶然与 x 一起共享 500 的值。为了过滤掉这些不需要的值,内存扫描器允许你重新扫描结果列表,移除那些不再持有与 x 相同值的地址,无论 x 是否仍为 500 或已发生变化。

为了使这些重新扫描有效,游戏的整体状态必须具有显著的 ——一种衡量混乱的指标。通过改变游戏内的环境,通常是通过四处走动、击杀怪物或更换角色,可以增加熵值。随着熵值的增加,无关的地址不太可能继续随意持有相同的值,且在足够的熵值下,几次重新扫描应该可以过滤掉所有假阳性,最终只留下 x 的真实地址。

Cheat Engine 的内存扫描器

本节将带你了解 Cheat Engine 的内存扫描选项,帮助你追踪游戏状态值在内存中的地址。我将在《基础内存编辑》的第 11 页让你有机会亲自尝试扫描器;现在,打开 Cheat Engine 并四处看看。内存扫描器紧密封装在其主窗口中,如图 1-1 所示。

image

图 1-1:Cheat Engine 主界面

要开始扫描游戏的内存,点击附加图标 ➊ 以附加到一个进程,然后输入你想要查找的扫描值(在我们概念化的扫描器中称为 x) ➌。通过附加到一个进程,我们告诉 Cheat Engine 准备对其进行操作;在这种情况下,这个操作是一个扫描。接下来,我将讨论告诉 Cheat Engine 执行哪种类型的扫描也会有所帮助。

扫描类型

Cheat Engine 允许你选择两种不同的扫描指令,分别是扫描类型和数值类型 ➍。扫描类型告诉扫描器如何使用以下扫描类型之一来比较你的扫描值与正在扫描的内存:

精确值 返回指向等于扫描值的地址。如果你正在寻找的值在扫描过程中不会改变,请选择此选项;例如,生命值、法力值和等级通常属于此类别。

大于 返回指向比扫描值更大的值的地址。当你搜索的值不断增加时,这个选项非常有用,这通常发生在计时器中。

小于 返回指向比扫描值更小的值的地址。与“大于”类似,这个选项在寻找计时器时非常有用(在这种情况下,是倒计时而不是正计时)。

介于某两个值之间 返回指向位于扫描值范围内的值的地址。此选项结合了“大于”和“小于”,显示一个次级扫描值框,允许你输入一个更小范围的值。

未知初始值 返回程序内存中所有地址,允许重新扫描检查相对于它们初始值的整个地址范围。此选项对于寻找物品或生物类型非常有用,因为你并不总是知道游戏开发者用来表示这些对象的内部值。

值类型指令告诉作弊引擎扫描器正在寻找哪种类型的变量。

运行第一次扫描

一旦设置了两个扫描指令,点击第一次扫描 ➋开始初始值扫描,扫描仪将填充结果列表➎。列表中任何绿色的地址都是静态的,意味着它们应该在程序重启后保持不变。以黑色列出的地址位于动态分配内存中,即在运行时分配的内存。

当结果列表首次填充时,它会显示每个结果的地址和实时值。每次重新扫描时,还会显示上次扫描期间每个结果的值。(任何显示的实时值会以你在 编辑 ▸ 设置 ▸ 常规设置 ▸ 更新间隔 中设置的时间间隔进行更新。)

下一次扫描

一旦结果列表被填充,扫描仪会启用“下一次扫描”➋按钮,该按钮提供六种新的扫描类型。这些附加扫描类型允许你将结果列表中的地址与上次扫描中的值进行比较,从而帮助你缩小哪个地址包含你正在扫描的游戏状态值。它们如下:

增加的值 返回指向已增加的值的地址。此选项与“大于”扫描类型互补,保持相同的最小值,并移除任何值已减少的地址。

增加的值 返回指向值增加了指定数量的地址。此扫描类型通常会返回更少的误报,但仅当你准确知道一个值增加了多少时才可以使用它。

减少的值 这个选项是“增加的值”的相反操作。

减少的值 这个选项是“增加的值”的相反操作。

已更改的值 返回指向已更改的值的地址。当你知道一个值会发生变化,但不确定变化方式时,此类型非常有用。

未更改的值 返回指向未更改的值的地址。这有助于你排除误报,因为你可以轻松产生大量熵,同时确保所需的值保持不变。

您通常需要使用多种扫描类型,以便缩小大量结果列表并找到正确的地址。消除误报通常是通过正确创建熵(如在“基本内存扫描”第 4 页中所述)、战术性地改变扫描指令、勇敢地按下“下一次扫描”,然后重复这一过程,直到只剩下一个地址。

当你无法获得单一结果时

有时在作弊引擎中无法确定一个单一的结果,在这种情况下,您必须通过实验来确定正确的地址。例如,如果您正在寻找角色的生命值并且无法将地址缩小到少于五个地址,您可以尝试修改每个地址的值(如在“使用作弊引擎手动修改”第 8 页中所述),直到看到生命值显示变化,或者其他值自动变化为您设置的值。

作弊表

一旦您找到正确的地址,可以双击它将其添加到作弊表面板 ➏;在作弊表面板中的地址可以被修改、监视,并保存到作弊表文件中以供将来使用。

在作弊表面板的每个地址上,您可以通过双击描述栏来添加描述,也可以通过右键单击并选择“更改颜色”来添加颜色。您还可以通过右键单击并分别选择“以十六进制显示”或“以十进制显示”来显示每个地址的值,最后,您可以通过双击类型栏来更改每个值的数据类型,或者通过双击值栏来更改值本身。

由于作弊表面板的主要目的是允许游戏黑客整洁地跟踪地址,因此它可以动态保存和加载。请前往文件保存文件另存为,将当前的作弊表面板保存为一个.ct文档文件,其中包含每个地址及其值类型、描述、显示颜色和显示格式。要加载已保存的.ct文档,请前往文件加载。(您可以在cheatengine.org/tables.php找到许多流行游戏的现成作弊表。)

现在我已经描述了如何扫描游戏状态值,接下来我将讨论在您知道它在内存中的位置时,如何更改该值。

游戏中的内存修改

机器人通过修改游戏状态中的内存值来作弊,以便给你大量的游戏内货币、修改你角色的健康、改变角色位置等。在大多数在线游戏中,角色的生命值(如健康、魔法、技能和位置)保存在内存中,但由游戏服务器控制并通过互联网传递到你的本地游戏客户端,因此在在线游戏中修改这些值仅仅是表面上的,不会影响实际值。(对在线游戏的任何有效内存修改都需要一种比作弊引擎更高级的黑客技术。)然而,在没有远程服务器的本地游戏中,你可以随意操控这些值。

使用作弊引擎手动修改

我们将使用作弊引擎来了解内存修改的原理。

要手动修改内存,请执行以下操作:

  1. 将作弊引擎附加到一个游戏。

  2. 你可以扫描你想要修改的地址,或者加载一个包含该地址的作弊表。

  3. 双击地址栏的值列,打开输入提示框,你可以在其中输入新值。

  4. 如果你想确保新值不会被覆盖,请选择“活动”列下的框,冻结该地址,这将使作弊引擎每次该地址值发生变化时,都将相同的值写回。

这种方法对快速粗糙的黑客攻击非常有效,但手动不断更改值是繁琐的;一个自动化的解决方案将更加吸引人。

训练器生成器

作弊引擎的训练器生成器允许你在不编写任何代码的情况下自动化整个内存修改过程。

要创建一个训练器(一个简单的机器人,将内存修改操作绑定到键盘快捷键),请转到文件从表格创建通用训练器 Lua 脚本。这将打开一个训练器生成器对话框,类似于图 1-2 所示的对话框。

image

图 1-2:作弊引擎训练器生成器对话框

这里有一些可以修改的字段:

进程名称 训练器应该附加到的可执行文件名称。这是你在使用作弊引擎时在进程列表中看到的名称,通常会自动填充作弊引擎附加的进程名称。

按键时弹出训练器 可选择启用一个快捷键——你可以通过在复选框下方的框中输入一个键组合来设置——用来显示训练器的主窗口。

标题 训练器的名称,将显示在其界面上。这个字段是可选的。

关于文本 你训练器的描述,将显示在界面上;这是可选的。

冻结间隔(毫秒) 冻结操作覆盖值的时间间隔。通常,你应该将其保持为 250,因为较短的间隔会消耗资源,而较长的间隔可能太慢。

配置好这些值后,点击 添加快捷键 来设置一个键序列以激活你的训练器。系统会提示你从作弊表中选择一个值。输入一个值后,你将进入类似于 图 1-3 的设置/更改快捷键界面。

image

图 1-3:Cheat Engine 设置/更改快捷键界面

在此页面上,将光标放在标记为“输入要设置的快捷键”的框中 ➊,并输入所需的快捷键组合。接下来,从下拉菜单中选择所需的操作 ➋;选项应该按以下顺序出现:

切换冻结 切换地址的冻结状态。

切换冻结并允许增加 切换地址的冻结状态,但允许值增加。每当值减少时,训练器会用其先前的值覆盖它。增加的值将不会被覆盖。

切换冻结并允许减少 执行与“切换冻结并允许增加”相反的操作。

冻结 如果地址未被冻结,则将地址设置为冻结状态。

解冻 如果地址被冻结,则解冻该地址。

设置值为 将值设置为你在值框 ➌ 中指定的内容。

减少值 根据你在值框 ➌ 中指定的数值减少该值。

增加值 执行与“减少值”相反的操作。

最后,你可以为该操作设置一个描述 ➍。点击 应用,然后点击 确定,你的操作将出现在训练器生成器屏幕上的列表中。此时,Cheat Engine 会在后台运行训练器,你只需按下配置的快捷键即可执行内存操作。

要将训练器保存为可执行文件,请点击 生成训练器。在游戏启动后运行该可执行文件将会将训练器附加到游戏中,这样你就可以在不启动 Cheat Engine 的情况下使用它。

现在你已经熟悉了 Cheat Engine 的内存扫描器和训练器生成器,试着自己修改一些内存吧。

基础内存编辑

www.nostarch.com/gamehacking/ 下载本书的文件,并运行文件 BasicMemory.exe。接着,启动 Cheat Engine 并附加到二进制文件。然后,仅使用 Cheat Engine,找到灰色球的 x 和 y 坐标地址。(提示:使用 4 字节值类型。)

一旦找到这些值,修改它们,将球放置在黑色方块上方。当你成功时,游戏会通过显示文本“做得好!”来告知你。(提示:每次移动球时,它的位置—作为一个 4 字节整数—在该平面中变化 1。还要尝试只查找静态的 [绿色] 结果。)

指针扫描

如我所提到的,在线游戏通常将值存储在动态分配的内存中。虽然引用动态内存的地址本身对我们没有用,但某个静态地址总是会指向另一个地址,而该地址又会指向另一个,以此类推,直到链的末尾指向我们感兴趣的动态内存。Cheat Engine 可以使用一种叫做指针扫描的方法来定位这些链。

在本节中,我将向你介绍指针链,并描述如何在 Cheat Engine 中进行指针扫描。当你掌握了用户界面后,你可以在第 18 页的“指针扫描”中获得一些实践经验。

指针链

我刚才描述的这个偏移链被称为指针链,其结构如下:

list<int> chain = {start, offset1, offset2[, ...]}

这个指针链中的第一个值(start)称为内存指针。它是启动链的地址。其余的值(offset1offset2,依此类推)构成到达目标值的路径,称为指针路径

这个伪代码展示了如何读取一个指针链:

   int readPointerChain(chain) {
➊      ret = read(chain[0])
       for i = 1, chain.len - 1, 1 {
           offset = chain[i]
           ret = read(ret + offset)
       }
       return ret
   }

这段代码创建了一个名为readPointerPath()的函数,它以一个名为chain的指针链作为参数。readPointerPath()函数将chain中的指针路径视为从地址ret(最初设置为➊处的内存指针)开始的一系列内存偏移量。然后,它循环遍历这些偏移量,在每次迭代时通过read(ret + offset)更新ret的值,并在完成后返回ret。以下是当循环展开时,readPointerPath()的伪代码:

list<int> chain = {0xDEADBEEF, 0xAB, 0x10, 0xCC}
value = readPointerPath(chain)
// the function call unrolls to this
ret = read(0xDEADBEEF) //chain[0]
ret = read(ret + 0xAB)
ret = read(ret + 0x10)
ret = read(ret + 0xCC)
int value = ret

该函数最终调用read四次,针对chain中的每个地址调用一次。

注意

许多游戏黑客更喜欢将链读取直接编写在代码中,而不是像 readPointerPath() 那样将它们封装成函数。

指针扫描基础

指针链存在的原因是每一块动态分配的内存都必须有一个对应的静态地址,游戏代码可以用这个地址来引用它。游戏黑客可以通过定位引用这些内存块的指针链来访问它们。然而,由于指针链具有多层结构,它们无法通过内存扫描器使用的线性方式来定位,因此游戏黑客发明了新的方法来查找它们。

从逆向工程的角度来看,你可以定位并分析汇编代码,从而推断它使用的指针路径来访问该值,但这样做非常耗时且需要高级工具。指针扫描器通过暴力破解的方法递归遍历每一个可能的指针链,直到找到一个可以解析到目标内存地址的链。

清单 1-1 中的伪代码应该能让你大致了解指针扫描器是如何工作的。

   list<int> pointerScan(target, maxAdd, maxDepth) {
➊    for address = BASE, 0x7FFFFFF, 4 {
           ret = rScan(address, target, maxAdd, maxDepth, 1)
           if (ret.len > 0) {
               ret.pushFront(address)
               return ret
           }
      }
      return {}
   }
   list<int> rScan(address, target, maxAdd, maxDepth, curDepth) {
➋      for offset = 0, maxAdd, 4 {
            value = read(address + offset)
➌          if (value == target)
                return list<int>(offset)
        }
➍      if (curDepth < maxDepth) {
            curDepth++
➎          for offset = 0, maxAdd, 4 {
                ret = rScan(address + offset, target, maxAdd, maxDepth, curDepth) 
➏              if (ret.len > 0) {
                    ret.pushFront(offset)
➐                  return ret
                }
            }
        }
        return {}
   }

清单 1-1:指针扫描的伪代码

这段代码创建了pointerScan()rScan()这两个函数。

pointerScan()

pointerScan() 函数是扫描的入口点。它接受以下参数:target(要查找的动态内存地址),maxAdd(任何偏移量的最大值),以及 maxDepth(指针路径的最大长度)。然后,它会遍历游戏中的每个 4 字节对齐的地址 ➊,并使用参数 address(当前迭代中的地址)、targetmaxAddmaxDepthcurDepth(路径的深度,此时始终为 1)调用 rScan()

rScan()

rScan() 函数从 0maxAdd 之间的每个 4 字节对齐偏移量 ➋ 读取内存,并在结果等于 target ➌ 时返回。如果 rScan() 在第一次循环中没有返回,且递归深度不太深 ➍,它会递增 curDepth 并再次循环每个偏移量 ➎,对每次迭代进行自我调用。

如果自调用返回一个部分指针路径 ➏,rScan() 会将当前偏移量添加到路径前,并沿着递归链向上返回 ➐,直到它到达 pointerScan()。当从 pointerScan() 调用 rScan() 并返回一个指针路径时,pointerScan() 会将当前地址推送到路径的前面,并将其作为完整的链条返回。

使用 Cheat Engine 进行指针扫描

前面的示例展示了指针扫描的基本过程,但我展示的实现方式比较原始。除了执行速度极慢外,它还会生成无数的假阳性。Cheat Engine 的指针扫描器使用了许多高级插值方法来加速扫描并提高其准确性,在本节中,我将向你介绍可用的各种扫描选项。

要在 Cheat Engine 中启动指针扫描,右键点击你的作弊表中的动态内存地址,然后点击 Pointer scan for this address。当你启动指针扫描时,Cheat Engine 会询问你将扫描结果存储为 .ptr 文件的位置。一旦你输入了位置,类似 图 1-4 所示的指针扫描选项对话框将会出现。

image

图 1-4:Cheat Engine 指针扫描选项对话框

顶部的“查找地址”输入字段显示的是你的动态内存地址。现在,仔细从 Cheat Engine 的多种扫描选项中选择。

关键选项

Cheat Engine 的几个扫描选项通常保留默认值。以下是这些选项:

地址必须是 32 位对齐 告诉 Cheat Engine 只扫描 4 的倍数的地址,这大大提高了扫描速度。正如你在 第四章 中将学到的那样,编译器会对数据进行对齐,因此大多数地址默认情况下会是 4 的倍数。你很少需要禁用这个选项。

仅查找具有静态地址的路径 通过防止 Cheat Engine 查找具有动态起始指针的路径来加速扫描。这个选项应该 始终 启用,因为扫描从另一个动态地址开始的路径可能会适得其反。

不包含只读节点的指针 也应该始终启用。存储易变数据的动态分配内存不应为只读。

当找到静态地址时停止遍历路径 当发现指向静态起始地址的指针路径时终止扫描。应该启用此选项,以减少假阳性并加速扫描。

指针路径仅限于此区域内 通常可以保持默认设置。你可以使用的其他选项通过智能缩小扫描范围来弥补这一大范围。

指针结构的第一个元素必须指向模块 告诉 Cheat Engine 不要搜索那些未找到虚函数表的堆块,假设游戏是使用面向对象编程方法编写的。虽然这个设置可以极大地加速扫描,但它非常不可靠,你几乎总是应该将其禁用。

无循环指针 使任何指向自身的路径无效,从而筛除低效路径,但会稍微降低扫描速度。通常应该启用此选项。

最大层级 确定指针路径的最大长度。(记得 Listing 1-1 示例代码中的 maxDepth 变量吗?)这个值应该保持在 6 或 7 左右。

当然,也会有需要改变这些选项的情况。例如,如果使用“无循环指针”或“最大层级”设置时无法获得可靠结果,通常意味着你正在寻找的值存在于一个动态数据结构中,比如链表、二叉树或向量。另一个例子是“当找到静态地址时停止遍历路径”选项,在少数情况下,它可能会阻止你获得可靠的结果。

情况性选项

与之前的选项不同,其余设置的配置将取决于你的情况。以下是如何为每个设置确定最佳配置:

使用收集的堆数据改善指针扫描 允许 Cheat Engine 使用堆分配记录来确定偏移限制,从而有效地通过筛除许多假阳性来加速扫描。如果你遇到使用自定义内存分配器的游戏(这种情况越来越常见),这个选项实际上可能会做出与其预期相反的效果。在初次扫描时可以启用此设置,但当你无法找到可靠路径时,它应该是第一个禁用的选项。

路径中只允许静态和堆地址 使所有无法通过堆数据优化的路径无效,从而使该方法更加激进。

每个节点的最大不同偏移量 限制扫描器检查的相同值指针的数量。也就是说,如果 n 个不同的地址指向 0x0BADF00D,此选项告诉 Cheat Engine 只考虑前 m 个地址。当你无法缩小结果集时,这个选项可以非常有帮助。在其他情况下,你可能希望禁用它,因为它会错过许多有效的路径。

允许将第一线程的堆栈地址视为静态 扫描游戏中最旧的 m 个线程的调用栈,考虑每个栈中前 n 字节。这使得 Cheat Engine 能够扫描游戏调用链中的函数参数和局部变量(目标是找到游戏主循环使用的变量)。使用此选项找到的路径既可能非常不稳定,又可能极其有用;当我无法找到堆地址时,我才会使用这个选项。

将堆栈地址视为唯一的静态地址 通过仅允许堆栈地址出现在指针路径中,进一步加强了之前的选项。

指针必须以特定的偏移量结束 如果你知道有效路径结尾的偏移量,这个选项会非常有用。它允许你指定这些偏移量(从最后一个偏移量开始),大大减少扫描的范围。

扫描线程数 确定扫描器将使用多少个线程。通常,线程数设置为与处理器核心数相同效果最佳。下拉菜单中的选项允许你为每个线程指定优先级。如果你希望扫描非常缓慢,选择“空闲”最合适;如果是大多数扫描,选择“正常”;“时间关键”适用于长时间的扫描,但会使你的电脑在扫描过程中变得无法使用。

最大偏移量值 确定路径中每个偏移量的最大值。(记得 Listing 1-1 中的 maxAdd 变量吗?)我通常从一个较低的值开始,只有当扫描失败时才会增加它;128 是一个不错的起始值。请记住,如果你使用的是堆优化选项,这个值大多会被忽略。

注意

如果同时启用了“仅允许路径中的静态和堆地址”和“将堆栈地址视为唯一的静态地址”选项,会怎么样?扫描会没有结果吗?看起来像是一个有趣的实验,尽管它可能没什么用。

定义扫描选项后,点击 确定 开始指针扫描。当扫描完成后,会出现一个结果窗口,显示找到的指针链列表。这个列表通常会有数千个结果,包含了真实链和误报。

指针重扫

指针扫描器有一个重扫功能,可以帮助你消除误报。首先,在结果窗口中按 CTRL-R 打开重扫指针列表对话框,如 图 1-5 所示。

image

图 1-5:Cheat Engine 重扫指针列表对话框

在告诉 Cheat Engine 进行重扫时,有两个主要选项需要考虑:

仅过滤无效指针 如果你勾选这个框 ➊,重扫将仅丢弃指向无效内存的指针链,这在初始结果集非常大的时候会很有帮助。禁用此选项则会过滤掉那些没有解析到特定地址或值的路径(如图所示)。

重复重扫直到停止 如果你勾选此框 ➋,重扫将以循环方式执行。理想情况下,你应该启用此设置并让重扫在你创建大量内存熵时运行。

对于初始重扫,启用 仅过滤无效指针重复重扫直到停止,然后按 确定 以启动重扫。重扫窗口将消失,结果窗口中会出现一个“停止重扫循环”按钮。结果列表将持续重扫,直到你点击停止重扫循环,但在此之前,花几分钟创建内存熵。

在极少数情况下,使用重扫循环可能仍然会留下一个较大的可能路径列表。当这种情况发生时,你可能需要重新启动游戏,找到保存你值的地址(它可能已更改!),并在该地址上使用重扫功能进一步缩小结果范围。在此扫描中,保持 仅过滤无效指针 取消选中,并在 查找的地址 字段中输入 新的 地址。

注意

如果你不得不关闭结果窗口,你可以重新打开它,并通过进入主 Cheat Engine 窗口,按下结果窗格下方的内存视图按钮来加载结果列表。这应该会弹出一个内存转储窗口。窗口出现后,按 CTRL-P 打开指针扫描结果列表。然后按 CTRL-O 打开你保存指针扫描的 .ptr 文件。

如果你的结果仍然不够精确,尝试在系统重启后,甚至在不同的系统上运行相同的扫描。如果这仍然产生较大的结果集,则可以安全地认为每个结果是静态的,因为多个指针链可能会解析到相同的地址。

一旦你缩小了结果集的范围,双击一个可用的指针链,将其添加到作弊表中。如果你有几条看似可用的链,选择偏移量最少的那一条。如果你发现有多个指针链的偏移量完全相同且起始指针相同,但在某一点后分岔,那么你的数据可能存储在动态数据结构中。

这就是 Cheat Engine 中指针扫描的全部内容。试试看吧!

指针扫描

访问 www.nostarch.com/gamehacking/ 并下载 MemoryPointers.exe。与上一个任务不同,这个任务要求你在 10 秒内赢得 50 次。每次获胜后,x 和 y 坐标的内存地址将会变化,意味着你只能在找到合适的指针路径时冻结该值。像前一个任务一样开始此练习,但一旦找到地址,使用指针扫描功能来定位指针路径。然后,将球放在黑色方块上,冻结该值,并按 TAB 开始测试。就像之前一样,游戏会在你获胜后告知你。(提示:尝试将最大级别设置为 5,最大偏移值设置为 512。此外,可以调整选项来允许堆栈地址,找到静态时终止扫描,并通过堆数据改善指针扫描。看看哪种选项组合能给出最佳结果。)

Lua 脚本环境

历史上,机器人开发者在游戏发布补丁时很少使用 Cheat Engine 来更新地址,因为在 OllyDbg 中这样做要容易得多。这使得 Cheat Engine 对游戏黑客除了初期研究和开发外几乎没有用处——直到一个强大的基于 Lua 的嵌入式脚本引擎在 Cheat Engine 强大的扫描环境中实现。虽然这个引擎是为了在 Cheat Engine 中开发简单的机器人而创建的,但专业游戏黑客发现他们也可以利用它轻松编写复杂的脚本,自动定位不同版本游戏二进制文件中的地址——否则这个任务可能需要几个小时。

注意

你可以在 wiki 上找到有关 Cheat Engine Lua 脚本引擎的更多详细信息,网址是 wiki.cheatengine.org/

要开始使用 Lua 引擎,从主 Cheat Engine 窗口按 CTRL-ALT-L。窗口打开后,在文本区域编写你的脚本,并点击 执行脚本 来运行它。使用 CTRL-S 保存脚本,使用 CTRL-O 打开已保存的脚本。

脚本引擎有数百个函数和无限的使用案例,因此我将通过分解两个脚本来让你了解它的一部分能力。每个游戏都是不同的,每个游戏黑客编写脚本来实现独特的目标,所以这些脚本仅用于演示概念。

搜索汇编模式

第一个脚本定位组成出站数据包的函数,并将它们发送到游戏服务器。它通过在游戏的汇编代码中搜索包含特定代码序列的函数来工作。

➊ BASEADDRESS = getAddress("Game.exe")
➋ function LocatePacketCreation(packetType)
➌     for address = BASEADDRESS, (BASEADDRESS + 0x2ffffff) do
           local push = readBytes(address, 1, false)
           local type = readInteger(address + 1)
           local call = readInteger(address + 5)
➍         if (push == 0x68 and type == packetType and call == 0xE8) then
               return address
           end
       end
       return 0
   end
   FUNCTIONHEADER = { 0xCC, 0x55, 0x8B, 0xEC, 0x6A }
➎ function LocateFunctionHead(checkAddress)
       if (checkAddress == 0) then return 0 end 
➏     for address = checkAddress, (checkAddress - 0x1fff), -1 do
           local match = true
           local checkheader = readBytes(address, #FUNCTIONHEADER, true)
➐         for i, v in ipairs(FUNCTIONHEADER) do
               if (v ~= checkheader[i]) then
                   match = false
                   break
               end
           end
➑         if (match) then return address + 1 end
       end
       return 0
   end

➒ local funcAddress = LocateFunctionHead(LocatePacketCreation(0x64))
   if (funcAddress ~= 0) then
       print(string.format("0x%x",funcAddress))
   else
       print("Not found!")
   end

代码首先获取 Cheat Engine 附加的模块的基地址 ➊。获得基地址后,定义了函数 LocatePacketCreation() ➋。这个函数循环遍历游戏中前 0x2FFFFFF 字节的内存 ➌,搜索一个代表此 x86 汇编代码的序列:

PUSH type   ; Data is: 0x68 [4byte type]
CALL offset ; Data is: 0xE8 [4byte offset]

该函数检查类型是否等于packetType,但它不关心函数偏移量是什么 ➍。一旦找到这个序列,函数就会返回。

接下来,定义了LocateFunctionHead()函数 ➎。该函数从给定地址回溯最多 0x1FFF 字节 ➏,并在每个地址处检查一个看起来像这样的汇编代码存根 ➐:

INT3         ; 0xCC
PUSH EBP     ; 0x55
MOV EBP, ESP ; 0x8B 0xEC
PUSH [-1]    ; 0x6A 0xFF

这个存根将在每个函数的开头出现,因为它是设置函数堆栈帧的函数前言的一部分。一旦找到代码,函数将返回存根的地址加 1 ➑(第一个字节0xCC是填充字节)。

为了将这些步骤串联起来,调用LocatePacketCreation()函数,并传入我正在寻找的packetType(随意设为0x64),然后将得到的地址传递给LocateFunctionHead()函数 ➒。这实际上定位了第一个将packetType传递到函数调用中的函数,并将其地址存储在funcAddress中。这个代码片段显示了结果:

INT3          ; LocateFunctionHead back-tracked to here
PUSH EBP      ;   and returned this address
MOV EBP, ESP
PUSH [-1]
--snip--
PUSH [0x64]   ; LocatePacketCreation returned this address
CALL [something]

这个 35 行的脚本可以在不到一分钟的时间内自动定位 15 个不同的函数。

搜索字符串

接下来的 Lua 脚本扫描游戏内存中的文本字符串。它的工作方式与在 Cheat Engine 中使用字符串值类型时的内存扫描器相似。

   BASEADDRESS = getAddress("Game.exe")
➊ function findString(str)
       local len = string.len(str)
➋     local chunkSize = 4096
➌     local chunkStep = chunkSize - len
       print("Found '" .. str .. "' at:")
➍     for address = BASEADDRESS, (BASEADDRESS + 0x2ffffff), chunkStep do
           local chunk = readBytes(address, chunkSize, true)
            if (not chunk) then break end
➎         for c = 0, chunkSize-len do 
➏             checkForString(address , chunk, c, str, len)
           end
       end
   end
   function checkForString(address, chunk, start, str, len)
       for i = 1, len do
           if (chunk[start+i] ~= string.byte(str, i)) then
               return false
           end
       end
➐     print(string.format("\t0x%x", address + start))
   end

➑ findString("hello")
➒ findString("world")

在获取基本地址后,定义了findString()函数 ➊,该函数将一个字符串str作为参数。该函数会以 4,096 字节为一块循环遍历游戏内存 ➍。数据块按顺序扫描,每个块的起始位置比上一个块的末尾提前lenstr的长度)字节 ➌,以防止遗漏从一个块开始、另一个块结束的字符串。

findString()读取每个数据块时,它会遍历每个字节,直到数据块中的重叠点 ➎,然后将每个子块传递给checkForString()函数 ➏。如果checkForString()将子块与str匹配,它会将该子块的地址打印到控制台 ➐。

最后,为了查找所有引用字符串"hello""world"的地址,调用了findString("hello") ➑和findString("world") ➒。通过使用这段代码搜索嵌入的调试字符串,并将其与前面的代码结合使用来定位函数头,我能够在几秒钟内找到游戏中的大量内部函数。

优化内存代码

由于内存读取的高开销,当编写执行内存读取的代码时,优化非常重要。在之前的代码片段中,请注意findString()函数没有使用 Lua 引擎内建的readString()函数。相反,它会读取大块的内存并在其中搜索所需的字符串。让我们来分析一下这些数字。

使用 readString() 的扫描会尝试在每一个可能的内存地址处读取一个 len 字节的字符串。这意味着它最多会读取 (0x2FFFFFF * len + len) 字节。而 findString() 会读取 4,096 字节的块,并在本地扫描这些块中的匹配字符串。这意味着它最多会读取 (0x2FFFFFF + 4096 + (0x2FFFFFF / (4096 - 10)) * len) 字节。在查找一个长度为 10 的字符串时,每种方法读取的字节数分别为 503,316,480 和 50,458,923 字节。

findString() 不仅读取的数据量少一个数量级,而且调用的内存读取次数也大大减少。以 4,096 字节为单位进行分块读取,可能需要总共 (0x2FFFFFF / (4096 - len)) 次读取。相比之下,使用 readString() 的扫描则需要 0x2FFFFFF 次读取。使用 findString() 的扫描有了巨大的改进,因为调用读取操作的代价要比增加读取数据的大小要高得多。(注意,我选择 4,096 是随便挑的。由于读取内存可能非常耗时,我保持数据块相对较小,因为一次读取四页数据可能会浪费时间,仅仅是为了在第一页中找到字符串。)

结束语

到这个阶段,你应该已经对 Cheat Engine 及其工作原理有了基本的理解。Cheat Engine 是你工具箱中非常重要的一项工具,我鼓励你通过阅读 “基本内存编辑”(第 11 页)和 “指针扫描”(第 18 页),并自己动手实践,来积累一些使用经验。

第二章:2

使用 OLLYDBG 调试游戏

image

使用 Cheat Engine 你可以了解游戏运行时的一些表面现象,但使用一个优秀的调试器,你可以深入挖掘,直到完全理解游戏的结构和执行流程。这使得 OllyDbg 成为你游戏破解工具包中不可或缺的部分。它包含了许多强大的工具,如条件断点、字符串引用搜索、汇编模式搜索和执行跟踪,使其成为一个功能强大的 32 位 Windows 应用程序汇编级调试器。

我将在第四章详细讲解低级代码结构,但在本章中,我假设你至少对现代代码级调试器有所了解,比如微软 Visual Studio 自带的调试器。OllyDbg 在功能上与这些调试器相似,但有一个重要的区别:它与应用程序的汇编代码进行交互,即使没有源代码和/或调试符号,仍然能够工作,这使得它在你需要深入了解游戏内部时非常理想。毕竟,游戏公司通常不会“好心”或“愚蠢”到将调试符号随游戏一起发布!

在本章中,我将介绍 OllyDbg 的用户界面,向你展示如何使用其最常见的调试功能,解析其表达式引擎,并提供一些实际案例,展示如何将其应用于你的游戏破解工作。最后,我还将介绍一些有用的插件,并送你一款测试游戏,帮助你入门 OllyDbg。

注意

本章聚焦于 OllyDbg 1.10,可能对后续版本不完全适用。我使用这个版本是因为,在撰写时,OllyDbg 2 的插件接口仍然比 OllyDbg 1 的接口弱很多。

当你觉得已经掌握了 OllyDbg 的界面和功能时,可以通过第 46 页中的“修改if()语句”自己尝试调试一个游戏。

简要了解 OllyDbg 的用户界面

访问 OllyDbg 官网 (www.ollydbg.de/),下载并安装 OllyDbg,然后打开程序。你应该会看到如图 2-1 所示的工具栏和多窗口界面区域。

image

图 2-1:OllyDbg 主窗口

这个工具栏包含了程序控制按钮➊、调试按钮➋、跳转按钮➌、控制窗口按钮➍和设置按钮➎。

这三个程序控制选项让你分别能够打开可执行文件并附加到它创建的进程、重启当前进程,或者终止当前进程的执行。你也可以通过快捷键 F3、CTRL-F2 和 ALT-F2 分别完成这些功能。要附加到已经在运行的进程,点击文件附加

调试按钮控制调试器的操作。表 2-1 描述了这些按钮的功能、快捷键和作用。此表还列出了三个没有按钮的有用调试器操作。

表 2-1: 调试按钮和其他调试器功能

按钮 快捷键 功能
播放 F9 恢复进程的正常执行。
暂停 F12 暂停进程中所有线程的执行,并在当前执行的指令处打开 CPU 窗口。
步进进入 F7 单步执行到下一个操作(会进入函数调用)。
步进越过 F8 单步执行到当前作用域内的下一个操作(会跳过函数调用)。
跟踪进入 CTRL-F11 执行深度跟踪,跟踪每个执行的操作。
跟踪超出 CTRL-F12 运行被动跟踪,仅跟踪当前作用域内的操作。
执行直到返回 CTRL-F9 执行直到当前作用域内遇到返回操作为止。
CTRL-F7 每次操作都自动单步执行,跟随反汇编窗口中的执行。这使得执行看起来像是动画的。
CTRL-F8 也会动画化执行,但跳过函数而不是进入函数。
ESC 停止动画,暂停当前操作的执行。

“转到”按钮打开一个对话框,要求输入一个十六进制地址。一旦输入地址,OllyDbg 将打开 CPU 窗口并显示指定地址处的反汇编。当 CPU 窗口处于焦点时,你还可以通过快捷键 CTRL-G 显示该信息。

控制窗口按钮打开不同的控制窗口,这些窗口展示了调试中进程的有用信息,并暴露更多的调试功能,例如设置断点的能力。OllyDbg 共有 13 个控制窗口,所有这些窗口可以在多窗口界面中同时打开。表 2-2 描述了这些窗口,按它们在窗口按钮工具栏中出现的顺序列出。

表 2-2: OllyDbg 的控制窗口

窗口 快捷键 功能
日志 ALT-L 显示日志消息列表,包括调试打印、线程事件、调试器事件、模块加载等。
模块 ALT-E 显示加载到进程中的所有可执行模块的列表。双击一个模块以在 CPU 窗口中打开它。
内存映射 ALT-M 显示进程分配的所有内存块的列表。双击列表中的某个块以打开该内存块的转储窗口。
线程 显示进程中运行的线程列表。每个线程在此列表中都有一个名为线程信息块(TIB)的结构。OllyDbg 允许你查看每个线程的 TIB;只需右键点击线程并选择“转储线程数据块”。
Windows 显示进程所持有的窗口句柄列表。在此列表中右键单击一个窗口,跳转到该窗口或在其类过程上设置断点(该过程是当消息发送到窗口时被调用的函数)。
Handles 显示进程所持有的句柄列表。(注意,Process Explorer 的句柄列表比 OllyDbg 更好,具体细节我会在第三章中讨论。)
CPU ALT-C 显示主要的反汇编界面并控制大多数调试器功能。
Patches CTRL-P 显示你对进程中模块所做的任何汇编代码修改的列表。
Call stack ALT-K 显示活动线程的调用栈。进程停止时,窗口会更新。
Breakpoints ALT-B 显示活动的调试器断点列表,并允许你切换它们的开关。
References 显示参考列表,通常包含多种类型搜索的结果。它会在你运行搜索时自动弹出。
Run trace 显示调试器追踪记录的操作列表。
Source 如果存在程序调试数据库,显示反汇编模块的源代码。

最后,设置按钮打开 OllyDbg 设置窗口。暂时保持默认设置即可。

现在你已经参观了 OllyDbg 主窗口,让我们更深入地探讨 CPU、Patches 和 Run trace 窗口。作为一名游戏黑客,你将大量使用这些窗口,熟悉它们是关键。

OllyDbg 的 CPU 窗口

图 2-2 中的 CPU 窗口是游戏黑客在 OllyDbg 中大部分时间都待的地方,因为它是调试功能的主要控制窗口。

image

图 2-2:OllyDbg CPU 窗口

这个窗口包含四个独立的控制面板:反汇编面板 ➊、寄存器面板 ➋、转储面板 ➌ 和堆栈面板 ➍。这四个面板封装了 OllyDbg 的主要调试功能,因此了解它们的细节非常重要。

查看和浏览游戏的汇编代码

你将通过 OllyDbg 的反汇编面板浏览游戏代码并控制调试的大多数方面。这个面板显示当前模块的汇编代码,其数据整齐地以由四列组成的表格显示:地址、十六进制转储、反汇编和注释。

地址列显示你所附加的游戏进程中每个操作的内存地址。你可以双击此列中的地址来切换它是否为显示基址。当地址被设置为显示基址时,地址列将显示所有其他地址作为相对于它的偏移量。

十六进制转储列显示每个操作的字节码,并按操作码和参数分组。此列左侧跨越多行的黑色括号标记了已知函数的边界。指向这些操作的跳转操作将显示在这些括号内的右箭头。执行跳转的操作会根据跳转的方向,在这些括号内显示向上或向下的箭头。例如,在 图 2-2 中,地址为 0x779916B1(高亮显示)的指令上有一个向上的箭头,表示这是一个向上的跳转。您可以将跳转视为一个 goto 操作符。

反汇编列显示游戏执行的每个操作的汇编代码。例如,您可以通过查看汇编代码确认 图 2-2 中地址为 0x779916B1 的指令是一个跳转指令,因为汇编代码中显示了 JNZ(非零时跳转)指令。此列中的黑色括号标记了循环的边界。附着在这些括号上的右箭头指向控制循环是否继续或退出的条件语句。在 图 2-2 中的这一列里,三个右箭头指向 CMP(比较)和 TEST 指令,这些指令用于汇编代码中进行值的比较。

注释列显示游戏执行的每个操作的可读注释。如果 OllyDbg 遇到已知的 API 函数名,它将自动插入带有函数名称的注释。同样,如果它成功检测到传递给函数的参数,它将标记这些参数(例如,Arg1Arg2、...、ArgN)。您可以在该列中双击以添加自定义注释。此列中的黑色括号标记了假定的函数调用参数边界。

注意

OllyDbg 在代码分析过程中推断函数边界、跳转方向、循环结构和函数参数,因此如果这些列缺少边界线或跳转箭头,只需按 CTRL-A 对二进制文件进行代码分析即可。

当反汇编窗格获得焦点时,您可以使用一些快捷键来快速导航代码并控制调试器。使用 F2 来切换断点,SHIFT-F12 来设置条件断点,-(短横线)来后退,+(加号)来前进(这两个操作与网页浏览器中的表现相同),*(星号)来跳转到 EIP(即 x86 架构中的执行指针),CTRL--(短横线)来跳转到上一函数,CTRL-+ 来跳转到下一函数。

反汇编器还可以通过不同类型的搜索结果填充引用窗口。当您想要更改引用窗口的内容时,右键单击反汇编窗格,鼠标悬停在“搜索”菜单上以展开它,然后选择以下选项之一:

所有跨模块调用 搜索所有调用远程模块中函数的操作。例如,这可以让你看到游戏中所有调用 Sleep()PeekMessage() 或任何其他 Windows API 函数的地方,从而使你能够检查或在调用时设置断点。

所有命令 搜索所有给定的汇编操作的出现,其中新增的操作符 CONSTR32 分别匹配常量值或寄存器值。此选项的一个用例可能是搜索诸如 MOV [0xDEADBEEF], CONSTMOV [0xDEADBEEF], R32;和 MOV [0xDEADBEEF], [R32+CONST] 的命令,以列出所有修改地址 0xDEADBEEF 上内存的操作,这个地址可以是任何东西,包括你角色的生命值地址。

所有序列 搜索所有给定操作序列的出现。这类似于之前的选项,但允许你指定多个命令。

所有常量 搜索给定十六进制常量的所有实例。例如,如果你输入角色生命值的地址,这将列出所有直接访问该地址的命令。

所有开关 搜索所有的开关-案例块。

所有引用的文本字符串 搜索代码中引用的所有字符串。你可以使用此选项搜索所有引用的字符串,并查看哪些代码访问了它们,这对于关联游戏中的文本显示与显示它们的代码非常有用。此选项对于定位任何调试断言或日志字符串也非常有用,这对确定代码部分的目的帮助巨大。

反汇编器还可以将当前模块中的所有标签(CTRL-N)或所有模块中已知的标签(搜索 ▸ 名称在所有模块中)填充到名称窗口中。已知的 API 函数将自动标记为其名称,你可以通过高亮命令、按 SHIFT-; 并在提示时输入标签来为命令添加标签。当代码中引用带标签的命令时,标签将替代地址显示。使用此功能的一种方法是为你分析过的函数命名(只需在函数的第一个命令上设置标签),这样你就可以看到其他函数调用时的名称。

查看和编辑寄存器内容

寄存器窗格显示八个处理器寄存器的内容、所有八个标志位、六个段寄存器、最后的 Windows 错误代码和 EIP。在这些值下方,窗格可以显示浮点单元(FPU)寄存器或调试寄存器;点击窗格的标题更改显示的寄存器类型。只有在你冻结进程时,这些值才会被填充。以红色显示的值自上次暂停以来已被更改。双击此窗格中的值可进行编辑。

查看和搜索游戏内存

转储窗格显示特定地址的内存转储。要跳转到某个地址并显示该地址的内存内容,请按 CTRL-G 并在弹出的框中输入地址。你也可以通过右键点击其他 CPU 窗格的地址列,并选择“在转储中跟随”来跳转到该条目的地址。

虽然转储窗格始终有三列,但你应始终看到的唯一一列是地址列,它的行为与反汇编窗格中的类似。你选择的数据展示类型决定了其他两列的显示方式。右键点击转储窗格来更改展示类型;对于图 2-2 中显示的类型,你需要右键点击并选择“十六进制” ▸ “十六进制/ASCII(8 字节)”。

你可以通过右键点击转储窗格中显示的地址并展开断点子菜单,在某个地址上设置内存断点。 从此菜单中选择内存按访问,可以在任何使用该地址的代码上断点,或者选择内存按写入,仅在写入该内存区域的代码上断点。要删除内存断点,请在相同菜单中选择删除内存断点;此选项仅在右键点击的地址上已有断点时出现。

在转储窗格中选择一个或多个值后,你可以按 CTRL-R 在当前模块的代码中搜索引用所选值的地址;此搜索的结果会显示在“引用”窗口中。你还可以使用 CTRL-B 搜索此窗格中的二进制字符串,使用 CTRL-N 搜索标签。发起搜索后,按 CTRL-L 可以跳转到下一个匹配项。CTRL-E 可以让你编辑任何已选择的值。

注意

你可以从内存窗口打开的转储窗口与转储窗格的工作方式相同。

查看游戏的调用栈

最后一种 CPU 窗格是堆栈窗格,顾名思义,它显示调用栈。与转储和反汇编窗格一样,堆栈窗格也有地址列。堆栈窗格还具有值列,显示堆栈中的 32 位整数数组,并且有一个注释列,显示返回地址、已知的函数名称和其他信息标签。堆栈窗格支持与转储窗格相同的所有快捷键,唯一的例外是 CTRL-N。

多客户端补丁

一种被称为多客户端补丁的黑客方式,会在游戏的二进制文件中覆盖单实例限制代码,将其替换为无操作代码,从而允许用户运行多个游戏客户端,即使通常情况下这样做是被禁止的。由于执行实例限制代码必须在游戏客户端启动后非常早的时候进行,这使得机器人的补丁几乎不可能及时注入。最简单的解决方法是通过在 OllyDbg 中应用补丁并直接将其保存到游戏二进制文件中,使多客户端补丁得以持久化。

创建代码补丁

OllyDbg 的 代码补丁 允许你对想要破解的游戏进行汇编代码修改,无需专门为该游戏开发工具。这使得原型设计 控制流破解——通过游戏设计缺陷、x86 汇编协议和常见的二进制构造组合来操控游戏行为——变得更加容易。

游戏黑客通常将完善的补丁作为可选功能集成到机器人的工具套件中,但在某些情况下,使这些功能持久化实际上对最终用户更为方便。幸运的是,OllyDbg 的补丁提供了你所需的完整功能,允许你仅使用 OllyDbg 来设计、测试并永久保存代码修改到可执行的二进制文件中。

要放置一个补丁,在 CPU 窗口中导航到你想要修补的汇编代码行,双击你希望修改的指令,在弹出的提示框中输入新的汇编指令,并点击 汇编,如 图 2-3 所示。

image

图 2-3:在 OllyDbg 中设置补丁

始终注意补丁的大小——你不能随意调整和移动汇编代码。比你打算替换的代码更大的补丁会溢出到后续的操作中,可能会移除关键功能。比你打算替换的操作更小的补丁是安全的,只要勾选了“用 NOP 填充”选项。此选项会用无操作(NOP)指令填充任何弃用的字节,NOP 指令是单字节操作,在执行时不会执行任何操作。

所有你放置的补丁都会列出,并显示其地址、大小、状态、旧代码、新代码和注释,在补丁窗口中查看。在这个列表中选择一个补丁,访问一组小而强大的快捷键,参见 表 2-3。

表 2-3:补丁窗口快捷键

操作符 功能
ENTER 跳转到反汇编器中的补丁。
空格键 切换补丁的启用或禁用状态。
F2 在补丁上设置断点。
SHIFT-F2 在补丁上设置条件断点。
SHIFT-F4 在补丁上设置条件日志断点。
DEL 仅从列表中移除补丁条目。

在 OllyDbg 中,你还可以直接将补丁保存到二进制文件中。首先,在反汇编器中右键单击,然后点击 复制到可执行文件所有修改。如果你只想复制特定的补丁,可以在反汇编窗格中高亮显示它们,然后按 复制到可执行文件选择

确定补丁大小

有几种方法可以判断你的补丁是否会与原始代码的大小不同。例如,在图 2-3 中,你可以看到位于 0x7790ED2E 的命令从 SHR AL, 6 被更改为 SHR AL, 7。如果你看命令左边的字节,你会看到三个字节,表示该命令的内存。这意味着我们的新命令必须是 3 个字节,或者如果少于 3 个字节,则用 NOP 填充。此外,这些字节被分为两列。第一列包含 0xC00x08,它们分别表示命令 SHR 和第一个操作数 AL。第二列包含 0x06,表示原始操作数。因为第二列显示了一个字节,所以任何替代操作数也必须是 1 字节(介于 0x000xFF 之间)。如果第二列显示的是 0x00000006,则替代操作数的长度可以达到 4 字节。

典型的代码补丁通常会使用所有的 NOP 指令来完全移除命令(通过留空并让它填充整个命令为 NOP),或者仅替换一个操作数,因此这种检查补丁大小的方法几乎总是有效的。

通过汇编代码追踪

当你对任何程序进行追踪时,OllyDbg 会逐步执行每个操作,并记录每个操作的数据。当追踪完成后,记录的数据会显示在运行追踪窗口中,如图 2-4 所示。

image

图 2-4:运行追踪窗口

运行追踪窗口被组织为以下六列:

返回 记录操作与当前执行状态之间的操作次数

线程 执行该操作的线程

模块 操作所在的模块

地址 操作的地址

命令 执行的操作

修改的寄存器 操作更改的寄存器及其新值

在破解游戏时,我发现 OllyDbg 的追踪功能非常有效,能帮助我找到动态内存的指针路径,尤其是在 Cheat Engine 扫描结果不明确时。这之所以有效,是因为你可以在运行追踪窗口中,逆向跟踪从内存使用点到内存从静态地址解析点的日志。

这个强大的功能的有用性仅受使用它的黑客创造力的限制。虽然我通常只用它来查找指针路径,但我遇到过一些其他情况,在这些情况下,它证明了非常宝贵。《OllyDbg 表达式的实际应用》中的轶事,以及第 36 页的内容,有助于阐明追踪的功能和强大之处。

OllyDbg 的表达式引擎

OllyDbg 拥有一个自定义的表达式引擎,能够以简单的语法编译和计算高级表达式。这个表达式引擎出乎意料地强大,如果使用得当,它可以成为普通 OllyDbg 用户和 OllyDbg 大师之间的差距。你可以使用这个引擎为许多功能指定表达式,如条件断点、条件跟踪和命令行插件。本节介绍了表达式引擎及其提供的选项。

注意

本节内容部分基于官方的表达式文档www.ollydbg.de/Help/i_Expressions.htm)。然而,我发现文档中定义的某些组件似乎并不起作用,至少在 OllyDbg v1.10 版本中是如此。两个例子是INTASCII数据类型,它们必须替换为别名LONGSTRING。因此,在此我只包括了我亲自测试过并完全理解的组件。

在断点中使用表达式

条件断点被激活时,OllyDbg 会提示你输入一个条件表达式;这就是大多数表达式的使用场景。当断点被触发时,OllyDbg 会悄悄暂停执行并计算该表达式。如果计算结果非零,执行会保持暂停状态,并且你会看到断点被触发。但如果计算结果是 0,OllyDbg 会悄悄恢复执行,就像什么都没发生一样。

由于游戏中每秒会发生大量的执行操作,你经常会发现某段代码在太多的上下文中被执行,导致断点不再是获取你所需数据的有效方式。将条件断点与对周围代码的良好理解结合起来,是避免这种情况的万无一失的方式。

在表达式引擎中使用运算符

对于数值数据类型,OllyDbg 表达式支持常见的 C 风格运算符,如表 2-4 所示。虽然没有明确的文档说明运算符优先级,但 OllyDbg 似乎遵循 C 风格的优先级规则,并且可以使用括号进行作用域限定。

表 2-4: OllyDbg 数值运算符

运算符 功能
a == b 如果 a 等于 b,则返回 1,否则返回 0
a != b 如果 a 不等于 b,则返回 1,否则返回 0
a > b 如果 a 大于 b,则返回 1,否则返回 0
a < b 如果 a 小于 b,则返回 1,否则返回 0
a >= b 如果 a 大于或等于 b,则返回 1,否则返回 0
a <= b 如果 a 小于或等于 b,则返回 1,否则返回 0
a && b 如果 ab 都非零,则返回 1,否则返回 0
a &#124;&#124; b 如果 ab 其中之一非零,则返回 1,否则返回 0
a ^ b 返回 XOR(a, b) 的结果。
a % b 返回MODULUS(a, b)的结果。
a & b 返回AND(a, b)的结果。
a &#124; b 返回OR(a, b)的结果。
a << b 返回将a左移b位后的结果。
a >> b 返回将a右移b位后的结果。
a + b 返回ab的和。
a - b 返回a减去b的差。
a / b 返回a除以b的商。
a * b 返回a乘以b的积。
+a 返回a的符号表示。
-a 返回a*-1
!a 如果a0,返回1,否则返回0

另一方面,对于字符串,唯一可用的运算符是==!=,它们遵循以下规则:

• 字符串比较不区分大小写。

• 如果只有一个操作数是字符串字面量,比较将在达到字面量的长度后终止。因此,表达式[STRING EAX]=="ABC123",其中EAX是指向字符串ABC123XYZ的指针,将评估为1而不是0

• 如果在字符串比较中未指定操作数的类型,而另一个操作数是字符串字面量(例如,"MyString"!=EAX),则比较会首先假设非字面量操作数是一个 ASCII 字符串,如果该比较返回0,它将尝试进行第二次比较,假设操作数是 Unicode 字符串。

当然,运算符没有操作数是没有太大用处的。让我们来看一下在表达式中可以评估的一些数据。

操作基本表达式元素

表达式能够评估许多不同的元素,包括:

CPU 寄存器 EAXEBXECXEDXESPEBPESIEDI。你还可以使用 1 字节和 2 字节寄存器(例如,AL表示EAX的低字节,AX表示EAX的低字)。EIP也可以使用。

段寄存器 CSDSESSSFSGS

FPU 寄存器 ST0ST1ST2ST3ST4ST5ST6ST7

简单标签 可以是 API 函数名称,如GetModuleHandle,或用户定义的标签。

Windows 常量ERROR_SUCCESS

整数 以十六进制格式或十进制格式表示(例如,FFFF65535)。

浮点数 允许以十进制格式表示指数(例如,654.123e-5)。

字符串字面量 用引号括起来(例如,"my string")。

表达式引擎按它们列出的顺序查找这些元素。例如,如果你有一个标签与 Windows 常量的名称匹配,引擎会使用该标签的地址,而不是常量的值。但是,如果你有一个以寄存器命名的标签,例如 EAX,引擎会使用寄存器的值,而不是标签的值。

使用表达式访问内存内容

OllyDbg 表达式还足够强大,可以结合内存读取,你可以通过将内存地址或计算出内存地址的表达式括在方括号中来实现。例如,[EAX+C][401000]表示 EAX+C 和 401000 地址处的内容。要将内存读取为除DWORD外的其他类型,你可以在方括号前指定所需类型,例如BYTE [EAX],或在方括号内的第一个标记中指定,如[STRING ESP+C]。支持的类型列在表 2-5 中。

表 2-5: OllyDbg 数据类型

数据类型 解释
BYTE 8 位整数(无符号)
CHAR 8 位整数(有符号)
WORD 16 位整数(无符号)
SHORT 16 位整数(有符号)
DWORD 32 位整数(无符号)
LONG 32 位整数(有符号)
FLOAT 32 位浮动点数
DOUBLE 64 位浮动点数
STRING 指向一个 ASCII 字符串的指针(以空字符结尾)
UNICODE 指向一个 Unicode 字符串的指针(以空字符结尾)

在游戏黑客中,将内存内容直接插入到 OllyDbg 表达式中非常有用,部分原因是你可以让调试器在暂停之前检查角色的健康、名字、金币等信息。你将在“当特定玩家的名字被打印时暂停执行”第 37 页看到一个示例。

OllyDbg 表达式示例

OllyDbg 中的表达式使用的语法与大多数编程语言类似;你甚至可以将多个表达式组合在一起,或将一个表达式嵌套在另一个表达式内。游戏黑客(实际上所有黑客)通常使用它们来创建条件断点,正如我在“在断点中使用表达式”第 34 页中描述的那样,但你可以在 OllyDbg 中的许多不同位置使用它们。例如,OllyDbg 的命令行插件可以实时评估表达式并显示其结果,使你能够轻松读取任意内存,检查汇编代码正在计算的值,或快速得到数学方程的结果。此外,黑客们甚至可以通过将表达式与追踪功能结合,创建智能的、与位置无关的断点。

在本节中,我将分享一些在我的工作中表达式引擎派上用场的轶事。我会解释我的思考过程,逐步展示我的调试过程,并将每个表达式分解成其组成部分,这样你就可以看到一些在游戏黑客中使用 OllyDbg 表达式的方式。

注意

这些示例包含了一些汇编代码,但如果你没有太多汇编经验,也不必担心。只需忽略细节,了解像ECXEAXESP这样的值是进程寄存器,类似于在“查看和编辑寄存器内容”第 29 页提到的寄存器内容。接下来,我会解释其余的内容。

如果在我走过这些轶事时,你对表达式中的某个运算符、元素或数据类型感到困惑,只需参考“OllyDbg 的表达式引擎”(第 33 页)。

当打印特定玩家的名称时暂停执行

在一次特定的调试会话中,我需要搞清楚在游戏绘制玩家名称时究竟发生了什么。具体来说,我需要在游戏绘制“Player 1”名称之前设置断点,忽略所有绘制的其他名称。

弄清楚在哪儿暂停执行

作为起点,我使用 Cheat Engine 找到了玩家 1 名称在内存中的地址。一旦找到了地址,我就用 OllyDbg 在该字符串的第一个字节上设置了内存断点。每次这个断点被触发时,我快速检查汇编代码,弄清楚它是如何使用玩家 1 名称的。最终,我找到了名称被直接访问的地方,它位于一个我之前命名为printText()的函数调用上方。我找到了绘制名称的代码。

我移除了我的内存断点,并在调用printText()的地方设置了一个代码断点。然而,出现了一个问题:由于调用printText()的地方在一个循环中,这个循环遍历了游戏中的每个玩家,所以每次绘制名称时,我的新断点都会被触发——这太频繁了。我需要修复它,使其仅在特定玩家时触发。

检查我之前的内存断点时发现,每个玩家的名称是通过以下汇编代码访问的:

PUSH DWORD PTR DS:[EAX+ECX*90+50]

EAX寄存器包含了一个玩家数据数组的地址;我将其称为playerStructplayerStruct的大小为 0x90 字节,ECX寄存器包含迭代索引(著名的变量i),每个玩家的名称存储在其相应playerStruct开始位置后 0x50 字节的地方。这意味着,这条PUSH指令本质上将EAX[ECX].name(索引i的玩家名称)压入栈中,并作为参数传递给printText()函数调用。于是,循环大致可以分解为如下伪代码:

playerStruct EAX[MAX_PLAYERS]; // this is filled elsewhere
for (int ➊ECX = 0; ECX < MAX_PLAYERS; ECX++) {
    char* name = ➋EAX[ECX].name;
    breakpoint(); // my code breakpoint was basically right here
    printText(name);
}

通过分析,我确定了playerStruct()函数包含了所有玩家的数据,而循环遍历了玩家的总数(通过ECX ➊递增),为每个索引获取了角色名称 ➋,并打印了这个名字。

制作条件断点

知道了这一点,为了仅在打印“Player 1”时暂停执行,我只需要在执行断点之前检查当前玩家的名称。在伪代码中,新的断点看起来像这样:

if (EAX[ECX].name == "Player 1") breakpoint();

一旦我弄清楚了新断点的形式,我需要在循环中访问EAX[ECX].name。这时,OllyDbg 的表达式引擎派上了用场:通过稍微修改汇编代码所用的表达式,我达到了目标,最终得到了这个表达式:

[STRING EAX + ECX*0x90 + 0x50] == "Player 1"

我移除了 printText() 上的代码断点,改为使用一个条件断点,该断点使用这个表达式,告诉 OllyDbg 仅当 EAX + ECX*0x90 + 0x50 存储的字符串值与 Player 1 的名字匹配时才中断。这个断点只会在绘制 "Player 1" 时触发,从而让我继续我的分析。

设置这个断点所需的工作量可能看起来很大,但通过练习,整个过程变得像写代码一样直观。经验丰富的黑客可以在几秒钟内完成这个操作。

实际上,这个断点使我能够在 "Player 1" 一出现就检查 playerStruct() 函数中的某些值。这样做很重要,因为这些值的状态只有在玩家进入屏幕后头几帧才与我的分析相关。像这样创造性地使用断点,可以让你分析各种复杂的游戏行为。

当角色生命值降低时暂停执行

在一次调试过程中,我需要找到在我的角色的生命值降到最大值以下后,第一次被调用的函数。我知道两种方法来解决这个问题:

• 查找每一段访问生命值的代码,并在每一段代码上设置一个条件断点,检查生命值。然后,当其中一个断点被触发时,逐步执行代码,直到下一个函数调用。

• 使用 OllyDbg 的追踪功能来创建一个动态断点,能够在我需要的地方停止。

第一个方法需要更多的设置,而且不容易重复,主要是因为需要设置大量的断点,而且我必须手动逐步执行代码。相比之下,后者方法的设置较为快速,且由于它是自动执行的,因此容易重复。尽管使用追踪功能会显著降低游戏速度(每个操作都会被追踪捕获),但我选择了后者方法。

编写检查生命值的表达式

我再次使用了 Cheat Engine 来查找存储生命值的地址。通过参考 “Cheat Engine 的内存扫描器” 在 第 5 页 中描述的方法,我确定该地址为 0x40A000。

接下来,我需要一个表达式,告诉 OllyDbg 当我的生命值低于最大值时返回 1,否则返回 0。知道我的生命值存储在 0x40A000,并且最大值是 500,我最初设计了这个表达式:

[0x40A000] < 500.

这个表达式会在我的生命值低于 500 时触发中断(记住,在表达式引擎中,十进制数字必须以句点作为后缀),但与其等待一个函数被调用,不如立即触发中断。为了确保它等待直到函数被调用,我用 && 运算符附加了另一个表达式:

[0x40A000] < 500\. && [➊BYTE EIP] == 0xE8

在 x86 处理器中,EIP 寄存器存储正在执行的操作的地址,因此我决定检查 EIP ➊ 处的第一个字节,看看它是否等于 0xE8。这个值告诉处理器执行一个近距离函数调用,这正是我所寻找的调用类型。

在开始我的跟踪之前,我还需要做一件最后的事情。由于跟踪功能会反复单步执行(如 “简要了解 OllyDbg 的用户界面” 中所述,Trace into 使用步入,Trace over 使用步过,第 24 页),我需要从一个位于或高于任何可能更新生命值的代码的地方开始跟踪。

找出从哪里开始跟踪

为了找到一个合适的位置,我在 OllyDbg 的 CPU 窗口中打开了游戏的主模块,在反汇编面板中右键单击,然后选择了“搜索 ▸ 所有模块间调用”。随后弹出了“引用”窗口,显示了游戏调用的外部 API 函数列表。几乎所有的游戏软件都使用 Windows 的 USER32.PeekMessage() 函数来轮询新的消息,因此我根据目标列对列表进行了排序,并输入了 PEEK(你可以通过将窗口聚焦并直接输入名称来搜索列表),找到了第一次调用 USER32.PeekMessage() 的位置。

多亏了目标列排序,每次调用这个函数的记录都紧随其后地列在一起,正如图 2-5 所示。我通过选择它并按下 F2 在每个调用上设置了断点。

image

图 2-5:OllyDbg 的找到的模块间调用窗口

尽管有大约十几次调用 USER32.PeekMessage(),但只有其中两次触发了我的断点。更好的是,活跃的调用彼此相邻,位于一个无条件的循环中。在这个循环的底部有一些内部函数调用。这看起来完全像是一个主游戏循环。

激活跟踪

为了最终设置我的跟踪,我删除了所有先前的断点,并在怀疑的主循环顶部放置了一个断点。我在断点触发后立即将其移除。然后,我按下了 CPU 窗口中的 CTRL-T,弹出了一个名为“条件暂停跟踪运行”的对话框,如图 2-6 所示。在这个新对话框中,我启用了“条件为 TRUE”选项,将我的表达式放入旁边的框中,并点击了 OK。接着,我回到 CPU 窗口,按下 CTRL-F11 开始一个“跟踪进入”会话。

image

图 2-6:用于暂停跟踪运行的条件对话框

一旦跟踪开始,游戏运行得非常慢,几乎无法玩耍。为了减少我的测试角色的生命值,我打开了第二个游戏实例,登录了一个不同的角色,并攻击了我的测试角色。当跟踪执行进度赶上实时进度时,OllyDbg 看到我的生命值发生变化,并在以下函数调用时触发了断点——正如预期的那样。

在这个游戏中,修改健康值的主要代码块是直接从网络代码中调用的。通过这个追踪,我能够找到网络模块在网络数据包告诉游戏更改玩家健康值后直接调用的函数。以下是游戏执行的伪代码:

   void network::check() {
       while (this->hasPacket()) {
           packet = this->getPacket();
           if (packet.type == UPDATE_HEALTH) {
               oldHealth = player->health;
               player->health = packet.getInteger();
➊             observe(HEALTH_CHANGE, oldHealth, player->health);
           }
       }
   }

我知道游戏中的代码只需在玩家的健康值发生变化时执行,而我需要添加代码以响应此类变化。在不了解整体代码结构的情况下,我猜测与健康值相关的代码会在 health 更新后直接通过某个函数调用执行。我的追踪条件断点确认了这一猜测,因为它直接在 observe() 函数 ➊ 处断开。从这里,我能够在该函数上放置一个 钩子钩子是一种拦截函数调用的方式,具体描述请见 “Hooking to Redirect Game Execution” 在 第 153 页)并在玩家的健康值发生变化时执行我自己的代码。

OllyDbg 插件为游戏黑客提供的工具

OllyDbg 高度灵活的插件系统可能是其最强大的功能之一。经验丰富的游戏黑客通常会配置 OllyDbg 环境,安装数十个有用的插件,包括公开发布的和自定义制作的插件。

你可以从 OpenRCE (www.openrce.org/downloads/browse/OllyDbg_Plugins) 和 tuts4you (www.tuts4you.com/download.php?list.9/) 插件仓库下载流行的插件。安装它们很简单:只需解压插件文件并将其放置在 OllyDbg 的安装文件夹中。

安装后,某些插件可以通过 OllyDbg 的插件菜单项访问。然而,其他插件可能只能在 OllyDbg 界面中的特定位置找到。

你可以通过这些在线仓库找到数百个强大的插件,但在构建你的工具库时要小心。工作环境中充斥着未使用的插件可能会影响生产力。在本节中,我精心挑选了四个插件,我认为它们不仅是游戏黑客工具包中不可或缺的部分,而且对环境的影响较小。

使用 Asm2Clipboard 复制汇编代码

Asm2Clipboard 是一个来自 OpenRCE 仓库的简约插件,允许你将反汇编窗格中的汇编代码片段复制到剪贴板。这在更新地址偏移量和设计代码空洞时非常有用,这两者是游戏黑客中我在 第五章 和 第七章 中深入探讨的游戏破解要素。

安装 Asm2Clipboard 后,您可以在反汇编器中高亮一段汇编代码,右键点击高亮的代码,展开 Asm2Clipboard 子菜单,并选择“将固定的 Asm 代码复制到剪贴板”或“将 Asm 代码复制到剪贴板”。后者会在每条指令的代码前加上代码地址作为注释,而前者仅复制纯代码。

通过作弊工具将 Cheat Engine 添加到 OllyDbg

来自 tuts4you 的 Cheat Utility 插件在 OllyDbg 中提供了一个高度精简版的 Cheat Engine。虽然 Cheat Utility 仅允许您执行精确值扫描,并且支持的数据显示类型非常有限,但当您不需要 Cheat Engine 的完整功能来找到所需的内容时,它可以使简单的扫描变得更加轻松。在安装 Cheat Utility 后,要打开其界面(如图 2-7 所示),请选择 插件作弊工具启动

image

图 2-7:作弊工具界面

Cheat Utility 的用户界面和操作与 Cheat Engine 非常相似,因此如果您需要复习,可以参考第一章。

注意

Games Invader 是 tuts4you 发布的 Cheat Utility 更新版,旨在提供更多功能。然而,我发现它存在一些 bug,且我更喜欢使用 Cheat Utility,因为我总是可以利用 Cheat Engine 进行高级扫描。

通过命令行控制 OllyDbg

命令行插件使您能够通过一个小型命令行界面控制 OllyDbg。要访问插件,您可以按 ALT-F1 或选择 插件 ▸ 命令行 ▸ 命令行。然后,您将看到一个窗口,如图 2-8 所示,作为命令行界面。

image

图 2-8:命令行界面

要执行命令,请在输入框 ➊ 中输入命令并按 ENTER 键。您将在中间的列表 ➋ 中看到会话级别的命令历史,底部标签会显示命令的返回值 ➌(如果有的话)。

尽管有许多可用的命令,但我发现其中大多数是无用的。我主要将此工具作为测试表达式是否按预期解析以及作为便捷计算器的工具,但也有一些其他使用场景值得一提。我已在表 2-6 中描述了这些用法。

表 2-6: 命令行插件命令

命令 功能
BC 标识符 移除 标识符 上的任何断点,标识符 可以是代码地址或 API 函数名称。
BP 标识符 [,条件] 标识符 上设置调试器断点,标识符 可以是代码地址或 API 函数名称。当 标识符 为 API 函数名时,断点将设置在函数入口点。条件 参数是一个可选表达式,如果存在,它将作为断点条件。
BPX label 在当前反汇编模块中,每次出现label时都会设置一个调试断点。这个label通常是 API 函数名。
CALC expression ? expression 评估expression并显示结果。
HD address 移除address上任何已存在的硬件断点。
HE address address上放置硬件执行断点。
HR address address上放置硬件访问断点。每次只能存在四个硬件断点。
HW address address上放置硬件写入断点。
MD 移除任何现有的内存断点(如果存在)。
MR address1, address2 address1开始并延伸至address2的内存访问上设置断点。将替换任何现有的内存断点。
MW address1, address2 address1开始并延伸至address2的内存写入上设置断点。将替换任何现有的内存断点。
WATCH expression W expression 打开观察窗口并将expression添加到观察列表中。此列表中的表达式将在每次进程接收到消息时重新评估,并且评估结果将显示在旁边。

命令行插件是由 OllyDbg 开发者制作的,应已预装在 OllyDbg 中。

使用 OllyFlow 可视化控制流

OllyFlow 可以在 OpenRCE 插件目录中找到,是一个纯粹的可视化插件,可以生成像图 2-9 中的代码图表,并使用 Wingraph32 显示它们。

image

图 2-9:OllyFlow 函数流程图

注意

Wingraph32 并不随 OllyFlow 一起提供,但可以在 IDA 的免费版本中获取,下载链接如下: www.hex-rays.com/products/ida/。下载后,将.exe文件放入 OllyDbg 安装文件夹中。

虽然这些图表不是交互式的,但它们能帮助你轻松识别游戏代码中的结构,例如循环和嵌套的if()语句,这在控制流分析中至关重要。安装 OllyFlow 后,你可以通过转到插件 ▸ OllyFlow(或者右键点击反汇编面板,展开 OllyFlow 图表子菜单)并选择以下选项之一来生成图表:

生成函数流程图 生成当前作用域中函数的图表,拆分不同的代码块并显示跳转路径。图 2-9 展示了一个函数流程图。毫无疑问,这是 OllyFlow 最有用的功能。

从图表生成交叉引用 生成一个图表,显示当前作用域中的函数所调用的所有函数。

生成图表交叉引用 生成一个图表,显示所有调用当前作用域中的函数的函数。

生成调用栈图表 生成从进程入口点到当前作用域中的函数的假定调用路径图表。

生成模块图 理论上生成整个模块中所有函数调用的完整图,但实际上很少能成功工作。

为了了解 OllyFlow 的实用性,可以查看 图 2-9 中的图形,并将其与生成该图形的相对简单的汇编函数进行比较:

   76f86878:
➊     MOV EAX,DWORD PTR DS:[76FE7E54]
       TEST AL,1
       JE ntdll.76F8689B
   76f86881:
➋     MOV EAX,DWORD PTR FS:[18]
       MOV EAX,DWORD PTR DS:[EAX+30]
       OR DWORD PTR DS:[EAX+68],2000000
       MOV EAX,DWORD PTR DS:[76FE66E0]
       OR DWORD PTR DS:[EAX],1
       JMP ntdll.76F868B2
   76f8689b:
➌     TEST EAX,8000
       JE ntdll.76F868B2
   76f868a2:
➍     MOV EAX,DWORD PTR FS:[18]
       MOV EAX,DWORD PTR DS:[EAX+30]
       OR DWORD PTR DS:[EAX+68],2000000
   76f868b2:
➎     MOV AL,1
       RETN

图 2-9 中有五个框,它们分别映射到这个函数的五个部分。该函数从 ➊ 开始,如果分支失败,则跳转到 ➋;如果成功,则跳转到 ➌。执行完 ➋ 后,直接跳转到 ➎,然后返回函数。执行完 ➌ 后,可能会继续执行 ➍ 或跳转到 ➎ 直接返回。执行完 ➍ 后,无条件地跳转到 ➎。理解 OllyFlow 的重点不在于了解这个函数的作用;现在只需要关注代码如何映射到图形上。

补丁处理 IF() 语句

如果你认为自己已经准备好用 OllyDbg 进行实际操作,那就继续阅读。访问 www.nostarch.com/gamehacking/,下载本书的资源文件,获取 BasicDebugging.exe,并执行它。乍一看,你会发现它看起来像经典游戏 Pong。在这个版本的 Pong 中,球在你对手的屏幕上时是不可见的。你的任务是禁用这个功能,让你始终能看到球。为了让你更轻松,我已使游戏变得自动化。你不需要玩,只需要进行黑客操作。

首先,将 OllyDbg 附加到游戏上。然后将 CPU 窗口聚焦于主模块(在模块列表中找到 .exe 并双击它),使用引用的文本字符串功能定位隐藏球时显示的字符串。接下来,双击该字符串,将其带到代码中并分析周围的代码,直到找到决定是否隐藏球的if()语句。最后,使用代码补丁功能,修改if()语句,使球始终被绘制。作为额外奖励,您可以尝试使用 OllyFlow 来绘制这个函数的图形,以便更好地理解它到底在做什么。(提示:if()语句检查球的 x 坐标是否小于 0x140。如果是,它跳转到绘制球的代码。如果不是,它绘制没有球的场景。如果您将 0x140 改为例如 0xFFFF,球就永远不会被隐藏。)

结语

OllyDbg 比 Cheat Engine 要复杂得多,但你会通过使用它学得最好,所以大胆尝试,亲自动手吧!你可以通过将本章中讲解的控制与调试技巧结合起来,开始在一些真实游戏中实践。如果你还没有准备好动手修改虚拟命运,那么可以尝试解决“修改 if() 语句”中的示例,作为练习环境。当你完成后,继续阅读第三章,在这里我将向你介绍两个在游戏破解侦查中非常有价值的工具——Process Monitor 和 Process Explorer。

第三章:3

使用 Process Monitor 和 Process Explorer 进行侦察

image

Cheat Engine 和 OllyDbg 可以帮助你拆解游戏的内存和代码,但你还需要了解游戏如何与文件、注册表项、网络连接和其他进程交互。为了了解这些交互方式,你必须使用两种工具,它们擅长监控进程的外部操作:Process Monitor 和 Process Explorer。借助这些工具,你可以追踪完整的游戏映射,找到保存文件,识别用于存储设置的注册表键,并列出远程游戏服务器的互联网协议(IP)地址。

在本章中,我将教你如何使用 Process Monitor 和 Process Explorer 来记录系统事件并检查它们,以了解游戏是如何与系统交互的。这些工具主要用于初步侦察,能够清晰且详细地展示游戏如何与系统交互。你可以从 Windows Sysinternals 网站下载这两个程序(* technet.microsoft.com/en-us/sysinternals/*)。

Process Monitor

你可以通过探索游戏如何与注册表、文件系统和网络交互来了解很多游戏的内容。Process Monitor 是一个强大的系统监控工具,能够实时记录这些事件,并让你将数据无缝集成到调试会话中。该工具提供大量关于游戏如何与外部环境交互的有用数据。通过你的细致分析(有时也依靠直觉),这些数据可以揭示关于数据文件、网络连接和注册表事件的细节,这些对你理解并操控游戏功能大有帮助。

在这一节中,我将向你展示如何使用 Process Monitor 来记录数据,浏览这些数据,并根据经验判断游戏与哪些文件发生了交互。在这个界面介绍之后,你将有机会在“查找高分文件”中尝试使用 Process Monitor,位于第 55 页。

记录游戏内事件

Process Monitor 的日志可以保存各种潜在有用的信息,但它们最实际的用途是帮助你找出数据文件的存储位置,比如游戏中的物品定义。启动 Process Monitor 时,你首先看到的对话框是 Process Monitor 过滤器,如图 3-1 所示。

image

图 3-1:Process Monitor 过滤器对话框

这个对话框允许你根据事件所具备的多个动态属性来显示或抑制事件。要开始监控进程,选择进程名称YourGameFilename.exe包含,然后按添加应用确定。这会告诉 Process Monitor 显示由YourGameFilename.exe触发的事件。在正确设置过滤器后,你将进入如图 3-2 所示的主窗口。

image

图 3-2:进程监视器主窗口

要配置进程监视器日志区域中显示的列,右键单击标题并选择 选择列。有很多令人印象深刻的选项,但我建议选择七个。

时间 让你看到操作发生的时间。

进程名称 如果你正在监视多个进程,它是有用的,但对于通常用于游戏的单进程过滤器来说,禁用此选项可以节省宝贵的空间。

进程 ID 类似于进程名称,但它显示的是进程 ID 而不是名称。

操作 显示执行的操作;因此,此选项是必选的。

路径 显示操作目标的路径;这是必选的。

详细信息 只有在某些情况下才有用,但启用它不会造成任何问题。

结果 显示操作失败时的情况,例如加载文件时失败。

当你显示更多列时,日志可能会变得非常拥挤,但坚持使用这些选项应该能帮助保持输出简洁。

一旦监视器运行,并且你已定义了希望看到的列,你可以切换五个事件类过滤器(在图 3-2 中用黑色标出),进一步清理日志。事件类过滤器允许你根据事件类型选择在日志中显示哪些事件。 从左到右,这些过滤器如下:

注册表 显示所有注册表活动。进程创建时,注册表中会有很多白噪声,因为游戏很少使用注册表,而 Windows 库总是使用它。禁用此过滤器可以节省日志中的大量空间。

文件系统 显示所有文件系统活动。这是最重要的事件类过滤器,因为了解数据文件存储的位置及其访问方式对于编写有效的机器人程序至关重要。

网络 显示所有网络活动。网络事件的调用堆栈在查找游戏中的网络相关代码时非常有用。

进程和线程活动 显示所有进程和线程的操作。对于这些事件的调用堆栈可以让你深入了解游戏代码如何处理线程。

进程分析 定期显示每个正在运行的进程的内存和 CPU 使用情况;游戏黑客通常不会使用它。

如果类级别的事件过滤仍然不足以精确过滤掉日志中不需要的信息,可以右键单击特定事件进行事件级别的过滤选项。一旦配置了只记录需要的事件的过滤器,你就可以开始浏览日志。表 3-1 列出了控制日志行为的一些有用快捷键。

表 3-1: 进程监视器快捷键

快捷键 操作
CTRL-E 切换日志记录。
CTRL-A 切换日志的自动滚动。
CTRL-X 清除日志。
CTRL-L 显示过滤器对话框。
CTRL-H 显示高亮对话框。该对话框与过滤器对话框非常相似,但用于指示应该高亮显示哪些事件。
CTRL-F 显示搜索对话框。
CTRL-P 显示所选事件的事件属性对话框。

当你浏览日志时,你可以检查记录的操作,查看事件的详细信息。

检查进程监视器日志中的事件

进程监视器会记录每个事件的所有数据点,使你能够了解这些事件的更多信息,而不仅仅是它们作用的文件。仔细检查数据丰富的列,如结果和详细信息,可以揭示一些非常有趣的信息。

例如,我发现游戏有时会直接从文件中按元素读取数据结构。当日志中包含大量对同一文件的读取时,且每次读取的偏移量是连续的但长度不同时,这种行为尤为明显。考虑表 3-2 中的假设事件日志。

表 3-2: 示例事件日志

操作 路径 详细信息
创建文件 C:\file.dat 期望访问: 读取
读取文件 C:\file.dat 偏移量: 0 大小: 4
读取文件 C:\file.dat 偏移量: 4 大小: 2
读取文件 C:\file.dat 偏移量: 6 大小: 2
读取文件 C:\file.dat 偏移量: 8 大小: 4
读取文件 C:\file.dat 偏移量: 12 大小: 4
... ... ...继续读取 4 字节的数据块一段时间

该日志显示游戏正按部就班地从文件中读取结构,揭示了一些关于该结构的线索。例如,假设这些读取反映了以下数据文件:

struct myDataFile
{
    int header;        // 4 bytes (offset 0)
    short effectCount; // 2 bytes (offset 4)
    short itemCount;   // 2 bytes (offset 6)
    int* effects;
    int* items;
};

将日志与表 3-2 中的结构进行比较。首先,游戏读取了 4 个header字节。接着,它读取两个 2 字节的值:effectCountitemCount。然后,它创建了两个整数数组,effectsitems,它们的长度分别为effectCountitemCount。游戏随后从文件中读取数据,填充这些数组,读取了effectCount + itemCount次,每次 4 个字节。

注意

开发者绝对不应该使用这样的过程从文件中读取数据,但你会惊讶于这种情况发生的频率。幸运的是,对于你来说,像这样的天真行为反而让你的分析变得更加容易。

在这种情况下,事件日志可以识别文件中的小块信息。但请记住,虽然将读取操作与已知结构关联很容易,但从一个空白的事件日志反向推测一个未知结构则要困难得多。通常,游戏黑客会使用调试器来获取有关每个有趣事件的更多上下文信息,而进程监视器的数据可以无缝地集成到调试会话中,有效地将两种强大的逆向工程范式结合起来。

调试游戏以收集更多数据

让我们暂时跳出这个假设的文件读取场景,看看 Process Monitor 如何让你从事件日志转到调试。Process Monitor 会为每个事件存储完整的堆栈跟踪,显示导致事件触发的完整执行链。你可以在事件属性窗口的堆栈标签页中查看这些堆栈跟踪(双击事件或按 CTRL-P),如 图 3-3 所示。

image

图 3-3:Process Monitor 事件调用堆栈

堆栈跟踪以一个表格的形式展示,首先是帧栏 ➊,显示执行模式和堆栈帧索引。该栏中的粉色 K 表示调用发生在内核模式,而蓝色 U 表示调用发生在用户模式。由于游戏黑客通常在用户模式下工作,因此内核模式操作通常没有意义。

模块栏 ➋ 显示了调用代码所在的可执行模块。每个模块仅仅是发出调用的二进制文件的名称;这使得我们可以轻松识别哪些调用实际上是从游戏二进制文件内部发出的。

位置栏 ➌ 显示了发出每个调用的函数名称,以及调用的偏移量。这些函数名是从模块的导出表中推导出来的,通常在游戏二进制文件中的函数不会显示名称。当没有函数名时,位置栏会显示模块名称以及调用的 偏移量(即调用在内存中距离模块基地址的字节数)。

注意

在代码上下文中,偏移量是指从某个项到其源地址之间的汇编代码字节数。

地址栏 ➍ 显示了调用的代码地址,这非常有用,因为你可以在 OllyDbg 反汇编器中跳转到该地址。最后,路径栏 ➎ 显示了发出调用的模块的路径。

在我看来,堆栈跟踪是 Process Monitor 中最强大的功能。它揭示了导致事件发生的完整上下文,在调试游戏时非常有用。你可以用它找到触发事件的确切代码,沿着调用链向上查看事件是如何发生的,甚至可以确定完成每个操作所使用的库。

Process Monitor 的姊妹应用程序 Process Explorer 在功能上与 Process Monitor 或 OllyDbg 相比并没有太多额外的功能。但它确实能更有效地展示其中的一些功能,使其在特定情况下成为理想的选择。

查找高分文件

如果你准备好测试你的 Process Monitor 技能,那么你来对地方了。打开GameHackingExamples/Chapter3_FindingFiles目录并执行FindingFiles.exe。你会看到这是一个 Pong 游戏,类似于在“修改 if() 语句”中的游戏,位于第 46 页。不过,与第二章不同的是,现在这个游戏实际上可以玩了。它还会显示你当前的分数和历史最高分数。

现在重启游戏,在第二次执行游戏之前启动 Process Monitor。通过过滤文件系统活动并创建你认为合适的其他过滤器,尝试找出游戏存储高分文件的位置。作为额外奖励,尝试修改此文件,使游戏显示最高可能的分数。

Process Explorer

Process Explorer 是一个高级任务管理器(它甚至有一个按钮可以让你将其设置为默认的任务管理器),当你开始了解一个游戏如何运行时,它非常方便。它提供关于正在运行的进程的复杂数据,如父子进程、CPU 使用率、内存使用率、加载的模块、打开的句柄和命令行参数,并且能够操作这些进程。它擅长显示高层次的信息,如进程树、内存消耗、文件访问和进程 ID,这些都非常有用。

当然,这些数据单独看没有特别大的用处。但通过敏锐的观察,你可以找到关联并得出一些有用的结论,了解游戏访问了哪些全局对象——包括文件、互斥体和共享内存段。此外,当这些数据与调试会话中收集的数据进行交叉引用时,它们的价值将更大。

本节介绍了 Process Explorer 界面,讨论了它显示的属性,并描述了你如何使用这个工具来操作句柄(对系统资源的引用)。在这个介绍之后,请使用“查找并关闭互斥体”在第 60 页来磨练你的技能。

Process Explorer 的用户界面和控制

当你打开 Process Explorer 时,你会看到一个分成三个不同部分的窗口,如图 3-4 所示。

image

图 3-4:Process Explorer 主窗口

这三个部分是工具栏 ➊、上窗格 ➋ 和下窗格 ➌。上窗格显示进程列表,采用树状结构显示它们的父子关系。不同的进程使用不同的颜色高亮显示;如果你不喜欢当前的颜色,点击 选项配置颜色,将弹出一个对话框,让你查看并更改颜色。

就像在进程监视器中一样,此表格的显示非常灵活,你可以通过右键点击表头并选择“选择列”来定制它。可能有超过 100 个自定义选项,但我发现默认设置加上“ASLR 启用”这一列就已经非常合适了。

注意

地址空间布局随机化(ASLR)是一项 Windows 安全功能,它将可执行映像分配到不可预测的位置,知道它是否启用对于你在内存中修改游戏状态值时至关重要。

下半部分窗格有三种可能的状态:隐藏、DLL 和句柄。隐藏选项会将窗格隐藏,DLL 选项显示当前进程中加载的动态链接库(DLL)列表,句柄选项显示进程所持有的句柄列表(见图 3-4)。你可以通过切换视图 ▸ 显示下半部分窗格来隐藏或显示整个下半部分窗格。当它可见时,你可以通过选择视图 ▸ 下半部分窗格视图 ▸ DLL 或 视图 ▸ 下半部分窗格视图 ▸ 句柄来更改信息显示。

你还可以使用快捷键在下半部分窗格模式之间快速切换,而不影响上半部分窗格中的进程。这些快捷键列在表 3-3 中。

表 3-3: 进程资源管理器快捷键

快捷键 操作
CTRL-F 在下半部分窗格数据集中搜索某个值。
CTRL-L 切换下半部分窗格的隐藏与显示。
CTRL-D 切换下半部分窗格显示 DLL。
CTRL-H 切换下半部分窗格显示句柄。
空格键 切换进程列表的自动刷新。
ENTER 显示选定进程的属性对话框。
DEL 终止选中的进程。
SHIFT-DEL 终止选中的进程及其所有子进程。

使用图形用户界面或快捷键来练习切换模式。当你熟悉主窗口后,我们将查看另一个重要的进程资源管理器对话框,称为属性。

检查进程属性

与进程监视器类似,进程资源管理器采用了一种非常动态的数据收集方式;最终结果是一个广泛且冗长的信息谱。事实上,如果你打开一个进程的属性对话框(见图 3-5),你会看到一个巨大的标签栏,包含 10 个标签。

默认选中的“图像”标签(见图 3-5)显示可执行文件的名称、版本、构建日期和完整路径。它还显示当前工作目录以及可执行文件的地址空间布局随机化(ASLR)状态。ASLR 状态是这里最重要的信息,因为它直接影响机器人如何从游戏中读取内存。我将在第六章中详细讨论这个问题。

image

图 3-5:进程资源管理器属性对话框

性能、性能图表、磁盘和网络、以及 GPU 图表选项卡显示了有关进程的 CPU、内存、磁盘、网络和 GPU 使用情况的各种指标。如果你创建了一个注入到游戏中的机器人,这些信息可以帮助你判断你的机器人对游戏性能的影响程度。

TCP/IP 选项卡显示了一个活动的 TCP 连接列表,你可以用它来找到游戏连接的任何服务器 IP 地址。如果你想测试连接速度、终止连接,或者研究游戏的网络协议,这些信息至关重要。

字符串选项卡显示了在进程的二进制文件或内存中找到的字符串列表。与 OllyDbg 中的字符串列表不同,OllyDbg 仅显示由汇编代码引用的字符串,而这个列表包括了任何包含三个或更多连续可读字符且后跟空字符的字符串。当游戏二进制文件更新时,你可以使用差异工具对比每个游戏版本中的字符串列表,查看是否有新的字符串值得你进一步研究。

线程选项卡显示了进程内运行的线程列表,并允许你暂停、恢复或终止每个线程;安全选项卡显示了进程的安全权限;环境选项卡显示了进程已知的或设置的任何环境变量。

注意

如果你打开一个.NET 进程的属性对话框,你会注意到两个额外的选项卡:.NET 程序集和.NET 性能。这些选项卡中的数据非常直观易懂。请记住,本书中的大多数技术不能应用于使用.NET 编写的游戏。

句柄操作选项

正如你所看到的,进程资源管理器可以为你提供关于进程的大量信息。然而,它不仅仅只有这些功能:它还可以操控进程的某些部分。例如,你可以在进程资源管理器的下方窗格中查看并操作打开的句柄(见图 3-4)。这本身就足以证明将进程资源管理器添加到你的工具箱中是有价值的。关闭一个句柄的操作很简单,只需右键点击它并选择“关闭句柄”。例如,当你需要关闭互斥锁时,这非常实用,而互斥锁的关闭对某些类型的黑客攻击至关重要。

注意

你可以右键点击下方窗格的标题并选择“选择列”来定制显示的内容。你可能会发现特别有用的一列是“句柄值”,当你在 OllyDbg 中看到一个句柄被传递时,它能帮助你了解该句柄的作用。

关闭互斥锁

游戏通常只允许同时运行一个客户端;这称为 单实例限制。你可以通过多种方式实现单实例限制,但使用系统互斥量是常见方法,因为互斥量是会话范围的,并且可以通过简单的名称访问。利用互斥量来限制实例非常简单,得益于 Process Explorer,去除这个限制同样简单,使你能够同时运行多个游戏实例。

首先,下面是一个游戏如何使用互斥量处理单实例限制的示例:

int main(int argc, char *argv[]) {
    // create the mutex
    HANDLE mutex = CreateMutex(NULL, FALSE, "onlyoneplease");
    if (GetLastError() == ERROR_ALREADY_EXISTS) {
        // the mutex already exists, so exit
        ErrorBox("An instance is already running.");
        return 0;
    }
    // the mutex didn't exist; it was just created, so
    // let the game run
    RunGame();
    // the game is over; close the mutex to free it up
    // for future instances
    if (mutex)
        CloseHandle(mutex);
    return 0;
}

这段示例代码创建了一个名为 onlyoneplease 的互斥量。接下来,函数检查 GetLastError() 看互斥量是否已存在,如果已存在,则关闭游戏。如果互斥量不存在,游戏会创建第一个实例,从而阻止未来的游戏客户端运行。在这个例子中,游戏正常运行,并在完成后调用 CloseHandle() 关闭互斥量,从而允许将来运行多个游戏实例。

你可以使用 Process Explorer 关闭限制实例的互斥量,并同时运行多个游戏实例。为此,选择下方窗格的 Handles 视图,查找所有类型为 Mutant 的句柄,确定哪个互斥量在限制游戏实例,并关闭该互斥量。

警告

互斥量也用于跨线程和进程同步数据。只有当你确定其唯一目的是你正在尝试颠覆的目的时,才可以关闭它!

多客户端黑客通常需求量大,因此能够快速为新兴游戏开发此类黑客程序对你作为该市场内的机器人开发者整体成功至关重要。由于互斥量是实现单实例限制的最常见方法之一,Process Explorer 是原型开发这类黑客程序的一个重要工具。

检查文件访问

与 Process Monitor 不同,Process Explorer 无法显示文件系统调用的列表。另一方面,Process Explorer 窗口下方的 Handles 视图可以显示游戏当前打开的所有文件句柄,准确揭示哪些文件正在被持续使用,而无需在 Process Monitor 中设置复杂的过滤条件。只需查找类型为 File 的句柄即可查看游戏当前正在使用的所有文件。

这个功能在你试图定位日志文件或存档文件时非常有用。此外,你还可以定位用于进程间通信(IPC)的命名管道;这些文件的前缀为 *\Device\NamedPipe*。看到这些管道通常是游戏正在与另一个进程通信的提示。

查找并关闭互斥量

为了将你的 Process Explorer 技能付诸实践,进入 GameHackingExamples/Chapter3_CloseMutex 目录并执行 CloseMutex.exe。这个游戏的玩法与 “查找高分文件” 中的内容完全相同,第 55 页也有类似的介绍,但它阻止你同时运行多个实例。正如你可能猜到的,它通过一个单实例限制互斥锁来实现这一点。在 Process Explorer 的下方窗格中使用 Handles 视图,找到负责这一限制的互斥锁并关闭它。如果你成功了,你将能够打开游戏的第二个实例。

结束语

要有效使用 Process Monitor 和 Process Explorer,首先需要对这些应用程序显示的数据以及它们用来展示数据的接口有深刻的了解。虽然本章的概述是一个很好的基础,但这些应用程序的复杂性只能通过实践来掌握,因此我鼓励你在自己的系统上尝试使用它们。

你不会经常使用这些工具,但在某些时候,它们会帮你大忙:当你在努力理解某段代码的工作原理时,你会回忆起在之前使用 Process Explorer 或 Process Monitor 时看到的一条不经意间注意到的信息。这就是为什么我认为它们是有用的侦察工具。

第二部分

游戏解析

第四章:4

从代码到内存:一个通用入门

image

在最低级别,游戏的代码、数据、输入和输出是不断变化的字节的复杂抽象。许多字节代表由编译器生成的变量或机器代码,该编译器是根据游戏的源代码生成的。有些代表图像、模型和声音。其他的则只存在一瞬间,由计算机硬件作为输入发布,并在游戏完成处理后销毁。剩余的字节则通知玩家游戏的内部状态。但人类无法用字节思考,因此计算机必须以我们能理解的方式将其翻译出来。

反过来,也存在一个巨大的断层。计算机实际上并不理解高级代码和直观的游戏内容,因此这些必须从抽象转换为字节。有些内容——如图像、声音和文本——是无损存储的,准备好在微秒级别向玩家呈现。另一方面,游戏的代码、逻辑和变量则被剥去所有人类可读性,并被编译成机器数据。

通过操控游戏数据,游戏黑客能够在游戏中获得人类难以实现的优势。然而,要做到这一点,他们必须理解开发者的代码在被编译和执行后如何表现。实质上,他们必须像计算机一样思考。

为了让你像计算机一样思考,本章将从教你数字、文本、简单结构和联合体在字节级别上的内存表示开始。然后,你将深入探索类实例是如何在内存中存储的,以及抽象实例如何在运行时知道该调用哪些虚拟函数。在本章的后半部分,你将参加一门 x86 汇编语言速成课程,内容包括语法、寄存器、操作数、调用栈、算术运算、分支操作、函数调用和调用约定。

本章非常注重一般技术细节。虽然没有很多直接与游戏黑客相关的“精彩”信息,但你在这里获得的知识将在接下来的章节中发挥核心作用,当我们讨论如何通过程序读取和写入内存、注入代码以及操控控制流等主题时。

由于 C++是游戏和机器人开发的事实标准,本章将解释 C++代码与代表它的内存之间的关系。大多数本地语言都有非常相似(有时是相同的)低级结构和行为,因此你应该能够将所学的知识应用到几乎任何软件中。

本章中的所有示例代码都在本书源文件的 GameHackingExamples/Chapter4_CodeToMemory 目录中。包含的项目可以用 Visual Studio 2010 编译,但也应该适用于任何其他 C++ 编译器。如果你想跟着做,可以从 www.nostarch.com/gamehacking/ 下载并编译它们。

变量和其他数据在内存中的表现

正确操作游戏的状态可能非常困难,找到控制它的数据并不像点击“下一次扫描”并希望 Cheat Engine 不会失败那样简单。实际上,许多黑客必须同时操作几十个相关的值。找到这些值及其关系通常需要你分析地识别结构和模式。此外,开发游戏黑客通常意味着在你的机器人的代码中重新创建原始结构。

为了完成这些任务,你需要深入了解变量和数据在游戏内存中的布局。通过示例代码、OllyDbg 内存转储和一些表格来将一切联系在一起,本节将教你如何了解不同类型的数据如何在内存中表现。

数值数据

大多数游戏黑客需要的值(比如玩家的血量、魔法值、位置和等级)都由数值数据类型表示。由于数值数据类型也是所有其他数据类型的构建块,理解它们是极其重要的。幸运的是,它们在内存中有相对直接的表示方式:它们是按预测方式对齐的,并且有固定的位宽。表 4-1 展示了你在 Windows 游戏中会遇到的五种主要数值数据类型,以及它们的大小和范围。

表 4-1: 数值数据类型

类型名称 大小 有符号范围 无符号范围
char, BYTE 8 位 -128 到 127 0 到 255
short, WORD, wchar_t 16 位 -32,768 到 -32,767 0 到 65535
int, long, DWORD 32 位 -2,147,483,648 到 2,147,483,647 0 到 4,294,967,295
long long 64 位 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 0 到 18,446,744,073,709,551,615
float 32 位 +/-1.1754910^(-38) 到 +/-3.4028210³⁸ 不适用

数值数据类型的大小在不同的架构甚至编译器之间可能会有所不同。由于本书聚焦于在 Windows 上破解 x86 游戏,因此我使用了 Microsoft 标准的类型名称和大小。除了 float 外,表 4-1 中的数据类型采用 小端序 存储,这意味着整数的最低有效字节存储在该整数占用的最低地址中。例如,图 4-1 显示 DWORD 0x0A0B0C0D 被字节 0x0D 0x0C 0x0B 0x0A 表示。

image

图 4-1:小端序排列示意图

float数据类型可以存储混合数字,因此它在内存中的表示不像其他数据类型那么简单。例如,如果你在内存中看到 0x0D 0x0C 0x0B 0x0A,并且这个值是float类型的,你不能简单地将其转换为 0x0A0B0C0D。相反,float值有三个组成部分:符号(位 0)、指数(位 1–8)和尾数(位 9–31)。

符号位决定数字是正数还是负数,指数决定小数点移动多少位(从尾数之前开始),尾数则存储该值的近似值。你可以通过计算表达式尾数 × 10^(n)(其中n是指数)来恢复存储的值,如果符号位被设置,则结果乘以-1。

现在让我们来看一下内存中的一些数字数据类型。清单 4-1 初始化了九个变量。

unsigned char ubyteValue = 0xFF;
char byteValue = 0xFE;
unsigned short uwordValue = 0x4142;
short wordValue = 0x4344;
unsigned int udwordValue = 0xDEADBEEF;
int dwordValue = 0xDEADBEEF;
unsigned long long ulongLongValue = 0xEFCDAB8967452301;
long long longLongValue = 0xEFCDAB8967452301;
float floatValue = 1337.7331;

清单 4-1:在 C++中创建数字数据类型的变量

从顶部开始,这个示例包括了charshortintlong longfloat类型的变量。其中四个是无符号的,五个是有符号的。(在 C++中,float不能是无符号的。)考虑到你到目前为止学到的内容,请仔细研究清单 4-1 中的代码和图 4-2 中的内存转储之间的关系。假设这些变量在全局作用域中声明。

image

图 4-2:OllyDbg 的数字数据内存转储

你可能会注意到一些值看起来被随机分隔开。由于处理器访问存储在地址大小倍数位置的值更快(在 x86 中是 32 位),因此编译器会用零来填充这些值,以便将它们对齐到这样的地址上——因此,填充也被称为对齐。单字节值不会进行填充,因为访问它们的操作无论对齐如何都执行相同。

请记住这一点,来看一下表 4-2,它提供了图 4-2 中的内存转储和清单 4-1 中声明的变量之间的内存与代码的映射。

表 4-2: 清单 4-1 和图 4-2 的内存与代码的映射

地址 大小 数据 对象
0x00BB3018 1 字节 0xFF ubyteValue
0x00BB3019 1 字节 0xFE byteValue
0x00BB301A 2 字节 0x00 0x00 uwordValue之前的填充
0x00BB301C 2 字节 0x42 0x41 uwordValue
0x00BB301E 2 字节 0x00 0x00 wordValue之前的填充
0x00BB3020 2 字节 0x44 0x43 wordValue
0x00BB3022 2 字节 0x00 0x00 udwordValue之前的填充
0x00BB3024 4 字节 0xEF 0xBE 0xAD 0xDE udwordValue
0x00BB3028 4 字节 0xEF 0xBE 0xAD 0xDE dwordValue
0x00BB302C 4 字节 0x76 0x37 0xA7 0x44 floatValue
0x00BB3030 8 字节 0x01 0x23 0x45 0x67 0x89 0xAB 0xCD 0xEF ulongLongValue
0x00BB3038 8 字节 0x01 0x23 0x45 0x67 0x89 0xAB 0xCD 0xEF LongLongValue

地址列列出了内存中的位置,数据列告诉你存储在这些位置的内容。对象列告诉你每条数据与清单 4-1 中哪个变量相关。注意,尽管 floatValue 在清单 4-1 中是最后声明的变量,但它被放置在 ulongLongValue 之前。这是因为这些变量是在全局范围内声明的,编译器可以将它们放置在任何位置。这个特定的排列很可能是由于对齐或优化的结果。

字符串数据

大多数开发者将字符串一词与文本等同起来,但文本只是字符串最常见的应用。低层次上,字符串只是一些看起来线性且未对齐的内存中任意数值对象的数组。清单 4-2 展示了四个文本字符串声明。

// char will be 1 byte per character
char* thinStringP = "my_thin_terminated_value_pointer";
char thinStringA[40] = "my_thin_terminated_value_array";

// wchar_t will be 2 bytes per character
wchar_t* wideStringP = L"my_wide_terminated_value_pointer";
wchar_t wideStringA[40] = L"my_wide_terminated_value_array";

清单 4-2:在 C++ 中声明多个字符串

在文本的上下文中,字符串包含字符对象(char 用于 8 位编码,wchar_t 用于 16 位编码),每个字符串的结束由空终止符指定,该字符等于 0x0。让我们看看这些变量存储的内存,如图 4-3 中的两个内存转储所示。

image

图 4-3:在这个 OllyDbg 字符串数据内存转储中,ASCII 列中的人类可读文本是我们在清单 4-2 中存储的文本。

如果你不习惯阅读内存,OllyDbg 转储可能在这个时候会有点难以跟随。表 4-3 展示了清单 4-2 中的代码与图 4-3 中的内存之间的更深层次的关联。

表 4-3: 清单 4-2 和 图 4-3 中的内存与代码映射

地址 大小 数据 对象
面板 1
0x012420F8 32 字节 0x6D 0x79 0x5F {...} 0x74 0x65 0x72 thinStringP字符
0x01242118 4 字节 0x00 0x00 0x00 0x00 thinStringP 终结符和填充
0x0124211C 4 字节 0x00 0x00 0x00 0x00 无关数据
0x01242120 64 字节 0x6D 0x00 0x79 {...} 0x00 0x72 0x00 wideStringP字符
0x01242160 4 字节 0x00 0x00 0x00 0x00 wideStringP 终结符和填充
无关数据
面板 2
0x01243040 4 字节 0xF8 0x20 0x24 0x01 指向 thinStringP 的指针,位于 0x012420F8
0x01243044 30 字节 0x6D 0x79 0x5F {...} 0x72 0x61 0x79 thinStringA字符
0x01243062 10 字节 0x00 重复 10 次 thinStringA 终结符和数组填充
0x0124306C 4 字节 0x20 0x21 0x24 0x01 指向 wideStringP 的指针,位于 0x01242120
0x01243070 60 字节 0x6D 0x00 0x79 {...} 0x00 0x79 0x00 wideStringA字符
0x012430AC 20 字节 重复 10 次的 0x00 wideStringA 终止符和数组填充

在图 4-3 中,面板 1 显示了存储在thinStringP(地址 0x01243040)和wideStringP(地址 0x0124306C)所在内存位置的数据仅为 4 字节长,并且不包含任何字符串数据。这是因为这些变量实际上是指向各自数组第一个字符的指针。例如,thinStringP包含 0x012420F8,在图 4-3 中的面板 2 里,你可以看到位于地址 0x012420F8 的字符串 "my_thin_terminated_value_pointer"

查看面板 1 中这些指针之间的数据,你可以看到由thinStringAwideStringA存储的文本。此外,注意到thinStringAwideStringA在它们的空字符终止符之后被填充了空间;这是因为这些变量被声明为长度为40的数组,因此它们被填充到 40 个字符。

数据结构

与我们之前讨论的数据类型不同,结构体是包含多个简单相关数据的容器。懂得如何识别内存中结构体的游戏黑客可以在他们自己的代码中模拟这些结构体。这可以大大减少他们必须查找的地址数量,因为他们只需要找到结构体起始地址,而不是每个单独项的地址。

注意

本节讨论了作为简单容器的结构体,它们没有成员函数,仅包含简单数据。超出这些限制的对象将在“类和虚函数表”中讨论,详见第 74 页。

结构体元素的顺序和对齐

由于结构体仅仅是多个对象的集合,它们在内存转储中不会直接显示。相反,结构体的内存转储会显示其中包含的对象。这个转储看起来与我在本章中展示的其他转储非常相似,但在顺序和对齐上有重要的区别。

为了查看这些差异,首先请查看示例 4-3。

struct MyStruct {
    unsigned char ubyteValue;
    char byteValue;
    unsigned short uwordValue;
    short wordValue;
    unsigned int udwordValue;
    int dwordValue;
    unsigned long long ulongLongValue;
    long long longLongValue;
    float floatValue;
};
MyStruct& m = 0;
printf("Offsets: %d,%d,%d,%d,%d,%d,%d,%d,%d\n",
        &m->ubyteValue, &m->byteValue,
        &m->uwordValue, &m->wordValue,
        &m->udwordValue, &m->dwordValue,
        &m->ulongLongValue, &m->longLongValue,
        &m->floatValue);

示例 4-3:一个 C++ 结构体及其使用的代码

这段代码声明了一个名为MyStruct的结构体,并创建了一个名为m的变量,假设它指向地址 0 上的该结构体实例。实际上地址 0 上并没有结构体实例,但这个技巧让我可以在printf()调用中使用取地址符号(&)来获取结构体每个成员的地址。由于结构体位于地址 0,因此打印的每个成员的地址相当于它相对于结构体起始位置的偏移量。

这个示例的最终目的是精确查看每个成员在内存中的布局,相对于结构体的起始位置。如果你运行这段代码,你会看到以下输出:

Offsets: 0,1,2,4,8,12,16,24,32

如你所见,MyStruct 中的变量是按照代码中定义的顺序排列的。这种顺序排列是结构体的强制属性。与示例 4-1 中的例子对比,我们声明了一组相同的变量;在图 4-2 中的内存转储里,编译器明显将一些值放置到了内存中的不正确顺序。

此外,你可能已经注意到,成员的对齐方式与示例 4-1 中的全局作用域变量不同;例如,如果它们对齐的话,在 uwordValue 前面应该有 2 个填充字节。这是因为结构体成员会按照能被结构体成员对齐(这是一个编译器选项,接受 1、2、4、8 或 16 字节;在此示例中设置为 4)或成员的大小——取较小者——的地址进行对齐。我安排了 MyStruct 的成员,使得编译器无需填充这些值。

然而,如果我们在 ulongLongValue 后立即放一个 charprintf() 调用将会输出以下结果:

Offsets: 0,1,2,4,8,12,16,28,36

现在,一起看看原始输出和修改后的输出:

Original: Offsets: 0,1,2,4,8,12,16,24,32
Modified: Offsets: 0,1,2,4,8,12,16,28,36

在修改后的版本中,最后两个值,即 longLongValuefloatValue 从结构体起始位置的偏移量发生了变化。由于结构体成员对齐,变量 longLongValue 移动了 4 字节(1 字节用于 char 值,后面跟着 3 字节)以确保它被放置在一个能被 4 整除的地址上。

结构如何工作

理解结构体——它们是如何对齐的以及如何模拟它们——非常有用。例如,如果你在自己的代码中复制了一个游戏的结构体,你可以在一次操作中从内存中读取或写入整个结构体。考虑一个游戏,声明玩家当前的生命值和最大生命值,如下所示:

struct {
    int current;
    int max;
} vital;
vital health;

如果一个经验不足的游戏黑客想要从内存中读取这些信息,他们可能会写如下代码来获取生命值:

int currentHealth = readIntegerFromMemory(currentHealthAddress);
int maxHealth =  readIntegerFromMemory(maxHealthAddress);

这个游戏黑客没有意识到,看到这些值在内存中紧挨着彼此可能不仅仅是一个幸运的偶然现象,所以他们使用了两个独立的变量。但如果你带着对结构体的理解来分析,你可能会得出结论:既然这些值密切相关并且在内存中相邻,那么我们的黑客本可以使用一个结构体来代替:

   struct {
       int current;
       int max;
   } _vital;
➊ _vital health = readTypeFromMemory<_vital>(healthStructureAddress);

由于这段代码假设正在使用结构体并正确模拟了它,它只需一行代码就能获取健康值和最大健康值 ➊。我们将在第六章中深入探讨如何编写自己的代码来读取内存。

联合体

与封装多个相关数据的结构体不同,联合体包含通过多个变量暴露的单一数据。联合体遵循三条规则:

• 联合体在内存中的大小等于其最大成员的大小。

• 联合体的成员都引用相同的内存。

• 联合体继承其最大成员的对齐方式。

以下代码中的printf()调用有助于说明前两个规则:

union {
    BYTE byteValue;
    struct {
        WORD first;
        WORD second;
    } words;
    DWORD value;
} dwValue;
dwValue.value = 0xDEADBEEF;
printf("Size %d\nAddresses 0x%x,0x%x\nValues 0x%x,0x%x\n",
    sizeof(dwValue), &dwValue.value, &dwValue.words,
    dwValue.words.first, dwValue.words.second);

这个printf()调用输出以下内容:

Size 4
Addresses 0x2efda8,0x2efda8 
Values 0xbeef,0xdead

第一个规则通过Size值来说明,该值首先被打印。尽管dwValue有三个成员,占用了 9 个字节,但它的大小仅为 4 个字节。这个大小结果也验证了第二个规则,因为dwValue.valuedwValue.words都指向地址0x2efda8,这在Addresses后面打印的值中得到了显示。第二个规则也得到了验证,因为dwValue.words.firstdwValue.words.second分别包含0xbeef0xdead,这些值在Values后面打印出来,考虑到dwValue.value0xdeadbeef,这就有意义了。第三个规则在这个示例中没有展示,因为我们没有足够的内存上下文,但如果你将这个联合体放入一个结构体中,并围绕它放置任何你喜欢的类型,它实际上总是会像DWORD那样对齐。

类与 VF 表

类似于结构体,是用于容纳和隔离多个数据项的容器,但类还可以包含函数定义。

一个简单的类

带有普通函数的类,如清单 4-4 中的bar,遵循与结构体相同的内存布局。

class bar {
public:
    bar() : bar1(0x898989), bar2(0x10203040) {}
    void myfunction() { bar1++; }
    int bar1, bar2;
};

bar _bar = bar();
printf("Size %d; Address 0x%x : _bar\n", sizeof(_bar), &_bar);

清单 4-4:一个 C++类

清单 4-4 中的printf()调用将输出以下内容:

Size 8; Address 0x2efd80 : _bar

即使bar有两个成员函数,输出仍然显示它仅占用 8 个字节来保存bar1bar2。这是因为bar类不包含这些成员函数的抽象,程序可以直接调用它们。

注意

publicprivateprotected等访问级别在内存中不会显现。无论这些修饰符如何,类的成员仍然按其定义顺序排列。

一个带有虚拟函数的类

在包含抽象函数(通常称为虚拟函数)的类中,程序必须知道调用哪个函数。请参考清单 4-5 中的类定义:

class foo {
public:
foo() : myValue1(0xDEADBEEF), myValue2(0xBABABABA) {}
    int myValue1;
    static int myStaticValue;
    virtual void bar() { printf("call foo::bar()\n"); }
    virtual void baz() { printf("call foo::baz()\n"); }
    virtual void barbaz() {}
    int myValue2;
};

int foo::myStaticValue = 0x12121212;

class fooa : public foo {
public:
    fooa() : foo() {}
    virtual void bar() { printf("call fooa::bar()\n"); }
    virtual void baz() { printf("call fooa::baz()\n"); }
};

class foob : public foo {
public:
    foob() : foo() {}
    virtual void bar() { printf("call foob::bar()\n"); }
    virtual void baz() { printf("call foob::baz()\n"); }
};

清单 4-5:foo、fooa 和 foob 类

foo类有三个虚拟函数:barbazbarbazfooafoob类继承自foo类并重载了barbaz。由于fooafoob有一个公开的基类foo,因此一个foo指针可以指向它们,但程序仍然必须调用正确版本的barbaz。你可以通过执行以下代码来查看这一点:

foo* _testfoo = (foo*)new fooa();
_testfoo->bar(); // calls fooa::bar()

下面是输出:

call fooa::bar()

输出结果显示,尽管_testfoo是一个foo指针,_testfoo->bar()调用了fooa::bar()。程序知道调用哪个版本的函数,因为编译器在_testfoo的内存中包含了一个VF(虚拟函数)表。VF 表是函数地址的数组,抽象类实例使用它来告诉程序它们的重载函数的位置。

类实例与虚拟函数表

为了理解类实例与 VF 表之间的关系,我们来检查一下在此清单中声明的三个对象的内存转储:

foo _foo = foo();
fooa _fooa = fooa();
foob _foob = foob();

这些对象是清单 4-5 中定义的类型。您可以在图 4-4 中看到它们的内存布局。

图片

图 4-4:OllyDbg 的类数据内存转储

面板 1 显示每个类实例像结构体一样存储其成员,但它们之前会有一个 DWORD 值,指向该类实例的 VF 表。面板 2 显示了我们三个类实例的 VF 表。表 4-4 中的内存到代码交叉映射展示了这些面板和代码是如何关联的。

表 4-4: 清单 4-5 和 图 4-4 的内存到代码交叉映射

地址 大小 数据 对象
面板 1
0x0018FF20 4 字节 0x004022B0 _foo 的开始及指向 foo VF 表的指针
0x0018FF24 8 字节 0xDEADBEEF 0xBABABABA _foo.myValue1_foo.myValue2
0x0018FF2C 4 字节 0x004022C0 _fooa 的开始及指向 fooa VF 表的指针
0x0018FF30 8 字节 0xDEADBEEF 0xBABABABA _fooa.myValue1_fooa.myValue2
0x0018FF38 4 字节 0x004022D0 _foob 的开始及指向 foob VF 表的指针
0x0018FF3C 8 字节 0xDEADBEEF 0xBABABABA _foob.myValue1_foob.myValue2
无关数据
面板 2
0x004022B0 4 字节 0x00401060 foo VF 表的开始;foo::bar 的地址
0x004022B4 4 字节 0x00401080 foo::baz 的地址
0x004022B8 4 字节 0x004010A0 foo::barbaz 的地址
0x004022BC 4 字节 0x0040243C 无关数据
0x004022C0 4 字节 0x004010D0 fooa VF 表的开始;fooa::bar 的地址
0x004022C4 4 字节 0x004010F0 fooa::baz 的地址
0x004022C8 4 字节 0x004010A0 foo::barbaz 的地址
0x004022CC 4 字节 0x004023F0 无关数据
0x004022D0 4 字节 0x00401130 foob VF 表的开始;foob::bar 的地址
0x004022D4 4 字节 0x00401150 foob::baz 的地址
0x004022D8 4 字节 0x004010A0 foo::barbaz 的地址

这个交叉表显示了清单 4-5 中的代码如何在内存中布局 VF 表。每个 VF 表都是在生成二进制文件时由编译器生成的,这些表是常量。为了节省空间,相同类的实例都会指向相同的 VF 表,这也是 VF 表没有与类内联的原因。

由于我们有三个 VF 表,您可能会想知道类实例如何知道使用哪个 VF 表。编译器会在每个虚拟类构造函数中插入类似以下的汇编代码:

MOV DWORD PTR DS:[EAX], VFADDR

这个示例获取了 VF 表的静态地址(VFADDR),并将其作为类的第一个成员放置在内存中。

现在查看表 4-4 中的地址 0x004022B0、0x004022C0 和 0x004022D0。这些地址包含了foofooafoob VF 表的起始位置。请注意,foo::barbaz存在于这三个 VF 表中;这是因为该函数没有被任何子类重载,这意味着每个子类的实例都将直接调用原始实现。

请注意,foo::myStaticValue在这个交叉表中并没有出现。由于该值是静态的,它实际上不需要作为foo类的一部分存在;它只是在这个类内部放置,以便更好地组织代码。实际上,它像一个全局变量一样被处理,并且被放置在其他地方。

VF 表和作弊引擎

还记得作弊引擎的“指针结构的第一个元素必须指向模块选项,以便从图 1-4 上的第 14 页进行指针扫描”吗?现在你已经读了一些关于 VF 表的内容,这些知识应该能帮助你理解这个选项是如何工作的:它使作弊引擎忽略所有堆块,其中第一个成员不是指向有效 VF 表的指针。它加快了扫描速度,但仅在指针路径中的每一步都是抽象类实例的情况下有效。

内存之旅到此为止,但如果将来你在识别一段数据时遇到困难,欢迎回来参考这一节。接下来,我们将探讨计算机是如何理解游戏的高级源代码的。

x86 汇编速成课程

当一个程序的源代码被编译成二进制时,它会被剥离掉所有不必要的艺术品,并转化为机器码。这个机器码只由字节组成(命令字节叫做操作码,但也有表示操作数的字节),它直接传递给处理器,告诉它如何精确地执行操作。那些 1 和 0 翻转晶体管以控制计算,它们可能非常难以理解。为了让与计算机的交流稍微变得容易一些,处理这类代码的工程师使用汇编语言,它是一种简化的语言,通过缩写名称(称为助记符)和简单的语法,来表示原始机器操作码。

汇编语言对于游戏黑客来说非常重要,因为许多强大的黑客技术只能通过直接操控游戏的汇编代码实现,例如使用 NOP 或钩子等方法。在本节中,你将学习* x86 汇编语言*的基础知识,这是一种特定的汇编语言,专为与 32 位处理器交互而设计。汇编语言非常广泛,因此为了简洁,本节仅讲解对游戏黑客最有用的那一小部分汇编概念。^(1)

注意

在本节中,许多小段汇编代码包含了由分号(;)分隔的注释,用来更详细地描述每个指令。

命令语法

汇编语言用于描述机器码,因此其语法相当简单。虽然这种语法使得理解单个命令(也叫操作)变得非常容易,但它也使得理解复杂的代码块变得非常困难。即使是用高级语言编写的易读算法,在汇编中看起来也显得晦涩难懂。例如,以下伪代码片段:

if (EBX > EAX)
    ECX = EDX
else
    ECX = 0

它在 x86 汇编中看起来像列表 4-6。

    CMP EBX, EAX
    JG label1
    MOV ECX, 0
    JMP label2
label1:
    MOV ECX, EDX
label2:

列表 4-6:一些 x86 汇编命令

因此,理解汇编中的即使是最简单的功能也需要大量的练习。然而,理解单个命令是非常简单的,到本节结束时,你将知道如何解析我刚刚给出的命令。

指令

汇编命令的第一部分被称为指令。如果你把汇编命令类比为终端命令,那么指令就是要运行的程序。在机器码层面,指令通常是命令的第一个字节;^(2) 也有一些 2 字节的指令,其第一个字节是 0x0F。不管怎样,指令告诉处理器要做什么。在列表 4-6 中,CMPJGMOVJMP都是指令。

操作数语法

虽然一些指令是完整的命令,但绝大多数指令如果没有跟随操作数或参数,都是不完整的。列表 4-6 中的每个命令至少有一个操作数,如EBXEAXlabel1

汇编操作数有三种形式:

立即数 是一种内联声明的整数值(十六进制值后跟一个h)。

寄存器 是指处理器寄存器的名称。

内存偏移量 是一个表达式,放在方括号内,表示某个值的内存位置。该表达式可以是立即数或寄存器。或者,它可以是寄存器和立即数的和或差(类似于[REG+Ah][REG-10h])。

每条 x86 汇编指令可以有零到三个操作数,多个操作数用逗号分隔。在大多数情况下,需要两个操作数的指令有一个源操作数和一个目标操作数。这些操作数的顺序取决于汇编语法。例如,列表 4-7 展示了一组用 Intel 语法编写的伪命令,这种语法被 Windows(因此也被 Windows 游戏黑客)使用:

   MOV R1, 1          ; set R1 (register) to 1 (immediate)
➊ MOV R1, [BADF00Dh] ; set R1 to value at [BADFOODh] (memory offset)
   MOV R1, [R2+10h]   ; set R1 to value at [R2+10h] (memory offset)
   MOV R1, [R2-20h]   ; set R1 to value at [R2+20h] (memory offset)

列表 4-7:演示 Intel 语法

在 Intel 语法中,目标操作数首先出现,源操作数紧随其后,因此在➊处,R1是目标操作数,[BADFOODh]是源操作数。另一方面,像 GCC 这样的编译器(可以用于在 Windows 上编写机器人)使用一种被称为 AT&T 或 UNIX 语法的语法。这种语法的处理方式略有不同,如下例所示:

MOV $1, %R1         ; set R1 (register) to 1 (immediate)
MOV 0xBADF00D, %R1  ; set R1 to value at 0xBADFOOD (memory offset)
MOV 0x10(%R2), %R1  ; set R1 to value at 0x10(%R2) (memory offset)
MOV -0x20(%R2), %R1 ; set R1 to value at -0x20(%R2) (memory offset)

这段代码是清单 4-7 的 AT&T 版本。AT&T 语法不仅反转了操作数顺序,还要求操作数前缀,并且对于内存偏移量操作数有不同的格式。

汇编命令

一旦你理解了汇编指令及其操作数的格式,你就可以开始编写命令。以下代码展示了一个汇编函数,由一些非常基础的命令组成,实际上什么也不做。

 PUSH EBP     ; put EBP (register) on the stack
MOV EBP, ESP ; set EBP to value of ESP (register, top of stack)
PUSH -1      ; put -1 (immediate) on the stack
ADD ESP, 4   ; negate the 'PUSH -1' to put ESP back where it was (a PUSH
                 ; subtracts 4 from ESP, since it grows the stack)
MOV ESP, EBP ; set ESP to the value of EBP (they will be the same anyway,
                 ; since we have kept ESP in the same place)
POP EBP      ; set EBP to the value on top of the stack (it will be what
                 ; EBP started with, put on the stack by PUSH EBP)
XOR EAX, EAX ; exclusive-or EAX (register) with itself (same effect as
                 ; 'MOV EAX, 0' but much faster)
RETN         ; return from the function with a value of 0 (EAX typically
                 ; holds the return value)

前两行,一个PUSH命令和一个MOV命令,设置了堆栈帧。接下来的一行将-1 压入堆栈,这在通过ADD ESP, 4命令将堆栈恢复到原始位置时被撤销。之后,堆栈帧被移除,返回值(存储在EAX中)通过XOR指令被设置为0,然后函数返回。

你将在“调用栈”(第 86 页)和“函数调用”(第 94 页)中了解更多关于堆栈帧和函数的信息。现在,将注意力转向代码中的常量——即经常作为操作数使用的EBPESPEAX。这些值,连同其他值,被称为处理器寄存器,理解它们对于理解堆栈、函数调用以及汇编代码的其他低级方面至关重要。

处理器寄存器

与高级编程语言不同,汇编语言没有用户定义的变量名。相反,它通过引用内存地址来访问数据。然而,在进行大量计算时,处理器不断处理读取和写入 RAM 数据的开销可能会非常昂贵。为了减轻这种高昂的成本,x86 处理器提供了一小组临时变量,称为处理器寄存器,这些寄存器是处理器内部的小存储空间。由于访问这些寄存器的开销远小于访问 RAM,因此汇编语言使用它们来描述其内部状态,传递易失性数据并存储上下文相关的变量。

通用寄存器

当汇编代码需要存储或操作任意数据时,它使用一组称为通用寄存器的进程寄存器子集。这些寄存器专门用于存储特定于进程的数据,例如函数的局部变量。每个通用寄存器是 32 位的,因此可以视为一个DWORD变量。通用寄存器也经过优化以用于特定目的:

EAX,累加器 这个寄存器经过优化,专门用于数学运算。有些操作,比如乘法和除法,只能在 EAX 中进行。

EBX,基址寄存器 这个寄存器被任意用于额外存储。由于它的 16 位前身 BX 是唯一一个可以用于引用内存地址的寄存器,EBX 曾被用作对 RAM 的引用。然而,在 x86 汇编中,所有寄存器都可以作为地址引用,使得 EBX 失去了其真正的用途。

ECX,计数器 这个寄存器被优化用于充当循环中的计数变量(通常在高级代码中称为 i)。

EDX,数据寄存器 这个寄存器被优化为 EAX 的辅助寄存器。例如,在 64 位计算中,EAX 作为位 0–31,EDX 作为位 32–63。

这些寄存器还拥有一组 8 位和 16 位的子寄存器,您可以用它们来访问部分数据。可以把每个通用寄存器看作一个联合体,其中寄存器名称描述的是 32 位成员,而子寄存器则是允许访问寄存器中更小部分的备用成员。以下代码展示了 EAX 的联合体可能的样子:

union {
    DWORD EAX;
    WORD AX;
    struct {
        BYTE L;
        BYTE H;
    } A;
} EAX;

在这个示例中,AX 允许访问 EAX 的低位 WORD,而 AL 允许访问 AX 的低位 BYTEAH 则是高位 BYTE。每个通用寄存器都有这样的结构,我在 图 4-5 中列出了其他寄存器的子寄存器。

image

图 4-5:x86 寄存器和子寄存器

EAX、EBC、ECX 和 EDX 也有高位字,但是编译器几乎从不单独访问它们,因为它可以在需要仅存储字时直接使用低位字。

索引寄存器

x86 汇编还有四个索引寄存器,用于访问数据流、引用调用栈以及跟踪局部信息。与通用寄存器一样,索引寄存器是 32 位的,但索引寄存器有着更为严格的用途:

EDI,目标索引 这个寄存器用于索引目标为写操作的内存。如果代码中没有写操作,编译器可以在需要时将 EDI 用作任意存储。

ESI,源索引 这个寄存器用于索引目标为读取操作的内存。它也可以被任意使用。

ESP,栈指针 这个寄存器用于引用调用栈的顶部。所有栈操作都会直接访问这个寄存器。您必须在处理栈时使用 ESP,且它必须始终指向栈的顶部。

EBP,栈基指针 这个寄存器标记了栈帧的底部。函数使用它来引用它们的参数和局部变量。一些代码可能在编译时选择忽略这种行为,这种情况下 EBP 可以被任意使用。

与通用寄存器类似,每个索引寄存器都有一个 16 位的对应寄存器:DI、SI、SP 和 BP,分别对应。然而,索引寄存器没有 8 位的子寄存器。

为什么一些 x86 寄存器有子寄存器?

通用寄存器和索引寄存器都有 16 位对应版本,背后有一个历史原因。x86 架构基于 16 位架构,随后它扩展了寄存器 AX、BX、CX、DX、DI、SI、SP 和 BP。恰当的,扩展版本保留相同的名称,但在前面加上了 E,表示“扩展”。16 位版本保留是为了向后兼容。这也解释了为什么索引寄存器没有 8 位的抽象:它们旨在用作内存地址偏移量,并且没有实际需求去了解这些值的部分字节。

执行索引寄存器

执行索引寄存器,简称 EIP,有一个非常明确的目的:它指向当前由处理器执行的代码的地址。由于它控制执行流程,因此由处理器直接递增,并且不允许汇编代码直接修改。要修改 EIP,汇编代码必须通过间接操作来访问它,如 CALLJMPRETN

EFLAGS 寄存器

与高级语言不同,汇编语言没有像 ==>< 这样的二进制比较运算符。它使用 CMP 指令比较两个值,并将结果信息存储在 EFLAGS 寄存器中。然后,代码通过特殊的操作根据存储在 EFLAGS 中的值改变控制流。

虽然比较指令是唯一能够访问 EFLAGS 的用户模式操作,但它们仅使用该寄存器的状态位:0、2、4、6、7 和 11。位 8–10 作为控制标志,位 12–14 和 16–21 作为系统标志,其余位为处理器保留。表 4-5 显示了每个 EFLAGS 位的类型、名称和描述。

表 4-5: EFLAGS 位

位(s) 类型 名称 描述
0 状态 进位 如果前一条指令的最高有效位产生了进位或借位,则设置。
2 状态 奇偶校验 如果前一条指令的结果值的最低有效字节有偶数个位被设置,则设置。
4 状态 调整 与进位标志相同,但考虑最低有效的 4 位。
6 状态 如果前一条指令的结果值为 0,则设置。
7 状态 符号 如果前一条指令的结果值的符号位(最高有效位)被设置,则设置。
8 控制 陷阱 设置时,处理器在执行下一个操作后向操作系统内核发送一个中断。
9 控制 中断 如果未设置,系统将忽略可屏蔽中断。
10 控制 方向 设置时,ESI 和 EDI 在自动修改的操作中被递减。未设置时,它们将被递增。
11 状态 溢出 如果前一条指令发生了溢出,例如在对正数执行 ADD 操作后,结果为负数时,则设置。

EFLAGS 寄存器还包含一个系统位和一个保留位,但这些在用户模式汇编和游戏破解中无关紧要,因此我将它们从本表中省略。在调试游戏代码时,记得留意 EFLAGS。例如,如果你在一个 JE(相等时跳转)指令上设置断点,可以查看 EFLAGS 0 位,判断是否会跳转。

段寄存器

最后,汇编语言有一组 16 位寄存器,称为段寄存器。与其他寄存器不同,段寄存器不是用来存储数据的;它们用于定位数据。理论上,它们指向内存中的隔离段,使得不同类型的数据可以存储在完全独立的内存段中。这种分段的实现由操作系统来完成。这些是 x86 段寄存器及其预期用途:

CS,代码段 该寄存器指向存储应用程序代码的内存。

DS,数据段 该寄存器指向存储应用程序数据的内存。

ES、FS 和 GS,额外段 这些寄存器指向操作系统使用的任何专有内存段。

SS,堆栈段 该寄存器指向作为专用调用堆栈的内存。

在汇编代码中,段寄存器作为内存偏移操作数的前缀使用。当没有指定段寄存器时,默认使用 DS。这意味着PUSH [EBP]命令实际上与PUSH DS:[EBP]相同。但PUSH FS:[EBP]命令则不同:它从 FS 段读取内存,而不是 DS 段。

如果你仔细观察 Windows x86 的内存分段实现,可能会注意到这些段寄存器并没有按预期使用。要查看这个实际效果,你可以在 OllyDbg 附加到一个暂停的进程时,使用 OllyDbg 命令行插件运行以下命令:

? CALC (DS==SS && SS==GS && GS==ES)
? 1
? CALC DS-CS
? 8
? CALC FS-DS
; returns nonzero (and changes between threads)

该输出告诉我们三个不同的事实。首先,它显示了 Windows 只使用了三个段:FS、CS 和其他所有段。通过 DS、SS、GS 和 ES 相等的方式来证明这一点。出于同样的原因,该输出还显示 DS、SS、GS 和 ES 可以互换使用,因为它们指向相同的内存段。最后,由于 FS 根据线程变化,因此该输出显示它是线程依赖的。FS 是一个有趣的段寄存器,它指向特定线程的数据。在《绕过生产环境中的 ASLR》一章中的第 128 页,我们将探讨如何利用 FS 中的数据来绕过 ASLR——这是大多数机器人需要做的事情。

实际上,在为 Windows 编译器生成的汇编代码中,你只会看到使用了三个段:DS、FS 和 SS。有趣的是,尽管 CS 似乎显示了与 DS 的常量偏移量,但它在用户模式代码中没有实际用途。了解了这些之后,你可以进一步得出结论,Windows 实际上只使用了两个段:FS 和其他所有段。

这两个段实际上指向相同内存中的不同位置(虽然没有简单的方法来验证这一点,但它确实是事实),这表明 Windows 实际上根本不使用内存段。相反,它使用了一个平坦的内存模型,其中段寄存器几乎是无关紧要的。虽然所有段寄存器都指向相同的内存,只有 FS 和 CS 指向不同的位置,而 CS 并未被使用。

总结一下,当你在 Windows 中使用 x86 汇编时,关于段寄存器有三件事是你需要了解的。首先,DS、SS、GS 和 ES 是可以互换的,但为了清晰起见,应该使用 DS 来访问数据,使用 SS 来访问调用堆栈。第二,CS 可以安全地忽略。第三,FS 是唯一具有特殊用途的段寄存器;目前应该将其保持不变。

调用堆栈

寄存器非常强大,但不幸的是它们的数量非常有限。为了使汇编代码有效地存储所有的局部数据,它还必须使用调用堆栈。堆栈用于存储许多不同的值,包括函数参数、返回地址和一些局部变量。

理解调用堆栈的运作方式在反向工程游戏时会非常有用。此外,当我们进入第八章的控制流操作时,你将会严重依赖这一知识。

结构

你可以将调用堆栈看作一个FILO(先进后出)DWORD值列表,这些值可以被汇编代码直接访问和操作。之所以称之为堆栈,是因为这种结构类似于一堆纸:物体既可以被添加到堆栈的顶部,也可以从顶部移除。数据通过PUSH 操作数命令添加到堆栈,而通过POP 寄存器命令将其移除(并放入寄存器)。图 4-6 显示了这一过程的示例。

image

图 4-6:堆栈的结构

在 Windows 中,堆栈从较高的内存地址增长到较低的内存地址。它占据一块有限的内存区域,从地址 n(绝对底部)堆积到地址 0x00000000(绝对顶部)。这意味着 ESP(堆栈顶部的指针)随着项的添加而减少,随着项的移除而增加。

堆栈帧

当一个汇编函数使用栈存储数据时,它通过创建一个栈帧来引用这些数据。它通过将 ESP 存储在 EBP 中,然后从 ESP 中减去n字节,实际上打开了一个n字节的间隙,这个间隙被框定在 EBP 和 ESP 寄存器之间。为了更好地理解这一点,首先想象图 4-7 中的栈被传递给一个需要 0x0C 字节本地存储空间的函数。

image

图 4-7:初始示例栈(从下到上阅读)

在这个示例中,地址 0x0000 是栈的绝对顶部。从地址 0x0000 到 0xFF00 – 4 之间有未使用的内存,函数调用时,0xFF00 是栈的顶部。ESP 指向这个地址。0xFF00 之后的栈内存被调用链中之前的函数使用(从 0xFF04 到 0xFFFF)。当函数被调用时,它首先执行以下汇编代码,创建一个 0x0C(即 12 个十进制字节)的栈帧:

PUSH EBP      ; saves the bottom of the lower stack frame
MOV EBP, ESP  ; stores the bottom of the current stack frame, in EBP
                  ; (also 4 bytes above the lower stack frame)
SUB ESP, 0x0C ; subtracts 0x0C bytes from ESP, moving it up the stack
                  ; to mark the top of the stack frame

在这段代码执行之后,栈的状态更接近于图 4-8 所示的样子。创建完这个栈后,函数可以使用它在栈上分配的 0x0C 字节。

0x0000 仍然是栈的绝对顶部。从地址 0x0000 到 0xFF00 – 20 之间有未使用的栈内存,而地址 0xFF00 – 16 处的内存包含了本地存储的最后 4 个字节(由[EBP-Ch]引用)。这也是当前栈帧的顶部,因此 ESP 指向此处。0xFF00 – 12 包含本地存储的中间 4 个字节(由[EBP-8h]引用),0xFF00 – 8 包含本地存储的前 4 个字节(由[EBP-4h]引用)。EBP 指向 0xFF00 – 4,这是当前栈帧的底部;该地址保存了 EBP 的原始值。0xFF00 是较低栈帧的顶部,原始的 ESP 在图 4-7 中指向此处。最后,你仍然可以看到来自调用链中前一个函数的栈内存,从 0xFF04 到 0xFFFF。

image

图 4-8:设置好栈帧的示例栈(从下到上阅读)

在栈处于这种状态时,函数可以随意使用它的本地数据。如果这个函数调用了另一个函数,新函数将使用相同的技术构建它自己的栈帧(栈帧真的会一个个叠加)。然而,一旦一个函数完成了对栈帧的使用,它必须将栈恢复到先前的状态。在我们的例子中,这意味着栈要恢复到图 4-7 中的样子。当第二个函数完成时,第一个函数会通过以下两个命令清理栈:

MOV ESP, EBP  ; demolishes the stack frame, bringing ESP to 4 bytes above
                  ; its original value (0xFF00-4)
POP EBP       ; restores the bottom of the old stack frame that was saved by
                  ; 'PUSH EBP'. Also adds 4 bytes to ESP, putting it back at
                  ; its original value

但如果你想要修改传递给游戏中某个函数的参数,不要在该函数的栈帧中寻找它们。一个函数的参数存储在调用它的函数的栈帧中,并通过[EBP+8h][EBP+Ch]等进行引用。它们从[EBP+8h]开始,因为[EBP+4h]存储着函数的返回地址。(“函数调用”在第 94 页对这个主题进行了进一步的解释。)

注意

代码可以在禁用栈帧的情况下编译。当这种情况发生时,你会注意到函数不会以PUSH EBP开头,而是相对于 ESP 引用所有内容。然而,通常情况下,编译后的游戏代码是启用了栈帧的。

现在你已经掌握了汇编代码的基础知识,接下来让我们探讨一些在破解游戏时会用到的具体技巧。

游戏破解的关键 x86 指令

尽管汇编语言有数百条指令,许多经验丰富的游戏黑客只了解其中的一小部分,而这些指令是我在这里详细讲解的。这些指令通常包括所有用于修改数据、调用函数、比较值或在代码中跳转的指令。

数据修改

数据修改通常会经过多个汇编操作,但最终结果必须存储在内存或寄存器中,通常通过MOV指令来完成。MOV操作接受两个操作数:目标和源。表 4-6 展示了所有可能的MOV操作数组合以及你可以期待的结果。

表 4-6: MOV指令的操作数

指令语法 结果
MOV R1, R2 R2的值复制到R1
MOV R1, [R2] R2引用的内存中的值复制到R1
MOV R1, [R2+Ah] R2+0xA引用的内存中的值复制到R1
MOV R1, [DEADBEEFh] 将内存地址 0xDEADBEEF 中的值复制到R1
MOV R1, BADF00Dh 将值 0xBADF00D 复制到R1
MOV [R1], R2 R2的值复制到R1引用的内存中。
MOV [R1], BADF00Dh 将值 0xBADF00D 复制到R1引用的内存中。
MOV [R1+4h], R2 R2的值复制到R1+0x4引用的内存中。
MOV [R1+4h], BADF00Dh 将值 0xBADF00D 复制到R1+0x4引用的内存中。
MOV [DEADBEEFh], R1 R1的值复制到内存地址 0xDEADBEEF。
MOV [DEADBEEFh], BADF00Dh 将值 0xBADF00D 复制到内存地址 0xDEADBEEF。

MOV指令可以接受多种操作数组合,但并不是所有组合都被允许。首先,目标操作数不能是立即数;它必须是寄存器或内存地址,因为立即数无法被修改。其次,不能直接将一个内存地址的值复制到另一个内存地址。复制值需要两个独立的操作,如下所示:

MOV EAX, [EBP+10h]   ; copy memory from EBP+0x10 to EAX
MOV [DEADBEEFh], EAX ; MOV the copied memory to memory at 0xDEADBEEF

这些指令将 EBP+0x10 处存储的内容复制到 0xDEADBEEF 处的内存中。

算术

与许多高级语言一样,汇编语言也有两种算术类型:一元和二元。一元指令接受一个操作数,该操作数同时充当目标和源。这个操作数可以是一个寄存器或一个内存地址。表 4-7 显示了 x86 中常见的一元算术指令。

表 4-7: 一元算术指令

指令语法 结果
INC 操作数 将 1 加到操作数值上。
DEC 操作数 从操作数值中减去 1。
NOT 操作数 逻辑上取反操作数的值(翻转所有位)。
NEG 操作数 执行二进制补码取反(翻转所有位并加 1;实质上是乘以 -1)。

另一方面,二元指令(占据了大多数 x86 算术指令)在语法上与 MOV 指令类似。它们需要两个操作数,并且具有相似的操作数限制。然而,与 MOV 不同的是,它们的目标操作数有第二个作用:它也是计算中的左值。例如,汇编操作 ADD EAX,EBX 等同于 C++ 中的 EAX = EAX + EBXEAX += EBX。表 4-8 显示了常见的 x86 二元算术指令。

表 4-8: 二元算术指令

指令语法 功能 操作数说明
ADD 目标, 源 目标 += 源
SUB 目标, 源 目标 -= 源
AND 目标, 源 目标 &= 源
OR 目标, 源 目标 |= 源
XOR 目标, 源 目标 ^= 源
SHL 目标, 源 目标 = 目标 << 源 必须是 CL 或 8 位立即数。
SHR 目标, 源 目标 = 目标 >> 源 必须是 CL 或 8 位立即数。
IMUL 目标, 源 目标 *= 源 目标 必须是一个寄存器;源不能是立即数。

在这些算术指令中,IMUL 是特别的,因为你可以传递一个第三个操作数,作为立即数的形式。通过这个原型,目标操作数不再参与计算,计算改由剩余的操作数进行。例如,汇编命令 IMUL EAX,EBX,4h 等价于 C++ 中的 EAX = EBX * 0x4

你还可以向 IMUL 传递一个单一的操作数。^(3) 在这种情况下,操作数充当源,可以是一个内存地址或一个寄存器。根据源操作数的大小,该指令会使用 EAX 寄存器的不同部分作为输入和输出,如 表 4-9 所示。

表 4-9: 可用的 IMUL 寄存器操作数

源大小 输入 输出
8 位 AL 16 位,存储在 AH:AL(即 AX)中
16 位 AX 32 位,存储在 DX:AX 中(AX 中的第 0-15 位和 DX 中的第 16-31 位)
32 位 EAX 64 位,存储在 EDX:EAX 中(EAX 中的第 0-31 位和 EDX 中的第 32-64 位)

请注意,即使输入只有一个寄存器,每个输出也使用了两个寄存器。这是因为在乘法运算中,结果通常会大于输入。

让我们来看一个使用IMUL并带有单个 32 位操作数的计算示例:

IMUL [BADFOODh] ; 32-bit operand is at address 0xBADFOOD

该指令的行为类似于以下伪代码:

EDX:EAX = EAX * [BADFOODh]

同样,这里是一个使用IMUL并带有单个 16 位操作数的操作:

IMUL CX ; 16-bit operand is stored in CX

及其对应的伪代码:

DX:AX = AX * CX

最后,这是一个带有单个 8 位操作数的IMUL指令:

IMUL CL ; 8-bit operand is stored in CL

及其对应的伪代码:

AX = AL * CL

x86 汇编语言也有除法,通过IDIV指令实现。^(4) IDIV指令接受一个单一的源操作数,并遵循类似于IMUL的寄存器规则。如表 4-10 所示,IDIV操作需要两个输入和两个输出。

表 4-10: 可能的IDIV寄存器操作数

源大小 输入 输出
8 位 16 位,存储在 AH:AL(即 AX)中 余数存储在 AH;商存储在 AL
16 位 32 位,存储在 DX:AX 中 余数存储在 DX;商存储在 AX
32 位 64 位,存储在 EDX:EAX 中 余数存储在 EDX;商存储在 EAX

在除法中,输入通常比输出大,因此这里的输入使用了两个寄存器。此外,除法操作必须存储余数,这个余数会被存储在第一个输入寄存器中。例如,下面是一个 32 位IDIV计算的样子:

MOV EDX, 0          ; there's no high-order DWORD in the input, so EDX is 0
MOV EAX, inputValue ; 32-bit input value 
IDIV ECX            ; divide EDX:EAX by ECX

这是一些伪代码,表达了背后的实际操作:

EAX = EDX:EAX / ECX ; quotient
EDX = EDX:EAX % ECX ; remainder

这些IDIVIMUL的细节很重要,因为如果仅仅看指令,行为可能会变得相当晦涩。

分支

在评估完一个表达式后,程序可以根据结果决定接下来执行什么,通常使用诸如if()语句或switch()语句等结构。然而,这些控制流语句在汇编级别并不存在。相反,汇编代码使用 EFLAGS 寄存器来做决策,并通过跳转操作来执行不同的代码块;这一过程称为分支

为了获得正确的 EFLAGS 值,汇编代码使用以下两条指令之一:TESTCMP。这两者都会比较两个操作数,设置 EFLAGS 的状态位,然后丢弃任何结果。TEST通过逻辑与运算来比较操作数,而CMP则通过有符号减法将后一个操作数从前一个操作数中减去。

为了正确地进行分支,代码在比较操作后会紧接着有一个跳转指令。每种跳转指令接受一个操作数,该操作数指定要跳转到的代码地址。特定跳转指令的行为取决于 EFLAGS 的状态位。表 4-11 描述了一些 x86 跳转指令。

表 4-11: 常见的 x86 跳转指令

指令 名称 行为
JMP dest 无条件跳转 跳转到dest(将EIP设置为dest)。
JE dest 等于跳转 ZF(零标志)为 1 时跳转。
JNE dest 不等跳转 ZF为 0 时跳转。
JG dest 大于跳转 ZF为 0 且SF(符号标志)等于OF(溢出标志)时跳转。
JGE dest 大于或等于跳转 SF等于OF时跳转。
JA dest 无符号JG CF(进位标志)为 0 且ZF为 0 时跳转。
JAE dest 无符号JGE CF为 0 时跳转。
JL dest 小于跳转 SF不等于OF时跳转。
JLE dest 小于或等于跳转 ZF为 1 或SF不等于OF时跳转。
JB dest 无符号JL CF为 1 时跳转。
JBE dest 无符号JLE CF为 1 或ZF为 1 时跳转。
JO dest 溢出跳转 OF为 1 时跳转。
JNO dest 非溢出跳转 OF为 0 时跳转。
JZ dest 零跳转 ZF为 1 时跳转(与JE相同)。
JNZ dest 非零跳转 ZF为 0 时跳转(与JNE相同)。

记住哪些标志控制哪些跳转指令可能很麻烦,但它们的用途通过指令名称已清楚表达。一个好的经验法则是,前面有CMP指令的跳转与相应的操作符相同。例如,表 4-11 列出了JE为“等于跳转”,因此当JE紧跟CMP操作时,它与==操作符相同。同样,JGE对应于>=JLE也对应于>=,以此类推。

例如,考虑在示例 4-8 中显示的高级代码。

--snip--
if (EBX > EAX)
    ECX = EDX;
else
    ECX = 0;
--snip--

示例 4-8:一个简单的条件语句

这个if()语句只是检查EBX是否大于EAX,并根据结果设置ECX。在汇编语言中,相同的语句可能如下所示:

       --snip--
       CMP EBX, EAX  ; if (EBX > EAX)
       JG label1     ; jump to label1 if EBX > EAX
       MOV ECX, 0    ; ECX = 0 (else block)
       JMP label2    ; jump over the if block
   label1: 
➊     MOV ECX, EDX ; ECX = EDX (if block)
   label2:
       --snip--

在示例 4-8 中的if()语句的汇编代码以CMP指令开始,并在EBX大于EAX时进行分支。如果分支被执行,EIP会被设置为if块中的位置 ➊,这是通过JG指令实现的。如果分支没有被执行,代码将继续按线性顺序执行,并立即跳到JG指令后面的else块。当else块执行完毕后,一个无条件的JMP指令将EIP设置为0x7,跳过if块。

函数调用

在汇编代码中,函数是通过CALL指令执行的独立命令块。CALL指令只接受一个函数地址作为操作数,推送返回地址到栈中,并将EIP设置为其操作数值。以下伪代码展示了CALL的执行过程,左侧是内存地址的十六进制表示:

0x1: CALL EAX
0x2: ...

当执行CALL EAX时,下一地址被推送到栈中,EIP被设置为EAX,这表明CALL本质上是一个PUSHJMP操作。以下伪代码强调了这一点:

0x1: PUSH 3h
0x2: JMP EAX
0x3: ...

虽然PUSH指令和要执行的代码之间有一个额外的地址,但结果是一样的:在执行EAX中的代码块之前,后续代码的地址会被推入堆栈。这是为了让被调用方(被调用的函数)知道在调用方(调用函数)返回时,应该跳转到哪里。

如果调用的是一个没有参数的函数,则只需要一个CALL指令即可。然而,如果被调用方有参数,则必须先将参数按逆序推入堆栈。以下伪代码展示了一个带有三个参数的函数调用可能的样子:

PUSH 300h   ; arg3
PUSH 200h   ; arg2 
PUSH 100h   ; arg1 
CALL ECX    ; call

当被调用方执行时,堆栈的顶部包含一个返回地址,指向调用后面的代码。第一个参数0x100位于返回地址下方。第二个参数0x200位于其下方,再下来是第三个参数0x300。被调用方设置它的堆栈帧,使用从EBP开始的内存偏移量来引用每个参数。一旦被调用方执行完毕,它会恢复调用者的堆栈帧并执行RET指令,这会将返回地址从堆栈弹出并跳转到该地址。

由于参数不是被调用方堆栈帧的一部分,它们在RET执行后仍然保留在堆栈上。如果是调用者负责清理堆栈,它会在CALL ECX完成后立即将 12(3 个参数,每个 4 字节)加到 ESP。如果是被调用方负责清理,它会通过执行RET 12而不是RET来清理堆栈。这个责任由被调用方的调用约定决定。

函数的调用约定告诉编译器如何通过汇编代码传递参数、存储实例指针、传递返回值以及清理堆栈。不同的编译器有不同的调用约定,但表 4-12 中列出的这四种是游戏黑客最可能遇到的。

表 4-12: 游戏黑客需要了解的调用约定

指令 清理者 备注
__cdecl 调用方 Visual Studio 中的默认约定。
__stdcall 被调用方 Win32 API 函数使用的调用约定。
__fastcall 被调用方 前两个DWORD(或更小)参数通过 ECX 和 EDX 传递。
__thiscall 被调用方 用于成员函数。类实例的指针通过 ECX 传递。

表 4-12 中的指令列给出了调用约定的名称,清理者列则告诉你根据该指令,谁负责清理堆栈。在这四种调用约定中,参数总是从右到左推入堆栈,返回值总是存储在 EAX 中。这是一个标准,但不是规则;它可能会在其他调用约定中有所不同。

结束思考

我写这章的目的是帮助你在深入游戏破解的具体内容之前,先大致了解内存和汇编。通过你刚刚获得的像计算机一样思考的能力,你应该已经具备了足够的能力,开始应对更高级的内存取证任务。如果你迫不及待想要看看如何将这些知识应用到实际中,可以翻到“将调用钩子应用于 Adobe AIR”第 169 页或“将跳转钩子和 VF 钩子应用于 Direct3D”第 175 页。

如果你想亲自动手操作内存,可以编译本章的示例代码,使用 Cheat Engine 或 OllyDbg 来检查、调整并调试内存,直到你掌握了技巧。这非常重要,因为下一章将通过教授你更高级的内存取证技术,来建立在这些技能之上。

第五章:5

高级内存取证

image

无论你是将黑客游戏作为爱好还是事业,你最终都会发现自己陷入困境……一大堆难以理解的内存转储中。无论是与竞争对手机器人开发者竞赛发布高度请求的功能、与游戏公司不断更新的战斗,还是在内存中寻找复杂数据结构的努力,你都需要顶级的内存取证技能才能脱颖而出。

成功的机器人开发是在速度和技巧之间微妙平衡的,顽强的黑客必须迎接挑战,通过迅速发布巧妙的功能、及时响应游戏更新,并积极寻找即使是最难找到的数据。要做到这一点,需要对常见的内存模式、高级数据结构以及不同数据片段的用途有全面的理解。

这三种内存取证的技术也许是你武器库中最有效的武器,本章将教你如何使用它们。首先,我将讨论高级内存扫描技术,重点是通过理解数据的目的和使用方式来搜索数据。接下来,我将教你如何使用内存模式应对游戏更新,并在不必重新定位所有地址的情况下调整你的机器人。最后,我将剖析 C++标准库中最常见的四种复杂数据结构(std::stringstd::vectorstd::liststd::map),帮助你在内存中识别它们并枚举它们的内容。到本章结束时,我希望你能够深刻理解内存取证,并能够应对任何与内存扫描相关的挑战。

高级内存扫描

在游戏的源代码中,每一段数据都有一个冷静、计算过的定义。然而,当游戏正在进行时,所有这些数据会汇聚在一起,创造出新的东西。玩家只体验到美丽的景色、真实的声音和紧张的冒险;驱动这些体验的数据是无关紧要的。

有鉴于此,假设黑客 A 刚刚开始入侵他最喜欢的游戏,想用机器人来自动化一些无聊的环节。他还没有完全理解内存,对于他来说,这些数据不过是一些假设。他想:“我有 500 点生命值,所以我可以让作弊引擎查找一个值为 500 的 4 字节整数来找到生命值地址。”黑客 A 对数据有着准确的理解:它只是在特定位置(地址)存储的以定义结构(类型)表示的信息(值)。

现在想象一下黑客 B,她已经完全理解了这款游戏;她知道玩游戏如何改变游戏内存中的状态,这些数据不再有什么秘密。她知道数据的每个定义属性都可以根据其目的来确定。与黑客 A 不同,黑客 B 对数据的理解超越了单一变量声明的局限:她考虑的是数据的目的使用。在本节中,我们将讨论这两者。

游戏中的每一条数据都有一个目的,游戏的汇编代码在某个时刻必须引用这些数据来实现这个目的。找到使用某条数据的唯一代码意味着找到一个版本无关的标记,这个标记会在游戏更新时持续存在,直到数据被删除或其目的发生变化。让我来告诉你为什么这很重要。

推测目的

到目前为止,我只向你展示了如何盲目地搜索内存中的某条数据,而没有考虑它是如何被使用的。这种方法可能有效,但并不总是高效。在许多情况下,推测数据的目的、确定哪些代码可能使用该数据,然后定位这些代码以最终找到数据的地址,往往更快捷。

这听起来可能不容易,但“扫描游戏内存以查找特定数据类型的特定值,然后根据不断变化的标准持续筛选结果列表”也并不容易,这正是你到目前为止学会做的事。所以让我们来看看如何根据其目的定位健康值的地址。参考列表 5-1 中的代码。

struct PlayerVital {
    int current, maximum;
};
PlayerVital health;
--snip--
printString("Health: %d of %d\n", health.current, health.maximum);

列表 5-1:一个包含玩家生命体征的结构体,以及一个显示它们的函数

如果你假装printString()是一个在游戏界面上绘制文本的高级函数,那么这段代码非常接近你在游戏中可能会找到的内容。PlayerVital结构体有两个属性:current值和maximum值。health值是一个PlayerVital结构体,所以它也有这些属性。单凭名称,你就可以推测health是用来显示玩家健康信息的,当printString()使用这些数据时,你可以看到这个目的被实现了。

即使没有代码,你也可以通过查看游戏界面上显示的健康文本直观地得出类似的结论;毕竟,没有代码,计算机什么也做不了。除了实际的health变量外,还有一些代码元素需要存在才能向玩家显示这些文本。首先,需要有一个函数来显示文本。其次,字符串Healthof必须在附近。

注意

为什么我假设文本被拆分成两个独立的字符串,而不是一个?游戏界面显示当前的健康值位于这两个字符串之间,但有很多方式可能导致这种情况,包括格式化字符串, strcat(),或者多个显示文本调用对齐的文本。在分析数据时,最好保持假设的广泛性,以考虑所有可能性。

要在不使用内存扫描器的情况下查找health,我们可以利用这两个独立的字符串。不过,我们可能无法知道显示文本的函数是什么样子、它在哪里或被调用了多少次。实际上,字符串就是我们唯一需要寻找的线索,这就足够了。让我们逐步分析。

使用 OllyDbg 查找玩家的健康状态

在本节中,我将引导你如何追踪health结构,但我也在书籍的资源文件中包含了我分析的二进制文件。为了跟随学习并获得一些实际操作经验,使用文件Chapter5_AdvancedMemoryForensics_Scanning.exe

首先,打开 OllyDbg 并将其附加到可执行文件。然后,打开 OllyDbg 的可执行模块窗口,并双击主模块;在我的示例中,主模块是模块窗口中唯一的 .exe 文件。CPU 窗口应该会弹出。现在,在反汇编窗格中右键单击并选择搜索所有引用的文本字符串。这将打开引用窗口,如图 5-1 所示。

image

图 5-1:OllyDbg 的引用窗口,显示的仅是字符串列表。实际游戏中会有远远超过四个字符串。

从这个窗口,右键点击并选择搜索文本。会出现一个搜索对话框。输入你要查找的字符串,如图 5-2 所示,并通过禁用区分大小写和启用整个范围来尽可能扩大搜索范围。

image

图 5-2:在 OllyDbg 中搜索字符串

点击确定执行搜索。引用窗口重新聚焦,首个匹配项被高亮显示。双击匹配项查看在 CPU 窗口中使用该字符串的汇编代码。反汇编窗格聚焦在 0x401030 的代码行,这行代码将格式化字符串参数推送到printString()。你可以在图 5-3 中看到这一行,我已经高亮了整个函数调用块。

image

图 5-3:在 CPU 窗口的反汇编窗格中查看printString()调用

通过阅读汇编代码,你可以非常准确地理解游戏到底在做什么。左侧的黑色括号显示字符串Health位于一个函数调用中。注意该函数的参数。按顺序,这些参数是 EAX ➊、ECX ➋,以及位于 0x4020D0 的格式化字符串 ➌。EAX 是 0x40301C 处的值,ECX 是 0x403018 处的值,格式化字符串包含 Health。由于字符串中包含两个格式占位符,你可以假设剩下的两个参数是这些占位符的参数。

了解了这些参数是什么,并且它们是按反向顺序入栈的,你可以倒推并得出原始代码类似于 Listing 5-2 的结论。

int currentHealth; // value at 0x403018
int maxHealth;     // value at 0x40301C
--snip--
someFunction("Health: %d of %d\n",
    currentHealth, maxHealth);

Listing 5-2:游戏黑客可能如何解读汇编代码,该代码编译成 Figure 5-3

存储在 EAX 和 ECX 中的值在内存中是相邻的,这意味着它们可能是某个结构的一部分。不过,为了简化起见,本例只是将它们展示为变量定义。无论哪种方式,这两者都是用来显示玩家生命值的数字。由于这两个重要的值都显示在游戏的用户界面中,因此很容易做出关于显示它们的底层代码的假设。当你知道某个数据的用途时,你可以快速找到负责实现它的代码;在这种情况下,这种知识帮助我们迅速找到了这两个地址。

在许多情况下,找到地址可能是如此简单,但有些数据的用途如此复杂,以至于很难猜测该寻找什么。例如,在 OllyDbg 中查找地图数据或角色位置可能相当棘手。

字符串并不是你用来查找游戏中想要更改的数据的唯一标记,但它们无疑是最容易教授的,且不需要做过于牵强的示例。此外,一些游戏的代码中嵌入了日志或错误字符串,在 OllyDbg 的引用文本字符串窗口中进行探查可以快速判断这些字符串是否存在。如果你熟悉某个游戏的日志实践,你将能够更轻松地找到这些值。

游戏更新后如何确定新地址

当应用程序代码被修改并重新编译时,会生成一个反映这些变化的新二进制文件。这个二进制文件可能与之前的非常相似,或者它们完全不同;两者之间的差异与高层次变更的复杂性直接相关。像修改字符串或更新常量这样的简单变更,往往不会对代码或数据的地址产生影响,二进制文件也可能几乎相同。但更复杂的变更——如新增功能、全新的用户界面、重构的内部结构或新的游戏内容——常常会导致关键内存位置的变化。

自动查找 CURRENTHEALTH 和 MAXHEALTH

在 “搜索汇编模式”(第 19 页)和 “搜索字符串”(第 21 页)中,我展示了一些作弊引擎 Lua 脚本,并解释了它们的工作原理。在这些示例中使用 findString() 函数,你可以让作弊引擎自动定位我们刚刚在 OllyDbg 中手动找到的格式字符串的地址。接下来,你可以编写一个小函数,扫描位于字节 0x68 后面的地址(这是 PUSH 指令的字节,如你在 图 5-3 中 0x401030 旁边看到的)来定位将其压入堆栈的代码的地址。然后,你可以从 pushAddress - 5pushAddress - 12 读取 4 个字节,分别定位到 currentHealthmaxHealth

这看起来可能没有用,因为我们已经找到了这些地址,但如果这是一个真实的游戏,这些地址会在更新发布时发生变化。利用这些知识来自动查找地址会非常有帮助。如果你有挑战精神,试试看吧!

由于不断的错误修复、内容改进和功能添加,网络游戏是软件中发展最快的类型之一。有些游戏每周更新一次,游戏黑客通常将大部分时间花在逆向工程新的二进制文件上,以便相应地更新他们的机器人程序。

如果你创建的是高级机器人,它们将越来越多地依赖于内存地址的基础。每当更新发布时,确定大量值和函数的新地址将是你面临的最耗时的必然任务。依赖于“更新竞赛获胜技巧”会非常有帮助,但这些技巧并不能帮助你找到更新后的地址。你可以通过作弊引擎脚本自动定位一些地址,但这并不总是有效。有时候,你需要手动做这项“脏活”。

如果你试图重新发明轮子,并像最初一样找到这些地址,你会浪费时间。然而,你实际上有一个很大的优势:旧的二进制文件和这些地址本身。利用这两者,你可以在极短的时间内找到所有需要更新的地址。

图 5-4 展示了两个不同的反汇编:左侧是新版本的游戏二进制文件,右侧是旧版本。我从一款真实的游戏(名字保密)中截取了这张图片,以便为你提供一个真实的例子。

image

图 5-4:两个版本的游戏反汇编并排显示

我的机器人修改了 0x047B542 处的代码(右侧),我需要在新版本中找到相应的代码,最终我在 0x047B672 处找到了它(左侧)。这个函数调用会在接收到数据包时调用一个数据包解析函数。为了最初找到这个地址(“最初”指的是大约 100 次更新前),我弄明白了游戏的网络协议是如何工作的,设定了许多与网络相关的 API 调用的断点,逐步执行并检查栈上的数据,直到找到一个与我预期的协议知识相似的内容。

更新竞赛获胜提示

在饱和的市场中,成为第一个发布稳定更新的机器人开发者对于成功至关重要。比赛在游戏更新的那一刻就开始了,决心最快的黑客会花上数百小时准备。这些是保持领先的最常见方法:

创建更新警报 通过编写软件,在游戏打补丁时立即提醒你,你就可以尽早开始处理更新。

自动化机器人安装 游戏通常在玩家最少的时候安排预期的更新。做机器人行为的玩家讨厌在开始之前起床并下载新软件,但他们喜欢醒来时发现软件已经在游戏打补丁时悄然安装好了。

使用更少的地址 更新的内容越少越好。将相关数据合并为结构体,并消除不必要的内存地址使用可以节省大量时间。

拥有优秀的测试用例 数据会发生变化,黑客也会犯错。能够快速测试每个功能的方式可能是稳定机器人与随机崩溃、导致用户死亡甚至让角色被封禁之间的区别。

使用这些方法来攻击更新将为你提供一个可观的起步优势,但它们可能并不总是足够让你获得胜利。最重要的是,尽量理解逆向工程,并利用这种理解为自己争取优势。

我本可以按照相同的步骤处理此后的每次 100 多个更新,但那样就没必要了。多年来代码保持相对不变,这让我可以使用旧代码中的模式,在新代码中找到这个函数调用的地址。

现在,考虑这段汇编代码:

PUSH EDI
PUSH EAX 
LEA EAX,DWORD PTR SS:[EBP-C] 
MOV DWORD PTR FS:[0],EAX 
MOV DWORD PTR SS:[EBP-10],ESP 
MOV DWORD PTR SS:[EBP-220],-1 
MOV DWORD PTR SS:[EBP-4],0

看起来熟悉吗?将其与图 5-4 进行比较,你会发现这个确切的代码在游戏的两个版本中都存在于突出显示的函数调用上方。不管它做什么,这一系列操作看起来相当独特;由于代码相对于 EBP 使用了多个不同的偏移量,其他地方的二进制文件中不太可能存在完全相同的代码块。

每次我需要更新这个地址时,我会在 OllyDbg 中打开旧的二进制文件,选中这一段操作,右键点击,选择 Asm2Clipboard ▸ 复制固定汇编到剪贴板。然后,我在 OllyDbg 中打开新的二进制文件,进入 CPU 窗口,按 CTRL-S,粘贴汇编代码,然后点击查找。在 9.5 次中的 10 次,这样我就能直接定位到新版本中需要找到的函数调用。

当更新到来时,你可以使用相同的方法找到几乎所有已知的地址。这对于你能在汇编代码中轻松找到的每个地址都适用。不过,有几个注意事项:

• OllyDbg 将搜索限制为八个操作,因此你必须找到大小为该数或更小的代码标记。

• 你使用的操作不能包含任何其他地址,因为那些地址很可能已经发生变化。

• 如果游戏的某些部分发生了变化,使用了你正在寻找的地址,那么代码可能会有所不同。

• 如果游戏更换了编译器或切换了优化设置,几乎所有的代码都会发生完全的变化。

正如在第 102 页的《自动找到currentHealthmaxHealth》中讨论的那样,你可以通过编写脚本来帮助你自动完成这些任务。资深的游戏黑客会非常努力地自动定位尽可能多的地址,而一些最好的机器人则设计成每次在运行时自动检测它们的地址。虽然最初可能需要很多工作,但这项投资绝对值得。

识别游戏数据中的复杂结构

第四章描述了游戏如何将数据存储在静态结构中。当你尝试查找简单数据时,这些知识足够使用,但对于通过动态结构存储的数据来说,它就显得不足够了。这是因为动态结构可能会分散在不同的内存位置,遵循长指针链,或者需要复杂的算法才能从中提取数据。

本节将探讨你在电子游戏代码中常见的动态结构,以及一旦找到这些结构,如何从中读取数据。首先,我将讲解每个动态结构的基本组成。接下来,我会概述读取这些结构数据所需的算法。(为简单起见,每个算法的讨论假设你有指向该结构实例的指针,并且有某种方式从内存中读取数据。)最后,我将介绍一些技巧,帮助你确定在内存中查找的值是否实际上被封装在这些结构之一中,这样你就知道何时应用这些知识。我将重点讨论 C++,因为它的面向对象特性和广泛使用的标准库通常负责这种结构。

注意

这些结构可能会因编译器、优化设置或标准库实现的不同,在不同机器上略有差异,但基本概念是相同的。此外,为了简洁起见,我将省略这些结构中的无关部分,例如自定义分配器或比较函数。示例代码可以在 www.nostarch.com/gamehacking/ 的资源文件中找到,位于第五章的内容。

std::string 类

std::string的实例是动态存储最常见的罪魁祸首之一。C++标准模板库(STL)中的这个类将字符串操作从开发者中抽象出来,同时保持效率,因此广泛应用于各种软件中。一个视频游戏可能会使用std::string结构来存储任何字符串数据,比如生物的名字。

检查 std::string 的结构

当你去掉std::string类中的成员函数和其他非数据组件时,剩下的结构大概是这样的:

class string {
    union {
        char* dataP;
        char dataA[16];
    };
    int length;
};

// point to a string in memory
string* _str = (string*)stringAddress;

该类预留了 16 个字符,用于直接存储字符串。然而,它也声明前 4 个字节可以是指向字符的指针。看起来这有些奇怪,但这是优化的结果。某个时候,这个类的开发者决定 15 个字符(加上一个空终止符)对于许多字符串来说是一个合适的长度,因此他们选择通过提前预留 16 个字节的内存来节省内存分配和回收的开销。为了适应更长的字符串,他们允许这 16 个字节的前 4 个字节作为指针指向更长字符串的字符。

注意

如果代码是编译为 64 位的,那么实际上指向字符的是前 8 个字节(而不是 4 个字节)。不过,在本例中,你可以假设使用的是 32 位地址,并且int是地址的大小。

以这种方式访问字符串数据会带来一定的开销。定位正确缓冲区的函数大致如下所示:

const char* c_str() {
    if (_str->length <= 15)
        return (const char*)&_str->dataA[0];
    else
        return (const char*)_str->dataP;
}

std::string可以是一个完整的字符串,也可以是指向更长字符串的指针,这使得从游戏破解的角度来看,这种结构相当棘手。有些游戏可能会使用std::string来存储字符串,这些字符串很少超过 15 个字符。在这种情况下,你可能会编写依赖于这些字符串的机器人,却不知道底层结构其实比一个简单的字符串要复杂得多。

忽视 std::string 可能会破坏你的乐趣

如果你不了解包含所需数据的结构的真实情况,就可能编写出一个只在某些时候有效、而在关键时刻失效的机器人。例如,假设你正在尝试弄清楚一个游戏是如何存储生物数据的。在你的假设性搜索中,你发现游戏中的所有生物都存储在一个结构体数组中,这些结构体看起来类似于示例 5-3。

struct creatureInfo {
    int uniqueID;
    char name[16];
    int nameLength;
    int healthPercent;
    int xPosition;
    int yPosition;
    int modelID;
 int creatureType;
};

列表 5-3:你可能如何解释内存中找到的生物数据

扫描内存中的生物数据后,假设你注意到每个结构的前 4 个字节对于每个生物都是唯一的,因此你将这些字节称为uniqueID,并假设它们形成了一个单一的int属性。进一步查看内存后,你发现生物的name紧接在uniqueID之后存储,并通过一些推理,你发现名字的长度为 16 字节。接下来,你在内存中看到的值原来是nameLength;虽然一个以空字符结尾的字符串有一个长度字段有点奇怪,但你忽略了这个怪异现象,继续分析内存中的数据。经过进一步分析,你弄清楚了剩余值的用途,定义了列表 5-3 中展示的结构,并写了一个机器人,自动攻击具有某些名字的生物。

在几周的测试中,你的机器人不断在与像独眼巨人巨人猎犬之类的生物战斗时表现良好,你决定是时候把机器人交给你的朋友们使用了。为了首次使用,你把大家聚集在一起,去击败一只名为超级老板至尊的 boss。全体队员设定机器人优先攻击 boss,并在 boss 超出攻击范围时,将目标转向像恶魔死神之类的次级生物。

一旦你的团队到达了老板的副本……你们都会被慢慢消灭。

这个场景中出了什么问题?你的游戏一定是用std::string存储生物名字,而不是简单的字符数组。creatureInfo中的namenameLength字段,实际上是std::string字段的一部分,而name字符数组是dataAdataP成员的联合体。超级老板至尊的名字超过了 15 个字符,而由于机器人没有意识到std::string的实现,它没有识别出 boss。相反,机器人不断将目标锁定为召唤出的恶魔生物,实际上让你无法锁定 boss,同时 boss 慢慢消耗了你的生命和资源。

判断数据是否存储在 std::string 中

如果不了解std::string类的结构,你就很难定位像我刚才描述的假设性 bug。把你在这里学到的知识与经验结合起来,你就能完全避免这类 bug。当你在内存中发现像name这样的字符串时,不要仅仅假设它是存储在一个简单的数组中。为了弄清楚一个字符串是否实际上是一个std::string,问自己以下几个问题:

• 为什么一个以空字符结尾的字符串会有字符串长度的记录?如果你想不出一个合理的理由,那么你可能就遇到了std::string

• 一些生物(或其他游戏元素,具体取决于你在寻找什么)是否有超过 16 个字母的名字,但在内存中你只能找到 16 个字符的空间?如果是这样,数据几乎可以肯定是存储在std::string中。

• 名称是直接存储在内存中的,需要开发人员使用 strcpy() 来修改它吗?它可能是一个 std::string,因为这样处理原始 C 字符串被认为是不好的做法。

最后,请记住,还有一个名为 std::wstring 的类,用于存储宽字符字符串。其实现非常相似,只不过在每个 char 的地方使用了 wchar_t

std::vector 类

游戏必须跟踪许多动态数据数组,但管理动态大小的数组可能非常棘手。为了提高速度和灵活性,游戏开发人员通常使用一个模板化的 STL 类 std::vector 来存储这些数据,而不是使用简单的数组。

检查 std::vector 的结构

该类的声明类似于清单 5-4。

template<typename T>
class vector {
    T* begin;
    T* end;
    T* reservationEnd;
};

清单 5-4:一个抽象的 std::vector 对象

这个模板增加了一层额外的抽象,因此我将继续使用一个声明为 DWORD 类型的 std::vector 来描述。以下是游戏可能如何声明该 vector:

std::vector<DWORD> _vec;

现在,让我们解析一个 std::vector 类型的 DWORD 对象在内存中的表现。如果你知道 _vec 的地址并共享相同的内存空间,你就可以重新构造该类的底层结构,并像在清单 5-5 中一样访问 _vec

class vector {
    DWORD* begin;
    DWORD* end;
    DWORD* tail;
};
// point to a vector in memory
vector* _vec = (vector*)vectorAddress;

清单 5-5:一个 DWORD std::vector 对象

你可以将成员 begin 视为原始数组,因为它指向 std::vector 对象中的第一个元素。然而,没有数组长度成员,因此你必须根据 beginend 来计算 vector 的长度,end 是跟在数组中最后一个对象后面的空对象。长度计算代码如下所示:

int length() {
    return ((DWORD)_vec->end - (DWORD)_vec->begin) / sizeof(DWORD);
}

该函数简单地将 begin 中存储的地址与 end 中存储的地址相减,以找到它们之间的字节数。然后,为了计算对象的数量,它将字节数除以每个对象的字节数。

使用 beginlength() 函数,你可以安全地访问 _vec 中的元素。代码可能如下所示:

DWORD at(int index) {
    if (index >= _vec->length())
        throw new std::out_of_range();
    return _vec->begin[index];
}

给定一个索引,这段代码将从 vector 中获取一个元素。但如果索引大于 vector 的长度,则会抛出一个 std::out_of_range 异常。不过,如果该类无法预留或重用内存,向 std::vector 中添加值将会非常昂贵。为了解决这个问题,该类实现了一个名为 reserve() 的函数,告诉 vector 需要为多少个对象预留空间。

std::vector 的绝对大小(即其 容量)是通过一个额外的指针来确定的,在我们重新创建的 vector 类中,这个指针被称为 tail。容量的计算与长度计算类似:

int capacity() {
    return ((DWORD)_vec->tail - (DWORD)_vec->begin) / sizeof(DWORD);
}

要找到 std::vector 的容量,而不是像计算长度那样从 begin 地址减去 end 地址,这个函数是通过将 begin 地址减去 tail 来进行计算的。此外,你还可以使用这个计算的第三次来确定向量中自由元素的数量,方法是使用 tailend 来代替:

int freeSpace() {
    return ((DWORD)_vec->tail - (DWORD)_vec->end) / sizeof(DWORD);
}

给定适当的内存读取和写入函数,你可以使用 列表 5-4 中的声明以及随后的计算来访问和操作游戏内存中的向量。第六章详细讨论了内存读取,但现在让我们看看如何判断你感兴趣的数据是否存储在 std::vector 中。

判断数据是否存储在 std::vector 中

一旦你在游戏的内存中找到了一个数据数组,你可以按照以下步骤来判断它是否存储在 std::vector 中。首先,如果数组有静态地址,你可以确定它不是存储在 std::vector 中,因为 std::vector 对象需要通过指针路径来访问底层数组。如果数组确实需要指针路径,最终偏移为 0 将表明它是一个 std::vector。为了确认,你可以将最终偏移改为 4,并检查它是否指向数组中的最后一个对象,而不是第一个对象。如果是这样,那么你几乎可以确定你正在查看一个向量,因为你已经确认了 beginend 指针。

std::list 类

类似于 std::vectorstd::list 是一个可以用来存储链表中项集合的类。主要的区别在于,std::list 不需要元素的连续存储空间,不能通过索引直接访问元素,并且可以在不影响任何前面元素的情况下扩展大小。由于访问项所需的开销,游戏中很少使用这个类,但在一些特殊情况下它会出现,我将在本节中讨论。

检查 std::list 的结构

std::list 类看起来像是 列表 5-6 中的样子。

template<typename T>
class listItem {
    listItem<T>* next;
    listItem<T>* prev;
    T value;
};

template<typename T>
class list {
    listItem<T>* root;
    int size;
};

列表 5-6:一个抽象化的 std::list 对象

这里有两个类:listItemlist。为了避免在解释 std::list 工作原理时出现额外的抽象,我将描述这个对象,当类型为 DWORD 时的样子。下面是一个游戏如何声明一个 DWORD 类型的 std::list

std::list<DWORD> _lst;

给定这个声明,std::list 的结构就像 列表 5-7 中的代码。

 class listItem {
    listItem* next;
    listItem* prev;
    DWORD value;
};
class list {
    listItem* root;
    int size;
};
// point to a list
list* _lst = (list*)listAddress;

列表 5-7:一个 DWORD std::list 对象

list表示列表头,而listItem表示存储在列表中的值。与连续存储不同,列表中的项是独立存储的。每个项都包含指向后继项(next)和前驱项(prev)的指针,这些指针用于在列表中定位项。root项充当列表末尾的标记;最后一个项的next指针指向root,第一个项的prev指针也指向rootroot项的nextprev指针分别指向第一个项和最后一个项。图 5-5 展示了这个结构。

给定此结构,你可以使用以下代码来遍历std::list对象:

image

图 5-5:std::list流程图

// iterate forward
listItem* it = _lst->root->next;
for (; it != _lst->root; it = it->next)
    printf("Value is %d\n", it->value);

// iterate backward
listItem* it = _lst->root->prev;
for (; it != _lst->root; it = it->prev)
    printf("Value is %d\n", it->value);

第一个循环从第一个项(root->next)开始,向前迭代(it = it->next),直到遇到结束标记(root)。第二个循环从最后一个项(root->pres)开始,向后迭代(it = it->prev),直到遇到结束标记(root)。这个迭代依赖于nextprev,因为与数组中的对象不同,std::list中的对象不是连续的。由于std::list中每个对象的内存不是连续的,因此没有快速粗略的方法来计算其大小。相反,类只是定义了一个大小成员。此外,为新对象预留空间的概念对于列表来说是无关紧要的,因此没有变量或计算来确定列表的容量。

确定游戏数据是否存储在 std::list 中

确定存储在std::list类中的对象可能会很棘手,但你可以注意到一些提示。首先,std::list中的项不能具有静态地址,因此,如果你要查找的数据具有静态地址,那么你就可以排除它。显然属于集合的一部分的项,如果它们在内存中不是连续存储的,可能是std::list的一部分。

还需要考虑的是,std::list中的对象可以具有无限长的指针链(例如it->prev->next->prev->next->prev...),并且在作弊引擎中进行指针扫描时,关闭“禁用循环指针”选项后,可能会显示更多的结果。

你也可以使用脚本来检测某个值是否存储在链表中。清单 5-8 展示了一个执行此操作的作弊引擎脚本。

function _verifyLinkedList(address)
    local nextItem = readInteger(address) or 0
    local previousItem = readInteger(address + 4) or 0
    local nextItemBack = readInteger(nextItem + 4)
    local previousItemForward = readInteger(previousItem)

    return (address == nextItemBack
            and address == previousItemForward)
end

function isValueInLinkedList(valueAddress)
    for address = valueAddress - 8, valueAddress - 48, -4 do
        if (_verifyLinkedList(address)) then
            return address
        end
    end
    return 0
end

local node = isValueInLinkedList(addressOfSomeValue)
if (node > 0) then
    print(string.format("Value in LL, top of node at 0x0%x", node))
end

清单 5-8:使用作弊引擎 Lua 脚本确定数据是否存储在std::list

这里有相当多的代码,但实际上它做的事情非常简单。isValueInLinkedList()函数获取某个值的地址,然后向后查找最多 40 个字节(10 个整数对象,以防值存在于更大的结构中),从地址上方 8 个字节开始(两个指针必须存在,每个指针 4 字节)。由于内存对齐问题,这个循环以 4 字节为步长进行迭代。

在每次迭代中,地址会被传递给_verifyLinkedList()函数,这就是魔法发生的地方。如果我们根据本章定义的链表结构来看,函数只是做了以下操作:

return (node->next->prev == node && node->prev->next == node)

也就是说,函数基本假设它获得的内存地址指向一个链表,并且它确保该节点具有有效的前后节点。如果这些节点有效,那么假设是正确的,这个地址就是链表节点的地址。如果节点不存在或没有指向正确的位置,那么假设是错误的,这个地址不属于链表的一部分。

请记住,这个脚本不会给出列表根节点的地址,而只是给出包含你提供的值的节点的地址。要正确遍历链表,你需要扫描有效的指针路径直到根节点,所以你需要根节点的地址。

查找该地址可能需要搜索内存转储、进行大量的试错和头疼的思考,但这是绝对可能的。最好的开始方式是跟踪prevnext节点的链,直到你找到一个数据为空、无意义或填充有值0xBAADF00D的节点(一些标准库实现使用这个值来标记根节点,但不是所有实现都如此)。

如果你确切知道列表中有多少个节点,这项调查也会变得更加容易。即使没有列表头,你也可以通过不断跟踪下一个指针,直到回到起始节点,从而确定节点的数量,正如在 Listing 5-9 中所示。

function countLinkedListNodes(nodeAddress)
    local counter = 0
    local next = readInteger(nodeAddress)
    while (next ~= nodeAddress) do
        counter = counter + 1
        next = readInteger(next)
    end
    return counter
end

Listing 5-9:使用 Cheat Engine Lua 脚本确定任意std::list的大小

首先,这个函数创建一个计数器来存储节点数量,以及一个变量来存储下一个节点的地址。然后,while循环会遍历节点,直到它回到初始节点。最后,它返回计数器变量,该变量在每次循环迭代时都会增加。

通过脚本查找根节点

实际上,可以编写一个脚本来查找根节点,但我将其作为一个可选练习留给你。它是如何工作的呢?根节点必须在节点链中,列表头指向根节点,且列表的大小会紧跟根节点在内存中。基于这些信息,你可以编写一个脚本,搜索任何包含指向某个列表节点的指针的内存,后面紧跟着的是列表的大小。通常情况下,这块内存就是列表头,它指向的节点就是根节点。

std::map 类

std::list一样,std::map也使用元素之间的链接来形成其结构。然而,std::map独特之处在于每个元素存储两部分数据(一个键和值),并且排序元素是底层数据结构(红黑树)的固有属性。下面的代码展示了组成std::map的结构。

template<typename keyT, typename valT>
struct mapItem {
    mapItem<keyT, valT>* left;
    mapItem<keyT, valT>* parent;
    mapItem<keyT, valT>* right;
    keyT key;
    valT value;
};

template<typename keyT, typename valT>
struct map {
    DWORD irrelevant;
    mapItem<keyT, valT>* rootNode;
    int size;
}

红黑树是一种自平衡的二叉搜索树,因此std::map也是如此。在 STL 的std::map实现中,树中的每个元素(或节点)都有三个指针:leftparentright。除了指针外,每个节点还拥有一个key和一个value。节点在树中的排列是基于它们键的比较。一个节点的left指针指向一个具有较小键的节点,right指针指向一个具有较大键的节点。parent指向上一级节点。树中的第一个节点叫做rootNode,没有子节点的节点指向它。

可视化std::map

图 5-6 显示了一个包含键值 1、6、8、11、13、15、17、22、25 和 27 的std::map

image

图 5-6:红黑树

顶部节点(持有值13)由rootNodeparent指向。它左侧的所有节点有较小的key,右侧的所有节点有较大的key。这一规则适用于树中的任何节点,正是这种规则使得基于键的搜索非常高效。虽然图中没有表现出来,但根节点的left指针将指向最左边的节点(1),而right指针将指向最右边的节点(27)。

访问std::map中的数据

再次说明,在讨论如何从结构中提取数据时,我将使用静态的std::map定义。由于模板需要两个类型,我也会使用一些伪类型来保持清晰。以下是我将在本节余下部分引用的std::map对象的声明:

typedef int keyInt;
typedef int valInt;
std::map<keyInt, valInt> myMap;

通过这个声明,myMap的结构变成了:

struct mapItem {
    mapItem* left;
    mapItem* parent;
    mapItem* right;
    keyInt key;
    valInt value;
};
struct map {
    DWORD irrelevant;
    mapItem* rootNode;
    int size;
}
map* _map = (map*)mapAddress;

在游戏中,你可能需要访问std::map结构中的一些重要算法。首先,盲目遍历映射中的每一项可能是有用的,尤其是当你只是想查看所有数据时。为了按顺序执行这一操作,你可以像这样编写一个迭代函数:

void iterateMap(mapItem* node) {
    if (node == _map->rootNode) return;
    iterateMap(node->left);
    printNode(node);
    iterateMap(node->right);
}

一个遍历整个映射的函数会首先读取当前节点,并检查它是否是rootNode。如果不是,它会递归向左,打印节点,然后递归向右。

调用这个函数时,你需要传入指向rootNode的指针,代码如下:

iterateMap(_map->rootNode->parent);

然而,std::map的目的是以快速可搜索的方式存储带键数据。当你需要根据特定的key来定位一个节点时,模仿内部搜索算法比扫描整个树更为可取。搜索std::map的代码大概是这样的:

mapItem* findItem(keyInt key, mapItem* node) {
    if (node != _map->rootNode) {
        if (key == node->key)
            return node;
        else if (key < node->key)
            return findItem(key, node->left);
        else
            return findItem(key, node->right);
    } else return NULL;
}

从树的顶部开始,如果当前的键大于搜索键,你就递归向左;如果小于,则递归向右。如果键相等,你就返回当前节点。如果你到达树的底部且没有找到该键,说明该键不在映射中,你应该返回NULL

这里有一种你可能会使用findItem()函数的方式:

mapItem* ret = findItem(someKey, _map->rootNode->parent);
if (ret)
    printNode(ret);

只要findItem()不返回NULL,这段代码应该会打印出_map中的一个节点。

确定游戏数据是否存储在std::map

通常,在我确定集合不是数组、std::vectorstd::list之前,我甚至不会考虑数据是否可能存储在std::map中。如果排除了这三种选项,那么就像对待std::list一样,你可以查看值前面的三个整数值,并检查它们是否指向可能是其他映射节点的内存。

再次说明,这可以通过 Cheat Engine 中的 Lua 脚本完成。这个脚本与我之前展示的列表脚本相似,向后循环遍历内存,以查看在值之前是否找到有效的节点结构。不过,与列表代码不同的是,验证节点的函数要复杂得多。看看列表 5-10 中的代码,接下来我会详细分析它。

   function _verifyMap(address)
       local parentItem = readInteger(address + 4) or 0

       local parentLeftItem = readInteger(parentItem + 0) or 0
       local parentRightItem = readInteger(parentItem + 8) or 0

➊     local validParent =
           parentLeftItem == address
           or parentRightItem == address
       if (not validParent) then return false end

       local tries = 0
       local lastChecked = parentItem
       local parentsParent = readInteger(parentItem + 4) or 0
➋     while (readInteger(parentsParent + 4) ~= lastChecked and tries < 200) do
           tries = tries + 1
           lastChecked = parentsParent
           parentsParent = readInteger(parentsParent + 4) or 0
       end

       return readInteger(parentsParent + 4) == lastChecked
   end

列表 5-10:使用 Cheat Engine Lua 脚本确定数据是否在std::map

给定address,这个函数检查address是否在一个映射结构中。它首先检查是否有有效的父节点,如果有,检查这个父节点是否指向address的任一侧 ➊。但仅仅这个检查还不够。如果检查通过,函数还会沿着parent节点的链向上爬升,直到找到一个节点是其父节点的父节点 ➋,并在尝试 200 次后才会停止。如果爬升成功,找到了一个其祖父节点的节点,那么address一定指向一个映射节点。这是可行的,因为正如我在《可视化std::map》中概述的那样,在每个映射的顶部有一个根节点,其父节点指向树中的第一个节点,而该节点的父节点则指回根节点。

注意

我敢打赌,你没想到在阅读一本游戏黑客书时会遇到时间旅行的祖父悖论!

使用这个函数和稍作修改的回溯循环,来自列表 5-8,你可以自动检测值是否在一个映射中:

function isValueInMap(valueAddress)
    for address = valueAddress - 12, valueAddress - 52, -4 do
        if (_verifyMap(address)) then
            return address
        end
    end
    return 0
end

local node = isValueInMap(addressOfSomeValue)
if (node > 0) then
    print(string.format("Value in map, top of node at 0x0%x", node))
end

除了函数名称外,这段代码与列表 5-8 中的代码唯一的变化是,它开始从值之前的 12 个字节循环,而不是 8 个字节,因为映射有三个指针,而列表只有两个。映射结构的一个好处是,根节点很容易获取。当_verifyMap函数返回 true 时,parentsParent变量将包含根节点的地址。通过一些简单的修改,你可以将这个地址返回到主调用中,并在一个地方拥有读取std::map数据所需的一切。

结束语

内存取证是破解游戏中最耗时的部分,它的障碍可能以各种形式和大小出现。然而,利用目标、模式以及对复杂数据结构的深刻理解,你可以迅速克服这些障碍。如果你仍然对发生了什么感到有些困惑,确保下载并尝试提供的示例代码,因为它包含了本章涉及的所有算法的概念证明。

在第六章中,我们将开始深入了解你需要从自己的程序中读取和写入游戏内存的代码,以便你可以迈出第一步,将所有关于内存结构、地址和数据的信息付诸实践。

第六章:6

读取和写入游戏内存

image

前面的章节讨论了内存是如何构建的,以及如何使用 Cheat Engine 和 OllyDbg 扫描和修改内存。在你开始编写机器人时,操作内存将是至关重要的,你的代码需要知道如何操作内存。

本章深入探讨了内存操作的代码层面细节。首先,你将学习如何使用代码定位并获取游戏进程的句柄。接下来,你将学习如何使用这些句柄从远程进程或注入的代码中读取和写入内存。最后,你将学习绕过某种内存保护技术,并附带一个小示例,演示代码注入。你将在本书的源代码文件中的GameHackingExamples/Chapter6_AccessingMemory目录下找到本章的示例代码。

注意

当我在本章(以及后续章节)中谈论 API 函数时,我指的是 Windows API,除非另有说明。如果我没有提到库的头文件,你可以假设它是 Windows.h。

获取游戏的进程标识符

要读取或写入游戏的内存,你需要其进程标识符(PID),这是一个唯一标识活动进程的数字。如果游戏有一个可见窗口,你可以通过调用 GetWindowThreadProcessId() 来获取创建该窗口的进程的 PID。此函数将窗口的句柄作为第一个参数,并将 PID 输出到第二个参数。你可以通过将窗口标题(任务栏上的文本)作为第二个参数传递给 FindWindow() 来找到窗口的句柄,如 示例 6-1 所示。

HWND myWindow =
    FindWindow(NULL, "Title of the game window here");
DWORD PID;
GetWindowThreadProcessId(myWindow, &PID);

示例 6-1:获取窗口句柄以获取 PID

获取到窗口句柄后,你只需创建一个存储 PID 的地方并调用 GetWindowThreadProcessId(),如以下示例所示。

如果游戏没有窗口,或者窗口名称不可预测,你可以通过枚举所有进程并查找游戏二进制文件的名称来找到游戏的 PID。示例 6-2 就是使用 API 函数 CreateToolhelp32Snapshot()Process32First()Process32Next() 来实现的,这些函数来自 tlhelp32.h

#include <tlhelp32.h>

PROCESSENTRY32 entry;
entry.dwSize = sizeof(PROCESSENTRY32); 
HANDLE snapshot =
    CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
if (Process32First(snapshot, &entry) == TRUE) {
    while (Process32Next(snapshot, &entry) == TRUE) {
        wstring binPath = entry.szExeFile;
        if (binPath.find(L"game.exe") != wstring::npos) {
            printf("game pid is %d\n", entry.th32ProcessID);
            break;
        }
    }
} 
CloseHandle(snapshot);

示例 6-2:在没有窗口名称的情况下获取游戏的 PID

示例 6-2 可能看起来比 示例 6-1 更复杂,但在所有代码的背后,这个函数实际上就像一个典型的 for (iterator; 比较器; 增量) 循环。CreateToolhelp32Snapshot() 函数获取一个名为 snapshot 的进程列表,entry 是该列表的迭代器Process32First() 返回的值初始化了迭代器,而 Process32Next() 递增 了它。最后,Process32Next() 的布尔返回值是比较器。这段代码只是遍历了每个运行中的进程的快照,寻找其二进制路径包含文本 game.exe 的进程,并打印其 PID。

获取进程句柄

一旦你知道了游戏的 PID,你可以通过一个名为 OpenProcess() 的 API 函数获取到该进程的句柄。此函数允许你以所需的访问权限获取句柄,以便读取和写入内存。这对于游戏破解至关重要,因为任何操作进程的函数都需要具有适当访问权限的句柄。

让我们来看一下 OpenProcess() 的原型:

HANDLE OpenProcess(DWORD DesiredAccess, BOOL InheritHandle, DWORD ProcessId);

第一个参数 DesiredAccess 期望一个或多个进程访问标志,用于设置 OpenProcess() 返回的句柄。你可以使用多种标志,但在游戏破解中最常见的是以下几个:

PROCESS_VM_OPERATION 返回的句柄可以与 VirtualAllocEx()VirtualFreeEx()VirtualProtectEx() 一起使用,分别用于分配、释放和保护内存块。

PROCESS_VM_READ 返回的句柄可以与 ReadProcessMemory() 一起使用。

PROCESS_VM_WRITE 返回的句柄可以与 WriteProcessMemory() 一起使用,但它也必须具有 PROCESS_VM_OPERATION 权限。你可以通过将 PROCESS_VM_OPERATION | PROCESS_VM_WRITE 作为 DesiredAccess 参数来设置这两个标志。

PROCESS_CREATE_THREAD 返回的句柄可以与 CreateRemoteThread() 一起使用。

PROCESS_ALL_ACCESS 返回的句柄可以用来执行任何操作。避免使用此标志,因为它只能由启用了调试权限的进程使用,并且在旧版本的 Windows 中存在兼容性问题。

在获取游戏的句柄时,通常可以将 OpenProcess() 函数的第二个参数 InheritHandle 设置为 false。第三个参数 ProcessId 期望打开的进程的 PID。

使用 OpenProcess()

现在让我们来看一个使用访问权限允许从内存读取和写入的句柄调用 OpenProcess() 的示例:

   DWORD PID = getGamePID();
   HANDLE process = OpenProcess(
       PROCESS_VM_OPERATION |
           PROCESS_VM_READ |
           PROCESS_VM_WRITE,
 FALSE,
       PID 
   );
➊ if (process == INVALID_HANDLE_VALUE) {
      printf("Failed to open PID %d, error code %d",
             PID, GetLastError());
   }

首先,调用 getGamePID() 获取你需要的 PID。(这个函数是你需要自己编写的,不过它可以只是我在列表 6-1 和 6-2 中展示的代码片段,扩展成一个完整的函数。)接着,代码使用三个标志调用 OpenProcess()PROCESS_VM_OPERATION 标志为该句柄提供内存访问权限,另外两个标志组合提供了读写权限。这个示例还包含了一个错误处理案例 ➊,但只要你有正确的 PID,拥有有效的访问标志,且代码运行的权限与游戏相同或更高(例如,如果你以管理员身份启动你的机器人),则该调用不应失败。

一旦使用完句柄,就应使用 CloseHandle() 清理它,代码如下:

CloseHandle(process);

你可以随意重用句柄,所以你可以一直保持一个句柄直到完全不再使用它,或者直到你的机器人退出。

现在你已经看到如何打开进程句柄,为操作游戏内存做好准备,接下来让我们深入探讨如何实际访问该进程的内存。

访问内存

Windows API 提供了两个对内存访问至关重要的函数:ReadProcessMemory()WriteProcessMemory()。你可以使用这些函数来外部操控游戏的内存。

使用 ReadProcessMemory() 和 WriteProcessMemory()

这两个函数的原型(如在清单 6-3 中所示)非常相似,使用它们时你几乎会遵循完全相同的步骤。

BOOL ReadProcessMemory(
    HANDLE Process, LPVOID Address,
    LPVOID Buffer, DWORD Size,
    DWORD *NumberOfBytesRead 
);
BOOL WriteProcessMemory(
    HANDLE Process, LPVOID Address,
    LPCVOID Buffer, DWORD Size,
 DWORD *NumberOfBytesWritten 
);

*清单 6-3:ReadProcessMemory() WriteProcessMemory() 原型

两个函数都期望 Process 是一个进程句柄,Address 是目标内存地址。当函数从内存中读取时,Buffer 应该指向一个对象,该对象将保存读取的数据。当函数向内存写入时,Buffer 应该指向要写入的数据。在这两种情况下,Size 定义了 Buffer 的大小(以字节为单位)。两个函数的最后一个参数用于可选地返回已访问的字节数;你可以安全地将其设置为 NULL。除非函数失败,否则返回的最终参数值应该等于 Size

使用 ReadProcessMemory() 和 WriteProcessMemory() 访问内存中的值

清单 6-4 中的代码展示了如何使用这些函数来访问内存中的某个值。

DWORD val;
ReadProcessMemory(proc, adr, &val, sizeof(DWORD), 0);
printf("Current mem value is %d\n", val);

val++;

WriteProcessMemory(proc, adr, &val, sizeof(DWORD), 0);
ReadProcessMemory(proc, adr, &val, sizeof(DWORD), 0);
printf("New mem value is confirmed as %d\n", val);

清单 6-4:使用 Windows API 读取和写入进程内存

在这种代码出现在程序中之前,你需要找到 PID(proc),如在 “获取游戏进程标识符”一节中所述(位于第 120 页),以及你希望读取或写入的内存地址(adr)。有了这些值,ReadProcessMemory() 函数将从内存中读取的值存储在 val 中。然后,代码会递增 val 并通过调用 WriteProcessMemory() 替换原始值。写入操作完成后,ReadProcessMemory() 将再次调用相同的地址以确认新的内存值。注意,val 实际上并不是一个缓冲区。将 &val 作为 Buffer 参数传递是可行的,因为它可以是指向任何静态内存结构的指针,只要 Size 匹配即可。

编写模板化内存访问函数

当然,清单 6-4 中的示例假设你已经知道所处理的内存类型,并且它将类型硬编码为 DWORD。为了成为一名多才多艺的游戏黑客,最好在工具箱中准备一些通用代码,以避免为不同类型重复编写代码。支持不同类型的通用内存读取和写入函数可能像清单 6-5 中展示的那样。

 template<typename T>
T readMemory(HANDLE proc, LPVOID adr) {
    T val;
    ReadProcessMemory(proc, adr, &val, sizeof(T), NULL);
    return val;
}

template<typename T>
void writeMemory(HANDLE proc, LPVOID adr, T val) {
    WriteProcessMemory(proc, adr, &val, sizeof(T), NULL);
}

清单 6-5:通用内存函数

这些函数使用 C++ 模板来接受任意类型作为参数。它们允许你以非常简洁的方式访问内存中的各种类型。例如,基于我刚才展示的 readMemory()writeMemory() 模板,你可以在清单 6-6 中调用这些函数。

DWORD value = readMemory<DWORD>(proc, adr); // read
writeMemory<DWORD>(proc, adr, value++);     // increment and write

示例 6-6:调用模板化的内存访问函数

将其与 示例 6-4 中对 WriteProcessMemory()ReadProcessMemory() 的调用进行比较。这段代码仍然读取一个值,递增它,然后将新值写入内存。但是由于模板化函数允许你在调用时指定类型,因此你不需要为每种可能需要操作的数据类型创建一个新的 readMemory()writeMemory() 函数。这更加简洁,因为你通常希望处理各种数据。

内存保护

当内存被游戏(或任何程序)分配时,它会被放置在一个 页面 中。在 x86 Windows 中,页面是 4,096 字节的块,用于存储数据。由于所有内存必须在一个页面内,因此最小的分配单元为 4,096 字节。操作系统可以将小于 4,096 字节的内存块作为已有页面中足够未提交空间的子集,放置在新分配的页面中,或跨越具有相同属性的两个连续页面。

内存块为 4,096 字节或更大的范围,跨越 n 页,其中 n

image

操作系统通常在分配内存时会查找已有页面中的空闲空间,但如果必要,它会按需分配新页面。

注意

对于大块数据,也可能跨越 n + 1 页,因为没有保证数据块从页面的开始处开始。

关于内存页面,重要的是要理解每个页面都有一组特定的属性。这些属性在用户模式下大多数是透明的,但有一个在操作内存时你应该特别注意的属性:保护。

区分 x86 Windows 内存保护属性

到目前为止,你学到的内存读取技术非常基础。它们假设你访问的内存是用 PAGE_READWRITE 属性保护的。虽然这种假设对于变量数据是正确的,但还有其他类型的数据存在于具有不同保护类型的页面上。表 6-1 描述了 x86 Windows 中不同类型的内存保护。

表 6-1: 内存保护类型

保护类型 读取权限? 写入权限? 执行权限? 特殊权限?
PAGE_NOACCESS 0x01
PAGE_READONLY 0x02
PAGE_READWRITE 0x04
PAGE_WRITECOPY 0x08 是,写时复制
PAGE_EXECUTE 0x10
PAGE_EXECUTE_READ 0x20
PAGE_EXECUTE_READWRITE 0x40
PAGE_EXECUTE_WRITECOPY 0x80 是,写时复制
PAGE_GUARD 0x100 是,保护页

如果 表 6-1 中的某个保护类型在任何权限列中标有,则意味着可以在该内存页上执行相关操作。例如,如果一个页面是 PAGE_READONLY,则程序可以读取该页面的内存,但不能写入该内存。

例如,常量字符串通常使用 PAGE_READONLY 保护存储。其他常量数据,如虚拟函数表和一个模块的整个可移植执行文件(PE)头部(其中包含关于程序的信息,如它是哪种类型的应用程序、使用了哪些库函数、它的大小等)也存储在只读页面上。而汇编代码则存储在使用 PAGE_EXECUTE_READ 保护的页面上。

大多数保护类型仅涉及读、写和执行保护的某种组合。目前,你可以安全地忽略特殊的保护类型;如果你感兴趣,可以在 第 126 页的“特殊保护类型”中了解,我在其中有详细讲解,但只有非常高级的黑客才需要了解它们。然而,基本的保护类型在你的游戏黑客之旅中将会是常见的。

特殊保护类型

表 6-1 中的两种保护类型包括写时复制保护。当多个进程拥有相同的内存页(例如,映射的系统 DLL 页)时,使用写时复制保护来节省内存。实际数据只存储在一个物理位置,操作系统会虚拟映射所有包含该数据的内存页到物理位置。如果一个共享内存的进程对数据进行了修改,数据会在物理内存中创建一份副本,修改会应用到这份副本,且该进程的内存页会被重新映射到新的物理内存。当写时复制发生时,所有受影响的页面的保护会相应地发生变化;PAGE_WRITECOPY 会变成 PAGE_READWRITE,而 PAGE_EXECUTE_WRITECOPY 会变成 PAGE_EXECUTE_READWRITE。我没有发现写时复制页在游戏黑客中的特定用途,但了解它们是很有帮助的。

页还可以使用保护保护。保护页面必须具有二级保护,例如 PAGE_GUARD | PAGE_READONLY。当程序尝试访问受保护的页面时,操作系统会抛出 STATUS_GUARD_PAGE_VIOLATION 异常。一旦异常被处理,保护将从页面中移除,只剩下二级保护。操作系统使用此类保护的一种方式是通过在栈顶放置受保护页面,并在该受保护页面被访问时动态扩展调用栈,分配更多内存。一些内存分析工具会将保护页放置在堆内存之后,以检测堆损坏漏洞。在游戏黑客的背景下,受保护的页面可以作为一个触发器,当游戏可能尝试检测你的代码时,告诉你其内存中的变化。

更改内存保护

当你想要破解游戏时,有时需要以内存页保护所禁止的方式访问内存,这使得能够随意更改内存保护变得非常重要。幸运的是,Windows API 提供了VirtualProtectEx()函数来实现这一目的。该函数的原型如下:

BOOL VirtualProtectEx(
    HANDLE Process, LPVOID Address,
    DWORD Size, DWORD NewProtect,
    PDWORD OldProtect 
);

参数ProcessAddressSizeReadProcessMemory()WriteProcessMemory()函数中的输入相同。NewProtect应指定内存的新保护标志,而OldProtect则可以选择指向一个DWORD,其中存储旧的保护标志。

内存保护的最细粒度是按页进行的,这意味着VirtualProtectEx()会将新的保护设置为AddressAddress + Size - 1之间的每一页。

注意

VirtualProtectEx() 函数有一个姐妹函数叫做 VirtualProtect()。它们的工作方式相同,但 VirtualProtect() 只对调用它的进程起作用,因此没有进程句柄参数。

当你编写自己的代码来更改内存保护时,我建议通过创建一个模板来使其灵活。VirtualProtectEx()的通用包装函数应类似于清单 6-7。

template<typename T>
DWORD protectMemory(HANDLE proc, LPVOID adr, DWORD prot) {
    DWORD oldProt;
    VirtualProtectEx(proc, adr, sizeof(T), prot, &oldProt);
    return oldProt;
}

清单 6-7:更改内存保护的通用函数

在这个模板的基础上,如果你想,比如说,将一个DWORD写入一个没有写权限的内存页,你可能会做如下操作:

protectMemory<DWORD>(process, address, PAGE_READWRITE)
writeMemory<DWORD>(process, address, newValue)

首先,这段代码将内存的保护设置为PAGE_READWRITE。在授予写权限后,就可以调用writeMemory()并更改address处的数据。

当你更改内存保护时,最佳实践是让变更仅在需要时生效,并尽快恢复原始保护。这虽然效率较低,但可以确保游戏不会检测到你的机器人(例如,通过注意到其某些汇编代码页已变为可写)。

对只读内存进行典型写操作应如下所示:

DWORD oldProt =
    protectMemory<DWORD>(process, address, PAGE_READWRITE);
writeMemory<DWORD>(process, address, newValue);
protectMemory<DWORD>(process, address, oldProt);

这段代码调用清单 6-7 中的protectMemory()函数,将保护更改为PAGE_READWRITE。然后,它将newValue写入内存,之后再将保护恢复为oldProt,该值在最初调用protectMemory()时被设置为页面的原始保护。这里使用的writeMemory()函数与清单 6-5 中定义的相同。

一个最终重要的点是,当你操作游戏的内存时,完全有可能游戏会在你操作内存的同时访问它。如果你设置的新保护与原始保护不兼容,游戏进程将出现 ACCESS_VIOLATION 异常并崩溃。例如,如果你将内存保护从 PAGE_EXECUTE 更改为 PAGE_READWRITE,当内存没有标记为可执行时,游戏可能会尝试在该页执行代码。在这种情况下,你应该将内存保护设置为 PAGE_EXECUTE_READWRITE,以确保在允许游戏执行代码的同时,你也能操作内存。

地址空间布局随机化

到目前为止,我描述的内存地址是静态整数,只有在二进制文件发生变化时才会改变。这个模型在 Windows XP 及之前的版本中是正确的。然而,在后来的 Windows 系统中,内存地址相对游戏二进制文件的基地址是静态的,因为这些系统为支持的二进制文件启用了名为地址空间布局随机化(ASLR)的特性。当一个二进制文件编译时支持 ASLR(在 MSVC++ 2010 和许多其他编译器中默认启用),它的基地址每次运行时可能都不同。相反,非 ASLR 的二进制文件将始终具有基地址 0x400000。

注意

由于 ASLR 在 XP 上不起作用,我将 0x400000 称为 XP 基地址。

禁用 ASLR 简化机器人开发

为了保持开发简单,你可以禁用 ASLR 并使用透明的 XP 基地址。为此,你只需在 Visual Studio 命令提示符下输入一个命令:

> editbin /DYNAMICBASE:NO "C:\path\to\game.exe"

要重新启用它,请输入:

> editbin /DYNAMICBASE "C:\path\to\game.exe"

绕过生产环境中的 ASLR

禁用 ASLR 适合机器人开发,但不适用于生产环境;不能指望最终用户关闭 ALSR。相反,你可以编写一个函数,在运行时动态重新基址。如果使用带有 XP 基地址的地址,执行重新基址的代码如下:

DWORD rebase(DWORD address, DWORD newBase) {
    DWORD diff = address - 0x400000;
    return diff + newBase;
}

当你知道游戏的基地址(newBase)时,使用此函数可以通过重新基址 address 来基本忽略 ASLR。

然而,要找到 newBase,你需要使用 GetModuleHandle() 函数。当 GetModuleHandle() 的参数为 NULL 时,它总是返回一个指向进程主二进制文件的句柄。该函数返回的类型是 HMODULE,但实际返回的值就是二进制文件映射的地址。这就是基地址,因此你可以直接将其转换为 DWORD 来获取 newBase。不过,由于你是在另一个进程中寻找基地址,你需要一种方法来在该进程的上下文中执行此函数。

为此,调用 GetModuleHandle(),使用 CreateRemoteThread() API 函数,这个函数可以用来生成线程并在远程进程中执行代码。它的原型见 清单 6-8。

HANDLE CreateRemoteThread(
    HANDLE Process,
    LPSECURITY_ATTRIBUTES ThreadAttributes,
    DWORD StackSize,
    LPTHREAD_START_ROUTINE StartAddress,
    LPVOID Param,
    DWORD CreationFlags,
    LPDWORD ThreadId 
);

清单 6-8:一个生成线程的函数

被创建的线程将从 StartAddress 开始执行,将其视为一个单参数函数,Param 作为输入,并将返回的值设置为线程的退出代码。这是理想的,因为线程可以通过 StartAddress 指向 GetModuleHandle() 的地址,并将 Param 设置为 NULL 来启动。然后,你可以使用 API 函数 WaitForSingleObject() 等待线程执行完成,并使用 API 函数 GetExitCodeThread() 获取返回的基地址。

一旦所有这些操作结合起来,从外部机器人获取 newBase 的代码应该像 Listing 6-9 一样。

DWORD newBase;

// get the address of kernel32.dll
HMODULE k32 = GetModuleHandle("kernel32.dll");

// get the address of GetModuleHandle()
LPVOID funcAdr = GetProcAddress(k32, "GetModuleHandleA");
if (!funcAdr)
    funcAdr = GetProcAddress(k32, "GetModuleHandleW");

// create the thread
HANDLE thread =
    CreateRemoteThread(process, NULL, NULL,
        (LPTHREAD_START_ROUTINE)funcAdr,
        NULL, NULL, NULL);
 // let the thread finish
WaitForSingleObject(thread, INFINITE);

// get the exit code
GetExitCodeThread(thread, &newBase);

// clean up the thread handle
CloseHandle(thread);

Listing 6-9:使用 API 函数查找游戏的基地址

GetModuleHandle() 函数是 kernel32.dll 的一部分,该 DLL 在每个进程中都有相同的基地址,因此首先这段代码获取 kernel32.dll 的地址。由于 kernel32.dll 的基地址在每个进程中相同,GetModuleHandle() 的地址在游戏中和外部机器人中也是一样的。给定 kernel32.dll 的基地址,这段代码通过 API 函数 GetProcAddress() 很容易找到 GetModuleHandle() 的地址。接着,它调用 Listing 6-8 中的 CreateRemoteThread() 函数,让线程执行任务,并获取退出码以获得 newBase

结束语

现在你已经看到了如何从自己的代码中操作内存,我将向你展示如何将第一和第二部分中的技能应用于游戏。这些技能对于你将在接下来的章节中探索的概念至关重要,因此请确保你牢牢掌握正在发生的事情。如果你遇到困难,在复习概念时可以尝试修改示例代码,它为测试和调整本章及早期章节中的方法提供了一个安全的沙盒环境。

Listing 6-9 通过使游戏执行 GetModuleHandle() 来进行的操作是一种代码注入方式。但这仅仅是注入技术能做的一部分。如果你对学习更多注入内容感兴趣,可以深入研究 第七章,该章节将详细探讨这一主题。

第三部分

流程操控

第七章:7

代码注入

image

想象一下,你能够走进一家游戏公司的办公室,坐下来,开始向他们的游戏客户端添加代码。想象一下,你可以在任何你想要的时间、为任何你想要的游戏、添加任何你想要的功能。几乎所有你交谈过的玩家都会有改进游戏的想法,但在他们看来,这只是一个空想。然而,你知道梦想是可以实现的,现在你已经学会了一些关于内存如何工作的知识,你准备好开始抛弃规则。通过代码注入,你实际上可以变得和任何游戏的开发者一样强大。

代码注入是强制任何进程在其自己的内存空间和执行上下文中执行外部代码的一种方法。我之前在《绕过生产环境中的 ASLR》中提到过这个话题,位于第 128 页,在那里我向你展示了如何通过CreateRemoteThread()远程绕过 ASLR,但那个例子只是触及了表面。在本章的第一部分,你将学习如何创建代码洞、注入新线程、并劫持线程执行,强制游戏执行小段的汇编代码。在第二部分,你将学习如何直接将外部二进制文件注入游戏,迫使游戏执行你创建的整个程序。

通过线程注入注入代码洞

向另一个进程注入代码的第一步是编写位置无关的汇编代码,通常称为shellcode,其形式为字节数组。你可以将 shellcode 写入远程进程中,形成代码洞,它们作为你希望游戏执行的新线程的入口点。一旦创建了代码洞,你可以通过线程注入线程劫持来执行它。在本节中,我将展示一个线程注入的例子,线程劫持的例子将在《劫持游戏的主线程以执行代码洞》中讲解,详见第 138 页。

你可以在本书的资源文件中找到本章的示例代码,位于目录GameHackingExamples/Chapter7_CodeInjection。打开main-codeInjection.cpp,跟随我一起讲解如何构建该文件中简化版的injectCodeUsingThreadInjection()函数。

创建汇编代码洞

在《绕过生产环境中的 ASLR》中,位于第 128 页,我使用线程注入通过CreateRemoteThread()调用了GetModuleHandle()函数并获取了进程句柄。在那种情况下,GetModuleHandle()充当了代码洞;它具有合适的代码结构,可以作为新线程的入口点。不过,线程注入并不总是这么简单。

举个例子,假设你希望你的外部机器人远程调用游戏中的一个函数,并且该函数具有以下原型:

DWORD __cdecl someFunction(int times, const char* string);

有几个因素使得远程调用这个函数变得复杂。首先,它有两个参数,这意味着你需要创建一个代码洞来设置堆栈并正确地进行调用。CreateRemoteThread()允许你将一个参数传递给代码洞,你可以相对于ESP访问该参数,但另一个参数仍然需要硬编码到代码洞中。硬编码第一个参数times是最简单的。此外,你还需要确保代码洞能够正确清理堆栈。

注意

回想一下,在绕过第六章的 ASLR 时,我使用了CreateRemoteThread()来通过在给定地址执行任意代码并传递一个参数来启动新线程。这就是为什么这些示例可以通过堆栈传递一个参数的原因。

最终,将调用someFunction注入到正在运行的游戏进程中的代码洞将类似于以下伪代码:

PUSH DWORD PTR:[ESP+0x4] // get second arg from stack
PUSH times 
CALL someFunction 
ADD ESP, 0x8 
RETN

这个代码洞几乎是完美的,但它可以更简单一些。CALL操作需要两个操作数中的一个:要么是包含绝对函数地址的寄存器,要么是包含相对返回地址的函数偏移量的立即数。这意味着你需要做一堆偏移量计算,这会非常繁琐。

为了使代码洞的位置无关,修改它以改用寄存器,如列表 7-1 所示。

PUSH DWORD PTR:[ESP+0x4] // get second arg from stack
PUSH times 
MOV EAX, someFunction
CALL EAX 
ADD ESP, 0x8 
RETN

列表 7-1:调用someFunction的代码洞

由于调用者知道它调用的函数会用返回值覆盖EAX,调用者应该确保EAX不包含任何重要数据。了解这一点后,你可以使用EAX来存储someFunction的绝对地址。

将汇编代码转换为 Shellcode

由于代码洞需要写入另一个进程的内存,它们不能直接用汇编语言编写。相反,你需要逐字节编写它们。没有标准的方法来确定哪些字节表示哪些汇编代码,但有一些巧妙的方法。我个人最喜欢的是将包含汇编代码的空 C++应用程序编译出来,然后使用 OllyDbg 检查该函数。或者,你可以在任何任意进程中打开 OllyDbg,扫描反汇编代码,直到找到你需要的所有操作的字节。这种方法实际上非常好,因为你的代码洞应该尽可能简单地编写,这意味着所有操作应该是非常常见的。你也可以在网上找到汇编操作码的图表,但我发现它们都很难阅读;我刚才描述的方法总体上更容易。

当你知道字节应该是什么时,你可以使用 C++轻松生成正确的 shellcode。列表 7-2 展示了列表 7-1 中的汇编代码的最终 shellcode 骨架。

 BYTE codeCave[20] = {
    0xFF, 0x74, 0x24, 0x04,       // PUSH DWORD PTR:[ESP+0x4]
    0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0
    0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, 0x0
    0xFF, 0xD0,                   // CALL EAX
    0x83, 0xC4, 0x08,             // ADD ESP, 0x08
    0xC3                          // RETN
};

列表 7-2:Shellcode 骨架

这个例子创建了一个BYTE数组,包含所需的 shellcode 字节。但是,times参数需要动态处理,而且在编译时无法知道someFunction的地址,这也是为什么这个 shellcode 是作为骨架编写的原因。两组四个连续的 0x00 字节是timessomeFunction地址的占位符,你可以通过在运行时调用memcpy()将实际的值插入到代码洞中,正如清单 7-3 中的代码片段所示。

memcpy(&codeCave[5], &times, 4);
memcpy(&codeCave[10], &addressOfSomeFunc, 4);

清单 7-3:将timessomeFunction的位置插入到代码洞中

timessomeFunction的地址各占 4 个字节(回想一下,timesint类型,地址是 32 位值),它们分别位于codeCave[5-8]codeCave[10-13]。两次调用memcpy()将这些信息作为参数传递,以填补codeCave数组中的空白。

将代码洞写入内存

在创建了合适的 shellcode 之后,你可以通过VirtualAllocEx()WriteProcessMemory()将它放入目标进程中。清单 7-4 展示了实现这一点的一种方法。

   int stringlen = strlen(string) + 1; // +1 to include null terminator
   int cavelen = sizeof(codeCave);
➊ int fulllen = stringlen + cavelen;
   auto remoteString = // allocate the memory with EXECUTE rights
➋      VirtualAllocEx(process, 0, fulllen, MEM_COMMIT, PAGE_EXECUTE);

   auto remoteCave = // keep a note of where the code cave will go
➌      (LPVOID)((DWORD)remoteString + stringlen);

   // write the string first
➍ WriteProcessMemory(process, remoteString, string, stringlen, NULL);

   // write the code cave next
➎ WriteProcessMemory(process, remoteCave, codeCave, cavelen, NULL);

清单 7-4:将最终的 shellcode 写入代码洞内存

首先,这段代码确定了它需要多少字节的内存来将string参数和代码洞写入游戏的内存,并将该值存储在fulllen ➊中。接着,它调用 API 函数VirtualAllocEx()来分配fulllen字节的内存到process中,并使用PAGE_EXECUTE保护(你总是可以将第二个和第四个参数分别设置为0MEM_COMMIT),并将该内存的地址存储在remoteString ➋中。它还将remoteString地址加上stringlen字节,并将结果存储在remoteCave ➌中,因为 shellcode 应该直接写入紧随string参数后的内存。最后,它使用WriteProcessMemory()string ➍和存储在codeCave中的汇编字节 ➎填充到分配的缓冲区中。

表 7-1 展示了代码洞的内存转储可能的样子,假设它被分配在 0x030000,someFunction位于 0xDEADBEEF,times被设置为5,而string指向injected!文本。

表 7-1: 代码洞内存转储

地址 代码表示 原始数据 数据含义
0x030000 remoteString[0-4] 0x69 0x6E 0x6A 0x65 0x63 injec
0x030005 remoteString[5-9] 0x74 0x65 0x64 0x0A 0x00 ted!\0
0x03000A remoteCave[0-3] 0xFF 0x74 0x24 0x04 PUSH DWORD PTR[ESP+0x4]
0x03000E remoteCave[4-8] 0x68 0x05 0x00 0x00 0x00 PUSH 0x05
0x030013 remoteCave[9-13] 0xB8 0xEF 0xBE 0xAD 0xDE MOV EAX, 0xDEADBEEF
0x030018 remoteCave[14-15] 0xFF 0xD0 CALL EAX
0x03001A remoteCave[16-18] 0x83 0xC4 0x08 ADD ESP, 0x08
0x03001D remoteCave[19] 0xC3 RETN

地址列显示每个代码洞部分在内存中的位置;代码表示列告诉你remoteStringremoteCave的哪些索引对应原始数据列中的字节;数据意义列以人类可读的格式显示字节的含义。你可以看到 0x030000 处的injected!字符串,0x03000E 处的times值,以及 0x030014 处的someFunction地址。

使用线程注入执行代码洞

在内存中写入完整的代码洞后,剩下的唯一任务就是执行它。在这个例子中,你可以使用以下代码来执行代码洞:

HANDLE thread = CreateRemoteThread(process, NULL, NULL,
                    (LPTHREAD_START_ROUTINE)remoteCave,
                    remoteString, NULL, NULL);
 WaitForSingleObject(thread, INFINITE);
CloseHandle(thread);
VirtualFreeEx(process, remoteString, fulllen, MEM_RELEASE)

调用CreateRemoteThread()WaitForSingleObject()CloseHandle()可以注入并执行代码洞,VirtalFreeEx()通过释放代码中分配的内存(如示例 7-4 所示)来掩盖机器人的痕迹。最简单的形式就是执行注入到游戏中的代码洞。实际上,你还应在调用VirtualAllocEx()WriteProcessMemory()CreateRemoteThread()后检查返回值,以确保一切顺利。

例如,如果VirtualAllocEx()返回 0x00000000,意味着内存分配失败。如果你不处理这个失败,WriteProcessMemory()也会失败,且CreateRemoteThread()将以 0x00000000 为入口点开始执行,最终导致游戏崩溃。WriteProcessMemory()CreateRemoteThread()的返回值也是如此。通常,只有在打开进程句柄时没有使用所需的访问标志时,这些函数才会失败。

劫持游戏主线程以执行代码洞

在某些情况下,注入的代码洞需要与游戏进程的主线程同步。解决这个问题可能非常棘手,因为这意味着你必须控制外部进程中的现有线程。

你可以简单地暂停主线程,直到代码洞执行完毕,这可能有效,但速度非常慢。等待代码洞并恢复线程的开销相当大。一个更快的替代方法是强制线程为你执行代码,这个过程称为线程劫持

注意

打开 main-codeInjection.cpp 文件,以便跟随本书中的源代码构建这个线程劫持示例,这是一个简化版的 injectCodeUsingThreadHijacking()

构建汇编代码洞

与线程注入类似,线程劫持的第一步是知道你希望在代码洞中发生什么。然而,这次你并不知道劫持的线程会执行什么内容,所以你需要确保在代码洞开始时保存线程的状态,并在劫持完成后恢复状态。这意味着你的 shellcode 需要包裹在一些汇编代码中,如示例 7-5 所示。

PUSHAD // push general registers to the stack
PUSHFD // push EFLAGS to the stack
 // shellcode should be here

POPFD // pop EFLAGS from the stack
POPAD // pop general registers to the stack

// resume the thread without using registers here

清单 7-5:线程劫持代码洞的框架

如果你想调用与线程注入时相同的someFunction,你可以使用类似于清单 7-2 中的 Shellcode。唯一的不同是,你不能通过栈将第二个参数传递给你的机器人,因为你不会使用CreateRemoteThread()。但这没有问题;你可以像推送第一个参数那样推送第二个参数。执行你想要调用的函数的代码洞部分应该类似于清单 7-6 中的内容。

PUSH string
PUSH times 
MOV EAX, someFunction
CALL EAX 
ADD ESP, 0x8

清单 7-6:调用someFunction的汇编骨架

与清单 7-1 相比,唯一的变化是这个例子显式地推送了string,并且没有RETN。在这种情况下,你不调用RETN,因为你希望游戏线程回到它在被劫持之前所做的事情。

要正常恢复线程的执行,代码洞需要跳转回线程原始的 EIP,而不使用寄存器。幸运的是,你可以使用GetThreadContext()函数来获取EIP,然后在 C++中填充 Shellcode 骨架。接着,你可以将其推入栈中并执行返回操作。清单 7-7 展示了代码洞应该如何结束。

PUSH originalEIP
RETN

清单 7-7:间接跳转到 EIP

返回指令跳转到栈顶的值,因此,在压入 EIP 之后立即执行跳转即可实现目标。你应该使用这种方法,而不是跳转指令,因为跳转需要偏移量计算,并且会使生成 Shellcode 变得稍微复杂。如果将清单 7-5 到 7-7 连接起来,你会得到以下代码洞:

//save state
PUSHAD           // push general registers to the stack
PUSHFD           // push EFLAGS to the stack
 // do work with shellcode
PUSH string 
PUSH times 
MOV EAX, someFunction
CALL EAX 
ADD ESP, 0x8

// restore state
POPFD            // pop EFLAGS from the stack
POPAD            // pop general registers to the stack

// un-hijack: resume the thread without using registers
PUSH originalEIP 
RETN

接下来,按照“将汇编代码转换为 Shellcode”中的指示,在第 135 页上将这些字节插入到表示代码洞的数组中。

生成骨架 Shellcode 并分配内存

使用在清单 7-2 中展示的相同方法,你可以生成此代码洞的 Shellcode,如清单 7-8 所示。

BYTE codeCave[31] = {
    0x60,                         // PUSHAD
    0x9C,                         // PUSHFD
    0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0
    0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0
    0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, 0x0
    0xFF, 0xD0,                   // CALL EAX
    0x83, 0xC4, 0x08,             // ADD ESP, 0x08
    0x9D,                         // POPFD
    0x61,                         // POPAD
    0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0
    0xC3                          // RETN
};

// we'll need to add some code here to place
// the thread's EIP into threadContext.Eip

memcpy(&codeCave[3], &remoteString, 4);
memcpy(&codeCave[8], &times, 4);
memcpy(&codeCave[13], &func, 4);
memcpy(&codeCave[25], &threadContext.Eip, 4);

清单 7-8:创建线程劫持 Shellcode 数组

如同在清单 7-3 中所示,memcpy()被用来将变量放入骨架中。不过,与该清单中的不同之处在于,有两个变量不能立即复制;timesfunc是立即已知的,但remoteString是分配的结果,threadContext.Eip只有在线程被冻结后才会知道。冻结线程之前分配内存也是合理的,因为你不希望线程冻结的时间比必要的更长。下面是可能的实现方式:

int stringlen = strlen(string) + 1;
int cavelen = sizeof(codeCave);
int fulllen = stringlen + cavelen;

auto remoteString =
    VirtualAllocEx(process, 0, fulllen, MEM_COMMIT, PAGE_EXECUTE);
auto remoteCave =
    (LPVOID)((DWORD)remoteString + stringlen);

分配内存的代码与线程注入时相同,因此你可以重复使用相同的代码片段。

查找并冻结主线程

冻结主线程的代码稍微复杂一些。首先,你需要获取线程的唯一标识符。这与获取 PID 类似,你可以使用 CreateToolhelp32Snapshot()Thread32First()Thread32Next() 函数,来自 TlHelp32.h 文件。如同在 “获取游戏的进程标识符”(第 120 页)中所讨论的,这些函数基本上是用来遍历一个列表的。一个进程可以有多个线程,但以下示例假设游戏进程创建的第一个线程是需要被劫持的线程:

DWORD GetProcessThreadID(HANDLE Process) {
    THREADENTRY32 entry;
    entry.dwSize = sizeof(THREADENTRY32);
    HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);

    if (Thread32First(snapshot, &entry) == TRUE) {
        DWORD PID = GetProcessId(Process);
        while (Thread32Next(snapshot, &entry) == TRUE) {
            if (entry.th32OwnerProcessID == PID) {
                CloseHandle(snapshot);
                return entry.th32ThreadID;
            }
        }
    }
    CloseHandle(snapshot);
    return NULL;
}

这段代码简单地遍历系统中所有线程的列表,找到与游戏的 PID 匹配的第一个线程。然后它从快照条目中获取线程标识符。一旦你知道了线程标识符,就可以像这样获取线程当前的寄存器状态:

HANDLE thread = OpenThread(
    (THREAD_GET_CONTEXT | THREAD_SUSPEND_RESUME | THREAD_SET_CONTEXT),
    false, threadID);
SuspendThread(thread);
 CONTEXT threadContext;
threadContext.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(thread, &threadContext);

这段代码使用 OpenThread() 获取线程句柄。然后,它通过 SuspendThread() 暂停线程,并使用 GetThreadContext() 获取其寄存器的值。之后,列表 7-8 中的 memcpy() 代码应该拥有生成 shellcode 所需的所有变量。

在生成了 shellcode 后,可以像在 列表 7-4 中一样将代码洞写入已分配的内存:

WriteProcessMemory(process, remoteString, string, stringlen, NULL);
WriteProcessMemory(process, remoteCave, codeCave, cavelen, NULL);

一旦代码洞准备好并在内存中等待,你所需要做的就是将线程的 EIP 设置为代码洞的地址,让线程恢复执行,如下所示:

threadContext.Eip = (DWORD)remoteCave;
threadContext.ContextFlags = CONTEXT_CONTROL;
SetThreadContext(thread, &threadContext);
ResumeThread(thread);

这段代码使线程在代码洞的地址处恢复执行。由于代码洞的写法,线程根本不知道任何事情已经改变。代码洞保存了线程的原始状态,执行有效负载,恢复线程的原始状态,然后带着一切完整无损地返回到原始代码。

当你使用任何形式的代码注入时,了解你的代码洞会接触到哪些数据也很重要。例如,如果你创建一个代码洞来调用游戏的内部函数以创建并发送网络数据包,你需要确保当你完成后,任何函数接触到的全局变量(如数据包缓冲区、数据包位置标记等)都能安全地恢复。你永远无法知道在代码洞执行时游戏在做什么——它可能也在调用与你相同的函数!

注入 DLL 实现完全控制

代码洞非常强大(你可以使用汇编语言的 shellcode 让游戏做任何事情),但手工编写 shellcode 并不实际。注入 C++ 代码会方便得多,不是吗?这是可能的,但过程要复杂得多:代码必须先编译成汇编语言,打包成与位置无关的格式,意识到任何外部依赖项,完全映射到内存中,然后在某个入口点执行。

幸运的是,Windows 已经处理了所有这些问题。通过将一个 C++项目改为编译为动态库,你可以创建一个自包含、位置无关的二进制文件,称为动态链接库(DLL)。然后,你可以使用线程注入或劫持和LoadLibrary()API 函数的混合方法,将你的 DLL 文件映射到游戏的内存中。

打开main-codeInjection.cpp,该文件位于GameHackingExamples/Chapter7_ CodeInjection目录下,以及GameHackingExamples/Chapter7_CodeInjection_DLL中的dllmain.cpp,按照这部分内容中的一些示例代码进行操作。在main-codeInjection.cpp中,特别查看LoadDLL()函数。

欺骗进程加载你的 DLL

通过使用代码洞,你可以欺骗远程进程调用LoadLibrary()来加载 DLL,从而有效地将外部代码加载到其内存空间中。由于LoadLibrary()只接受一个参数,因此你可以创建一个代码洞来调用它,如下所示:

// write the dll name to memory
wchar_t* dllName = "c:\\something.dll";
int namelen = wcslen(dllName) + 1;
LPVOID remoteString =
    VirtualAllocEx(process, NULL, namelen * 2, MEM_COMMIT, PAGE_EXECUTE);
WriteProcessMemory(process, remoteString, dllName, namelen * 2, NULL);

// get the address of LoadLibraryW()
HMODULE k32 = GetModuleHandleA("kernel32.dll");
LPVOID funcAdr = GetProcAddress(k32, "LoadLibraryW");

// create a thread to call LoadLibraryW(dllName)
HANDLE thread =
    CreateRemoteThread(process, NULL, NULL,
        (LPTHREAD_START_ROUTINE)funcAdr,
        remoteString, NULL, NULL);

// let the thread finish and clean up
WaitForSingleObject(thread, INFINITE);
CloseHandle(thread);

这段代码实际上是混合了来自“绕过 ASLR 生产环境”第 128 页的线程注入代码和在 Listings 7-2 和 7-3 中创建的调用someFunction的代码洞。与前者类似,这个示例使用单参数 API 函数的函数体,具体是LoadLibrary,作为代码洞的主体。不过像后者一样,它需要将一个字符串注入到内存中,因为LoadLibrary将字符串指针作为第一个参数。一旦线程被注入,它会强制LoadLibrary加载被注入内存中的 DLL,从而有效地将外部代码注入到游戏中。

注意

为你计划注入的任何 DLL 起一个独特的名字,比如MySuperBotV2Hook.dll。更简单的名字,例如Hook.dllInjected.dll,则过于通用,具有潜在危险。如果名字与已经加载的 DLL 冲突,LoadLibrary()将认为它是同一个 DLL,从而不加载它!

一旦LoadLibrary()代码洞将你的 DLL 加载到游戏中,DLL 的入口点——即DllMain()——将以DLL_PROCESS_ATTACH作为原因被执行。当进程被终止或调用FreeLibrary()时,DLL 的入口点将以DLL_PROCESS_DETACH作为原因被调用。从入口点处理这些事件可能看起来是这样的:

BOOL APIENTRY DllMain(HMODULE hModule,
                      DWORD ul_reason_for_call,
                      LPVOID lpReserved) {
    switch (ul_reason_for_call) {
        case DLL_PROCESS_ATTACH:
            printf("DLL attached!\n");
            break;
        case DLL_PROCESS_DETACH:
            printf("DLL detached!\n");
            break;
    }
    return TRUE;
}

这个示例函数首先检查为什么调用了DllMain()。然后输出文本,指示它是因为 DLL 被附加还是分离而被调用,无论哪种情况,都会返回TRUE

请记住,DLL 的入口点是在加载器锁内执行的,加载器锁是一个全局同步锁,用于所有读取或修改进程中加载的模块列表的函数。像GetModuleHandle()GetModuleFileName()Module32First()Module32Next()等函数会使用这个加载器锁,这意味着从 DLL 入口点运行复杂代码可能会导致死锁,应当避免。

如果你需要从 DLL 入口点运行代码,请通过新线程来执行,如下所示:

DWORD WINAPI runBot(LPVOID lpParam) {
    // run your bot
    return 1;
}

// do this from DllMain() for case DLL_PROCESS_ATTACH
auto thread = CreateThread(NULL, 0, &runBot, NULL, 0, NULL);
CloseHandle(thread);

DllMain()开始,这段代码创建了一个新线程,线程从runBot()函数开始。然后它立即关闭了对该线程的句柄,因为从DllMain()执行进一步操作可能会导致严重的问题。在runBot()内部,你可以开始执行你的机器人代码。代码在游戏内部运行,这意味着你可以直接使用类型转换方法来操作内存。你还能做更多的事情,正如你将在第八章中看到的。

在注入 DLL 时,确保没有依赖问题。如果你的 DLL 依赖某些非标准的 DLL,例如,你必须先将这些 DLL 注入游戏中,或者将它们放在LoadLibrary()会搜索的文件夹中,比如PATH环境变量中的任何文件夹。前者只有在这些 DLL 没有自己的依赖关系时才有效,而后者实现起来有些棘手,并且容易发生名称冲突。最佳的选择是将所有外部库静态链接,这样它们就会直接编译到你的 DLL 中。

访问注入 DLL 中的内存

当你尝试从注入的 DLL 访问游戏内存时,进程句柄和 API 函数会成为障碍。因为游戏与所有注入其中的代码共享相同的内存空间,所以你可以直接从注入的代码访问游戏的内存。例如,要从注入的代码访问一个DWORD值,你可以写如下代码:

DWORD value = *((DWORD*)adr); // read a DWORD from adr
*((DWORD*)adr) = 1234;        // write 1234 to DWORD adr

这只是将内存地址adr强制转换为DWORD*类型,然后解引用该指针为一个DWORD。这样进行类型转换是可以的,但如果将函数抽象化并通用化,像 Windows API 包装器一样,你的内存访问代码会更加简洁。

用于从注入的代码内部访问内存的通用函数看起来像这样:

template<typename T>
T readMemory(LPVOID adr) {
    return *((T*)adr);
}

template<typename T>
void writeMemory(LPVOID adr, T val) {
    *((T*)adr) = val;
}

使用这些模板就像在第 123 页的“编写模板化内存访问函数”部分使用函数一样。以下是一个例子:

DWORD value = readMemory<DWORD>(adr); // read
writeMemory<DWORD>(adr, value++);     // increment and write

这些调用与清单 6-6 中第 124 页的调用几乎完全相同;它们只是无需将进程句柄作为参数传入,因为它们是从进程内部调用的。你可以通过创建一个名为pointMemory()的第三个模板函数来使这个方法更灵活,如下所示:

template<typename T>
T* pointMemory(LPVOID adr) {
    return ((T*)adr);
}

这个函数跳过了内存读取的解引用步骤,直接给你数据的指针。从这里开始,你可以自由地通过解引用这个指针来读取和写入内存,就像这样:

DWORD* pValue = pointMemory<DWORD>(adr); // point
DWORD value = *pValue;                   // 'read'
(*pValue)++;                             // increment and 'write'

使用像pointMemory()这样的函数,你可以省略对readMemory()writeMemory()的调用。你仍然需要事先找到adr,但从那时起,读取值、改变值并将其写回的代码会变得更加简洁。

绕过注入 DLL 中的 ASLR

类似地,由于代码已经被注入,因此无需再为游戏注入一个线程来获取基地址。相反,你可以直接调用GetModuleHandle(),像这样:

DWORD newBase = (DWORD)GetModuleHandle(NULL);

获取基地址的一个更快方法是利用游戏的 FS 内存段,这是你从注入的代码中获得的另一个超能力。这个内存段指向一个叫做线程环境块(TEB)的结构体,而 TEB 中偏移 0x30 的地方是指向进程环境块(PEB)结构体的指针。操作系统使用这些结构体,它们包含大量关于当前线程和当前进程的数据,但我们只对存储在 PEB 中的主模块基地址感兴趣,基地址位于 PEB 的偏移 0x8 处。通过内联汇编,你可以遍历这些结构来获取newBase,像这样:

DWORD newBase;
__asm {
    MOV EAX, DWORD PTR FS:[0x30]
    MOV EAX, DWORD PTR DS:[EAX+0x8]
    MOV newBase, EAX
}

第一个命令将PEB地址存储在EAX中,第二个命令读取主模块的基地址并将其存储在EAX中。最后一个命令将EAX复制到newBase

总结思考

在第六章中,我向你展示了如何远程读取内存,以及注入的 DLL 如何通过指针直接访问游戏的内存。本章展示了如何注入各种类型的代码,从纯汇编字节码到完整的 C++二进制文件。在下一章,你将了解到进入游戏内存空间究竟能赋予你多少权力。如果你觉得汇编代码注入很酷,那么你会喜欢将注入的 C++与控制流操作结合后的效果。

本章的示例代码包含了我们讨论的所有概念验证。如果你对其中的任何主题仍然不清楚,可以通过查看代码来了解具体发生了什么,并看到所有技巧的实际应用。

第八章:8

在游戏中操控控制流

image

强制一个游戏执行外部代码确实很强大,但如果你能改变一个游戏执行自身代码的方式呢?如果你能强迫游戏绕过绘制战争迷雾的代码,欺骗它让敌人透过墙壁变得可见,或者操控它传递给函数的参数呢?控制流操控让你正是可以做到这一点,通过拦截代码执行并监控、修改或阻止它,来改变一个进程的行为。

有很多方法可以操控进程的控制流,但几乎所有方法都需要修改进程的汇编代码。根据你的目标,你需要完全移除进程中的代码(这叫做NOP)或者强迫进程将执行重定向到注入的函数(这叫做钩子)。在本章的开头,你将学习 NOP、几种类型的钩子以及其他控制流操控技巧。一旦我解释了基础知识,我将展示如何将这些原则应用到常见的游戏库,如 Adobe AIR 和 Direct3D。

打开本书资源文件中的 GameHackingExamples/Chapter8_ControlFlow 目录,查看下一节的完整示例代码和 “钩子重定向游戏执行” 在 153 页的内容。

使用 NOP 移除不需要的代码

第七章描述了如何向游戏中注入新代码,但相反——从游戏中移除代码——也同样有用。一些黑客技术要求你停止某些游戏原始代码的执行,为了做到这一点,你必须将其移除。消除游戏进程中的代码的一种方法是 NOP 操作,这涉及用 NOP 指令覆盖原始的 x86 汇编代码。

何时使用 NOP

想象一个游戏,它无法显示隐形敌人的血条。很难看到隐形敌人接近,如果至少能看到他们的血条,你在战斗中就能获得巨大的优势。绘制血条的代码通常看起来像列表 8-1。

for (int i = 0; i < creatures.size(); i++) {
    auto c = creatures[i];
    if (c.isEnemy && c.isCloaked) continue;
    drawHealthBar(c.healthBar);
}

列表 8-1: drawCreatureHealthBarExample() 函数中的循环

在绘制血条时,一个有隐形生物的游戏可能会使用一个 for 循环来检查屏幕范围内的生物是否隐形。如果一个敌人不是隐形的,循环会调用某个函数(本例中是 drawHealthBar())来显示敌人的血条。

给定源代码,你可以通过简单地移除 if (c.isEnemy && c.isCloaked) continue; 来强迫游戏绘制隐形敌人的血条。但作为一个游戏黑客,你只有汇编代码,而不是源代码。当简化时,列表 8-1 翻译后的汇编代码看起来像下面的伪代码:

  startOfLoop:                        ; for
      MOV i, 0                        ; int i = 0
      JMP condition                   ; first loop, skip increment
  increment:
      ADD i, 1                        ; i++
  condition:
      CMP i, creatures.Size()         ; i < creatures.size()
      JNB endOfLoop                   ; exit loop if i >= creatures.size()
  body:
      MOV c, creatures[i]             ; auto c = creatures[i]
      TEST c.isEnemy, c.isEnemy       ; if c.isEnemy
      JZ drawHealthBar                ; draw bar if c.isEnemy == false
      TEST c.isCloaked, c.isCloaked   ; && c.isCloaked
      JZ drawHealthBar                ; draw bar if c.isCloaked == false
➊     JMP increment                   ; continue
  drawHealthBar:
      CALL drawHealthBar(c.healthBar) ; drawHealthBar(c.healthBar)
      JMP increment                   ; continue
  endOfLoop:

为了让游戏无论敌人是否隐形都能绘制所有敌人的血条,你需要移除在 c.isEnemy && c.isCloakedtrue 时执行的 JMP increment 指令 ➊。不过,在汇编中,用不执行任何操作的指令替换不需要的代码比删除代码更容易。这就是 NOP 指令的作用。由于 NOP 是一个字节(0x90),你可以用两个 NOP 指令覆盖两字节的 JMP increment 指令。当处理器遇到这些 NOP 指令时,它会跳过这些指令并进入 drawHealthBar(),即使 c.isEnemy && c.isCloakedtrue

如何进行 NOP 操作

NOP(无操作)一段汇编代码的第一步是使代码所在的内存块可写。虽然同一内存页上的代码在你写入 NOP 指令时仍然可能被执行,但你还需要确保内存仍然是可执行的。你可以通过将内存保护设置为PAGE_EXECUTE_READWRITE来同时完成这两个任务。一旦内存得到了适当的保护,你就可以写入 NOP 指令并完成操作。从技术上讲,保持内存可写并不会造成问题,但当你完成操作后,恢复原始的保护设置是一种好的实践。

只要你有适当的设施来写入和保护内存(如第六章所描述),你就可以编写类似于示例 8-2 中显示的函数,将 NOP 指令写入游戏内存。(可以通过打开项目中的 NOPExample.cpp 文件来跟随操作。)

template<int SIZE>
void writeNop(DWORD address)
{
    auto oldProtection =
        protectMemory<BYTE[SIZE]>(address, PAGE_EXECUTE_READWRITE);

    for (int i = 0; i < SIZE; i++)
        writeMemory<BYTE>(address + i, 0x90);

    protectMemory<BYTE[SIZE]>(address, oldProtection);
}

示例 8-2:正确的 NOP 操作,完整的内存保护

在这个例子中,writeNop() 函数设置了适当的内存保护,写入了与 SIZE 相等数量的 NOP 指令,并重新应用了原始的内存保护级别。

writeNop() 函数将 NOP 指令的数量作为模板参数传递,因为内存函数需要在编译时使用正确大小的类型。传递一个整数SIZE会告诉内存函数在编译时操作一个类型为 BYTE[SIZE] 的数组。为了在运行时指定动态大小,只需去掉循环,改为调用 protectMemory<BYTE>,并传入 addressaddress + SIZE 作为参数。只要大小不超过一个页面(实际上,你不应该将一个完整页面做 NOP),这将确保即使内存在页面边界处,它也会被正确保护。

使用你想要放置 NOP 指令的地址以及要放置的 NOP 指令数量来调用此函数:

writeNop<2>(0xDEADBEEF);

请记住,NOP 指令的数量应与被删除指令的字节大小匹配。这个 writeNop() 调用将两条 NOP 指令写入地址 0xDEADBEEF。

练习 NOP 操作

如果你还没有,赶紧打开本章示例代码中的NOPExample.cpp,玩一玩。你会发现writeNop()函数的工作实现和一个有趣的函数getAddressforNOP(),它扫描示例程序的内存,以找出 NOP 命令应该放置的位置。

要查看 NOP 命令的实际效果,在 Visual Studio 调试器中运行编译好的 NOP 应用程序,并在writeNop()函数的开始和结束处设置断点。当第一个断点被触发时,按下 ALT-8 打开反汇编窗口,在输入框中输入address并按回车。这将把你带到 NOP 的目标地址,在那里你将看到完整的汇编代码。按 F5 继续执行,这将触发第二个断点,并允许应用程序放置 NOP 指令。最后,跳回反汇编窗口中的address,你将看到代码已被 NOP 替换。

你可以重新调整这段代码做其他有趣的事情。例如,你可能尝试将 NOP 指令放在比较操作上,而不是跳转指令,或者甚至修改跳转的类型或目标地址。

这些和其他替代方法可能有效,但请注意,它们比用 NOP 命令覆盖单个 JMP 指令更容易出错。当修改外部代码时,请尽量减少更改,以最小化错误的可能性。

挂钩以重定向游戏执行

到目前为止,我已经向你展示了如何通过向游戏中添加代码、劫持其线程、创建新线程,甚至从其执行流中移除现有代码来操控游戏。这些方法本身已经非常强大,但当它们结合在一起时,就形成了一种更为强大的操控方法——挂钩。挂钩允许你拦截执行的精确分支,并将它们重定向到你编写的注入代码,从而决定游戏接下来应该做什么,且挂钩有多种形式。在本节中,我将教你四种最强大的游戏黑客挂钩方法:调用挂钩、虚拟函数表挂钩、导入地址表挂钩和跳转挂钩。

调用挂钩

调用挂钩直接修改CALL操作的目标,将其指向一段新的代码。在 x86 汇编中,CALL操作有几种变体,但挂钩通常只应用于其中一种:近调用,它将立即地址作为操作数。

在内存中使用近调用

在汇编程序中,近调用看起来是这样的:

CALL 0x0BADF00D

这种近调用由字节 0xE8 表示,因此你可能会假设它在内存中是这样存储的:

0xE8 0x0BADF00D

或者,当拆分成单个字节并交换字节序时,像这样:

0xE8 0x0D 0xF0 0xAD 0x0B

但近距离调用在内存中的结构并不像看起来那么简单。近距离调用不会存储被调用函数的绝对地址,而是存储相对于调用之后立即地址的偏移量。由于近距离调用是 5 字节,因此调用之后立即的地址是内存中的 5 字节。基于此,可以按以下方式计算存储的地址:

calleeAddress – (callAddress + 5)

如果CALL 0x0BADF00D位于内存中的 0xDEADBEEF 地址,那么 0xE8 之后的值是这样的:

0x0BADF00D – (0xDEADBEEF + 5) = 0x2D003119

在内存中,该CALL指令看起来是这样的:

0xE8 0x19 0x31 0x00 0x2D

要挂钩一个近距离调用,首先需要更改 0xE8 之后的偏移量(即小端格式的 0x19 0x31 0x00 0x2D)以指向你的新被调用函数。

挂钩近距离调用

按照示例 8-2 中展示的相同内存保护规则,你可以这样挂钩一个近距离调用(通过打开CallHookExample.cpp来跟随):

  DWORD callHook(DWORD hookAt, DWORD newFunc)
  {
      DWORD newOffset = newFunc - hookAt - 5;

      auto oldProtection =
          protectMemory<DWORD>(hookAt + 1, PAGE_EXECUTE_READWRITE);

      DWORD originalOffset = readMemory<DWORD>(➊hookAt + 1);
      writeMemory<DWORD>(hookAt + 1, newOffset);
      protectMemory<DWORD>(hookAt + 1, oldProtection);

➋     return originalOffset + hookAt + 5;
  }

该函数以CALL的地址(hookAt)和重定向执行的地址(newFunc)作为参数,并使用它们来计算调用newFunc包含的地址的偏移量。在应用正确的内存保护后,callHook()函数将新的偏移量写入hookAt + 1的内存 ➊,然后恢复原来的内存保护,计算原始调用的地址 ➋,并将该值返回给调用者。

下面是你可能在游戏破解中实际使用这样的函数的方法:

DWORD origFunc = callHook(0xDEADBEEF, (DWORD)&someNewFunction);

这将近距离调用挂钩到 0x0BADF00D(位于 0xDEADBEEF),并将其重定向到someNewFunction的地址,这就是你的破解程序将执行的代码。调用后,origFunc的值将包含 0x0BADF00D。

清理堆栈

新的被调用函数还必须正确处理堆栈,保存寄存器,并传递正确的返回值。至少,这意味着你的替代函数必须在调用约定和参数数量上与游戏的原始函数匹配。

假设这是原始的完整函数调用,汇编语言如下:

PUSH 1
PUSH 456
PUSH 321
CALL 0x0BADF00D 
ADD ESP, 0x0C

你可以通过查看函数使用 C++ __cdecl调用约定来判断,因为堆栈是由调用者重置的。另外,从堆栈中清除的 0x0C 字节显示有三个参数,你可以通过以下方式计算:

image

当然,你也可以通过检查推送到堆栈的项数来获取参数数量:有三个PUSH命令,每个参数一个。

编写调用钩子

无论如何,新的被调用函数someNewFunction必须遵循__cdecl约定,并且有三个参数。以下是新被调用函数的示例框架:

DWORD __cdecl someNewFunction(DWORD arg1, DWORD arg2, DWORD arg3)
{

}

在 Visual Studio 中,C++ 程序默认使用 __cdecl 调用约定,因此从技术上讲,你可以在函数定义中省略它;然而,我发现最好保持详细,这样可以养成具体明确的习惯。还要记住,如果调用者期望返回值,那么你的函数的返回类型也应该匹配。这个示例假设返回类型总是 DWORD 或更小。因为这个大小范围内的返回值都会通过 EAX 返回,接下来的示例也会使用 DWORD 作为返回类型。

在大多数情况下,钩子通过调用原始函数并将其返回值传递回调用者来完成。下面是这些内容如何组合在一起的示例:

typedef DWORD (__cdecl _origFunc)(DWORD arg1, DWORD arg2, DWORD arg3);

_origFunc* originalFunction =
    (_origFunc*)hookCall(0xDEADBEEF, (DWORD)&someNewFunction);

DWORD __cdecl someNewFunction(DWORD arg1, DWORD arg2, DWORD arg3)
{
    return originalFunction(arg1, arg2, arg3);
}

这个示例使用 typedef 声明一个类型,表示原始函数的原型,并创建一个指向该原始函数的指针。然后 someNewFunction() 使用这个指针调用原始函数,传递原始参数并将返回值传递回调用者。

目前,someNewFunction() 所做的只是返回到原始函数。但你可以在 someNewFunction() 的调用中做任何你想做的事情。你可以修改传递给原始函数的参数,或者拦截并存储有趣的参数以备后用。如果你知道调用者不期望返回值(或者知道如何伪造返回值),你甚至可以忽略原始函数,完全替换、复制或改进其功能,放入新的被调用函数中。一旦你掌握了这项技能,你就可以在游戏的任何部分添加你自己的本地 C 或 C++ 代码。

VF 表格钩子

与调用钩子不同,虚拟函数(VF)表钩子并不修改汇编代码。相反,它们修改存储在类的 VF 表中的函数地址。(如果你需要复习 VF 表,请参阅《具有虚拟函数的类》章节中的第 75 页)。同一类类型的所有实例共享一个静态的 VF 表,因此 VF 表钩子会拦截对成员函数的所有调用,无论游戏是从哪个类实例调用该函数。这既强大又棘手。

VF 表格的真相

为了简化说明,当我说 VF 表钩子可以拦截所有对函数的调用时,我稍微撒了个谎。实际上,只有当虚函数的调用方式让编译器产生某种合理的类型模糊时,VF 表才会被遍历。例如,当通过inst->function()调用函数时,VF 表会被遍历。虚函数调用时如果编译器对类型没有疑问,例如inst.function()或类似的调用,VF 表则不会被遍历,因为编译器已经知道函数的地址。相反,从一个作用域调用inst.function(),如果inst是作为引用传入的,那么 VF 表就会被遍历。在你尝试部署 VF 表钩子之前,确保你想要挂钩的函数调用有类型模糊性。

编写 VF 表钩子

在我们深入探讨如何放置 VF 表钩子之前,我们需要再次讨论那些令人头疼的调用约定。VF 表由类实例用来调用虚成员函数,所有成员函数都将采用__thiscall约定。__thiscall这个名字来源于成员函数用来引用当前类实例的this指针。因此,成员函数会将this作为伪参数放置在 ECX 寄存器上。

通过声明一个类作为所有__thiscall钩子回调的容器,确实可以匹配__thiscall的原型,但我不太倾向于使用这种方法。相反,我发现通过内联汇编来控制数据更容易。让我们看看当你在一个像这样的类上放置 VF 钩子时如何控制数据:

class someBaseClass {
    public:
        virtual DWORD someFunction(DWORD arg1) {}
};
class someClass : public someBaseClass {
    public:
        virtual DWORD someFunction(DWORD arg1) {}
};

someBaseClass类只有一个成员(一个公共虚函数),而someClass类继承自someBaseClass并重写了someBaseClass::someFunction成员。要挂钩someClass::someFunction,你需要在 VF 表钩子中复制原型,如清单 8-3 所示(在项目中的VFHookExample.cpp文件中查看)。

   DWORD __stdcall someNewVFFunction(DWORD arg1)
   { 
➊      static DWORD _this;
        __asm MOV _this, ECX
   }

清单 8-3:VF 表钩子的开始

这个函数之所以能作为钩子工作,是因为__thiscall__stdcall的唯一区别在于前者将this传递到 ECX 寄存器。为了调和这一小小的差异,回调函数使用内联汇编(用__asm表示)将this从 ECX 复制到静态变量➊。由于静态变量实际上初始化为全局变量,因此在执行MOV _this, ECX之前,唯一执行的代码是设置栈帧的代码——而这段代码从不接触 ECX。这确保了在执行汇编时,ECX 中的值是正确的。

注意

如果多个线程开始调用相同的 VF 函数, someNewVFFunction() 钩子将会失效,因为 _this 可能在一个调用被修改的同时仍然被另一个调用使用。我个人从未遇到过这个问题,因为游戏通常不会在线程之间传递多个关键类的实例,但一个有效的解决方法是将 _this 存储在线程本地存储中,从而确保每个线程都有自己的副本。

在返回之前,VF 表回调还必须恢复 ECX,以保持与 __thiscall 调用约定一致。以下是这个过程的具体操作:

DWORD __stdcall someNewVFFunction(DWORD arg1)
{
    static DWORD _this;
    __asm MOV _this, ECX

    // do game modifying stuff here

 __asm ➊MOV ECX, _this
}

在执行一些游戏破解代码之后,这个版本的函数 someNewVFFunction() 使用反向版本的第一个 MOV 指令来恢复 ECX ➊,该指令来自 Listing 8-3。

然而,与 __cdecl 函数不同的是,你不应该仅使用函数指针和 typedef(如同在调用钩子中那样)从纯 C++ 调用使用 __thiscall 调用约定的函数。当从 VF 表钩子中调用原始函数时,必须使用内联汇编——这是确保正确传递数据(特别是 _this)的唯一方法。例如,以下是如何继续构建 someNewVFFunction() 钩子:

   DWORD __stdcall someNewVFFunction(DWORD arg1)
   {
       static DWORD _this, _ret;
       __asm MOV _this, ECX

       // do pre-call stuff here

       __asm {
           PUSH arg1
           MOV ECX, _this
➊          CALL [originalVFFunction]
➋          MOV _ret, EAX
       }

       // do post-call stuff here

➌      __asm MOV ECX, _this
       return _ret;
   }

现在,someNewVFFunction()this 存储在 _this 变量中,允许一些代码执行,调用被钩住的原始游戏函数 ➊,将该函数的返回值存储在 _ret ➋,然后允许更多代码执行,恢复 this 到 ECX ➌,并返回存储在 _ret 中的值。被调用者负责清理 __thiscall 调用的栈,因此与调用钩子不同,推入的参数无需移除。

注意

如果你想在任何时刻移除一个被推入的参数,使用汇编指令 ADD ESP, 0x4 ,因为一个参数占 4 字节。

使用 VF 表钩子

在确定了调用约定并建立了基本的回调框架后,是时候进入有趣的部分了:真正使用 VF 表钩子。每个类实例的第一个成员是指向该类 VF 表的指针,因此设置 VF 表钩子只需要类实例的地址和要钩住的函数索引。通过这两条信息,你只需编写少量代码就能设置钩子。以下是一个示例:

DWORD hookVF(DWORD classInst, DWORD funcIndex, DWORD newFunc)
{
    DWORD VFTable = ➊readMemory<DWORD>(classInst);
    DWORD hookAt = VFTable + funcIndex * sizeof(DWORD);

    auto oldProtection =
        protectMemory<DWORD>(hookAt, PAGE_READWRITE);
    DWORD originalFunc = readMemory<DWORD>(hookAt);
    writeMemory<DWORD>(hookAt, newFunc);
    protectMemory<DWORD>(hookAt, oldProtection);

    return originalFunc;
}

hookVF() 函数通过读取类实例的第一个成员 ➊ 来找到 VF 表,并将其存储在 VFTable 中。由于 VF 表只是一个 DWORD 大小地址的数组,这段代码通过将函数在 VF 表中的索引(本例中的 funcIndex)乘以 DWORD 的大小,即 4,再将结果加到 VF 表的地址上,从而找到函数的地址。之后,hookVF() 的作用类似于调用钩子:它通过设置适当的保护确保内存可访问,存储原始函数地址以便后续使用,写入新的函数地址,最后恢复原始的内存保护。

你通常会钩住游戏实例化的类的 VF 表,而像 hookVF() 这样的函数调用,钩住 VF 表看起来是这样的:

DWORD origVFFunction =
    hookVF(classInstAddr, 0, (DWORD)&someNewVFFunction);

像往常一样,你需要提前找到 classInstAddrfuncIndex 参数。

在一些非常小众的情况下,VF 表钩子是有用的,而找到正确的类指针和函数可能非常困难。考虑到这一点,我不会展示一些牵强的使用案例,而是会在“将跳转钩子和 VF 钩子应用于 Direct3D”的 第 175 页 中回到 VF 表钩子,一旦我讨论了其他类型的钩子。

如果你想在阅读更多内容之前先玩一下 VF 钩子,可以在本书资源文件中的示例类里添加新的虚拟函数,并练习钩住它们。你甚至可以创建一个从 someBaseClass 派生的第二个类,并在其虚拟表上放置一个钩子,展示如何在两个继承自相同基类的类上拥有两个完全独立的 VF 钩子。

IAT 钩子

IAT 钩子实际上是通过替换特定类型的 VF 表中的函数地址来工作的,这种表被称为 导入地址表(IAT)。每个进程中加载的模块都包含一个 IAT,在其 PE 头部。一个模块的 IAT 保存了该模块依赖的所有其他模块的列表,以及该模块使用的每个依赖项中的函数列表。可以将 IAT 看作是一个 API 调用彼此的查找表。

当模块被加载时,它的依赖项也会被加载。依赖项加载是一个递归过程,直到所有模块的所有依赖项都被加载为止。当每个依赖项被加载时,操作系统会找到被依赖模块所使用的所有函数,并用函数地址填充其 IAT 中的空白位置。然后,当模块调用依赖项中的某个函数时,它会通过从 IAT 中解析函数地址来执行该调用。

为了可移植性付出的代价

函数地址总是从 IAT 中实时解析出来的,所以钩住 IAT 类似于钩住 VF 表。由于函数指针存储在 IAT 中并与其实际名称一起出现,所以不需要进行逆向工程或内存扫描;只要你知道你想钩住的 API 的名称,就可以钩住它!此外,IAT 钩子使你可以在模块特定的基础上轻松钩住 Windows API 调用,从而允许你的钩子仅拦截来自游戏主模块的 API 调用。

然而,这种可移植性是有代价的;放置 IAT 钩子的代码比你迄今所见的要复杂得多。首先,你需要定位游戏主模块的 PE 头部。由于 PE 头是任何二进制文件中的第一个结构,你可以在每个模块的基地址处找到它,如 Listing 8-4 中所示(可以在项目的 IATHookExample.cpp 文件中跟随)。

DWORD baseAddr = (DWORD)GetModuleHandle(NULL);

Listing 8-4:获取模块的基地址

一旦你找到了基地址,就必须验证 PE 头部是否有效。这项验证非常重要,因为一些游戏会在加载后通过混淆 PE 头部中的非必要部分来防止这类钩子。有效的 PE 头部之前是一个 DOS 头部,表示该文件是一个 DOS MZ 可执行文件;DOS 头部的标识值为 0x5A4D。DOS 头部中的一个成员e_lfanew指向可选头部,后者包含诸如代码大小、版本号等信息,并由魔术值 0x10B 标识。

Windows API 有 PE 结构IMAGE_DOS_HEADERIMAGE_OPTIONAL_HEADER,分别对应 DOS 头部和可选头部。你可以通过代码如 Listing 8-5 来验证 PE 头部。

auto dosHeader = pointMemory<IMAGE_DOS_HEADER>(baseAddr);
if (dosHeader->e_magic != 0x5A4D)
    return 0;

auto optHeader =
    pointMemory<IMAGE_OPTIONAL_HEADER>(baseAddr + dosHeader->e_lfanew + 24);
if (optHeader->Magic != 0x10B)
    return 0;

Listing 8-5: 确认 DOS 头部和可选头部有效

pointMemory()的调用创建了两个指向需要检查的头部的指针。如果任何一个if()语句返回0,则表示对应的头部的魔术数字错误,意味着 PE 头部无效。

从汇编中对 IAT 的引用是硬编码的,这意味着汇编引用不会遍历 PE 头部来定位 IAT。相反,每个函数调用都有一个静态位置,指示在哪里可以找到函数地址。这意味着通过覆盖 PE 头部来声明没有导入是防止 IAT 钩子的可行方法,一些游戏就采取了这种保护措施。

为了应对这种情况,你还需要确保游戏的 IAT 仍然存在。Listing 8-6 展示了如何在 Listing 8-5 中的代码中添加此检查。

auto IAT = optHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
if (IAT.Size == 0 || IAT.VirtualAddress == 0)
    return 0;

Listing 8-6: 检查 IAT 是否实际存在

PE 头部包含许多部分,用于存储应用程序代码、嵌入资源、重定位等信息。Listing 8-6 中的代码特别关注数据段——正如你可能猜到的,它存储了许多不同类型的数据。每种类型的数据存储在自己的目录中,IMAGE_OPTIONAL_HEADER中的DataDirectory成员是一个目录头数组,描述了数据段中每个目录的大小和虚拟地址。Windows API 定义了一个常量IMAGE_DIRECTORY_ENTRY_IMPORT,它恰好是 IAT 头部在DataDirectory数组中的索引。

因此,这段代码使用optHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]来解析 IAT 的头部,并检查该头部的SizeVirtualAddress是否非零,基本上确认了其存在性。

遍历 IAT

一旦你确认 IAT 仍然完好无损,你就可以开始遍历它了,这也是 IAT 钩子开始变得复杂的地方。IAT 是一个由称为导入描述符的结构体数组组成。每个依赖项都有一个导入描述符,每个导入描述符指向一个称为thunks的结构体数组,每个 thunk 代表从依赖项中导入的函数。

幸运的是,Windows API 通过IMAGE_IMPORT_DESCRIPTORIMAGE_THUNK_DATA结构分别暴露了导入描述符和 thunks。由于这些结构是预定义的,你不必自己创建它们,但这并没有使遍历 IAT 的代码更加简洁。要理解我的意思,看看列表 8-7,它是基于列表 8-4 至 8-6 构建的。

   auto impDesc =
       pointMemory<IMAGE_IMPORT_DESCRIPTOR>(➊baseAddr + IAT.VirtualAddress);

➋ while (impDesc->FirstThunk) {
➌     auto thunkData =
          pointMemory<IMAGE_THUNK_DATA>(baseAddr + impDesc->OriginalFirstThunk);
      int n = 0;
➍     while (thunkData->u1.Function) {
          // the hook happens in here
          n++;
          thunkData++;
      }
      impDesc++;
   }

列表 8-7:遍历 IAT 以查找函数

请记住,导入描述符是相对于 PE 头部的开始位置存储的,代码通过将模块的基地址加到 IAT 目录头部找到的虚拟地址 ➊,创建一个指针impDesc,指向模块的第一个导入描述符。

导入描述符存储在一个顺序数组中,若某个描述符的FirstThunk成员为NULL,则表示数组的结束。知道这一点后,代码使用while循环 ➋,直到impDesc->FirstThunkNULL,每次迭代时通过执行impDesc++来递增描述符。

对于每个导入描述符,代码会创建一个指针,名为thunkData ➌,指向描述符中的第一个 thunk。通过一个熟悉的循环,代码会遍历 thunks ➍,直到找到一个Function成员为NULL的 thunk。该循环还使用一个整数n来跟踪当前的 thunk 索引,因为在放置钩子时,索引非常重要。

放置 IAT 钩子

从这里开始,放置钩子只需要找到正确的函数名称并替换函数地址。你可以在嵌套的while循环中找到该名称,如列表 8-8 所示。

char* importFunctionName =
    pointMemory<char>(baseAddr + (DWORD)thunkData->u1.AddressOfData + 2);

列表 8-8:查找函数名称

每个 thunk 的函数名称存储在thunkData->u1.AddressOfData + 2字节处,因此你可以将该值加到模块的基地址上,以定位内存中的函数名称。

获取到函数名称的指针后,使用strcmp()来检查它是否为目标函数,方法如下:

if (strcmp(importFuncName, funcName) == 0) {
    // the final step happens in here
}

一旦你通过其名称找到了目标函数,你只需将函数地址覆盖为你自己函数的地址。与函数名称不同,函数地址存储在每个导入描述符开头的数组中。使用n从 thunk 循环中,你最终可以设置钩子,如列表 8-9 所示。

   auto vfTable = pointMemory<DWORD> (baseAddr + impDesc->FirstThunk);
   DWORD original = vfTable[n];

➊ auto oldProtection = protectMemory<DWORD>((DWORD)&vfTable[n], PAGE_READWRITE);
➋ vfTable[n] = newFunc;
   protectMemory<DWORD>((DWORD)&vfTable[n], oldProtection);

列表 8-9:查找函数地址

这段代码通过将第一个 thunk 的地址加到模块基地址来定位当前描述符的 VF 表。VF 表是一个函数地址数组,因此代码使用n变量作为索引来定位目标函数地址。

一旦找到地址,列表 8-9 中的代码就像典型的 VF 钩取一样工作:它存储原始函数地址,将 VF 表中索引n的保护设置为PAGE_READWRITE ➊,将新的函数地址插入到 VF 表中 ➋,最后恢复旧的保护状态。

如果你将列表 8-4 到 8-9 的代码拼接起来,最终的 IAT 钩取函数看起来就像列表 8-10。

DWORD hookIAT(const char* funcName, DWORD newFunc)
                 {
    DWORD baseAddr = (DWORD)GetModuleHandle(NULL);
    auto dosHeader = pointMemory<IMAGE_DOS_HEADER>(baseAddr);
    if (dosHeader->e_magic != 0x5A4D)
        return 0;

    auto optHeader =
        pointMemory<IMAGE_OPTIONAL_HEADER>(baseAddr + dosHeader->e_lfanew + 24);
    if (optHeader->Magic != 0x10B)
        return 0;

    auto IAT =
        optHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
    if (IAT.Size == 0 || IAT.VirtualAddress == 0)
        return 0;

    auto impDesc =
        pointMemory<IMAGE_IMPORT_DESCRIPTOR>(baseAddr + IAT.VirtualAddress);

    while (impDesc->FirstThunk) {
        auto thunkData =
            pointMemory<IMAGE_THUNK_DATA>(baseAddr + impDesc->OriginalFirstThunk);
        int n = 0;
        while (thunkData->u1.Function) {
            char* importFuncName = pointMemory<char>
                (baseAddr + (DWORD)thunkData->u1.AddressOfData + 2);
            if (strcmp(importFuncName, funcName) == 0) {
                auto vfTable = pointMemory<DWORD>(baseAddr + impDesc->FirstThunk);
                DWORD original = vfTable[n];
                auto oldProtection =
                    protectMemory<DWORD>((DWORD)&vfTable[n], PAGE_READWRITE);
                vfTable[n] = newFunc;
                protectMemory<DWORD>((DWORD)&vfTable[n], oldProtection);
                return original;
            }
            n++;
            thunkData++;
        }
        impDesc++;
    }
}

列表 8-10:完整的 IAT 钩取函数

这是我们到目前为止编写的最复杂的代码,在页面压缩显示时非常难以阅读。如果你还没完全理解它的作用,建议在继续之前先学习本书资源文件中的示例代码。

使用 IAT 钩取与游戏线程同步

使用列表 8-10 中的代码,钩取任何 Windows API 函数都变得像知道函数名和正确的原型一样简单。Sleep() API 是游戏黑客中常用的钩取 API,因为机器人可以通过钩取Sleep()来与游戏的主循环同步线程。

与线程同步

你的注入代码必然需要与游戏的主循环同步,否则它将无法正常工作。例如,当你读写大于 4 字节的数据时,如果不同步,游戏可能会同时读写这些数据。这时你就会干扰到游戏,反之亦然,导致各种竞态条件和数据损坏问题。同样,如果你尝试从自己的线程调用游戏函数,如果该函数不是线程安全的,可能会导致游戏崩溃。

由于 IAT 钩取是对 PE 头的线程安全修改,它们可以从任何线程进行设置。通过将钩子放置在游戏主循环之前或之后调用的函数上,你可以有效地与游戏的主线程同步。你需要做的就是放置钩子,并在钩子回调中执行任何线程敏感的代码。

下面是使用hookIAT()钩取Sleep() API 的一种方式:

VOID WINAPI newSleepFunction(DWORD ms)
{
    // do thread-sensitive things
    originalSleep(ms);
}

typedef VOID (WINAPI _origSleep)(DWORD ms);
_origSleep* originalSleep =
    (_origSleep*)hookIAT("Sleep", (DWORD)&newSleepFunction);

下面是为什么这能奏效的原因。在游戏的主循环结束时,它可能会调用Sleep()来休息,直到准备好绘制下一帧。由于处于休眠状态,你可以安全地执行任何操作而不必担心同步问题。有些游戏可能不会这样做,或者它们可能从多个线程调用Sleep(),这些游戏就需要使用不同的方法。

一个更具可移植性的替代方法是钩住PeekMessageA() API 函数,因为游戏通常会在等待输入时从主循环调用该函数。然后,你的机器人可以在PeekMessageA()钩子内进行线程敏感的操作,确保它们从游戏的主线程中执行。你也可能希望你的机器人使用这种方法钩住send()recv() API 函数,因为拦截这些函数可以相对简单地创建一个数据包嗅探器。

跳转钩子

跳转钩子允许你在没有分支代码可以操作的地方钩住代码。跳转钩子用一个无条件跳转替换被钩住的代码,跳转到一个弹跳函数。当跳转被触发时,弹跳函数会保存所有当前的寄存器和标志值,调用你选择的回调函数,恢复寄存器,恢复标志,执行被钩住的代码,最后跳回钩子下方的代码。这一过程在图 8-1 中展示。

image

图 8-1:一个跳转钩子

原始代码展示了你在游戏中可能遇到的一些未修改的汇编代码,而钩住的代码展示了跳转钩子钩住后该汇编代码的样子。弹跳函数框展示了一个汇编语言的示例弹跳函数,而回调函数则表示你通过钩子试图执行的代码。在原始代码中,汇编代码是从上到下执行的。在钩住的代码中,要从SUB EAX,1指令跳到RETN指令,执行路径必须按照虚线箭头所示的路径进行。

注意

如果你的回调代码很简单,它可以直接集成到弹跳函数中。并且并不总是需要存储和恢复寄存器和标志,但这样做是良好的实践。

放置跳转

无条件跳转的字节码与近距离调用类似,但第一个字节是 0xE9 而不是 0xE8。(有关更多信息,请参阅《在内存中使用近距离调用》第 153 页。)在图 8-1 中,无条件跳转JMP trampoline替换了以下四个操作:

POP EAX
MOV AL, 1
POP EDI
POP ESI

在这种情况下,你需要替换多个连续操作,以适应无条件跳转的 5 字节大小。你可能会遇到需要替换的操作(或操作)的大小大于 5 字节的情况。当这种情况发生时,用 NOP 指令替换剩余的字节。

现在,让我们来看一下如何替换这些操作。清单 8-11 展示了如何放置跳转钩子的代码。

   DWORD hookWithJump(DWORD hookAt, DWORD newFunc, int size)
   {
       if (size > 12) // shouldn't ever have to replace 12+ bytes
           return 0;
➊      DWORD newOffset = newFunc - hookAt - 5;

       auto oldProtection =
           protectMemory<DWORD[3]>(hookAt + 1,PAGE_EXECUTE_READWRITE);
➋      writeMemory<BYTE>(hookAt, 0xE9);
➌      writeMemory<DWORD>(hookAt + 1, newOffset);
       for (unsigned int i = 5; i < size; i++)
           writeMemory<BYTE>(hookAt + i, 0x90);
       protectMemory<DWORD[3]>(hookAt + 1, oldProtection);

       return hookAt + 5;
   }

清单 8-11:如何设置跳转钩子

该函数接受钩子的地址、回调函数的地址和要覆盖的内存大小(以字节为单位)作为参数。首先,它计算钩子位置和跳板之间的偏移量,并将结果存储在 newOffset ➊ 中。接下来,将 PAGE_EXECUTE_READWRITE 权限应用于需要修改的内存。然后将无条件跳转指令(0xE9) ➋ 和回调函数的地址 ➌ 写入内存,并且使用 for 循环将 NOP 指令(0x90)写入任何被弃用的字节。旧的保护被重新应用后,hookWithJump() 返回到原始地址。

请注意,hookWithJump() 函数确保在放置跳转之前,size 不会超过 12。这个检查很重要,因为跳转占用 5 字节,这意味着如果前四个命令都是单字节,它最多可以替换五个命令。如果前四个命令是单字节的,第五个命令需要超过 8 字节才会触发 if (size > 12) 子句。由于 9 字节的操作非常罕见,因此 12 是一个安全但灵活的限制。设置这个限制可以避免各种 bug,尤其是当你的机器人动态检测 size 参数时。如果机器人出错并传递了 size500,000,000,例如,这个检查将阻止你把整个宇宙做 NOP 操作。

编写跳板函数

使用清单 8-11 中的函数,你可以复制图 8-1 中显示的钩子,但首先你需要按以下方式创建跳板函数:

   DWORD restoreJumpHook = 0;
   void __declspec(naked) myTrampoline()
   {
      __asm {
➊         PUSHFD
➋         PUSHAD
➌         CALL jumpHookCallback
➍         POPAD
➎         POPFD
➏         POP EAX
          MOV AL, 1
          POP EDI
➐         POP ESI
➑         JMP [restoreJumpHook]
      }
  }

就像在图 8-1 中描述的跳板一样,这个跳板会存储当前所有标志 ➊ 和寄存器值 ➋,调用回调函数 ➌,恢复寄存器 ➍,恢复标志 ➎,执行被钩子替换的代码 ➏ 和 ➐,最后跳回到原始代码的跳转位置下方并执行 NOP 操作 ➑。

注意

为了确保编译器不会在跳板中自动生成任何额外的代码,请始终使用 __declspec(naked) 约定声明跳板。

完成跳转钩子

一旦创建了跳板,定义回调函数并按如下方式设置钩子:

void jumpHookCallback() {
    // do stuff
}
restoreJumpHook = hookWithJump(0xDEADBEEF, &myTrampoline, 5);

最后,在 jumpHookCallback() 函数内部,执行依赖于钩子的代码。如果你的代码需要读取或写入在钩子执行时的寄存器值,那么你很幸运。PUSHAD 命令会按照 EAX、ECX、EDX、EBX、原始 ESP、EBP、ESI 和 EDI 的顺序将寄存器值压入栈中。跳板会在调用 jumpHookCallback() 之前直接调用 PUSHAD,因此你可以将寄存器值作为参数引用,像这样:

void jumpHookCallback(DWORD EDI, DWORD ESI, DWORD EBP, DWORD ESP,
                      DWORD EBX, DWORD EDX, DWORD ECX, DWORD EAX) {
    // do stuff
}
restoreJumpHook = hookWithJump(0xDEADBEEF, &myTrampoline, 5);

由于跳板使用 POPAD 从栈中直接恢复寄存器的值,因此你对参数所做的任何修改将在寄存器从栈中恢复时应用到实际寄存器。

像 VF 表钩子一样,跳转钩子很少需要使用,而且用一个简单的例子来模拟它们可能会比较棘手。为了帮助你理解它们,我将在《将跳转钩子和 VF 钩子应用于 Direct3D》一文中,展示一个现实世界中的实际应用案例,内容位于第 175 页。

专业 API 钩子库

有一些预写的钩子库,比如微软的 Detours 和 MadCHook,仅使用跳转钩子。这些库可以自动检测并跟踪其他钩子,知道需要替换多少条指令,并且为你生成跳板函数。这些库之所以能够做到这一点,是因为它们理解如何反汇编并分析汇编指令,以确定指令长度、跳转目标等。如果你需要使用如此强大的钩子功能,使用这些库可能比自己编写更为合适。

将调用钩子应用于 Adobe AIR

Adobe AIR 是一个开发框架,可以用来在类似 Adobe Flash 的环境中制作跨平台游戏。AIR 是在线游戏中常用的框架,因为它允许开发者使用一种叫做 ActionScript 的多功能高级语言编写跨平台代码。ActionScript 是一种解释型语言,AIR 在虚拟机中运行这些代码,这使得直接钩住游戏特定的代码变得不可行。相反,钩住 AIR 本身会更容易。

本节的示例代码可以在本书的源文件中找到,路径为 GameHackingExamples/Chapter8_AdobeAirHook。这些代码来自我以前的一个项目,适用于运行 Adobe AIR.dll 版本 3.7.0.1530 的任何游戏。我也让它在其他版本上运行过,但不能保证它能在更新或更旧的版本上正常工作,因此请将此作为案例研究来参考。

访问 RTMP 金矿

实时消息传输协议(RTMP) 是一种基于文本的网络协议,ActionScript 用它来序列化并通过网络发送整个对象。RTMP 运行在 超文本传输协议(HTTP) 之上,而安全版本的 RTMPS 则运行在 安全的 HTTP(HTTPS) 之上。RTMPS 使游戏开发者能够通过安全连接轻松发送和接收整个对象实例,几乎没有复杂的操作,因此成为了在 AIR 上运行的游戏的首选网络协议。

注意

通过 RTMP/RTMPS 发送的数据是通过 Action Message Format (AMF)* 序列化的,解析 AMF 数据包超出了本书的范围。你可以在网上搜索 “AMF3 Parser”,你会找到很多可以解析 AMF 数据包的代码。*

通过 RTMP 和 RTMPS 发送的数据非常丰富。这些数据包包含了关于对象类型、名称和数值的信息。这是一座金矿。如果你能够实时拦截这些数据,你就能即时响应游戏状态的变化,无需从内存中读取信息就能看到大量关键数据,甚至能发现一些你可能从未意识到存在的数据。

一段时间前,我正在开发一个需要大量洞察游戏状态的工具。从内存中直接获取如此大量的数据将是极其困难的,甚至是不可能的。经过一些研究,我意识到游戏使用 RTMPS 与服务器进行通信,这促使我开始挖掘这个金矿。

由于 RTMPS 是加密的,我知道我必须在获取任何可用数据之前以某种方式钩取 AIR 使用的加密函数。经过在线搜索,我找到了一个名为 airlog 的小工具的源代码,它是由另一位游戏黑客创建的,和我一样,他也在尝试记录通过 RTMPS 发送的数据包。虽然这个工具钩取了我所需的确切函数,但代码已经过时、杂乱无章,最糟糕的是,它在我尝试钩取的 AIR 版本上不起作用。

但这并不意味着它没有用。airlog 不仅钩取了我需要的两个函数,而且它还通过扫描 Adobe AIR 库中的某些字节模式来定位它们。然而,这些字节模式已经三年没更新了,所以它们不再有效。Adobe AIR 的新版本发生了足够的变化,以至于汇编字节不再相同。字节的差异对 airlog 中的代码来说是个问题,但对我来说却不是。

在内联汇编块中,你可以使用以下函数调用来指定原始字节:

_emit BYTE

如果你将BYTE替换为例如0x03,代码将以一种将0x03视为汇编代码中的字节的方式编译,无论这是否有意义。利用这个技巧,我将字节数组重新编译成了汇编代码。代码没有执行任何操作,也不是为了执行;使用这个技巧只是让我能够通过 OllyDBG 连接到我的虚拟应用程序,并检查字节,这些字节被方便地呈现为清晰的反汇编。

由于这些字节表示了我所需函数周围的代码,它们的反汇编也是如此。代码相当标准,看起来不太可能发生变化,所以我将注意力转向了常量。代码中有一些立即值作为命令中的偏移量传递。考虑到这些常量变化的频率,我重新编写了 airlog 的模式匹配算法以支持通配符,更新了模式以将任何常量视为通配符,然后运行匹配。经过一些对模式的调整和对重复搜索结果的挖掘,我找到了我想钩取的函数。我将它们适当地命名为encode()decode(),并开始开发一个类似于 airlog 的工具——但更加完善。

钩取 RTMPS encode()函数

我发现encode()函数用于加密传出数据包的数据显示,它是一个非虚拟的__thiscall,意味着它是通过近距离调用的。此外,调用发生在一个循环中。整个循环的代码如下所示:Listing 8-12,直接取自 OllyDBG 的反汇编窗格。

   loop:
       MOV EAX, [ESI+3C58]
       SUB EAX,EDI
       PUSH EAX
➊      LEA EAX, [ESI+EDI+1C58]
       PUSH EAX
       MOV ECX,ESI
➋      CALL encode
       CMP EAX,-1
➌      JE SHORT endLoop
       ADD EDI,EAX
➍      CMP EDI, [ESI+3C58]
       JL loop
   endLoop:

Listing 8-12: encode()循环

通过一些分析和来自 airlog 的指导,我确定了在➊调用的encode()函数接受一个字节数组和缓冲区长度(分别称为buffersize)作为参数。当函数失败时返回-1,否则返回size。该函数以 4,096 字节为单位操作,这就是为什么它会在一个循环中执行的原因。

转换为更易读的伪代码后,调用encode()的循环看起来像这样(数字表示在 Listing 8-12 中相关汇编指令的位置):

for (EDI = 0; EDI < ➍[ESI+3C58]; ) {
    EAX = ➋encode(➊&[ESI+EDI+1C58], [ESI+3C58] - EDI);
    if (EAX == -1) ➌break;
    EDI += EAX;
}

我并不关心encode()做了什么,但我需要它循环处理的整个缓冲区,而钩住encode()是我获取这个缓冲区的手段。通过查看 Listing 8-12 中的实际循环,我知道调用对象实例的完整缓冲区存储在 ESI+0x1C58 处,完整的大小存储在 ESI+0x3C58 处,且 EDI 寄存器包含循环计数器。我在这些信息的基础上设计了钩子,最终创建了一个由两部分组成的钩子。

我的钩子的第一部分是一个reportEncode()函数,它在第一次循环迭代时记录整个缓冲区。下面是完整的reportEncode()函数:

DWORD __stdcall reportEncode(
    const unsigned char* buffer,
    unsigned int size,
    unsigned int loopCounter)
{
    if (loopCounter == 0)
        printBuffer(buffer, size);
    return origEncodeFunc;
}

该函数接受buffersizeloopCounter作为参数,并返回我称之为encode()的函数的地址。然而,在获取该地址之前,我的钩子的第二部分,myEncode()函数,会完成所有的脏活,获取buffersizeloopCounter,具体如下:

void __declspec(naked) myEncode()
{
    __asm {
        MOV EAX, DWORD PTR SS:[ESP + 0x4]     // get buffer
        MOV EDX, DWORD PTR DS:[ESI + 0x3C58]  // get full size
        PUSH ECX           // store ecx
        PUSH EDI           // push current pos (loop counter)
        PUSH EDX           // push size
        PUSH EAX           // push buffer
        CALL reportEncode  // report the encode call
        POP ECX            // restore ecx
        JMP EAX            // jump to encode
    }
}

myEncode()函数是一个纯汇编函数,它通过一个近距离调用钩子替代了原始的encode()函数调用。在将 ECX 寄存器保存到堆栈后,myEncode()获取buffersizeloopCounter,并将它们传递给reportEncode()函数。调用完reportEncode()函数后,myEncode()恢复 ECX 寄存器并直接跳转到encode(),使得原始函数得以执行,并优雅地返回到循环中。

由于myEncode()在执行时会清除它使用的所有堆栈数据,因此在myEncode()运行后,堆栈仍然保持原始参数和返回地址在正确的位置。这就是为什么myEncode()直接跳转到encode()而不是使用函数调用的原因:堆栈已经设置好了正确的返回地址和参数,因此encode()函数会认为一切都像正常一样发生。

钩住 RTMPS 的 decode()函数

我命名为decode()的函数用于解密传入的数据,它也是一个__thiscall,并且在一个循环中被调用。它处理 4,096 字节的块,并以缓冲区和大小作为参数。这个循环要复杂得多,包含多个函数调用、嵌套循环和循环退出,但钩子工作原理与钩住所谓的encode()函数类似。增加的复杂性与钩住函数无关,但使得代码难以概括,因此我不会在此展示原始函数。最重要的是,一旦所有复杂性被去除,decode()的循环实际上就是encode()循环的逆过程。

再次地,我设计了一个由两部分组成的近距离调用钩子。第一部分,reportDecode(),如下所示:

void __stdcall reportDecode(const unsigned char* buffer, unsigned int size)
{
    printBuffer(buffer, size);
}

该函数记录每个通过的数据包。当时我没有循环索引,所以我决定记录每一个部分数据包。

钩子的第二部分,myDecode()函数,充当新的调用者并执行所有脏活,具体如下:

   void __declspec(naked) myDecode()
   {
       __asm {
           MOV EAX, DWORD PTR SS:[ESP + 0x4] // get buffer
           MOV EDX, DWORD PTR SS:[ESP + 0x8] // get size
           PUSH EDX                          // push size
           PUSH EAX                          // push buffer
➊          CALL [origDecodeFunc]

           MOV EDX, DWORD PTR SS:[ESP + 0x4] // get the buffer

           PUSH EAX                          // store eax (return value)
           PUSH ECX                          // store ecx
           PUSH EAX                          // push size
           PUSH EDX                          // push buffer
           CALL reportDecode                 // report the results now
           POP ECX                           // restore ecx
➋          POP EAX                           // restore eax (return value)
➌          RETN 8                            // return and clean stack
       }
   }

我知道缓冲区是原地解密的,这意味着加密的块会在调用decode()完成后被解密的块覆盖。这意味着myDecode()必须在调用reportDecode()函数之前,先调用原始的decode()函数 ➊,然后才会返回解码结果。最终,myDecode()还需要返回与原始decode()函数相同的值,并清理堆栈,最后的POP ➋和RETN ➌指令处理了这一部分。

放置钩子

我遇到的下一个问题是,钩子是针对模块Adobe AIR.dll中的代码,而该模块并不是游戏的主模块。由于代码的位置,我需要以不同的方式找到钩子的基地址。此外,鉴于我需要这些钩子在多个不同版本的 Adobe AIR 中工作,我还必须找到每个版本的正确地址。我决定不去收集所有版本的 Adobe AIR,而是从 airlog 的策略中借鉴,决定通过编写一个小型内存扫描器来程序化地定位地址。在编写内存扫描器之前,我需要获取Adobe AIR.dll的基地址和大小,以便将内存搜索限制在该区域。

我使用Module32First()Module32Next()找到了这些值,具体方法如下:

   MODULEENTRY32 entry;
   entry.dwSize = sizeof(MODULEENTRY32);
   HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, NULL);

   DWORD base, size;
   if (Module32First(snapshot, &entry) == TRUE) {
➊      while (Module32Next(snapshot, &entry) == TRUE) {
           std::wstring binaryPath = entry.szModule;
➋          if (binaryPath.find("Adobe AIR.dll") != std::wstring::npos) {
               size = (DWORD)entry.modBaseSize;
               base = (DWORD)entry.modBaseAddr;
               break;
           }
       }
   }

   CloseHandle(snapshot);

这段代码会循环遍历进程中的所有模块,直到找到Adobe AIR.dll ➊。当它找到正确的模块条目 ➋ 时,它从中提取modBaseSizemodBaseAddr属性,然后立即跳出循环。

下一步是找到一个字节序列,我可以用它来识别这些函数。我决定使用每个调用周围的字节码。我还必须确保每个序列是唯一的,同时避免在模式中使用任何常量,以确保代码的可移植性。列表 8-13 显示了我最终得到的字节序列。

const char encodeSeq[16] = {
    0x8B, 0xCE,                   // MOV ECX, ESI
    0xE8, 0xA6, 0xFF, 0xFF, 0xFF, // CALL encode
    0x83, 0xF8, 0xFF,             // CMP EAX, -1
    0x74, 0x16,                   // JE SHORT endLoop
    0x03, 0xF8,                   // ADD EDI, EAX
    0x3B, 0xBE};                  // part of CMP EDI, [ESI+0x3C58]
const char decodeSeq[12] = {
    0x8B, 0xCE,                   // MOV ECX, ESI
    0xE8, 0x7F, 0xF7, 0xFF, 0xFF, // CALL decode
    0x83, 0xF8, 0xFF,             // CMP EAX, -1
    0x89, 0x86};                  // part of MOV [ESI+0x1C54], EAX

列表 8-13:encode()decode() 字节序列

注意每个模式中的 CALL 指令;这些是我命名为 encode()decode() 的 Adobe AIR 函数的调用。我使用以下函数扫描这些序列:

DWORD findSequence(
    DWORD base, DWORD size,
    const char* sequence,
    unsigned int seqLen){
    for (DWORD adr = base; adr <= base + size – seqLen; adr++) {
        if (memcmp((LPVOID)sequence, (LPVOID)adr, seqLen) == 0)
            return adr;
    }
    return 0;
}

Adobe AIR.dll 的内存视为字节数组后,findSequence() 函数会查找该字节数组中的某个字节序列作为子集,并返回它找到的第一个匹配项的地址。编写完 findSequence() 函数后,找到我需要钩住的 encode()decode() 的地址变得简单了。这些调用是这样的:

DWORD encodeHookAt =
    findSequence(base, size, encodeSeq, 16) + 2;
DWORD decodeHookAt =
    findSequence(base, size, decodeSeq, 12) + 2;

由于每个目标调用在其接受的搜索序列中相差 2 个字节,我所需要做的就是定位每个序列并加上 2。然后,最后一步是使用 “调用钩子” 方法在 第 153 页 上放置钩子。

在完成我的钩子后,我能够看到游戏客户端和服务器之间传输的每一块数据。而且,由于 RTMPS 协议发送序列化的 ActionScript 对象,数据本身就像文档一样自说明。每一块信息都有一个变量名。每个变量都是一个描述清晰的对象的成员。每个对象都有一个一致的名称。正如我所说——这简直是个宝藏。

将跳转钩子和 VF 钩子应用于 Direct3D

与我刚刚描述的 Adobe AIR 钩子不同,Direct3D 钩子(微软 DirectX API 的 3D 图形组件)非常常见,并且有着高度的文档支持。Direct3D 在游戏界中无处不在:大多数 PC 游戏都使用这个库,这意味着钩住它为你提供了一种非常强大的方法,用来截取数据并操控多个游戏的图形层。你可以使用 Direct3D 钩子完成许多任务,比如检测隐藏敌人玩家的位置、增强游戏中昏暗环境的光照,或无缝地显示额外的图形信息。有效利用 Direct3D 钩子需要你了解 API,但这本书中有足够的信息帮助你入门。

在本节中,我将为你简要介绍使用 Direct3D 的游戏循环,然后再深入实现 Direct3D 钩子的方法。与我在 Adobe AIR 钩子中所做的详细内部解析和分析背景不同,我将介绍最流行的 Direct3D 钩子方法,因为它有大量文档支持,且被大多数游戏黑客使用。

本书的在线资源包括两个示例代码文件;如果你想跟着一起做,可以现在就找到这些文件。第一部分是一个 Direct3D 9 应用程序示例,供你进行修改,位于 GameHackingExamples/Chapter8_Direct3DApplication。第二部分是实际的挂钩代码,位于 Chapter8_Direct3DHook

任何给定时间,都会使用多个版本的 Direct3D,并且有方法可以挂钩每一个版本。在本书中,我将重点讲解如何挂钩 Direct3D 9,因为它是唯一在 Windows XP 上得到支持的常用版本。

注意

尽管 XP 已经结束生命周期,但许多发展中国家的人们仍然将其作为主要的游戏平台。Direct3D 9 支持所有版本的 Windows,并且几乎与其后继版本一样强大,因此许多游戏公司仍然更倾向于使用它,而不是那些不具备强大向后兼容性的更新版本。

绘制循环

让我们直接进入 Direct3D 工作原理的速成课程。在 Direct3D 游戏的源代码中,你会发现一个无限循环,处理输入并渲染图形。这个绘制循环中的每次迭代称为一个 。如果我们去掉所有多余的代码,仅关注基本骨架的话,我们可以通过以下代码来可视化一个游戏的主循环:

int WINAPI WinMain(args)
{
    /* Some code here would be called
       to set up Direct3D and initialize
       the game. Leaving it out for brevity. */
    MSG msg;
    while(TRUE) {
        /* Some code would be here to handle incoming
           mouse and keyboard messages. */
        drawFrame(); // this is the function we care about
    }
    /* Some code here would be called to
       clean up everything before exiting. */
}

这个函数是游戏的入口点。简而言之,它初始化游戏,然后进入游戏的主循环。在主循环中,它执行处理用户输入的代码,然后调用 drawFrame() 使用 Direct3D 重新绘制屏幕。(查看 GameHackingExamples/Chapter8_Direct3DApplication 中的代码,看看一个完整功能的游戏循环。)

每次调用时,drawFrame() 函数会重新绘制整个屏幕。代码大致如下所示:

   void drawFrame()
   { 
➊      device->Clear(0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0, 0, 0), 1.0f, 0);
       device->BeginScene();
       // drawing will happen here
       device->EndScene();
       device->Present(NULL, NULL, NULL, NULL);
   }

在使用 device->Clear ➊ 清除屏幕之后,drawFrame() 函数调用 device->BeginScene() 解锁场景以便绘制。然后它执行一些绘制代码(这些绘制代码具体做什么现在不重要),并通过 device->EndScene() 调用锁定场景。最后,它通过调用 device->Present() 函数将场景渲染到屏幕上。

请注意,这些函数都是作为名为 device 的实例的成员进行调用的。这个实例只是一个表示 Direct3D 设备的对象实例,用于调用各种绘制操作。此外,注意到这个函数并没有实际的绘制代码,但这没关系。现在重要的是你要理解绘制循环、帧和 Direct3D 设备的高级概念。总结一下,游戏有一个主循环,负责两个任务:

• 处理传入的消息

• 将游戏绘制到屏幕上

该循环中的每一次迭代称为一帧,每一帧由一个设备绘制。控制设备让你可以访问游戏状态的最敏感和最详细的细节;也就是说,你可以在数据解析、处理并渲染到屏幕后查看游戏状态。此外,你还可以修改这种状态的输出。这两种超级能力使你能够完成各种令人惊叹的黑客操作。

查找 Direct3D 设备

要控制一个 Direct3D 设备,你需要钩住设备 VF 表中的成员函数。不幸的是,使用 Direct3D API 从注入代码实例化相同的 device 类并不意味着你会和游戏实例共享同一个 VF 表。Direct3D 设备使用定制的运行时 VF 表实现,每个设备都有自己独特的 VF 表。此外,设备有时会重写自己的 VF 表,移除任何钩子并恢复原始函数地址。

这两个 Direct3D 特性让你面临一个不可避免的选择:你必须找到游戏设备的地址,并直接修改它的 VF 表。方法如下:

  1. 创建一个 Direct3D 设备并遍历其 VF 表以找到 EndScene() 的真实地址。

  2. EndScene() 上放置一个临时跳转钩子。

  3. 当跳转钩子回调被执行时,存储调用该函数的设备地址,移除钩子,并恢复正常执行。

  4. 从那里,使用 VF 钩子来钩住 Direct3D 设备的任何成员函数。

跳转钩住 EndScene()

由于每个设备会在每一帧结束时调用 EndScene(),你可以使用跳转钩子钩住 EndScene(),并在钩子回调中截取游戏的设备。独特的设备可能有自己独特的 VF 表,但不同的表仍然指向相同的函数,因此你可以在任何设备的 VF 表中找到 EndScene() 的地址。使用标准的 Direct3D API 调用,你可以像这样创建自己的设备:

LPDIRECT3D9 pD3D = Direct3DCreate9(D3D_SDK_VERSION);
if (!pD3D) return 0;

D3DPRESENT_PARAMETERS d3dpp;
ZeroMemory( &d3dpp, sizeof(d3dpp) );
d3dpp.Windowed = TRUE;
d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD;
d3dpp.hDeviceWindow = hWnd;

LPDIRECT3DDEVICE9 device;
HRESULT res = pD3D->CreateDevice(
    D3DADAPTER_DEFAULT,
    D3DDEVTYPE_HAL,
    hWnd,
    D3DCREATE_SOFTWARE_VERTEXPROCESSING,
    &d3dpp, &device);
if (FAILED(res)) return 0;

解释 Direct3D 中所有的工作原理超出了本书的范围,因此只需知道,你可以复制这段代码来创建一个包含 EndScene() 函数作为成员的 Direct3D 设备。EndScene() 地址位于 device 的 VF 表中的索引 42(详见“设备、Direct3D 和 VF 钩子的含义”框,了解如何找到该索引),你可以使用来自“使用 VF 表钩子”第 159 页的 VF 表钩子代码子集来读取它,如下所示:

DWORD getVF(DWORD classInst, DWORD funcIndex)
{
    DWORD VFTable = readMemory<DWORD>(classInst);
    DWORD hookAddress = VFTable + funcIndex * sizeof(DWORD);
    return readMemory<DWORD>(hookAddress);
}
DWORD EndSceneAddress = getVF((DWORD)device, 42);

一旦你获得了地址,你的发现设备就完成了它的使命,你可以通过调用 Release() 函数将其销毁:

pD3D->Release();
device->Release();

拥有 EndScene() 的地址后,你就准备好开始思考如何将钩子放置到内存中。但由于你只拥有一个函数地址,你唯一的选择是将跳转钩子放在函数的顶部。

设备、Direct3D 和 VF 钩子的含义

如果你在想我怎么知道EndScene()函数的索引是42,那么你来对地方了。由于 Direct3D 9 是一个自由开放的库,你实际上可以看到很多底层的内容。这个库的主头文件是d3d9.h。如果你在编辑器中打开这个文件并搜索“EndScene”,你会看到一个大型类定义的中间部分,其中使用 C 宏定义了几个函数。这是所有 Direct3D 9 device实现的基类,它定义了类使用的虚拟函数。

VF 表是按函数在代码中定义的顺序构建的,因此你可以通过简单地数行数来确定任何成员函数的索引。你可以滚动到类定义的顶部(在我版本的库中是第 426 行,可能你也一样),记下第一个函数声明的行(第 429 行),然后滚动到EndScene()的定义并记下该行(第 473 行)。最后,数一下空行或注释行的数量(我这里是两行),然后做个简单的数学运算:473 – 429 – 2 = 42。

预览!EndScene()函数是第 43 个声明的函数,因此它位于 VF 表的第 42 个位置。拥有这个头文件的另一个好处是,你可以看到设备类中每个函数的名称、参数类型、参数名称和返回类型。所以,当你将来编写自己的钩子时,你将确切知道该去哪里查看。

放置和移除跳转钩子

由于你只是用钩子来查找设备,因此只需要调用一次它。获取设备后,你将移除跳转钩子,并将执行恢复到EndScene()的起始位置,这样绘制循环就可以继续工作。信不信由你,这会让你的生活轻松很多。由于代码会立即恢复,因此不需要你的跳板执行被跳转替换的命令,也不需要用 NOP 填充跳转。你需要做的就是存储原始字节并放置钩子。为此,你可以使用稍微调整过的 Listing 8-11 中的跳转钩子代码:

   unsigned char* hookWithJump(DWORD hookAt, DWORD newFunc)
   {
       DWORD newOffset = newFunc - hookAt - 5;
➊      auto oldProtection = protectMemory<BYTE[5]>(hookAt, PAGE_EXECUTE_READWRITE);
       unsigned char* originals = new unsigned char[5];
       for (int i = 0; i < 5; i++)
➋          originals[i] = readMemory<unsigned char>(hookAt + i);
➌      writeMemory<BYTE>(hookAt, 0xE9);
       writeMemory<DWORD>(hookAt + 1, newOffset);
       protectMemory<BYTE[5]>(hookAt, oldProtection);
       return originals;
   }

像 Listing 8-11 中的函数一样,这个函数使内存变为可写➊,放置钩子➌,然后恢复内存保护。在放置钩子之前,它分配了一个 5 字节的缓冲区,名为originals➋,并用原始字节填充它。放置钩子后,它将originals返回给调用函数。

当需要移除钩子时,将originals传递给以下函数:

void unhookWithJump(DWORD hookAt, unsigned char* originals)
{
    auto oldProtection = protectMemory<BYTE[5]>(hookAt, PAGE_EXECUTE_READWRITE);
    for (int i = 0; i < 5; i++)
        writeMemory<BYTE>(hookAt + i, originals[i]);
    protectMemory<BYTE[5]>(hookAt, oldProtection);
    delete [] originals;
}

这段代码简单地遍历originals,并悄悄地将那 5 个字节放回它们被找到的位置,这样当执行返回到EndScene()函数时,一切都如预期那样。当时机到来时,你可以使用两行代码来放置和移除你的实际钩子,像这样:

auto originals = hookWithJump(EndSceneAddress, (DWORD)&endSceneTrampoline);
unhookWithJump(EndSceneAddress, originals);

一旦你拥有了hookWithJump()unhookWithJump()函数,就可以准备回调并找到设备了。

编写回调函数和跳转钩子

尽管你可以从 VF 表中获取EndScene()的地址,但EndScene()函数实际上并不遵循__thiscall约定。Direct3D 类只是 C API 的简单封装,所有的成员函数调用都会转发到__stdcall函数,这些函数的第一个参数是类实例。这意味着你的跳转钩子只需要从栈中获取设备,将其传递给回调函数,然后跳回EndScene()函数。回调函数只需在返回到跳转钩子之前移除跳转钩子。

回调函数和跳转钩子的最终代码如下所示:

   LPDIRECT3DDEVICE9 discoveredDevice;
   DWORD __stdcall reportInitEndScene(LPDIRECT3DDEVICE9 device)
   {
       discoveredDevice = device;
       unhookWithJump(EndSceneAddress, originals);
       return EndSceneAddress;
   }
   __declspec(naked) void endSceneTrampoline()
   {
       __asm {
           MOV EAX, DWORD PTR SS:[ESP + 0x4]
           PUSH EAX  // give the device to the callback
➊          CALL reportInitEndScene
           JMP EAX   // jump to the start of EndScene
       }
   }

使用hookWithJump()函数,你可以在EndScene()上放置一个跳转钩子,调用endSceneTrampoline()函数。当游戏的设备调用EndScene()函数时,跳转钩子函数会调用reportInitEndScene()函数 ➊。reportInitEndScene()函数将捕获的设备指针存储到名为discoveredDevice的全局变量中,通过调用unhookWithJump()移除钩子,并返回EndScene()的地址给跳转钩子。最后,跳转钩子会直接跳转到 EAX 寄存器,EAX 存储的是从报告函数返回的地址。

注意

你可以使用跳转钩子来完全跳过我将要展示的 VF 表钩子,但在常用的钩子 API 函数上使用“傻瓜式”跳转钩子非常不可靠。要仅使用跳转钩子获得稳定的效果需要专业的钩子库,我更愿意教你如何完全独立地完成这一过程。

这时,剩下的工作就是将discoveredDevice的 VF 表钩住,从而破解游戏。接下来的两个章节将带你走完EndScene()Reset()函数的钩子过程,如果你想要一个稳定的钩子,这是必要的。

编写 EndScene() 钩子

EndScene()钩子非常有用,因为它允许你在渲染完成的帧即将被渲染时进行拦截;你可以有效地在游戏循环内执行你自己的渲染代码。正如你在“跳转钩住EndScene()”中所看到的,在第 178 页找到该函数的地址时,该函数位于 VF 表的索引42处。你可以通过以下方式使用 VF 钩子钩住EndScene()

typedef HRESULT (WINAPI* _endScene)(LPDIRECT3DDEVICE9 pDevice);
_endScene origEndScene =
    (_endScene)hookVF((DWORD)discoveredDevice, 42,(DWORD)&myEndScene);
HRESULT WINAPI myEndScene(LPDIRECT3DDEVICE9 pDevice)
{
    // draw your own stuff here
    return origEndScene(pDevice);
}

这段代码使用了来自“使用 VF 表钩子”中的hookVF()函数,在第 159 页将EndScene()钩住,钩子的索引是42,并使用myEndScene()作为回调函数。直接的 Direct3D 设备偶尔会重新修补自己的 VF 表,并恢复原始的函数地址。这通常发生在EndScene()函数内部,这意味着在调用原始的EndScene()函数后,你还需要重新修补 VF 表。你可以对这个钩子进行一些修改来处理这种情况,具体方法见清单 8-14。

_endScene origEndScene = NULL;
void placeHooks()
{
    auto ret = hookVF((DWORD)discoveredDevice, 42, (DWORD)&myEndScene);
    if (ret != (DWORD)&myEndScene) // don't point to your hook
        origEndScene = (_endScene)ret;
}
placeHooks();

HRESULT WINAPI myEndScene(LPDIRECT3DDEVICE9 pDevice)
{
    // draw your own stuff here
    auto ret = origEndScene(pDevice);
    placeHooks(); // update hooks
    return ret;
}

清单 8-14:钩住EndScene()的最终代码

放置钩子的代码已被移入一个名为placeHooks()的函数中,以便可以轻松多次调用。回调函数仍然将调用转发到原始函数,但在返回之前会确保调用placeHooks()。这样可以确保钩子始终处于激活状态,即使原始的EndScene()函数将其移除。

另一个需要注意的点是,每当钩子被替换时,placeHooks()都会更新origEndScene()的地址,只要从hookVF()返回的地址不是myEndScene()函数的地址。这做了两件不同的事情。首先,它允许其他应用程序钩住EndScene()而不会相互干扰,因为它会将origEndScene()更新为 VF 表中看到的任何内容。其次,它确保origEndScene()的值永远不会是我们回调的地址,从而防止潜在的无限循环。否则可能会出现无限循环,因为origEndScene()并不总是修复设备的 VF 表,这意味着当 VF 表仍然包含myEndScene()函数时,placeHooks()仍然可以被调用。

为 Reset()编写钩子

当你在生产环境中使用 Direct3D 钩子时,你将执行各种任务,比如绘制自定义文本、显示与你的机器人相关的图像,以及与游戏中的函数调用进行交互。这些任务将要求你创建与游戏设备绑定的 Direct3D 对象,这可能会成为一个问题。因为游戏有时会通过Reset()函数完全重置其设备。当设备被重置时,你需要更新为该设备创建的任何对象(最常见的是字体和精灵),使用它们的OnLostDevice()成员函数。

由于Reset()是从设备的 VF 表中调用的,你可以在它上面使用钩子来告诉你设备何时被重置。Reset()接受两个参数,并在 VF 表中位于索引16。你可以将这段代码添加到清单 8-14 中的placeHooks()来钩住Reset()函数:

auto ret = hookVF((DWORD)discoveredDevice, 16, (DWORD)&myReset);
if (ret != (DWORD)&myReset)
    origReset = (_reset)ret;

这是用于origReset的声明:

typedef HRESULT (WINAPI* _reset)(
    LPDIRECT3DDEVICE9 pDevice,
    D3DPRESENT_PARAMETERS* pPresentationParameters);
_reset origReset = NULL;

当重置成功时,原始函数会返回D3D_OK。你的钩子函数会识别这一点并相应地调用OnLostDevice()

HRESULT WINAPI myReset(
    LPDIRECT3DDEVICE9 pDevice,
    D3DPRESENT_PARAMETERS* pPresentationParameters)
{
    auto result = origReset(pDevice, pPresentationParameters);
    if (result == D3D_OK) {
        // call onLostDevice() for all of your objects
    }
    return result;
}

一旦你填充了if()语句的内容,所有的对象就可以重新使用了。

接下来是什么?

现在我已经向你展示了如何控制游戏的 Direct3D 设备,你可能在想你能用它做什么。与书中的其他示例不同,本节中的代码和示例代码没有一一对应的关系,但功能仍然相同。以下是本章与Chapter8_Direct3DHook示例项目中代码的高层次对应关系。

文件DirectXHookCallbacks.h包含EndScene()Reset()函数的回调函数,另外还有两个常见函数的回调,以及用于临时跳转钩子的跳板函数和报告函数。这些函数基本上与本章中描述的相同,只不过它们调用了一个在DirectXHook.hDirectXHook.cpp中定义的单例类。这个单例类负责将调用转发到原始函数。

这个类还负责所有的重型操作,它包含了创建发现设备、放置钩子、绘制文本、处理设备重置和显示图像的代码。此外,它允许外部代码为每个钩子添加自定义回调,如你在main.cpp中所看到的那样。在这里,你将看到许多不同的回调,它们绘制自定义文本、向屏幕添加新图像并改变游戏绘制的模型纹理。我建议你深入研究代码,以便更好地理解发生了什么,但不要过于投入。我们将在第九章中深入探讨这段代码,讨论它所能做的所有有趣的破解。

提高稳定性的可选修复

本章描述的Reset()EndScene()钩子应该能够在运行 Direct3D 9 的任何游戏中正常工作,但它稍微有些不稳定。如果游戏在放置跳转钩子时尝试执行EndScene(),它会因为字节被修改而崩溃。有两种方法可以解决这个问题。首先,你可以通过在PeekMessage()上放置一个 IAT 钩子来放置跳转钩子。这将有效,因为放置 IAT 钩子是线程安全的操作,但它假设PeekMessage()只会从执行 Direct3D 绘制的相同线程中调用。

一个更安全,但更复杂的替代方法是遍历游戏中的每个线程(类似于线程劫持的方式),并使用SuspendThread()来暂停游戏中的所有线程(当然,除非是放置钩子的线程)。在暂停一个线程之前,你必须确保它的EIP不会执行EndScene()的前 5 个字节。钩子安装完成后,你必须使用ResumeThread()来恢复执行,并且保持钩子生效。

结束语

控制流操作是游戏破解中的一项非常重要的技能,本书中的许多破解都依赖于它。在接下来的两章中,你将学习如何使用 Direct3D 钩子创建常见的破解技术,并且你将更好地了解钩子的常见使用场景。即使你感到有些不确定,也请继续阅读第九章。那里的代码示例集中在 Direct3D 钩子,并将让你更加熟悉钩子技术。

第四部分

创建机器人

第九章:9

利用超感知能力驱散战争迷雾

image

战争迷雾(通常简称为迷雾)是一种游戏开发者常用的机制,用来限制玩家的环境感知并隐藏关于游戏环境的信息。迷雾在大型多人在线战术竞技游戏(MOBA)中通常表现为视觉上的缺失,但这个概念还包括任何相关的游戏信息缺失或模糊。隐形人物、黑暗房间和隐藏在墙后面的敌人都是迷雾的一种形式。

游戏黑客可以使用超感知能力(ESP)黑客技术来减少或完全去除战争迷雾。ESP 黑客通过钩取、内存操作或两者结合,强制游戏显示隐藏信息。这些黑客利用了这样的事实:某些类型的迷雾通常是在客户端实现的,而不是服务器端,这意味着游戏客户端仍然包含有关隐藏内容的部分或完整信息。

在本章中,您将学习如何实现不同类型的 ESP 黑客技术。首先,您将学习如何点亮黑暗环境。接下来,您将使用 X 光视觉透视墙壁。最后,您将了解缩放黑客技术、调整信息显示以及其他简单的 ESP 黑客,这些都可以揭示游戏中各种有用的(但通常隐藏的)信息。

背景知识

本章开始从黑客技术、操控和逆向工程转向编码。从现在开始,您将学习如何实际编写自己的黑客技术。为了保持话题一致,迄今为止我讨论的所有内容都将视为背景知识。如果您看到一个您不太记得的技术,例如内存扫描、设置内存断点、钩取或写入内存,请翻回相关章节,稍作复习再继续阅读。在整个文本中,您将看到一些提示,提醒您可以在哪些地方回顾某些话题。

具体来说,本章将大量讨论 Direct3D。在《"将 Jump Hooks 和 VF Hooks 应用于 Direct3D"》的第 175 页中,我解释了如何将钩子插入游戏的 Direct3D 绘图循环。该章节的示例代码包括一个功能齐全的 Direct3D 钩取引擎,位于GameHackingExamples/Chapter8_Direct3DHook。本章中的许多黑客技术都建立在这个钩子基础上,示例代码可以在 Direct3D 钩子代码的main.cpp文件中找到。您可以从GameHackingExamples/Chapter8_Direct3DApplication运行编译后的应用程序,在测试应用程序上看到黑客技术的实际效果。

通过 Lighthacks 揭示隐藏细节

Lighthacks增加了黑暗环境中的光照,使您可以清楚地看到敌人、宝箱、路径以及任何通常被黑暗掩盖的东西。光照通常是在游戏的图形层中添加的装饰性变化,通常可以通过图形层的钩子直接修改。

最佳光照效果取决于相机的朝向、环境布局,甚至游戏引擎的具体特性,你可以操控这些因素来创建光照破解。然而,最简单的方法就是给房间添加更多的光源。

添加中央环境光源

本书的在线资源包括两个小型光照破解示例。第一个是 main.cpp 中的 enableLightHackDirectional() 函数,如 清单 9-1 中所示。

void enableLightHackDirectional(LPDIRECT3DDEVICE9 pDevice)
{
    D3DLIGHT9 light;
    ZeroMemory(&light, sizeof(light));
    light.Type = D3DLIGHT_DIRECTIONAL;
    light.Diffuse = D3DXCOLOR(0.5f, 0.5f, 0.5f, 1.0f);
    light.Direction = D3DXVECTOR3(-1.0f, -0.5f, -1.0f);

    pDevice->SetLight(0, &light);
    pDevice->LightEnable(0, TRUE);
}When you know how much experience you

清单 9-1:方向光照破解

这段代码从 EndScene() 钩子中调用,通过创建一个名为 light 的光源向场景添加光照。代码将 light.Type 设置为方向光,这意味着光源将像聚光灯一样投射光线到特定方向。然后,代码将 light.Diffuse 的红、绿、蓝值分别设置为 0.5、0.5 和 0.5,使光源在从表面反射时呈现淡白色光泽。接下来,它将 light.Direction 设置为三维空间中的一个任意点。最后,代码使用游戏的 Direct3D 设备在索引 0 处设置光源并启用光照效果。

注意

在示例应用程序中,光源从场景的左下角向上和向右照射。你可能需要根据目标游戏的渲染方式来改变这个位置。

请注意,在索引 0 处插入光源适用于这个概念验证,但并非始终有效。游戏通常定义了多个光源,设置光源的索引可能会覆盖游戏使用的关键光照效果。实际上,你可以尝试将索引设置为一个任意较高的数字。然而,这种光照破解方法存在一个问题:方向光会被墙壁、生物和地形等物体阻挡,这意味着阴影仍然会被投射。方向光在开阔空间中效果很好,但对于狭窄的走廊或地下洞穴效果不佳。

增加绝对环境光

另一种光照破解方法,如 enableLightHackAmbient() 函数中所见,比 清单 9-1 中的更为激进。它会全球性地影响光照强度,而不是增加额外的光源。以下是代码的样子:

void enableLightHackAmbient(LPDIRECT3DDEVICE9 pDevice)
{
    pDevice->SetRenderState(D3DRS_AMBIENT, D3DCOLOR_XRGB(100, 100, 100));
}

这个光照破解方法将绝对环境光(通过将 D3DRS_AMBIENT 传递给 SetRenderState() 函数来指示)设置为中等强度的白色。D3DCOLOR_XRGB 宏设置了该强度,以 100 作为红、绿、蓝级别的参数。这会使用全向白光照亮物体,从而有效地揭示所有物体,但会失去阴影和其他基于光照的细节。

创建其他类型的光照破解

创建光照作弊的方法有很多,但它们因游戏而异。一种创造性的方法是禁用游戏调用device->SetRenderState()函数的代码。由于此函数用于设置全局环境光强度,禁用对它的调用会使 Direct3D 保持默认的光照设置,从而使一切都变得可见。这也许是最强大的光照作弊方法,但它要求你的机器人知道光照代码的地址才能禁用它。

还有基于内存的光照作弊。在一些游戏中,玩家和生物会根据其装备、坐骑或活跃的法术发出不同颜色和强度的光。若你了解游戏生物列表的结构,就可以直接修改决定生物光照强度的值。

举个例子,假设在某个游戏中,角色在施加治疗或强化法术时会发出一团蓝色的光球。在游戏的内存中,存在与每个生物相关的值,这些值告诉游戏该生物应该发出什么颜色和强度的光。如果你能在内存中找到这些值,你就可以改变它们,使生物有效地发出光球。这种类型的光照作弊通常用于 2D 俯视风格的游戏,因为围绕个别生物的光球产生了一种很酷的艺术效果,同时还可以照亮屏幕上的重要部分。然而,在 3D 游戏中,这种作弊方法只会将生物变成四处跑动的光团。

你还可以在游戏的 Direct3D 设备的 VF 表中挂钩SetLight()成员函数的第 51 个索引。然后,每当调用你的挂钩回调时,你可以在将其传递给原始函数之前修改拦截到的D3DLIGHT9光照结构的属性。例如,你可以将所有光源更改为D3DLIGHT_POINT类型,导致游戏中的任何现有光源像灯泡一样四面八方地发光。这种类型的光照作弊非常强大且精确,但它可能会产生一些令人不安的视觉效果。而且,它通常在没有光照的环境中失效,且不透明的障碍物仍然会阻挡点光源。

光照作弊非常强大,但它不会揭示任何内容。如果信息被障碍物而不是黑暗所隐藏,你将需要使用透视墙壁作弊来揭示它。

用透视墙壁作弊揭示狡猾的敌人

你可以使用透视墙壁作弊来显示被墙壁、地板和其他障碍物隐藏的敌人。创建这些作弊的方法有几种,但最常见的方法是利用一种叫做z-buffering的渲染技术。

使用 Z-Buffering 渲染

大多数图形引擎,包括 Direct3D,都支持 z 缓冲,它是一种确保在场景中有重叠物体时,只绘制最上面物体的方式。z 缓冲通过将场景“绘制”到一个二维数组来工作,这个数组描述了屏幕上每个像素的物体离观察者的远近。可以将数组的索引看作是坐标轴:它们对应于屏幕上每个像素的 x 轴(左右)和 y 轴(上下)。数组中存储的每个值都是某个像素的 z 轴值。

当一个新物体出现时,它是否真的绘制到屏幕上由 z 缓冲数组决定。如果物体的 x 和 y 位置在数组中已经被填充,那就意味着屏幕上的那个像素位置已有其他物体。如果新物体的 z 轴值更低(也就是离观察者更近),它才会出现在这个位置。当场景绘制完成后,数组会被刷新到屏幕上。

为了说明这一点,假设一个三维空间需要通过某个游戏绘制到一个 4×4 像素的二维视口上。这个场景的 z 缓冲看起来会像图 9-1。

image

图 9-1:空的 z 缓冲

首先,游戏绘制一个完全填充视口的蓝色背景,并将其放置在 z 轴上尽可能远的位置;假设最高的 z 值是 100。接着,游戏在位置(0,0)绘制一个 2×2 像素的红色矩形,z 位置为 5。最后,游戏在位置(1,1)绘制一个 2×2 像素的绿色矩形,z 位置为 3。此时,z 缓冲看起来像图 9-2。

image

图 9-2:填充后的 z 缓冲

z 缓冲根据物体的 z 位置巧妙地处理了重叠物体。离 z 轴最近的绿色方块会覆盖离它稍远的红色方块,而两个方块又会覆盖远离屏幕的蓝色背景。

这种行为允许游戏在不需要担心玩家实际看到什么的情况下绘制地图、玩家、怪物、细节和粒子。这对于游戏开发者来说是一个巨大的优化,但也暴露了一个巨大的攻击面。由于所有游戏模型总是被提供给图形引擎,你可以使用钩子来检测玩家看不见的物体。

创建一个 Direct3D 墙 hack

你可以通过在DrawIndexedPrimitive()函数上设置钩子来创建操控 z 缓冲的透视墙 hack,这个函数在游戏绘制 3D 模型到屏幕时会被调用。当敌方玩家模型被绘制时,这种类型的墙 hack 会禁用 z 缓冲,调用原始函数绘制模型,然后重新启用 z 缓冲。这样,敌方模型就会被绘制在场景中的所有其他物体之上,不管它前面有什么。某些墙 hack 还可以将特定模型以纯色显示,比如敌人用红色,盟友用绿色。

切换 Z 缓冲区

来自GameHackingExamples/Chapter8_ Direct3DHookmain.cpp中的 Direct3D 钩子有这个例子透视墙,位于onDrawIndexedPrimitive()函数中:

void onDrawIndexedPrimitive(
    DirectXHook* hook,
    LPDIRECT3DDEVICE9 device,
    D3DPRIMITIVETYPE primType,
    INT baseVertexIndex, UINT minVertexIndex,
    UINT numVertices, UINT startIndex, UINT primCount)
{
    if (numVertices == 24 && primCount == 12) {
        // it's an enemy, do the wallhack
    }
}

这个函数作为对DrawIndexedPrimitive()的钩子回调函数,位于游戏的 Direct3D 设备的 VF 索引 82 处。游戏绘制的每个模型都会经过这个函数,并伴随一些模型特有的属性。通过检查其中一部分属性,特别是numVerticesprimCount值,钩子能够检测到何时绘制一个敌人模型,并启动透视墙。在这个例子中,表示敌人模型的值是2412

魔法发生在if()语句内部。只需要几行代码,透视墙就能以忽略 z 缓冲区的方式绘制模型,如下所示:

device->SetRenderState(D3DRS_ZENABLE, false); // disable z-buffering
DirectXHook::origDrawIndexedPrimitive(        // draw model
    device, primType, baseVertexIndex,
    minVertexIndex, numVertices, startIndex, primCount);
device->SetRenderState(D3DRS_ZENABLE, true);  // enable z-buffering

简单来说,这段代码在绘制敌人模型时禁用 z 缓冲区,并在绘制后重新启用它。禁用 z 缓冲区后,敌人会绘制在所有其他物体前面。

更改敌人的纹理

当一个模型在屏幕上渲染时,纹理被用来为模型贴图。纹理是二维图像,拉伸并包裹在三维模型上,用以应用构成模型三维艺术作品的颜色和图案。要改变敌人在你的透视墙(wallhack)中显示的方式,你可以将其设置为使用不同的纹理,就像这个例子一样:

// when hook initializes
LPDIRECT3DTEXTURE9 red;
D3DXCreateTextureFromFile(device, "red.png", &red);
// just before drawing the primitive
device->SetTexture(0, red);

这段代码的第一个块加载纹理文件,并且只会执行一次——当钩子被初始化时。完整的示例代码在initialize()函数中执行此操作,这个函数在第一次调用EndScene()钩子回调时被调用。第二个代码块发生在调用原始DrawIndexedPrimitive()函数之前,它使模型使用自定义纹理绘制。

指纹识别你想要显示的模型

创建一个好的透视墙的最棘手部分是找到正确的numVerticesprimCount值。为了做到这一点,你可以创建一个工具,记录所有这两个变量的唯一组合,并允许你使用键盘在列表中遍历。这个工具的工作示例代码在本章提供的示例应用中可能并不有用,但我会给你一些高层的实现细节。

首先,在全局作用域中,你需要声明一个结构体,它包含成员用于存储以下内容:

numVerticesprimCount

• 一个此结构的std::set(我们称之为seenParams

• 一个该结构的实例(我们称之为currentParams

std::set需要一个比较器来比较该结构,因此你还需要声明一个比较函数对象,使用memcmp()来比较两个结构体。每当调用DrawIndexedPrimitive()回调时,你的透视墙可以创建一个结构实例,并传递到seenParams.insert()函数中,该函数只有在该参数对不在列表中时才会插入它。

使用 GetAsyncKeyState() Windows API 函数,你可以检测空格键是否被按下,并执行类似以下伪代码的操作:

auto current = seenParams.find(currentParam);
if (current == seenParams.end())
    current = seenParams.begin();
else
    current++;
currentParams = *current;

这样,当按下空格键时,currentParams 会被设置为 seenParams 中的下一个对。通过这段代码,你可以使用类似于壁 hack 的代码,改变与 currentParams.numVerticescurrentParams.primCount 匹配的模型的纹理。工具还可以将这些值绘制在屏幕上,以便你看到它们并将其记录下来。

使用像这样的工具,找到合适的模型就像在一个角色不会死亡的模式下启动游戏(与朋友对战、在自定义模式下等),运行机器人,然后按空格键直到每个需要的模型被高亮显示。一旦你得到了目标模型的数值,就可以修改你的壁 hack 中的 numVerticesprimCount 检查,让它知道需要高亮显示哪些模型。

注意

角色模型通常由多个较小的模型组成,用于显示身体各个部位,而且游戏通常会在不同的距离显示同一角色的不同模型。这意味着一个游戏可能会为一种类型的角色有 20 个或更多的模型。即便如此,选择仅一个模型(比如敌人的躯干)在壁 hack 中显示,可能就足够了。

使用 Zoomhacks 获得更广的视野

许多 MOBA 和即时战略(RTS)类型的游戏使用 3D 俯视风格,使它们免受壁 hack 的影响。它们还使用地图上的黑暗区域作为一种迷雾,但使用光 hack 显示黑暗区域并不会提供任何额外的信息;迷雾中的模型只有游戏服务器知道,而客户端无法看到。

这种风格使得大多数类型的 ESP 作弊无效:几乎没有未知信息可以揭示,因此这些作弊只会增强你已经能看到的信息。然则,有一种类型的 ESP 作弊仍然有帮助。Zoomhacks 让你可以将视野缩小到游戏通常允许的范围之外,从而有效地显示出你否则无法看到的地图大片区域——因此可以绕过游戏的壁 hack 和光 hack 免疫。

使用 NOPing Zoomhacks

MOBA 和 RTS 游戏通常允许玩家进行可变的但有限的缩放。最简单的类型的 zoomhack 是找到缩放因子(一个随着缩放级别变化而变化的乘数,通常是floatdouble)并用更大的值覆盖它。

要找到缩放因子,启动 Cheat Engine 并搜索一个初始值未知的 float。(要复习 Cheat Engine,请参考《Cheat Engine 的内存扫描器》在第 5 页的内容。)对于重新扫描,请重复以下过程,直到只剩下少数几个值,以找到缩放因子:

  1. 转到游戏窗口并放大视野。

  2. 在 Cheat Engine 中搜索增大的值。

  3. 转到游戏窗口并缩小视野。

  4. 在 Cheat Engine 中搜索减小的值。

尝试将值列表缩小到一个选项。为了确认剩下的值是缩放因子,将其在 Cheat Engine 中冻结并查看游戏中的缩放行为;冻结正确的值将禁用缩放。如果无法通过float搜索找到缩放因子,请尝试使用double进行搜索。如果两次搜索都失败,请再次尝试,但将缩放与减小的值对应,放大与增大的值对应。一旦找到内存中的缩放因子,你可以编写一个小的机器人来覆盖它为最适合你的缩放因子。

更高级的缩放黑客方法会将负责确保缩放因子在设定范围内的游戏代码 NOP 掉。你应该能够使用 OllyDbg 找到这段代码。设置一个针对缩放因子的内存写入断点,在游戏中缩放以触发断点,然后检查断点处的代码。(要提升你的 OllyDbg 内存断点技能,翻到第 43 页的“通过命令行控制 OllyDbg”部分。)你应该能看到修改缩放因子的代码。缩放限制代码通常很容易辨认:与最小和最大缩放值匹配的常量就是明显的提示。

如果你使用这种方法无法找到限制代码,那么这个限制可能是在新缩放级别下重新绘制图形时应用的,而不是缩放因子变化时。在这种情况下,将断点切换到内存读取时,寻找相同的线索。

触及缩放黑客的表面

你还可以通过在device->SetTransform(type, matrix)函数上使用 Direct3D 钩子来创建缩放黑客,但这需要深入理解游戏是如何设置玩家视角的。有几种不同的方式来管理视角,但你可以通过视图(变换类型D3DTS_VIEW)或投影(变换类型D3DTS_PROJECTION)来控制缩放级别。

正确操作控制视图和投影的变换矩阵需要相当深入的 3D 图形数学知识,因此我会尽量避免使用这种方法——而且我从未遇到过仅仅操作缩放因子会有问题。如果你对这种技巧感兴趣,我建议你先阅读一本 3D 游戏编程书籍,了解更多关于 3D 数学的内容。

但有时,即使是缩放黑客也不够。一些有用的信息可能会作为游戏的内部状态隐藏,或者可能对于玩家来说一眼难以看出。在这些情况下,抬头显示(HUD)就是合适的工具。

通过 HUD 显示隐藏数据

抬头显示(HUD) 是一种 ESP 黑客,它以叠加的形式显示关键的游戏信息。HUD 通常类似于游戏现有的界面,用于显示诸如剩余弹药、迷你地图、当前健康值、任何正在进行的技能冷却时间等信息。HUD 通常显示历史信息或汇总数据,主要用于 MMORPG。它们通常是文本为主,但也有些包含精灵图、形状以及其他小型视觉效果。

你可以创建的 HUD 取决于游戏中可用的数据。常见的数据点有:

• 每小时经验增长(exp/h)

• 每小时击杀生物数(KPH)

• 每秒伤害(DPS)

• 每小时获得金币(GPH)

• 每分钟治疗量

• 预计达到下一级所需时间

• 花费在物资上的金币数量

• 被盗物品的总金币价值

更高级的自定义 HUD 可能会显示包含已获得物品、使用的物资、每种生物的击杀数,以及最近被看到的玩家名称的大表格。

除了你已经学习的关于读取内存、挂钩图形引擎和显示自定义数据的内容外,我没什么别的可以教你如何创建 HUD 的知识。大多数游戏的架构足够简单,你可以轻松地从内存中获取所需的大部分信息。然后,你可以进行一些基本的按小时、百分比或总和的计算,将数据转化为可用的格式。

创建经验 HUD

想象一下,你希望一个 HUD 显示你的当前等级、每小时经验以及距离角色升级还需要多长时间。首先,你可以使用 Cheat Engine 查找包含你等级和经验的变量。当你知道这些值后,你可以使用游戏特定的算法或硬编码的经验表来计算达到下一级所需的经验。

当你知道了升级所需的经验量后,你可以计算每小时经验。用伪代码表示,这个过程可能是这样的:

   // this example assumes the time is stored in milliseconds
   // for seconds, remove the "1000 * "
   timeUnitsPerHour = 1000 * 60 * 60
   timePassed = (currentTime - startTime)
➊ timePassedToHourRatio = timeUnitsPerHour / timePassed
➋ expGained = (currentExp - startExp)
   hourlyExp = expGained * timePassedToHourRatio

➌ remainingExp = nextExp - currentExp
➍ hoursToGo = remainingExp / hourlyExp

为了找到每小时经验 hourlyExp,你需要存储你的经验值和 HUD 启动时的时间;这些分别是 startExpstartTime。这个例子还假设 currentLevelcurrentExp 已经定义,其中 currentLevel 是角色的等级,currentExp 是当前的经验值。

有了这些值,hourlyExp 可以通过将时间单位比例 ➊(即一个小时内的时间单位与已经过去的时间之间的比值)乘以从 startTime 以来获得的经验来计算 ➋。在这个例子中,时间单位是毫秒,所以时间单位需要乘以 1,000。

接下来,currentExpnextExp 中减去,以确定升级所需的剩余经验 ➌。为了计算还需要多少小时才能升级,剩余经验除以每小时经验 ➍。

当你拥有所有这些信息时,你就可以最终将其显示在屏幕上。使用本书示例代码中提供的 Direct3D hooking 引擎,你可以在EndScene()钩子回调函数中通过这个调用来绘制文本:

hook->drawText(
    10, 10,
    D3DCOLOR_ARGB(255, 255, 0, 0),
    "Will reach level %d in %0.20f hours (%d exp per hour)",
    currentLevel, hoursToGo, hourlyExp);

这就是一个有效的经验跟踪 HUD 所需要的一切。这些相同公式的变体可以用来计算 KPH、DPS、GPH 和其他有用的基于时间的度量。此外,你可以使用 Direct3D 钩子的drawText()函数来显示任何你能找到并规范化的信息。钩子还包含addSpriteImage()drawSpriteImage()函数,你可以用它们来绘制自定义图像,让你的 HUD 变得更加炫酷。

使用钩子定位数据

读取内存并不是获取自定义 HUD 数据的唯一方式。你还可以通过计算DrawIndexedPrimitive()函数绘制特定模型的次数、钩取游戏内部负责绘制某些类型文本的函数,甚至拦截负责处理来自游戏服务器的数据包的函数调用来收集信息。你用来实现这些的方式会因每个游戏而大不相同,找到这些方法将需要你将本书中学到的所有知识与自己的创造力和编程直觉结合起来。

例如,要创建一个显示地图上有多少敌人的 HUD,你可以使用墙壁透视(wallhacks)中使用的模型指纹识别方法来计算敌人的数量,并将这个数字输出到屏幕上。这个方法比从内存中读取敌人列表更好,因为它不需要在每次游戏更新时都找到新的内存地址。

另一个例子是显示敌人的技能冷却列表,这需要你拦截告知客户端显示哪些技能效果的传入数据包。然后,你可以根据技能和敌人位置、技能类型等,将某些技能与某些敌人关联,并利用这些信息来追踪每个敌人使用过的技能。如果你将这些数据与冷却时间数据库关联,你可以准确地显示每个敌人技能何时可以再次使用。这非常强大,因为大多数游戏并不会将敌人的技能冷却存储在内存中。

其他 ESP 黑客概述

除了本章讨论的黑客技巧外,还有一些没有通用名称、专门针对某些类型或甚至某些特定游戏的 ESP 黑客。我将简要介绍一些这些黑客的理论、背景和架构。

范围黑客

范围黑客使用类似于墙壁透视(wallhacks)的方法来检测不同类型的英雄或角色的模型何时被绘制。然后,它们会在每个英雄模型周围绘制圆圈。每个圆圈的半径对应于包围该英雄的最大攻击范围,从而有效地显示你可以被每个敌人攻击的区域。

加载屏幕 HUD

加载屏幕 HUD 在 MOBA 和 RTS 游戏中很常见,这些游戏要求所有玩家在游戏启动时都要经历一个加载屏幕。这些黑客利用了这些游戏通常有网站,可以查询历史玩家统计信息的事实。你可以编写一个自动查询每个玩家统计信息的机器人,并将信息无缝地显示为加载屏幕上的叠加层,让你在战斗前了解敌人的情况。

选择阶段 HUD

选择阶段 HUD 与加载屏幕的 HUD 类似,但它们显示在每个玩家选择一个角色或英雄的预游戏阶段。与显示敌人统计数据不同,选择阶段的 HUD 显示的是盟友的统计信息。这使你能够快速评估盟友的优缺点,从而做出更好的决策,选择适合的角色。

楼层间谍黑客

楼层间谍黑客在较旧的 2D 自上而下游戏中很常见,这些游戏具有不同的楼层或平台。如果你在顶层,你可能希望在冲进去之前了解楼下发生了什么。你可以编写楼层间谍黑客,修改当前楼层值(通常是 unsigned int),将其更改为你上方或下方的其他楼层,从而让你窥探其他楼层的情况。

游戏通常会根据玩家的位置每帧重新计算当前楼层值,因此有时需要使用 NOP 操作来防止每次重绘帧时楼层值被重置。找到当前楼层值以及 NOP 代码的方法与查找缩放因子类似,正如在《使用 NOP 缩放黑客》一节中所讨论的,在第 197 页上。

结束语

ESP 黑客是获取游戏额外信息的强大方式。有些黑客可以通过 Direct3D 钩子或简单的内存编辑轻松完成,而其他一些则需要你了解游戏的内部数据结构并钩住专有函数,这样你就可以运用你的逆向工程技能。

如果你想尝试 ESP 黑客,可以研究并调整本章的示例代码。为了更具体地练习 ESP 黑客,我鼓励你去找一些游戏来进行探索。

第十章:10

响应式黑客

image

平均玩家的反应时间为 250 毫秒,或者四分之一秒。职业玩家的平均反应时间为五分之一秒,但有些玩家能够在六分之一秒内做出反应。这些数据基于在线测试,测量玩家对单一、可预测事件的反应时间。然而,在实际游戏中,玩家必须对数十个不同的事件做出反应,比如失去健康、技能射击的来临、技能冷却结束、敌人攻击等。只有非常熟练的玩家才能在如此动态的环境中维持四分之一或五分之一秒的反应时间;而要想更快,唯一的方法就是使用计算机。

在这一章中,你将学习如何制作比任何玩家反应更快的机器人。首先,我会展示一些你可以在机器人中使用的代码模式,用于检测游戏中何时发生某些事件。接下来,你将学会如何让一个机器人独立移动你的角色、治疗或施放法术。一旦你掌握了这些基本技巧,我将帮助你将它们结合起来,实现一些最常见且最强大的响应式黑客。

观察游戏事件

在玩游戏的几秒钟内,大多数人就能对游戏环境做出基本的观察。你可以清晰地看到导弹飞向你的角色,健康值过低时,或者技能冷却结束时。而对于机器人来说,这些看似直观的观察并不容易。机器人必须通过查看内存变化、检测视觉提示或截取网络流量来识别每个事件。

内存监控

为了检测简单的事件,比如你的健康条下降,你可以编程让机器人定期从内存中读取健康值,并将其与某个最小可接受值进行比较,如清单 10-1 所示。

// do this every 10 milliseconds (100 times a second)
auto health = readMemory<int>(HEALTH_ADDRESS);
if (health <= 500) {
    // some code to tell the bot how to react
}

清单 10-1:一个检查健康的if语句

给定你角色的健康地址,你可以根据需要频繁检查其值;通常每 10 毫秒检查一次是一个好的频率。(如果需要复习如何在内存中查找值,请翻回第一章。)一旦health值降到某个特定值以下,你就需要运行一些反应代码,比如施放治疗法术或喝药水。我将在本章后面讲解如何做到这一点。

如果你希望你的机器人拥有更精细的信息并能够提供更多样化的反应,可以编程使其对任何健康变化作出反应,而不仅仅是在超过设定阈值后才反应。为此,请将清单 10-1 中的代码更改为比较当前的健康值和上次执行时的健康值,如下所示:

// still do this every 10 milliseconds
static int previousHealth = 0;
auto health = readMemory<int>(HEALTH_ADDRESS);
if (health != previousHealth) {
    if (health > previousHealth) {
        // react to increase
    } else {
        // react to decrease
    }
    previousHealth = health;
}

现在,这段代码使用一个名为 previousHealth 的静态变量来追踪上一轮迭代中的 health 值。如果 previousHealthhealth 不同,机器人不仅会对生命值的变化作出反应,还会根据生命值的增加或减少作出不同的反应。这种技术是最简单、最常见的应对游戏状态变化的方式。通过正确的内存地址,你可以使用这种代码模式来观察生命值、魔法值、技能冷却时间以及其他关键数据的变化。

检测视觉提示

生命值对机器人来说相对简单,因为它只是一个数字,但一些游戏元素必须以不同的方式传递给机器人。例如,当状态异常或增益效果影响角色时,最简单的方式就是通过屏幕上的状态指示器来判断,机器人也可以采用同样的方法。

当读取内存不足以满足需求时,你可以通过钩取游戏的图形引擎并等待游戏渲染特定模型来检测某些事件。(请参见 “在 Direct3D 中应用跳跃钩子和 VF 钩子” 第 175 页 和 “创建 Direct3D 墙体透视” 第 194 页 来复习有关 Direct3D 钩子的内容。)当模型被绘制时,你可以排队一个反应,在帧绘制完成后执行,像这样:

// below is the drawIndexedPrimitive hook
void onDrawIndexedPrimitive(...) {
    if (numVertices == EVENT_VERT && primCount == EVENT_PRIM) {
        // react, preferably after drawing is done
    }
}

使用与第九章 墙体透视代码 相同的模型指纹识别技巧,这段代码可以检测特定模型何时被绘制到屏幕上,并做出相应的反应。然而,这段代码会在每一帧都做出反应,这可能会导致游戏无法正常进行。你可能希望加入一些内部冷却机制,以避免反应过度。在那些指示器模型持续绘制(即非闪烁)的情况下,你实际上可以跨帧追踪它,以确定它何时出现或消失。

下面是一个也处理追踪的代码片段:

bool eventActive = false;
bool eventActiveLastFrame = false;
// below is the drawIndexedPrimitive hook
void onDrawIndexedPrimitive(...) {
    if (numVertices == EVENT_VERT && primCount == EVENT_PRIM)
        eventActive = true;
}

// below is the endScene hook
void onDrawFrame(...) {
    if (eventActive) {
        if (!eventActiveLastFrame) {
            // react to event model appear
        }
        eventActiveLastFrame = true;
    } else {
        if (eventActiveLastFrame) {
            // react to event model disappear
        }
        eventActiveLastFrame = false;
    }
    eventActive = false;
}

onDrawIndexedPrimitive() 函数仍然会检查是否绘制了某个模型,但现在有两个布尔值变量追踪该模型是出现在当前帧还是上一帧。当帧完全绘制完后,机器人可以检查这些变量,并根据模型的出现或消失作出反应。

这种方法对于检测只在角色受到眩晕、移动减速、束缚、毒药等影响时才会出现的视觉状态指示器非常有效。你还可以用它来检测敌人在 MOBA 和 RTS 游戏中的出现和消失,因为这些游戏只绘制处于友方单位或玩家视距范围内的敌人。

拦截网络流量

观察事件最可靠的方法之一与游戏客户端的做法相同:等待游戏服务器告诉你事件已经发生。在这种通信方式中,游戏服务器通过套接字将称为 数据包 的字节数组发送到客户端。数据包通常是加密的,包含通过专有格式序列化的数据块。

典型的数据包解析函数

要接收和处理数据包,游戏客户端在绘制帧之前执行类似于清单 10-2 的操作。

void parseNextPacket() {
    if (!network->packetReady()) return;

    auto packet = network->getPacket();
    auto data = packet->decrypt();
    switch (data->getType()) {
        case PACKET_HEALTH_CHANGE:
            onHealthChange(data->getMessage());
            break;
        case PACKET_MANA_CHANGE:
            onManaChange(data->getMessage());
            break;
        // more cases for more packet types
    }
}

清单 10-2:游戏如何解析数据包的简化示例

任何特定游戏的具体代码可能看起来有所不同,但控制流始终相同:接收数据包,解密它,决定它包含什么类型的消息,然后调用一个知道如何处理它的函数。一些游戏黑客拦截原始网络数据包并在他们的机器人中复制这种功能。这种技术是有效的,但需要广泛的加密知识、对游戏如何在数据包中存储数据的全面理解、能够进行中间人攻击的能力,并且需要能够定位游戏客户端使用的解密密钥。

在数据包被解密和解析后,挂钩负责处理数据包的函数是一个更好的方法;在清单 10-2 中,这些函数是 onHealthChange()onManaChange() 函数。这种方法利用了游戏本身处理数据包的能力,使机器人无需了解游戏使用的各种网络设施。它还使你可以自行决定拦截哪些网络数据,因为你只需要挂钩那些满足你需求的处理程序。

注意

拦截整个数据包有时是有利的——例如,在任何使用 Adobe AIR 并通过 RTMPS 通信的游戏中。由于 RTMPS 已被广泛记录,因此无需逆向工程格式或加密。第八章详细解释了如何挂钩 RTMPS。

有一些技巧可以帮助你轻松找到解析函数,并最终定位到处理数据包的 switch() 语句。我发现最有用的方法是将断点放在游戏用于接收网络数据的函数上,然后在断点触发时分析应用程序的流程。

让我们一起走一遍在 OllyDbg 中附加到目标游戏时如何进行操作的过程。在 Windows 中,recv() 是接收来自套接字数据的 API 函数。在 OllyDbg 命令行中,你可以通过输入 bp recv 命令来设置一个 recv() 的断点。当断点触发时,你可以使用 CTRL-F9(执行直到返回的快捷键)和 F8(单步跳过的快捷键)来爬取调用堆栈。这个组合实际上让程序执行直到被调用者返回给调用者,从而让你和游戏同步地爬取调用堆栈。在每个堆栈级别,你可以检查每个调用者的代码,直到找到包含一个大型 switch() 语句的调用者;这应该就是数据包解析器。

更复杂的解析器

然而,根据游戏的架构,找到解析函数可能并不像想象中那么简单。考虑一个有如下解析函数的游戏:

packetHandlers[PACKET_HEALTH_CHANGE] = onHealthChange;
packetHandlers[PACKET_MANA_CHANGE] = onManaChange;

void parseNextPacket()
{
    if (!network->packetReady()) return;
    auto packet = network->getPacket();
    auto data = packet->decrypt();
    auto handler = packetHandlers[data->getType()];
    handler->invoke(data->getMessage());
}

由于 parseNextPacket() 函数没有 switch() 语句,因此没有明显的方法在内存中识别它。除非你特别小心,否则很可能会在调用堆栈中跳过它。当一个游戏有像这样的解析函数时,试图弄清楚解析函数的样子可能是没有意义的。如果在爬取 recv() 调用堆栈时没有看到 switch() 语句,你将不得不记录堆栈中的每个被调用者。

与其从断点开始爬取调用堆栈,不如去查看 OllyDbg 堆栈窗格中 ESP 以下标记为 RETURN 的每个地址。这些是每个被调用者返回到每个调用者的返回地址。在每个返回地址处,你需要在 OllyDbg 的反汇编窗格中找到调用者的顶部并记下地址。这样,你就可以得到一份所有函数调用的列表,直到 recv() 调用。

接下来,你需要重复相同的列表制作过程,设置断点到一些游戏的处理函数上。你可以通过监控这些函数必然会使用的内存来找到一个处理函数。例如,健康变化的数据包的处理函数将会更新内存中的健康值。通过 OllyDbg,你可以将一个 内存写入 断点设置到健康地址。当断点被触发时,意味着游戏从处理函数更新了健康值。这应该对于大多数由服务器控制的值同样适用。服务器会控制任何游戏关键的值,如健康、魔法、等级、物品等等。

一旦你记录了 recv() 和一些处理函数的调用堆栈,你就可以将它们关联起来,找到解析函数。例如,考虑 表 10-1 中的三个伪调用堆栈。

表 10-1: 三个与数据包相关的函数的伪调用堆栈

recv() 堆栈 onHealthChange() 堆栈 onManaChange() 堆栈
0x0BADF00D 0x101E1337 0x14141414
0x40404040 0x50505050 0x60606060
0xDEADBEEF 0xDEADBEEF 0xDEADBEEF
0x30303030 0x30303030 0x30303030
0x20202020 0x20202020 0x20202020
0x10101010 0x10101010 0x10101010

这些栈显示了在调用 recv() 和游戏的假设 onHealthChange()onManaChange() 函数时内存的可能状态。请注意,每个函数都起源于一链四个共同的函数调用(以粗体显示)。最深的共同地址 0xDEADBEEF 是解析器的地址。为了更好地理解这种结构,可以查看以树形结构展示的调用栈,如 图 10-1 所示。

image

图 10-1:我们三个调用栈的树形视图

每个函数的调用栈都从地址 0xDEADBEEF 处分支,这意味着该函数是三个调用的共同起点。示例中的 parseNextPacket() 函数负责调用这些函数,因此它必须是 0xDEADBEEF 处的最新共同祖先。

注意

这些调用栈是假设的,它们简化得比你通常遇到的情况要多。实际的调用栈可能会有更多的函数调用,比较它们的难度也会更大。

一个混合解析系统

解析循环的第三种变体可能是前两者的混合,它在函数调用后使用 switch() 语句。这里是另一个假设的函数:

void processNextPacket()
{
    if (!network->packetReady()) return;
    auto packet = network->getPacket();
    auto data = packet->decrypt();
    dispatchPacket(data);
}

void dispatchPacket(data)
{
    switch (data->getType()) {
    case PACKET_HEALTH_CHANGE:
        processHealthChangePacket(data->getMessage());
        break;
    case PACKET_MANA_CHANGE:
        processManaChangePacket(data->getMessage());
        break;
        // more cases for more data types
    }
}

processNextPacket() 函数获取一个新的数据包,并调用 dispatchPacket() 来处理数据。在这种情况下,dispatchPacket() 函数出现在每个处理函数的调用栈中,但不在 recv() 函数的调用栈中。例如,可以查看 表 10-2 中的假设栈。

表 10-2: 三个与数据包相关的函数的伪调用栈

recv() onHealthChange() onManaChange()
0x0BADF00D 0x101E1337 0x14141414
0x40404040 0x00ABCDEF 0x00ABCDEF
0xDEADBEEF 0xDEADBEEF 0xDEADBEEF
0x30303030 0x30303030 0x30303030
0x20202020 0x20202020 0x20202020
0x10101010 0x10101010 0x10101010

尽管这三个函数的调用栈中的前四个地址相同,只有两个处理程序有一个额外的共同地址(再次以粗体显示)。那个地址是 0x00ABCDEF,它是 dispatchPacket() 函数的地址。你可以再次想象这些栈以树形结构展现,如 图 10-2 所示。

image

图 10-2:我们三个调用栈的树形视图

一个解析器破解

一旦你找到了负责将数据包分发到处理函数的函数,你就能识别出可以被调用的每个处理程序。你可以通过在某个处理程序上设置断点,观察它执行时内存中变化的值,从而推断该处理程序的功能。然后,你可以挂钩你需要响应的任何处理程序。(如果你需要复习如何挂钩这些函数,可以翻回 第八章。)

当然,实现网络行为的方法是无穷无尽的。我无法覆盖所有方法,但看到这三种常见技巧应该能帮助你理解这种方法论。无论你处理的是哪个游戏,在recv()上设置一个断点应该是朝着正确方向迈出的第一步。

执行游戏内操作

在机器人能够响应事件之前,你需要教会它如何玩游戏。它需要能够施放法术、四处移动和激活物品。在这一方面,机器人与人类并没有太大区别:它们只需被告知按下哪些按钮。按按钮很简单,许多情况下也足够用,但在更复杂的情况下,机器人可能需要通过网络与服务器进行通信,告诉服务器它正在做什么。

为了跟随本节的示例并在之后自行探索,请打开本书资源文件中的GameHackingExamples/Chapter10_ResponsiveHacks/文件夹。

模拟键盘

在游戏中,你最常按下的按钮是键盘按键,有几种方法可以教你的机器人进行输入。

SendInput()函数

一种常见的模拟键盘的方法是使用SendInput() Windows API 函数。这个函数将键盘和鼠标输入发送到最上层的窗口,它的原型如下:

UINT SendInput(UINT inputCount, LPINPUT inputs, int size);

第一个参数,inputCount,是发送的输入数量。对于本书中的示例,我将始终使用1作为值。第二个参数,inputs,是指向一个结构体(或者一个结构体数组,其长度与inputCount值匹配)的指针,该结构体类型为预定义的INPUT类型。最后一个参数是输入在内存中的大小,通过公式size = inputCount × sizeof(INPUT)来计算。

INPUT结构体告诉SendInput()函数要发送什么类型的输入,以下代码展示了如何初始化INPUT实例来按下 F1 键:

INPUT input = {0};
input.type = INPUT_KEYBOARD;
input.ki.wVk = VK_F1;

要让你的机器人真正按下 F1 键,你需要像这样发送两次这个输入:

SendInput(1, &input, sizeof(input));
// change input to key up
input.ki.dwFlags |= KEYEVENTF_KEYUP;
SendInput(1, &input, sizeof(input));

第一次调用SendInput()按下 F1 键,第二次释放它。释放操作并非因为输入被发送了两次,而是因为第二次调用时,在input参数的键盘标志字段中启用了KEYEVENTF_KEYUP标志。由于即使是设置一个单一的按键输入也有些繁琐,因此最好将所有内容包装在一个函数中。结果看起来像清单 10-3。

void sendKeyWithSendInput(WORD key, bool up)
{
    INPUT input = {0};
    input.type = INPUT_KEYBOARD;
    input.ki.wVk = key;
    input.ki.dwFlags = 0;

    if (up)
        input.ki.dwFlags |= KEYEVENTF_KEYUP;
    SendInput(1, &input, sizeof(input));
}
sendKeyWithSendInput(VK_F1, false); // press
sendKeyWithSendInput(VK_F1, true);  // release

清单 10-3:一个包装器,用于通过SendInput()模拟按键

该函数使用给定的key初始化input,如果up被设置,则启用KEYEVENTF_KEYUP标志,并调用SendInput()函数。这意味着即使释放键是必须的,sendKeyWithSendInput()仍然需要被调用第二次来发送按键释放。这样编写的原因是,因为涉及到修改键(如 SHIFT、ALT 或 CTRL)的组合键必须以稍有不同的方式发送;修改键的按下必须在主键按下之前,释放则必须在主键释放之后。

以下代码展示了如何使用sendKeyWithSendInput()函数告诉机器人按下 SHIFT-F1:

sendKeyWithSendInput(VK_LSHIFT, false); // press shift
sendKeyWithSendInput(VK_F1, false);     // press F1
sendKeyWithSendInput(VK_F1, true);      // release F1
sendKeyWithSendInput(VK_LSHIFT, true);  // release shift

你需要调用sendKeyWithSendInput()四次,但这比直接使用没有包装函数的代码要简单。

SendMessage()函数

发送按键的另一种方法依赖于SendMessage()Windows API 函数。这个函数允许你将输入发送到任何窗口,即使它被最小化或隐藏,通过直接将数据发送到目标窗口的消息队列。这一优点使其成为游戏黑客的首选方法,因为它使得用户可以在机器人在后台运行游戏的同时进行其他操作。SendMessage()的原型如下:

LRESULT SendMessage(
    HWND window,
    UINT message,
    WPARAM wparam,
    LPARAM lparam);

第一个参数window是输入将要发送到的窗口句柄。第二个参数message是发送的输入类型;对于键盘输入,此参数是WM_KEYUPWM_KEYDOWNWM_CHAR。第三个参数wparam应该是键码。最后一个参数lparamWM_KEYDOWN消息时应为0,否则为1

在使用SendMessage()函数之前,你必须获取目标进程主窗口的句柄。根据窗口的标题,你可以使用FindWindow()Windows API 函数来获取句柄,如下所示:

auto window = FindWindowA(NULL, "Title Of Game Window");

在拥有有效窗口句柄的情况下,调用SendMessage()大致如下:

SendMessageA(window, WM_KEYDOWN, VK_F1, 0);
SendMessageA(window, WM_KEYUP, VK_F1, 0);

第一个调用按下 F1 键,第二个调用释放它。然而,请记住,这一系列调用只对不输入文本的按键有效,比如 F1、INSERT 或 TAB。若要让你的机器人按下会输入文本的按键,你还必须在按下和释放消息之间发送WM_CHAR消息。例如,要输入 W,你可以像这样操作:

DWORD key = (DWORD)'W';
SendMessageA(window, WM_KEYDOWN, key, 0);
SendMessageA(window, WM_CHAR, key, 1);
SendMessageA(window, WM_KEYUP, key, 1);

这创建了一个key变量,以便可以轻松更改要按下的字母键。然后,它遵循与 F1 示例相同的步骤,只是在中间加上了WM_CHAR消息。

注意

你实际上可以只发送WM_CHAR消息也能得到相同的结果,但最好是发送所有三个消息。游戏开发者可以通过修补游戏来忽略那些没有跟随WM_KEYDOWNWM_CHAR消息,从而轻松关闭机器人,甚至可以用它来检测你的机器人并封禁你。

正如我通过SendInput()技术所展示的,你可以围绕这个功能创建一个包装器,使得你的机器人代码更容易操作。这个包装器看起来像这样:

void sendKeyWithSendMessage(HWND window, WORD key, char letter)
{
    SendMessageA(window, WM_KEYDOWN, key, 0);
    if (letter != 0)
        SendMessageA(window, WM_CHAR, letter, 1);
    SendMessageA(window, WM_KEYUP, key, 1);
}

与清单 10-3 不同,这个包装器实际上同时发送按下和释放操作。这是因为SendMessage()不能用于发送带有修改键的按键输入,因此不需要在两个调用之间插入代码。

注意

不过,游戏可能通过多种方式检查是否按下了修改键。您可能能够通过调用SendMessage()函数向某些游戏发送修改键,但这取决于这些游戏如何检测修改键。

您可以像在清单 10-3 中一样使用这个包装器。例如,这段代码发送 F1 键,后跟 W 键:

sendKeyWithSendMessage(window, VK_F1, 0);
sendKeyWithSendMessage(window, 'W', 'W');

这个示例,就像我之前展示的所有SendMessage()代码一样,简单地完成了任务。它可以输入文本,但并没有准确地发送正确的消息。

如果您希望使用SendMessage()函数发送 100%有效的消息,您需要确保处理很多小细节。例如,lparam的前 16 位应该存储由于按住键而自动重复的次数。接下来的 8 位应该存储扫描码,这是一个特定于每个键盘制造商的键标识符。接下来的第 24 位仅在按钮位于键盘扩展部分(例如数字键盘)时设置。接下来的 4 位是未记录的,接下来的 1 位仅在消息来源时 ALT 键按下时设置。最后 2 位是上一个状态标志和转换状态标志。上一个状态标志仅在键先前按下时设置,而转换状态仅在键的当前状态与其之前的状态相反时设置(即,如果键现在是松开的而之前是按下的,或反之)。

幸运的是,大多数游戏并不在意这些值。事实上,大多数软件也不关心它们。如果您必须为这些值填充正确的数据才能让您的机器人正常工作,那么您正在朝错误的方向前进。还有许多其他更简单的方式来执行操作,其中大多数比尝试模拟操作系统内核级别的键盘输入处理器/分发器的精确行为要简单得多。事实上,已经有一个函数可以做到这一点,我之前已经提到过:SendInput()函数。

您还可以使用SendInput()SendMessage()函数来控制鼠标,但我强烈建议避免这样做。您发送的任何鼠标命令都会受到玩家发送的任何合法鼠标移动、鼠标点击或按键输入的影响,反之亦然。键盘输入也是如此,不过这种复杂性要少得多。

发送数据包

在游戏绘制一帧之前,它会检查键盘和鼠标输入。当它接收到导致动作的输入(例如移动或施放魔法)时,它会检查动作是否可能,如果可以,它会告诉游戏服务器该动作已经执行。检查事件并提醒服务器的游戏代码通常类似于以下内容:

void processInput() {
    do {
        auto input = getNextInput();
        if (input.isKeyboard())
            processKeyboardInput(input);
        // handle other input types (e.g., mouse)
    } while (!input.isEmpty());
}
void processKeyboardInput(input) {
    if (input.isKeyPress()) {
        if (input.getKey() == 'W')
            step(FORWARD);
        else if (input.getKey() == 'A')
            step(BACKWARD);
        // handle other keystrokes (e.g., 'S' and 'D')
    }
}
void step(int direction) {
    if (!map->canWalkOn(player->position))
        return;
    playerMovePacket packet(direction);
    network->send(packet);
}

processInput()函数在每一帧被调用。该函数遍历所有待处理的输入,并将不同类型的输入分派到相应的处理程序。在这个例子中,当接收到键盘输入时,它会将输入分派给processKeyboardInput()函数。然后,这个处理程序检查按键是否为 W 或 S,如果是,它会调用step()将玩家移到相应的方向。

由于step()用于执行动作,因此它被称为演员函数。调用演员函数的过程被称为执行。你可以直接从你的机器人调用游戏的演员函数来执行一个动作,而完全绕过输入层。

然而,在你能够调用一个演员函数之前,必须先找到它的地址。为此,你可以将 OllyDbg 附加到游戏上,打开命令行并输入bp send。这将在send()函数上设置一个断点,该函数用于通过网络发送数据。当你玩游戏时,每次你采取步骤、施放魔法、拾取战利品或做任何其他事情时,断点应该会触发,你可以记录下调用堆栈中的每个函数。

注意

游戏应该在你玩游戏的每个动作后都调用 send() 。注意每次send()断点被触发之前你做了什么,这将给你一个大概的思路,了解每个调用传达给服务器的是什么动作,最终帮助你了解你找到的演员函数的职责。

一旦你有了几个不同的调用堆栈,你可以比较它们来定位演员函数。为了了解如何识别演员函数,我们来看一下图 10-3 中标注的两个调用堆栈。

image

图 10-3:两个演员函数的调用堆栈树状视图

就像这两个堆栈一样,你找到的调用堆栈应该在顶部是相同的,共享几个负责通用网络传输的公共函数。它们在底部也应该是相同的,因为每次调用send()都应该来自processInput()函数。尽管如此,每个堆栈在这些相同区域之间应该有一些独特的函数,这些就是你要寻找的演员函数。通常,感兴趣的函数紧跟在公共网络调用之后。在这个例子中,两个演员函数是step()castSpell()函数。

在同一个游戏中黑客攻击一段时间后,你会了解到演员函数距离send()调用的栈深度。例如,在图 10-3 中,演员函数是在send()调用前的三次调用。知道这一点后,当你的send()断点被触发时,你可以在 OllyDbg 中爬升栈(按 CTRL-F9 后再按 F8)三次,并进入发送数据的演员函数。

一旦你找到了一个演员函数,你可以从注入的 DLL 中调用它。假设你在地址 0xDEADBEEF 处找到了step(),以下是如何调用它的示例:

typedef void _step(int direction);
auto stepActor = (_step*)0xDEADBEEF;

stepActor(FORWARD);

由于机器人并不知道该游戏函数的实际名称,因此代码将内存中 0xDEADBEEF 地址的内容赋值给一个方便命名的变量:stepActor。然后,代码就像调用任何其他函数一样调用stepActor()

如果你有正确的地址、函数原型和参数,这应该能完美地工作;你将能够像访问游戏源代码一样自动化操作。只要确保从与游戏相同的线程内调用演员函数,否则你可能会遇到线程问题。最好的做法是从一个主要函数的钩子中调用演员函数,比如 Direct3D 的EndScene()或 Windows API 的PeekMessage()函数,因为这些函数通常只会从游戏的主线程中调用。

使用此方法调用 _ _THISCALL

如果你尝试调用一个属于类的非静态成员的演员函数,该函数将采用_thiscall调用约定,这意味着你需要将类的实例传递到 ECX 寄存器中。(你可以在“函数调用”中复习调用约定,见第 94 页)。传递实例是直接的,但你首先需要定位到指向类实例的指针链。

为了找到指针链,你可以在演员函数上设置断点,在断点触发时从 ECX 中获取类实例的值,并将该值放入 Cheat Engine 的指针扫描中。然后,为了调用该函数,你需要遍历指针链,获取当前实例的地址,并使用内联汇编设置 ECX,执行实际的函数调用。这个过程与 VF 钩子回调调用其原始函数的方式类似,如在“编写 VF 表钩子”的第 156 页所示。

将各个部分结合起来

在你创建了用于观察事件和执行动作的框架之后,你可以将它们结合起来创建响应式黑客程序。响应式黑客程序有很多种类型,但有一些是常见的。

打造完美治疗者

游戏玩家中最受欢迎的机器人是自动治疗,这是一种在玩家的生命值急剧下降或低于某个阈值时自动使用治疗法术的黑客程序。给定一种检测生命值变化的方法和一个施放法术的演员函数,一个自动治疗器可能如下所示:

void onHealthDecrease(int health, int delta) {
    if (health <= 500)     // health below 500
        castHealing();
    else if (delta >= 400) // large drop in health
        castHealing();
}

这个自动治疗函数相当简单,但效果很好。更高级的自动治疗器可能会有更多的治疗层级,并且能够随着时间的推移不断学习。在《控制理论与游戏黑客》一章中,你将看到实际的示例代码,并且对高级自动治疗器有更深入的解释,第 222 页会讲解具体内容。

抵抗敌人的人群控制攻击

反人群控制黑客会检测到即将到来的人群控制攻击,并自动施放减少效果或完全消除效果的法术。人群控制攻击会以某种方式禁用玩家,所以敌人对你施放这些攻击会让人很头疼。

如果有办法检测到即将到来的或已激活的人群控制效果,例如通过检测 Direct3D 模型或拦截即将到来的数据包,并且有一个角色函数来施放法术,那么你就可以让机器人即时反应,例如:

void onIncomingCrowdControl() {
    // cast a shield to block the crowd control
    castSpellShield();
}
void onReceiveCrowdControl() {
    // cleanse crowd control that has already taken effect
    castCleanse();
}

onIncomingCrowdControl()函数可能会尝试阻止人群控制法术击中你。如果失败,机器人可以调用onReceiveCrowdControl()法术来移除效果。

避免浪费法力

法术训练师在机器人玩家中也相当常见。法术训练师会等到玩家的法力值满了,然后施放法术来提高玩家的魔法等级或属性。这使得玩家可以快速提升魔法技能,因为他们永远不会浪费法力恢复,即使法力已经满了。

如果有办法检测到法力的变化,并且有一个角色函数来施放法术,那么一个机器人可能会包含以下伪代码来作为法术训练师:

void onManaIncrease(int mana, int delta) {
    if (delta >= 100) // player is using mana potions,
        return;       // they must need the mana, abort
    if (mana >= MAX_MANA - 10) // mana is nearly full, waste some
        castManaWasteSpell();
}

这个函数将玩家的法力和玩家法力的增量(delta)作为参数。如果法力的增量超过一定值,函数假设玩家正在使用药水或其他物品来补充法力,并且不会施放任何额外的法术。否则,如果玩家有充足的法力,函数会施放任何旧的法术来为玩家获取一些经验值。

其他常见的响应式黑客包括自动重装来立即重新装填弹药,自动闪避来躲避来袭的飞弹,以及自动连招来立即攻击与附近盟友相同的目标。实际上,你可以为机器人添加的响应式黑客的数量,唯一的限制是你的机器人能够观察到的游戏事件数量,再乘以它能为每个事件发送的有效和有帮助的响应数量。

结语

通过使用钩子、内存操控和键盘模拟,你可以开始创建你的第一个响应式黑客。这些黑客是你进入游戏自主性的大门,但它们只是你所能做到的一小部分。《第十一章》将是你游戏黑客冒险的巅峰。利用你迄今为止学到的所有知识,并基于响应式黑客的原则,你将学会如何自动化高级动作,并创建一个真正自主的机器人。

如果你还没有准备好深入学习,我强烈建议你复习一下之前的内容,然后在自己电脑上的隔离环境中进行一些实践。实现像这样的机器人比你想象的要简单,而且这是一个非常令人满足的体验。一旦你熟悉了制作自动治疗机器人和其他基本的响应式黑客,你就准备好开始完全自动化游戏玩法了。

第十一章:11

将所有内容整合:编写自动化机器人

image

游戏黑客的最终目标是制作一个功能完善的自动化机器人,能够连续数小时玩游戏。这些机器人可以治疗、喝药水、打怪、掠夺尸体、四处走动、出售战利品、购买补给等等。要制作出如此强大的机器人,需要将你的挂钩和内存读取与控制理论、状态机和搜索算法等概念结合,这些内容在本章中都有涉及。

在这里的课程中,你还将学习常见的自动化黑客以及它们在高层次上应该如何表现。在讲解了自动化黑客背后的理论和代码后,我将从高层次上向你展示两种依赖于此类代码的机器人类型:洞穴机器人,它们可以探索洞穴并带回战利品;战争机器人,它们可以为你与敌人作战。到本章结束时,你应该准备好拿出你的工具,启动开发环境,开始制作一些非常酷的机器人。

控制理论与游戏黑客

控制理论 是一门工程学科,提供了一种控制动态系统行为的方法。控制理论使用传感器确定系统的状态,然后由控制器确定一组行动,来将系统的当前状态转变为另一个期望的状态。在控制器执行该行动集合中的第一个动作后,整个过程—称为反馈回路—会重复(参见图 11-1)。

image

图 11-1:控制理论反馈回路

让我们将这个反馈回路应用到游戏黑客中。为了在游戏(系统)中实现自动化玩法,机器人实现一些算法(控制器),它们能够理解如何在内存读取、网络挂钩等所观察到的任何状态下进行游戏(传感器)。控制器通常有一些人类输入,比如行走的路径、攻击的生物和要拾取的战利品。因此,为了达到期望的状态,控制器必须执行一些子集的输入,这些输入在当前状态下是可能的。

例如,如果屏幕上没有生物,也没有尸体可以掠夺,那么期望的状态可能是玩家到达下一个位置(称为路径点)。在这种情况下,控制器会在每次迭代中将玩家向路径点推进一步。如果玩家遇到一个生物,控制器可能会在第一帧决定攻击这个生物,接下来的帧中则在逃跑(称为拉扯)和向其施放法术之间切换。一旦生物死亡,控制器会执行一系列操作来掠夺尸体并继续前往下一个路径点。

鉴于反馈回路可能如何运作的这个例子,编写这样一个系统可能看起来令人不知所措。幸运的是,有一些设计模式使得这项任务比听起来要容易得多。

状态机

状态机是计算的数学模型,用来描述系统如何基于输入行为。图 11-2 展示了一个简单的状态机,它读取一组二进制数字。机器从初始状态S[1]开始。在遍历输入中的数字时,它会相应地改变自己的状态。在这种情况下,当机器遇到 1 时,状态S[1]和S[2]会自我重复,并且当遇到 0 时,它们会彼此激活。例如,对于二进制数字 11000111,状态转换将是S[1]、S[1]、S[2]、S[1]、S[2]、S[2]、S[2],最后是S[2]。

image

图 11-2:一个简单的状态机

通过对经典状态机理论稍作修改,状态机可以作为控制理论反馈回路中的控制器。这个修改后的状态机包括一组状态、每个状态的标志条件,以及为了到达每个状态而必须执行的动作。

状态机与游戏黑客

游戏黑客状态机不仅必须保持内部状态,还必须根据该状态响应(或激活)游戏环境。整体游戏状态可以根据你的机器人动作、其他玩家的行为以及游戏环境中其他不可预测的事件发生变化。因此,试图基于观察到的游戏环境持久地遍历一个状态机是徒劳的;几乎不可能为每个状态创建一组转换来应对每次迭代中可能发生的每个观察结果。更合理的做法是,让状态机每次在考虑输入时重新评估游戏环境,就像一个全新的起点。为了做到这一点,状态机必须使用游戏环境本身作为状态之间转换的机制——也就是说,机器对环境的激活应对下一次迭代产生足够的影响,从而激活新的状态。可以设计出能够像这样工作的经典状态机,但我们将简化它们,以更简单但仍然非常强大的方式使用它们。

如果你熟悉经典状态机,可能觉得这不太直观,但在接下来的章节中,你将看到状态机如何变异并与控制理论结合,以实现我们想要的效果。

主要的区别在于,游戏自动化状态机中的每个状态并不是简单地激活另一个状态,而是机器人将在游戏中执行改变游戏整体状态的动作,从而改变下次反馈回路迭代时检测到的状态。在代码中,表示这个状态机中某个状态的对象可能看起来像这样:

class StateDefinition {
public:
    StateDefinition(){}
    ~StateDefinition(){}
    bool condition();
    void reach();
};

你可以将StateDefinition对象通过一个简单的std::vector定义组装成一个状态机,像这样:

std::vector<StateDefinition> stateMachine;

于是,状态机的骨架就完成了,准备好接收你创建的任何StateDefinition对象。配合反馈循环,这个状态机可以用来定义自动化的流程。

首先,你可以创建一个定义列表,模型化你的机器人期望的行为,并按照重要性顺序排列。在该列表中的每个StateDefinition对象都可以使用来自传感器的信息作为输入,将这些数据传递给condition()函数,以判断是否应激活该状态。然后,你可以创建一个控制器,遍历状态列表,调用第一个condition()函数返回false的状态的reach()函数。最后,你可以将控制器包裹在反馈循环中。如果你还不明白这个反馈循环是如何工作的,别担心;我现在会向你展示如何编码实现。

注意

你可以将condition() 函数中的语句看作是机器转移到下一个状态的条件。如果该语句为真,则意味着在评估列表中的下一个状态并继续迭代循环之前,不需要执行任何激励。如果该语句为假,则意味着必须执行某些激励,才能发生状态转移。

你将在本书源文件的GameHackingExamples/Chapter11_ StateMachines目录中找到以下部分以及“错误修正”的所有示例代码,代码位于第 230 页。所包含的项目可以使用 Visual Studio 2010 编译,但它们也应该能在任何其他 C++编译器上运行。如果你想跟着一起做,可以从* www.nostarch.com/gamehacking/*下载并编译它们。

结合控制理论与状态机

要将状态通过反馈循环连接起来,首先你需要为每个StateDefinition对象提供一种通用的方式来访问你实现的传感器和执行器。StateDefinition类随后变成以下形式:

class StateDefinition {
public:
    StateDefinition(){}
    ~StateDefinition(){}
    bool condition(GameSensors* sensors);
    void reach(GameSensors* sensors, GameActuators* actuators);
};

这个变化只是简单地修改了condition()reach()函数,使其能够接受GameSensorsGameActuators类的实例作为参数。GameSensorsGameActuators是你需要定义的类;GameSensors将包含从游戏中拦截的内存读取、网络挂钩和其他数据源的结果,而GameActuators将是一个包含可执行游戏内动作的函数集合。

接下来,你需要一种通用的方式来定义每个独立的状态。你可以将每个状态的定义抽象为一个继承自StateDefinition并实现condition()reach()虚函数的类。或者,如果源代码需要适应有限空间(比如书籍,眨眼),你可以保持使用单一类来表示每个定义,并使用std::function在类定义外部实现condition()reach()函数。

根据那个替代方法,StateDefinition的最终版本会如下所示:

class StateDefinition {
public:
    StateDefinition(){}
    ~StateDefinition(){}
    std::function<bool(GameSensors*)> condition;
    std::function<void(GameSensors*, GameActuators*)> reach;
};

使用这个版本的StateDefinition类,你可以通过创建该类的实例并将condition()reach()分别赋值为与预期行为相对应的函数来定义一个新的状态。

一个基础治疗状态机

接下来的步骤是定义机器人的实际行为。为了保持示例代码的简洁,假设你正在实现一个自动治疗机器人。这个治疗机器人有两种治疗方法:当玩家的血量低于或等于 50%时使用强治愈,当玩家的血量在 51%到 70%之间时使用弱治愈。

一个表示这种行为的状态机需要两个状态,一个用于强治愈,另一个用于弱治愈。首先,你需要将状态机定义为一个包含两个StateDefinition对象的向量:

std::vector<StateDefinition> stateMachine(2);

这段代码创建了一个名为stateMachine的状态机,并使用两个空的StateDefinition对象初始化它。接下来,你为这些状态定义了condition()reach()函数。强治愈状态是最重要的,因为它防止角色死亡,因此应该在向量中排在第一位,正如列表 11-1 所示。

   auto curDef = stateMachine.begin();
   curDef->condition = [](GameSensors* sensors) {
➊     return sensors->getHealthPercent() > 50;
   };
   curDef->reach = [](GameSensors* sensors, GameActuators* actuators) {
➋     actuators->strongHeal();
   };

列表 11-1:强治愈状态的代码

这段代码首先创建了一个名为curDef的迭代器,指向stateMachine向量中的第一个StateDefinition对象。然后定义该对象的condition()函数➊;用英文表达就是,“如果玩家的血量百分比大于 50%,则满足该状态。”如果状态没有满足,则该对象的reach()函数调用strongHeal()角色函数➋,以便执行强治愈。

定义了强治愈状态后,接下来你定义弱治愈状态,正如列表 11-2 所示。

   curDef++;
   curDef->condition = [](GameSensors* sensors) {
➊     return sensors->getHealthPercent() > 70;
   };
   curDef->reach = [](GameSensors* sensors, GameActuators* actuators) {
➋     actuators->weakHeal();
   };

列表 11-2:弱治愈的代码

在将curDef递增,使其指向stateMachine向量中的第二个StateDefinition对象之后,这段代码定义了该对象的condition()函数➊为:“如果玩家的血量百分比大于 70%,则满足该状态。”它还定义了该对象的reach()函数为actuators->weakHeal()调用➋。

一旦完成了状态机的定义,你必须实现控制器。由于控制器的实际行为包含在状态机中,你只需添加一个简单的循环来完成它:

for (auto state = stateMachine.begin(); state != stateMachine.end(); state++) {
    if (➊!state->condition(&sensors)) {
        state->reach(&sensors, &actuators);
        break;
    }
}

这个控制器循环遍历状态机,执行第一个condition()函数返回false的状态的reach()函数➊,如果任何reach()函数被调用,就会跳出循环。最后一步是实现反馈循环,并将控制器循环放入其中,正如列表 11-3 所示。

while (true) {
    for (auto state = stateMachine.begin();
         state != stateMachine.end();
         state++) {
        if (!state->condition(&sensors)) {
            state->reach(&sensors, &actuators);
            break;
    }
    Sleep(FEEDBACK_LOOP_TIMEOUT);
}

列表 11-3:最终的治愈状态机和反馈循环

这个循环持续执行控制器循环,并在每次执行之间暂停 FEEDBACK_LOOP_TIMEOUT 毫秒。Sleep() 调用允许游戏服务器接收并处理上一次迭代的任何操作,并允许游戏客户端在执行下一个控制器循环之前接收来自服务器的操作结果。

如果你对我刚才展示的内容还有点困惑,可以查看图 11-3,它展示了清单 11-3 中无限循环的代码如何工作。首先,它检查强治疗条件是否为 true,如果是,则检查弱治疗条件。如果强治疗条件为 false,那么玩家的健康必须处于或低于 50%,于是调用强治疗方法。如果弱治疗条件检查为 false,则玩家的健康必须在 51% 到 70% 之间,于是执行弱治疗方法。

image

图 11-3:治疗状态机和反馈循环的流程图

在任一方法后,机器会进入休眠状态。如果两个条件检查都为 true,则玩家无需治疗。机器不会更改状态,进入休眠状态后再次从 while 循环的顶部开始。

一个复杂的假设状态机

治疗状态机中实现的行为很简单,因此将其融入这种控制结构可能显得有些过度,但如果你想扩展控制器,它是非常有用的。例如,如果你想将治疗状态机与我在《控制理论与游戏黑客》中讨论的“走路、攻击、拾取战利品”行为结合起来(见第 222 页),控制结构将会复杂得多。我们来高层次地看看你需要的状态:

强治疗 如果健康值超过 50%,则满足条件。通过施放强治疗法术来达到。

弱治疗 如果健康值超过 70%,则满足条件。通过施放弱治疗法术来达到。

攻击法术 如果没有目标可用,或者攻击法术正在冷却,则满足条件。通过向目标施放攻击法术来达到。

牵制怪物 如果没有目标可用,或者与目标的距离足够,则满足条件。(“足够”的定义取决于你在牵制敌人时希望与敌人的距离有多远。)通过远离目标迈步来达到。

目标怪物 如果没有可攻击的生物,则满足条件。通过攻击一个生物来达到。

拾取战利品 如果没有打开的尸体,或者打开的尸体没有任何可以拾取的物品,则满足条件。通过从打开的尸体中取出物品来达到。

接近尸体 如果没有可以打开的尸体或接近一个尸体,则满足条件。通过向将要被打开的尸体迈步来达到。

打开尸体 如果角色没有接触到一个可以打开的尸体,则满足条件。通过打开相邻的尸体来达到。

跟随路径 如果角色无法移动到当前航点,或者站在当前航点上,则满足该条件。通过向当前航点迈步来到达。

前进航点 如果没有剩余的航点可供跟随,则满足该条件。通过将当前航点更新为列表中的下一个航点来到达。如果角色由于某些原因(例如卡住)无法到达当前航点,则前进航点状态可以防止其卡住。如果角色已经到达当前航点,前进航点会选择下一个航点,确保进度继续推进。

这个状态机比仅用于治疗的状态机复杂得多。如果我画出这个状态机的图表,图表中将有 23 个对象,箭头连接 33 条控制路径。相比之下,图 11-3 只有 7 个对象和 9 条控制路径。

你可以在不使用状态机或反馈循环的情况下编写治疗者行为,但我无法想象如何轻松地对这个完整功能的机器人做同样的事情。这些 10 个状态不仅依赖于各自的条件,还依赖于前面每个状态的条件。此外,硬编码逻辑将需要大量嵌套的if()语句或一堆堆叠的if()/return()语句——无论哪种方式,它都将像状态机一样工作,但没有运行时的灵活性。

运行时灵活性 指的是状态机的变异能力。与硬编码条件检查不同,状态机中的状态定义可以动态移动、移除和添加。状态机方法使你能够根据用户输入,灵活地插拔不同的行为和功能。

更进一步,你可以将传感器和执行器暴露给 Lua 环境,创建能够添加和移除状态的 Lua 函数,并修改StateDefinition,使其condition()reach()函数能够调用 Lua 环境暴露的 Lua 函数。通过这种方式编写控制系统,可以让你在 C++中编写机器人的核心部分(钩子、内存读取、执行),同时让 Lua(作为一种高级动态语言)为你提供自动化功能。

注意

你可以通过包含一些头文件并链接 Lua 库,将 Lua 嵌入到你自己的程序中。这个过程并不困难,但超出了本书的范围,所以我鼓励你查看《Lua 程序设计》(作者:Roberto Ierusalimschy)第二十四章 (www.lua.org/pil/24.html) 以获取更多信息。

错误修正

另一种对游戏黑客有用的控制理论是错误修正。控制器中的错误修正机制观察执行结果,将结果与预期结果进行比较,并调整未来的计算,以使后续结果更接近预期结果。错误修正对于处理随机系统时非常有用,在这些系统中,给定输入生成的输出无法完全预测。

游戏整体是随机的,但幸运的是,对于游戏黑客来说,动作的结果大多是确定的。以治疗控制器为例。在大多数游戏中,你可以准确计算出通过给定法术能够恢复多少生命值,从而知道何时进行治疗。但试想一下,你正在编写一个治疗器,用于处理一些无法计算的治疗情况;例如,可能机器人需要在没有用户输入的情况下,适应各种不同级别的角色。

错误修正可以让你的机器人学会如何最好地治疗玩家。在这种情况下,你可以实现错误修正的方式有两种,每一种都取决于治疗系统的工作原理。

调整恒定比例

如果你以恒定的比例进行治疗,你只需要在第一次治疗后调整你的控制器。假设你的传感器能够检测你恢复了多少生命值,这只需要几行代码。你可以轻松地将 Listing 11-2 中的微弱治疗状态修改成像这样的代码:

curDef->condition = [](GameSensors* sensors) -> bool {
    static float healAt = 70;
    static bool hasLearned = false;
    if (!hasLearned && sensors->detectedWeakHeal()) {
        hasLearned = true;
        healAt = 100 - sensors->getWeakHealIncrease();
    }
    return sensors->getHealthPercent() > healAt;
};

与其硬编码70作为微弱治疗的阈值,不如将阈值移动到一个名为healAt的静态变量中。它还添加了另一个静态变量hasLearned,这样代码就能知道何时学习完成。

每次调用此condition()函数时,代码会检查两个条件:hasLearned是否为false,以及传感器是否检测到微弱的治疗事件。当这个检查通过时,代码会将hasLearned设置为true,并更新healAt以在完美百分比处或以下进行治疗;也就是说,如果你的微弱治疗使生命值增加了 20%,那么healAt将被设置为 80%而不是 70%,这样每次治疗都会将玩家的生命值恢复到 100%。

实现可调节的错误修正

但如果你的治疗能力增加了呢?如果一个角色可以升级、分配技能点或者增加最大生命值,那么他能恢复的生命值也可能相应变化。例如,如果你让一个 10 级的角色作为机器人的起始角色,并让它运行到 40 级,你的治疗代码就需要适应变化。一个 40 级的角色如果按照 10 级的治疗方式来治疗,将会造成极度过度治疗,或者在与同级敌人作战时快速死亡。

为了处理这种情况,机器人需要不断更新其治疗阈值,以反映观察到的治疗量。清单 11-4 展示了如何修改清单 11-1 中的强治疗条件函数来实现这一点。

   curDef->condition = [](GameSensors* sensors) -> bool {
       static float healAt = 50;
➊     if (sensors->detectedStrongHeal()) {
          auto newHealAt = 100 - sensors->getStrongHealIncrease();
➋         healAt = (healAt + newHealAt) / 2.00f;
➌         sensors->clearStrongHealInfo();
       }
       return sensors->getHealthPercent() > healAt;
   };

清单 11-4:调整强治疗条件代码

和修改后的弱治疗函数一样,治疗阈值已被移到一个名为healAt的静态变量中,但这次逻辑稍有不同。由于学习必须不断进行,因此没有变量来跟踪机器人是否已经学习到其真实的治疗能力。相反,代码只是检查自上次调用以来,传感器是否已经检测到过一次强治疗事件 ➊。如果是,代码将healAt替换为healAtnewHealAt的平均值,并调用一个函数清除与强治疗相关的传感器信息 ➌。

清除传感器实际上非常重要,因为这可以防止代码因相同的强治疗技能反馈而不断更新healAt。还需要注意的是,这个函数并不会将healAt更新为一个完美值,而是将其滑向观察到的最佳值。这种行为使得新函数非常适合在治疗量有一定随机性的情况下使用。如果你的机器人需要更快地滑向新值,你可能需要将➋处的代码改成如下形式:

healAt = (healAt + newHealAt * 2) / 3.00f;

这段更新healAt的代码使用了一个加权平均值,偏向于newHealAt值。然而,使用这种方法时有几点需要注意。首先,当你治疗过度时会发生什么?在一些游戏中,当你治疗到满血时,传感器可能只能检测到你实际治疗了多少。在其他游戏中,传感器可能能够检测到实际治疗的量。换句话说,如果你从 85%的血量施放了一个 30%的强治疗,你的传感器看到的是 30%还是 15%的治疗量?如果答案是 30%,那么你已经准备好了。如果答案是 15%,你的代码需要有办法进行调整。

调整的方法之一是,当你的传感器检测到治疗使你恢复到满血时,减少healAt,像这样:

   curDef->condition = [](GameSensors* sensors) -> bool {
       static float healAt = 50;
       if (sensors->detectedStrongHeal()) {
➊         if (sensors->getStrongHealMaxed()) {
               healAt--;
           } else {
               auto newHealAt = 100 - sensors->getStrongHealIncrease();
               healAt = (healAt + newHealAt) / 2.00f;
           }
           sensors->clearStrongHealInfo();
       }
       return sensors->getHealthPercent() > healAt;
   };

这段代码与清单 11-4 几乎相同,但它增加了一个if()语句,用于在检测到最大治疗时减少healAt ➊。否则,函数应该像清单 11-4 那样运行。

治愈是一个简单的案例,但这段代码展示了如何使用错误修正动态改进机器人行为的一个很好的例子。另一个更高级的用例是调整技能射击以考虑敌人的移动模式。每个玩家在躲避技能射击时都有一定的模式,因此,如果你的传感器能够测量敌人在躲避技能射击时的方向和距离,你的控制代码可以调整机器人最初发射技能射击的位置。在同样的场景中,学习也能帮助机器人考虑游戏服务器延迟、角色移动速度等差异。

在使用错误修正时,请注意,如果你的状态定义除了静态变量之外有一些内部账务管理,你的代码将更加简洁和便于移植。此外,为了避免状态定义中杂乱无章,我建议将错误修正逻辑封装在一些外部模块中,方便在需要时调用。

使用搜索算法进行路径规划

在编写自主机器人时,你常常会面临一个挑战,即计算一个角色从一个位置到另一个位置的路径。除了逆向工程的挑战——创建传感器来读取游戏地图上哪些坐标阻止前进之外,还有计算路径的算法挑战。计算路径叫做 路径规划,游戏黑客通常使用 搜索算法 来解决这个问题。

两种常见的搜索技术

给定一个瓦片网格、一个起始位置 a 和一个结束位置 b,搜索算法计算从 ab 的路径。该算法通过在 a 创建一个 节点,将与 a 邻近的节点添加到待探索的瓦片列表(称为 前沿)中,更新节点为前沿中最佳的瓦片,并重复此过程,直到节点到达 b。不同的搜索算法通过不同的方式选择最佳节点,使用 成本启发式 或两者结合。

Dijkstra 算法 例如,根据瓦片到 a 节点的距离计算瓦片的成本,并选择成本最低的瓦片。想象一个空的二维网格,a 位于中间。在遵循 Dijkstra 算法的搜索中,前沿将以围绕 a 的圆形模式扩展,直到 b 位于圆的边缘,如图 11-4 所示。

贪心最佳优先搜索 算法并不通过节点到起点的距离来优先排序,而是使用启发式方法来估算前沿中某个节点到 b 的距离。然后,算法会选择估算距离最短的节点。想象一下这个算法在之前的网格中运行;前沿几乎会直接从 ab,如图 11-5 所示。

image

图 11-4:Dijkstra 算法的前沿。较浅的瓦片表示更高的成本。

image

图 11-5:贪心的最佳优先搜索算法的边界。较浅的格子表示较高的代价。

障碍如何干扰搜索

一旦向网格中添加障碍物,这些算法的行为差异变得更加明显。例如,如果一堵墙把 ab 分隔开,Dijkstra 算法总是会找到最快的路径,但代价也很大。围绕 a 的圆形边界的半径将等于最终路径的长度;我们把这个半径叫做 r。如果没有网格边界限制该边界的扩展,你可以通过计算半径为 r 的圆的面积来粗略估算打开的节点数。如果绕过墙的路径是 50 个格子,算法将大约打开 7,854 个格子,计算公式如下:

π × 50² = 7,854

在相同的场景下,贪心的最佳优先搜索会计算出一个次优路径,但打开的格子数会少得多。它的边界扩展过程不容易可视化,而且现在这个问题不重要,因此我不会在这里详细讨论。最终,这两种算法都不完全适合路径寻找问题。最优路径虽然准确,但较慢;而快速路径却不是最优的。

为了快速计算最优路径,你需要将 Dijkstra 算法和贪心的最佳优先搜索结合起来。幸运的是,已经有人做过这件事, resulting 算法就是著名的 A-star 搜索(通常简称为 *A**)。

A* 算法通过将一个代价(g)和一个启发式函数(h)相加来选择节点。这两个值的和称为得分。简单来说,得分 = g + h。像 Dijkstra 算法一样,A* 可以计算从 ab 的最优路径;而像贪心的最佳优先搜索一样,A* 也能相对快速地完成这一任务。

A 搜索算法*

现在你已经了解了基础知识,让我们编写代码实现 A* 算法。这个实现将应用于二维网格。最初它不会允许对角线移动,但稍后我会讨论如何修改代码以支持对角线移动。

本节的所有示例代码都位于本书源文件中的 GameHackingExamples/Chapter11_SearchAlgorithms 目录下。包含的项目可以使用 Visual Studio 2010 编译,但它们也应该适用于任何其他 C++ 编译器。你可以在 www.nostarch.com/gamehacking/ 下载它们并进行编译,跟着一起操作。如果你执行 Chapter11_ SearchAlgorithms.exe,你将能够定义自己的 20×20 网格,并观看算法计算搜索路径。

创建 A* 节点

首先,定义一个空的 AStarNode 类,如下所示:

typedef std::shared_ptr<class AStarNode> AStarNodePtr;
class AStarNode
{
public:
};

这段代码定义了 AStarNode 类和一个名为 AStarNodePtrstd::shared_ptr 类型定义,以便更方便地创建指向该类的安全指针。接下来,在这个类的公共范围内,声明节点的 x 坐标、y 坐标、代价和节点得分等成员变量:

int x, y;
int g, score;

此外,你需要一个类型为AStarNodePtr的公共成员,用于引用父节点:

AStarNodePtr parent;

在声明所有成员变量后,声明一个公共构造函数,在实例创建时初始化这些成员,代码如下:

AStarNode(int x, int y, int cost, AStarNodePtr p, int score = 0)
    : x(x), y(y), g(cost), score(score), parent(p)
{}

现在,为了简化安全指针的创建,添加一个像这样的静态辅助函数:

static AStarNodePtr makePtr(
    int x, int y, int cost,
    AStarNodePtr p,
    int score = 0)
{
    return AStarNodePtr(new AStarNode(x, y, cost, p, score));
}

这个makePtr()函数创建一个新的AStarNode实例,并返回被AStarNodePtr包装的实例。

让我们总结一下。AStarNode类有成员变量xygscoreparent。当类被构造时,所有这些成员会根据传递给构造函数的值进行初始化,score是可选的(因为它仅在创建AStarNode实例的副本时使用),如果没有提供则默认为0

接下来,定义一个公共成员函数,用于在给定目标坐标时计算启发式值:

   int heuristic(const int destx, int desty) const
   {
       int xd = destx - x;
       int yd = desty - y;
➊     return abs(xd) + abs(yd);
   }

这个函数返回曼哈顿距离启发式➊,它是一种适用于没有对角线移动的网格的距离计算方法:

x| + |Δy|

要计算允许对角线移动的路径,你需要修改这个函数,使用欧几里得距离启发式,其形式如下:

image

该类还需要一个更新score的函数。你可以将这个函数添加到公共作用域,代码如下:

#define TILE_COST 1
void updateScore(int endx, int endy)
{
    auto h = this->heuristic(endx, endy) * TILE_COST;
    this->score = g + h;
}

现在,当给定目标坐标以计算h时,score应该变为g + h

总结一下,节点类还需要一个可以计算其所有子节点的函数。这个函数可以通过为每个与当前节点相邻的瓦片创建新节点来实现。每个新节点将当前节点作为它的父节点,因此该类还需要能够创建指向当前节点副本的AStarNodePtr。以下是具体的实现方式:

   AStarNodePtr getCopy()
   {
       return AStarNode::makePtr(x, y, g, parent, score);
   }
   std::vector<AStarNodePtr> getChildren(int width, int height)
   {
       std::vector<AStarNodePtr> ret;
       auto copy = getCopy();
       if (x > 0)
➊         ret.push_back(AStarNode::makePtr(x - 1, y, g + TILE_COST, copy));
       if (y > 0)
➋         ret.push_back(AStarNode::makePtr(x, y - 1, g + TILE_COST, copy));
       if (x < width - 1)
➌         ret.push_back(AStarNode::makePtr(x + 1, y, g + TILE_COST, copy));
       if (y < height - 1)
➍         ret.push_back(AStarNode::makePtr(x, y + 1, g + TILE_COST, copy));
       return ret;
   }

这个函数在(x – 1, y)➊、(x, y – 1)➋、(x + 1, y)➌和(x, y + 1)➍位置创建子节点。它们的parent是调用getChildren的节点,g值是父节点的g加上TILE_COST

为了支持对角线移动,函数需要在(x – 1, y – 1)、(x + 1, y – 1)、(x + 1, y + 1)和(x – 1, y + 1)位置添加子节点。此外,如果对角线移动的成本更高——即角色执行此操作所需的时间更多——你还需要做如下处理:

  1. TILE_COST改为10

  2. 定义一个常量DIAG_TILE_COST,其值为TILE_COST乘以时间增加的倍数。如果对角线步骤需要 1.5 倍的时间,DIAG_TILE_COST的值将是15

  3. 给对角线子节点的g值设为父节点的g加上DIAG_TILE_COST

为了完成AStarNode,声明用于比较两个节点优先级和相等性的运算符。你可以像这样将这些声明放在类外部的全局作用域:

➊ bool operator<(const AStarNodePtr &a, const AStarNodePtr &b)
   {
       return a.score > b.score;
   }
➋ bool operator==(const AStarNodePtr &a, const AStarNodePtr &b)
   {
       return a.x == b.x && a.y == b.y;
   }

这些运算符允许std::priority_queue通过score➊对节点进行排序,并且std::find通过位置➋来判断节点是否相等。

编写 A*搜索函数

现在你已经完成了AStarNode类的编写,可以开始编写实际的搜索函数。首先定义函数原型:

template<int WIDTH, int HEIGHT, int BLOCKING>
bool doAStarSearch(
    int map[WIDTH][HEIGHT],
    int startx, int starty,
    int endx, int endy,
    int path[WIDTH][HEIGHT])
{ }

原型接受游戏地图的宽度和高度,以及表示地图上阻塞块的值作为模板参数。doAStarSearch()函数还接受地图本身(map)、起始坐标(startxstarty)、目标坐标(endxendy)以及一个空白地图(path),在计算路径完成时填充计算出的路径。

注意

前三个参数是模板参数,因此你可以将它们作为编译时常量传递。我在示例代码中这样做,以允许显式声明mappath参数的数组大小,并允许通过一个明确的值来标识地图上的阻塞块。在实际应用中,你从游戏中读取的地图将具有动态大小,你可能需要更强大的方式来传递这些数据。

接下来,doAStarSearch()函数需要一个排序好的列表来保存 frontier,并需要一个容器来追踪所有创建的节点,以便在打开某个节点作为不同父节点的子节点时,可以更新已有节点的分数和父节点。你可以按如下方式创建这些容器:

std::vector<AStarNodePtr> allNodes;
std::priority_queue<AStarNodePtr> frontier;

frontier使用std::priority_queue定义,因为它可以根据分数自动排序节点。节点容器allNodes定义为std::vector

现在,让我们创建第一个节点:

auto node = AStarNode::makePtr(startx, starty, 0, nullptr);
node->updateScore(endx, endy);
allNodes.push_back(node);

第一个节点是一个无成本的孤立节点,位置为(startxstarty)。该节点根据updateScore()函数返回的值来赋予一个分数,然后被添加到allNodes容器中。

有了容器中的节点后,接下来编写 A*算法的核心部分,从一个简单的循环开始:

while (true) {
}

在没有其他说明之前,本节中的其余代码将按顺序出现在这个循环中。

从这里开始,第一步是检查目标状态。在这种情况下,目标是为玩家找到一条路径,通往下一个航点,这发生在node对象的位置为(endxendy)时。因此,要检查目标状态,程序需要检查node是否已经到达这些坐标。以下是该检查的代码:

if (node->x == endx && node->y == endy) {
    makeList<WIDTH, HEIGHT>(node, allNodes, path);
    return true;
}

当达到目标状态时,程序会向调用者报告true并填充path为最终路径。现在,假设有一个名为makeList()的函数可以为你填充path;稍后我会展示这个函数。如果目标状态没有达到,你需要扩展node的子节点,这实际上是一个相当复杂的过程:

   auto children = node->getChildren(WIDTH, HEIGHT);
   for (auto c = children.begin(); c != children.end(); c++) {
➊     if (map[(*c)->x][(*c)->y] == BLOCKING) continue;
       auto found = std::find(allNodes.rbegin(), allNodes.rend(), *c);
➋     if (found != allNodes.rend()) {
➌         if (*found > *c) {
               (*found)->g = (*c)->g;
               (*found)->parent = (*c)->parent;
               (*found)->updateScore(endx, endy);
           }
       } else {
           (*c)->updateScore(endx, endy);
➍         frontier.push(*c);
➎         allNodes.push_back(*c);
       }
   }

在调用 node->getChildren 生成可以添加到前沿的节点列表后,代码会遍历每个子节点,忽略掉位于阻塞地块上的子节点➊。接着,对于每个子节点,代码会检查是否已经有一个节点在相同的坐标上被打开➋。如果有,并且现有节点的 score 大于新子节点的 score,则通过 ➌ 处的 if() 语句将现有节点更新为新子节点的 parentcostscore。如果新子节点没有“异母兄弟”,它将直接被添加到前沿 ➍ 和节点列表 ➎ 中。

还要注意,std::find使用的是allNodes的反向开始迭代器和反向结束迭代器,而不是常规的迭代器➊。这个例子之所以这么做,是因为新的节点会被追加到向量的末尾,重复的节点通常会靠在一起,因此重复的节点通常会更接近向量的末端。(这一步也可以直接针对前沿进行处理,但std::priority_queue不允许对节点进行迭代,而且将排序写到内存中会使代码变得过于庞大,不适合打印。)

最终,函数将没有新的子节点可以添加到前沿;以下的 if() 语句处理这种情况:

   if (frontier.size() == 0) return false;
➊ node = frontier.top();
➋ frontier.pop();

这段代码将 node 指向前沿中最便宜的节点 ➊,然后将其从前沿中移除 ➋,并让循环继续。如果前沿为空,函数会向调用者返回 false,因为没有剩余的节点可以搜索。

创建路径列表

最后,到了实现 makeList() 函数的时候:

   template<int WIDTH, int HEIGHT>
   void makeList(
       AStarNodePtr end,
       std::vector<AStarNodePtr> nodes,
       int path[WIDTH][HEIGHT])
   {
       for (auto n = nodes.begin(); n != nodes.end(); n++)
➊         path[(*n)->x][(*n)->y] = 2;
       auto node = end;
       while (node.get() != nullptr) {
➋         path[node->x][node->y] = 1;
           node = node->parent;
       }
   }

这个函数通过更新 path 来包含已关闭节点的列表➊和计算出的路径➋。对于这个例子,值 2 代表已关闭的节点,1 代表路径节点。程序通过从目标节点开始,沿着父节点一路回溯,直到到达起始节点,来计算路径中的节点,起始节点是一个孤立节点,父节点是 nullptr

A 搜索特别有用的情况*

确保尝试上一节的示例代码和可执行文件,因为这是你真正了解 A* 搜索行为的唯一方式。在大多数新游戏中,你应该能够直接发送包含目标的包,甚至在地图上模拟点击目标位置,但当你遇到需要计算路径的情况时,你会很高兴自己学过 A*。

实际上,有很多情况下计算路径是非常有用的:

选择目标

当你的机器人在选择攻击目标时,你可能需要检查角色是否能真正到达这些目标。否则,如果敌人被困在一个无法到达的房间里,你可能会永远停在原地,尝试攻击他们!

选择尸体

当你的抢夺状态决定了要打开哪些尸体时,你可以通过总是优先抢夺最靠近的尸体来进行优化。

模拟鼠标移动

很少情况下,一些防护严密的游戏实际上会将游戏内的动作与鼠标移动进行关联,以确保没有机器人在运行。在这种情况下,你可能需要模拟鼠标。通过修改版的 A* 算法,其中屏幕就是地图,没有阻挡的方块,并且节点成本稍作随机化,你可以计算出类似人类的路径,让你的鼠标在模拟移动时跟随这些路径。

引导怪物

如果你需要编写代码来引导怪物,你可以实现 A* 算法,目标状态是让所有生物距离你 N 单位远。使用本章所示的相同成本机制,调整启发式函数,给接近生物的节点更高的成本。引导怪物并不是一个常见的使用案例,启发式函数需要大量调整,但一旦成功,它的效果非常惊人。有些实现甚至能比人类更好地引导任意数量的怪物!

预测敌人的移动

如果你正在编写一个与其他玩家对战的机器人,你可以使用 A* 来预测他们的移动并作出相应的反应。例如,如果你的敌人开始逃跑,你的机器人可以推测他们正在跑向他们的基地,计算他们的路线,并使用法术阻挡他们的路径,甚至传送到他们预计会出现的位置。

这些只是 A* 搜索的一些使用案例,随着你提升机器人的能力,你肯定会发现更多用例。在本章的剩余部分,我将描述一些流行的自动化作弊手段,这些方法可以通过本书中描述的技术来实现。

A 搜索的其他用途*

A* 不仅仅是用来计算路径的。通过在 AStarNode 类上进行抽象,你可以将同样的算法应用于任何搜索问题。实际上,A* 算法只是在多维数据集上进行加权迭代,直到找到某个目标对象,因此它可以解决任何可以表示为多维数据集的问题。A* 的更高级应用包括下棋和跳棋,甚至在与三维曼哈顿距离启发式函数和深度优先搜索结合时,可以解决魔方问题。遗憾的是,我不会详细介绍这些用例;如果你想深入学习搜索算法,我鼓励你在线上进行更多研究。

常见且酷炫的自动化作弊

现在你已经了解了创建高效、自学习机器人的设计模式和算法,是时候了解一些流行的自动化作弊方法,这些方法超出了简单的治疗和路径规划。让我们从高空 10,000 英尺的高度,深入探讨两种类型的机器人。

使用洞窟机器人进行掠夺

在讨论控制理论、状态机和搜索算法时,我提到了一个洞窟机器人,它可以杀死生物、拾取战利品并在洞窟中行走。洞窟机器人的能力差异非常大。

存储金币与补充物资

如果你希望让角色连续数天进行挂机,你需要一个存款器和一个补充器。存款器可以将战利品存入你的银行或保险库,而补充器则负责补充你的药水、符文和其他物资。这些功能可以用六个基本状态来描述:

离开刷怪区 如果角色处于刷怪区或洞穴,且没有任何物品需要存款,且有足够的物资,则满足此条件。通过离开刷怪区或洞穴来达到此状态。

前往城镇 如果角色处于刷怪区或洞穴,则满足此条件。通过从刷怪区或洞穴走到城镇来达到此状态。

存款 如果角色处于刷怪区或洞穴,或者角色在城镇中且没有任何物品需要存款,则满足此条件。通过将战利品存入银行或保险库来达到此状态。

取款 如果角色处于刷怪区或洞穴,或者角色在城镇中且没有物资需要购买,或者角色有足够的金币购买物资,则满足此条件。通过从银行或保险库取款来达到此状态。

购买物资 如果角色处于刷怪区或洞穴,或者角色有足够的物资开始狩猎,则满足此条件。通过购买物资来达到此状态。

进入刷怪区 如果角色处于刷怪区或洞穴,则满足此条件。通过走到刷怪区或洞穴来达到此状态。

这些状态应该位于与跟随路径点相关的状态之前(我在《复杂的假设状态机》一书的第 228 页中描述了其中的一些状态)。将它们放在前面可以优先于待在洞穴中的状态,同时仍然允许角色在返回城镇的路上击杀和掠夺怪物。根据你狩猎的位置以及你希望机器人如何表现,你还可以告诉你的目标状态在角色不在刷怪区或洞穴时不要攻击生物,或者你可以在“前往城镇”状态之前增加一个额外的状态,只攻击那些阻挡角色前进道路的怪物。指定这个额外的状态可以提高机器人的效率,因为如果途中遇到的怪物不值得击杀,前往和返回城镇的行程将会更快。

使用角色作为诱饵

另外两个能让你的机器人变得强大的洞穴机器人功能是诱饵模式动态诱饵。你不会将这两个功能实现为复杂机器人的实际状态;相反,你会让它们影响机器人的目标选择和行走状态,从而帮助机器人做出决策。

你可以通过路径中的特殊路径点来控制引怪模式,它的代码会告诉你的目标状态只在机器人卡住时才攻击生物,类似于我们之前讨论的从城镇到城镇的行走机制。不同之处在于,引怪模式可以在洞窟中的不同区域打开和关闭,使你可以在攻击怪物之前,将多个怪物群引到特定的位置。这可以使你的机器人更加高效,因为某些类型的角色可能擅长一次性击杀大量怪物。

动态引怪与此相似,但它不是通过路径点在特定位置开启或关闭,而是在怪物不足时自动开启引怪模式。例如,具有动态引怪功能的机器人可能会告诉目标状态,在屏幕上没有五个怪物时,不要攻击任何生物。目标状态将恢复攻击并引导,直到所有五个怪物被击败,然后机器人会切换回引怪模式,直到出现适当数量的怪物群。

然而,如果你的角色足够快,能够跑得过怪物,你需要修改机器人的行走状态,在引怪模式开启并且有生物存在时,走得慢一些。否则,角色会把怪物落在身后而不去击杀它们。你可以通过在状态机定义中添加一个状态,延迟在引怪模式开启且任何生物离得太远时的移动,从而减慢角色的速度。

允许玩家编写自定义行为脚本

几乎每个洞窟机器人都包括一个脚本接口,允许玩家添加自己的行为。你可以实现这个接口来指定自定义的路径点、使用的法术或拾取的物品。在更高级的机器人中,你可能会让目标、拾取、行走和引怪系统尽可能地动态化,这样玩家就可以添加独特的功能。如果你使用 Lua 实现自动化,第三方可以轻松改进和扩展你机器人的功能。

使你的机器人易于编写脚本可以减轻你的工作负担,因为其他玩游戏的程序员可能会发布脚本,以支持新的狩猎点并改进你的自动化功能。这类脚本服务在机器人社区中很常见,玩家经常创建并出售与机器人兼容的专业级脚本。

使用战斗机器人自动化战斗

另一类自动化机器人用于玩家对战玩家(PvP)战斗。这些战斗机器人,或PvP 机器人,拥有许多被归类为响应式或 ESP(外部感知)作弊的功能,因为这些机器人专注于响应敌人伤害或法术、揭示隐藏敌人,并为玩家提供信息上的优势。

完全自动化的战争机器人很少见,但我已经简要讨论了如何使用一些自动化技术来制造更智能的治疗机器人,教机器人如何命中更精准的技能射击,并预测玩家的路径以将其阻止。接下来,让我们探讨一些其他有趣的黑科技,它们处于响应式、ESP 和自动化的边缘。

注意

在完全基于 PvP 的游戏中,例如战场或实时策略游戏,某些玩家可能会称这些为机器人,因为战争或 PvP 是机器人的唯一目的。

自动墙壁机器人

如果你的角色有一个创建临时墙壁的技能,你可以编写一个机器人,在敌人进入小走廊时自动阻挡他们。通过错误修正,机器人可以学习在敌人之前多远的地方放置墙壁。通过一些非常有创意的工程设计,机器人甚至可以通过检查每个敌人是否在墙壁消失之前成功越过墙壁,来学习哪些敌人能跳过墙壁。

自动狙击机器人

对于拥有远程技能或全球执行技能的角色,你可以使用自动化来检测地图对面的敌人何时血量低,并施放技能将其击杀。你还可以使用错误修正,更准确地猜测该如何射击远程技能。如果你无法精确计算伤害数值,错误修正也能帮助机器人判断技能造成的伤害,并相应地调整施法阈值。

自动风筝机器人

如果你玩的是一个通过近战攻击造成大部分伤害的输出型角色,你可以实现一个机器人,自动风筝敌人。通过使用一组与洞窟机器人类似的状态,你可以制作一个在你攻击敌人时自动风筝敌人。当你停止锁定敌人时,机器人可以停止风筝。利用 A*搜索,你可以改进风筝机制,避免多个敌人,或者如果你想在攻击时逃跑,可以引导风筝机制回到安全地点,比如你队伍的基地或一个中立位置。

结语

到了这个时候,你应该已经准备好开始制作一些相当酷的机器人了。如果你对本章中的技术仍然不完全熟悉,不要担心;最好的学习方法就是直接动手开始编写。利用本书提供的成千上万行示例代码,你可以不用从零开始就能入门,最重要的是,享受其中的乐趣!

在下一章,我将讨论机器人如何隐藏自己,以避开反作弊机制,这些是游戏用来检测和阻止机器人的软件。

第十二章:12

保持隐蔽

image

游戏作弊是一个不断发展的行为,是黑客和游戏开发者之间的猫鼠游戏,双方都在努力颠覆对方。只要有人制造机器人,游戏公司就会找到阻碍机器人进展并封禁使用机器人玩家的方法。不过,游戏公司并不将游戏本身变得更难破解,而是专注于检测

最大的游戏公司拥有非常复杂的检测套件,称为反作弊软件。在本章的开始部分,我将讨论最常见的反作弊套件的功能。在揭示这些套件如何检测机器人后,我将教你一些强大的规避方法。

著名的反作弊软件

最著名的反作弊套件使用与大多数杀毒软件相同的方法来扫描机器人并将其标记为威胁。一些反作弊套件也是动态的,这意味着它们的内部工作原理和功能可以根据它们保护的游戏而有所变化。反作弊软件开发人员还会追踪并修补他们的套件,以应对绕过软件,因此在面对任何反作弊软件时,务必进行深入的自我研究。

当这些套件检测到机器人时,它们会将机器人的账户标记为待封禁。每隔几周,游戏公司管理员会在封禁波中封禁被标记的玩家。游戏公司使用封禁波而不是即时封禁,因为波次封禁更有利可图。如果机器人在几周的游戏后被封禁,由于他们对游戏的熟悉,可能更容易购买新账户,而不是在机器人开始运行的瞬间就被封禁。

有数十种反作弊套件,但我将重点讨论五个最常见且被彻底了解的套件:PunkBusterESEA 反作弊Valve 反作弊(VAC)GameGuardWarden

PunkBuster 工具包

PunkBuster 由 Even Balance 公司制作,是最早的反作弊工具包。许多游戏使用 PunkBuster,但它在第一人称射击游戏中最为常见,如荣誉勋章孤岛惊魂 3战地系列的多个版本。

这个工具包使用了多种检测方法,其中最强大的包括基于签名的检测(SBD)、截图和哈希验证。PunkBuster 还因实施硬件封禁而闻名,它通过保存硬件序列号的指纹来永久封禁作弊者的计算机,而不仅仅是他们的游戏账户,并阻止来自匹配该指纹的机器的登录。

基于签名的检测

PunkBuster 扫描运行该软件的系统上所有进程的内存,搜索已知作弊软件的独特字节模式,称为签名。如果 PunkBuster 检测到签名,玩家将被标记为待封禁。PunkBuster 使用NtQueryVirtualMemory() Windows API 函数从用户模式执行内存扫描,并有时从多个隐藏进程中运行扫描。

基于签名的检测方法从设计上是盲目的,最终会面临一个致命缺陷:误报。2008 年 3 月 23 日,一组黑客团队试图通过向公共聊天室发送一个 PunkBuster 会识别为机器人签名的文本字符串来证明这个缺陷的存在。由于 SBD 会盲目扫描进程内存中的匹配模式,所有在这些公共聊天室中的合法玩家都被标记为机器人玩家。

这导致成千上万的公平玩家被禁赛,且没有任何正当理由。2013 年 11 月,类似的情况再次发生:PunkBuster 错误地禁止了成千上万的玩家在 Battlefield 4 中的账号。那次,没有人试图证明某种观点;公司只是在软件中添加了一个错误的签名。

PunkBuster 通过恢复玩家账户解决了这两个问题,但这些事件显示出它的 SBD 在执行时有多么激进。然而,经过这些攻击之后,PunkBuster 的 SBD 通过仅在预定义的二进制偏移量上检查签名,减少了误报的数量。

截图

作为另一种机器人检测方法,PunkBuster 还会定期截图玩家的屏幕,并将截图发送到中央游戏服务器。这种检测方式很麻烦,而且与 SDB 相比效果较差。游戏作弊社区推测,PunkBuster 实施这个功能是为了给游戏管理员提供证据,以应对那些对禁令提出异议的机器人玩家。

哈希验证

除了采用 SBD 和截图,PunkBuster 还通过在玩家系统上创建游戏可执行文件的加密哈希,并将其与存储在中央服务器上的哈希进行比较来检测机器人。如果哈希不匹配,玩家将被标记为禁赛。这个检查只会在文件系统中的二进制文件上进行,而不会在内存中的二进制文件上进行。

ESEA 反作弊工具包

ESEA 反作弊工具包被 E-Sports Entertainment Association (ESEA) 使用,主要用于其 Counter-Strike: Global Offensive 联赛。与 PunkBuster 不同,这个工具包以产生非常少的误报并且在抓取作弊者方面非常有效而闻名。

ESEA 反作弊的检测能力与 PunkBuster 相似,但有一个显著的不同。ESEA 反作弊的 SBD 算法是通过一个内核模式驱动程序执行的,使用了三个不同的 Windows 内核函数:MmGetPhysicalMemoryRanges() 函数、ZwOpenSection() 函数和 ZwMapViewOfSection() 函数。这个实现使得反作弊系统几乎免疫于内存伪造(这是一种常见的绕过 SBD 的方式),因为这些扫描函数在从驱动程序调用时更难被挂钩。

VAC 工具包

VAC 是 Valve 公司应用于自家游戏和许多第三方游戏(通过其 Steam 游戏平台提供)的工具包。VAC 使用与 PunkBuster 检测技术相似的 SDB 和哈希验证方法,还使用了域名系统(DNS)缓存扫描和二进制验证。

DNS 缓存扫描

DNS 是一种平滑转换域名和 IP 地址的协议,DNS 缓存是存储这些信息的地方。当 VAC 的 SBD 算法检测到作弊软件时,VAC 会扫描玩家的 DNS 缓存,查找与作弊网站相关的任何域名。目前尚不确定是否需要进行 DNS 缓存扫描,才能让 VAC 的 SBD 算法标记玩家为封禁对象,或者 DNS 缓存扫描是否仅仅是对已经被 SBD 标记的玩家的进一步确认。

注意

要查看您的 DNS 缓存,请在命令提示符下输入 ipconfig /displaydns。是的,VAC 会查看所有这些信息。

二进制验证

VAC 还使用二进制验证来防止可执行二进制文件在内存中被篡改。它通过将内存中的二进制代码的哈希与文件系统中相同代码的哈希进行比较,扫描如 IAT、跳转和代码钩取等修改。如果发现不匹配,VAC 会标记该玩家为作弊并进行封禁。

这种检测方法非常强大,但 Valve 最初实施该算法时存在缺陷。2010 年 7 月,VAC 的二进制验证错误地封禁了 12,000 名 使命召唤 玩家。二进制验证模块未考虑到 Steam 更新,导致玩家的内存中的代码与文件系统中的更新二进制文件不匹配时被封禁。

虚假正例

VAC 也曾出现过虚假正例。其最初版本经常因为“内存故障”错误地封禁公正玩家。这个早期版本还因玩家使用 Cedega(一个在 Linux 上运行 Windows 游戏的平台)而封禁玩家。2004 年 4 月 1 日,Valve 由于服务器端故障错误地封禁了几千名玩家。在 2011 年 6 月和 2014 年 2 月的两次事件中,VAC 也因公司拒绝披露的漏洞错误地封禁了数千名 Team Fortress 2Counter-Strike 玩家。与 PunkBuster 类似,这些事件表明 VAC 非常具有攻击性。

GameGuard 工具包

GameGuard 是由 INCA Internet Co. Ltd. 开发的反作弊工具包,被许多 MMORPG 游戏使用,包括 天堂 IICabal Online仙境传说 Online。除了使用一些较为激进的 SBD,GameGuard 还利用根工具包积极防止作弊软件运行。

用户模式根工具包

GameGuard 使用用户模式根工具包来拒绝机器人访问它们操作所需的 Windows API 函数。根工具包在函数的最低级入口点进行钩取,通常位于 ntdll.dlluser32.dllkernel32.dll 中的未文档函数内。这些是 GameGuard 最常钩取的 API 函数,下面是 GameGuard 在每个被钩取函数中的行为:

NtOpenProcess() 阻止任何对被保护游戏的 OpenProcess() 尝试。

NtProtectVirtualMemory() 阻止任何 VirtualProtect()VirtualProtectEx() 对游戏的尝试。

NtReadVirtualMemory() NtWriteVirtualMemory() 阻止任何对游戏的 ReadProcessMemory()WriteProcessMemory() 尝试。

NtSuspendProcess() NtSuspendThread() 阻止任何试图暂停 GameGuard 的行为。

NtTerminateProcess() NtTerminateThread() 阻止任何试图终止 GameGuard 的行为。

PostMessage()SendMessage() SendInput() 阻止任何试图向游戏发送程序化输入的行为。

SetWindowsHookEx() 阻止机器人全局拦截鼠标和键盘输入。

CreateProcessInternal() 自动检测并钩住新进程。

GetProcAddress()LoadLibraryEx() MapViewOfFileEx() 阻止任何向游戏或 GameGuard 注入库的尝试。

内核模式 Rootkit

GameGuard 还使用基于驱动的 rootkit 来防止在内核中工作的机器人。这个 rootkit 具有与其用户模式对等体相同的能力,它通过钩住 ZwProtectVirtualMemory()ZwReadVirtualMemory()ZwWriteVirtualMemory()SendInput() 等函数来工作。

Warden 工具包

Warden,是暴雪专门为其游戏开发的工具包,是我遇到的最先进的反机器人工具包。很难说 Warden 究竟做了什么,因为它在运行时下载动态代码。这些代码以编译后的 shellcode 形式交付,通常有两个主要职责:

• 检测机器人。

• 定期向游戏服务器发送心跳信号。发送的值不是预定义的,而是由某些检测代码的子集生成。

如果 Warden 无法完成第二个任务或发送错误的值,游戏服务器就会知道它已被禁用或篡改。此外,机器人无法禁用检测代码并保持心跳代码运行。

停机问题

一个能够禁用 Warden 检测代码并仍能发送心跳信号的机器人,将解决停机问题,该问题由艾伦·图灵于 1936 年证明是不可能解决的。停机问题是指通过一个通用算法判断一个程序是否会完成执行或永远运行下去。由于 Warden 使用相同的 shellcode 执行两个任务,编写一个通用算法只禁用其中一个任务是停机问题的一个变种:该算法无法确定哪些代码部分一定会执行,哪些不会,哪些部分负责执行每个任务。

Warden 很强大,因为你不仅无法知道自己在隐藏什么,还无法禁用这个工具包。即使你今天设法避免了检测,明天也可能会使用新的检测方法。

如果你计划公开分发机器人,你最终会遇到前面描述的某种反作弊解决方案——你必须战胜它。根据你机器人的足迹、游戏中的检测类型以及你的实现方式,躲避这些工具包的难度可能从微不足道到极其困难不等。

谨慎管理机器人足迹

机器人的足迹是指其拥有的独特、可检测的特征。例如,一个挂钩了 100 个函数的机器人通常比一个只挂钩 10 个函数的机器人更容易被检测到,因为前者对游戏代码的改动比后者多了一个数量级。由于目标检测系统只需要检测一个挂钩,前者机器人的开发者需要花费更多的时间确保机器人的所有挂钩都尽可能隐蔽。

另一个足迹特征是机器人的用户界面有多详细。如果一个已知的机器人有很多对话框,并且每个对话框都有特定的标题,游戏公司可以让其反作弊软件通过搜索具有这些标题的窗口来检测该机器人。同样的基本推理也适用于进程名称和文件名。

最小化机器人的足迹

根据你的机器人如何工作,有很多方法可以最小化它的足迹。例如,如果你的机器人大量依赖挂钩,你可以避免直接挂钩游戏的代码,而是专注于挂钩 Windows API 函数。Windows API 挂钩出乎意料地常见,因此开发者不能假设挂钩 Windows API 的程序就是机器人。

如果你的机器人有一个明确的用户界面,你可以通过去除所有窗口条、按钮等的字符串来掩盖界面。你可以改为显示带有文本的图像。如果你担心特定的进程名称或文件名会被反作弊软件检测到,可以使用通用的文件名,并让你的机器人每次启动时将自己复制到一个新的、随机的目录中。

掩盖你的足迹

最小化你的足迹是避免被检测的首选方法,但这不是必须的。你也可以对你的机器人进行混淆,使得任何人都更难理解它是如何工作的。混淆可以防止反机器人开发者试图检测你的机器人,也可以防止其他机器人开发者分析你的机器人以窃取专有功能。如果你出售你的机器人,混淆还可以防止别人破解它来绕过你的购买验证。

一种常见的混淆方式叫做打包。打包一个可执行文件会将其加密,并隐藏在另一个可执行文件中。当容器可执行文件启动时,打包的可执行文件会在内存中被解密并执行。打包后的机器人,分析其二进制文件以了解机器人做了什么几乎是不可能的,而且调试机器人进程也变得更加困难。一些常见的打包程序有UPXArmadilloThemidaASPack

教会机器人检测调试器

当反机器人开发者(或其他机器人的创建者)能够调试一个机器人时,他们可以搞清楚它是如何工作的,从而知道如何阻止它。如果有人正在积极试图拆解一个机器人,那么仅仅打包可执行文件可能不足以避开他们。为了防范这种情况,机器人通常采用反调试技术,当检测到调试器时,通过改变机器人的行为来混淆控制流。在这一节中,我将简要介绍一些检测调试器附加到机器人上的常用方法,接下来我将展示一些混淆的技巧。

调用 CheckRemoteDebuggerPresent()

CheckRemoteDebuggerPresent() 是一个 Windows API 函数,可以告诉你当前进程是否附加了调试器。检测调试器的代码可能如下所示:

bool IsRemoteDebuggerPresent() {
    BOOL dbg = false;
    CheckRemoteDebuggerPresent(GetCurrentProcess(), &dbg);
    return dbg;
}

这个检查非常简单——它调用 CheckRemoteDebuggerPresent(),传入当前进程和指向 dbg 布尔值的指针。调用这个函数是检测调试器的最简单方法,但调试器也很容易规避。

检查中断处理程序

中断 是处理器发送的信号,用于触发 Windows 内核中的相应处理程序。中断通常由硬件事件生成,但也可以通过使用 INT 汇编指令在软件中生成。内核允许一些中断——即中断 0x2D 和 0x03——触发用户模式的中断处理程序,形式为异常处理程序。你可以利用这些中断来检测调试器。

当调试器在某条指令上设置断点时,它会用断点指令(例如 INT 0x03)替换该指令。当中断被执行时,调试器通过异常处理程序得到通知,在那里它处理断点、替换原始代码,并允许应用程序无缝地恢复执行。当遇到无法识别的中断时,一些调试器甚至会悄无声息地跳过该中断,并允许执行正常继续,而不触发任何其他异常处理程序。

你可以通过故意在代码中的异常处理程序内生成中断来检测这种行为,正如示例 12-1 所示。

inline bool Has2DBreakpointHandler() {
    __try { __asm INT 0x2D }
    __except (EXCEPTION_EXECUTE_HANDLER){ return false; }
    return true;
}

inline bool Has03BreakpointHandler() {
    __try { __asm INT 0x03 }
    __except (EXCEPTION_EXECUTE_HANDLER){ return false; }
    return true;
}

示例 12-1:检测中断处理程序

在正常执行期间,这些中断会触发代码中围绕它们的异常处理程序。在调试会话中,一些调试器可能会拦截这些中断生成的异常并悄无声息地忽略它们,从而阻止周围的异常处理程序执行。因此,如果中断没有触发你的异常处理程序,那么说明存在调试器。

检查硬件断点

调试器还可以使用处理器的调试寄存器设置断点;这些被称为硬件断点。调试器可以通过将指令的地址写入其中一个调试寄存器来在某条指令上设置硬件断点。

当一个存在于调试寄存器上的地址被执行时,调试器会收到通知。为了检测硬件断点(从而检测到调试器的存在),你可以像下面这样检查任意四个调试寄存器上的非零值:

bool HasHardwareBreakpoints() {
    CONTEXT ctx = {0};
    ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
    auto hThread = GetCurrentThread();
    if(GetThreadContext(hThread, &ctx) == 0)
        return false;
    return (ctx.Dr0 != 0 || ctx.Dr1 != 0 || ctx.Dr2 != 0 || ctx.Dr3 != 0);
}
打印调试字符串

OutputDebugString()是一个 Windows API 函数,可以用于将日志消息打印到调试器控制台。如果没有调试器,函数会返回一个错误代码。然而,如果有调试器,函数则会正常返回而没有错误代码。你可以用这个函数作为一个简单的调试器检查方法:

inline bool CanCallOutputDebugString() {
    SetLastError(0);
    OutputDebugStringA("test");
    return (GetLastError() == 0);
}

CheckRemoteDebuggerPresent()方法类似,这个方法非常直接,但也很容易被调试器规避。

检查 DBG_RIPEXCEPTION 处理程序

调试器通常有异常处理程序,会盲目捕捉 Windows 的DBG_RIPEXCEPTION异常代码,这使得此代码成为一个明显的检测调试器的方式。你可以像 Listing 12-1 检测中断处理程序一样,检测这些异常处理程序:

#define DBG_RIPEXCEPTION 0x40010007
inline bool hasRIPExceptionHandler() {
    __try { RaiseException(DBG_RIPEXCEPTION, 0, 0, 0); }
    __except(EXCEPTION_EXECUTE_HANDLER){ return false; }
    return true;
}
控制关键代码段的时间

如果一个反机器人开发者正在调试你的机器人,开发者可能会在关键代码上设置断点,并单步执行。这种活动可以通过测量代码执行时间来检测;当某人单步执行代码时,执行时间会比平时长得多。

例如,如果一个函数仅仅是放置一些钩子,你可以确定该代码在进行内存保护时不会花费超过十分之一秒。你可以通过以下方式借助GetTickCount() Windows API 函数来检查内存保护的执行时间:

--snip--
auto startTime = GetTickCount();
protectMemory<>(...);
if (GetTickCount() - startTime >= 100)
    debuggerDetectedGoConfuseIt();
--snip--
检查调试驱动程序

一些调试器会加载内核模式驱动程序来辅助其操作。你可以尝试通过获取它们的内核模式驱动程序句柄来检测这些调试器,像这样:

bool DebuggerDriversPresent() {
    // an array of common debugger driver device names
    const char drivers[9][20] = {
        "\\\\.\\EXTREM", "\\\\.\\ICEEXT",
        "\\\\.\\NDBGMSG.VXD", "\\\\.\\RING0",
        "\\\\.\\SIWVID", "\\\\.\\SYSER",
        "\\\\.\\TRW", "\\\\.\\SYSERBOOT",
        "\0"
    };
    for (int i = 0; drivers[i][0] != '\0'; i++) {
        auto h = CreateFileA(drivers[i], 0, 0, 0, OPEN_EXISTING, 0, 0);
        if (h != INVALID_HANDLE_VALUE) {
            CloseHandle(h);
            return true;
        }
    }
    return false;
}

有一些常见的内核模式驱动程序设备名称可以检查,比如\\\\.\\EXTREMdrivers数组中列出的其他名称。如果此句柄获取代码成功,那么系统上就运行着调试器。不过,与前面的方法不同,获取其中一个驱动程序的句柄并不总是意味着调试器已经附加到你的机器人上。

反调试技巧

一旦你检测到调试器,便有多种方式来混淆你的控制流。例如,你可能尝试让调试器崩溃。以下代码会使 OllyDbg v1.10 崩溃:

OutputDebugString("%s%s%s%s");

字符串"%s%s%s%s"包含格式说明符,而 OllyDbg 将其传递给printf()而不附加任何额外的参数,这就是为什么调试器会崩溃的原因。你可以将这段代码放入一个函数中,当检测到调试器时调用,但此方法只对 OllyDbg 有效。

引发无法避免的无限循环

另一种尝试的混淆方法是使系统过载,直到调试你的机器人时,调试者不得不关闭机器人和调试器。这个函数可以达到这个效果:

void SelfDestruct() {
    std::vector<char*> explosion;
    while (true)
        explosion.push_back(new char[10000]);
}

无限的while循环不断向explosion中添加元素,直到进程耗尽内存或有人断开电源。

栈溢出

如果你想真正让分析人员困惑,你可以创建一个函数链,最终导致栈溢出,但以间接的方式:

#include <random>
typedef void (* _recurse)();
void recurse1(); void recurse2();
void recurse3(); void recurse4();
void recurse5();
_recurse recfuncs[5] = {
    &recurse1, &recurse2, &recurse3,
    &recurse4, &recurse5
};
void recurse1() { recfuncs[rand() % 5](); }
void recurse2() { recfuncs[(rand() % 3) + 2](); }
void recurse3() {
    if (rand() % 100 < 50) recurse1();
    else recfuncs[(rand() % 3) + 1]();
}
void recurse4() { recfuncs[rand() % 2](); }
void recurse5() {
    for (int i = 0; i < 100; i++)
        if (rand() % 50 == 1)
            recfuncs[i % 5]();
    recurse5();
}
// call any of the above functions to trigger a stack overflow

简而言之,这些函数会随机且无限递归,直到调用栈没有空间为止。间接导致溢出使得分析人员很难在他们意识到发生了什么之前暂停并检查之前的调用。

导致蓝屏死机(BSOD)

如果你认真对待混淆,你甚至可以在检测到调试器时触发蓝屏死机(BSOD)。一种方法是使用SetProcessIsCritical() Windows API 函数将你的机器人的进程设置为关键进程,然后调用exit(),因为当关键进程被终止时,Windows 会触发蓝屏死机。你可以这样做:

void BSODBaby() {
    typedef long (WINAPI *RtlSetProcessIsCritical)
        (BOOLEAN New, BOOLEAN *Old, BOOLEAN NeedScb);
    auto ntdll = LoadLibraryA("ntdll.dll");
    if (ntdll) {
        auto SetProcessIsCritical = (RtlSetProcessIsCritical)
            GetProcAddress(ntdll, "RtlSetProcessIsCritical");
        if (SetProcessIsCritical)
            SetProcessIsCritical(1, 0, 0);
    }
}

BSODBaby();
exit(1);

或者,也许你是个坏人,在这种情况下你可以这样做:

BSODBaby();
OutputDebugString("%s%s%s%s");
recurse1();
exit(1);

假设你已经实现了本节中描述的所有技巧,这段代码将导致蓝屏死机(BSOD),崩溃调试器(如果是 OllyDbg v1.10),溢出栈并退出正在运行的程序。如果任何一种方法失败或被修补,分析人员仍然需要处理剩下的方法,才能继续调试。

击败基于签名的检测

即使有令人惊叹的混淆技术,你也不容易打败签名检测。分析机器人并编写签名的工程师非常熟练,而混淆技术充其量只是让他们的工作变得稍微困难一些。

要完全躲避基于签名的检测,你需要颠覆检测代码。这要求你准确了解 SBD 的工作原理。例如,PunkBuster 使用NtQueryVirtualMemory()扫描所有正在运行的进程内存中的任何签名。如果你想绕过它,你可以通过在NtQueryVirtualMemory()函数上设置钩子,将代码注入所有 PunkBuster 进程。

当函数尝试从你的机器人进程查询内存时,你可以给它任何你想要的数据,像这样:

   NTSTATUS onNtQueryVirtualMemory(
       HANDLE process, PVOID baseAddress,
       MEMORY_INFORMATION_CLASS memoryInformationClass,
       PVOID buffer, ULONG numberOfBytes, PULONG numberOfBytesRead) {

       // if the scan is on this process, make sure it can't see the hook DLL
       if ((process == INVALID_HANDLE_VALUE ||
           process == GetCurrentProcess()) &&
           baseAddress >= MY_HOOK_DLL_BASE &&
           baseAddress <= MY_HOOK_DLL_BASE_PLUS_SIZE)
➊             return STATUS_ACCESS_DENIED;

       // if the scan is on the bot, zero the returned memory
       auto ret = origNtQueryVirtualMemory(
           process, baseAddress,
           memoryInformationClass,
           buffer, numberOfBytes, numberOfBytesRead);
       if(GetProcessId(process) == MY_BOT_PROCESS)
➋         ZeroMemory(buffer, numberOfBytesRead);
       return ret;
   }

这个onNtQueryVirtualMemory()钩子在NtQueryVirtualMemory()尝试查询钩子 DLL 的内存时返回STATUS_ACCESS_DENIED ➊,但是当NtQueryVirtualMemory()尝试查询机器人内存时,它返回零填充的内存 ➋。这种差异并没有特别的原因;我只是展示了两种可以躲避NtQueryVirtualMemory()函数调用的方式。如果你真的很疑心,你甚至可以用随机字节序列替换整个缓冲区。

当然,这种方法只对来自用户模式的基于签名的检测(SBD)有效,例如 PunkBuster 或 VAC 中的 SBD。来自驱动程序的 SBD,如 ESEA,或不可预测的 SBD,如 Warden 的,不容易绕过。

在这些情况下,你可以采取预防措施来消除你机器人中的独特标识符。然而,如果你将机器人分发给十几个人以上,去除所有的区分特征是非常棘手的。为了迷惑分析师,每次你给某人一个副本时,你可以尝试以下几种组合:

• 使用不同的编译器编译机器人

• 更改编译器优化设置

• 在使用__fastcall__cdecl之间切换

• 使用不同的打包工具打包二进制文件

• 在静态链接和动态链接运行时库之间切换

改变这些元素会为每个用户创建不同的汇编代码,但通过这种方式,你可以生产的独特版本数量是有限的。超过某个点后,这种方法就无法满足需求了,最终,游戏公司将会为你的机器人每一个版本都创建特征。

除了混淆和代码变异外,几乎没有其他方法能击败高级的 SBD 机制。你可以将机器人实现为驱动程序,或者创建一个内核模式的 rootkit 来隐藏你的机器人,但即使是这些方法也并非万无一失。

注意

本书没有涉及如何在驱动程序中实现机器人或创建 rootkit 来隐藏机器人,因为这两个主题都相当复杂。单单是 rootkit 开发就是一个已经被许多书籍详细讨论的主题。我推荐 Bill Blunden 的《Rootkit Arsenal: Escape and Evasion in The Dark Corners of The System》(Jones & Bartlett Learning,2009 年)。

一些游戏黑客试图覆盖每一个细节,挂钩每个内存读取函数和整个文件系统 API,但仍然会被像 Warden 这样的系统抓到。事实上,我建议你避免与 Warden 和暴雪有任何接触。

击败截图

如果你遇到一种检测机制,利用截图作为额外证据来抓捕机器人使用者,那么你很幸运。绕过截图机制非常简单:不要让你的机器人被看到。

你可以通过保持最小的用户界面并且不对游戏客户端做出明显可区分的改动来规避这种类型的检测。如果你的机器人需要一个 HUD 或者其他独特的 UI 显示,别担心——你完全可以两者兼得。只要你能够拦截截图代码,你就可以在截图时隐藏你的“指纹”。

在某些版本的 PunkBuster 中,例如,Windows API 函数GetSystemTimeAsFileTime()会在截图拍摄前被调用。你可以在这个函数上设置钩子,以便快速隐藏你的 UI 几秒钟,确保它不会被看到:

void onGetSystemTimeAsFileTime(LPFILETIME systemTimeAsFileTime) {
    myBot->hideUI(2000); // hide UI for 2 seconds
    origGetSystemTimeAsFileTime(systemTimeAsFileTime);
}

只需使用在“重定向游戏执行的挂钩”中描述的技术钩住GetSystemTimeAsFileTime()函数,第 153 页上有详细说明,编写一个hideUI()函数,并在执行继续前调用该hideUI()函数。

击败二进制验证

击败二进制验证的方法很简单——不要在游戏特定的二进制文件中放置挂钩。Windows API 函数中的跳转挂钩和 IAT 挂钩非常常见,所以只要可能,尽量使用这些方法,而不是在游戏的二进制文件中使用跳转或近调用挂钩。在必须直接挂钩游戏代码的情况下,你可以通过拦截二进制扫描并伪造数据,使其匹配反作弊软件预期的数据,从而欺骗反作弊软件的二进制验证过程。

像 SBD 一样,二进制验证通常使用NtQueryVirtualMemory()来扫描内存。为了欺骗验证代码,从挂钩这个函数开始。然后,写一个像这样的函数,当NtQueryVirtualMemory()被调用时伪造数据:

NTSTATUS onNtQueryVirtualMemory(
    HANDLE process, PVOID baseAddress,
    MEMORY_INFORMATION_CLASS memoryInformationClass,
    PVOID buffer, ULONG numberOfBytes, PULONG numberOfBytesRead) {

    auto ret = origNtQueryVirtualMemory(
        process, baseAddress,
        memoryInformationClass,
        buffer, numberOfBytes, numberOfBytesRead);
    // place tricky code somewhere in here
    return ret;
}

在这个挂钩内,你需要监控任何对已被你的挂钩修改的内存进行的扫描。

注意

这个示例假设机器人只有一个挂钩,并且以HOOK_为前缀的变量已经存在,并描述了挂钩替换的代码。

列表 12-2 展示了一些扫描监控代码。

   // is the scan on the current process?
   bool currentProcess =
       process == INVALID_HANDLE_VALUE ||
       process == GetCurrentProcess();

   // is the hook in the memory range being scanned?
   auto endAddress = baseAddress + numberOfBytesRead - 1;
   bool containsHook =
       (HOOK_START_ADDRESS >= baseAddress &&
        HOOK_START_ADDRESS <= endAddress) ||
       (HOOK_END_ADDRESS >= baseAddress &&
        HOOK_END_ADDRESS <= endAddress);
➊ if (currentProcess && containsHook) {
       // hide the hook
   }

列表 12-2:检查是否挂钩的内存正在被扫描

当对已挂钩代码进行内存扫描时(这会使得currentProcesscontainsHook同时变为true),if()语句内的代码➊会更新输出缓冲区,以反映原始代码。这意味着你必须知道挂钩代码在扫描块中的位置,考虑到该块可能只跨越挂钩代码的一个子集。

所以如果baseAddress标记了扫描开始的地址,HOOK_START_ADDRESS标记了修改后的代码开始的位置,endAddress标记了扫描结束的地址,HOOK_END_ADDRESS标记了修改后的代码结束的位置,你可以使用一些简单的数学计算来确定修改后的代码在缓冲区的哪些部分。你可以按照以下步骤操作,使用writeStart来存储修改代码在扫描缓冲区中的偏移量,使用readStart来存储扫描缓冲区相对于修改代码的偏移量,以防扫描缓冲区开始的位置在修改代码的中间:

int readStart, writeStart;
if (HOOK_START_ADDRESS >= baseAddress) {
    readStart = 0;
    writeStart = HOOK_START_ADDRESS - baseAddress;
} else {
    readStart = baseAddress - HOOK_START_ADDRESS;
    writeStart = baseAddress;
}

int readEnd;
if (HOOK_END_ADDRESS <= endAddress)
    readEnd = HOOK_LENGTH - readStart - 1;
else
    readEnd = endAddress – HOOK_START_ADDRESS;

一旦你知道需要替换多少字节、放置它们的位置以及从哪里获取它们,你可以通过三行代码来完成欺骗:

char* replaceBuffer = (char*)buffer;
for ( ; readStart <= readEnd; readStart++, writeStart++)
    replaceBuffer[writeStart] = HOOK_ORIG_DATA[readStart];

完全组装后的代码如下所示:

NTSTATUS onNtQueryVirtualMemory(
    HANDLE process, PVOID baseAddress,
    MEMORY_INFORMATION_CLASS memoryInformationClass,
    PVOID buffer, ULONG numberOfBytes, PULONG numberOfBytesRead) {
    auto ret = origNtQueryVirtualMemory(
        process, baseAddress,
        memoryInformationClass,
        buffer, numberOfBytes, numberOfBytesRead);
    bool currentProcess =
        process == INVALID_HANDLE_VALUE ||
        process == GetCurrentProcess();
    auto endAddress = baseAddress + numberOfBytesRead - 1;
    bool containsHook =
        (HOOK_START_ADDRESS >= baseAddress &&
         HOOK_START_ADDRESS <= endAddress) ||
        (HOOK_END_ADDRESS >= baseAddress &&
         HOOK_END_ADDRESS <= endAddress);
    if (currentProcess && containsHook) {
        int readStart, writeStart;
        if (HOOK_START_ADDRESS >= baseAddress) {
            readStart = 0;
            writeStart = HOOK_START_ADDRESS - baseAddress;
        } else {
            readStart = baseAddress - HOOK_START_ADDRESS;
            writeStart = baseAddress;
        }

        int readEnd;
        if (HOOK_END_ADDRESS <= endAddress)
            readEnd = HOOK_LENGTH - readStart - 1;
        else
            readEnd = endAddress – HOOK_START_ADDRESS;

        char* replaceBuffer = (char*)buffer;
        for ( ; readStart <= readEnd; readStart++, writeStart++)
            replaceBuffer[writeStart] = HOOK_ORIG_DATA[readStart];
    }
    return ret;
}

当然,如果你有多个挂钩需要隐藏免受二进制验证扫描的影响,你需要以更健壮的方式实现此功能,以便能够相应地跟踪多个修改过的代码区域。

击败反作弊 Rootkit

GameGuard 和一些其他反作弊套件带有用户模式的 Rootkit,这些 Rootkit 不仅能检测机器人程序,还能主动防止它们运行。为了击败这种保护方式,你不必跳出框框思考,你可以完全复制这个框,并在这个副本内进行工作。

例如,如果你想向游戏写入内存,必须调用由kernel32.dll导出的WriteProcessMemory()函数。当你调用这个函数时,它会直接调用ntdll.dll中的NtWriteVirtualMemory()函数。GameGuard 会钩住ntdll.NtWriteVirtualMemory()函数,防止你写入内存。但如果NtWriteVirtualMemory()从另一个文件,如ntdll_copy.dll中导出,GameGuard 就无法钩住这个函数。

这意味着你可以复制ntdll.dll并动态导入所有需要的函数,如下所示:

// copy and load ntdll
copyFile("ntdll.dll", "ntdll_copy.dll");
auto module = LoadLibrary("ntdll_copy.dll");

// dynamically import NtWriteVirtualMemory
typedef NTSTATUS (WINAPI* _NtWriteVirtualMemory)
    (HANDLE, PVOID, PVOID, ULONG, PULONG);
auto myWriteVirtualMemory = (_NtWriteVirtualMemory)
    GetProcAddress(module, "NtWriteVirtualMemory");

// call NtWriteVirtualMemory
myWriteVirtualMemory(process, address, data, length, &writtenlength);

复制ntdll.dll后,这段代码从复制的文件中导入NtWriteVirtualMemory(),并将其命名为myWriteVirtualMemory()。从此,机器人可以使用这个函数来替代NtWriteVirtualMemory()函数。它们实际上是相同的代码,位于相同的库中,只是以不同的名称加载。

复制一个被反作弊软件钩住的函数,只能在你以最低级别的入口点调用该函数时有效。如果这段代码复制了kernel32.dll并动态导入了WriteProcessMemory()函数,反作弊根套件依然会阻止机器人,因为kernel32_copy.dll在调用WriteProcessMemory()时仍然依赖于ntdll.NtWriteVirtualMemory()

击败启发式算法

除了我们刚才讨论的所有先进的客户端检测机制,游戏公司还会采用服务器端的启发式算法,通过监控玩家的行为来检测机器人。这些系统通过机器学习算法学会区分人类玩家和自动化玩家的行为。它们的决策过程通常是内部的,人类难以理解,因此很难确切指出哪些游戏特征会导致被检测出来。

你不需要了解这些算法如何工作来欺骗它们;你的机器人只需要表现得像人类。以下是一些常见的行为模式,它们在人类和机器人之间有明显的区别:

操作之间的间隔

许多机器人执行操作的速度异常快,或者按照固定的间隔进行。机器人如果在操作之间有合理的冷却时间,它们看起来会更加像人类。机器人还应具备某种随机化机制,以防止它们以固定的频率重复执行某个操作。

路径重复

自动刷怪的机器人会访问一个预先编程的地点列表,去击杀怪物。这些路径列表通常非常精确,将每个位置标记为一个精确的像素。相比之下,人类玩家的移动方式较不规则,会沿着熟悉的区域访问一些更加独特的地方。为了模拟这种行为,机器人可能会走到目标地点的某个范围内的随机位置,而不是直接到达目标位置。而且,如果机器人随机化访问目标地点的顺序,它所走的路径种类将会进一步增加。

不真实的游戏方式

一些机器人使用者会让他们的机器人在同一个位置运行数百小时,但人类不可能连续玩这么长时间。建议你的用户避免一次使用机器人超过八小时,并警告他们,如果连续七天做同样的事情,肯定会在启发式系统中触发警报。

完美的准确度

机器人可以连续打出一千个爆头,不打多余的一发子弹,且能稳定地命中每一个技能射击。但对于人类来说,几乎不可能做到这一点,所以一个聪明的机器人有时应该故意不那么精准。

这些只是一些例子,但一般来说,只要你运用常识,你就能绕过启发式检测。不要让机器人做出人类无法做到的事情,也不要让机器人做某一件事做得太久。

结束语

游戏黑客和游戏开发者之间一直在进行着智力的较量。黑客会不断寻找规避检测的方法,而开发者则会不断寻找更好的检测方式。然而,如果你决心要胜利,本章的知识应该能帮助你击败你遇到的任何反作弊软件。

第十三章:索引

A

关于文本字段,训练生成器对话框,9

访问内存

在注入的 DLL 中,145–146

用于写入和读取,122–124

动作消息格式(AMF),169

演员功能,216

激励,216,223

地址列

事件属性对话框,55

OllyDbg 反汇编窗格,27

地址,内存。另见 内存地址

地址空间布局随机化(ASLR),128

在注入的 DLL 中绕过,146–147

在生产环境中绕过,128–130

禁用用于机器人开发,128

在进程资源管理器中,56,57

Adobe AIR 钩取,169

decode() 函数,172–173,174–175

encode() 函数,171–172,174–175

放置钩子,173–175

RTMP,评估,169–170

Adobe AIR.dll,173–175

airlog 工具,170

对齐

在数字数据中,68

变量,在数据结构中,70–71

环境光,添加,190–192

AMF(动作消息格式),169

反作弊软件,245–246

反作弊根套件,击败,261–262

二进制验证,击败,259–261

机器人足迹,管理,250–256

ESEA 反作弊工具包,247

GameGuard 工具包,248–249

启发式,击败,262–263

PunkBuster 工具包,246–247

截图,击败,258

基于签名的检测,规避,256–257

VAC 工具包,247–248

Warden 工具包,249–250

反人群控制黑客,218

反调试技术,251,255–256

算术指令,90–92

A* 搜索算法,234

成本,233

创建节点,234–237

创建路径列表,239–240

分数,234

用途,240–241

编写搜索函数,237–239

ASLR。另见 地址空间布局随机化(ASLR)

Asm2Clipboard 插件,42

汇编代码

复制,42

跟踪,32–33

在 OllyDbg 中查看和导航,27–29

汇编语言,78。另见 x86 汇编语言

汇编模式,搜索,19–21

AStarNode 类,234–236

AT&T 语法,80

自动连招,219

自动闪避,219

自动滑翔机器人,244

自动治疗器,218,225–228,230–232

自主机器人,221–222。另见 控制理论;状态机

洞穴机器人,241–243

复杂的假设状态机,228–230

错误校正,230–232

治疗器状态机,225–228

使用搜索算法的路径寻找,232–234

战争机器人,243–244

自动重载,219

自动抢先机器人,244

自动穿墙机器人,244

B

封禁波,246

Bigger Than 扫描类型,Cheat Engine,6

二进制算术指令,90

二进制验证,248,259–261

位,EFLAGS 寄存器,84

蓝屏死机(BSOD),256

机器人。另见 自主机器人;超感知(ESP)黑客

反人群控制黑客,218

反调试技术,251,255–256

自动治疗器,218,225–228,230–232

检测调试器,251–254

检测视觉提示,205–206

禁用 ASLR 进行开发,128

模拟键盘,211–215

足迹管理,250–256

游戏更新,处理,101–104

拦截网络流量,206–211

内存监控,204–205

混淆,251,255–256

发送数据包,215–217

法术训练器,219

分支,92–94

断点,30,34,38

断点窗口,OllyDbg,26

BSOD(蓝屏死机),256

BYTE 数据类型,67

字节,机器码,78

C

C++,66

被调用者,94–95

调用者,94–95

callHook() 函数,154

调用挂钩,153–156。另见 Adobe AIR 挂钩

调用约定,95

对于调用挂钩,155

__cdecl,95,155

__fastcall,95

__stdcall,95

__thiscall,95,217

用于跳板函数, 168

VF 表钩子, 156–158

CALL指令, 94–95

调用堆栈

溢出, 255–256

查看, 30

x86 汇编语言, 86–88

调用堆栈窗口, OllyDbg, 26

std::vector的容量, 109

施放法术。 法术

洞穴机器人, 241–243

__cdecl约定, 95, 155

改变值扫描类型, Cheat Engine, 7

字符。另见 敌人

血条, 使用机器人监控, 204–205

当生命值下降时暂停执行, 39–42

玩家生命值, 使用 OllyDbg 查找, 99–101

char数据类型, 67

Cheat Engine, 3, 5–6

自动定位字符串地址, 102

修改表格, 7–8

确定正确地址, 7

第一次扫描, 运行, 6

安装, 4

Lua 脚本环境, 18–22

内存修改, 8–11

下一次扫描, 运行, 7

使用指针扫描, 14–18

扫描类型, 6

std::list, 确定数据是否存储在其中, 112–113

std::map, 确定数据是否存储在其中, 117

训练生成器, 9–11

VF 表, 78

查找缩放因子, 197

修改表格, Cheat Engine, 7–8

Cheat Utility 插件, 42–43

CheckRemoteDebuggerPresent()函数, 251

类, 74–78

类实例, 76

CloseHandle()函数, 122, 138

关闭互斥体, 59–60

CMP指令, 92

代码洞, 134

加载 DLLs, 143–146

线程劫持, 138–142

线程注入, 134–138

代码注入, 133–134

在生产环境中绕过 ASLR, 128–130

DLLs, 142–146

使用线程劫持, 138–142

使用线程注入, 134–138

创建代码补丁, 31–32

列配置, 进程监视器, 51

战斗, 自动化, 243–244

命令行插件, OllyDbg, 43–44

命令语法, x86 汇编语言, 79–81

注释栏, OllyDbg 反汇编窗格, 28

复杂的假设状态机, 228–230

条件断点, 34, 38

条件语句, 93

调整健康常数比例, 230–231

控制关键例程的时间, 254

控制流破解, 31

控制流操作, 149–150。另见 Adobe AIR 钩取; Direct3D 钩取

调用钩取, 153–156

IAT 钩取, 160–165

跳转钩取, 165–169

NOP 操作, 150–152

VF 表钩取, 156–160

控制理论, 222

与状态机结合, 225

复杂假设状态机, 228–230

错误修正, 230–232

治疗者状态机, 225–228

控制窗口,OllyDbg, 25–26

显示敌人冷却时间, 200–201

复制汇编代码, 42

写时复制保护, 126

对尸体的机器人行为, 229, 240

确定正确地址,在 Cheat Engine 中, 7

CPU 窗口,OllyDbg, 26–30, 40

崩溃调试器, 255

CreateRemoteThread() 函数, 129, 130, 134, 138

CreateToolhelp32Snapshot() 函数, 120, 141

生物数据,了解其背后的结构, 106–107

显示关键游戏信息, 198–201

群体控制攻击, 218

加密函数,钩取, 170

CS 寄存器, 85

C 风格操作符,OllyDbg, 34–35

自定义行为用于洞穴机器人,脚本编写, 243

D

照亮黑暗环境, 190–192

数据修改指令, 89

数据结构, 71–73

数据类型, 66

类和 VF 表, 74–78

数字数据, 67–69

OllyDbg, 36

字符串数据, 69–71

联合体, 73–74

检查 DBG_RIPEXCEPTION 处理程序, 253

调试。另见 OllyDbg

反调试技术, 255–256

检查调试驱动程序, 254

打印调试字符串, 253

检测调试器, 251–254

进程监视器, 52–53

__declspec(naked) 约定, 168

decode() 函数,钩取, 172–173, 174–175

通过扫描类型减少值, Cheat Engine, 7

减少值扫描类型, Cheat Engine, 7

依赖关系, DLL, 145

依赖加载, 160

存款人, 242

目标操作数, 80

避免检测. 参见 反作弊软件

device->SetRenderState() 函数, 192

Dijkstra 算法, 233–234

Direct3D 9, 176

Direct3D 钩子, 175–176. 另见 超感知(ESP)黑客

游戏中的视觉提示检测, 205–206

绘图循环, 176–177

查找设备, 177–181

稳定性可选修复, 184

EndScene() 编写钩子, 182–183

Reset() 编写钩子, 183–184

方向性光影 hack, 190–191

禁用 ASLR, 128

反汇编窗口, OllyDbg, 27–29, 42

反汇编列, OllyDbg 反汇编窗口, 28

dispatchPacket() 函数, 210

显示基准, 27

DLL(动态链接库), 注入, 142–146

DllMain() 入口点, 144–145

DLL 选项, 进程资源管理器窗口, 57

域名系统(DNS)缓存扫描, 248

DOS 头, 160–161

DrawIndexedPrimitive() 函数, 194, 195, 196, 200

绘图循环, Direct3D, 176–177

DS 寄存器, 85

转储窗口, OllyDbg, 29–30

DWORD 数据类型, 67, 145–146

动态分配内存, 6, 11, 12

动态链接库 (DLL), 注入, 142–146

动态诱饵, 242–243

动态结构, 105

std::list 类, 110–113

std::map 类, 114–118

std::string 类, 105–108

std::vector 类, 108–110

E

EAX 寄存器, 81

EBP 寄存器, 83

EBX 寄存器, 82

ECX 寄存器, 82, 157

EDI 寄存器, 83

EDX 寄存器, 82

EFLAGS 寄存器, 84, 92

EIP 寄存器, 83, 139

模拟键盘, 211–215

enableLightHackDirectional() 函数, 190–191

encode() 函数,钩子,171–172,174–175

EndScene() 函数

跳转钩子,178–181

稳定性,184

为…编写钩子,182–183

endSceneTrampoline() 函数,181

敌人。另见 超感知(ESP)作弊

冷却时间,显示,200–201

显示关键游戏信息,198–201

预测移动,241

纹理,修改,195–196

熵,5,7

环境选项卡,Process Explorer 属性对话框,58

错误修正,230–232

ESEA(电子竞技娱乐协会),247

ESEA 反作弊工具包,247

ESI 寄存器,83

ESP 作弊。 超感知(ESP)作弊

ESP 寄存器,83

ES 寄存器,85

欧几里得距离启发式,236

事件类别过滤器,Process Monitor,51–52

事件日志,Process Monitor,52–53

事件属性对话框,54–55

精确值扫描类型,Cheat Engine,6

异常处理程序,检查,253

执行保护,125–128

执行直到返回按钮,OllyDbg,25

经验追踪 HUD,200

指数,float 数据类型,68

表达式,OllyDbg,36–37

使用访问内存内容,36

由…评估的元素,35–36

表达式引擎,33–36

当角色生命值下降时暂停执行,39–42

当玩家名字显示时暂停执行,37–38

支持的数据类型,36

超感知(ESP)作弊,189–190

背景知识,190

地板间谍作弊,201–202

HUD,198–201

光线作弊,190–192

加载屏幕 HUD,201

选择阶段 HUD,201

范围作弊,201

穿墙作弊,192–197

缩放作弊,197–198

F

假阳性,VAC 工具包,248

__fastcall 调用约定,95

反馈回路,222

文件访问,在 Process Explorer 中检查,60

文件系统事件类别过滤器,52

FILO(先进后出),86

过滤器,事件类别,51–52

findItem() 函数,116–117

findSequence() 函数,175

后进先出(FILO),86

第一人称射击游戏(FPS),xxii,246

第一次扫描,在 Cheat Engine 中运行,6

flags, 进程访问,121

float 数据类型,67–68

地板间谍黑客,201–202

战争迷雾,189。 参见 超感知(ESP)黑客

足迹,管理,250–256

在 OllyDbg 中找到的跨模块调用窗口,40

FPS(第一人称射击游戏),xxii,246

FPU 寄存器,29

帧列,事件属性窗口,54

帧,在 Direct3D 绘图循环中,176

冻结间隔,训练器生成器对话框,9

冻结

地址,8

主线程,141

前沿,233

FS 寄存器,85

函数调用,x86 汇编语言,94–95

函数流程图,OllyFlow,45

函数名称,查找 IAT 挂钩,163

G

GameActuators 类,225

游戏自动化状态机,223–224

GameGuard 工具包,248–249

游戏更新,确定新地址后,101–104

通用寄存器,81–82

通用内存函数,123–124

getAddressforNOP() 函数,152

GetAsyncKeyState() 函数,196

GetExitCodeThread() 函数,129

GetModuleFileName() 函数,144

GetModuleHandle() 函数,129–130,134,144,146–147

GetSystemTimeAsFileTime() 函数,258

GetThreadContext() 函数,139,142

GetTickCount() 函数,254

GetWindowThreadProcessId() 函数,120

目标状态,238

转到按钮,OllyDbg,25

贪婪最佳优先搜索算法,233–234

GS 寄存器,85

保护防护,126

H

停机问题,250

句柄操作选项,进程浏览器,59–60

处理程序函数,208

句柄,56,121,210–211,252

句柄选项,进程浏览器面板,57

句柄窗口,OllyDbg,26

硬件断点,检查,252–253

哈希验证,247

抬头显示(HUD),198–201

治疗者状态机,225–228,230–232

角色生命值

生命条,通过机器人监控,204–205

敌人的生命条,显示,150–152

执行暂停,在丢包时,39–42

堆数据,16

启发式,233

击败,262–263

欧几里得距离,236

曼哈顿距离,235

十六进制转储列,OllyDbg 反汇编窗格,27–28

隐藏数据,显示,198–201

隐藏选项,进程资源管理器窗格,57

钩子,42,149,153。另见 Adobe AIR 钩子;Direct3D 钩子;超感官知觉(ESP)修改

调用,153–156

在游戏中检测视觉提示,205–206

IAT,160–165

截取网络流量,206–211

跳转,165–169

预写库,169

基于签名的检测,规避,257

VF 表,156–160

缩放修改,198

热键

补丁窗口,OllyDbg,32

进程资源管理器,57

进程监视器,52

为训练器设置,10

每小时经验,查找,200

HTTP(超文本传输协议),169

HTTPS(安全超文本传输协议),169

HUD(抬头显示),198–201

I

IAT(导入地址表)钩子,160–165

IDIV 指令,92

IMAGE_DOS_HEADER 结构,161

IMAGE_IMPORT_DESCRIPTOR 结构,162

IMAGE_OPTIONAL_HEADER 结构,161

图像标签,进程资源管理器属性对话框,57–58

IMAGE_THUNK_DATA 结构,162

立即数,80

导入地址表(IAT)钩子,160–165

导入描述符,162

IMUL 算术指令,90–91

Cheat Engine 的增加值扫描类型,7

Cheat Engine 的增加值扫描类型,7

索引寄存器,83

无法避免的无限循环,255

游戏内动作,自动化程序

反控制技能修改,218

自动治疗器,218,225–228,230–232

模拟键盘,211–215

发送数据包,215–217

法术训练器,219

游戏内事件,日志记录,50–52

指令,79

算术,90–92

分支,92–94

数据修改,89

函数调用,94–95

跳转,92–94

int 数据类型,67

英特尔语法,80

中断处理程序,检查,252

迭代器,120

J

jumpHookCallback() 函数,168

跳转钩子,165–169,178–181

跳转指令,x86 汇编语言,92–94

K

内核模式根套件,GameGuard 工具包,249

键盘,模拟,211–215

KEYEVENTF_KEYUP 标志,212

kite(风筝操作),222,240–241

L

库,钩子,169

小光欺骗,190–192

list 类,110–111

listItem 类,110–111

小端序,67

加载器锁,144

加载屏幕 HUD,201

LoadLibrary() 函数,143–144

位置列,事件属性窗口,54

事件日志记录,Process Monitor,50–52

日志窗口,OllyDbg,25

long 数据类型,67

long long 数据类型,67

掠夺,229,241–243

Lua 脚本环境,Cheat Engine,18–22

诱饵模式,242

M

机器码,78

主循环

Direct3D 绘图循环,176–177

与之同步,164–165

法力,避免浪费,219

曼哈顿距离启发式,235

尾数,float 数据类型,68

大型多人在线角色扮演游戏(MMORPGs),xxi–xxii,198,248

大型在线战斗竞技场(MOBA),xxii,189,197,201,206

memcpy() 函数,136

内存,65–66

类和虚函数表,74–78

数据结构,71–73

数值数据,67–69

字符串数据,69–71

联合,73–74

内存访问

在注入的 DLL 中,145–146

用于读写,122–124

内存地址,4

使用 OllyDbg 表达式访问,36

在 Cheat Engine 中确定是否正确,7

冻结,8

更新后确定新地址,101–104

运行时重新基址,128–129

静态,6

基于内存的光效,192

内存转储

类数据,76

代码洞,检查,137

数据结构,检查,70–71

数值数据,检查,68–69

字符串数据,检查,70

内存取证,97–98

更新后确定新地址,101–104

玩家健康值,使用 OllyDbg 查找,99–101

数据的用途,推断,98–99

std::list 类,110–113

std::map 类,114–118

std::string 类,105–108

std::vector 类,108–110

内存操作,119

访问内存,122–124

地址空间布局随机化,128–130

内存保护,124–128

进程标识符,获取,120–122

内存映射窗口,OllyDbg,26

内存修改,8–11

使用机器人监控内存,204–205

内存偏移,80

写入断点的内存,208

内存指针,11

内存保护,124–128,151

内存扫描,3,98。另见 Cheat Engine;指针扫描

基础,4–5

重要性,4

内存修改,8–11

更新后确定新地址,101–104

代码优化,22

玩家健康值,使用 OllyDbg 查找,99–101

数据的用途,推断,98–99

MMORPG(大型多人在线角色扮演游戏),xxi–xxii,198,248

助记符,78

MOBA(大型在线战术竞技场),xxii,189,197,201,206

修改内存值,8–11

Module32First() 函数,144,174

Module32Next() 函数,144,174

模块列,事件属性窗口,54

模块窗口,OllyDbg,25

使用机器人监控内存,204–205

怪物,风筝,240–241

鼠标移动,模拟,215,240

MOV 指令,89

多客户端补丁,30

互斥锁,关闭,59–60

N

命名管道,定位,60

当特定玩家的名字打印时暂停执行,37–38

Names 窗口,OllyDbg,29

附近调用,153–154

附近函数调用,39

.NET 进程,59

网络事件类过滤器,52

游戏更新后确定新地址,101–104

在 Cheat Engine 中运行的下一次扫描,7

节点,233,234–238

无操作(NOP)指令,31,32

NOP 填充,150–152

光影 hack,192

缩放 hack,197–198

NtQueryVirtualMemory() 函数,246,257,259

NtWriteVirtualMemory() 函数,261–262

空终止符,70

数值数据类型,67–69

数值运算符,OllyDbg,34–35

O

混淆,251,255–256

观察游戏事件

检测视觉提示,205–206

拦截网络流量,206–211

监控内存,204–205

被阻碍的搜索,233–234

偏移量,54

OllyDbg,23–24

汇编代码,27–29,32–33

查看调用堆栈,30

创建代码补丁,31–32

命令行,43–44

控制窗口,25–26

CPU 窗口,26–30

崩溃调试器,255

处理游戏更新,104

调试器按钮和功能,25

表达式引擎,33–37

查看和搜索内存,29–30

数值数据的内存转储,68–69

字符串数据的内存转储,70

包解析器,查找,207–208

补丁窗口,31–32

补丁 if() 语句,46–47

当角色生命值下降时暂停执行,39–42

当玩家的名字打印时暂停执行,37–38

插件,42–46

注册内容,查看和编辑,29

运行追踪窗口,32–33

支持的数据类型,36

将代码洞汇编翻译为 shellcode,135–136

用户界面,24–26

查找缩放限制代码,198

OllyFlow 插件,45–46

操作码,78

OpenProcess() 函数,121–122

OpenThread() 函数,142

操作数

二进制算术指令,90

IDIV 指令,92

MOV 指令,89

语法,80–81

一元算术指令,90

操作,79

运算符,OllyDbg 表达式引擎中的使用,34–35

优化内存代码,22

排序,小端格式,67

变量顺序,在数据结构中,70–71

OutputDebugString() 函数,253

P

数据包

拦截,206–211

发送,215–217

压缩,251

填充,68

页面保护,125–126

页面,124

解析数据包,206–211

补丁窗口,OllyDbg,26,31–32

补丁,多个客户端,30

补丁 if() 语句,46–47

路径列,事件属性对话框,55

使用搜索算法进行路径查找,232–234。另请参见 A* 搜索算法

路径列表,A* 搜索算法,239–240

暂停按钮,OllyDbg,25

暂停执行,37–38,39–42

暂停线程,184

PEB(进程环境块)结构,146

PeekMessage() 函数,184

PE 头,160–161

选择阶段 HUD,201

PID(进程标识符),120–122

管道,定位命名管道,60

播放按钮,OllyDbg,25

玩家生命值,使用 OllyDbg 查找,99–101

玩家对战玩家(PvP)战斗,243–244

插件,OllyDbg,42–46

指针链,11–12

指针路径,11

指针扫描选项对话框,作弊引擎,14–16

指针扫描,11

基础,12–14

使用作弊引擎,14–18

指针链,11–12

重新扫描,17–18

Pong,46–47

按键弹出训练器字段,训练器生成器对话框,9

预测敌方移动,241

预写的钩子库,169

printf() 调用,72,73–74,75

打印调试字符串,253

Process32First() 函数,120

Process32Next() 函数,120–121

进程访问标志,121

PROCESS_ALL_ACCESS 标志,121

进程和线程活动事件类别过滤器,52

PROCESS_CREATE_THREAD 标志,121

进程环境块(PEB)结构,146

进程查看器,49–50,55–56

配置颜色,56

处理操作选项,59–60

快捷键,57

属性对话框,57–59

用户界面和控件,56–57

获取进程句柄,121

进程标识符(PID),120–122

processInput() 函数,215–216

processKeyboardInput() 函数,216

进程监视器,49–50

配置列,51

调试,53–55

事件类别过滤器,51–52

查找高分文件,55

快捷键,52

检查事件日志中的事件,52–53

记录游戏内事件,50–52

进程监视器过滤器对话框,50

进程名称字段,生成器对话框,9

processNextPacket() 函数,210

处理器寄存器,81–86

进程分析事件类别过滤器,52

PROCESS_VM_OPERATION 标志,121,122

PROCESS_VM_READ 标志,121

PROCESS_VM_WRITE 标志,121

属性对话框,进程查看器,57–59

保护,内存,124–128,151

PunkBuster 工具包,246–247,257

推断数据的目的,98–99

PvP(玩家对玩家)战斗,243–244

R

范围黑客,201

从游戏内存读取,119

访问内存,122–124

地址空间布局随机化,128–130

内存保护,124–128

获取进程标识符,120–122

ReadProcessMemory() 函数,122–124

读取保护,125–128

实时消息传递协议(RTMP)

评估,169–170

decode() 函数,钩子,172–173,174–175

encode() 函数,钩子,171–172,174–175

拦截数据包,207

实时战略(RTS),xxii,197,201,206,243

在运行时重定向地址,128–129

侦察,49–50

Process Explorer,55–60

Process Monitor,50–55

recv() 函数,207–208

红黑树,114–115

引用窗口,OllyDbg,26,28–29,40,100

填充器,242

寄存器,处理器,81–86

注册表窗格,OllyDbg,29

注册事件类过滤器,51

重新扫描指针列表窗口,Cheat Engine,17–18

响应式黑客,203

反人群控制黑客,218

自动治疗器,218,225–228,230–232

检测视觉线索,205–206

模拟键盘,211–215

拦截网络流量,206–211

监控内存,204–205

发送数据包,215–217

法术训练师,219

rootkits(根套件)

击败反作弊,261–262

GameGuard 工具包,248–249

根节点,113–114

RTMP。请参见 实时消息协议

实时战略(RTS),xxii,197,201,206,243

运行时灵活性,229

运行跟踪窗口,OllyDbg,26,32–33

S

SBD。请参见 基于签名的检测(SBD)

扫描码,214

扫描类型,Cheat Engine,6

扫描值,4

分数,234

屏幕截图,247,258

为洞穴机器人编写自定义行为脚本,243

脚本引擎,Cheat Engine,18–22

搜索算法,232–234。另见 A* 搜索算法

安全标签,Process Explorer 属性对话框,58

段寄存器,84–86

send() 函数,216–217

发送数据包,215–217

SendInput() 函数,211–212,215

SendMessage() 函数,213–215

系统的传感器,222

设置/更改热键屏幕,Cheat Engine,10

SetLight() 成员函数,192

SetProcessIsCritical() 函数,256

Shellcode, 134, 135–136, 138–141

short 数据类型, 67

符号, float 数据类型, 68

基于签名的检测 (SBD)

ESEA 反作弊工具包, 247

规避, 256–257

PunkBuster 工具包, 246–247

签名, 246

单实例限制, 59–60

技能射击, 232

Sleep() 函数, 164–165, 227

小于扫描类型, Cheat Engine, 6

源操作数, 80

源窗口, OllyDbg, 26

创建线程, 129

咒语

反众控黑客, 218

复杂的假设状态机, 228–230

拼写训练器, 219

SS 寄存器, 85

栈帧, 87–89

栈溢出, 255–256

栈面板, OllyDbg, 30

栈跟踪, 进程监视器, 54–55

状态机, 223–224

自动治疗器, 225–228

结合控制理论, 225

复杂假设, 228–230

错误修正, 230–232

添加 Lua 函数, 229–230

运行时灵活性, 229

静态地址, 6

__stdcall 调用约定, 95

std::list 类, 110–113

std::map 类, 114–118

std::string 类, 105–108

std::vector 类, 108–110

步入按钮, OllyDbg, 25

步过按钮, OllyDbg, 25

随机系统, 230

字符串数据, 21, 69–71, 100–101

字符串操作符, OllyDbg, 35

字符串选项卡, 进程资源管理器属性对话框, 58

结构体成员对齐, 71

结构体, 数据, 71–73

子寄存器, 83

SuspendThread() 函数, 142, 184

与游戏线程同步, 164–165

系统, 控制行为的, 222

T

目标选择, 240

TCP/IP 选项卡, 进程资源管理器属性对话框, 58

TEB(线程环境块), 146

模板

用于更改内存保护, 127

内存访问函数, 123–124, 145–146

TEST 指令, 92

文本字符串,21,69–71,100–101

敌人纹理的变化,195–196

__thiscall 调用约定,95,156–158,217

Thread32First() 函数,141

Thread32Next() 函数,141

线程环境块(TEB),146

线程

劫持,138–142

注入,134–138

生成,129

线程标签,Process Explorer 属性对话框,58

OllyDbg 中的线程窗口,26

thunks,162–163

时间控制关键例程,254

标题字段,训练器生成器对话框,9

切换 z-buffering,195

OllyDbg 中的“Trace into”按钮,25

OllyDbg 中的“Trace over”按钮,25

使用 OllyDbg 跟踪,32–33,39–42

训练器生成器,Cheat Engine,9–11

跳板函数,165–168,181

遍历

IAT hooking,162

VF 表,156

U

一元算术指令,90

导致不可避免的无限循环,255

未改变值扫描类型,Cheat Engine,7

联合体,73–74

Unix 语法,80

未知初始值扫描类型,Cheat Engine,6

更新,确定新地址后,101–104

用户界面,Process Explorer,56–57

用户模式 rootkit,GameGuard 工具包,248–249

V

VAC 工具包,247–248

值范围扫描类型,Cheat Engine,6

值类型指令,Cheat Engine,6

VF(虚拟函数)表

类实例及其,76–78

查找 Direct3D 设备,177–181

hooking,156–160,182–183

遍历,156

VirtualAllocEx() 函数,136–137,138

虚拟函数,具有类的,75–76

VirtualProtectEx() 函数,126–128

VirtualProtect() 函数,127

W

WaitForSingleObject() 函数,129,138

墙面透视(wallhacks),192

为 Direct3D 创建,194–197

使用 z-buffering 渲染,193–194

战争机器人,243–244

Warden 工具包,249–250

路点,222,229

wchar_t 数据类型,67

窗口句柄,获取,120

Windows 窗口,OllyDbg,26

WM_CHAR 消息,213–214

WORD 数据类型,67

WriteProcessMemory() 函数,122–124,136–137,138

写保护,125–128

向游戏内存写入,119

访问内存,122–124

地址空间布局随机化,128–130

代码洞,136–137

内存保护,124–128

进程标识符,获取,120–122

X

x86 汇编语言,78–79

算术指令,90–92

分支指令,92–94

调用栈,86–88

命令语法,79–81

数据修改指令,89

函数调用,94–95

跳转指令,92–94

NOP 操作,150–152

处理器寄存器,81–86

x86 Windows 内存保护属性,125–126

Z

z-缓冲,192–195

缩放因子,197

缩放黑客,197–198

第十四章:脚注

第四章:从代码到内存:通用入门

1。Randall Hyde 的《汇编语言的艺术》(第二版)(No Starch Press,2010 年)是一本很棒的书,能够教会你有关汇编语言的所有知识。2。每条指令必须适配在 15 字节内,大多数指令只有 6 字节或更少。3。还有一个无符号乘法指令MUL,它只与单一操作数一起使用。4。正如MULIMUL的对照,DIVIDIV的无符号对照。

第十五章

资源

访问 www.nostarch.com/gamehacking/ 获取资源、勘误和其他信息。

更多实用书籍来自 image NO STARCH PRESS

image

黑帽 Python

黑客与渗透测试者的 Python 编程

作者:贾斯廷·塞茨

2014 年 12 月,192 页,$34.95

ISBN 978-1-59327-590-7

image

汽车黑客手册

渗透测试员指南

作者:克雷格·史密斯

2016 年 3 月,304 页,$49.95

ISBN 978-1-59327-703-1

image

IDA Pro 书籍,第 2 版

世界上最流行的反汇编器非官方指南

作者:克里斯·伊格尔

2011 年 7 月,672 页,$69.95

ISBN 978-1-59327-289-0

image

实用法医成像

使用 Linux 工具保护数字证据

作者:布鲁斯·尼克尔

2016 年秋季,256 页,$49.95

ISBN 978-1-59327-793-2

image

IOS 应用程序安全

黑客与开发者的权威指南

作者:大卫·蒂尔

2016 年 2 月,296 页,$49.95

ISBN 978-1-59327-601-0

image

实用恶意软件分析

恶意软件分析实践指南

作者:迈克尔·西科尔斯基 安德鲁·霍尼格

2012 年 2 月,800 页,$59.95

ISBN 978-1-59327-290-6

800.420.7240 或 415.863.9900 | SALES@NOSTARCH.COM | WWW.NOSTARCH.COM

第十六章

image

第十七章:进入游戏的内部

你无需成为巫师,就能把你喜欢的游戏转变为你爱的游戏。想象一下,如果你能给你最喜欢的 PC 游戏添加一个更具信息性的头显显示,或者立即收集你在最新史诗战斗中的所有战利品。

带上你对基于 Windows 的开发和内存管理的知识,游戏黑客将教你成为一名真正的游戏黑客所需的技能。学习基础知识,如逆向工程、汇编代码分析、程序化内存操作和代码注入,并通过实际的示例代码和实践二进制文件来磨练你的新技能。

在学习的过程中提升自己,掌握如何:

image 使用 Cheat Engine 扫描和修改内存

image 使用 OllyDbg 探索程序结构和执行流程

image 使用 Process Monitor 记录进程并精确定位有用的数据文件

image 通过 NOP 操作、钩子技术等操控控制流

image 定位并剖析常见的游戏内存结构

你甚至会发现一些常见游戏机器人背后的秘密,包括:

image 超感官知觉黑客技巧,如墙壁透视和头显显示

image 响应式黑客技巧,如自动治疗器和连击机器人

image 带有人工智能的机器人,如洞穴行走者和自动掠夺者

游戏黑客可能看起来像黑魔法,但其实不必如此。一旦你理解了机器人是如何制作的,你就能更好地在自己的游戏中防御它们。通过游戏黑客,你将深入了解 PC 游戏的内部运作,并对游戏设计和计算机安全有更深刻的理解。

关于作者

Nick Cano 在 12 岁时为开源游戏服务器编写了他的第一个脚本,从那时起他便成为了游戏黑客社区的一员。他有多年的恶意软件检测和防御经验,并为开发者和设计师提供保护游戏免受机器人攻击的最佳实践建议。Nick 曾在多个会议上介绍过他的研究和工具。

警告! 本书不支持盗版、违反《数字千年版权法案》(DMCA)、侵犯版权或违反游戏内服务条款。游戏黑客因其行为被永久封禁、被诉以数百万美元,甚至因其工作而入狱。

image

极致的极客娱乐™

www.nostarch.com

posted @ 2025-12-01 09:43  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报