应用安全 --- frida脚本 之 dump 自动化动脱 so
为什么我要动态在内存中查找so并下载修复一个so,因为这个so文件被安全软件进行了加固处理使得代码大面积加密,用ida打开后会发现代码是红色的报错
用到的脱壳so的工具:
https://github.com/lasting-yang/frida_dump/tree/master/android
原版不好用,存在的问题有不支持远程连接,不支持延迟加载so导致有些so文件无法获取。我进行了脚本优化,支持多个adb设备指定。
前提是已经有一个root手机,安装了magisk,并安装好了frida,并且app没有检测root和frida。
整体功能:这是一个用于从Android应用中dump(提取)SO库文件的工具
主要流程:
设备连接:自动检测并选择连接的Android设备
进程附加:使用Frida连接到目标应用(得物App)
模块查找:在应用的内存中查找指定的SO库
内存dump:将SO库从内存中提取出来
文件修复:使用SoFixer工具修复dump出来的文件,让它能正常使用
清理工作:删除临时文件
关键技术点:
使用Frida进行动态分析和内存操作
通过ADB与Android设备通信
处理ARM32和ARM64两种架构
包含重试机制确保模块加载完成
1. 动态内存分析原理 Frida Hook技术: Frida是一个动态插桩框架,可以在运行时注入JavaScript代码到目标进程 通过Hook系统调用和内存操作,获取进程的内存布局信息 不需要root权限就能访问应用的内存空间 内存映射获取: # 通过Frida脚本获取所有已加载的模块信息 allmodule = script.exports_sync.allmodule() # 每个模块包含:名称、基址、大小、路径等信息 2. SO文件在内存中的存储原理 ELF文件加载过程: Android系统加载SO文件时,会将其映射到进程的虚拟内存空间 系统会记录每个SO文件的基址(base address)和大小 文件在内存中可能被分散存储,但逻辑上是连续的 内存布局: 进程内存空间: [0x7000000000] - libc.so [0x7001000000] - libssl.so [0x7002000000] - 目标SO文件 ← 我们要提取的 [0x7003000000] - 其他库文件 3. 内存Dump的核心机制 直接内存读取: // 在dump_so.js中(Frida脚本) function dumpmodule(module_name) { var module = Process.findModuleByName(module_name); if (module) { // 直接从内存基址读取指定大小的数据 var buffer = Memory.readByteArray(module.base, module.size); return buffer; } } 关键步骤: 通过Process.findModuleByName()定位模块 获取模块的基址和大小信息 使用Memory.readByteArray()直接读取内存数据 将二进制数据传回Python端保存为文件 4. 文件修复的必要性 为什么需要修复: 从内存dump出来的SO文件可能缺少某些段信息 内存中的地址是虚拟地址,需要转换为文件偏移 某些重定位信息可能已被修改 SoFixer工具原理: # SoFixer的作用 /data/local/tmp/SoFixer -m 基址 -s 源文件 -o 输出文件 -m:指定原始内存基址,用于地址重定位 重建ELF文件头和段表 修复符号表和重定位表 确保文件可以被IDA、Ghidra等工具正确解析
脚本如下:
我希望用aya工具箱进行远程连接手机,手机打开远程调试功能,输入ip和端口就可以远程adb了。
注意要将adb命令配置为环境变量,这样这个脚本就可以进行使用adb进行远程拉取脱壳后的so了。我们会发现目录下多了一个修复好的so文件。
注意过掉frida检测。我这个app过检测的方法是在/data/app的/lib下删除libmasaosec.so这个验证文件,如果不删除在执行脚本时会发现应用闪退。
我执行的命令如下
# 指定设备
python dump_so.py -d 192.168.1.164:41309 -p com.shizhuanxxxxx.duapp -s libGameVMP.so
Multiple devices found: 1. 192.168.1.164:41309 2. adb-KCAIKN05L048ZAF-ovuptT._adb-tls-connect._tcp Select device (1-2): 1 Started and attached to process: com.shizhuanxxxx.duapp (PID: 5913) Frida script loaded Looking for module: libGameVMP.so Module not found, retry 1/10... Found module info: {'name': 'libGameVMP.so', 'version': None, 'base': '0x725d4cf000', 'size': 454656, 'path': '/data/app/~~HP4rmsQIdDjYodK-wFzpgg==/com.shizhuanxxxxx.duapp-53DwSEmI6IWzTVKI_jTzYg==/lib/arm64/libGameVMP.so'} Starting dump of module: base=0x725d4cf000, size=454656 Saved dump file: libGameVMP.so.dump.so Device architecture: arm64 android/SoFixer64: 1 file pushed, 0 skipped. 4.8 MB/s (2672240 bytes in 0.536s) libGameVMP.so.dump.so: 1 file pushed, 0 skipped. 23.0 MB/s (454656 bytes in 0.019s) adb -s 192.168.1.164:41309 shell /data/local/tmp/SoFixer -m 0x725d4cf000 -s /data/local/tmp/libGameVMP.so.dump.so -o /data/local/tmp/libGameVMP.so.dump.so.fix.so [main_loop:87]start to rebuild elf file [Load:69]dynamic segment have been found in loadable segment, argument baseso will be ignored. [RebuildPhdr:25]=============LoadDynamicSectionFromBaseSource==========RebuildPhdr========================= [RebuildPhdr:37]=====================RebuildPhdr End====================== [ReadSoInfo:552]=======================ReadSoInfo========================= [ReadSoInfo:699]soname [ReadSoInfo:624] constructors (DT_INIT) found at 20230 [ReadSoInfo:632] constructors (DT_INIT_ARRAY) found at 6b8e0 [ReadSoInfo:636] constructors (DT_INIT_ARRAYSZ) 35 [ReadSoInfo:640] destructors (DT_FINI_ARRAY) found at 6b9f8 [ReadSoInfo:644] destructors (DT_FINI_ARRAYSZ) 2 [ReadSoInfo:583]string table found at 10f0 [ReadSoInfo:587]symbol table found at 568 [ReadSoInfo:598] plt_rel_count (DT_PLTRELSZ) 123 [ReadSoInfo:594] plt_rel (DT_JMPREL) found at 2110 [ReadSoInfo:702]Unused DT entry: type 0x00000009 arg 0x00000018 [ReadSoInfo:702]Unused DT entry: type 0x00000018 arg 0x00000000 [ReadSoInfo:702]Unused DT entry: type 0x6ffffffb arg 0x00000001 [ReadSoInfo:702]Unused DT entry: type 0x6ffffffe arg 0x000015e8 [ReadSoInfo:702]Unused DT entry: type 0x6fffffff arg 0x00000003 [ReadSoInfo:702]Unused DT entry: type 0x6ffffff0 arg 0x000014ee [ReadSoInfo:702]Unused DT entry: type 0x6ffffff9 arg 0x00000059 [ReadSoInfo:706]=======================ReadSoInfo End========================= [RebuildShdr:42]=======================RebuildShdr========================= [RebuildShdr:539]=====================RebuildShdr End====================== [RebuildRelocs:786]=======================RebuildRelocs========================= [RebuildRelocs:812]=======================RebuildRelocs End======================= [RebuildFin:712]=======================try to finish file rebuild ========================= [RebuildFin:736]=======================End========================= [main:123]Done!!! /data/local/tmp/libGameVMP.so.dump.so.fix....skipped. 4.3 MB/s (455601 bytes in 0.100s) Fixed SO file: libGameVMP.so_0x725d4cf000_454656_fix.so Cleaned up temporary files PS C:\Users\21558\Downloads\frida_dump-master>
最后我们分析一下js关键代码
dump_so.js // 定义RPC导出对象,这些函数可以被Python端调用 rpc.exports = { // 查找指定名称的模块函数 findmodule: function (so_name) { // 使用Process.findModuleByName()在当前进程中查找指定名称的模块 // so_name: 要查找的SO文件名,如"libnative.so" var libso = Process.findModuleByName(so_name); // 返回模块对象,包含name、base、size、path等信息 // 如果找不到模块则返回null return libso; }, // dump指定模块的内存数据函数 dumpmodule: function (so_name) { // 首先查找指定名称的模块 var libso = Process.findModuleByName(so_name); // 如果模块不存在,返回-1表示失败 if (libso == null) { return -1; } // 修改内存保护属性为可读写执行(rwx) // 这是为了确保我们能够读取模块的所有内存区域 // ptr(libso.base): 将基址转换为指针对象 // libso.size: 模块的大小 // 'rwx': 读(r)写(w)执行(x)权限 Memory.protect(ptr(libso.base), libso.size, 'rwx'); // 从模块基址开始读取整个模块的字节数据 // ptr(libso.base): 模块在内存中的起始地址 // readByteArray(libso.size): 读取指定大小的字节数组 var libso_buffer = ptr(libso.base).readByteArray(libso.size); // 将读取的缓冲区数据附加到模块对象上(可选,用于调试) libso.buffer = libso_buffer; // 返回读取到的字节数组,这就是SO文件的完整内存映像 return libso_buffer; }, // 获取所有已加载模块的函数 allmodule: function () { // Process.enumerateModules()返回当前进程中所有已加载模块的数组 // 每个模块对象包含:name(名称)、base(基址)、size(大小)、path(路径) return Process.enumerateModules() }, // 获取当前设备架构的函数 arch: function () { // Process.arch返回当前进程的CPU架构 // 可能的值:'arm', 'arm64', 'ia32', 'x64' // 这个信息用于选择正确的SoFixer工具版本 return Process.arch; } }
dump_dex.js
// 获取当前进程名称的函数 function get_self_process_name() { // 找到open导出函数 var openPtr = Module.getExportByName('libc.so', 'open'); // NativeFunction是c和js函数的桥梁。创建open函数的NativeFunction包装,参数:返回类型int,参数类型[pointer, int]。将一个已知地址的原生 C 函数 open,包装成一个可以被 JavaScript 直接、安全调用的 JavaScript 函数 open。它定义了如何转换参数和返回值,使得两个不同语言的世界能够无缝通信。 var open = new NativeFunction(openPtr, 'int', ['pointer', 'int']); // 找到read导出函数 var readPtr = Module.getExportByName("libc.so", "read"); // 创建read函数的NativeFunction包装,参数:返回类型int,参数类型[int, pointer, int] var read = new NativeFunction(readPtr, "int", ["int", "pointer", "int"]); // 获取libc.so中close函数的地址 var closePtr = Module.getExportByName('libc.so', 'close'); // 创建close函数的NativeFunction包装,参数:返回类型int,参数类型[int] var close = new NativeFunction(closePtr, 'int', ['int']); // Memory.allocUtf8String() 的作用就是充当 js和 C串指针之间的桥梁。 // 这个文件包含当前进程的命令行参数,第一个参数就是进程名 var path = Memory.allocUtf8String("/proc/self/cmdline"); // 打开文件,参数0表示只读模式 var fd = open(path, 0); // 如果文件打开成功(文件描述符不等于-1) if (fd != -1) { // 分配4KB内存用于读取文件内容 var buffer = Memory.alloc(0x1000); // 从文件中读取数据到缓冲区 var result = read(fd, buffer, 0x1000); // 关闭文件 close(fd); // 将缓冲区内容转换为C字符串并返回 result = ptr(buffer).readCString(); return result; } // 如果获取失败,返回"-1" return "-1"; } // 创建目录 function mkdir(path) { // 获取libc.so中mkdir函数的地址 var mkdirPtr = Module.getExportByName('libc.so', 'mkdir'); // 创建mkdir函数的NativeFunction包装 var mkdir = new NativeFunction(mkdirPtr, 'int', ['pointer', 'int']); // 获取libc.so中opendir函数的地址,用于检查目录是否存在 var opendirPtr = Module.getExportByName('libc.so', 'opendir'); // 创建opendir函数的NativeFunction包装 var opendir = new NativeFunction(opendirPtr, 'pointer', ['pointer']); // 获取libc.so中closedir函数的地址 var closedirPtr = Module.getExportByName('libc.so', 'closedir'); // 创建closedir函数的NativeFunction包装 var closedir = new NativeFunction(closedirPtr, 'int', ['pointer']); // 将js路径字符串转换为C字符串 var cPath = Memory.allocUtf8String(path); // 尝试打开目录,检查是否存在 var dir = opendir(cPath); // 如果目录存在(opendir返回非0值) if (dir != 0) { // 关闭目录句柄 closedir(dir); // 目录已存在,直接返回 return 0; } // 目录不存在,创建目录,权限设置为755(rwxr-xr-x) mkdir(cPath, 755); // 设置目录权限 chmod(path); } // 修改文件/目录权限的函数 function chmod(path) { // 获取libc.so中chmod函数的地址 var chmodPtr = Module.getExportByName('libc.so', 'chmod'); // 创建chmod函数的NativeFunction包装 var chmod = new NativeFunction(chmodPtr, 'int', ['pointer', 'int']); // 将路径字符串转换为C字符串 var cPath = Memory.allocUtf8String(path); // 设置权限为755(rwxr-xr-x) chmod(cPath, 755); } // DEX文件dump的核心函数 function dump_dex() { // 查找libart.so模块,这是Android Runtime的核心库 var libart = Process.findModuleByName("libart.so"); // 初始化DefineClass函数地址为null var addr_DefineClass = null; // 枚举libart.so中的所有符号 var symbols = libart.enumerateSymbols(); // 遍历所有符号,查找DefineClass函数 for (var index = 0; index < symbols.length; index++) { var symbol = symbols[index]; var symbol_name = symbol.name; // 这个DefineClass的函数签名是Android9的 // _ZN3art11ClassLinker11DefineClassEPNS_6ThreadEPKcmNS_6HandleINS_6mirror11ClassLoaderEEERKNS_7DexFileERKNS9_8ClassDefE // 通过符号名称特征匹配DefineClass函数 // _ZN3art11ClassLinker11DefineClassEPNS_6ThreadEPKcmNS_6HandleINS_6mirror11ClassLoaderEEERKNS_7DexFileERKNS9_8ClassDefE // 拆解后就是: // 符号部分 含义(“地址”组成部分) 通俗解释 // _ZN ... E 开始和结束标记 这是一个“修饰名”的包裹。 // 3art 命名空间 (Namespace) art。Android Runtime,就是安卓的系统核心。 // 11ClassLinker 类名 (Class) ClassLinker 类。这是ART里一个负责加载和链接类的“管理员”。 // 11DefineClass 函数名 (Function) DefineClass 方法。这是这个“管理员”的核心工作:定义一个类。 // EPNS_6ThreadE 参数1 (Parameter 1) art::Thread*。需要一个线程指针。就像办事要指明是哪个“工作人员”在处理。 // PKc 参数2 (Parameter 2) const char*。一个字符串,通常是类的描述符(如 "java/lang/String")。 // m 参数3 (Parameter 3) size_t。一个数字,表示上面字符串的长度。 // PNS_6HandleINS_6mirror11ClassLoaderEEE 参数4 (Parameter 4) art::Handle<art::mirror::ClassLoader>。一个类加载器对象的句柄。告诉系统用哪个“工具箱”(比如App自己的还是系统的)来加载这个类。 // RKNS_7DexFileE 参数5 (Parameter 5) const art::DexFile&。一个Dex文件的常量引用。这是最重要的参数!它告诉管理员:“请从这个DEX文件包裹里”取出类来。 // RKNS9_8ClassDefE 参数6 (Parameter 6) const art::DexFile::ClassDef&。一个类定义的常量引用。它进一步指明:“就取这个包裹里特定的那一份文件(类定义)”。 // 所以,这个函数到底是干嘛的? // 它的核心工作就一件事: // 当一个Android App运行时,系统需要把DEX文件(打包好的Java代码)里的类加载到内存中才能执行。 // 这个 DefineClass 函数就是ART虚拟机里负责这项工作的“首席加载官”。 // 调用它的过程,就像是下指令: // “喂!ART系统的ClassLinker管理员!(3art11ClassLinker) // 现在请你 (11DefineClass): // 在当前这个线程 (EPNS_6ThreadE) 上, // 根据这个名字叫"com/example/MyClass" (PKc) 长度是XX (m) 的类, // 使用App提供的这个类加载器 (PNS_6HandleINS_6mirror11ClassLoaderEEE), // 从这个DEX文件里 (RKNS_7DexFileE), // 找到这个类的具体定义数据 (RKNS9_8ClassDefE), // 然后把它在内存里创建出来!” // 为什么Dump Dex的脚本要Hook它? // 这正是脚本聪明的地方! // 时机完美:这个函数被调用时,意味着系统正在主动地读取并加载一个DEX文件中的类。此时,整个DEX文件肯定已经完整地映射到内存中了。 // 信息齐全:这个函数的参数就像一个“情报包”,直接包含了两个关键情报: // RKNS_7DexFileE: DexFile对象的内存地址。通过这个对象,脚本就能顺藤摸瓜找到DEX文件在内存中的起始地址 (begin_) 和大小 (size_)。 // 这样,脚本就不需要漫无目的地搜索内存,而是在这个函数被调用时,直接“领取”了DEX文件的地址和大小,然后把它 dump 到磁盘上。 // 总结一下:这个奇怪的字符串就是ART虚拟机里“加载类”这个核心功能员的完整身份证。Hook它,就能在最合适的时机、用最直接的方式,拿到我们想要dump的DEX文件的内存地址。 if (symbol_name.indexOf("ClassLinker") >= 0 && symbol_name.indexOf("DefineClass") >= 0 && symbol_name.indexOf("Thread") >= 0 && symbol_name.indexOf("DexFile") >= 0) { console.log(symbol_name, symbol.address); // 保存找到的DefineClass函数地址 addr_DefineClass = symbol.address; } } // 用于存储已发现的DEX文件映射(基址->大小) var dex_maps = {}; // DEX文件计数器,用于生成文件名 var dex_count = 1; console.log("[DefineClass:]", addr_DefineClass); // 如果找到了DefineClass函数 if (addr_DefineClass) { // hook DefineClass函数 Interceptor.attach(addr_DefineClass, { // 函数调用前的回调 onEnter: function (args) { // _ZN3art11ClassLinker11DefineClassEPNS_6ThreadEPKcmNS_6HandleINS_6mirror11ClassLoaderEEERKNS_7DexFileERKNS9_8ClassDefE //真实的参数列表是这样的: // 序号 参数 对应 args[] // 0 this (指向 ClassLinker 对象的指针) args[0] // 1 art::Thread* thread args[1] // 2 const char* descriptor args[2] // 3 size_t hash args[3] // 4 art::Handle... class_loader args[4] // 5 const art::DexFile& dex_file <-- 目标 args[5] // 6 const art::DexFile::ClassDef& class_def args[6] // 结论: // dex_file 参数在函数的正式参数列表中排在第5位(从0开始数),是因为它前面还有一个看不见的“第0号”参数——this 指针。 // 所以,args[5] 取到的就是传递给 DefineClass 函数的 dex_file 参数。 var dex_file = args[5]; // 这是DEX文件在内存中的起始地址 var base = ptr(dex_file).add(Process.pointerSize).readPointer(); // 这是DEX文件的大小 var size = ptr(dex_file).add(Process.pointerSize + Process.pointerSize).readUInt(); // 如果这个DEX文件还没有被记录过 if (dex_maps[base] == undefined) { // 记录DEX文件的基址和大小 dex_maps[base] = size; // 读取DEX文件的魔数(前几个字节) var magic = ptr(base).readCString(); // 检查是否是有效的DEX文件(以"dex"开头) if (magic.indexOf("dex") == 0) { // 获取当前进程名 var process_name = get_self_process_name(); if (process_name != "-1") { // 构建dump目录路径 var dex_dir_path = "/data/data/" + process_name + "/files/dump_dex_" + process_name; // 创建dump目录 mkdir(dex_dir_path); // 构建DEX文件路径,第一个文件名为class.dex,后续为class2.dex, class3.dex... var dex_path = dex_dir_path + "/class" + (dex_count == 1 ? "" : dex_count) + ".dex"; console.log("[find dex]:", dex_path); // 创建文件用于写入 var fd = new File(dex_path, "wb"); if (fd && fd != null) { // 增加DEX文件计数 dex_count++; // 从内存中读取完整的DEX文件数据 var dex_buffer = ptr(base).readByteArray(size); // 写入文件 fd.write(dex_buffer); // 刷新缓冲区 fd.flush(); // 关闭文件 fd.close(); console.log("[dump dex]:", dex_path); } } } } }, // 函数调用后的回调(这里为空) onLeave: function (retval) { } }); } } // 标记是否已经hook了libart.so var is_hook_libart = false; // hook动态库加载函数的函数 function hook_dlopen() { // hook标准的dlopen函数 在较老版本的Android,或者一些非常规的、直接调用标准C库的场景中使用。 Interceptor.attach(Module.findExportByName(null, "dlopen"), { // dlopen调用前的回调 onEnter: function (args) { // args[0]是库文件路径参数 var pathptr = args[0]; if (pathptr !== undefined && pathptr != null) { // 读取库文件路径字符串 var path = ptr(pathptr).readCString(); //console.log("dlopen:", path); // 如果正在加载libart.so if (path.indexOf("libart.so") >= 0) { // 标记可以hook libart this.can_hook_libart = true; console.log("[dlopen:]", path); } } }, // dlopen调用后的回调 onLeave: function (retval) { // 如果可以hook libart且还没有hook过 if (this.can_hook_libart && !is_hook_libart) { // 开始dump DEX文件 dump_dex(); // 标记已经hook过了 is_hook_libart = true; } } }) // hook Android特有的android_dlopen_ext函数 //例如 Java 代码中加载原生库 // static { // System.loadLibrary("my-native-lib"); // 这会触发 dlopen 或 android_dlopen_ext // } 在现代Android版本中,系统内部加载核心库(如 libart.so)时,更倾向于使用这个功能更强的函数。 Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { // 调用前的回调 onEnter: function (args) { // args[0]是库文件路径参数 var pathptr = args[0]; if (pathptr !== undefined && pathptr != null) { // 读取库文件路径字符串 var path = ptr(pathptr).readCString(); //console.log("android_dlopen_ext:", path); // 如果正在加载libart.so if (path.indexOf("libart.so") >= 0) { // 标记可以hook libart this.can_hook_libart = true; console.log("[android_dlopen_ext:]", path); } } }, // android_dlopen_ext调用后的回调 onLeave: function (retval) { // 如果可以hook libart且还没有hook过 if (this.can_hook_libart && !is_hook_libart) { // 开始dump DEX文件 dump_dex(); // 标记已经hook过了 is_hook_libart = true; } } }); } // 立即执行dump_dex函数(如果libart.so已经加载) setImmediate(dump_dex);
脱出所有dex和so的测试脚本
https://gitee.com/null_465_7266/dump-all-so/tree/master
使用方法
PS C:\Users\21558\Documents\dumpallso\dump-all-so> python dump_so.py -p com.shizhuang.duapp --app-path "/data/app/~~YxjKiMfU5GhbqDTDhPhhpw==/com.shizhuang.duapp-f6V4lziH2H4ySLWwb1S4_A==/lib/arm64