LOADING

加载过慢请开启缓存 浏览器默认开启

手撸一个ARM64 ELF静态修补工具

2026/2/28

逆向分析时,我们经常需要修改 native 层的行为。比如给某个函数加个日志输出,或者 hook 掉某些检查。传统方法是用 IDA 改完再导出,或者用 Frida 动态 hook。但有些场景下动态 hook 不方便,静态修补反而更直接。而且静态修补可以永久修改文件,一劳永逸。

但手动改 ELF 真的挺麻烦的:要处理段扩展、重定位、PC 相对指令修复……稍微不注意就改坏了。所以我想要一个工具,让我只需要写汇编代码,剩下的自动搞定。就像写个脚本一样简单。

手撸一个 ARM64 ELF 静态补丁工具,顺便聊聊 LIEF 和 Keystone 的那些坑

最近在研究 Android 逆向,发现在一些场景下,app的防护会依据静态的文件检验动态库是否被插桩,所以引发了如下思考:如果我静态patch修补一个so(比如修补libc.so,使用magisk挂载替换文件),是否可以绕过crc检测或脏页检测等检测方案?

静态修补也有许多固有问题,由于是手动写汇编,一般只能实现比较简短的功能,比如打打log啥的;而且几乎是不可避免的会导致文件结构异常,导致被检测。

