什么方法可以用于在程序执行期间原子地更改代码流?

逆向工程 视窗 x86 C x64 函数挂钩
2021-06-22 05:53:26

我一直在阅读了很多有关Windows API钩子不同的技术(技术,我特别着迷和喜欢的),似乎在实现的一个重大问题realiable钩子函数是确保挂钩的方式书写时那是线程安全的。当然,有一些技术已经解决了这个问题或者可以简单地解决这个问题,例如热补丁Windows API,但热补丁并不能保证对所有的 win32 或第三方 API 函数都有效,以及支持挂钩它们的技术通常不是线程安全的。

一个很常见的技术是有问题的多线程是一个内联钩子,它用一个跳转指令代替普通的函数序言代码到钩子过程,然后根据需要通过蹦床调用原始函数。

内联钩子技术有几个固有的问题,这使它成为一种非常复杂的使用和调试方法。正如我所提到的,一个主要问题是,它在现实世界中的正统多线程环境中并不安全。这是因为在改变函数的字节时,你不能保证指令指针不会在你新注入的代码中间,这可能会导致目标应用程序因执行旧的无效混合而崩溃操作码与您插入的操作码混合。

这个问题有一些解决方案,一种是暂停进程中的所有线程,然后检查每个线程中的指令指针,以确保当前没有线程正在执行您要替换的目标指令。然后,如果碰巧有一两个线程执行该特定功能,那么您可以通过执行诸如执行堆栈跟踪以在返回地址处放置断点,恢复线程,然后在线程出现异常时处理异常来做出相应的响应已从目标函数返回。

当然,这种方法仍然不安全,因为CreateThread()在你可以挂起进程中所有正在运行的线程之前,没有什么可以阻止一个偷偷摸摸的线程的使用(我电脑上的一些应用程序同时运行 40 多个线程)。甚至可能有一个相关进程CreateRemoteThread()在您的目标应用程序中使用,然后在它安全之前调用您正在挂钩的函数。

该问题的解决方案可能是尝试调试进程并接收进程何时创建新线程的通知,然后通过挂起该线程来响应。当然,Windows API 或第三方 API 提供的许多事件通知系统不会实时发送,这可能允许该线程在挂起之前执行不安全的操作。

另一种解决方案可能是在进程启动之前使用钩子函数静态修补可执行文件,大概是通过钩子 EAT/IAT。这对我来说不是一个选择,因为我需要一个可以在整个进程范围内工作的实现,无论函数是如何解析的,或者在新的未挂钩模块调用该函数的情况下。

使用内联钩子技术还有许多其他问题需要克服,我没有提到。这让我再次回到我的问题:

什么方法可以用于在程序执行期间原子地更改代码流?

我很好奇是否有更强大的解决方案可以克服我在本文中介绍的方法的一些缺点。

请不要为挂钩函数提供第三方库建议。我想实现我自己的教育利益。

我更喜欢使用 C 编程语言的挂钩技术文档和示例。

我的处理器是兼容 x86-x64 的 AMD Athlon II X2 250,我的操作系统是 Windows 7。

3个回答

如何实现自己的补丁系统的草图,其中替换指令的长度小于或等于您要补丁的指令的长度:

  1. 确保您的任何代码都不依赖于您要修补的任何代码。这是一个重入问题。如果您修补您的修补系统将使用的代码,那么您可能会死锁/阻塞/引发无限数量的信号。一个常见的解决方案是做一些事情,比如重新实现libc你需要的子集,或者静态链接到libc. 符号版本控制可以帮助确保在运行时没有其他库链接到您的libc(或libc类似函数)的版本
  2. 有一个专门的修补“主”线程。如果您使用的是 linux,则此修补线程可以是通过ptrace. 另一种方法是从一个收到信号的线程中动态地选择一个主线程(在下一点中提到)。
  3. 安装一个信号处理程序,以便您的修补线程可以向所有其他线程发出信号以停止并阻塞条件变量。显然,请确保您的修补程序线程不会自行发出信号。您可能需要仔细检查在向所有线程(您知道的)发出信号所花费的时间内,没有创建更多新线程。
  4. 现在您可以修补代码。更改包含您要修补的代码页的内存保护,以便它们可读、可写但不可执行。确保您使用的代码不会出现在同一页面上!更改代码。将该代码的内存保护改回可读、可执行但不可写。
  5. 通知您的条件变量/线程唤醒。
  6. 当您的线程从其信号处理程序中的条件变量被阻塞中唤醒时,它们需要执行同步指令,例如CPUID,在执行修补代码之前。这是为了旧版本的代码不会保留在任何指令预取缓存/缓冲区/任何东西中。英特尔的软件优化手册在这里详细介绍了一些细节。顺便说一句,注意修补用于实现信号/等待/等的并发/信号机制!
  7. 然后线程将从信号处理程序返回以在它们被发出信号的地方恢复执行。

