跳到内容
Shiny's Blog

Dump、IAT 修复与脱壳验证

内存里的原程序怎样变回可以运行的 exe

上一章已经沿着 UPX 解压存根找到原程序入口,并通过运行前后的字节变化确认:壳尾 0x4557E7 的跳转目标 0x442E44 是本例的 OEP。现在 x32dbg 中的进程已经能够执行恢复后的 Delphi 代码,但磁盘上的 CrackMe2.exe 仍然是原来的加壳文件。

这两种状态不能混为一谈:

进程内存
已经包含恢复后的代码、数据和解析完成的 API 地址

磁盘文件
仍保存 UPX 重新组织后的节、壳入口和不完整的原程序导入信息

如果只把某段内存原样保存到文件,Windows 不一定能够重新装载它。至少还要解决三个问题:

  1. 把进程中的虚拟内存布局转换成适合磁盘保存的 PE 节数据。
  2. 将 PE 入口从 UPX 壳入口改为已经确认的 OEP。
  3. 根据运行时 IAT 中的函数地址,重新建立 DLL 名称和 API 名称等导入结构。

本章继续使用上一章的样本和调试状态,只回答一个问题:

怎样把内存中已经恢复的程序保存为能够脱离调试器独立运行的 PE 文件?

整条路线如下:

x32dbg 停在 OEP
→ Scylla 搜索运行时 IAT
→ 解析 DLL 和 API
→ Dump 进程中的 PE 映像
→ 手工创建只含一个 API 的导入实验副本
→ 将重建的导入表写入 Dump 文件
→ 独立运行
→ 用 DIE、010 Editor 和 IDA 复核结果

本章不会分析 Serial 验证算法。目标只是得到一个结构能够被 Windows、IDA 和其他 PE 工具重新读取的脱壳文件。

Warning

样本来自第三方 CrackMe 合集。继续在虚拟机或其他隔离环境中操作,不要覆盖原始 CrackMe2.exe,也不要把生成的 Dump 文件当作可信程序传播。

先明确 Dump 和修复导入解决什么问题

Windows 装载原加壳文件时,先按节表把磁盘数据映射到内存。UPX 随后在当前进程中恢复代码和数据,所以内存中的节内容已经和磁盘文件不同。

上一章观察到两个直接变化:

0x401000:从全零变成 Delphi 类型数据
0x442E44:从压缩内容变成原程序入口代码

这里先把 UPX0 的变化按时间顺序连起来。原文件中的 UPX0 并不是“没有这个节”,而是只在内存中预留空间:

UPX0.VirtualSize   = 0x35000
UPX0.SizeOfRawData = 0

Windows 因此会映射从 0x401000 开始的 0x35000 字节零初始化内存。随后,UPX 解压循环通过 EDI0x401000 开始写入恢复结果。等程序到达壳尾时,这片内存早已被解压代码填充。

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

重新开始实验时,先按上一章的方法运行到壳尾:

  1. 用 x32dbg 打开原始 CrackMe2.exe
  2. 0x4557E7 设置临时软件断点。
  3. F9 运行到该断点。
  4. 删除临时断点,按一次 F7 执行 jmp 0x442E44
  5. 确认当前 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 附近的代码开始,先寻找一条通过内存地址进行的间接 calljmp,得到一个可能的 IAT 槽位;再以这个槽位为中心向前、向后扫描,根据空项和无效地址推测整片 IAT 的边界。
  • Advanced Search:反汇编 OEP 附近的可执行内存,收集多条间接 calljmp 引用的地址槽位,过滤无效引用,再根据首尾候选槽位确定 IAT 范围。

可以把区别简化为:

Normal:找到一个候选槽位 → 向相邻内存扩展边界
Advanced:收集代码中的多处引用 → 根据首尾引用确定范围

“Advanced”不表示结果一定正确。高级搜索可能漏掉没有被当前反汇编覆盖、经过计算或混淆的引用;普通搜索则可能把 IAT 前后的空 DWORD 或相邻数据包含进来。两种结果不同时,应检查有差异的边界槽位,而不是只按算法名称选择。

OEP 正确,为什么仍可能找不到 IAT?

