ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从UPX脱壳机到自动化流水线:手写最小脱壳机与逆向工程实践

从UPX脱壳机到自动化流水线:手写最小脱壳机与逆向工程实践 简介UPX.rar脱壳机是一份面向逆向工程初学者与恶意软件分析人员的小型工具源码包用于解除UPX打包外壳、还原被压缩可执行文件的原始代码与行为。UPX作为知名可执行文件压缩器常被用于减小体积或为程序加壳而脱壳则是安全研究、反病毒分析中揭示程序真实逻辑的必要环节。资源共16个文件约110KB以C源码cpp、h为核心辅以VC工程文件dsp、dsw、资源脚本rc、图标位图ico、bmp、批处理脚本bat及说明文档txt、nfo构成一套可编译、可参考的完整脱壳实现。已有403人学习下载读者可从中理解文件头识别、内存映射、外壳解码、原始数据恢复与PE重布局修复等关键流程并借助工程文件自行编译调试为后续静态分析、动态调试与反汇编学习打下基础。1. 从“UPX.rar脱壳机”说起一个被误读的搜索词背后到底在找什么有人搜“UPX.rar脱壳机”大概率不是想找一个叫“UPX.rar”的压缩包而是手里正好有一个被 UPX 加壳的可执行文件文件名可能带 .rar 后缀也可能是在某个 rar 压缩包里翻出来的想把它还原成原始二进制。UPX 本身是开源可执行文件压缩器压缩后的 PE、ELF、Mach-O 文件运行时会在内存里自解压壳的特征非常明显所以它既是加壳工具也是脱壳入门最好的练手对象。这篇文章面向三类人做逆向分析需要还原样本的、做安全检测需要看原始代码的、以及单纯想搞明白“脱壳机”到底怎么写的工程师。我会从 UPX 壳的识别讲起落到手动脱壳、脚本脱壳、以及自己写一个最小脱壳机的完整路径中间穿插参数、偏移计算和踩坑记录。你不需要有现成的“UPX.rar脱壳机”工具跟着走能自己造一个出来。2. UPX 壳的识别与手工脱壳先看懂再动手2.1 UPX 加壳后的文件长什么样拿到一个疑似 UPX 加壳的文件第一步不是急着脱而是确认它到底是不是 UPX。UPX 加壳后会留下非常明显的痕迹最直接的是节区名。用objdump或 PE 工具查看节表通常会看到UPX0、UPX1有时还有UPX2。UPX0一般是未初始化数据段原始大小很大但文件里不占空间UPX1存放压缩后的原始代码和自解压 stub。另一个特征是入口点。原始程序的入口点通常在.text段加壳后入口点会跳到UPX1段里指向一段自解压代码。用 Python 快速看一眼节区名和入口点比开图形工具更适合批量处理import pefile def inspect_upx(path): pe pefile.PE(path) print(fEntryPoint: {hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)}) for section in pe.sections: name section.Name.rstrip(b\x00).decode(errorsignore) print(fSection: {name:8s} fVA: {hex(section.VirtualAddress):10s} fVSize: {hex(section.Misc_VirtualSize):10s} fRawSize: {hex(section.SizeOfRawData):10s}) pe.close() inspect_upx(sample.exe)这段代码用pefile解析 PE 结构打印入口点和每个节区的虚拟地址、虚拟大小、原始大小。判断依据如果看到UPX0的VirtualSize远大于SizeOfRawData而UPX1的SizeOfRawData接近文件实际大小基本可以确认是 UPX。参数上不需要额外配置pefile会自动处理对齐。注意Name字段是 8 字节定长必须rstrip掉\x00再解码否则会带一堆空字符。2.2 手工脱壳的核心找到 OEP 并 dump手工脱壳的本质是让程序自己跑完解压 stub在跳到原始入口点OEP的那一刻把内存镜像 dump 下来。UPX 的 stub 逻辑很固定先解压UPX1里的数据到UPX0然后修复导入表最后jmp到 OEP。用调试器x64dbg、gdb 都行加载样本在UPX1段入口下断点单步跟到一段长跳转跳转目标落在UPX0段内且地址比较靠前那个目标大概率就是 OEP。手工操作步骤用调试器打开样本停在系统断点后在UPX1段起始地址下硬件断点。运行到断点开始单步重点关注popad之后的jmp或pushret。当跳转目标落在UPX0段且该地址处代码看起来像正常函数序言push ebp/mov ebp, esp或 x64 的sub rsp, xx记录该地址为 OEP。在 OEP 处下断点运行到 OEP然后用调试器的 dump 功能把整个进程镜像保存下来。用导入表修复工具如 Scylla重建 IAT因为 UPX 解压后导入表可能被重定向过。这里有个血泪经验不要一上来就 dump必须确认程序已经完成解压。判断方法是看UPX0段的内存是否已经被写入数据。如果UPX0还是全零或全是CC说明 stub 还没跑完dump 出来的是废壳。2.3 用 upx -d 直接脱最省事但有限制如果样本就是标准 UPX 加壳没有二次修改最省事的方法是用 UPX 自带的-d参数upx -d sample.exe -o sample_unpacked.exe这条命令让 UPX 自己执行解压逻辑并写出原始文件。参数-d表示解压-o指定输出文件名。逻辑上 UPX 会读取壳里的压缩数据按内置算法还原然后重建文件头。限制在于如果加壳时用了--ultra-brute或修改过 stub-d可能报CantUnpackException。另外如果文件被额外加密或节区名被改过-d也会失败。所以upx -d适合标准样本遇到变种就得回到手工或脚本方案。3. 写一个最小 UPX 脱壳机从内存 dump 到文件重建3.1 脱壳机的整体架构自己写脱壳机核心就三件事启动目标进程并让它运行到 OEP、在 OEP 处 dump 内存、把 dump 下来的内存重建为可执行文件。听起来简单但每一步都有细节。启动进程可以用CreateProcess带CREATE_SUSPENDED标志这样进程创建后主线程挂起我们可以先设置断点再恢复。找 OEP 可以用内存断点或单步陷阱但更稳的方式是扫描UPX1段里的跳转指令或者直接在UPX0段下写入断点等解压完成触发。下面是一个用 Python 配合ctypes调用 Windows API 的骨架展示如何挂起启动并读取内存import ctypes from ctypes import wintypes kernel32 ctypes.WinDLL(kernel32, use_last_errorTrue) CREATE_SUSPENDED 0x00000004 PROCESS_ALL_ACCESS 0x1F0FFF class STARTUPINFO(ctypes.Structure): _fields_ [ (cb, wintypes.DWORD), (lpReserved, wintypes.LPWSTR), (lpDesktop, wintypes.LPWSTR), (lpTitle, wintypes.LPWSTR), (dwX, wintypes.DWORD), (dwY, wintypes.DWORD), (dwXSize, wintypes.DWORD), (dwYSize, wintypes.DWORD), (dwXCountChars, wintypes.DWORD), (dwYCountChars, wintypes.DWORD), (dwFillAttribute, wintypes.DWORD), (dwFlags, wintypes.DWORD), (wShowWindow, wintypes.WORD), (cbReserved2, wintypes.WORD), (lpReserved2, ctypes.POINTER(ctypes.c_byte)), (hStdInput, wintypes.HANDLE), (hStdOutput, wintypes.HANDLE), (hStdError, wintypes.HANDLE), ] class PROCESS_INFORMATION(ctypes.Structure): _fields_ [ (hProcess, wintypes.HANDLE), (hThread, wintypes.HANDLE), (dwProcessId, wintypes.DWORD), (dwThreadId, wintypes.DWORD), ] def launch_suspended(path): si STARTUPINFO() si.cb ctypes.sizeof(si) pi PROCESS_INFORMATION() ok kernel32.CreateProcessW( None, path, None, None, False, CREATE_SUSPENDED, None, None, ctypes.byref(si), ctypes.byref(pi) ) if not ok: raise ctypes.WinError(ctypes.get_last_error()) return pi pi launch_suspended(sample.exe) print(fPID: {pi.dwProcessId}, Thread: {pi.dwThreadId})这段代码用CreateProcessW以挂起方式启动目标拿到进程句柄和线程句柄。参数CREATE_SUSPENDED是关键它让主线程不执行给我们时间读取 PE 头、计算 OEP 候选地址、设置断点。PROCESS_INFORMATION里的hProcess后续用于ReadProcessMemoryhThread用于ResumeThread。注意STARTUPINFO的字段顺序必须和 Windows 头文件一致否则CreateProcessW会失败并返回奇怪的错误码。3.2 定位 OEP 的两种实用方法方法一内存写入断点。UPX 解压时会把数据写入UPX0段我们可以在UPX0段起始地址处设置一个硬件写入断点。当 stub 第一次写入该区域时触发然后继续单步直到跳转到UPX0段内的非写入地址。这个方法需要调试器支持纯 API 实现较复杂但可以用VirtualProtect把UPX0段设为只读触发异常后捕获再改回可写。这种方法叫“守卫页”技巧。方法二扫描跳转指令。UPX stub 的最后通常是一个jmp或pushret目标地址在UPX0段内。我们可以在UPX1段的内存里搜索E9jmp rel32或FF 25jmp [mem]指令计算目标地址筛选落在UPX0范围内的候选。下面是一个扫描示例import struct def find_oep_candidates(process_handle, upx1_base, upx1_size, upx0_base, upx0_size): buffer ctypes.create_string_buffer(upx1_size) bytes_read ctypes.c_size_t(0) kernel32.ReadProcessMemory( process_handle, ctypes.c_void_p(upx1_base), buffer, upx1_size, ctypes.byref(bytes_read) ) data buffer.raw[:bytes_read.value] candidates [] for i in range(len(data) - 5): if data[i] 0xE9: rel struct.unpack(i, data[i1:i5])[0] target upx1_base i 5 rel if upx0_base target upx0_base upx0_size: candidates.append((hex(upx1_base i), hex(target))) elif data[i] 0xFF and data[i1] 0x25: ptr struct.unpack(I, data[i2:i6])[0] # 需要读取 ptr 处的值略去细节 pass return candidates这段代码读取UPX1段内存遍历字节找E9相对跳转计算目标地址筛选落在UPX0范围内的。参数upx1_base和upx0_base需要从 PE 节表里解析出来。注意ReadProcessMemory的缓冲区大小要匹配upx1_size如果段很大可能一次读不完需要分块。找到候选后在候选地址下断点运行到该地址确认代码特征即可认定为 OEP。3.3 dump 内存并重建 PE 文件找到 OEP 后dump 的范围通常是整个模块的镜像从ImageBase到ImageBase SizeOfImage。用ReadProcessMemory一次性读出然后按 PE 节表把各节区的原始数据写回文件。关键点内存中的节区按VirtualAddress对齐文件中的节区按FileAlignment对齐重建时需要把内存数据按文件对齐截断或填充。导入表也需要修复因为 dump 下来的 IAT 可能指向的是解压后的地址需要用工具重建。重建的核心代码逻辑def dump_process_image(process_handle, image_base, size_of_image, output_path): buffer ctypes.create_string_buffer(size_of_image) bytes_read ctypes.c_size_t(0) ok kernel32.ReadProcessMemory( process_handle, ctypes.c_void_p(image_base), buffer, size_of_image, ctypes.byref(bytes_read) ) if not ok: raise ctypes.WinError(ctypes.get_last_error()) with open(output_path, wb) as f: f.write(buffer.raw[:bytes_read.value]) print(fDumped {bytes_read.value} bytes to {output_path})这段代码把整个镜像从内存读到缓冲区然后原样写入文件。参数image_base从 PE 可选头的ImageBase字段获取size_of_image从SizeOfImage获取。注意这样 dump 出来的文件节区对齐还是内存对齐需要用 PE 编辑工具修正PointerToRawData和SizeOfRawData或者用pefile重新写节表。更稳妥的做法是解析节表逐节 dump 并按文件对齐写入。4. 避坑与排查脱壳路上最容易翻车的五个点4.1 现象upx -d 报错 CantUnpackException原因样本不是标准 UPX 加壳可能用了修改版 stub、加了额外加密层或者节区名被改过。解决先用节区名和入口点特征确认是否 UPX 变种。如果是变种放弃-d走手工或脚本方案。可以尝试用upx -d --force但成功率不高且可能产出损坏文件。4.2 现象dump 出来的文件无法运行提示“不是有效的 Win32 应用程序”原因dump 时程序还没运行到 OEP内存里还是压缩数据或者 dump 范围不对漏掉了部分节区。解决确认 OEP 处代码已经是解压后的正常代码检查UPX0段是否已被写入。dump 范围用SizeOfImage而不是文件大小。重建后检查节表对齐。4.3 现象脱壳后程序能运行但导入表全是乱码原因UPX 解压后会重建导入表但 dump 下来的 IAT 指向的是内存地址直接写回文件后地址失效。解决用 Scylla 或ImpRec重建导入表或者在脱壳机里实现 IAT 修复逻辑遍历导入描述符把内存地址转换为文件偏移。4.4 现象调试器一附加就检测到调试器程序退出原因样本加了反调试UPX 本身不带反调试但二次加壳可能带。解决用插件隐藏调试器或者改用静态脱壳方案不启动进程直接模拟解压算法。UPX 的解压算法是公开的可以用lzma或ucl解压UPX1段数据然后手动重建。4.5 现象脱壳后文件比原始文件大很多原因dump 时把内存对齐的填充也写进去了或者没有压缩。解决重建 PE 时按文件对齐重新计算SizeOfRawData去掉多余填充。如果原始文件是压缩的脱壳后变大是正常的因为 UPX 压缩率通常很高。5. 进阶用 Python 实现自动化 UPX 脱壳流水线5.1 批量识别与自动脱壳实际工作中经常要处理一批样本手动一个个脱不现实。我一般会写一个流水线先扫描目录下所有 PE 文件用节区名和入口点特征判断是否 UPX然后对标准样本调用upx -d对变种走内存 dump 流程。下面是一个批量识别的脚本import os import pefile def is_upx(path): try: pe pefile.PE(path, fast_loadTrue) pe.parse_data_directories() section_names [s.Name.rstrip(b\x00).decode(errorsignore) for s in pe.sections] has_upx any(name.startswith(UPX) for name in section_names) pe.close() return has_upx except Exception: return False def batch_scan(root_dir): results [] for dirpath, _, filenames in os.walk(root_dir): for fn in filenames: if fn.lower().endswith((.exe, .dll, .sys)): full os.path.join(dirpath, fn) if is_upx(full): results.append(full) return results for f in batch_scan(./samples): print(f)这段代码遍历目录对每个 PE 文件解析节区名判断是否有以UPX开头的节区。fast_loadTrue加快解析速度parse_data_directories确保节表被解析。参数上不需要额外配置但注意pefile对损坏文件可能抛异常用try包住。识别出来的文件可以交给后续脱壳模块处理。5.2 验证脱壳是否成功的三个指标脱壳完不能只看文件能不能打开我习惯用三个指标验证第一入口点是否落在.text段而不是UPX1第二节区名是否恢复正常没有UPX0、UPX1第三用pefile检查导入表是否能正常解析出函数名。下面是一个验证函数def verify_unpacked(path): pe pefile.PE(path) entry pe.OPTIONAL_HEADER.AddressOfEntryPoint entry_section None for s in pe.sections: if s.VirtualAddress entry s.VirtualAddress s.Misc_VirtualSize: entry_section s.Name.rstrip(b\x00).decode(errorsignore) break imports_ok hasattr(pe, DIRECTORY_ENTRY_IMPORT) pe.close() return { entry_section: entry_section, imports_ok: imports_ok, is_likely_unpacked: entry_section not in (None, UPX0, UPX1) and imports_ok }这个函数返回入口点所在节区名和导入表是否可解析。如果entry_section是.text且imports_ok为真基本可以认为脱壳成功。参数上AddressOfEntryPoint是 RVA需要和节区的VirtualAddress比较。注意有些程序入口点在.itext或其他自定义段只要不是UPX段就算正常。5.3 一个我常犯的错误忽略重定位表早期我脱壳后直接运行发现程序在非默认基址加载时崩溃。原因是 dump 下来的重定位表没有修复或者重建时把重定位目录丢了。UPX 加壳时可能会剥离重定位表脱壳后如果原始程序依赖重定位需要手动重建。我的习惯是脱壳后先用pefile检查DIRECTORY_ENTRY_BASERELOC是否存在如果不存在且程序需要就从原始文件里恢复或重新生成。这个坑不常遇到但遇到一次就够折腾半天。希望帮到你。本文还有配套的精品资源点击获取
返回列表