这篇文章是Retrowin32 系列文章的一部分。
演示场景程序有时会使用UPX等可执行压缩器进行打包。 EXE 加壳程序会摄取一个 EXE 并发出一个较小的 EXE,该 EXE 执行相同的操作。这对于满足演示竞赛文件大小限制非常有用。 (这是上周的一些 4kb 演示。4kb,包括音乐!光是这篇博文的文字就几乎是这个数字的两倍!)
从较高的层面来看,打包的可执行文件是一个微小的程序,在运行时将实际程序解压缩到内存中,然后运行它。从模拟器的角度来看,打包的可执行文件与常规可执行文件相同;它们都只是最终进行一些系统调用的程序。
但从调试运行打包的可执行文件时出现问题的角度来看,所有有趣的可执行状态(甚至包括它调用的系统函数)都只是在可执行文件解压后才会显示出来。因此,能够解压打包的可执行文件并重建原始程序非常有用,然后您可以将其输入到像 Ghidra 这样的程序分析器中。 (像 UPX 这样的工具甚至可以自行解压,就像 zip 实用程序也提供解压功能一样。)
但是如果您的程序没有使用 UPX 打包怎么办?或者(我遇到过更多)如果它包含了足够旧的 UPX 版本,当前的 UPX 将不再解压它怎么办?
模拟器可以将解包逻辑视为黑匣子并执行它。如果您在此之后立即暂停程序,则可以获取内存中的解压状态并将其转储回新的 exe。这个exe看起来就像一个普通的程序,可以进行分析。
我最近在retrowin32中实现了这个想法,并成功转储了至少一个打包的exe。这篇文章讨论了它是如何工作的。
问题一:寻找main
第一步是找出要转储的执行点。理想情况下,您可以在可执行文件解压后但在其任何主要代码运行之前获取内存状态,但那到底在哪里呢?
打包的 exe 看起来像:
entry_point: ; ...some loop to unpack things here jmp some_address ; jmp to the unpacked code
如果将打包的 exe 加载到像 Ghidra 这样的工具中,则 jmp 看起来像是跳转到垃圾地址,因为该跳转另一侧的代码仅在解包逻辑运行后才存在。我想你可能会因此而自动发现它。
现在,由于我首先深入分析这些可执行文件,假设我只是手动识别我们想要中断的some_address
地址。
下一个问题是您不能只在该点设置一个普通的断点。软件断点的工作原理是使用int3
指令修补目标,但如果我们在启动时设置其中一个断点,它就会被解包循环覆盖。因此,我可以在解包代码的最后一条指令(上例中的jmp
)处设置一个断点,然后单步执行一次(跟随jmp
)。
这也适用于我见过的另一种类型的加壳器,它生成如下代码:
entry_point: ; ...some loop to unpack things here, ; including: push some_address ; ...more asm ret ; jmps to some_address
在ret
上设置断点比查找它恰好跳转到哪个地址更容易。
从该状态我可以通过转储当前内存状态来创建一个.exe
文件,并正确设置 exe 的entry_point
,并且一切正常……除了一个重要的部分。
问题二:重建导入
为了最小化文件大小,打包的可执行文件仅声明对最小系统函数集的依赖关系。解包过程解压缩底层可执行文件的所有实际依赖项的列表,并动态加载这些依赖项,以便在底层可执行文件启动后可以调用它们。
为了离线静态分析的目的,我们需要解决这些动态依赖关系。我们希望将可执行文件加载到 Ghidra 中并让它了解它调用哪些系统函数。
要了解如何管理此问题,您需要了解一些合理的程序如何解决导入问题。我之前写过相关内容,包括一些图片。我将重新总结此处相关的广泛描述。
调用GetStartupInfoA()
等系统函数的常规程序将通过导入地址表(“IAT”)进行调用。在汇编中,它看起来像:
call [imp_GetStartupInfoA] ; where imp_GetStartupInfoA is a fixed address
也就是说,每个调用站点都会说“调用在这个固定地址(IAT 内的一个点)找到的地址”。可执行加载程序读取命名导入列表(称为导入目录表(“IDT”))并用解析的地址填充 IAT。
打包的可执行文件的 IDT 几乎是空的,因为所有有趣的工作都发生在解包时。但是加壳程序启动的底层可执行文件有自己的 IAT;打包机将其填充作为拆包的一部分。为了扭转这种情况,在我们解压的可执行文件中,我们需要重建我们自己的 IDT,它指向 IAT,以便 Ghidra 相信它。
那个IAT在哪里?我们不知道。从模拟器的角度来看,解包代码进行了大量的工作,最终进行了一系列调用,例如:
hmodule = LoadLibrary("ddraw.dll") func = GetProcAddress(hmodule, "DirectDrawCreate") ... stash func in the IAT of the in-memory unpacked executable
不幸的是,如何遵循“隐藏”操作并不明显。 (在写这篇文章时,我想到也许我们可以观察所有内存写入?)但是我们可以观察这些对GetProcAddress
调用并记录我们发出的地址,然后在转储时我们可以扫描程序的内存以查找这些值的最终位置。 (这类似于 exe 转储工具Scylla尝试解决此问题的方式。但 Scylla 无法通过使用模拟器来作弊。)
我们希望每个值在解压的可执行文件的 IAT 中仅在内存中显示一次。 (如果由于意外与某些不相关的字节序列发生冲突而在内存中多次找到一个值怎么办?这还没有出现,但我的一个想法是我可以使用不同的内存布局模拟解包序列两次,以便GetProcAddress
调用返回不同的值,然后比较两者是否重叠。)
一旦我们有了 IAT 地址,我们就可以构造一个 IDT,告诉加载器哪些函数对应于哪些地址,并将新的 IDT 作为附加数据填充到我们的可执行文件中。通过对 exe 标头进行一些细微调整,我现在有了一个llvm-objdump 可以理解的exe 解包程序,并且 Ghidra 通过系统函数调用完成渲染,例如:
00428c37 6a 05 PUSH 0x5 00428c39 50 PUSH EAX 00428c3a ff 15 b0 CALL dword ptr [->USER32.DLL::ShowWindow] 20 43 00 00428c40 ff 76 10 PUSH dword ptr [ESI + 0x10] 00428c43 ff 15 ac CALL dword ptr [->USER32.DLL::UpdateWindow] 20 43 00
原文: https://neugierig.org/software/blog/2025/04/unpacking.html