现在,如果您想N用这样的M字节指令修补字节指令该M > N怎么办?我们将应用大致相同的技术,但我们将修改信号线程的返回地址以指向包含您的补丁的原始指令的副本P

例如,假设您有指令I1; I2; I3; I4; ...,而您的补丁P(如果放置)最终将是:P; I3_tail_garbage; I4; ...

然后您可以P; I1_copy; I2_copy; I3_copy; jmp &I4;在 address创建一个补丁入口patch您将修改信号处理程序中的一些返回地址,如下所示:

  • 如果RA == &I1,则使其指向:P; I1_copy; I2_copy; I3_copy; jmp &I4;
  • 如果RA == &I2,则使其指向:I2_copy; I3_copy; jmp &I4;
  • 如果RA == &I3,则使其指向:I3_copy; jmp &I4;

补丁I1; I2; I3; I4; ...做到以下几点:jmp patch; int3; ...; int3; I4; ...

注意:在复制代码时,如果您的指令以某种方式从指令指针读取,则需要重新相对化它。例如,如果I1I2、 或I3是分支指令,或计算RIP相对地址,则它们可能需要用等效指令进行扩展/修改/替换。

另一种方法是每个补丁的I1I2I3如果你这样做,那么你必须首先修补这些指令中的每一个第一个字节,并且只能使用int3. 这可以安全地完成,即使其他线程正在执行被修补的代码。但是,如果其他线程同时执行这些指令,则您无法安全地修改这些指令的其他字节。这是因为这些指令可能已被预取,一旦发生,它们就不再是一个有凝聚力的单元。

找出正确的协议来处理线程并发执行int3s 的情况很棘手,但我认为可以通过遵循与上述复制前几条指令类似的方法来处理它,这样您就可以保证这些指令不会丢失,但您还可以捕获执行补丁区域内代码的线程。

我不熟悉 Windows 环境,所以这个CreateRemoteThread问题听起来很棘手,但我认为int3在代码中安装指令以及在搜索线程以发出信号时保护代码不被执行可能就足够了。您还可以考虑让主修补线程休眠一小段时间。

最后,一些很好的参考资料还有 Kprobes 和 RCU 的东西,因为一些“额外”线程看到旧版本或新版本(或中间版本)所面临的问题是 RCU 的一个主要问题。作为结束语,请注意英特尔手册 wrt 缓存一致性和 icache 的语言。许多文本可以解释为好像对数据缓存的原子写入将在 icache 中表示,但实际上,这不能保证是真的(尤其是在涉及预取的情况下),并且有一些重要的 CPU 勘误表使问题比最初出现的更难的问题。

这是一个很好的问题,我认为没有 100% 安全的方法来修补正在运行的 Windows 进程,除非您主动调试它,即使那样也可能存在边缘情况。您可以消除许多潜在的问题,但我觉得出于通用目的无法完全消除潜在的线程问题。

在我看来,这留下了几个实用的选择:

1.) 暂停进程,修补您的代码,然后恢复执行。要么所有线程都挂起,要么不挂起,如果您有权开始修补进程,这很容易被检测到。这是我的首选方法,尽管基于计时器和防御钩子的反调试措施可以检测到这一点。总的来说,虽然我会说它非常可靠。

2.) 充分了解您的目标,不要依赖“通用”一刀切的修补技术。您应该事先知道多线程是否会妨碍特定目标的特定地址上的特定补丁,以及执行可靠的实时补丁的可行性。

如果您知道或怀疑您的目标代码是线程化的,请找到使用的同步方法(锁、互斥锁、互锁操作等)并从线程安全代码开始您的补丁,最好是在强制临时线程争用/死锁以防止执行时修补。可靠地执行此操作可能非常针对特定目标,因此至少需要对您的目标有一点相当深入的了解。

最重要的是:了解当前硬件上的哪些指令是原子指令。如果没有这些知识,您就不可能创建一个原子补丁。

然后,您会遇到进行一系列原子写入(原子指令)的问题,以便在补丁中执行不会以意外方式崩溃/挂起/更改执行。这不是一个需要解决的小问题。暂停进程并安全运行。

编辑:我刚刚意识到我上钩了,并以仅考虑挂钩(即修补)的方式回答,即使您的问题专门询问如何在执行期间以原子方式更改代码流。在大多数情况下,正确的 DLL 注入应该允许您非常可靠地执行此操作,但与往常一样,当您修改正在运行的进程时,这永远不是确定的事情。