OEP 只是 Scylla 搜索代码的起点,不保证附近一定存在它能够识别的 IAT 引用。Normal Search 只会从 OEP 附近跟随有限的控制流,Advanced Search 则主要收集形如 call [地址]jmp [地址] 的直接内存引用。

如果程序先用 mov 把 IAT 槽位读入寄存器再执行 call eax,通过 LoadLibraryGetProcAddress 动态解析 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 中验证第二项:

  1. 在 Dump 窗口选中 0x4450F0 处的 DWORD。
  2. 右击并选择 Follow DWORD in Disassembler
  3. CPU 窗口跳到 0x769441F0,窗口标题显示当前浏览的模块是 kernel32.dll
  4. 下方信息栏将该地址标记为 GetCurrentThreadId
  5. 右侧 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.dllVirtualAllocCreateFileAGetProcAddress
user32.dllCreateWindowExAMessageBoxAGetWindowTextA
gdi32.dllCreateFontIndirectABitBltDeleteObject
advapi32.dllRegOpenKeyExARegQueryValueExA
oleaut32.dllSysAllocStringLenVariantClear
ole32.dllIsEqualGUID
comctl32.dllImageList_CreateImageList_Draw

同一个 DLL 可能在树中出现多个分组。这是因为搜索范围中存在多个由空项分隔的 IAT 块,Scylla 按块保存结果;它不表示加载了多个不同的 kernel32.dll。本例所有分组均显示绿色勾,Invalid = 0,没有需要手工指定的未知函数。

Dump 前核对三个字段

Get Imports 完成后,本次界面仍显示 Imports = 350Invalid = 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.exeCrackMe2_dump.exe,展开 UPX0 的节表项。两个文件中的关键字段如下:

文件VirtualSizeSizeOfRawDataPointerToRawData
原始 CrackMe2.exe0x3500000x400
CrackMe2_dump.exe0x350000x350000x400

原文件的 SizeOfRawData = 0,所以文件偏移 0x400 后没有一段属于 UPX0 的原始节数据。Dump 文件则为它安排了 0x35000 字节磁盘空间,并把进程中 0x401000~0x435FFF 的当前内容写到从文件偏移 0x400 开始的位置。

这个变化可以直接串成下面的对应关系:

运行前:UPX0 在磁盘中没有数据,映射后由 Windows 补零
运行中:UPX 解压循环向 0x401000 开始的内存写入恢复结果
Dump 时:Scylla 把这段内存写入新文件,并把 SizeOfRawData 改为 0x35000

所以 UPX0 在 Dump 文件中有数据,不是因为 popad 填充了它。popad 只说明壳准备离开;Scylla 保存的是此前已经由解压循环写好的内存。

手工核对 Dump 后的入口

继续展开 OptionalHeaderCrackMe2_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 绝对地址。例如解析工具会读到类似 0x76961CF00x76946780 的数值,却无法从这些数值还原磁盘加载时需要的 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 头中的字段:

文件偏移字段原值新值
0x106FileHeader.NumberOfSections34
0x120OptionalHeader.SizeOfInitializedData0x20000x2200
0x150OptionalHeader.SizeOfImage0x580000x59000
0x180Import Directory RVA0x5704C0x58020
0x184Import Directory Size0x1A80x28

原来三个节表项结束于文件偏移 0x270。从这里写入第四个 IMAGE_SECTION_HEADER

字段
Name.MANIMP
VirtualSize0x100
VirtualAddress0x58000
SizeOfRawData0x200
PointerToRawData0x55E00
PointerToRelocations0
PointerToLinenumbers0
NumberOfRelocations0
NumberOfLinenumbers0
Characteristics0x40000040

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

文件偏移字段写入值
0x55E20OriginalFirstThunk0x58000
0x55E24TimeDateStamp0
0x55E28ForwarderChain0
0x55E2CName0x58060
0x55E30FirstThunk0x450F0

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指向的内容
OriginalFirstThunk0x58000导入名称表 INT
TimeDateStamp0未使用
ForwarderChain0未使用
Name0x58694字符串 kernel32.dll
FirstThunk0x450F0原程序运行时使用的第一个 IAT 槽位

