内存里的原程序怎样变回可以运行的 exe
上一章已经沿着 UPX 解压存根找到原程序入口,并通过运行前后的字节变化确认:壳尾 0x4557E7 的跳转目标 0x442E44 是本例的 OEP。现在 x32dbg 中的进程已经能够执行恢复后的 Delphi 代码,但磁盘上的 CrackMe2.exe 仍然是原来的加壳文件。
这两种状态不能混为一谈:
进程内存
已经包含恢复后的代码、数据和解析完成的 API 地址
磁盘文件
仍保存 UPX 重新组织后的节、壳入口和不完整的原程序导入信息
如果只把某段内存原样保存到文件,Windows 不一定能够重新装载它。至少还要解决三个问题:
- 把进程中的虚拟内存布局转换成适合磁盘保存的 PE 节数据。
- 将 PE 入口从 UPX 壳入口改为已经确认的 OEP。
- 根据运行时 IAT 中的函数地址,重新建立 DLL 名称和 API 名称等导入结构。
本章继续使用上一章的样本和调试状态,只回答一个问题:
怎样把内存中已经恢复的程序保存为能够脱离调试器独立运行的 PE 文件?
整条路线如下:
x32dbg 停在 OEP
→ Scylla 搜索运行时 IAT
→ 解析 DLL 和 API
→ Dump 进程中的 PE 映像
→ 手工创建只含一个 API 的导入实验副本
→ 将重建的导入表写入 Dump 文件
→ 独立运行
→ 用 DIE、010 Editor 和 IDA 复核结果
本章不会分析 Serial 验证算法。目标只是得到一个结构能够被 Windows、IDA 和其他 PE 工具重新读取的脱壳文件。
样本来自第三方 CrackMe 合集。继续在虚拟机或其他隔离环境中操作,不要覆盖原始 CrackMe2.exe,也不要把生成的 Dump 文件当作可信程序传播。
先明确 Dump 和修复导入解决什么问题
Windows 装载原加壳文件时,先按节表把磁盘数据映射到内存。UPX 随后在当前进程中恢复代码和数据,所以内存中的节内容已经和磁盘文件不同。
上一章观察到两个直接变化:
0x401000:从全零变成 Delphi 类型数据
0x442E44:从压缩内容变成原程序入口代码
这里先把 UPX0 的变化按时间顺序连起来。原文件中的 UPX0 并不是“没有这个节”,而是只在内存中预留空间:
UPX0.VirtualSize = 0x35000
UPX0.SizeOfRawData = 0
Windows 因此会映射从 0x401000 开始的 0x35000 字节零初始化内存。随后,UPX 解压循环通过 EDI 从 0x401000 开始写入恢复结果。等程序到达壳尾时,这片内存早已被解压代码填充。
0x4557E6 popad 只负责从栈中恢复寄存器,不会向 UPX0 写入数据。硬件断点让调试器停在它后面的 0x4557E7,只是因为这个位置标志着解压和现场恢复已经结束;真正填充 UPX0 的工作发生在此前的解压循环中。随后在 OEP 进行 Dump,才把这些已经存在于内存中的内容保存到新文件。
Dump 要保存的就是这份已经变化的内存映像。但内存映像与磁盘 PE 还有两类差异。
第一类是地址布局。内存中的节按 SectionAlignment 映射,磁盘文件中的节则按 FileAlignment 排列。Dump 工具需要根据节表把内存内容重新放回适合磁盘读取的位置,而不是简单地从 0x400000 开始连续复制全部字节。
第二类是导入信息。程序装载后,IAT 槽位中保存的是本次运行解析出的 API 地址。现在还不知道完整 IAT 位于哪里,也不知道某个槽位具体对应哪个 DLL 和 API;这些关系要等后面的地址搜索和跟随操作实际确认。
运行时绝对地址只对当前进程有效。重新启动时,系统 DLL 可能被映射到其他地址,因此磁盘文件不能只保存这些运行时指针;它还需要记录 DLL 和 API 名称,让 Windows 装载器重新完成解析。
所以本章中的两个文件承担不同阶段:
| 文件 | 作用 |
|---|---|
CrackMe2_dump.exe | 保存恢复后的内存映像 |
CrackMe2_dump_SCY.exe | 在 Dump 基础上加入 Scylla 重建的导入结构 |
只有第二个文件才是本章要验证的最终结果。
在 OEP 打开 Scylla
重新开始实验时,先按上一章的方法运行到壳尾:
- 用 x32dbg 打开原始
CrackMe2.exe。 - 在
0x4557E7设置临时软件断点。 - 按 F9 运行到该断点。
- 删除临时断点,按一次 F7 执行
jmp 0x442E44。 - 确认当前 EIP 为
0x442E44,尚未执行的指令是push ebp。
本例使用的 x32dbg 发行包把 Scylla 放在工具栏中,以红底 S 图标打开;它不一定出现在 Plugins 菜单。不同发行包可能没有这个按钮,此时也可以单独运行 Scylla x86 并选择正在调试的进程。
必须等到 OEP 再打开 Dump 工具。过早 Dump 只能保存尚未恢复完整的代码和 IAT,得到的文件即使具有 PE 头,也不能代表原程序的可运行状态。
点击 S 后,Scylla 会自动附加到当前调试进程。本次界面显示:
Process = 2992 - CrackMe2.exe
ImageBase = 0x00400000
OEP = 0x00442E44
这里要注意 Scylla 当前界面的地址约定:OEP 输入框填写的是绝对 VA,因此显示 0x00442E44,不是 RVA 0x00042E44。两者的换算仍然是:
OEP RVA = OEP VA - ImageBase
= 0x442E44 - 0x400000
= 0x42E44
从运行时地址反查 IAT
现在只知道原程序入口,还不知道完整 IAT 的起点和长度。点击 IAT Autosearch,让 Scylla 扫描主模块中的连续 DWORD,检查哪些数值指向已加载 DLL 的导出函数。
Scylla 会用两种方法搜索 IAT:
- Normal Search:从 OEP 附近的代码开始,先寻找一条通过内存地址进行的间接
call或jmp,得到一个可能的 IAT 槽位;再以这个槽位为中心向前、向后扫描,根据空项和无效地址推测整片 IAT 的边界。 - Advanced Search:反汇编 OEP 附近的可执行内存,收集多条间接
call或jmp引用的地址槽位,过滤无效引用,再根据首尾候选槽位确定 IAT 范围。
可以把区别简化为:
Normal:找到一个候选槽位 → 向相邻内存扩展边界
Advanced:收集代码中的多处引用 → 根据首尾引用确定范围
“Advanced”不表示结果一定正确。高级搜索可能漏掉没有被当前反汇编覆盖、经过计算或混淆的引用;普通搜索则可能把 IAT 前后的空 DWORD 或相邻数据包含进来。两种结果不同时,应检查有差异的边界槽位,而不是只按算法名称选择。
OEP 正确,为什么仍可能找不到 IAT?
OEP 只是 Scylla 搜索代码的起点,不保证附近一定存在它能够识别的 IAT 引用。Normal Search 只会从 OEP 附近跟随有限的控制流,Advanced Search 则主要收集形如 call [地址]、jmp [地址] 的直接内存引用。
如果程序先用 mov 把 IAT 槽位读入寄存器再执行 call eax,通过 LoadLibrary、GetProcAddress 动态解析 API,把 IAT 拆成多个不连续区域,或者在更晚阶段才填写部分槽位,即使 OEP 正确,自动搜索也可能失败。
遇到这种情况,应先确认解压已经完成,再从间接调用或 API 解析过程追踪槽位地址,检查其中的值是否属于已加载 DLL,最后手动确定一个或多个 IAT 范围。Scylla 找不到 IAT 时应先复查 OEP 和 Dump 时机,但不能仅凭搜索失败就断定 OEP 错误。
本次搜索给出了两个略有差异的结果:
Advanced:VA 0x4450F0,RVA 0x450F0,Size 0x5A0
Normal: VA 0x4450EC,RVA 0x450EC,Size 0x5A4
Scylla 会询问是否采用高级搜索结果。两个起点只差 4 字节,因此不能只看名称选择;要检查多出来的 DWORD 是否真是 API 地址。
在 x32dbg 的 Dump 中分别查看这两个位置:
[0x4450EC] = 0x00000000
[0x4450F0] = 0x769441F0
第一项已经能够判断:0x4450EC 是空 DWORD,不能作为本次有效 IAT 块的起点。但 0x769441F0 目前只是一个非零地址,不能仅凭数值就断言它属于系统 DLL。
继续在 x32dbg 中验证第二项:
- 在 Dump 窗口选中
0x4450F0处的 DWORD。 - 右击并选择 Follow DWORD in Disassembler。
- CPU 窗口跳到
0x769441F0,窗口标题显示当前浏览的模块是kernel32.dll。 - 下方信息栏将该地址标记为
GetCurrentThreadId。 - 右侧 EIP 仍为
0x442E44,说明这个操作只跟随地址,没有执行目标代码。
现在才可以把观察结果连起来:
IAT 槽位 0x4450F0
→ 保存地址 0x769441F0
→ 地址位于 kernel32.dll
→ x32dbg 识别为 GetCurrentThreadId
0x4450EC 是 IAT 前面的空 DWORD,而 0x4450F0 是已经确认的第一个有效 API 槽位。回到 Scylla 的提示框,点击 是(Y),使用从 0x4450F0 开始的高级搜索结果;如果点击 否(N),Scylla 则会采用包含前导空 DWORD 的普通搜索结果。
IAT VA = 0x004450F0
IAT RVA = 0x000450F0
IAT Size = 0x000005A0
这里的方法可以迁移到其他样本:自动搜索出现边界差异时,回到内存检查边界槽位。有效 IAT 项通常指向已加载模块的可执行地址,而全零、明显的数据值或不属于任何模块的地址通常不应被直接并入当前块。
解析 DLL 和 API 名称
确认 IAT 范围后点击 Get Imports。Scylla 会读取每个槽位中的运行时地址,再到已加载模块的导出表中反查 DLL 和函数名称。
本次结果为:
Imports = 350
Invalid = 0
导入树中出现的主要模块包括:
| 模块 | 部分 API 示例 |
|---|---|
kernel32.dll | VirtualAlloc、CreateFileA、GetProcAddress |
user32.dll | CreateWindowExA、MessageBoxA、GetWindowTextA |
gdi32.dll | CreateFontIndirectA、BitBlt、DeleteObject |
advapi32.dll | RegOpenKeyExA、RegQueryValueExA |
oleaut32.dll | SysAllocStringLen、VariantClear |
ole32.dll | IsEqualGUID |
comctl32.dll | ImageList_Create、ImageList_Draw |
同一个 DLL 可能在树中出现多个分组。这是因为搜索范围中存在多个由空项分隔的 IAT 块,Scylla 按块保存结果;它不表示加载了多个不同的 kernel32.dll。本例所有分组均显示绿色勾,Invalid = 0,没有需要手工指定的未知函数。
Dump 前核对三个字段
Get Imports 完成后,本次界面仍显示 Imports = 350、Invalid = 0,OEP 也保持为 0x00442E44。点击 Dump 前,再把三个输入框与已经获得的证据核对一次:
OEP = 0x00442E44
VA = 0x004450F0
Size = 0x000005A0
这里不需要再次修改 OEP。核对的目的只是避免输错地址或在错误的进程状态下继续操作。
先 Dump,再修复导入
确认三个地址后点击 Dump,不要覆盖原始样本。本次保存为:
C:\crackme\Acid_Bytes.2\CrackMe2_dump.exe
Scylla 日志显示:
Dump success C:\crackme\Acid_Bytes.2\CrackMe2_dump.exe
这个操作把恢复后的代码和数据从进程内存整理回 PE 文件,但还没有把刚才解析出的 DLL 和 API 名称写入文件。先不要立即点击 Fix Dump,下面先检查这个中间文件,确认 Dump 到底改变了什么。
手工核对 Dump 后的 UPX0
用 010 Editor 分别打开原始 CrackMe2.exe 和 CrackMe2_dump.exe,展开 UPX0 的节表项。两个文件中的关键字段如下:
| 文件 | VirtualSize | SizeOfRawData | PointerToRawData |
|---|---|---|---|
原始 CrackMe2.exe | 0x35000 | 0 | 0x400 |
CrackMe2_dump.exe | 0x35000 | 0x35000 | 0x400 |
原文件的 SizeOfRawData = 0,所以文件偏移 0x400 后没有一段属于 UPX0 的原始节数据。Dump 文件则为它安排了 0x35000 字节磁盘空间,并把进程中 0x401000~0x435FFF 的当前内容写到从文件偏移 0x400 开始的位置。
这个变化可以直接串成下面的对应关系:
运行前:UPX0 在磁盘中没有数据,映射后由 Windows 补零
运行中:UPX 解压循环向 0x401000 开始的内存写入恢复结果
Dump 时:Scylla 把这段内存写入新文件,并把 SizeOfRawData 改为 0x35000
所以 UPX0 在 Dump 文件中有数据,不是因为 popad 填充了它。popad 只说明壳准备离开;Scylla 保存的是此前已经由解压循环写好的内存。
手工核对 Dump 后的入口
继续展开 OptionalHeader。CrackMe2_dump.exe 已经包含:
ImageBase = 0x400000
AddressOfEntryPoint = 0x42E44
将两者相加仍然得到:
0x400000 + 0x42E44 = 0x442E44
这说明 Scylla 的 Dump 不只是复制节内容,也使用刚才填写的 OEP VA 计算并写入了新的入口 RVA。原文件中的 0x55690 指向壳入口,现在则改为原程序入口 0x42E44。
手工检查为什么仍需修复导入
Dump 文件已经包含恢复后的代码和正确入口,但它的 Import Directory 仍沿用运行时恢复出的旧布局:
Import Directory RVA = 0x5704C
第一个 FirstThunk RVA = 0x570EC
这些 thunk 槽位里保存的是本次进程中的 API 绝对地址。例如解析工具会读到类似 0x76961CF0、0x76946780 的数值,却无法从这些数值还原磁盘加载时需要的 IMAGE_IMPORT_BY_NAME。换一次运行环境后,这些地址也不保证保持不变。
前面选择 IAT 搜索结果时,已经通过跟随地址确认了第一个槽位:
[0x4450F0] = 0x769441F0
根据当时已经完成的操作,可以不依赖 Scylla 的导入树记录出完整对应关系:
IAT 槽位 VA = 0x4450F0
IAT 槽位 RVA = 0x450F0
DLL = kernel32.dll
API = GetCurrentThreadId
但一个可重新装载的文件不能只保存本次运行中的 0x769441F0,而要保存下面这组稳定关系:
kernel32.dll
→ GetCurrentThreadId
→ 装载时把实际地址写入 RVA 0x450F0
手工重建一个导入至少要准备 DLL 名称、API 名称、IMAGE_IMPORT_BY_NAME、名称表项、IAT 槽位和 IMAGE_IMPORT_DESCRIPTOR。下面先在实验副本中真正写入这一项,而不是只观察 Scylla 的结果。
手工创建一个只含一个 API 的导入节
复制 CrackMe2_dump.exe,将副本命名为:
CrackMe2_manual_import.exe
这个副本只用于验证 PE 导入结构。它不会成为最终脱壳文件,因为原程序实际需要 350 个导入,而本实验只恢复 GetCurrentThreadId 一项。
CrackMe2_dump.exe 当前文件大小为 0x55E00。在 010 Editor 中向文件末尾追加 0x200 字节的零,使新文件大小变为:
0x55E00 + 0x200 = 0x56000
这 0x200 字节将作为新节 .MANIMP 的磁盘内容。先修改 PE 头中的字段:
| 文件偏移 | 字段 | 原值 | 新值 |
|---|---|---|---|
0x106 | FileHeader.NumberOfSections | 3 | 4 |
0x120 | OptionalHeader.SizeOfInitializedData | 0x2000 | 0x2200 |
0x150 | OptionalHeader.SizeOfImage | 0x58000 | 0x59000 |
0x180 | Import Directory RVA | 0x5704C | 0x58020 |
0x184 | Import Directory Size | 0x1A8 | 0x28 |
原来三个节表项结束于文件偏移 0x270。从这里写入第四个 IMAGE_SECTION_HEADER:
| 字段 | 值 |
|---|---|
Name | .MANIMP |
VirtualSize | 0x100 |
VirtualAddress | 0x58000 |
SizeOfRawData | 0x200 |
PointerToRawData | 0x55E00 |
PointerToRelocations | 0 |
PointerToLinenumbers | 0 |
NumberOfRelocations | 0 |
NumberOfLinenumbers | 0 |
Characteristics | 0x40000040 |
0x40000040 表示该节包含已初始化数据并且可读。新节从 RVA 0x58000 开始,占用不到 0x1000 字节,因此按 SectionAlignment = 0x1000 向上对齐后,SizeOfImage 变为 0x59000。
接下来填写新节中的内容。先在文件偏移 0x55E00 写入两项 INT:
文件偏移 0x55E00:DWORD 0x00058080
文件偏移 0x55E04:DWORD 0x00000000
第一项指向稍后创建的 IMAGE_IMPORT_BY_NAME,第二项是 INT 结束标记。
在文件偏移 0x55E20 写入第一个 IMAGE_IMPORT_DESCRIPTOR:
| 文件偏移 | 字段 | 写入值 |
|---|---|---|
0x55E20 | OriginalFirstThunk | 0x58000 |
0x55E24 | TimeDateStamp | 0 |
0x55E28 | ForwarderChain | 0 |
0x55E2C | Name | 0x58060 |
0x55E30 | FirstThunk | 0x450F0 |
从 0x55E34 开始的下一个 IMAGE_IMPORT_DESCRIPTOR 保持 20 字节全零,表示描述符数组结束。
然后写入 DLL 和 API 名称:
文件偏移 0x55E60:ASCII "kernel32.dll" + 00
文件偏移 0x55E80:WORD 0x0240
文件偏移 0x55E82:ASCII "GetCurrentThreadId" + 00
0x0240 是本次解析到的 Hint;真正决定导入目标的是后面的函数名称。最后,将原 IAT 槽位 RVA 0x450F0 换算到文件偏移:
UPX1.PointerToRawData + (IAT RVA - UPX1.VirtualAddress)
= 0x35400 + (0x450F0 - 0x36000)
= 0x444F0
把文件偏移 0x444F0 处的 DWORD 清零。Windows 装载这个实验副本时,会根据刚才写入的 DLL 和 API 名称,把当前环境中的 GetCurrentThreadId 地址写回该槽位。
保存后重新运行 PE 模板,应该能看到第四个节 .MANIMP。用 IDA 或其他 PE 解析器打开实验副本,还应得到:
Import Directory RVA = 0x58020
DLL = kernel32.dll
API = GetCurrentThreadId
FirstThunk RVA = 0x450F0
这才是一次真正的手工导入修复:从增加节、修改 Data Directory,到写入描述符、INT、名称和 IAT 关联,所有关键字段都由我们自己填写。实验副本只有一个导入,因此不要求它运行;验证工具能够解析出这一项,就说明结构已经连通。
用 Scylla 批量完成其余导入
回到未经手工修改的 CrackMe2_dump.exe。本例还有 349 个导入,逐项录入不会增加新的理解,只会重复刚才的字段填写。现在点击 Fix Dump,在文件选择窗口中选中 CrackMe2_dump.exe,让 Scylla 按同样的结构批量完成全部导入。
修复完成后,Scylla 生成:
C:\crackme\Acid_Bytes.2\CrackMe2_dump_SCY.exe
并在日志中显示:
Import Rebuild success C:\crackme\Acid_Bytes.2\CrackMe2_dump_SCY.exe
手工拆解重建后的第一个导入
Fix Dump 已经批量完成写入,但还不能只相信成功提示。现在用 010 Editor 打开 CrackMe2_dump_SCY.exe,手工沿着第一条导入关系走一遍,确认文件中确实保存了刚才记录的 kernel32.dll -> GetCurrentThreadId -> IAT RVA 0x450F0。
Scylla 新建的 .SCY 节具有下面的映射关系:
.SCY.VirtualAddress = 0x58000
.SCY.PointerToRawData = 0x55E00
第一个 IMAGE_IMPORT_DESCRIPTOR 位于 RVA 0x585A4。换算到文件偏移:
文件偏移 = 0x55E00 + (0x585A4 - 0x58000)
= 0x563A4
在这个位置按 5 个 DWORD 读取描述符:
| 字段 | RVA | 指向的内容 |
|---|---|---|
OriginalFirstThunk | 0x58000 | 导入名称表 INT |
TimeDateStamp | 0 | 未使用 |
ForwarderChain | 0 | 未使用 |
Name | 0x58694 | 字符串 kernel32.dll |
FirstThunk | 0x450F0 | 原程序运行时使用的第一个 IAT 槽位 |
接着跟随 OriginalFirstThunk = 0x58000。它正好位于 .SCY 起点,对应文件偏移 0x55E00。这里的第一个 DWORD 为:
0x000586A1
这个 RVA 指向第一项 IMAGE_IMPORT_BY_NAME。在 RVA 0x586A1 可以读到:
Hint = 0x0240
Name = "GetCurrentThreadId"
与此同时,FirstThunk = 0x450F0 指回前面手工检查过的 IAT 槽位。磁盘中的这个槽位可以先保持为零;Windows 装载文件时,会根据 kernel32.dll 和 GetCurrentThreadId 重新查询当前 API 地址,再把结果写入这里。
把整条关系连起来就是:
IMAGE_IMPORT_DESCRIPTOR @ RVA 0x585A4
├─ Name RVA 0x58694 → "kernel32.dll"
├─ OriginalFirstThunk RVA 0x58000
│ └─ 0x586A1 → Hint 0x0240 + "GetCurrentThreadId"
└─ FirstThunk RVA 0x450F0 → 由 Windows 装载时填写
这次手工操作已经走完了一个导入项从 DLL 名称、API 名称到 IAT 槽位的完整链条。Scylla 所做的批量修复,本质上就是为其余导入重复建立同样的结构,而不是把某次运行中的 API 绝对地址永久写死。
两个文件的大小和哈希如下:
| 文件 | 大小 | SHA-256 |
|---|---|---|
CrackMe2_dump.exe | 351744 字节 | 9337ec0524d789af9fdef6c1cf22e2832105c769f914cf157fcc7263047c0bf0 |
CrackMe2_dump_SCY.exe | 359424 字节 | b6a979a14b5f4f7aa2c2493076798737e43a9b97adcccf470a7c8ab86f73978f |
最终文件更大,是因为 Scylla 在 Dump 后追加了保存导入描述符、DLL 名称和 API 名称的新节。
验证文件能够脱离调试器运行
先保留 x32dbg 中暂停的原进程,再从资源管理器单独启动:
CrackMe2_dump_SCY.exe
本次文件能够正常显示 CrackMe 窗口,关闭后再次启动也成功。这一步同时验证了几件事:
Windows 能够重新映射 Dump 文件
→ 新入口 RVA 0x42E44 可以执行
→ 装载器能够读取重建后的导入表
→ 原程序启动所需的 API 地址得到重新填写
“能够运行”是最直接的行为验证,但不能单独说明 PE 中每个字段都已经恢复成原编译器的布局。下面继续用静态工具检查入口、节和导入信息。
用 DIE 识别重建痕迹
用 DIE 打开最终文件,可以看到:
文件大小:351.00 KiB
Compiler:Borland Delphi 4 Enterprise
Language:Object Pascal / Delphi
Tool:Scylla [reconstruct dump]
原加壳文件只有 132.00 KiB。脱壳文件变大,主要是因为原程序代码和数据已按内存中的展开形式保存,并增加了 Scylla 重建导入所需的数据。
DIE 仍然可能给出类似下面的启发式结果:
Section #0 ("UPX0") has RWX
Sections like UPX
这是因为 Dump 文件保留了 UPX0、UPX1 节名和宽松的页面权限。检测工具看到的是“仍然具有 UPX 外观”,不是证明当前文件里的代码仍处于压缩状态。判断脱壳是否成功,应综合入口、代码内容、导入结构和独立运行结果,不能只看一个壳签名。
用 PE 字段核对修复结果
最终文件仍以 0x400000 为 ImageBase,但入口已经改为:
AddressOfEntryPoint = RVA 0x42E44
入口 VA = 0x400000 + 0x42E44
= 0x442E44
Scylla 还增加了第四个节 .SCY,用于保存重建出的导入结构:
.SCY RVA = 0x58000
Import Directory RVA = 0x585A4
Import Directory Size = 0xF0
SizeOfImage = 0x5A000
重建后的 Import Directory 位于 .SCY,但每个描述符的 FirstThunk 仍指向原程序运行时使用的 IAT。例如第一个描述符记录:
DLL Name = kernel32.dll
FirstThunk = RVA 0x450F0
装载最终文件时,Windows 根据 .SCY 中的名称重新解析 API,再把当前进程中的函数地址写回从 RVA 0x450F0 开始的槽位。这正是“重建导入表”与“保存某次运行的绝对地址”之间的区别。
用 IDA 验证入口和完整导入
最后用 IDA 新建数据库并加载 CrackMe2_dump_SCY.exe,不要覆盖原加壳样本的 IDB。
IDA 可能提示:
The imports segment seems to be destroyed.
这是因为 Scylla 重建后的导入描述符和名称位于 .SCY,FirstThunk 则指向原来的运行时 IAT,布局不同于编译器直接生成的常规导入节。点击 OK 继续即可;本例中 IDA 仍能正确解析导入名称。
自动分析完成后,IDA 得到以下结果:
Entry Point = 0x442E44 start
Imports = 350
Total Functions = 2768
Named Functions = 453
Library Functions = 1499
Strings = 829
Imports 窗口从 0x4450F0 GetCurrentThreadId 开始,能够继续列出 VirtualAlloc、CreateFileA、CreateWindowExA、MessageBoxA 等完整 API。这与 Scylla 的 Imports = 350、Invalid = 0 相互吻合。
入口 0x442E44 也被直接识别为 start。反编译后的主线为:
void __noreturn start()
{
int result;
Sysinit::InitExe(dword_442D34);
sub_43E814(*off_443C54);
sub_43E450(*off_443C54, &str_Crackers_For_Fr[1]);
TApplication::CreateForm(*off_443C54, off_44294C, off_443D24);
result = Forms::TApplication::Run(*off_443C54);
System::Halt0(result);
}
这里已经不再是 UPX 解压循环,而是典型的 Delphi 程序启动过程:初始化运行库、创建窗体、进入消息循环,最后结束进程。它为上一章的 OEP 判断增加了独立的静态证据。
怎样判断一次手动脱壳是否完成
不要把“工具显示 Success”当成唯一标准。一次可复现的验证至少应覆盖四层:
| 验证层次 | 本例证据 |
|---|---|
| 运行时 | 壳尾跳转后 EIP 到达 0x442E44 |
| PE 结构 | 入口改为 RVA 0x42E44,新增 .SCY 和 Import Directory |
| 静态分析 | IDA 识别 350 个导入,并把 0x442E44 识别为 Delphi start |
| 行为 | CrackMe2_dump_SCY.exe 能够脱离调试器反复启动 |
只满足其中一项都可能留下问题:
- 只找到 OEP:内存里能执行,磁盘文件仍可能是壳。
- 只完成 Dump:代码已经保存,但导入名称可能缺失。
- 只修正入口:Windows 进入了正确代码,却可能无法解析 API。
- 只看到工具成功提示:输出文件仍需实际运行和静态复核。
本例四层证据相互吻合,因此可以把 CrackMe2_dump_SCY.exe 作为本章完成的脱壳结果。
为什么还保留 UPX0 和 UPX1 节名
本章目标是得到能够运行和分析的文件,不是恢复原编译器生成的每一个节名和节权限。Scylla 复用了原加壳 PE 的节表,再把恢复后的内存内容写回这些范围,所以 UPX0、UPX1 仍然存在。继续重命名节、收紧权限或重新排布数据属于 PE 清理,不是让本例恢复运行的必要步骤。
常见问题
在壳入口打开 Scylla
如果当前 EIP 仍是 0x455690,原程序代码和 IAT 尚未恢复完整。应先运行到壳尾并单步进入 0x442E44,再进行搜索和 Dump。
把 OEP VA 填成 RVA
本例集成版 Scylla 的 OEP 输入框使用绝对 VA,应填写 0x00442E44。PE 文件中的 AddressOfEntryPoint 才是 RVA 0x42E44。
Dump 前没有核对输入字段
Scylla 会直接使用界面中的 OEP、IAT VA 和 Size 生成文件。点击 Dump 前应确认它们分别为 0x00442E44、0x004450F0 和 0x5A0,但本次 Get Imports 并没有修改 OEP,也不需要重复填写。
直接运行没有修复导入的 Dump
CrackMe2_dump.exe 只保存恢复后的映像,Fix Dump 才会把 Scylla 解析出的 DLL 和 API 名称写入新文件。最终应验证带 _SCY 后缀的文件。
DIE 仍然报告 UPX
DIE 会根据 UPX0、UPX1 节名和 RWX 权限继续给出 UPX 启发式结果。应检查入口是否为 0x42E44、IDA 能否解析完整导入,以及文件能否独立运行。
小结
本章把上一章找到的 OEP 转换成了可以重新装载的磁盘文件:
EIP 停在 OEP VA 0x442E44
→ Scylla 找到 IAT VA 0x4450F0 / Size 0x5A0
→ 解析出 350 个有效导入
→ Dump 恢复后的内存映像
→ Fix Dump 新增 .SCY 并重建 Import Directory
→ PE 入口改为 RVA 0x42E44
→ 最终文件能够独立运行
→ DIE 和 IDA 识别出 Delphi 程序、完整导入和 start
手动脱壳的终点不是“看见 OEP”,也不是“Dump 按钮显示成功”,而是让运行时状态重新变成一个能够被 Windows 装载、被静态工具分析,并能重复验证行为的 PE 文件。
练习
-
已知 OEP VA 为
0x442E44、ImageBase 为0x400000,计算应写入 PEAddressOfEntryPoint的值。参考答案AddressOfEntryPoint保存 RVA:0x442E44 - 0x400000 = 0x42E44 -
为什么不能把运行时 IAT 中的 API 绝对地址直接当作永久导入信息?
参考答案系统 DLL 在不同进程或不同运行中可能被映射到其他地址。磁盘 PE 需要保存 DLL 名称、API 名称和导入描述符,让 Windows 装载器在每次启动时重新解析并填写 IAT。
-
IAT Autosearch 得到
0x4450EC和0x4450F0两个候选起点,怎样判断本例应选择哪一个?参考答案先检查两个地址中的 DWORD:
0x4450EC为全零,0x4450F0保存非零地址0x769441F0。再对后者执行 Follow DWORD in Disassembler,观察到目标位于kernel32.dll,并被识别为GetCurrentThreadId,因此 IAT 从0x4450F0开始。 -
为什么
CrackMe2_dump.exe和CrackMe2_dump_SCY.exe不是同一个阶段的结果?参考答案前者主要保存恢复后的内存映像;后者还加入了 Scylla 根据运行时 IAT 重建的导入描述符、DLL 名称和 API 名称,Windows 才能在重新启动时解析外部函数。
-
DIE 仍然根据
UPX0、UPX1节名报告 UPX,为什么不能据此判定脱壳失败?参考答案节名和权限只是启发式特征。最终文件已经使用 OEP RVA
0x42E44,IDA 能解析 350 个导入并识别 Delphi 启动代码,文件也能脱离调试器独立运行,这些证据更能直接说明恢复结果有效。