20、SO加固脱壳实战:UPX加壳、自定义加壳算法、IDA脚本自动脱壳
兄弟们,今天咱们聊点硬核的——SO加固脱壳。说实话,我早期做逆向的时候,最头疼的就是碰到加壳的SO文件。那时候没经验,看到一堆乱码就头大。后来踩的坑多了,慢慢就摸清了门道。
SO加固,说白了就是把你的核心逻辑藏起来。攻击者想逆向,得先过脱壳这一关。咱们今天就从UPX这种经典壳开始,再到自定义加壳算法,最后用IDA脚本实现自动化脱壳。一条龙服务,安排上。
20.1 UPX加壳与脱壳
UPX(Ultimate Packer for eXecutables)是最常见的可执行文件压缩工具。它不光能压缩PE,也能处理ELF格式的SO文件。我见过很多App直接用UPX给SO加壳,以为这样就安全了。其实……嗯,UPX的脱壳工具是公开的。
20.1.1 UPX加壳原理
UPX加壳的核心思路就两步:压缩 + 添加解压桩。原始SO的代码段和数据段被压缩成一个数据块,然后UPX在文件头部插入一段解压代码(stub)。运行时,stub先执行,把原始数据解压到内存,再跳转到真正的入口点。
我画了个流程图,帮你理解这个过程:
20.1.2 手动脱UPX壳
脱UPX壳其实有现成工具。但咱们做逆向的,不能光会用工具,得理解原理。我习惯先手动走一遍流程。
第一步,用 readelf -h 查看加壳后的SO文件头。你会发现入口点地址(Entry point)指向了UPX的stub代码,而不是原始代码。
# 查看加壳SO的入口点
readelf -h libupx_packed.so | grep Entry
# 输出示例
Entry point address: 0x1000 # 指向UPX stub
第二步,用IDA加载这个SO。跳转到入口点,你会看到一段典型的UPX解压代码。特征很明显:循环、异或、内存拷贝。我截个关键片段:
UPX0:00001000 push ebp
UPX0:00001001 mov ebp, esp
UPX0:00001003 push esi
UPX0:00001004 push edi
UPX0:00001005 push ecx
UPX0:00001006 mov esi, [ebp+8] ; 压缩数据起始地址
UPX0:00001009 mov edi, [ebp+0Ch] ; 解压目标地址
UPX0:0000100C mov ecx, [ebp+10h] ; 数据大小
UPX0:0000100F call decompress ; 调用解压函数
第三步,手动脱壳。最简单的方法:在IDA中下断点到解压完成后的跳转指令,运行到断点,然后dump内存。具体操作:
- 找到stub末尾的
jmp或call指令,它跳转到原始入口点 - 在该指令处下断点
- 运行程序,触发断点
- 用IDA的
File > Script command运行dump脚本
# 简单的IDA Python dump脚本
import idaapi
import idc
def dump_memory(start_addr, size, filename):
data = idaapi.get_bytes(start_addr, size)
with open(filename, 'wb') as f:
f.write(data)
print(f"Dumped {size} bytes to {filename}")
# 假设原始代码段从0x1000开始,大小0x5000
dump_memory(0x1000, 0x5000, "dump_original.so")
GetRegValue("EIP") 获取当前指令地址,然后往前推,找到真正的代码起始位置。
20.1.3 使用upx -d自动脱壳
当然,如果只是UPX标准壳,直接用 upx -d 命令就能脱。但要注意,有些App会魔改UPX,比如修改了UPX的魔数或者解压算法。这时候 upx -d 会报错。
# 标准脱壳命令
upx -d libpacked.so -o libunpacked.so
# 如果报错,说明被魔改了
upx: libpacked.so: NotPackedException: not packed by UPX
遇到这种情况,就得手动分析了。我曾经碰到过一个App,它把UPX的 UPX! 魔数改成了 MYX!,然后改了stub里的几个关键跳转。当时折腾了一下午才搞定。
20.2 自定义加壳算法
UPX虽然方便,但太容易被识别。很多加固厂商会自己写加壳算法。说白了,就是自定义一套压缩/加密流程,然后自己写stub来解压。这种壳没有现成工具,只能硬着头皮逆向。
20.2.1 自定义壳的特征
我总结了几条自定义壳的常见特征:
- 入口点异常:入口点不在常见的代码段范围内,而是指向一个很小的stub
- 大量花指令:stub里塞满了垃圾指令,干扰静态分析
- 动态解密:关键数据在运行时才解密,静态看不到明文
- 反调试:stub里会检查
ptrace、/proc/self/status等
举个例子,我之前逆向过一个金融App的SO,它的stub长这样:
.text:00001000 push {r4-r11, lr}
.text:00001004 mov r0, #0x12345678 ; 花指令
.text:00001008 eor r0, r0, r0 ; 花指令
.text:0000100C add r0, r0, #1 ; 花指令
.text:00001010 ldr r1, =0xABCDEF01 ; 真正的解密密钥
.text:00001014 bl decrypt_func ; 调用解密函数
.text:00001018 ...
看到没?前面一堆没用的指令,真正的解密逻辑藏在后面。这种壳,静态分析基本没用,得上动态调试。
20.2.2 手动分析自定义壳
分析自定义壳,我一般分三步走:
- 定位stub边界:找到stub的起始和结束位置。结束位置通常是一个跳转到原始代码的指令
- 理解解密算法:单步跟踪stub,记录每一步操作。常见的算法有XOR、AES、RC4等
- 提取解密后的数据:在stub执行完成后,从内存中dump出原始SO
这里有个坑:有些壳会分段解密,不是一次性解完。比如先解密一个loader,loader再解密真正的代码。遇到这种情况,你得耐心跟踪,直到所有段都解完。
20.3 IDA脚本自动脱壳
手动脱壳一次两次还行,但要是天天脱,谁也受不了。所以,我写了一套IDA Python脚本,能自动完成大部分脱壳工作。
20.3.1 脚本核心逻辑
自动脱壳脚本的核心思路是:模拟执行stub,然后dump内存。具体流程如下:
20.3.2 自动脱壳脚本实现
下面是我常用的自动脱壳脚本。它适用于大多数自定义壳,但遇到特别变态的壳,还是得手动调。
import idaapi
import idc
import idautils
class AutoUnpacker:
def __init__(self):
self.stub_start = None
self.stub_end = None
self.original_entry = None
def find_stub_boundary(self):
"""自动定位stub的起始和结束位置"""
# 获取入口点
entry = idc.get_entry_ordinal(0)
self.stub_start = idc.get_entry(entry)
# 向下扫描,找到跳转到原始代码的指令
addr = self.stub_start
while addr < self.stub_start + 0x1000:
mnem = idc.print_insn_mnem(addr)
if mnem in ['B', 'BL', 'BX', 'JMP']:
target = idc.get_operand_value(addr, 0)
# 检查目标地址是否在stub范围外
if target > self.stub_start + 0x1000 or target < self.stub_start:
self.stub_end = addr
self.original_entry = target
print(f"Found stub end at 0x{addr:X}, original entry at 0x{target:X}")
return True
addr = idc.next_head(addr)
return False
def set_breakpoints(self):
"""在关键位置设置断点"""
if self.stub_end:
idc.add_bpt(self.stub_end)
print(f"Breakpoint set at 0x{self.stub_end:X}")
def dump_unpacked(self, output_file):
"""dump解压后的内存"""
# 获取代码段范围
seg = idaapi.get_segm_by_name('.text')
if seg:
start = seg.start_ea
end = seg.end_ea
size = end - start
data = idaapi.get_bytes(start, size)
with open(output_file, 'wb') as f:
f.write(data)
print(f"Dumped {size} bytes to {output_file}")
return True
return False
def run(self, output_file="unpacked.so"):
"""主流程"""
print("[*] Starting auto unpack...")
if not self.find_stub_boundary():
print("[!] Failed to find stub boundary")
return False
self.set_breakpoints()
# 运行到断点
idc.start_process("", "", "")
idc.run_to(self.stub_end)
# dump内存
if self.dump_unpacked(output_file):
print("[+] Unpack success!")
return True
else:
print("[!] Unpack failed")
return False
# 使用示例
unpacker = AutoUnpacker()
unpacker.run("libtarget_unpacked.so")
20.3.3 脚本的局限性
这个脚本不是万能的。我遇到过几种情况,脚本会失效:
| 问题类型 | 表现 | 解决方案 |
|---|---|---|
| 反调试 | 脚本运行后程序崩溃 | 用Unicorn模拟执行,绕过反调试 |
| 多段解密 | 只dump了部分代码 | 多次设置断点,分段dump |
| 自修改代码 | stub会修改自身 | 使用硬件断点,监控内存写操作 |
| 环境检测 | 检测到IDA环境后退出 | 修改stub中的检测逻辑,或者用Frida hook |
20.4 实战案例:脱一个自定义壳
光说不练假把式。咱们拿一个实际案例走一遍。
目标SO:libcore.so,大小2.3MB。用 readelf 查看,发现入口点在 0x1000,但代码段只有0x200字节。明显是加壳了。
第一步,用IDA加载。跳转到0x1000,看到一段ARM Thumb代码。单步跟踪,发现它先解密了一个小函数(0x1200处),然后跳转过去。
第二步,在0x1200处下断点,继续运行。发现这个函数又解密了另一段代码(0x2000处)。如此反复,一共解密了5段。
第三步,写脚本自动记录每次解密后的内存范围。最后把所有段合并,修复ELF头,就得到了完整的原始SO。
# 分段dump脚本
import idaapi
def dump_segments():
segments = [
(0x1000, 0x2000), # stub
(0x2000, 0x8000), # 第一段解密
(0x8000, 0x10000), # 第二段解密
(0x10000, 0x18000),# 第三段解密
(0x18000, 0x20000),# 第四段解密
(0x20000, 0x28000),# 第五段解密
]
with open("libcore_unpacked.so", "wb") as f:
for start, end in segments:
data = idaapi.get_bytes(start, end - start)
f.write(data)
print(f"Dumped 0x{start:X} - 0x{end:X}")
dump_segments()
脱完壳后,用IDA重新加载,就能看到完整的函数列表了。嗯,这个App用的是某厂商的加固方案,核心逻辑在 Java_com_example_app_nativeMethod 里。后续的逆向分析就简单多了。
好了,关于SO加固脱壳,咱们就聊到这儿。UPX壳有现成工具,自定义壳需要手动分析,IDA脚本能帮你自动化大部分工作。记住一点:没有脱不了的壳,只有不够耐心的逆向工程师。
公众号:蓝海资料掘金营,微信deep3321