接着跟随 OriginalFirstThunk = 0x58000。它正好位于 .SCY 起点,对应文件偏移 0x55E00。这里的第一个 DWORD 为:

0x000586A1

这个 RVA 指向第一项 IMAGE_IMPORT_BY_NAME。在 RVA 0x586A1 可以读到:

Hint = 0x0240
Name = "GetCurrentThreadId"

与此同时,FirstThunk = 0x450F0 指回前面手工检查过的 IAT 槽位。磁盘中的这个槽位可以先保持为零;Windows 装载文件时,会根据 kernel32.dllGetCurrentThreadId 重新查询当前 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.exe351744 字节9337ec0524d789af9fdef6c1cf22e2832105c769f914cf157fcc7263047c0bf0
CrackMe2_dump_SCY.exe359424 字节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 文件保留了 UPX0UPX1 节名和宽松的页面权限。检测工具看到的是“仍然具有 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 重建后的导入描述符和名称位于 .SCYFirstThunk 则指向原来的运行时 IAT,布局不同于编译器直接生成的常规导入节。点击 OK 继续即可;本例中 IDA 仍能正确解析导入名称。

自动分析完成后,IDA 得到以下结果:

Entry Point     = 0x442E44 start
Imports         = 350
Total Functions = 2768
Named Functions = 453
Library Functions = 1499
Strings         = 829

Imports 窗口从 0x4450F0 GetCurrentThreadId 开始,能够继续列出 VirtualAllocCreateFileACreateWindowExAMessageBoxA 等完整 API。这与 Scylla 的 Imports = 350Invalid = 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 的节表,再把恢复后的内存内容写回这些范围,所以 UPX0UPX1 仍然存在。继续重命名节、收紧权限或重新排布数据属于 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 前应确认它们分别为 0x00442E440x004450F00x5A0,但本次 Get Imports 并没有修改 OEP,也不需要重复填写。

直接运行没有修复导入的 Dump

CrackMe2_dump.exe 只保存恢复后的映像,Fix Dump 才会把 Scylla 解析出的 DLL 和 API 名称写入新文件。最终应验证带 _SCY 后缀的文件。

DIE 仍然报告 UPX

DIE 会根据 UPX0UPX1 节名和 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 文件。

练习

  1. 已知 OEP VA 为 0x442E44、ImageBase 为 0x400000,计算应写入 PE AddressOfEntryPoint 的值。

    参考答案

    AddressOfEntryPoint 保存 RVA:

    0x442E44 - 0x400000 = 0x42E44
  2. 为什么不能把运行时 IAT 中的 API 绝对地址直接当作永久导入信息?

    参考答案

    系统 DLL 在不同进程或不同运行中可能被映射到其他地址。磁盘 PE 需要保存 DLL 名称、API 名称和导入描述符,让 Windows 装载器在每次启动时重新解析并填写 IAT。

  3. IAT Autosearch 得到 0x4450EC0x4450F0 两个候选起点,怎样判断本例应选择哪一个?

    参考答案

    先检查两个地址中的 DWORD:0x4450EC 为全零,0x4450F0 保存非零地址 0x769441F0。再对后者执行 Follow DWORD in Disassembler,观察到目标位于 kernel32.dll,并被识别为 GetCurrentThreadId,因此 IAT 从 0x4450F0 开始。

  4. 为什么 CrackMe2_dump.exeCrackMe2_dump_SCY.exe 不是同一个阶段的结果?

    参考答案

    前者主要保存恢复后的内存映像;后者还加入了 Scylla 根据运行时 IAT 重建的导入描述符、DLL 名称和 API 名称,Windows 才能在重新启动时解析外部函数。

  5. DIE 仍然根据 UPX0UPX1 节名报告 UPX,为什么不能据此判定脱壳失败?

    参考答案

    节名和权限只是启发式特征。最终文件已经使用 OEP RVA 0x42E44,IDA 能解析 350 个导入并识别 Delphi 启动代码,文件也能脱离调试器独立运行,这些证据更能直接说明恢复结果有效。


目录