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先执行,把原始数据解压到内存,再跳转到真正的入口点。

我画了个流程图,帮你理解这个过程:

原始SO文件 UPX加壳工具 加壳后SO文件 ┌──────────────┐ │ UPX 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内存。具体操作:

  1. 找到stub末尾的 jmpcall 指令,它跳转到原始入口点
  2. 在该指令处下断点
  3. 运行程序,触发断点
  4. 用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")
小技巧: 我一般会在dump之前先确认一下原始入口点。用 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 手动分析自定义壳

分析自定义壳,我一般分三步走:

  1. 定位stub边界:找到stub的起始和结束位置。结束位置通常是一个跳转到原始代码的指令
  2. 理解解密算法:单步跟踪stub,记录每一步操作。常见的算法有XOR、AES、RC4等
  3. 提取解密后的数据:在stub执行完成后,从内存中dump出原始SO

这里有个坑:有些壳会分段解密,不是一次性解完。比如先解密一个loader,loader再解密真正的代码。遇到这种情况,你得耐心跟踪,直到所有段都解完。

注意: 自定义壳通常有反调试机制。我建议用IDA + 硬件断点来绕过。或者用Unicorn模拟执行stub,这样不会触发反调试。

20.3 IDA脚本自动脱壳

手动脱壳一次两次还行,但要是天天脱,谁也受不了。所以,我写了一套IDA Python脚本,能自动完成大部分脱壳工作。

20.3.1 脚本核心逻辑

自动脱壳脚本的核心思路是:模拟执行stub,然后dump内存。具体流程如下:

加载SO到IDA 定位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
核心思路: 自动脱壳的本质是「模拟执行 + 内存dump」。不管壳多复杂,最终都要在内存中还原原始代码。只要抓住这个点,就能找到突破口。

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 里。后续的逆向分析就简单多了。

经验之谈: 脱壳只是第一步。很多App会在脱壳后的代码里继续做手脚,比如字符串加密、控制流平坦化。所以,脱完壳别急着高兴,还得继续分析。

好了,关于SO加固脱壳,咱们就聊到这儿。UPX壳有现成工具,自定义壳需要手动分析,IDA脚本能帮你自动化大部分工作。记住一点:没有脱不了的壳,只有不够耐心的逆向工程师


公众号:蓝海资料掘金营,微信deep3321