逆向分析时,我们经常需要修改 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_segment 和 extend_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_section 和 allocate_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节,通过解码adrp和ldr指令找到访问该 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 相对寻址的(比如 b、adr、ldr literal),如果直接复制到 trampoline,它引用的地址就错了,必须修正偏移。
这部分的代码参考了 And64InlineHook,但实现起来各种坑。比如 adrp 指令,它的立即数是由 immlo 和 immhi 拼起来的,要正确提取和重新编码。我写了 __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 平台,让我有机会分享自己的折腾经历。申请个会员,以后继续跟大家一起交流学习!
(注:文章里引用的代码都是项目里的实际代码,稍微调整了格式方便阅读。)