Frida学习笔记(二十三):SO Dump 与内存 Dump
原创
泡泡以安
泡泡以安
泡泡以安
2026年6月26日 08:17
浙江
在小说阅读器读本章
去阅读
加固 SO 静态分析时 IDA 看到的是乱码,但代码总得在某一刻被还原成可执行的明文——这一篇讲的就是怎么在 SO 解密之后的窗口里把它整段读出来、修好 ELF 头给 IDA。配套的还有一类常见需求:dump 出运行时密钥/Token 的位置,顺手在 §4 一起讲。
一、为什么需要 SO Dump
1.1 加固对 SO 的保护
第26篇讲 DEX 脱壳(Java 层用 frida-dexdump 一行就能搞定)。Native 层就没有这么轻松——加固方案通常这么干:
| 保护方式 | 效果 | 静态分析能看到什么 |
|---|---|---|
| SO 加密 | APK 中的 SO 文件被加密,运行时由壳解密 | IDA 打开看到乱码 |
| SO 压缩 | SO 被自定义压缩,运行时解压 | IDA 无法识别 ELF 格式 |
| 代码段加密 | SO 的 .text 段被加密,JNI\_OnLoad 中解密 | IDA 能打开但函数全是乱码 |
| 字符串加密 | SO 中的字符串被加密,运行时解密使用 | IDA 中看不到有意义的字符串 |
| 导出表混淆 | 真实的导出函数名被替换 | findExportByName 找不到函数 |
这些保护的共同特点是:运行时 SO 的代码必须被还原为可执行的明文状态——否则 CPU 就无法执行。Dump 的时机就是在这个还原完成之后。
1.2 Dump 的时机选择
SO 从加载到运行的整条生命周期上,有 4 个值得下手的窗口。没加固时任意时刻 dump 都行(下图窗口 ③);加固方案则需要更早下手——窗口 ② JNI\_OnLoad 返回是大多数场景的最佳时机,窗口 ④ 是 Anti-Dump 自毁前最后的窗口。
SO 加载生命周期与 4 个 dump 窗口
四种方案分别在 §2 / §5.1 / §5.2 / §5.3 展开。
二、方案一:从 /proc/self/maps 定位 + Memory.copy Dump
2.1 原理
Linux 系统中,/proc/self/maps 列出了进程的所有内存映射区域。SO 被加载后,其代码段(.text)和数据段(.data)会作为独立的映射区域出现在 maps 中。
2.2 完整的 SO Dump 脚本
// so_dump.js
// 从进程内存中 dump 指定的 SO 文件
// 用法: frida -U -n com.example.app -l so_dump.js
var TARGET_SO = \"libnative.so\"; // 要 dump 的 SO 名称
// 纯 Native 操作,无需 Java.perform 包装
(function () {
var module = Process.findModuleByName(TARGET_SO);
if (!module) {
console.log(\"[!] \" + TARGET_SO + \" 未加载\");
return;
}
console.log(\"[*] 找到 \" + TARGET_SO);
console.log(\" 基地址: \" + module.base);
console.log(\" 大小: \" + module.size + \" bytes (\" + (module.size / 1024).toFixed(1) + \" KB)\");
console.log(\" 路径: \" + module.path);
// 防止加固把 .text 设为 --x(仅可执行不可读)导致 readByteArray 触发 SIGSEGV
Memory.protect(module.base, module.size, 'rwx');
var buf = module.base.readByteArray(module.size);
send({
type: \"so_dump\",
name: TARGET_SO,
base: module.base.toString(),
size: module.size
}, buf);
console.log(\"[*] SO 已发送到电脑端(\" + module.size + \" bytes)\");
})();
Python 端接收并保存:
# dump_receiver.py
import frida
import sys
def on_message(message, data):
if message['type'] == 'send' and data:
payload = message['payload']
if payload.get('type') == 'so_dump':
filename = payload['name'] + '.dump'
with open(filename, 'wb') as f:
f.write(data)
print(f\"[*] 已保存: {filename} ({len(data)} bytes)\")
print(f\" 基地址: {payload['base']}\")
device = frida.get_usb_device()
session = device.attach(\"com.example.app\")
with open(\"so_dump.js\") as f:
script = session.create_script(f.read())
script.on('message', on_message)
script.load()
input(\"按回车退出...\")
session.detach()
2.3 精确 Dump:按内存段分别导出
上面的简单方式是一次性 dump 整个模块。但 SO 在内存中的布局可能和文件中不同(代码段和数据段之间可能有间隔)。更精确的方式是按段导出:
// so_dump_precise.js
// 精确的 SO dump:按照 /proc/self/maps 中的映射段导出
var TARGET_SO = \"libnative.so\";
(function() {
var module = Process.findModuleByName(TARGET_SO);
if (!module) {
console.log(\"[!] \" + TARGET_SO + \" 未加载\");
return;
}
console.log(\"[*] \" + TARGET_SO + \" @ \" + module.base + \" (\" + module.size + \" bytes)\
\");
// === Step 1: 枚举模块的所有可读段 ===
var ranges = module.enumerateRanges(\"r--\");
console.log(\"[*] 内存段 (\" + ranges.length + \" 个):\");
ranges.forEach(function(range, i) {
var offset = range.base.sub(module.base);
console.log(\" 段\" + i + \": \" + range.base + \" - \" + range.base.add(range.size) +
\" (\" + range.protection + \") \" + range.size + \" bytes\" +
\" (偏移 0x\" + offset.toString(16) + \")\");
});
// === Step 2: 计算合并后的总大小(最后一段末尾 - 基址) ===
var lastRange = ranges[ranges.length - 1];
var totalSize = lastRange.base.add(lastRange.size).sub(module.base).toInt32();
console.log(\"\
[*] 总大小: \" + totalSize + \" bytes\");
// === Step 3: 分配输出缓冲区 ===
// Memory.alloc 返回的页是零初始化的,不需要再 fill;
// 老教程里 `new Array(size).fill(0)` 在 2 MB+ 模块上会卡几秒。
var outputBuf = Memory.alloc(totalSize);
// === Step 4: 逐段拷贝到输出缓冲区 ===
ranges.forEach(function(range) {
var offset = range.base.sub(module.base).toInt32();
try {
Memory.copy(outputBuf.add(offset), range.base, range.size);
} catch(e) {
console.log(\" [!] 段复制失败 @ \" + range.base + \": \" + e.message);
}
});
// === Step 5: 一次性发送 ===
var dumpData = outputBuf.readByteArray(totalSize);
send({ type: \"so_dump\", name: TARGET_SO, size: totalSize }, dumpData);
console.log(\"\
[*] Dump 完成,已发送\");
})();
上面两个脚本默认目标 SO 已经在内存里。如果 SO 是 App 启动后才动态加载的(常见于加固 SO),或者你想精确卡在 JNI\_OnLoad 解密结束的瞬间,看 §5.1。
三、ELF 修复:让 dump 出来的 SO 能进 IDA
3.1 为什么 dump 的 SO 可能无法直接分析
从内存 dump 出的 SO 和原始的 SO 文件有几个关键区别:
- Section Header Table 缺失或损坏:linker 在加载 SO 时只使用 Program Header(段信息),不需要 Section Header(节信息)。但 IDA 的分析依赖 Section Header 来划分代码区域和数据区域。
- 地址重定位:内存中的 SO 已经经过了重定位(基地址 + 偏移),而 IDA 期望的是未重定位的文件格式。
- 空白填充:内存映射中段与段之间的空隙被零填充,但文件格式中这些空隙不存在。
APK 中的 SO vs 内存 dump 出的 SO 布局对比
3.2 使用 SoFixer 修复
SoFixer 是一个专门修复内存 dump SO 的工具:
# 使用 SoFixer 修复
# 参数: -s 源文件 -o 输出文件 -m 基地址 -d (dump模式)
./SoFixer -s libnative.so.dump -o libnative_fixed.so -m 0x7a8b000000 -d
基地址从 Frida dump 脚本的输出中获取。
3.3 兜底:30 行的极简修复版
§3.2 的 SoFixer 通常够用——下面这个版本是为了让你看明白「修复在做什么」(其实就是清几个字段),也作为 SoFixer 报错时的兜底:
# fix_elf.py
# 简单的 ELF Section Header 修复,兼容 ELF32 / ELF64
import struct
# ELF32 与 ELF64 的 e_shoff / e_shnum / e_shstrndx 偏移与宽度不同
LAYOUT = {
1: {\"shoff\": (32, '<I', 4), \"shnum\": (48, '<H', 2), \"shstrndx\": (50, '<H', 2)}, # ELFCLASS32
2: {\"shoff\": (40, '<Q', 8), \"shnum\": (60, '<H', 2), \"shstrndx\": (62, '<H', 2)}, # ELFCLASS64
}
def fix_dump(input_path, output_path):
with open(input_path, 'rb') as f:
data = bytearray(f.read())
if data[:4] != b'\\x7fELF':
print(\"[!] 不是有效的 ELF 文件\")
return
ei_class = data[4] # 1=ELF32, 2=ELF64
if ei_class not in LAYOUT:
print(f\"[!] 未知 EI_CLASS={ei_class}\")
return
L = LAYOUT[ei_class]
print(f\"[*] ELF{'32' if ei_class == 1 else '64'}\")
e_shoff = struct.unpack_from(L[\"shoff\"][1], data, L[\"shoff\"][0])[0]
e_shnum = struct.unpack_from(L[\"shnum\"][1], data, L[\"shnum\"][0])[0]
e_shstrndx = struct.unpack_from(L[\"shstrndx\"][1], data, L[\"shstrndx\"][0])[0]
print(f\" Section Header: offset=0x{e_shoff:x}, num={e_shnum}, strndx={e_shstrndx}\")
# Section Header 偏移指向文件外(dump 后变小) → 清零,让 IDA 走 Program Header 路径
if e_shoff > len(data) or e_shnum == 0:
print(\"[*] 清理无效的 Section Header 引用\")
struct.pack_into(L[\"shoff\"][1], data, L[\"shoff\"][0], 0)
struct.pack_into(L[\"shnum\"][1], data, L[\"shnum\"][0], 0)
struct.pack_into(L[\"shstrndx\"][1], data, L[\"shstrndx\"][0], 0)
with open(output_path, 'wb') as f:
f.write(data)
print(f\"[*] 修复完成: {output_path}\")
print(f\" IDA: File → Load file → ELF (勾 'Manual load' + 'Force load')\")
fix_dump(\"libnative.so.dump\", \"libnative_fixed.so\")
32-bit SO 提醒:如果你的目标 App 还在跑
armeabi-v7a(老游戏 / 海外 App / 加固兜底),IDA 处加载时记得选 ARM Little-endian 32-bit;SoFixer 同一条命令照用,无需切工具。IDA 加载技巧:打开修复后的 SO 时,IDA 可能会报警告。选择「Manual load」并勾选「Force load」可以强制加载。即使 Section Header 缺失,IDA 仍然能基于 Program Header 进行反汇编和伪代码生成——只是函数名识别和字符串引用可能不如完整 SO 精确。
四、配套场景:用第 14 篇的算法自吐定位密钥位置
第 14 篇的「算法自吐」能让你拿到密钥的值,但值在哪里产生才是更值钱的信息——判断密钥是硬编码常量、SO 数据段被解出的、还是堆上运行时计算的,直接决定后续怎么搞:硬编码就直接静态拿,运行时生成就要 Hook 派生函数。
// dump_key_location.js
var KNOWN_KEY_HEX = \"0123456789abcdef0123456789abcdef\"; // 算法自吐拿到的 16 字节 AES key
(function () {
var pattern = KNOWN_KEY_HEX.match(/.{2}/g).join(\" \");
Process.enumerateRanges(\"r--\").forEach(function (range) {
try {
Memory.scanSync(range.base, range.size, pattern).forEach(function (match) {
var mod = Process.findModuleByAddress(match.address);
var loc = mod ? mod.name + \"+0x\" + match.address.sub(mod.base).toString(16)
: \"堆/栈 @ \" + match.address;
console.log(\"\
[KEY] \" + loc + \" prot=\" + range.protection);
if (range.protection.indexOf(\"x\") !== -1) console.log(\" → 代码段:硬编码常量\");
else if (mod && mod.path.indexOf(\"/data/\") === 0) console.log(\" → App SO 数据段:可能是初始化时解出的\");
else if (!mod) console.log(\" → 堆/栈:运行时派生\");
console.log(hexdump(match.address.sub(32), { length: 96 }));
});
} catch (e) {}
});
})();
通用的「全进程搜字符串/特征码」Memory.scanSync 用法跟上面一样,改 pattern 即可——比如搜 AES S-Box 63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76 定位加密实现。
五、精准定位解密时机:在加固 SO 真正「脱壳」的瞬间下手
前面说「任意时刻 dump」对没加固的 SO 够用,但对二次解密、SO 自毁(Anti-Dump)、代码段重新加密等高级保护无效——你 dump 到的可能是早已被擦除的中间态,或者根本读不到 .text。下面给三种「精确踩点」。
§5.1 和 §5.3 都会调一个公共 dump 函数,先粘在这:
// 强制 rwx 再 read,避免 --x 段触发 SIGSEGV;
// 一次整块读,段间空隙交给 SoFixer 处理。
function dumpAndSend(soName) {
var m = Process.findModuleByName(soName);
if (!m) { console.log(\"[!] \" + soName + \" 未加载\"); return; }
Memory.protect(m.base, m.size, 'rwx');
send({ type: \"so_dump\", name: soName,
base: m.base.toString(), size: m.size },
m.base.readByteArray(m.size));
}
5.1 Hook JNI\_OnLoad onLeave
很多加固在 JNI_OnLoad 里完成最后一轮解密。早期的简化做法是 dlopen onLeave 后 setTimeout(500ms) 等 JNI\_OnLoad 大概率跑完——能用,但不准。准确做法是 Hook JNI_OnLoad 它自己的 onLeave:dlopen 返回之后立刻 dump 太早(可能拿到部分加密版),JNI\_OnLoad 进入之后立刻 dump 又太晚(解密还没做),返回那一刻正好。
// dump_after_jnionload.js
function hookJniOnLoadReturn(soName) {
var hooked = false;
[\"dlopen\", \"android_dlopen_ext\"].forEach(function (name) {
Interceptor.attach(Module.findExportByName(null, name), {
onEnter: function (args) {
this.path = args[0].readCString();
},
onLeave: function (retval) {
if (hooked) return;
var path = this.path || \"\";
if (path.indexOf(soName) === -1) return;
// ⚠ 不要用 Process.findModuleByAddress(retval):
// Android 10+ Bionic 对 dlopen 句柄做了加密混淆,
// retval 不是模块基址,会拿到 null。用 SO 名字找模块才稳:
var mod = Process.findModuleByName(soName);
if (!mod) return;
var jniOnLoad = Module.findExportByName(soName, \"JNI_OnLoad\");
if (!jniOnLoad) return;
hooked = true;
Interceptor.attach(jniOnLoad, {
onLeave: function () {
// 这是 .text 段最稳定的\"明文窗口\"
console.log(\"[*] \" + soName + \" JNI_OnLoad returned, dumping now\");
dumpAndSend(soName);
}
});
}
});
});
}
hookJniOnLoadReturn(\"libsecure.so\");
Android 10+ 别用
findModuleByAddress(retval):Bionic(Android 的 libc)把 dlopen 句柄做了加密混淆,它不是模块基址,永远找不到模块。从path提取 SO 名再findModuleByName才稳。
Android arm64 user 空间 vs dlopen 句柄实测值
5.2 用写入断点定位解密循环
如果加固没集中到 JNI_OnLoad、而是「边执行边解密」(像 OLLVM + 自定义 .text 段加密),可以反着抓——先让 .text 不可读,等谁来解密时被中断、就拿到了解密代码:
// catch_decrypt_routine.js
// 思路:把 .text 设为不可读,等访问异常被触发,看是谁触发的
var TARGET = \"libsecure.so\";
var mod = Process.findModuleByName(TARGET);
if (mod) {
var textSeg = mod.enumerateRanges(\"r-x\")[0]; // 首段通常是 .text
Memory.protect(textSeg.base, textSeg.size, \"---\");
Process.setExceptionHandler(function (ex) {
if (ex.type === \"access-violation\"
&& ex.memory && ex.memory.address
&& ex.memory.address.compare(textSeg.base) >= 0
&& ex.memory.address.compare(textSeg.base.add(textSeg.size)) < 0) {
console.log(\"[!] decrypt routine PC=\" + ex.context.pc
+ \" 访问 .text @ \" + ex.memory.address);
// 恢复保护,让程序继续执行
Memory.protect(textSeg.base, textSeg.size, \"rwx\");
return true; // 异常已处理
}
return false;
});
}
打印的 PC 就是壳的解密例程入口。把它在 IDA 里定位,能看清整个解密算法。
ex.memory.address必须按.text范围过滤:不过滤就完全淹没在 ART 的 write-barrier / card-table / JIT 良性缺页里。另外enumerateRanges('r-x')[0]只对单段 .text 的简单 SO 安全,多段.text的加固方案要遍历所有r-x段。
setExceptionHandler:地址过滤前后的信噪比对比
5.3 检测 SO 自毁,赶在擦除之前 dump
部分加固会在主线程稳定运行后主动擦除原始 .text 字节(Anti-Dump)。两条对策:
// 对策 A:JNI_OnLoad 一返回就立即 dump(时间窗口最大)
// 见 §5.1
// 对策 B:监控 mprotect/mremap,看谁在改 .text 段权限
var mprotect = Module.findExportByName(\"libc.so\", \"mprotect\");
Interceptor.attach(mprotect, {
onEnter: function (args) {
var addr = args[0];
var size = args[1].toInt32();
var prot = args[2].toInt32();
var mod = Process.findModuleByAddress(addr);
if (mod && mod.name === \"libsecure.so\") {
console.log(\"[mprotect] \" + addr + \" size=0x\" + size.toString(16)
+ \" prot=\" + prot + \" by \" + DebugSymbol.fromAddress(this.returnAddress));
// PROT_READ=1 / PROT_WRITE=2 / PROT_EXEC=4
// 若即将丢失可读位 → Anti-dump 擦除前最后窗口,立即 dump
if ((prot & 1) === 0) {
dumpAndSend(\"libsecure.so\");
}
}
}
});
为什么是 (prot & 1) === 0 而不是 (prot & 4) === 0?下面这张矩阵把 8 种 prot 取值与两种写法逐列对比,真机实测命中的 0x3 [rw-] 和 0x0 [---] 用淡黄高亮——能直接看到老教程写法在 [rw-] 上的 50% 误报率:
mprotect prot 参数:两种位检查的对比矩阵
六、实战 Case:dump 抖音 libmetasec_ml.so
抖音的核心加密在 libmetasec_ml.so(metasec 是字节内部安全 SDK MetaSec 的命名前缀,ml 指其机器学习风控子模块)。这个 SO 不被打包到 APK,而是从一个内嵌资源在运行时解密、写入 /data/data/.../app_msec/ 目录后再 dlopen。直接从 APK 提取一定是空的,必须 dump。
下面这两个文件就是配套仓库 scripts/frida23/ 里 24 行的精简版(改写自 lasting-yang/frida\_dump)。
6.1 工具源码(24 行能跑的版本)
// dump_so.js —— 仓库原版
rpc.exports = {
findmodule: function (so_name) {
return Process.findModuleByName(so_name);
},
dumpmodule: function (so_name) {
var libso = Process.findModuleByName(so_name);
if (libso == null) return -1;
Memory.protect(ptr(libso.base), libso.size, 'rwx'); // ← 关键
return ptr(libso.base).readByteArray(libso.size);
},
};
# dump_so.py —— 仓库原版(精简)
import frida, sys
JS = open(\"dump_so.js\").read()
def dump(so_name):
device = frida.get_usb_device()
pid = device.get_frontmost_application().pid
session = device.attach(pid)
script = session.create_script(JS)
script.load()
info = script.exports.findmodule(so_name)
print(f\"base={info['base']} size={info['size']}\")
buf = script.exports.dumpmodule(so_name)
if buf != -1:
with open(so_name + \".dump.so\", \"wb\") as f:
f.write(buf)
print(f\"wrote {so_name}.dump.so ({len(buf)} bytes)\")
if __name__ == \"__main__\":
dump(sys.argv[1] if len(sys.argv) > 1 else \"libmetasec_ml.so\")
24 行就是一把「够用的 SO dump 枪」。它的几个关键决策值得拆一下:
- 用 RPC 把 dump 操作做成 Python 主导,不依赖
send双端协议——script.exports.dumpmodule(...)一行同步拿到字节数组; Memory.protect(base, size, 'rwx')强制把模块标记为可读——很多加固会在JNI_OnLoad后把.text改成--x(仅可执行不可读),不强行 protect 直接readByteArray会触发 SIGSEGV;- 把整个模块视为一段连续内存(不区分段)一次读出,速度比逐段拼接快数倍。代价是模块中段间空隙会被零填充——这就是为什么后面 SoFixer 一定要跑。
6.2 操作步骤(实测)
# 1. 启动抖音 lite,等首页加载完毕(msec ML 已 dlopen)
adb shell am start -n com.ss.android.ugc.aweme.lite/.main.MainActivity
# 2. 跑 dump (假设 dump_so.js / dump_so.py 在当前目录)
python dump_so.py libmetasec_ml.so
# 输出: base=0x7b3c004000 size=2228224
# wrote libmetasec_ml.so.dump.so (2228224 bytes)
# 3. 用 SoFixer 修 ELF (从 https://github.com/F8LEFT/SoFixer 拿 release 二进制)
SoFixer -s libmetasec_ml.so.dump.so \\
-o libmetasec_ml.fixed.so \\
-m 0x7b3c004000 \\
-d
记下 base 0x7b3c004000,IDA 的 Manual load 弹窗里要用。
6.3 别跳过这一步:IDA 二次验证
不要相信「SoFixer 输出 success 就 OK」——很多 dump 在 IDA 里还是会出现 bad fixup、unresolvable reloc、伪代码全是 byte_xxx。验证步骤:
| 步骤 | 操作 | 通过标准 |
|---|---|---|
| ① 加载 | IDA → File → Open → libmetasec_ml.fixed.so,弹窗里勾 Manual load |
能选择 Processor type 为 ARM Little-endian (ARM64) |
| ② Loading offset | 弹窗「Loading address」填0(不是 dump 时的基址) | IDA 完成初始化无报错 |
| ③ 段识别 | View → Open subviews → Segments | 至少能看到 LOAD / .text / .dynamic 三段 |
| ④ 字符串 | View → Open subviews → Strings (Shift+F12) |
能看到正常字符串(如 \"libmetasec_ml\"、AES IV、域名等);如果全是乱码,说明二次解密还没完成,回到 §5.1 重抓 |
| ⑤ 导出表 | Exports 窗口(Ctrl+E) |
能看到 JNI_OnLoad、amazonaws_xxx 等真实导出名 |
| ⑥ 反汇编 | 跳到 JNI_OnLoad,按 F5 |
能生成伪代码且包含可读字符串引用(不是 *(char *)0xXXXXXX) |
| ⑦ 交叉引用 | 在伪代码里右键某个函数 → Jump to xref | 能跳到调用方 |
如果 ⑥ 失败:通常是 SoFixer 的 -m 基址写错了,或者 dump 时 JNI_OnLoad 还没执行完。回到 §5.1 用 JNI\_OnLoad onLeave 再 dump 一次。
6.4 反向验证:拿 dump 出的 SO 跑 frida-trace
最有力的「dump 是否成功」验证不是看 IDA,而是用 dump 出的 SO 推导出函数偏移、然后回到运行中的 App 用 frida-trace 验证它:
# 假设你在 IDA 里看到关键函数在 fixed SO 里偏移 0x1A340
frida-trace -U -n com.ss.android.ugc.aweme.lite \\
-a \"libmetasec_ml.so!0x1A340\"
# 如果触发到了、参数能打印——dump + IDA 分析链路全打通
6.5 从 24 行到通用工具:lasting-yang/frida\_dump 的演进
上面那 24 行就是 lasting-yang/frida\_dump 的内核。完整社区版多了 allmodule() 枚举、批量 dump、dump_dex.js(参考第 26 篇)等扩展,但核心还是同一把「枪」。值得抄过来的三个工程决策:
- RPC vs send:双向异步
send/recv适合流式数据;批量 dump 这种「我问你答」用 RPC,一行script.exports.dumpmodule(...)同步拿字节数组,省去on('message')回调和缓冲。 Memory.protect(rwx)在 read 之前:很多脚本漏了,遇到--x段直接 SIGSEGV。- 不做 ELF 修复,留给 SoFixer:单一职责。两步用 shell pipe 串起来即可,不要做大而全。
总结
| 场景 | 方案 | 时机 |
|---|---|---|
| 标准 SO(无加固) | Process.findModuleByName + readByteArray |
任意时刻 |
| 动态加载的 SO | Hook JNI_OnLoad onLeave(见 §5.1) |
JNI\_OnLoad 返回 |
| 加固 SO(有 JNI\_OnLoad 二次解密) | 同上 | onLeave |
| 加固 SO(边跑边解密) | Memory.protect(.text, '---') + 异常处理器 |
触发缺页时 |
| 加固 SO(Anti-Dump 自毁) | 监控 mprotect/mremap,赶在擦除前 dump |
检测到权限变更 |
| 配套需求:定位运行时密钥 | Memory.scanSync 已知值 + 内存属性归类 |
算法自吐拿到 key 后 |
dump 完不是终点,IDA 七步验证才是终点。如果 IDA 中 JNI_OnLoad 的 F5 伪代码没有可读字符串引用,回到 §5.1 重抓——这是最常见的「以为 dump 成功了其实没成」。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论