Featured image of post UPX arm64-linux 源码分析 && 修复Unidbg以支持模拟未脱壳的UPX安卓so

UPX arm64-linux 源码分析 && 修复Unidbg以支持模拟未脱壳的UPX安卓so

通过阅读UPX源码、Unidbg源码,找到了直接模拟未脱壳的UPX安卓so崩溃原因。目前此贡献已被合并至上游。

概述

通过阅读UPX源码、Unidbg源码,找到了直接模拟未脱壳的UPX安卓so崩溃原因。目前此贡献已被合并至上游。https://github.com/zhkl0228/unidbg/commit/44fd8c22685e2aeec5b1a41381abd8e36cd5ba33

UPX源码阅读

git版本提交SHA:611d67542ae0ae9f8128ff121ae2a21ea007261d

压缩阶段

  graph TD
    A[Start: 输入二进制文件] --> B{PackMaster识别格式}
    B -->|识别成功| C[创建对应 Packer 对象<br/>如 PackLinuxElf64arm]
    B -->|识别失败| Exit[退出: Unsupported Format]
    
    C --> D[pack1: 准备工作]
    D --> D1[生成新 ELF 头部]
    D --> D2[处理符号表与重定向]
    
    C --> E[pack2: 数据压缩]
    E --> E1[按块读取原始数据]
    E --> E2[应用指令滤镜 Filter 0x52]
    E --> E3[使用 LZMA/NRV 压缩块]
    
    C --> F[pack3: 注入 Stub]
    F --> F1[汇编 Entry: arm64-linux.elf-entry.S]
    F --> F2[压缩后的 C Stub: amd64-linux.elf-main.c]
    
    C --> G[pack4: 写入 PackHeader]
    G --> H[End: 生成加壳二进制文件]
  1. 格式识别与分发 (src/packmast.cpp)

  2. 准备工作 (src/p_lx_elf.cpp, src/packer.cpp)

    • 对于 ARM64,会使用 ElfLinkerArm64LE
    • pack1():生成新的可执行文件头。对于安卓so,会特别处理 android_shlib ,处理符号表和重定位。
  3. 数据压缩 (src/p_unix.cpp 中的 pack2)

    • UPX 将原文件内容按块(Block)读取。
    • 调用 compressWithFilters() 进行压缩。现在版本一般是NRV算法,也见过LZMA的。
  4. 注入解压 Stub (src/p_lx_elf.cpp 中的 pack3)

    • src/stub 提取 ARM64 专用的汇编代码(如 arm64-linux.elf-entry.S)和 C 逻辑。
    • 所谓的stub是UPX的一段可执行代码,stub本身也是运行时自解压的,是运行时自脱壳的核心。
  5. 收尾工作 (src/p_unix.cpp 中的 pack4)

    • 写入 PackHeader 信息到文件末尾。
    • 记录 overlay_offset,这是加壳后原始压缩数据在文件中的偏移量,供解压 stub 使用。这些地方很容易被魔改,随便改一点基本上就不能upx -d脱壳了。

    运行阶段

      sequenceDiagram
        participant OS as 内核态
        participant Entry as 带壳的程序入口
        participant CStub as UPX Stub
        participant Target as 原始程序空间
    
        OS->>Entry: _start
        Entry->>OS: syscall: memfd_create
        Entry->>Entry: 自解压 Folded C Stub 到 memfd
        Entry->>OS: syscall: mmap (执行权限)
        Entry->>CStub: 跳转到 C 逻辑入口
    
        CStub->>CStub: 解析程序段 (PT_LOAD) 元数据
        CStub->>OS: syscall: mmap (MAP_FIXED) <br/>一次性申请全部空间
    
        loop 每一数据块
            CStub->>Target: 解压数据到原始地址
            CStub->>Target: Unfilter: 还原 ARM64 跳转偏移
        end
    
        CStub->>OS: syscall: mprotect (恢复段权限)
        CStub->>Target: 动态写入 Escpae Hatch (逃生舱)
        CStub->>Target: 跳转到逃生舱
    
        Target->>OS: syscall: munmap (释放 Stub 内存)
        Target->>Target: 跳转到 OEP (原始入口点)
    
    1. 入口启动 (Entry)

      • 运行从 _start 开始,首先一次性压所有栈,保护现场。
      • 通过 memfd_create 系统调用或降级在 /dev/shm 下创建临时文件,用于存放即将解压的第二阶段 Stub 逻辑。
    2. Stub 自解压

      • 初始 Stub 将其自身“折叠(Folded)”部分的 C 逻辑代码解压到上述临时文件中。
      • 通过 mmap 将该临时文件映射为可执行内存,并跳转过去。
    3. 分析与内存规划 (do_xmap 函数)

      • 读取压缩时的元数据,使用 xfind_pages 预定原始程序的总内存范围。
      • 获取 AT_PAGESZ 以确保内存页对齐一致。
    4. 循环解压块 (unpackExtent 函数)

      • 循环解压数据块到原始内存位置。
      • 对 ARM64 指令进行 “unfilter” 处理(还原跳转偏移)。
    5. 权限恢复与清理

      • 调用 mprotect 恢复内存段权限。

      • 使用 ARM64 专用的 make_hatch_arm64 创建一段“逃生舱”代码,负责执行 munmap 后跳转回原始 OEP。这就是Unidbg无法直接带壳模拟的原因。

        为什么要用“逃生舱”,因为 munmap 不能操作自己所在的内存,所以需要申请一块新的,在新内存上操作。

    6. 恢复现场

      • 恢复所有寄存器状态并跳转。这是传统的手动脱壳方法的常用点。依稀记得用od调win就是在popad上打个断点然后单步几下找大跳。

所有的系统调用均通过SVC 0执行,这块东西是直接汇编手搓的

分析解决Unidbg报错

根据运行时报错可以定位到如下位置,修改如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
ddiff --git a/unidbg-api/src/main/java/com/github/unidbg/spi/AbstractLoader.java b/unidbg-api/src/main/java/com/github/unidbg/spi/AbstractLoader.java
index 756bca92e..a61afef46 100644
--- a/unidbg-api/src/main/java/com/github/unidbg/spi/AbstractLoader.java
+++ b/unidbg-api/src/main/java/com/github/unidbg/spi/AbstractLoader.java
@@ -195,8 +195,13 @@ public final int munmap(long start, int length) {
                 long size = aligned - removed.size;
                 while (size != 0) {
                     MemoryMap remove = memoryMap.remove(address);
+                    if (remove == null) {
+                        log.warn("munmap failed to find adjacent region at address=0x{}", Long.toHexString(address));
+                        break;
+                    }
                     if (removed.prot != remove.prot) {
-                        throw new IllegalStateException();
+                        log.warn("munmap prot mismatch: removed.prot={}, remove.prot={}, address=0x{}", removed.prot,
+                                remove.prot, Long.toHexString(address));
                     }
                     address += remove.size;
                     size -= remove.size;

根据UPX源码得知:运行时mmap会一次申请所有段的内存(防止临近的段被分到别的程序去了),此时这些段的权限必然是各不相同的(参考正常的程序)。因此,在munmap时,就会产生跨不同权限段操作的情况,原来代码中removed.prot != remove.prot必然成立。这样就会throw错误中断。我们仅需把throw改为无害的log warn即可完美解决此问题。

经过测试,原先不能直接模拟的lib,经过这样修改代码可以直接模拟。这完美避免了分析魔改UPX和修复头等工作。

使用 Hugo 构建
主题 StackJimmy 设计