这是一篇关于如何在 Windows 中实现热补丁较旧文章如果您希望它绝对原子地完成,则必须从内核模式完成。没有办法解决它。这是演练:

  • 将您的线程的 IRQL 设置为CLOCK1_LEVEL(哎呀,您甚至可以尝试HIGH_LEVEL),这远高于 2,这将在您的修补代码运行期间几乎停止该系统中的所有任务切换。它也足够抢占大多数中断。或者,您可以尝试使用CLI指令禁用它们

  • 还要在所有 CPU 上调度特定于 CPU 的 DPC,但您的线程使这些 DPC 保持忙碌。(这是在多核 CPU 的情况下需要的。)

这基本上将您的修补线程在短时间内变成了单线程环境。

  • 为了确保没有其他正在运行的线程在您应用 5 字节 JMP 的那一小段可执行内存上停止,请遍历每个线程的上下文并检查其 RIP 值。在不太可能发生重叠的情况下,要么取消补丁并在片刻后重试,要么在很短的时间内提高线程的 IRQL,然后再降低它。然后再检查。重复 N 次,直到它的 RIP 不与补丁重叠。

  • 最后贴上补丁。确保尽快完成。尽量不要调用任何外部函数。只需REP MOVS在准备好的内存块上快速完成即可。

  • 您可能需要清除处理器的指令缓存。(以防补丁之前的旧代码在那里。)

  • 然后撤消上述所有步骤,使系统恢复到工作状态。

附注。理论上应该有效。在实践中,调试这将是一个活生生的地狱。显然在 VM 中执行此操作并准备重新启动(很多)。


编辑: 这是一个实际示例,取自DebugView工具。如果您知道它的作用,它会尝试捕获程序的调试器输出。如果您在较旧的操作系统上启用内核调试器输出,则该工具别无选择,只能在 DebugView 启动时在实时系统上的DbgPrint函数上安装一个trampoline

这是它的工作方式(它使用了一些旧的内核函数,但它仍然提供了这个想法):

1.获取 CPU 核心数(它使用旧的KeNumberProcessors全局变量):

在此处输入图片说明

(对于现代代码,我可能会使用KeQueryActiveProcessors()其位掩码,并KeQueryGroupAffinity()考虑大于 32/64 的 CPU 数量。)

2.通过调用确保当前线程在 CPU core 0 上运行KeSetAffinityThread

在此处输入图片说明

(然后检查全局变量bDontSet_FuncTrampoline,以防我们不需要设置此蹦床并通过跳转到第 6 步来恢复线程关联。但这种情况并不有趣。)

3.然后检查我们是否只有一个 CPU 核,如果是,则跳到第 5 步。(也不是很有趣。)否则将全局变量设置nCountCpuCores为 CPU 核数,并将全局bAllowToContinue_CoreNon0_DeferredRoutine标志重置为 0:

在此处输入图片说明

4.设置每个CPU芯(除0以外,即,rsi从索引1,或0x8中指针开始于字节偏移量)为high importance毛乳头细胞(延迟过程调用),用于我们的DeferredRoutine与所述context集到CPU芯数:

在此处输入图片说明

这基本上会抢占其他内核正在做的任何事情,并将它们引导到我们的 DPC。

5.然后对于我们在核心 0 中运行的线程,执行 DPC 例程DeferredRoutine

在此处输入图片说明

6.之后将这个线程的亲和力恢复到以前的状态:

在此处输入图片说明


现在有趣的是发生在DeferredRoutine

(请注意,此例程将在每个 CPU 内核上执行。)

A.第一步,将线程的IRQL设置CLOCK_LEVEL(或13为 x64 代码)。这是通过使用cr8CPU 寄存器来完成的这样做将阻止处理大多数中断:

在此处输入图片说明

B.然后减少nCountCpuCores全局变量中的计数器,但使用lockCPU 前缀来确保所有 CPU 内核之间的同步:

在此处输入图片说明

C.检查这个线程在哪个 CPU 内核上运行并相应地进入一个自旋循环:

在此处输入图片说明

C.1. 上面代码流中的右侧块。)对于非 0 核,当全局变量bAllowToContinue_CoreNon0_DeferredRoutine为 0时,继续循环旋转

C.2. 上面代码流中的左侧块。)对于核心 0,在nCountCpuCores全局变量中处理的核心数未达到 0 时继续循环旋转

(我个人会在每个循环中添加一条pause指令,以确保 CPU 在“旋转”时不会浪费太多功率。)

C.3. 一旦条件 C.2.已经满足,这意味着我们拥有自己的核心 0,而所有其他核心都在 C.1 中忙于循环。我们可以通过调用我们的install_func_Trampoline函数来安装所需的蹦床。

C.4. 完成蹦床后,请记住从 C.1 中的自旋循环中释放所有内核。通过设置bAllowToContinue_CoreNon0_DeferredRoutine为 1。

D.最后,非常重要的是,将 IRQL 恢复到原来的样子:

在此处输入图片说明

nt_status如果是,则返回错误代码。否则,我们就完了!