YAOTU INSIGHTS

IDA Pro 反编译 .so 文件实战:从入口、JNI 到真机验证

IDA Pro 反编译 .so 文件实战:从入口、JNI 到真机验证
简介这份资源是“反编译 .so 文件”系列教程的配套附件主要面向使用 IDA Pro 分析安卓原生库或固件程序的逆向工程开发者与安全研究人员。内容围绕 IDA Pro 的 Hex-Rays 反编译插件展开既包括大量 Python 自动化脚本也提供基于 C 的示例代码可帮助读者解决 .so 文件加载、函数识别、伪代码生成与插件调用等常见问题适合从入门到进阶的不同技能层级。资源共 2000 个文件压缩包约 156.76MB其中 1769 个 Python 脚本用于批量分析与工具扩展93 个头文件与少量 C 源文件展示 SDK 接口调用方式另有文档与配置辅助说明整体目录便于按类型检索。目前已有 2714 人学习下载。包内还包含多组 Hex-Rays 示例源文件与关键 SDK 头文件配合实际 so 样本演练可快速掌握反编译插件工程结构省去自行搭建 SDK 环境与整理样例的时间。1. 反编译 .so 文件先从“黑匣子”里找一个正确入口开始很多开发者第一次拿到 .so 都会犯同一个错误直接拖进 IDA Pro然后从入口点开始顺着汇编往下读。读了几百行看到一堆blx跳到sub_XXXX最后彻底迷失。反编译 .so 文件确实是 IDA Pro 的强项但真正决定你能不能把它反明白的往往不是 F5 那个按钮而是你选了哪条路进入这个文件。我的习惯和大多数人相反不急着看入口先打开导出表找Java_xxx和JNI_OnLoad再按交叉引用往前摸。这个顺序十次有九次能把算法、协议、密钥相关的函数捞出来。这篇笔记适合要审计第三方 SDK、要还原某个 so 里签名算法、以及刚接手一个“没有文档、没有源码”的 native 模块的开发者。附件资源里如果有演示样本和工程文件照着下面这套流程走一遍比对着屏幕干猜快得多。2. 用 IDA Pro 打开 .so加载前先做三件事2.1 用 readelf/nm 先看一眼架构、位数、符号在不在把 .so 拖进 IDA 之前我一般会在终端里先跑两条命令花三十秒搞清楚三件事是不是 ARM、32 位还是 64 位、导出符号有没有被 strip 掉。这个信息直接决定后面很多操作比如你会不会遇到第 4 章里的“找不到 Java_xxx”问题。# 1. 看 ELF 头确认架构和字节序 readelf -h libnative_sample.so | head -20 # 2. 看动态符号表确认 JNI 导出是否还在 nm -D --defined-only libnative_sample.so | grep -E Java_|JNI_OnLoad第一条命令里的-h只读 ELF 头输出的 Machine 字段会明确写ARM或AArch64前者说明是 32 位 Arm后者是 64 位。这个信息用来在 IDA 加载时选对处理器类型选错了后面全是乱码。第二条命令里的-D表示查动态符号表--defined-only过滤掉未定义的导入符号grep帮你直接筛出 JNI 相关函数名。如果这个命令什么都没输出说明 so 的符号已经被 strip 过后面只能用第 4 章的思路去追动态注册。这里要强调一下readelf 和 nm 并不依赖目标平台架构本机就能直接解析 Android 的 ELF 文件不需要交叉编译环境。2.2 IDA 加载 .so 时的四个加载选项双击打开 IDA 后第一个弹窗是加载选项。很多人直接点 OK我一般会停两秒确认下面这几个东西Processor type如果之前 readelf 显示ARM这里就选 ARM 而不是 AArch64反过来同理。选错会导致反汇编结果完全是另一套指令解释看起来每个字节都对不上。Analysis 里的 “Create functions” 选项保持勾选。这是让 IDA 自动识别函数边界的开关关掉之后你会得到一个只有指令、没有函数划分的二进制F5 会大面积失灵。Load resources 相关选项对 Android 的用户态 ELF保持默认即可不用刻意去动内核加载相关的设置。Kernel/Thumb 选项这部分 IDA 通常会自动判断但它经常判断错。如果你加载完成后发现某个区域的反汇编歪了八成和 Thumb 切换有关这在第 4 章会单独讲。加载完成后按空格可以在图形视图和列表视图之间切换。列表视图更贴近汇编原文图形视图适合看函数间的调用关系建议定位阶段用列表视图追调用链时再切图形。2.3 用 Exports 窗口定位 JNI_OnLoad 与 Java_xxx 函数加载完成后按CtrlE打开 Exports 窗口这是整个反编译流程里我第一个看的窗口。对于静态注册的 so你会直接看到一长串Java_包名_类名_方法名形式的导出函数双击任意一个就能跳到对应代码区再按 F5 就是干净的伪代码。但很多商业 SDK 不会这么直白。它们只导出JNI_OnLoad其余函数全部隐藏成sub_xxxx。这种叫动态注册原理是在JNI_OnLoad里调用RegisterNatives把 Java 层的方法名和 native 函数指针绑到一起。这种情况你光看导出表是找不到线索的正确做法是跳到JNI_OnLoad的伪代码里找RegisterNatives的调用点然后看传入的第二个参数——那是一个结构体数组里面每一项都包含 Java 方法名、签名、native 函数指针三个字段。顺着这个数组找到函数指针才算是定位到真正的实现。第 4 章里我会给一个更快的字符串搜索法这里先记着思路。3. 读 .so 的三板斧先从字符串和交叉引用把路摸清3.1 交叉引用为什么“从引用处反向走”比顺序读更快反编译 .so 最忌讳的就是从头到尾线性阅读。函数之间跳来跳去你根本记不住哪个sub_xxxx是干嘛的。我的做法是反过来先在代码区选中有疑惑的目标函数、全局变量、字符串按X键弹出交叉引用窗口看看是谁在引用它然后朝着“引用者”的方向追。三个最常用的场景一是想知道某个Java_xxx函数被谁调用在函数名上按X往上找调用者二是发现一串可疑的常量表或密钥字符串在数据区选中它按X看哪些函数读走了它三是在 F5 伪代码里看到一个关键函数想确认它从哪里被调用直接切到汇编视图在函数开头按X看引用列表。交叉引用能回答的是“谁在碰它”比你自己顺着汇编找调用点快一个量级。需要注意如果一个函数既没有交叉引用也没有出现在导出表里那它大概率是通过函数指针间接调用的此时直接搜它的地址比翻引用列表更快。3.2 ShiftF12 字符串窗口刷线索按关键字过滤而不是眼整个表字符串窗口是反编译 .so 最快的黑匣子开锁方式。很多 native 逻辑藏得再深实现时都会留下日志输出、错误提示、算法常量名这类字符串。按ShiftF12打开窗口后直接用过滤框搜http、key、token、AES、salt、路径前缀这些特征词往往能瞬间定位到关键函数。但字符串窗口在 so 特别大时会非常长上千条结果光靠肉眼翻不现实。我一般用下面的脚本把关键词过滤直接做成可执行的方式import idautils # 关注与通信、加密、密钥相关的字符串 keywords (http, key, token, aes, rsa, salt, .so) for s in idautils.Strings(): text str(s) low text.lower() if any(k in low for k in keywords): print(%08x %s % (s.ea, text))这段脚本在 IDA 的 Python 控制台里直接跑。idautils.Strings()返回文件中的全部字符串对象每个对象都有.ea地址和内容两个属性str(s)拿到字符串内容后统一转小写做匹配。关键字按实际场景改比如你在分析登录类 SDK那就加login、password、sign分析支付类就加amount、order。打印结果里前 8 位十六进制是字符串地址双击地址就能跳到对应的数据区再按X就能找到引用它的代码。这个方法比在窗口里手动翻至少快三倍。3.3 F5 伪代码的阅读顺序先改签名再重命名最后看常量到了 F5 这一步很多人习惯直接从头读函数体。但优化过的 so 伪代码通常变量名全是v1、v2、a1结构体全被拍平成void *直接读等于猜谜。我的顺序固定是三步第一步先改函数签名。在伪代码窗口按Y把__int64 __fastcall sub_1234(void *a1, void *a2)改成能看懂的形式比如int native_decrypt(JNIEnv *env, jobject thiz, char *input, int len)。改完之后整个函数体的变量类型推导都会变好很多地址运算会变成合理的指针偏移。第二步重命名。在变量或中层函数上按N把v5改成cipher_len、sub_5678改成base64_decode。名字是你自己维护的地图改一次后面所有引用都能看懂。第三步看常量。F5 里出现大段0x12345678这样的立即数时按R可以把它转成字符、按H可以切换十六进制和十进制方便你反查这个常量是不是 ASCII 编码。如果函数特别多一个个重命名太慢可以用 IDAPython 批量处理。比如你已经通过字符串引用确认了一批函数地址和用途可以直接写映射表import idc import ida_name # 地址 - 函数名地址通过前面的交叉引用分析拿到 rename_map { 0x1234: native_decrypt, 0x1288: native_encrypt, 0x12F0: build_sign, } for ea, name in rename_map.items(): ok ida_name.set_name(ea, name, ida_name.SN_FORCE) print(%08x - %s, result%d % (ea, name, ok))set_name的第三个参数SN_FORCE表示即使该地址已经有名字也强制覆盖避免原来编译器生成的sub_前缀占着坑。函数返回值ok为 1 表示成功为 0 表示地址非法或权限不够。跑完这个脚本再回到 F5 窗口你会发现伪代码的可读性提升一大截这不只是让你看着舒服——重命名后的函数名会跟着交叉引用传播等于整个项目的阅读地图都更新了。4. 反编译 .so 的 5 个典型翻车现场现象、原因、解决4.1 F5 没反应或提示 not a function现象在某个地址上按 F5IDA 完全没有反应或弹出提示说当前地址不构成一个函数。新手往往会以为文件坏了其实大概率是 IDA 的函数识别失败了。原因编译器在优化时可能插入了一些 IDA 无法自动识别的跳转模式或者函数的开头被前面的数据覆盖导致 IDA 不敢把它定义为函数。尤其常见于带花指令混淆或代码段里混了字面量池的 so。解决把光标停在函数入口地址按C键强制将当前位置解释为代码再按P让 IDA 创建函数。如果入口不明显在附近找push、stmfd这类典型函数序言从那里开始按P。必要时用脚本强行创建import idc import ida_funcs # 假设入口地址是 0x1234结束地址不确定 start 0x1234 ida_funcs.add_func(start) # end 传 0 或省略时IDA 自己找结尾add_func接受两个参数第二个参数传 0 表示让 IDA 自动分析函数边界。如果这段代码中间夹着不可作为指令的数据块add_func会失败这时要先在中间数据区按U取消定义再用C逐段修最后重新add_func。这个操作属于手工修补遇到一次就要接受一个现实不是每个函数都能一键 F5有些边界必须手工喂给 IDA。4.2 导出表里全是 sub_xxxx找不到 Java 层调用的函数现象Exports 窗口里只有JNI_OnLoad其他全是sub_xxxx按名字搜索 Java 层方法完全搜不到。原因so 在编译时加了-fvisibilityhidden或发布前用 strip 工具删除了动态符号表另一个常见原因是开发者用了动态注册RegisterNativesnative 方法根本不在导出表里。解决去字符串窗口按ShiftF12搜 Java 层代码里“看起来像方法名”的字符串比如 Java 代码里调用的函数名叫nativeEncrypt那这个字符串通常会以字面量的形式出现在 so 里。找到字符串后按X看交叉引用找到把它作为参数传入RegisterNatives的代码区域。RegisterNatives的第二个参数是一个结构体数组里面每项 12 或 24 字节依次是方法名字符串指针、签名字符串指针、函数指针。顺着数组往下数看到那个指向代码段的指针就是你要找的 native 实现。这个方法是动态注册定位的标准路径配合第 3.3 的脚本批量重命名效率很高。4.3 字符串窗口乱码中文或 UTF-8 显示成一堆单字节符号现象ShiftF12里看到\xE4\xBD\xA0\xE5\xA5\xBD这类东西或者中文日志显示成乱码。原因IDA 默认用单字节编码去解析字符串而 Android so 里的字符串大多是 UTF-8。显示层面看不出内容直接导致你搜中文关键词完全搜不到。解决在字符串窗口的标题栏右键找到设置项把默认编码改成 UTF-8。不同 IDA 版本菜单位置略有差异但通常集中在字符串窗口的显示设置里。改完以后中文日志就能正常显示搜索中文关键词也生效了。另外还有一种情况字符串不是 UTF-8 而是 UTF-16LE窗口里能看到内容但中间夹着\x00这种需要在数据区手动选中该字符串右键选择“切换为 Unicode”让 IDA 重新按双字节解析。字符串显示正确是一切分析的基础我每次拿到带中文日志的 so第一件事就是设置编码省得后面反复猜。4.4 汇编变成一堆无效指令ARM/Thumb 切换被 IDA 搞错现象反汇编视图从某个地址开始全是红色显示undefined instruction或者一段代码反汇编出来明显不像是正常逻辑。原因ARM 指令集有两种模式ARM 模式每条指令固定 4 字节Thumb 模式则是 2/4 字节混合。函数切换模式时 IDA 可能没有识别到对应的状态改变导致后面字节流按错模式解析。这算是 ARM 反汇编最经典的问题不是文件坏了。解决把光标停在出问题区域的起点打开段属性设置Edit - Segments - Edit Segment检查 Thumb 标志是否与你判断的模式一致不一致就切换后重新分析。也可以直接看问题地址的低位Thumb 函数的入口地址末位通常是 1ARM 函数是 0。确认模式后按C强制重新反汇编。如果一段代码里混了两个模式需要在模式切换点手动修正再按P重建函数。这里有血泪经验不要因为看到错误指令就跳过去切换点恰恰是定位混淆逻辑的关键位置。4.5 F5 伪代码可读性崩了全是内联和魔法数字看不到任何库函数现象一个函数几十行变量全是v1、v2没有调用任何常见库函数只看到大段的位运算、数组下标、魔数常量。原因高优化等级O3下编译器把大量小函数直接内联展开你看到的是“展开后”的代码而不是“逻辑分层”的代码。再加上常量被直接编码成立即数你很难一眼看出它是某个算法族的特征。解决先别急着逐行读把函数里出现的魔数都收集起来重点匹配 256 字节的替换表、64 字符的码表这类特征结构。比如看到长度 256、值域是 0x63,0x7C,0x77 这种序列基本可以锁定 AES S 盒看到ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/直接判断是 Base64 码表。这类“特征表”是识别算法最快的方式比逐行解释位运算靠谱得多。确认算法族之后再回伪代码里找它对应的轮数、密钥长度这些参数比凭空猜快太多了。5. 让 F5 从“能看”变成“能信”补函数、补签名、调设置5.1 手工修边界add_func 的正确使用姿势第 4.1 说过有些函数必须手工修补。但手工修补并不是把地址随便填进去就完事顺序错了反而会把整个函数区域搞坏。我的固定操作是U取消错误定义C转代码P建函数如果P失败再用脚本add_func。import idc import ida_funcs # 1. 取消当前地址原有定义避免新旧冲突 idc.del_items(start, 0, idc.DELIT_SIMPLE) # 2. 如果知道函数结束地址就传两个参数 # 不知道就只传 start让 IDA 自动推导边界 ida_funcs.add_func(start, end if end start else 0)这段代码里del_items的第一个参数是起始地址第二个参数 0 表示只处理当前地址DELIT_SIMPLE是删除的粒度。add_func的第二个参数传 0IDA 会尝试从 start 往下扫描自动找函数结尾。我见过不少人第二个参数传一个猜的结束地址结果把真实代码切掉一段反而更难读。除非你已经明确看到函数末尾的bx lr否则优先让 IDA 自己去推它基于字节模式判断的准确率远高于手工猜边界。5.2 用 set_type 补函数签名把 JNI 参数改成可读类型F5 结果难看很大比例是函数签名没修。默认签名里参数全是void *F5 无法推断指针运算的语义于是整段代码都是地址加减。用set_type可以把签名直接写成可读形式import idc # 把 0x1234 处的函数签名改成带参数类型的形式 ea 0x1234 proto int __cdecl native_decrypt(void *env, void *obj, char *input, int len) idc.set_type(ea, proto)set_type的第二个参数是 IDA 类型字符串格式和 C 声明几乎一致。这里把env和obj保留为void *因为它们在纯逻辑分析里意义不大关键是input和len有了它们 F5 就能把数组索引、指针运算正确解释成长度遍历逻辑。调用约定部分在 ARM 上实际是 AAPCS但__cdecl在纯阅读场景下不影响伪代码生成不需要纠结。设置完签名后所有对这个函数的调用点都会自动更新参数显示这比你在每个调用处手工调整变量类型高效得多。5.3 三处值得调的 IDA 设置E除了修函数和签名还有三个设置项我每次拿到新 so 都会确认一下。它们不直接影响反汇编正确性但会极大影响你阅读和分析的舒适度。设置项作用我一般怎么设指令前显示地址和字节汇编窗口里每条指令都带地址和原始字节方便和 readelf/objdump 对照打开自动分析广度IDA 加载时扫描的范围影响函数识别和交叉引用完整性保持默认遇到漏函数再局部重分析字符串默认编码影响 ShiftF12 的显示与搜索改成 UTF-8地址和字节显示打开后你在汇编里看到的地址可以直接和命令行工具的偏移对应上排查加载偏移问题时非常有用。自动分析选项我一般保持默认不盲目拉满那样只会让加载速度变慢实际分析精度提升有限。字符串编码则是第四节踩过的坑现在开箱就设好不给自己添麻烦。6. 验证反编译结果的技巧用“对拍 笔记”给伪代码上保险6.1 把核心算法抽出来对拍伪代码看着对不等于真对F5 结果最大的风险不是看不懂而是看懂了但理解错了边界条件。比如一个加密函数你读伪代码以为它先做 Base64 再做 AES但实际顺序是反的。这种问题靠肉眼读代码很难发现最靠谱的验证方式是对拍把从伪代码里还原出来的算法逻辑用另一种语言重写一遍然后用同一组输入和真实 so 的输出做对比。import ctypes # 示例加载同一算法重新编译出的本地库与伪代码还原结果对比 lib ctypes.CDLL(./native_sample.so) lib.demo.argtypes [ctypes.c_char_p, ctypes.c_int] lib.demo.restype ctypes.c_char_p input_data bhello result lib.demo(input_data, len(input_data)) print(result)这里要说明一下Android so 依赖 bionic libc不能直接在桌面系统加载但纯算法函数通常不涉及系统调用把它抽出来用本地工具链重新编成一个动态库再用 ctypes 调用完全可行。如果输出和伪代码推演结果不一致优先检查函数签名、参数顺序、长度边界这三个位置这三点占对拍失败比例的八成。这个技巧能把你从“我觉得它是对的”拉回到“它确实是对的”的确定状态。6.2 记录反编译笔记给结论标置信度反编译这事做多了最大的敌人是遗忘。上周刚追完的调用链这周再看可能完全想不起来。我现在的习惯是每分析完一个关键函数就在笔记里记一行函数地址、函数名、调用来源、算法推断、证据链以及一条最重要的——置信度。置信度低说明这个结论是猜的需要后续用对拍验证置信度高说明有字符串引用和交叉引用双重佐证。后面再有人来接手这个 so或者下个月你自己回头看笔记里的证据链能帮你快速恢复上下文不用重新走一遍完整的分析流程。6.3 我现在固定的三步流程最后分享一个我自己的教训刚做反编译的时候总想用某个“神器”一键搞懂一切结果大概率是 F5 用了一百次关键函数一个都没定位对。后来我把流程固定成三步先用 readelf/nm 看一眼架构和符号再往 IDA 里拖并按 ShiftF12 找字符串和交叉引用最后把关键函数抄到对拍脚本里跑通输入输出。这三步做完一个陌生的 so 基本就能拆掉大半。如果遇到“看着对但跑不对”的算法别硬读代码先把输入输出对上再去改签名和边界条件。希望帮到你。本文还有配套的精品资源点击获取