Codecave 入门指南






4.98/5 (112投票s)
这是一份完整的初学者代码洞穴指南,涵盖了主要主题:什么是代码洞穴,代码洞穴的用途,以及如何使用代码洞穴。

目录
引言
“代码洞穴”?除非您花了一些时间在逆向工程领域工作,否则您可能从未听说过“代码洞穴”这个词。如果您听说过,您可能还没有读到过它的清晰定义,或者不太明白它是什么或为什么有用。我甚至问过有经验的汇编程序员关于这个词,他们中的大多数人都没有听说过。如果这对您来说是新的,请不用担心,您不是唯一一个。这是一个很少使用且仅在逆向工程上下文中才有所帮助的术语。此外,它是“codecave”还是“code cave”?我不确定,但我会尽力将其统一称为“codecave”。偶尔可能会出现空格。
如果您在互联网上搜索,您不会在代码洞穴主题上找到太多内容。即使找到,大多数资源也来自“不安全”的网站。诚然,代码洞穴在黑客的地下世界中占有重要且有用的地位,但它们也可以用于合法的目的(正如任何编程相关的、可以用于善恶的东西一样)。无论如何,代码洞穴只是程序员或逆向工程师可以用来增强其技能和工具集的另一种工具。您现在可能没有即时用途,但也许有一天您会有,并且会庆幸您知道如何使用这个概念。
本文的目的是提供一个完整的指南,以理解和使用代码洞穴。读完本文,您将了解什么是代码洞穴,它有什么用,以及如何使用它。此外,您还将看到一个实际示例来巩固您所学的知识,以便您能看到这个概念的实际应用。本文作为各级专业水平的指南,即使是初学者,但假定您具备 C/C++、汇编和逆向工程概念的一些基本知识。在阅读文章时,如果您觉得某些内容不够清晰,您可能想在网上搜索额外的参考资料。
本文分为四个主要部分,包含多个子部分。“引言”,也就是您现在正在阅读的部分,将对文章进行铺垫,并涵盖本文的主题以及您将要学到的内容。“理论”将讨论代码洞穴的理论,包括它们是什么以及如何使用。“应用”部分将展示“理论”部分的实际应用,通过一个完整的代码洞穴使用示例来完成特定任务。最后,“结论”将快速回顾文章讨论的内容并给出告别语。
既然枯燥的部分已经结束,是时候开始行动了!
理论
代码洞穴可以被定义为“将程序执行重定向到另一个位置,然后返回到程序之前离开的区域。” 在某种意义上,代码洞穴的概念与函数调用没有区别,除了几个小的差异。如果代码洞穴和函数调用如此相似,我们为什么还需要代码洞穴呢?我们需要代码洞穴的原因是,通常无法获得源代码来修改任何给定的程序。因此,我们必须在汇编级别物理(或虚拟)地修改可执行文件来进行更改。
此时,一些读者的警钟可能会敲响。我们为什么要修改一个没有源代码的现有程序呢?考虑以下假设的、但并非遥不可及的场景:
一家公司已经使用了他们过去 10 年开发的同一个软件系统。该软件系统一直运行良好,但现在是时候升级它以反映输出数据格式的强制性更改。唯一的问题是最初的程序员早已离职,并且没有希望获取原始源代码来更新程序。现在,这家公司已经培训了现在的资深员工,并且在过去 10 年里一直使用这个特定的软件系统,因此完全重写将对公司造成相当大的打击。重新培训所有员工使用新系统并以不同的方式重新编程不仅耗时,而且成本高昂。这样做大约需要一年时间,而这已经超出了公司的时间范围。最糟糕的是,您就是被聘来解决这个问题的程序员。
您可以耸耸肩说这不可能,但这对您的职业生涯没有什么帮助。相反,想象一下,如果您有一种方法可以继续使用同一个程序,但您有一个额外的 DLL,用于动态更新公司程序中的输出数据,使其符合要求的新标准。最重要的是,这是一种可以在截止日期前很久实现的解决方案,并且对公司现有的程序使用程序几乎没有改变。这时就需要代码洞穴了。
现在我们有了代码洞穴的定义以及我们可能需要它的原因,是时候对此概念进行可视化演示了。将以下图像视为正常的程序流程:
在上图中,执行按 A、B、C 的顺序依次通过标记的点。点 A、B、C 只是任意的代码区域;它们可以是一行汇编代码,也可以是多行汇编代码。关键在于(无双关之意),执行在某个时间点都会经过它们。如果我们对点 B 进行代码洞穴处理,我们将得到以下图像:
在此图像中,执行像往常一样首先通过 A。但是,它随后被重定向到代码洞穴,然后代码洞穴会替换点 B 执行。执行完成后,程序执行被重定向到 B 之后,并继续通过 C。如果您仔细观察图片,关于代码洞穴相对于程序的位置可能有点模糊。这是故意为之,只是为了先展示概念,因此使用了 A、B、C 的参考点,并且没有显示任何与代码相关的内容。希望现在您对代码洞穴的实际含义有了更好的理解。
代码洞穴属性
现在我们对代码洞穴有了更好的了解,可以继续讨论您需要熟悉的代码洞穴的各种属性。我将讨论我们需要了解和理解才能使用的三个主要属性。第一个是代码洞穴的位置,或者更简单地说,代码洞穴的实现位置。第二个是代码洞穴的入口和出口点,这指的是我们如何从 EXE 进入代码洞穴,以及如何从代码洞穴返回到正确的 EXE 位置。最后一个属性是代码洞穴堆栈和寄存器修改,这讨论了我们可以使用的各种逻辑,以确保我们在代码洞穴中不修改最终的堆栈和寄存器。
属性 1:代码洞穴位置
第一个属性是代码洞穴的位置。代码洞穴的这个属性描述了代码洞穴的实现位置。这个属性有两个部分:一个一般位置描述和一个具体位置描述。
物理位置
由于代码洞穴必须位于应用程序的进程空间内,因此有两种可能性:在 EXE 中或在加载的 DLL 中。当代码洞穴位于 EXE 中时,它通常是内联编写的。这仅仅意味着代码洞穴被放置在 EXE 的某个未使用的、为空或不经常使用的部分,例如异常处理代码。以下是一个包含在程序本身的程序洞穴示例:
在此图像中,执行在代码洞穴执行期间永远不会离开程序的模块。代码洞穴被放置在 EXE 的某个标记为“T”的区域,该区域假定适合代码洞穴。这种方法有几个优点和缺点。优点是实现速度快、效率高、易于测试和分发。缺点是您必须修改 EXE 本身,它不灵活,而且您必须用汇编语言编写。让我们仔细看看每组优缺点:
优点
- 实现速度快 - 这个优点来自于这样一个事实:有免费的反汇编器可以使用,它们允许您修改程序并立即保存更改(例如 OllyDbg)。使用这种方法,您可以找到 EXE 中适合代码洞穴的位置,进行适当的更改以实现代码洞穴(本文尚未讨论),并保存最终的 EXE。
- 高效 - 将代码洞穴放置在 EXE 中,这种方法之所以高效,是因为您无需加载任何额外的东西到程序中来实现您的更改。因此,这会导致灵活性方面的缺点,但只要您使用的是程序导入的基准 API 函数,并且没有执行复杂逻辑,这一点就成立。
- 易于测试和分发 - 由于您正在修改 EXE 本身并保存它,您可以在调试器中启动它,并在执行代码洞穴之前设置断点,以验证一切是否正常工作。当一切正常工作时,您只需分发 EXE 并替换原始的即可。您甚至可以编写一个文件补丁程序,将原始 EXE 补丁成这个最终工作的 EXE,以减小分发文件大小。
缺点
- EXE 修改 - 第一个缺点是因为您确实必须修改 EXE 本身。如果存在任何物理文件 CRC 检查,则此方法将需要绕过或伪造这些检查。EXE 中的空间有限,可能没有足够大的地方放置您想要的的代码洞穴。很难找到一个“安全”的覆盖位置,因此将新代码覆盖旧代码可能会导致未来的程序不稳定。
- 不灵活 - 这种方法不灵活,因为 EXE 中的任何更新都需要在新的 EXE 中更新代码洞穴。它在相对于下一个缺点的灵活性方面也不够。
- 必须用汇编语言编写 - 由于您正在修改 EXE 本身,您必须用汇编语言实现所有代码。这可能对您来说是优点或缺点,取决于您需要做什么,但总的来说,这是一个大问题,因为汇编语言代码占用的空间相当大。
现在我们知道了在 EXE 中实现代码洞穴的基础知识,我们可以看一下位置属性的第二个选项,即在 DLL 中实现代码洞穴。我将再次强调,代码洞穴必须位于应用程序的进程空间内,因此 DLL 中的代码洞穴的视觉图像与 EXE 中的相同。尽管如此,为了完整起见,这里有一个图像:
在此图像中,程序执行在代码洞穴执行期间会离开程序模块。代码洞穴位于某个加载的 DLL 中,位于标记为“Z”的某个区域。将代码洞穴放置在 DLL 中的优点和缺点与在 EXE 中的优点和缺点是相反的。优点是这种方法非常灵活、可以用更高级的语言编程,并且是动态的。缺点是实现起来需要更长时间,增加了进程开销,并且更难测试和分发。再次,我们将仔细查看每组优缺点。
优点
- 灵活 - 这种方法非常灵活,因为 EXE 中的任何代码更改都可以轻松地在 DLL 内的代码洞穴中更新。如果代码洞穴所属位置发生变化,DLL 加载器(用于将 DLL 加载到进程中)也可以轻松更新。同样可以参考由此产生的缺点。
- 可以用更高级的语言编程 - 由于代码洞穴现在在我们创建的 DLL 中,我们可以自由地用更高级的语言(如 C/C++/C++ CLI)编写我们的 DLL。这使我们能够拥有更多的控制权,并实现比以前在 EXE 中放置代码洞穴更复杂的逻辑。
- 动态性 - 由于这种方法不需要物理上更改 EXE,因此如果设置得当,我们可以虚拟地启用和禁用我们的代码洞穴。这使得我们能够创建“一次性”代码洞穴,它们只执行一次,并恢复之前用于进入代码洞穴的字节。由于其他优点,还可以实现更复杂的执行条件,以允许代码洞穴仅在特定条件下执行。
缺点
- 实现时间更长 - 这种方法的一个主要问题是,您首先必须在 DLL 中开发您的代码洞穴,然后将 DLL 加载到程序中,最后让 EXE 重定向到您的代码洞穴。如果您对此主题不熟悉,使您的 DLL “恰到好处”以便代码洞穴按预期工作需要一些时间,但一旦您习惯了,这就不算是一个大缺点了。
- 额外开销 - 这是一个明显的缺点,但仍然很重要。由于您加载了一个额外的 DLL 到程序中并执行了比以前更多的代码,如果您没有正确实现您的逻辑,您可能会降低性能。总的来说,只要您不编写在代码洞穴中执行的低效代码,对程序的性能影响可以忽略不计。
- 更难测试和分发 - 这个缺点与上面的灵活优点相关。由于代码洞穴在 DLL 中,您必须编写一个 DLL 加载器来将 DLL 加载到程序中。这种“DLL 注入”使得您无法轻易地用调试器启动目标进程。相反,您必须将其附加到正在运行的进程。当 EXE 刚刚启动时,这会导致一些棘手的调试代码洞穴的情况。
现在我们知道了实现代码洞穴的两个主要选项。我们可以将它们放置在 EXE 中,方便快捷地测试相对简单的逻辑。如果我们希望获得更多的灵活性和功能,我们可以选择在 DLL 中实现代码洞穴。
逻辑位置
这个属性的第二个要讨论的部分是代码洞穴相对于汇编列表的位置。我们必须遵循的指南是,使用下一节中讨论的任一方法设置代码洞穴,我们几乎总是需要至少5 字节的空间。无论我们将代码洞穴放在哪里,我们都必须能够轻松地恢复代码洞穴中被覆盖的字节。这意味着涉及 PUSH
、MOV
、CMP
或无条件长 JMP
的指令是最理想的代码洞穴放置位置,前提是它们占用 5 字节或更多。我们希望找到一个位置,使我们所需的额外工作最少。这一段可能有点难理解,所以这里有一些示例和解释说明其含义。
考虑来自 Pinball 游戏的任意函数的以下代码列表:
假设我们需要在该函数中某个地方设置一个代码洞穴,以访问程序中的某些任意数据。唯一的条件是代码洞穴必须在此函数内;一旦代码洞穴设置好,我们就可以访问所需内容。什么是放置代码洞穴的“最佳选择”位置?
由于我们只需要至少 5 字节的空间来放置代码洞穴,我们可以将其放在前 3 条指令中,它们正好占用 5 字节。但是,这意味着我们必须在代码洞穴中实现这些代码。“那些代码”是:
01017441 /$ 8BFF MOV EDI,EDI
01017443 |. 55 PUSH EBP
01017444 |. 8BEC MOV EBP,ESP
这个解决方案对我们来说效果很好,因为这段汇编代码没有版本特异性。函数开头的汇编代码通常由编译器生成(称为序言代码),并且在所有版本之间保持不变。由于这段代码也保证始终会被执行,因此它将是放置代码洞穴的绝佳选择。但是,让我们考虑其他可能性。如果我们查看以下行:
01017449 |. E8 6545FFFF CALL PINBALL.0100B9B3
这条指令也正好占用 5 字节,那么它会是一个好候选吗?答案是“视情况而定”。如果没有其他合适的位置,而我们必须选择这个位置,那也没问题。但是,使用这个位置有一些缺点。如果程序在更新过程中发生更改,CALL
地址很可能会不同。这将导致需要更新代码洞穴地址以及代码洞穴中调用的代码。简而言之,从长远来看,我们需要做更多的工作。
如果我们查看接下来的两条线:
0101744E |. F645 08 01 TEST BYTE PTR SS:[EBP+8],1
01017452 |. 74 07 JE SHORT PINBALL.0101745B
我们可以看到总共占用 6 字节,比 5 字节多,但其中包含条件跳转。我们想不惜一切代价避免将代码洞穴放在涉及条件跳转的代码中。它们为在代码洞穴中正确重新编程增加了大量工作。对于条件跳转后的调用怎么样?
01017455 |. E8 249D0000 CALL
与上面的 CALL 类似的原因,这一行存在相同的缺点,即在程序更新时,地址可能会改变,从而给我们带来更多工作,但真正的禁忌是它不一定会执行!这段代码可能是上面比较的错误条件,所以我们的代码洞穴可能永远不会被执行。在寻找代码洞穴位置时,我们必须特别注意这些条件。
最后,在函数末尾,我们有一组由以下指令组成的 5 字节:
0101745A |. 59 POP ECX
0101745B |> 8BC6 MOV EAX,ESI
0101745D |. 5E POP ESI
0101745E |. 5D POP EBP
这会是一个合适的代码洞穴位置吗?绝对不行!这个例子就是代码洞穴的雷区,会让您陷入程序崩溃的境地。如果您查看上面的条件跳转,它会跳转到 MOV EAX, ESI
这一行。这意味着当执行条件跳转时,程序将执行一个无效的字节序列,因为我们的代码洞穴从一个字节之前开始。在寻找代码洞穴位置时,必须注意这种情况。
在查看了放置代码洞穴的大多数可能性之后,最好的选择是放在函数开头。在这个例子中,我们的要求比较宽松,只需要在该位置放置代码洞穴。这是一个很少见的最佳情况示例。大多数时候,我们都有一个特定的代码洞穴必须放置的位置,所以我们必须仔细阅读代码,以确保如果我们放置代码洞穴在那里,程序不会崩溃。
从这个例子中可以得出的主要信息是,在选择放置代码洞穴的位置之前,您必须仔细阅读整个汇编列表代码。现在这个属性已经完全涵盖,我们可以继续下一个属性,这涉及到我们实际进入代码洞穴的方法。
属性 2:代码洞穴入口和出口
代码洞穴的第二个必须涵盖的属性是代码洞穴的实际入口和出口点。我们必须能够让 EXE 执行我们的代码洞穴,否则我们只会添加无用的代码。对于这个属性,有两种方法可以实现,这两种方法都涉及间接修改程序中的指令指针。第一种方法是使用 JMP
指令直接跳转到代码洞穴。第二种可能的方法是使用 CALL
指令调用代码洞穴。
JMP 方法
JMP
方法比前一种方法更容易使用,因为我们可以直接 JMP
到代码洞穴,然后通过 PUSH
和 RETN
JMP
回到我们应该在的位置。这种方法的优点是,当我们执行 JMP
时堆栈得到保留,因此一旦我们进入代码洞穴,就好像我们没有执行 JMP 一样。这一点很重要,因为我们必须执行代码洞穴位置中被覆盖的代码。如果我们不小心处理堆栈,可能会导致程序崩溃,如果事情顺序混乱。这种方法的缺点是必须在代码洞穴中硬编码返回地址。如果 EXE 不会改变,这种方法也没问题,但如果它经常改变,每次都要更新返回地址会很烦人。很容易忘记更新返回地址,并最终导致程序崩溃,因为您返回到一个无效的位置。稍后将对此缺点进行更详细的介绍。这是一个方法的示例:
如果您不熟悉 OllyDbg,可能需要一些时间阅读图像才能完全理解,但不必着急。仔细阅读并继续,一旦您明白了发生了什么。左栏是地址。已添加标签以帮助识别重要地址。中间两列分别是十六进制和汇编代码列表。第四列是附加注释。此示例仅用于展示此方法如何工作的快速方式。顺便说一句,第一个 JMP
通常不会是 SHORT JMP
,而是 LONG JMP
,因为代码洞穴不会离被代码洞穴化的位置那么近。
如上文缺点中所述,维护代码洞穴以返回到正确位置以继续执行是您的工作。在上图中,地址是 0x401002
。如果在该位置之前插入代码,那么我们将不得不更新代码洞穴,在代码洞穴返回之前 PUSH
新的返回地址。否则,我们将返回到一个无效的代码序列。根据第一个属性,如果您注意到,此示例是使用 EXE 本身的代码洞穴完成的。完全可视化呈现替代方法有点困难,这就是我选择此方式的原因。
以上就是 JMP
方法的全部内容。它的实现和使用非常简单,但需要稍微多一点维护,以应对 EXE 的变化。
CALL 方法
CALL
方法比 JMP
方法稍微复杂一些,因为我们必须在执行代码洞穴之前保存堆栈顶部的返回地址,然后在返回之前将其推回堆栈。这种方法的优点是我们不必自己维护返回地址,因为它已经推到了堆栈上。如果我们不这样做,我们所有的代码洞穴操作都必须相对于一个与预期不符的 4 字节堆栈指针进行。这很难处理,所以最好由我们自己来处理。
现在,上一段要么完全符合您的理解,要么让您挠头。让我假设是后者,并提供一些图片来说明我刚才所说的。让我们假设这是图像中显示的原始代码:
如果我们跟踪到 CALL
,我们的堆栈将如下所示:
如果一切正常,当我们执行 CALL
时,调用堆栈将与 JMP
方法中一样(事实并非如此)。
如果我们仔细观察,堆栈顶部现在包含我们执行的 CALL
的返回地址。因此,我们必须弹出这个值。但是,我们不能简单地丢弃它;我们必须保存它,以便在完成代码洞穴后返回到该地址。这就是为什么我们有将堆栈顶部弹出到我们的变量中的代码行(地址 0x00401017
处的指令)。一旦那一行执行完毕,堆栈的顶部值就被存储到我们的变量中,堆栈看起来应该是正确的,顶部是指向 0x6FFBC
而不是 0x6FFB8
,后者指向 CALL 的返回地址。
一旦我们处理完这个小麻烦,我们就可以继续进行,就像我们在 JMP
方法中一样。这种 CALL
方法也提供了一些灵活性,因为返回地址已经保存,我们可以实际修改它,使用各种偏移量,以便我们可以返回到任何我们想要的位置。假设 EXE 发生变化,而我们代码洞穴所在的函数没有变化,那么偏移量仍然有效!这并不是说我们在 JMP
方法中不能做同样的事情,只是对于那种额外的复杂逻辑会稍微麻烦一些。
覆盖了这两种方法后,我们就知道如何从 EXE 进入代码洞穴并从代码洞穴返回到 EXE 的理论了。应该注意的是,虽然上面的示例是在 EXE 中完成的代码洞穴,但如果它在 DLL 中,看起来会是一样的,只是地址会不同。我们现在还有最后一个属性要涵盖,它处理在代码洞穴内处理寄存器和堆栈的理论。
属性 3:代码洞穴堆栈/寄存器修改
我们必须注意的最后一个代码洞穴属性是代码洞穴内部发生的堆栈和寄存器修改。这个属性是一个极其重要的方面,在设计代码洞穴时必须仔细观察,否则可能会导致灾难性的后果。以下一个代码片段为例:
大失误! 在我们的代码洞穴中,我们修改了 EAX
寄存器,因此当代码洞穴返回时,我们将执行一个 CALL 0
指令,这将导致异常和程序崩溃。现在,为什么我们必须特别注意堆栈和寄存器就很明显了。这个例子很琐碎,但当您处理更复杂的代码时,您可能会无意中修改堆栈或寄存器并导致程序崩溃。我们可以做一些事情来帮助在代码洞穴中保留堆栈和寄存器。
PUSH/POP
我们可以使用这一组指令来一次保存一个寄存器。例如,如果我们只需要使用 ECX
寄存器,我们可以这样做:
push ecx
...
; Use ecx
...
pop ecx
这一组指令的优点是我们只需要保存和恢复我们需要的,如果我们确定只需要修改几个寄存器。如果我们想访问堆栈内容,我们知道只需要修改 4 个字节,所以这是一个可以接受的计算。
PUSHAD/POPAD
我们可以使用这一组指令来同时保存所有通用寄存器。如果我们想从代码洞穴内的某个函数调用一个可能会修改一个或多个寄存器的函数,我们会使用这个。
pushad
...
call MyFunction ; Modifies quite a few registers
...
popad
非常重要的一点是,PUSHAD
指令会修改堆栈 8 个 32 位值,所以如果您必须访问或使用堆栈,您应该在实际使用 PUSHAD
指令之前这样做。否则,您每次都必须将堆栈访问修改 0x20。以下是一个示例,展示了 PUSHAD
执行之前以及之后堆栈的样子。请注意堆栈的变化有多大:
在原始堆栈中,-1
的最顶层值被访问为 ESP + 8
。在 PUSHAD
之后,我们必须使用 ESP + 0x28
。这就是我们在修改堆栈时在代码洞穴中重新计算堆栈地址的含义。还请注意,$
只是 ESP
。
PUSHFD/POPFD
这一组指令与 PUSHAD
和 POPAD
对类似,但它将标志保存到堆栈中。这组指令只将一个 32 位值保存到堆栈中,因此您就像使用 PUSH
一样处理更新堆栈访问计算,只需添加 4。
pushfd
...
test eax, eax
...
popfd
以下是一个堆栈在执行 PUSHFD
之前和之后的样子:
如您所见,堆栈只修改了 4 个字节。请注意,我在上面的第二个图片中没有更改地址引用,这就是为什么最顶层的地址是 ESP - 4
而不是 ESP
,因为第二个图片是在 PUSHAD
示例中。这里看一下 TEST EAX, EAX
指令执行之前和之后的标志:
执行 POPFD
后,标志将恢复到上面第一张图中看到的原始状态。
临时存储
我们可以使用的最后一种技术来帮助保留堆栈和寄存器是使用临时变量存储。而不是将原始寄存器存储到堆栈中,我们可以将其保存到内存位置,使用寄存器,然后自己恢复它。
mov [VariableAddr], EAX
...
; Use EAX
...
mov EAX, [VariableAddr]
这是一个可视化示例:
在此代码中,我们首先将 EAX
寄存器移动到内存位置,使用 EAX
,然后从保存的内存位置将其恢复。通过这样做,我们根本不修改堆栈,所以我们不必更改任何堆栈计算!这需要更多的工作,而且我们必须确保将正确的变量恢复到寄存器,但除此之外,这是一种有趣的尝试方法。
这里有一个我必须提请您注意的例外情况。这一节是关于编写初学者代码洞穴的。在这种情况下,初学者不修改堆栈或寄存器很重要。但是,随着经验的增加,您会发现有时您确实想修改堆栈或寄存器。如果您想故意修改堆栈或寄存器,这是可以的。只是在这样做之前,请确保您知道自己在做什么。
现在已经讨论了代码洞穴的三个主要属性,我们对代码洞穴有了更全面、更完整的认识,并结合了前面关于基本理论的知识。剩下的是看看代码洞穴的实际应用,并对如何在实际示例中开发和使用它们有一个很好的感觉。
Application
代码洞穴理论已经涵盖,我们可以将这些知识付诸实践了。不过,在继续之前,我们需要准备一些工具。一旦我们准备好了一套工具,我们就必须获取我们将要处理的程序。对于本文,我选择使用 WinXP 自带的《太空学员弹珠》游戏。仔细阅读 Windows 组件的最终用户许可协议,我认为我无法重新分发我的确切版本。我们将在处理实际程序时提供一些小技巧,帮助那些有不同版本的人。一旦我们收集了下一节讨论的工具,我们就可以开始创建代码洞穴了!以下是本文使用的弹珠游戏的版本图像:
工具
我们需要的第一种工具是内存扫描器。对于这个工具,我推荐 TSearch。这个工具的目的是扫描进程的内存以查找数据的地址。Wikipedia 页面上有一个程序的链接,但请务必仔细扫描,就像您在互联网上下载任何东西一样。下一个我们需要的是反汇编器和调试器。对于这个,我推荐您使用 OllyDbg,因为它免费、强大且易于使用。我们最后需要的工具是 C++ 编译器。对于这个,我推荐您使用 Visual C++(如果您有的话)。如果您没有,您可以获取 Express Edition。请注意,像 Dev-Cpp 这样的其他免费替代品也可以,但它的源代码不兼容,因为它不使用 Intel 风格的内联汇编。总而言之,我们需要三种工具:内存扫描器、反汇编器和 C++ 编译器。如果您以前从未使用过这些工具,本文的这一部分可能需要您花费更多时间来理解。您可能需要参考一些关于这些工具的额外教程,如果这里提供的图像和文本不够的话。请记住要耐心,并重读您卡住的地方。这绝非易事。以下是我们即将使用的工具的额外概述信息(摘自 Wikipedia)。我没有包含教程和额外资源的链接,因为它们所在的网站性质如此。
TSearch
“TSearch(类似于 ArtMoney 和开源的 Cheat Engine)是由 Corsica Productions 开发的内存扫描器/调试器实用程序。TSearch 的主要功能是扫描打开的进程以查找字节地址;将搜索限制为“精确值”、“范围”或“未知值”。可以通过使用“下一次搜索”(或筛子)选项来优化搜索:这会重新搜索已找到的结果,以显示符合进一步细化标准的结果。TSearch 还具有十六进制编辑器和“自动破解”选项,并且在游戏破解社区中常用于开发第三方游戏“训练器”和“破解”。
OllyDbg
“OllyDbg 是一个强调二进制代码分析的调试器,在源代码不可用时很有用。它跟踪寄存器,识别过程、API 调用、开关、表、常量和字符串,以及定位来自目标文件和库的例程。根据程序的帮助文件,版本 1.10 是最后一个 1.x 版本。版本 2.0 正在开发中,并且是从头开始编写的。该软件是免费的,但共享软件许可证要求用户向作者注册。”
Visual C++
“Microsoft Visual C++(也称为 MSVC)是 Microsoft 为 C、C++ 和 C++/CLI 编程语言设计的集成开发环境(IDE)产品。它提供了用于开发和调试 C++ 代码的工具,特别是为 Microsoft Windows API、DirectX API 和 Microsoft .NET Framework 编写的代码。”
工作流程
现在我们有了我们将要使用的工具和我们的目标程序,我们可以开始将代码洞穴的理论应用于实际工作了。接下来的三个主要步骤是必须遵循的。第一步是为我们想要创建的代码洞穴找到一个目的。通常,您已经知道为什么需要代码洞穴,但为了本文的需要,我们将从头开始完成这个步骤。下一步是编写实际的代码洞穴。对于本文,我们将编写一个位于 DLL 中并使用 CALL
方法的代码洞穴。最后一步是将所有内容整合起来,让我们的 DLL 进入进程并观察我们的代码洞穴的实际运作。
步骤 1:寻找目的
让我们启动 Pinball 游戏,看看有什么可以让我们摆弄的。以下是主游戏屏幕的参考图像:
如果我们考虑显示的内容以及变化最多的内容,我们真正能处理的只有当前分数。如果我们想创建一个程序来显示程序外的当前分数怎么办?然后我们可以做某种分数记录或其他统计分析。无论哪种方式,我们都在尝试做目前无法做到的事情,因为只有游戏才知道当前的分数。我们可以直接从内存中读取,但在某些情况下,可能无法做到这一点,所以我们将假设我们的唯一选择是创建一个代码洞穴来提取数据。
作为步骤 1 的快速回顾,我们将目的定义为在 Pinball 游戏中创建一个代码洞穴,以便我们能够访问分数。既然我们知道了我们想做什么,我们就必须开始我们的逆向工程程序,以弄清楚如何在程序中找到一个合适的位置来提取分数。为此,我们将使用 TSearch。现在就启动 TSearch。在顶部的菜单栏中,选择“打开进程”按钮,然后选择我们的程序,“Pinball.exe”。
完成之后,窗口的标题将变为“TSearch - PINBALL.EXE”。这让我们知道进程已加载。TSearch 加载后,我们的首要任务是找到保存我们分数的地址。为此,玩一会儿弹珠游戏,获得一个非零分数,然后按 F3 暂停游戏。我在分数达到 6750 时停止了。一旦您有了分数,切换回 TSearch,我们将搜索分数的内存地址。
查看包含网格控件的左上角区域。我们将使用第一个按钮来搜索分数。点击第一个图标,即放大镜,开始新搜索。
将打开一个新对话框,允许我们选择一些数据类型选项、搜索选项以及实际值。由于分数显示为整数,我们将使用默认搜索参数来查找分数。在其他程序或游戏中,您可能需要调整设置以找到适合您所需内容的类型。继续输入您的分数,然后按“确定”。
如果一切顺利,TSearch 将扫描程序的内存,并告诉我们与我们分数匹配的数据地址。在我的情况下,正好是 2 个。取决于值,您可能会得到更多。如果您得到更多,我会通过多玩一会儿并再次搜索来更改您的分数,以便得到正好 2 个结果。以下是搜索后弹出的结果对话框图像:
在我们点击“确定”并回头查看我们用来搜索的网格控件后,我们可以看到两个地址列出了我们的分数:
现在,为什么我们有两个地址?考虑到我们有一个变量来存储分数,可能还有一个第二个变量来保存分数以显示在主 GUI 上。因此,在其他程序中,可能有两个或更多值包含我们要查找的数据。这是一个需要牢记的事情。我们知道一个是真实的分数,一个是假的,我们怎么区分它们?没有简单的答案,只有试错。参考上面的图像,点击带有“带框的绿色加号”的按钮,上面写着“将所有找到的行添加到表中”。点击该按钮后,这两个条目将被复制到右侧的面板对话框中。我们现在将找出哪个是真实的分数。
我们现在将从屏幕右侧开始,在那里刚刚添加了两个地址。点击“值”列的第一个条目,并将其更改为 0。
切换到游戏,玩一会儿以获得更多积分。如果内存布局与我的相似,那么您会看到显示的分数继续增加,并且我们刚才设置为 0 的内存位置会重置为当前分数。这意味着该地址不是我们的真实分数。为验证这一点,再次按 F3 暂停游戏并返回 TSearch。在第二个地址中,将其值更改为 0 并重复相同的过程。一旦我们在游戏中获得更多积分,我们将看到我们的分数已被重置,并且我们正在从头开始玩。这清楚地表明我们已经找到了正确的分数地址。
现在我们有了分数的地址,我们必须找到游戏在 EXE 的哪个位置访问或修改它,以便我们可以在该位置设置一个代码洞穴。为此,我们将把 OllyDbg 附加到进程。现在花点时间启动 OllyDbg。如果这是您第一次启动程序,如果出现第一个消息框,您需要点击“是”。
OllyDbg 打开后,点击“文件”,然后点击“附加”。从列表中选择 Pinball 进程,然后点击“附加”按钮。您可以点击“名称”标题按名称对列表进行排序,以更轻松地找到进程。
一旦您将 OllyDbg 附加到进程,您就会看到另一个消息框。只需按“确定”。
此时,OllyDbg 已附加到我们的程序,我们应该会看到类似这样的内容:
此时有几件重要的事情需要注意。目前我们的程序处于暂停状态。我们知道这一点,因为在右下角用黄色显示“Paused”。当前显示的模块不是我们的程序,而是 ntdll.dll 文件。这是由窗口标题预示的,该标题显示“module ntdl”。我们首先需要按 F9 键或菜单栏顶部的蓝色“播放”按钮(向右的三角形)来解除暂停我们的程序。如果您做得正确,右下角将从“Paused”变为“Running”。
程序再次运行时,我们需要查看 Pinball 模块而不是 ntdll.dll 模块。右键单击汇编列表窗格,选择“视图”,然后选择“模块‘Pinball’”。
您必须多次执行此操作,直到主窗口显示“CPU - main thread, module PINBALL”。如果您只执行一次,它仍然显示“module ntdll”,您将不得不再次执行!一旦进入主 PINBALL 模块,按“Ctrl + A”进行代码分析。这将使读取内容更清晰。或者,右键单击汇编列表,选择“分析”,然后选择“分析代码”。您可能还想更改代码的外观,使其不全是黑白的。再次右键单击汇编列表窗口,选择“外观”、“高亮显示”,然后选择“Jumps'n'Calls”。
此时,我们已将 OllyDbg 附加到 Pinball 进程,并且我们知道 TSearch 中的分数地址。将真实分数的地址从 TSearch 复制到剪贴板。切换到 OllyDbg,按“Ctrl + G”,然后粘贴地址。在地址前加上“0x
”,以便 OllyDbg 知道它是一个地址,因为它以字母开头。
按“确定”后,您将被带到主反汇编转储窗口中的地址。您应该看到类似这样的内容:
您现在看到的是该位置的内存内容的汇编。但是,这不是我们要查看此数据的方式。确保主行被高亮显示并右键单击。选择“在转储中跟随”,然后选择“选择”。在屏幕的底部部分,您将看到十六进制视图中的数据。高亮显示前 4 个字节,这是我们的分数变量。
接下来,我们必须在该位置设置一个内存断点,以便游戏在向该内存位置写入以更新分数时,OllyDbg 会暂停程序。通过这样做,我们知道了一个可以放置代码洞穴以获取当前分数的位置。右键单击高亮显示的 4 个字节,选择“断点”,然后选择“内存,写入时”。
设置内存断点后,我们可以切换回游戏并解除暂停。一旦您得分,游戏应该会暂停,调试器应该会激活。您的调试器在从断点焦点时应该看起来像这样:
请注意,右下角的过程再次“Paused”。在状态栏中,文本显示“Memory breakpoint when writing to [00AFFC82
]”。这让我们知道调试器已捕获到该进程正在向我们的分数内存位置写入。让我们看一下与分数修改相关的汇编指令。
01017579 |. 8D41 52 LEA EAX,DWORD PTR DS:[ECX+52]
0101757C |. 0130 ADD DWORD PTR DS:[EAX],ESI
0101757E |. 8B10 MOV EDX,DWORD PTR DS:[EAX]
01017580 |. 81FA 00CA9A3B CMP EDX,3B9ACA00
这是正在发生的事情的大致翻译。通过查看上面的内容,我们知道分数的地址是从 DS:[ECX + 0x52]
加载到 EAX
中的。然后,将 ESI
寄存器的值加到分数的值上。最后,分数变量加载到 EDX
中,然后与静态常量 0x3B9ACA00
(十进制 1,000,000,000)进行比较。在不同版本的 Pinball 代码中看到过至少一种不同的代码版本后,我将选择将代码洞穴放置在指令 CMP EDX, 3B9ACA00
上。由于该指令是 7 字节,因此满足代码洞穴至少需要 5 字节的要求。此外,在代码洞穴中重写该逻辑很简单;我们只需在代码洞穴结束时执行该行,然后再返回到 EXE。在那个位置放置代码洞穴非常方便,因为我们知道分数存储在 EDX
中。我们可以简单地将寄存器移动到我们自己的变量中使用,而无需额外工作。
如果您的列表与上面有所不同,没关系。只要您看到类似于上面的内容,并且有一个 CMP
指令将寄存器与 0x3B9ACA00
进行比较,就没问题。如果您没有在这样的位置中断,您可能选择了错误的分数变量地址。这也没关系,因为您可以按 CTRL + F 搜索 CMP EDX, 3B9ACA00
。您现在应该可以找到正确的位置了。我们应该记住 CMP
指令所在的地址,因为我们将在下一步中使用它。
这里可以暂停一下,回顾一下我们到目前为止所做的事情。我们首先查看了我们的 Pinball 游戏并找到了一个要完成的任务。我们决定我们想要能够访问游戏外的分数。一旦我们确立了目标,我们就进入了使用 TSearch 查找进程中该数据地址的步骤。一旦我们正确识别了位置,我们就使用 OllyDbg 来查找进程修改该地址的位置,这反过来又为我们提供了一个可以代码洞穴以提取当前分数的地点。完成所有这些之后,我们就完成了第一步。现在,我们可以进入下一步,即编写代码洞穴本身。
步骤 2:编写代码洞穴
在本文的这个阶段,出现了一个小问题。实现我们的代码洞穴本身不是问题,但实际将其写入进程将会是一个问题。动态更改已加载程序代码的概念是另一篇文章。由于这个限制,我将提供我们将用于完成本文目标的函数。您需要自己研究更多关于它们的内容,以完全理解它们的作用以及为什么是这样(这需要一些时间和实践)。代码注释很好,所以只需要解释几个概念。
对于我们制作的每个代码洞穴,我们通常至少有两个函数。第一个函数是代码洞穴本身。其余函数是从第一个函数调用的支持函数,用于处理无法放在第一个函数中的附加逻辑。代码洞穴函数本身必须是一种特殊类型的函数,即裸函数。以下是摘自 MSDN 的一段:
带有naked 属性声明的函数会去掉序言和结尾代码,从而允许您使用内联汇编器编写自己的自定义序言/结尾序列。裸函数作为高级功能提供。它们允许您声明一个从 C/C++ 以外的上下文调用的函数,从而对参数的位置或保留的寄存器做出不同的假设。例如,中断处理程序之类的例程。此功能对于虚拟设备驱动程序(VxD)的编写者特别有用。
有关序言和结尾代码的更多信息,请查看此 Code Project 文章:玩转堆栈,并对“序言”和“结尾”这两个词进行额外的 Google 搜索。
当我们在裸函数时,我们必须遵循一些指导原则。有关您必须遵循的指导原则列表,请参阅本文:裸函数规则和限制。需要记住的一个重要事项是,您不能在裸函数中声明变量。相反,它们必须在函数外部声明。如果您在裸函数中放置变量声明,您将引用堆栈上的地址。除此之外,建议在主代码洞穴函数中仅放置最少的非汇编代码。您应该将所有其他内容放在支持函数中。
由于我们使用的是 DLL 中的代码洞穴并使用 CALL
方法,因此我们将总共需要两个变量和两个函数。第一个变量将保存从游戏中获得的分数。第二个变量将保存进入和退出代码洞穴时的返回地址。对于这个简单的示例,我们将仅将当前分数显示到控制台窗口,以证明一切正常。
以下是代码洞穴实现的相关代码。未显示 DllMain
和其他实用函数。
// This variable holds our current score
DWORD currentScore = 0;
// This variable holds the return address, it must be global!
DWORD ExtractScoreRetAddr = 0;
// This is our higher level C++ function that is called to display
// the current score
void DisplayCurrentScore()
{
// Simply display the current score to the console
printf("Current score: %i\n", currentScore);
}
// This is our codecave function, we must remember to
// make it a "__declspec(naked)" function
__declspec(naked) void CC_ExtractScore(void)
{
__asm
{
// The first thing we must do in our codecave is save
// the return address from the top of the stack
pop ExtractScoreRetAddr
// Since we know the current score is in EDX, copy it over into
// our variable
MOV currentScore, EDX
// Remember that we need to preserve registers and the stack!
PUSHAD
PUSHFD
}
// Invoke our C++ function now
DisplayCurrentScore();
__asm
{
// Restore everything to how it was before
POPFD
POPAD
// This is an important part here, we must execute whatever
// code we took out for the codecave.
// Also note that we have to use 0x3B9ACA00 for a HEX #
// and not 3B9ACA00, which would be misinterpreted by the compiler.
CMP EDX, 0x3B9ACA00
// The last thing we must do in our codecave is push
// the return address back onto the stack and then RET back
push ExtractScoreRetAddr
ret
}
}
// Initialize function called by the loader's inject function
extern "C" __declspec(dllexport) void Initialize()
{
// We will place a codecave at the address 0x01017580.
// The function will call CC_ExtractScore
// and one extra byte will be NOP'ed
Codecave(0x01017580, CC_ExtractScore, 1);
// Create a console since we are in a DLL
CreateConsole();
}
现在看起来不算太糟糕,对吧?请记住,这里只显示了与代码洞穴相关的代码,其余代码属于项目的一部分。如果我们回想一下我们在第二部分中学到的所有理论,一切似乎都在这里。我们首先有代码洞穴将返回地址保存到变量中。这在行 pop ExtractScoreRetAddr
中可以看到。接下来,我们将数据保存到另一个变量中,如行 MOV currentScore, EDX
所示。在调用支持函数之前,我们将寄存器和标志保存到堆栈,然后稍后恢复它们。我们最后执行了为代码洞穴移除的代码,即 CMP EDX, 0x3B9ACA00
这一行,并使用存储的返回地址返回到我们应该在的位置。
Initialize
函数是我们加载程序将用于将 DLL 注入 Pinball 游戏的导出函数,以便 EXE 将调用代码洞穴。Codecave
函数将为我们完成大部分工作,即编写代码洞穴本身。由于我们使用 CALL
方法进入代码洞穴,Codecave 函数将使用我们传递的函数的地址创建一个 CALL
指令。最后一个参数是我们指定的 NOP
计数,用于擦除需要移除的额外字节。这个数字就是您想要代码洞穴的总字节数减去 5。在这种情况下,如汇编列表所示,CMP EDX, 3B9ACA00
指令共有 6 个字节。其中五字节用于代码洞穴,所以还剩一个字节需要 NOP
,以免程序在恢复时因无效字节序列而崩溃。如果正好是 5 字节,我们将不需要任何 NOP
。如果有 7 字节,我们将需要 2 个 NOP
。
要自己创建未来的代码洞穴 DLL,您可以使用提供的项目作为模板。您将需要更新 Initialize
函数并为您自己实现新的代码洞穴。现在代码洞穴 DLL 已完成,是时候进行此过程的最后一步了,即整合所有内容!
步骤 3:整合
为了看到我们刚刚完成的最终产品的实际效果,我们必须找到一种方法将我们的 DLL 引入 Pinball 进程。一旦我们做到了,就必须调用 Initialize
函数来让 DLL 补丁程序,以便调用代码洞穴。在那之后,每当分数更新时,我们的代码洞穴都会被触发,我们将看到我们的分数显示在控制台上。
为了完成最后一步,我将引用并使用我之前写的一篇文章:使用 CreateRemoteThread 进行更完整的 DLL 注入解决方案。我将使用那个项目来创建加载器,以将我们的 DLL 注入到进程中。加载器本身非常简单;该文章末尾显示的 WinMain
只对本文进行了少量修改:
// Program entry point
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPTSTR lpCmdLine, int nCmdShow)
{
// Structures for creating the process
STARTUPINFO si = {0};
PROCESS_INFORMATION pi = {0};
BOOL result = FALSE;
// Strings for creating the program
char exeString[MAX_PATH + 1] = {0};
char workingDir[MAX_PATH + 1] = {0};
// Holds where the DLL should be
char dllPath[MAX_PATH + 1] = {0};
// Get the current directory
GetCurrentDirectory(MAX_PATH, workingDir);
// Build the full path to the EXE
_snprintf(exeString, MAX_PATH, "\"%s\\PINBALL.EXE\" -quick", workingDir);
// Set the static path of where the Inject DLL is, hardcoded for a demo
_snprintf(dllPath, MAX_PATH, "PinballCodecave.dll");
// Need to set this for the structure
si.cb = sizeof(STARTUPINFO);
// Try to load our process
result = CreateProcess(NULL, exeString, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, workingDir, &si, p);
if(!result)
{
MessageBox(0, "Process could not be loaded!", "Error", MB_ICONERROR);
return -1;
}
// Inject the DLL, the export function is named 'Initialize'
Inject(pi.hProcess, dllPath, "Initialize");
// Resume process execution
ResumeThread(pi.hThread);
// Standard return
return 0;
}
请记住,上面只显示了 WinMain
,其余代码都在项目文件中。此时,我们已经拥有了所有主要部分。是时候进行测试了!将“PinballCodecave.dll”和“PinballLoader.exe”文件复制到您的 Pinball 文件夹中,默认情况下是“C:\Program Files\Windows NT\Pinball”。运行“PinballLoader.exe”文件以启动 Pinball 游戏,您还应该会看到一个 DOS 控制台弹出。如果一切顺利,您应该能够玩游戏。当您得分时,您的当前分数应该会输出到控制台窗口。很酷,不是吗?如果出现任何问题,请检查您放置代码洞穴的地址以及所有细节。这种底层操作的好处是,如果您搞砸了,90% 的情况下您都会知道,因为程序会崩溃或出现意外行为。
结论
这是一段漫长而充满挑战的旅程,但您终于走到了尽头。此时,您应该已经对代码洞穴有了基本但完整的理解。我们通过理论部分看到了代码洞穴的作用以及如何使用它。您现在知道设计自己的代码洞穴时必须牢记的三个重要属性。它们是代码洞穴的位置、入口和出口点,以及堆栈和寄存器保存技术。您还有一个实际示例可供参考,以及一个可供未来项目使用的模板代码。您甚至可能还了解了一些可以用于未来项目的新工具。
最后剩下的一个大问题总是“现在怎么办?” 从这里开始,您可以继续探索使用代码洞穴来完成您以前可能无法完成的各种任务。有时很难弄清楚“做什么”,所以如果您目前没有什么可以做的,不用担心!只需记住您学到的东西,也许将来可以在其他地方应用它。我希望您喜欢这篇文章,我知道它很长,但我希望听到您的反馈。