一开始我都是手动用ida修改,后来发现这样太 low 了,而且容易改崩。于是决定自己写个工具,批量读取patch文件,自动化完成这些操作。折腾了一段时间,终于搞出一个能用的东西,跟大家分享下经验(顺便来论坛申请个会员

成品库:elf-patcher

依赖:LIEF + Keystone

我使用了几个库:

  • LIEF:用来解析和修改 ELF 结构。它提供了非常方便的 API,可以读取段、节、符号,还能直接扩展段、添加重定位,把很多复杂的功能以比较简单的api进行了封装。
  • Keystone:汇编引擎,能把 ARM64 汇编转成机器码。它支持符号解析回调,这样我就可以在汇编代码里直接写函数名,让它自动替换成地址。

这俩搭配起来,完美满足需求。

核心设计思路

1. 补丁文件格式

我希望补丁文件能像写汇编一样简单。于是设计了一套简单的 DSL:

.function my_hook
.import_functions __android_log_print
.libraries liblog.so
.string tag "mytag"
.string msg "Hello from hook\n"
.asm
    sub sp, sp, #0x20
    stp x0, x1, [sp, #0x10]
    stp x2, x30, [sp]
    mov x0, #4
    adr x1, tag
    adr x2, msg
    bl __android_log_print
    ldp x2, x30, [sp]
    ldp x0, x1, [sp, #0x10]
    add sp, sp, #0x20
    ret

.function.patch 指定类型,后面可以跟 .import_functions.libraries.string 等指令,最后用 .asm 开始汇编代码。这样写起来很直观。(具体可以参考README.md)

解析器也不复杂,逐行读文件,遇到 . 开头的就当作指令处理,.asm 之后的行都存起来。代码在 parser.cpp 里,比如解析 .string 的部分:

else if (directive == ".string") {
    size_t first_quote = line.find('"');
    size_t last_quote = line.rfind('"');
    std::string content = line.substr(first_quote + 1, last_quote - first_quote - 1);
    def.strings[name] = content;
}

2. 段扩展与空间管理

要给二进制加新东西,首先得有地方放。LIEF 提供了 extend 方法,可以在文件末尾追加空间,并自动调整后续段和节的偏移。我封装了两个函数:extend_executable_segmentextend_got_plt_segment

前者是创建一个新的可执行段(PT_LOAD),用来放新代码和字符串。后者是找到包含 GOT 的段,扩展它来放新的 GOT 条目。

bool Binary::extend_executable_segment(uint64_t additional_size) {
    LIEF::ELF::Segment new_seg;
    new_seg.type(LIEF::ELF::Segment::TYPE::LOAD);
    new_seg.flags(LIEF::ELF::Segment::FLAGS::R | LIEF::ELF::Segment::FLAGS::X);
    std::vector<uint8_t> content(additional_size, 0);
    new_seg.content(content);
    new_seg.alignment(0x1000);
    auto result = binary.add(new_seg);
    // ...
    code_section.start = added_seg->virtual_address();
    code_section.current = code_section.start;
    return true;
}

扩展后,我用两个 Address 结构体记录空闲区域的起始和当前指针,通过 allocate_in_code_sectionallocate_in_got_section 来分配空间,确保不会重叠。

但这里有一个小坑,LIEF为了修改段表,会把第一个LOAD段的大小增加0x1000,导致此后所以偏移增加0x1000。这样直接使用.patch后的地址进行修补会导致位置出错,所以框架在处理时会自动将.patch后的地址加上0x1000。

3. 符号处理与导入

对于导入的函数和变量,需要在动态符号表里添加条目,并生成对应的 PLT 桩和 GOT 条目。以函数为例,我写了几个函数:

  • add_imported_function:添加动态符号(UNDEF,类型 FUNC
  • create_function_relocation:分配 GOT 条目,生成 PLT 桩(adrp + ldr + add + br),并返回 GOT 和 PLT 地址
  • add_function_relocation:添加 JUMP_SLOT 重定位

生成 PLT 桩时,需要计算页偏移和页内偏移,我直接用 Keystone 汇编:

std::vector<std::string> asm_lines = {
    "adrp x16, #" + utils::to_hex(page_gap << 12),
    "ldr x17, [x16, #" + utils::to_hex(offset) + "]",
    "add x16, x16, #" + utils::to_hex(offset),
    "br x17"
};
auto res = assembler::assemble_code(asm_lines, plt_addr);

4. 符号解析与地址获取

为了让汇编代码里能直接写 bl __android_log_print,我需要一个符号解析机制。Keystone 支持注册回调函数,在汇编时遇到符号会调用回调,让用户返回地址。我在 assembler.cpp 里设置了一个全局符号表指针,并在回调中查找:

static bool symbol_resolver_callback(const char* name, uint64_t* value) {
    auto it = g_symbol_map->find(name);
    if (it != g_symbol_map->end()) {
        *value = it->second;
        return true;
    }
    return false;
}

然后在 patchASM 中设置符号表,调用带解析的汇编版本:

assembler::set_global_symbol_map(&symbol_table);
auto res = assembler::assemble_code(def.asm_lines, patch_addr, true);

但是符号地址从哪来?对于本地函数,直接从二进制符号表拿;对于导入函数,需要拿到它的 PLT 桩地址。我在 Binary::get_symbol 里实现了这个逻辑:

  • 如果是导入函数(shndx == UNDEF),遍历 .rela.plt 找到对应 GOT 条目,然后扫描 .plt 节,通过解码 adrpldr 指令找到访问该 GOT 条目的 PLT 桩起始地址。
  • 如果是导入变量,则从 .rela.dyn 中找到 GLOB_DAT 重定位,返回 GOT 条目地址。

这部分代码比较有意思,贴一段扫描 PLT 的:

for (uint64_t pc = plt_base; pc + 8 <= plt_base + plt_size; pc += 4) {
    uint32_t adrp_insn = ...;
    uint64_t target_page = decode_adrp(adrp_insn, pc);
    uint32_t offset = decode_ldr_offset(ldr_insn);
    if (target_page + offset == got_addr) {
        return pc; // 这就是 PLT 桩的起始
    }
}

为了保证一定的通用性,只要是adrp + ldr序列,且偏移指向got,就会被判定为plt桩。

5. 指令修复的坑

对于 .patch 类型,我会在目标地址写入一条 b 指令跳转到补丁代码,然后把原指令备份到 trampoline 里,并在 trampoline 末尾加一条 b 跳回原处。但原指令可能是 PC 相对寻址的(比如 badrldr literal),如果直接复制到 trampoline,它引用的地址就错了,必须修正偏移。

这部分的代码参考了 And64InlineHook,但实现起来各种坑。比如 adrp 指令,它的立即数是由 immloimmhi 拼起来的,要正确提取和重新编码。我写了 __fix_branch_imm 和几个修复函数,比如修复无条件分支:

static size_t __fix_branch_imm(uint32_t orig_insn, uint64_t orig_pc, uint64_t new_pc, uint32_t* output) {
    // ... 提取目标地址
    int64_t new_offset = (absolute_addr - new_pc) >> 2;
    if (llabs(new_offset) >= 0x1ffffff) return 0; // 超出范围就放弃
    *output = opc | (new_offset & 0x3ffffff);
    return 4;
}

如果超出范围(21mb左右),我原本想生成绝对跳转序列(adr + ldr + add + br),但目前还没完全调通,就先返回 0 表示无法修复,上层会报错。这算是一个待完善的点,希望有大佬可以指点。

6. 对齐问题

ARM64 里有些指令要求数据对齐,比如 ldr 字面量要求目标地址 8 字节对齐。在生成 trampoline 时,如果起始地址不对齐,我就插入 NOP 来调整。这部分在 __fix_loadlit 里处理:

faligned >>= 2;
uint64_t current_pc = new_pc;
while ((new_pc_offset & faligned) != 0) {
    *output++ = A64_NOP;
    current_pc += 4;
    new_pc_offset = (absolute_addr - current_pc) >> 2;
}

虽然代码有点绕,但确实能保证对齐。

使用示例

写个简单的测试,给 so 里某个函数加个日志:

#include "patcher.h"
#include <iostream>

int main() {
    std::string binary_path = "./libmyapplication.so";  // 请确保此文件存在于构建目录
    std::string patch_dir = "./patches";

    patcher::Patcher patcher;
    if (!patcher.load(binary_path, patch_dir)) {
        std::cerr << "Failed to load: " << patcher.getLastError() << std::endl;
        return 1;
    }

    if (!patcher.apply()) {
        std::cerr << "Failed to apply patches: " << patcher.getLastError() << std::endl;
        return 1;
    }

    patcher.outputPatchedBinary("./libmyapplication_patched.so");
    std::cout << "Patches applied successfully!" << std::endl;
    return 0;
}

补丁文件:

.function my_new_function
.import_functions 
.import_variables
.libraries
.symbols _Z12do_some_logsv __android_log_print
.string my_tag "my_tag"
.string my_msg "Hello from my_new_function\n"
.asm
    mov     x0, #4
    adr     x1, my_tag
    adr     x2, my_msg
    bl      __android_log_print
    ret
.patch 0x25018
.import_functions write
.libraries libc.so
.symbols __android_log_print
.string tag "hook"
.string msg "Hooked at entry!\n"
.asm
    sub     sp, sp, #0x20
    stp     x0, x1, [sp, #0x10]
    stp     x2, x30, [sp]
    mov x0, #0x4
    adr x1, tag
    adr x2, msg
    bl __android_log_print
    ldp     x2, x30, [sp]
    ldp     x0, x1, [sp, #0x10]
    add     sp, sp, #0x20

把所有的补丁文件放 patches 目录下,跑一下,就能生成打过补丁的 so。用 IDA 打开看,代码已经被替换成跳转,新代码段里也有我们的日志输出逻辑。实际跑起来也能看到日志,说明补丁生效了。

总结与展望

这个工具目前基本能用,但还有一些不足:

  • 指令修复还不够完善,有些情况会放弃修复导致失败。
  • 只支持 ARM64,要是能支持 x86 就更好了。
  • 补丁文件语法还可以优化,比如支持 label 和更复杂的表达式。
  • 希望可以用libtcc将c转为.o文件,通过手动重定向,实现使用c编写patch或函数。

不过通过这个项目,我深入了解了 ELF 格式、重定位、ARM64 指令编码,也踩了不少 LIEF 和 Keystone 的坑,收获满满。希望能给同样有需求的兄弟们一点启发。

最后,感谢 LIEF 和 Keystone 的开发团队,没有这些优秀的库,这个工具不可能实现。也感谢 52pojie 平台,让我有机会分享自己的折腾经历。申请个会员,以后继续跟大家一起交流学习!


(注:文章里引用的代码都是项目里的实际代码,稍微调整了格式方便阅读。)