本篇目标:解决「Java 层算法自吐没有输出,但 App 确实在做加密」的问题。第14篇的 crypto_monitor.js 覆盖了 Java 标准加密 API,但有些 App 的加密操作完全在 Native 层——直接调用 OpenSSL/BoringSSL 的 C 函数,或者自己用 C 实现加密算法。本篇教你在 Native 层建立同等的「算法自吐」能力:覆盖 EVP 高级 API 和 AES_/RSA_/MD5_* 低级 API 两套路径,识别自研算法的特征,最终交付一个 native_crypto_monitor.js 拿到密钥和明文。
配套脚本:本篇产出的 native_crypto_monitor.js(单文件 1100+ 行、7 个模块开关、EVP/HMAC/AES/RSA/MD5/Base64 全覆盖、多副本 SO 自动装钩、TLS 噪声过滤、Spawn 模式 dlopen 兜底、ANSI 256 色对齐第 14 篇视觉)以及配套的 hook_registernatives.js / find_crypto_constants.js / check_app_crypto_imports.js 等已整理打包。关注本公众号后私信回复关键词「脚本」 即可获取,本系列与后续 Unidbg / SO 逆向 / ARM 汇编 等系列脚本会统一在此发放并持续更新。
一、什么时候需要 Native 层加密分析
1.1 判断加密是否在 Native 层
加载第14篇的 crypto_monitor.js,在 App 中执行目标操作(如登录),观察输出:
- 有 Cipher/HMAC 输出 → 加密在 Java 层,第14篇已解决
- 没有任何加密输出,但抓包确认有加密数据 → 加密在 Native 层,本篇的内容
进一步确认:用第06篇的模块枚举脚本看 App 加载了哪些 SO,重点关注:
| SO 文件 | 说明 |
|---|
libcrypto.so / libssl.so | OpenSSL/BoringSSL(系统或 App 自带) |
libnative-lib.so / libapp.so | App 自己的 Native 库(可能含自研加密) |
libflutter.so | Flutter 框架(使用 BoringSSL) |
libsqlcipher.so | 加密数据库 |
// 快速检查:App 是否导入了 OpenSSL 加密函数
(function() {
var found = [];
["libcrypto.so", "libssl.so"].forEach(function(lib) {
var mod = Process.findModuleByName(lib);
if (!mod) return;
mod.enumerateExports().forEach(function(exp) {
if (exp.name.match(/^EVP_|^AES_|^HMAC|^SHA[0-9]|^MD5/)) {
found.push(lib + "!" + exp.name);
}
});
});
if (found.length > 0) {
console.log("[*] 发现 " + found.length + " 个加密相关导出:");
found.slice(0, 20).forEach(function(f) { console.log(" " + f); });
} else {
console.log("[*] 未发现标准加密库导出,App 可能使用自研加密");
}
})();
关于本文的真机样例:为了让方法落地可见、避免"全是抽象代码",本文贯穿一个具体真机样例——酷狗音乐 v20.6.4(Pixel 6 Pro · Android 13 · frida-server 17.9.10)。这只是一个 BoringSSL App 的具体例子,本文讲的所有方法对任何走 BoringSSL/OpenSSL 的 App 同样适用,酷狗不是本文要分析/突破的对象,只是说明性载体。读者拿自己的目标 App 替换样例里的 SO 名即可。
在这个样例上跑第 1.1 节的扫描脚本,输出长这样——一眼能看出 libcrypto_kg.so 是 App 自带的定制 BoringSSL(1668 KB,596 个加密导出,包含完整 AES_* / RSA_* / BN_mod_exp_mont 低级 API 套):
libcrypto_kg 导出扫描
1.2 Native 加密的三种形式
形式一:调用系统 OpenSSL/BoringSSL。 Android 系统自带 BoringSSL(Google 的 OpenSSL 分支),提供了 libcrypto.so 和 libssl.so。App 的 Native 代码可以直接 #include <openssl/evp.h> 调用系统库。Flutter 和 Chromium 内核的 WebView 也使用 BoringSSL。
形式二:App 自带 OpenSSL。 有些 App 把自编译的 OpenSSL/BoringSSL 打包在 APK 中(如 lib/arm64-v8a/libcrypto.so),而不是使用系统的。函数名可能相同,但版本和编译选项不同。
形式三:自研加密算法。 少数高安全需求的 App 自己用 C 实现 AES/SM4 等算法,不调用任何标准库。这种情况下导入表中没有 EVP/AES 相关函数,需要通过 IDA 分析识别算法。
二、OpenSSL/BoringSSL 的两套 API · 选择策略
OpenSSL/BoringSSL 对外暴露两套 API,定位不同:
| API 风格 | 例子 | 何时遇到 |
|---|
| EVP 高级 API | EVP_EncryptInit_ex / EVP_DigestUpdate / HMAC_Init_ex | 新代码、Conscrypt、Flutter、Chromium 默认走这条 |
| 低级 API | AES_set_encrypt_key / AES_cbc_encrypt / RSA_public_encrypt / MD5_Update | 老代码、嵌入式、自带定制 BoringSSL 的大型 App、为追求性能直接调底层算法 |
Hook 策略:实战中两套都要 Hook——你不知道目标 App 走哪条。三步:第三章定位到 SO 里的 crypto 函数 → 第四章加载综合脚本 native_crypto_monitor.js 一次抓全 → 第五章学会读输出(key 形态识别、公钥提取、ctx 累积模式)。
2.1 EVP 是什么
EVP(Envelope)是 OpenSSL 的高级加密 API。它提供了一套统一的接口来使用各种加密算法——不管底层是 AES、DES、ChaCha20 还是 SM4,上层调用的 API 格式都相同。这和 Java 的 Cipher 类设计理念一致。
EVP 的对称加密工作流程:
// 1. 创建上下文
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
// 2. 初始化(指定算法、密钥、IV)
EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
// 3. 加密数据(可多次调用)
EVP_EncryptUpdate(ctx, out, &outl, in, inl);
// 4. 完成(处理填充)
EVP_EncryptFinal_ex(ctx, out + outl, &final_len);
// 5. 释放上下文
EVP_CIPHER_CTX_free(ctx);
解密使用对应的 EVP_DecryptInit_ex / EVP_DecryptUpdate / EVP_DecryptFinal_ex。
2.2 JCE ↔ OpenSSL/BoringSSL 函数对照表
Hook 时按需查表即可——左列是 Java 层调用、右列是对应的 Native 函数:
| Java JCE | OpenSSL/BoringSSL 函数 | 抓什么 |
|---|
Cipher.init (AES) | AES_set_encrypt_key(userKey, bits, AES_KEY*) | 原始 key、bits |
Cipher.doFinal (AES-CBC) | AES_cbc_encrypt(in, out, len, key, iv, enc) | 明文、密文、IV、方向 |
Cipher.doFinal (AES 单块) | AES_encrypt(in, out, key) | 明文 16B、密文 16B |
Cipher.init/doFinal (DES) | DES_set_key_unchecked + DES_cbc_encrypt | key(8B)、IV(8B)、明密文 |
Cipher.init/doFinal (3DES) | DES_ede3_cbc_encrypt | 三组 key、IV、明密文 |
Cipher.doFinal (EVP 高层) | EVP_EncryptInit_ex + EVP_EncryptUpdate + EVP_EncryptFinal_ex | key、iv、明密文 |
Cipher.doFinal (RSA 加密) | RSA_public_encrypt(flen, from, to, rsa, padding) | 明文、密文、padding |
Signature.sign (RSA) | RSA_sign(type, m, mlen, sig, siglen, rsa) | 摘要、签名 |
MessageDigest.update + digest | MD5_Update / MD5_Final、SHA256_Update / SHA256_Final 或 EVP_Digest* | 输入(可多次)、最终输出 |
Mac.doFinal (HMAC) | HMAC_Init_ex / Update / Final 或一次性 HMAC | key、消息、tag |
补充:
- 新写的 Native 代码更倾向走 EVP 系列,而不是直接调
AES_* / DES_* / RSA_* 低级 API。EVP_EncryptInit_ex(ctx, type, impl, key, iv) 一次拿到 key 和 iv,EVP_EncryptUpdate 拿到明密文,一组 Hook 覆盖所有对称算法。优先尝试 EVP;只有当 SO 里没有 EVP 导出符号时,再退回低级 API。 RSA_public_encrypt 底下最终会调 BN_mod_exp_mont 完成 c = m^e mod n,第 3、4 参数(指数 e、模数 n)直接暴露 RSA 公钥本体——IDA 里看不到明文公钥常量(key 是从 PEM 解析出来或堆上分配)时,这是最稳的公钥提取路径,第 4 章脚本段 6 内置 + 第 5.2 节讲怎么读出来的公钥。
三、三步定位法:从 Java 到 SO 函数
定位 Native 加密入口的标准流程:
第一步,在 jadx 里找到声明为 native 的方法,记下完整签名(类名 + 方法名 + JNI 签名)。
第二步,Hook libart.so 的 RegisterNatives 拿到函数地址。Java native 方法通过 RegisterNatives 绑定到 SO 里的具体地址。
// hook_registernatives.js · 跨 Android 版本统一脚本
// 关键点:
// 1) 枚举 libart.so 符号表抓 *所有* RegisterNatives 变体 (A13 模板特化的 Lb0/Lb1 不会漏)
// 2) Java.vm.tryGetEnv().getClassName(jclass) 把 jclass 解成 "android.net.NetworkUtils" 类名
// 3) DebugSymbol.fromAddress(this.returnAddress) 拿到 callee, 通常是某 SO 的 JNI_OnLoad
function find_RegisterNatives(params) {
let addrRegisterNatives = null;
if (Frida.version != undefined) {
const major = parseInt(Frida.version.split('.')[0], 10);
if (major >= 17) {
Process.findModuleByName("libart.so").enumerateSymbols().map(symbol => {
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {
addrRegisterNatives = symbol.address;
console.log("RegisterNatives is at ", symbol.address, symbol.name);
hook_RegisterNatives(addrRegisterNatives)
}
})
return;
}
}
// frida 16 兼容路径
let symbols = Module.enumerateSymbolsSync("libart.so");
for (let i = 0; i < symbols.length; i++) {
let symbol = symbols[i];
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {
addrRegisterNatives = symbol.address;
console.log("RegisterNatives is at ", symbol.address, symbol.name);
hook_RegisterNatives(addrRegisterNatives)
}
}
}
function hook_RegisterNatives(addrRegisterNatives) {
if (addrRegisterNatives != null) {
Interceptor.attach(addrRegisterNatives, {
onEnter: function (args) {
console.log("[RegisterNatives] method_count:", args[3]);
let java_class = args[1];
let class_name = Java.vm.tryGetEnv().getClassName(java_class);
let methods_ptr = ptr(args[2]);
let method_count = parseInt(args[3]);
for (let i = 0; i < method_count; i++) {
let name_ptr = ptr(methods_ptr.add(i * Process.pointerSize * 3)).readPointer();
let sig_ptr = ptr(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize)).readPointer();
let fnPtr_ptr = ptr(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2)).readPointer();
let name = ptr(name_ptr).readCString();
let sig = ptr(sig_ptr).readCString();
let symbol = DebugSymbol.fromAddress(fnPtr_ptr)
console.log("[RegisterNatives] java_class:", class_name,
"name:", name, "sig:", sig,
"fnPtr:", fnPtr_ptr,
" fnOffset:", symbol,
" callee:", DebugSymbol.fromAddress(this.returnAddress));
}
}
});
}
}
setImmediate(find_RegisterNatives);
在样例 App(Android 13)上 spawn 跑出来是这样的。
hook_registernatives 真机命中
接下来每条 [RegisterNatives] 命中包含 5 个维度:
| 字段 | 例子 | 用途 |
|---|
java_class | android.NetworkUtils | Java 类全限定名 |
name | bindProcessToNetworkHandle | Java 方法名 |
sig | (J)Z、(I)Z 等 | JNI 签名(参数类型 + 返回类型) |
fnPtr + fnOffset | 0x6e4a3e860 → libframework-connectivity-jni.so!_ZN7android..._utils_bindProcessToNetworkHandleEP7_JNIEnv+0x60 | 函数实现地址 + SO 偏移 |
callee | libframework-connectivity-jni.so!_ZN7android33register_android_net_NetworkUtilsEP7_JNIEnv+0x60 | 谁在 JNI_OnLoad 里调 RegisterNatives 注册的 |
第三步,用 IDA/Ghidra 打开对应 SO,跳到那个偏移,看调用了哪个 crypto API。看到 AES_cbc_encrypt、RSA_public_encrypt、MD5_Update 这类名字,直接 Hook 这些标准函数即可,业务函数本身可以不深入逆向。看不到名字(stripped)时需要用常量特征定位(S-box、0x10001、MD5 IV 67 45 23 01),那部分见 第七章 自研算法识别。
四、完整的 native_crypto_monitor.js
上一章把"从 Java 到 SO 函数"的入口定位讲清楚了;本章给一个综合脚本 native_crypto_monitor.js,把 EVP 高级 API、HMAC、AES + RSA + MD5 低级 API、Base64 编解码一次性覆盖,加载即用。这个脚本是本篇的核心交付物。
脚本结构 · 7 段拼装
脚本按 7 段 给出——这是结构骨架,按顺序拼起来就是一份能跑的 native_crypto_monitor.js。配置开关在段 1,可按需关闭模块以减少日志噪音。本章最后一节列出生产环境下还需要的几项工程化能力(多副本装钩、TLS 噪声过滤、ANSI 着色 ……);完整版(单文件 1100+ 行,文末关注公众号回复关键词获取)已合入这些能力,复制即用。
段 1 · 入口 + 配置 + 辅助函数
CONFIG 集中六个模块开关 + dump 字节上限 + 调用栈控制。辅助函数中 getEvpCipherName 是个小亮点——通过 EVP_CIPHER_CTX_cipher + EVP_CIPHER_nid + OBJ_nid2sn 三步反查算法名(aes-128-cbc / sm4-cbc 等),后续 EVP Hook 输出的算法标签全靠它。
本段重点:CONFIG 开关 / getCryptoExport 跨 SO 多名查找 / safeDump 限长 + 编码兼容 hex / getNativeStack 可选 backtrace / getEvpCipherName 三步反查。后面 6 段只调用这些 helper,不再展开。
// native_crypto_monitor.js
// Native 层加密操作全自动监控
// 用法: frida -U -f <包名> -l native_crypto_monitor.js --no-pause
(function() {
"use strict";
// ==================== 配置 ====================
var CONFIG = {
hookEvpCipher: true, // EVP_Encrypt*/EVP_Decrypt*
hookEvpDigest: true, // EVP_Digest* (SHA/MD5)
hookHmac: true, // HMAC / HMAC_Init/Update/Final
hookAesLowLevel: true, // AES_set_encrypt_key / AES_cbc_encrypt
hookRsaLowLevel: true, // RSA_public_encrypt + BN_mod_exp_mont (公钥提取)
hookMd5LowLevel: true, // MD5_Update / MD5_Final (按 ctx 累积输入)
maxDumpBytes: 128, // hexdump 最大字节数
showBacktrace: true, // 是否显示 Native 调用栈
backtraceDepth: 5 // 调用栈深度
};
// ==================== 辅助函数 ====================
// 在 BoringSSL/OpenSSL SO 里找导出符号 (frida 17 兼容写法)
// Flutter / Cronet 等内嵌 BoringSSL 的场景见第 6.3 节,扩展这里的 SO 列表即可
var CRYPTO_SOS = ["libcrypto.so"];
function getCryptoExport(name) {
for (var i = 0; i < CRYPTO_SOS.length; i++) {
var mod = Process.findModuleByName(CRYPTO_SOS[i]);
if (!mod) continue;
var addr = mod.findExportByName(name);
if (addr) return addr;
}
return null;
}
function getNativeStack(ctx) {
if (!CONFIG.showBacktrace) return "";
try {
return Thread.backtrace(ctx, Backtracer.ACCURATE)
.slice(0, CONFIG.backtraceDepth)
.map(function(addr) {
return " │ " + DebugSymbol.fromAddress(addr).toString();
})
.join("\n");
} catch(e) { return ""; }
}
function safeDump(ptr, size) {
if (ptr.isNull() || size <= 0) return "(null)";
var dumpSize = Math.min(size, CONFIG.maxDumpBytes);
try {
var hex = [];
for (var i = 0; i < dumpSize; i++) {
var b = ptr.add(i).readU8().toString(16);
hex.push(b.length === 1 ? "0" + b : b);
}
var result = hex.join("");
if (size > CONFIG.maxDumpBytes) result += "...(" + size + " bytes)";
return result;
} catch(e) {
return "(读取失败)";
}
}
function safeReadString(ptr, maxLen) {
if (ptr.isNull()) return "(null)";
try {
return ptr.readUtf8String(maxLen || 64);
} catch(e) {
return "(非字符串)";
}
}
function getEvpCipherName(ctx) {
// 从 EVP_CIPHER_CTX 中提取算法名称
try {
var EVP_CIPHER_CTX_cipher = new NativeFunction(
getCryptoExport("EVP_CIPHER_CTX_cipher") ||
getCryptoExport("EVP_CIPHER_CTX_get0_cipher"),
"pointer", ["pointer"]);
var cipherPtr = EVP_CIPHER_CTX_cipher(ctx);
if (cipherPtr.isNull()) return "unknown";
var EVP_CIPHER_nid = new NativeFunction(
getCryptoExport("EVP_CIPHER_nid") ||
getCryptoExport("EVP_CIPHER_get_nid"),
"int", ["pointer"]);
var nid = EVP_CIPHER_nid(cipherPtr);
var OBJ_nid2sn = new NativeFunction(
getCryptoExport("OBJ_nid2sn"),
"pointer", ["int"]);
var namePtr = OBJ_nid2sn(nid);
return namePtr.isNull() ? "nid:" + nid : namePtr.readUtf8String();
} catch(e) {
return "unknown";
}
}
段 2 · EVP 对称加密 · EVP_Encrypt* / EVP_Decrypt*
覆盖 Init / Update / Final 三步。Init 拿算法名 + 密钥 + IV;Update 拿明文 + 密文,顺便尝试把输入解为可读字符串(JSON、URL 等场景下 hex 看起来无意义但文本很直观);Final 拿最后一块填充。三步都用 EVP_CIPHER_CTX_cipher 反查算法名,日志里直接显示 aes-256-cbc 而不是 NID 数字。
本段重点:三个 forEach 循环分别 hook Init / Update / Final,每个 onLeave 都用 retval !== 1 过滤失败调用;密钥长度根据算法名后缀 128/192/256 推断;IV 固定 16 字节。
// ==================== EVP 对称加密 ====================
if (CONFIG.hookEvpCipher) {
// --- EVP_EncryptInit_ex / EVP_DecryptInit_ex ---
["EVP_EncryptInit_ex", "EVP_DecryptInit_ex",
"EVP_EncryptInit", "EVP_DecryptInit"].forEach(function(funcName) {
var addr = getCryptoExport(funcName);
if (!addr) return;
var isEncrypt = funcName.indexOf("Encrypt") !== -1;
var mode = isEncrypt ? "ENCRYPT" : "DECRYPT";
Interceptor.attach(addr, {
onEnter: function(args) {
// int EVP_EncryptInit_ex(EVP_CIPHER_CTX *ctx, const EVP_CIPHER *type,
// ENGINE *impl, const unsigned char *key,
// const unsigned char *iv);
this.ctx = args[0];
this.key = args[3];
this.iv = args[4];
},
onLeave: function(retval) {
if (retval.toInt32() !== 1) return; // 失败
var algoName = getEvpCipherName(this.ctx);
console.log("\n┌─[EVP] " + algoName + " | " + mode + " | Init");
if (!this.key.isNull()) {
// 密钥长度根据算法推断
var keyLen = 16; // 默认
if (algoName.indexOf("256") !== -1) keyLen = 32;
else if (algoName.indexOf("192") !== -1) keyLen = 24;
else if (algoName.indexOf("128") !== -1) keyLen = 16;
console.log("│ 密钥: " + safeDump(this.key, keyLen));
}
if (!this.iv.isNull()) {
console.log("│ IV: " + safeDump(this.iv, 16));
}
var stack = getNativeStack(this.context);
if (stack) console.log("│ 调用栈:\n" + stack);
console.log("└─");
}
});
console.log("[OK] " + funcName);
});
// --- EVP_EncryptUpdate / EVP_DecryptUpdate ---
["EVP_EncryptUpdate", "EVP_DecryptUpdate"].forEach(function(funcName) {
var addr = getCryptoExport(funcName);
if (!addr) return;
var isEncrypt = funcName.indexOf("Encrypt") !== -1;
Interceptor.attach(addr, {
onEnter: function(args) {
// int EVP_EncryptUpdate(EVP_CIPHER_CTX *ctx, unsigned char *out,
// int *outl, const unsigned char *in, int inl);
this.ctx = args[0];
this.outBuf = args[1];
this.outlPtr = args[2];
this.inBuf = args[3];
this.inLen = args[4].toInt32();
},
onLeave: function(retval) {
if (retval.toInt32() !== 1) return;
var outLen = this.outlPtr.readS32();
var algoName = getEvpCipherName(this.ctx);
var label = isEncrypt ? "Encrypt" : "Decrypt";
console.log("\n┌─[EVP] " + algoName + " | " + label + "Update");
console.log("│ 输入 (" + this.inLen + " bytes): " + safeDump(this.inBuf, this.inLen));
console.log("│ 输出 (" + outLen + " bytes): " + safeDump(this.outBuf, outLen));
// 尝试将输入/输出读为字符串
if (this.inLen > 0 && this.inLen < 256) {
var text = safeReadString(this.inBuf, this.inLen);
if (text && text.length > 2 && text.indexOf("(") === -1) {
console.log("│ 输入(text): \"" + text + "\"");
}
}
console.log("└─");
}
});
console.log("[OK] " + funcName);
});
// --- EVP_EncryptFinal_ex / EVP_DecryptFinal_ex ---
["EVP_EncryptFinal_ex", "EVP_DecryptFinal_ex",
"EVP_EncryptFinal", "EVP_DecryptFinal"].forEach(function(funcName) {
var addr = getCryptoExport(funcName);
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function(args) {
this.outBuf = args[1];
this.outlPtr = args[2];
},
onLeave: function(retval) {
if (retval.toInt32() !== 1) return;
var outLen = this.outlPtr.readS32();
if (outLen > 0) {
console.log("[EVP] Final: " + safeDump(this.outBuf, outLen) + " (" + outLen + " bytes)");
}
}
});
});
}
段 3 · EVP 哈希 · EVP_Digest*
只 Hook EVP_DigestUpdate + EVP_DigestFinal_ex——不 Hook Init 是因为算法名要从 ctx 反查,需要的额外代码与 Init 本身价值不匹配;真正需要时根据输出长度(MD5=16 / SHA-1=20 / SHA-256=32 / SHA-512=64)反推即可。
// ==================== EVP 哈希(Digest)====================
if (CONFIG.hookEvpDigest) {
// --- EVP_DigestUpdate ---
var digestUpdate = getCryptoExport("EVP_DigestUpdate");
if (digestUpdate) {
Interceptor.attach(digestUpdate, {
onEnter: function(args) {
// int EVP_DigestUpdate(EVP_MD_CTX *ctx, const void *d, size_t cnt);
this.data = args[1];
this.len = args[2].toInt32();
},
onLeave: function(retval) {
if (this.len > 0 && this.len < 4096) {
console.log("\n┌─[EVP Hash] DigestUpdate (" + this.len + " bytes)");
console.log("│ 数据: " + safeDump(this.data, this.len));
var text = safeReadString(this.data, Math.min(this.len, 128));
if (text && text.length > 2) {
console.log("│ 文本: \"" + text + "\"");
}
console.log("└─");
}
}
});
console.log("[OK] EVP_DigestUpdate");
}
// --- EVP_DigestFinal_ex ---
var digestFinal = getCryptoExport("EVP_DigestFinal_ex");
if (digestFinal) {
Interceptor.attach(digestFinal, {
onEnter: function(args) {
// int EVP_DigestFinal_ex(EVP_MD_CTX *ctx, unsigned char *md, unsigned int *s);
this.mdBuf = args[1];
this.sizePtr = args[2];
},
onLeave: function(retval) {
if (retval.toInt32() !== 1) return;
var size = this.sizePtr.readU32();
console.log("[EVP Hash] DigestFinal: " + safeDump(this.mdBuf, size) + " (" + size + " bytes)");
}
});
console.log("[OK] EVP_DigestFinal_ex");
}
}
段 4 · HMAC · 一次性 + 三步
OpenSSL HMAC 有两种用法:一次性 HMAC() 把 key+消息+输出一次塞过去,或 HMAC_Init_ex / HMAC_Update / HMAC_Final 三步链。两种都 Hook。API 签名场景里的密钥常常是可读字符串(如 "app_secret_key_2024"),所以 key 输出同时给 hex 和 utf8 两种形态。
本段重点:四个独立 hook —— 一次性 HMAC()(key + 数据 + 摘要一次性拿到)、HMAC_Init_ex(key)、HMAC_Update(每段消息)、HMAC_Final(摘要)。Update 每段独立打印,看 ctx 聚合输出的形态见第 5.4 节截图。
// ==================== HMAC ====================
if (CONFIG.hookHmac) {
// --- HMAC(一次性调用)---
var hmacAddr = getCryptoExport("HMAC");
if (hmacAddr) {
Interceptor.attach(hmacAddr, {
onEnter: function(args) {
// unsigned char *HMAC(const EVP_MD *evp_md,
// const void *key, int key_len,
// const unsigned char *d, size_t n,
// unsigned char *md, unsigned int *md_len);
this.keyPtr = args[1];
this.keyLen = args[2].toInt32();
this.dataPtr = args[3];
this.dataLen = args[4].toInt32();
this.mdPtr = args[5];
this.mdLenPtr = args[6];
},
onLeave: function(retval) {
if (retval.isNull()) return;
var mdLen = 32; // 默认 SHA-256
try { mdLen = this.mdLenPtr.readU32(); } catch(e) {}
console.log("\n┌─[HMAC] Native");
console.log("│ 密钥 (" + this.keyLen + " bytes): " + safeDump(this.keyPtr, this.keyLen));
console.log("│ 数据 (" + this.dataLen + " bytes): " + safeDump(this.dataPtr, this.dataLen));
var keyText = safeReadString(this.keyPtr, this.keyLen);
if (keyText && keyText.length > 1) console.log("│ 密钥(text): \"" + keyText + "\"");
var dataText = safeReadString(this.dataPtr, Math.min(this.dataLen, 256));
if (dataText && dataText.length > 1) console.log("│ 数据(text): \"" + dataText + "\"");
console.log("│ 输出 (" + mdLen + " bytes): " + safeDump(retval, mdLen));
var stack = getNativeStack(this.context);
if (stack) console.log("│ 调用栈:\n" + stack);
console.log("└─");
}
});
console.log("[OK] HMAC");
}
// --- HMAC_Init_ex ---
var hmacInit = getCryptoExport("HMAC_Init_ex");
if (hmacInit) {
Interceptor.attach(hmacInit, {
onEnter: function(args) {
// int HMAC_Init_ex(HMAC_CTX *ctx, const void *key, int len,
// const EVP_MD *md, ENGINE *impl);
var keyPtr = args[1];
var keyLen = args[2].toInt32();
if (keyLen > 0 && !keyPtr.isNull()) {
console.log("\n┌─[HMAC] Init");
console.log("│ 密钥 (" + keyLen + " bytes): " + safeDump(keyPtr, keyLen));
var keyText = safeReadString(keyPtr, keyLen);
if (keyText && keyText.length > 1) console.log("│ 密钥(text): \"" + keyText + "\"");
console.log("└─");
}
}
});
console.log("[OK] HMAC_Init_ex");
}
// --- HMAC_Update ---
var hmacUpdate = getCryptoExport("HMAC_Update");
if (hmacUpdate) {
Interceptor.attach(hmacUpdate, {
onEnter: function(args) {
var data = args[1];
var len = args[2].toInt32();
if (len > 0 && len < 4096) {
console.log("[HMAC] Update (" + len + " bytes): " + safeDump(data, len));
var text = safeReadString(data, Math.min(len, 256));
if (text && text.length > 1) console.log(" text: \"" + text + "\"");
}
}
});
console.log("[OK] HMAC_Update");
}
// --- HMAC_Final ---
var hmacFinal = getCryptoExport("HMAC_Final");
if (hmacFinal) {
Interceptor.attach(hmacFinal, {
onEnter: function(args) {
this.mdPtr = args[1];
this.mdLenPtr = args[2];
},
onLeave: function(retval) {
var mdLen = 32;
try { mdLen = this.mdLenPtr.readU32(); } catch(e) {}
console.log("[HMAC] Final (" + mdLen + " bytes): " + safeDump(this.mdPtr, mdLen));
}
});
console.log("[OK] HMAC_Final");
}
}
段 5 · AES 低级 API · AES_set_encrypt_key / AES_cbc_encrypt
针对不走 EVP 直接调用 AES_* 的代码——自带定制 BoringSSL 的大型 App、嵌入式 SDK、老代码常见。关键:AES_cbc_encrypt 的第 4 参是 key schedule 展开后的扩展密钥(176/240 字节,反推不出来),必须同时 hook AES_set_encrypt_key 抓原始 key——输出的具体含义见第 5.1 节解读。
本段重点:三个 hook —— AES_set_encrypt_key(原始 key + bits)、AES_set_decrypt_key(同上但解密方向)、AES_cbc_encrypt(IV + 明密文 + enc/dec 方向)。前两个对应第 5.1 节截图中的"双行密钥"事件。
// ==================== AES 低级 API ====================
if (CONFIG.hookAesLowLevel) {
// 有些代码不走 EVP,直接调用 AES_set_encrypt_key + AES_cbc_encrypt
var aesSetKey = getCryptoExport("AES_set_encrypt_key");
if (aesSetKey) {
Interceptor.attach(aesSetKey, {
onEnter: function(args) {
// int AES_set_encrypt_key(const unsigned char *userKey, const int bits, AES_KEY *key);
var keyPtr = args[0];
var bits = args[1].toInt32();
var keyLen = bits / 8;
console.log("\n┌─[AES] set_encrypt_key (" + bits + " bits)");
console.log("│ 密钥: " + safeDump(keyPtr, keyLen));
var stack = getNativeStack(this.context);
if (stack) console.log("│ 调用栈:\n" + stack);
console.log("└─");
}
});
console.log("[OK] AES_set_encrypt_key");
}
var aesSetDecKey = getCryptoExport("AES_set_decrypt_key");
if (aesSetDecKey) {
Interceptor.attach(aesSetDecKey, {
onEnter: function(args) {
var keyPtr = args[0];
var bits = args[1].toInt32();
console.log("[AES] set_decrypt_key (" + bits + " bits): " + safeDump(keyPtr, bits / 8));
}
});
console.log("[OK] AES_set_decrypt_key");
}
// AES_cbc_encrypt
var aesCbc = getCryptoExport("AES_cbc_encrypt");
if (aesCbc) {
Interceptor.attach(aesCbc, {
onEnter: function(args) {
// void AES_cbc_encrypt(const unsigned char *in, unsigned char *out,
// size_t length, const AES_KEY *key,
// unsigned char *ivec, const int enc);
this.inPtr = args[0];
this.outPtr = args[1];
this.length = args[2].toInt32();
this.ivPtr = args[4];
this.enc = args[5].toInt32();
},
onLeave: function(retval) {
var mode = this.enc === 1 ? "ENCRYPT" : "DECRYPT";
console.log("\n┌─[AES] cbc_encrypt | " + mode);
console.log("│ IV: " + safeDump(this.ivPtr, 16));
console.log("│ 输入 (" + this.length + " bytes): " + safeDump(this.inPtr, this.length));
console.log("│ 输出 (" + this.length + " bytes): " + safeDump(this.outPtr, this.length));
console.log("└─");
}
});
console.log("[OK] AES_cbc_encrypt");
}
}
段 6 · RSA + MD5 低级 API · RSA_public_encrypt / BN_mod_exp_mont / MD5_Update / MD5_Final
针对不走 EVP 直接调用 RSA_* / MD5_* 的代码。RSA 段双 hook:RSA_public_encrypt 抓明密文,BN_mod_exp_mont 解 BIGNUM 拿公钥本体(e + n)——具体读法见第 5.2 节。MD5 段按 MD5_CTX* 索引累积 Update 输入,Final 时一次性吐出所有分段 + 摘要——即便业务方复用同一 ctx 也不串(见第 5.5 节的 ctx 累积模式)。BN_mod_exp_mont 也被 DH/DSA 调用,BIGNUM 读不出时静默跳过避免噪音。
本段重点:四个 hook —— readBigNumHex helper(按 BoringSSL {d, top} 布局读 BIGNUM 转大端 hex)、RSA_public_encrypt(flen / padding / 明密文)、BN_mod_exp_mont(args[2] 指数 e、args[3] 模数 n,公钥本体)、MD5_Update + MD5_Final 配对(按 args[0] ctx 指针索引累积 chunks,Final 时一次性吐出)。MD5 这套 ctx-key 累积模式可直接套到 EVP_Digest / HMAC_Update 上做相同的"分段聚合"输出。
// ==================== RSA 低级 API ====================
if (CONFIG.hookRsaLowLevel) {
// BIGNUM 经典布局: { BN_ULONG* d; int top; ... }; BoringSSL 32/64 位实测都能用
function readBigNumHex(bnPtr) {
try {
var dPtr = bnPtr.readPointer();
var top = bnPtr.add(Process.pointerSize).readU32();
if (top <= 0 || top > 128) return null; // 不像 RSA modulus,跳过
var le = new Uint8Array(dPtr.readByteArray(top * Process.pointerSize));
var hex = [];
for (var i = le.length - 1; i >= 0; i--) {
var b = le[i].toString(16);
hex.push(b.length === 1 ? "0" + b : b);
}
return hex.join("");
} catch (e) { return null; }
}
var rsaEnc = getCryptoExport("RSA_public_encrypt");
if (rsaEnc) {
Interceptor.attach(rsaEnc, {
onEnter: function (args) {
this.flen = args[0].toInt32();
this.from = args[1];
this.to = args[2];
this.padding = args[4].toInt32();
},
onLeave: function (retval) {
var outLen = retval.toInt32();
var padMap = { 1: "PKCS1", 3: "NoPadding", 4: "OAEP" };
console.log("\n┌─[RSA] public_encrypt | " + (padMap[this.padding] || this.padding));
console.log("│ 明文 (" + this.flen + " B): " + safeDump(this.from, this.flen));
if (outLen > 0) console.log("│ 密文 (" + outLen + " B): " + safeDump(this.to, outLen));
var stack = getNativeStack(this.context);
if (stack) console.log("│ 调用栈:\n" + stack);
console.log("└─");
}
});
console.log("[OK] RSA_public_encrypt");
}
// BN_mod_exp_mont(r, a, p, m, ...) — args[2]=指数 e, args[3]=模数 n (公钥本体)
var modExp = getCryptoExport("BN_mod_exp_mont");
if (modExp) {
Interceptor.attach(modExp, {
onEnter: function (args) {
var n = readBigNumHex(args[3]);
if (!n) return; // 读不出则跳过 (DH/DSA 也调用此函数,布局不同)
var e = readBigNumHex(args[2]);
console.log("\n┌─[RSA] BN_mod_exp_mont (公钥)");
console.log("│ 指数 e: 0x" + e);
console.log("│ 模数 n: 0x" + n);
console.log("└─");
}
});
console.log("[OK] BN_mod_exp_mont");
}
}
// ==================== MD5 低级 API ====================
if (CONFIG.hookMd5LowLevel) {
// 按 MD5_CTX* 指针索引累积 Update 输入,Final 时一并吐出
var md5Update = getCryptoExport("MD5_Update");
var md5Final = getCryptoExport("MD5_Final");
if (md5Update && md5Final) {
var md5CtxInputs = {};
Interceptor.attach(md5Update, {
onEnter: function (args) {
var ctxKey = args[0].toString();
var len = args[2].toInt32();
if (!md5CtxInputs[ctxKey]) md5CtxInputs[ctxKey] = [];
try { md5CtxInputs[ctxKey].push(args[1].readByteArray(len)); } catch (e) {}
}
});
Interceptor.attach(md5Final, {
onEnter: function (args) {
this.outPtr = args[0];
this.ctxKey = args[1].toString();
},
onLeave: function () {
var chunks = md5CtxInputs[this.ctxKey] || [];
console.log("\n┌─[MD5] " + chunks.length + " 次 Update → Final");
chunks.forEach(function (c, i) {
var arr = new Uint8Array(c);
var preview = "";
for (var j = 0; j < Math.min(arr.length, 64); j++) {
var ch = arr[j];
preview += (ch >= 32 && ch <= 126) ? String.fromCharCode(ch) : ".";
}
console.log("│ Update[" + i + "] (" + arr.length + " B): " + preview);
});
console.log("│ 摘要: " + safeDump(this.outPtr, 16));
console.log("└─");
delete md5CtxInputs[this.ctxKey]; // 清理避免内存累积
}
});
console.log("[OK] MD5_Update + MD5_Final");
}
}
段 7 · 启动信息
加载完成横幅。和第 14 篇 Java 层算法自吐保持同一视觉风格——单边横线 + emoji,避免中英文混排导致右侧框线对不齐。
// ==================== 启动信息 ====================
// 不使用右侧闭合边框,规避中英文混排的对齐问题
console.log("");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" 🧬 Native 算法自吐 native_crypto_monitor.js v1.0");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" 📡 监控范围");
console.log(" • EVP Cipher (EVP_Encrypt* / EVP_Decrypt*)");
console.log(" • EVP Digest (SHA / MD5 via EVP_Digest*)");
console.log(" • HMAC (一次性 HMAC 与三步 Init/Update/Final)");
console.log(" • AES 低级 (AES_set_encrypt_key / AES_cbc_encrypt)");
console.log("");
console.log(" ⚙️ 配置提示");
console.log(" backtrace 默认开启,性能敏感时关 CONFIG.showBacktrace");
console.log(" Flutter App 见第 6.3 节,需替换 libcrypto.so → libflutter.so");
console.log("");
console.log(" 🩺 无输出排查");
console.log(" 1) 启动是否打印 [OK] 各 Hook 加载日志");
console.log(" 2) SO 没有 EVP_* 导出 → 段 5/6 的低级 API 应该补上");
console.log(" 3) 完全无输出 → 见第七章自研算法识别");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("");
})();
零代码替代方案 frida-trace:如果只想快速验证目标 SO 是否走 EVP / AES / RSA 路径、不需要明密文,frida-trace -U -f <包名> -i 'AES_*' -i 'RSA_*' -i 'EVP_*' 一条命令即可——自动生成模板 handler,函数命中就打印。本节脚本的价值是抓全参数 + 智能格式 + 调用栈 + ctx 累积,frida-trace 只告诉你"有没有调"。两者互补:先用 frida-trace 看哪些函数被命中,再用本脚本拿数据。
完整版补强清单
上面 7 段把 Hook 时机和参数布局讲清楚,但直接放到生产 App 上会撞到几堵墙。完整版(文末关注公众号回复关键词获取)在 7 段之上额外合入了下面这些能力,每一项都对应一个真机踩过的坑:
| 能力 | 解决的问题 | 关键接口 |
|---|
safeAttach 多副本装钩 | 腾讯系 App 一个进程内 3+ 份 libcrypto.so,findModuleByName 只返第一个,业务真实加密的副本可能整体 0 命中 | enumerateModules + addr 去重 |
| Base64 段 | JWT / cookie / 自定义协议高频走 BoringSSL 单步 base64,与第 14 篇 Java 层 android.util.Base64 形成同一事件的双观察点 | EVP_EncodeBlock / EVP_DecodeBlock |
isTlsNoise 握手噪声过滤 | TLS 1.2 的 tls1_P_hash PRF、TLS 1.3 的 HKDF_extract/expand + SSLTranscript::GetFinishedMAC 会触发海量派生,淹没业务事件 | caller LR + 符号名双层匹配 |
BN_mod_exp_mont 完整四元组 | 只 dump modulus N + e 没法验证 hook 抓到的真是这次 RSA 运算 | r = a^p mod m 四个 BIGNUM + caller LR |
| ANSI 256 色 + buf 累积 | 多线程并发时字段级输出会被其它线程割开,与第 14 篇 Java 版的视觉风格保持一致 | 事件累积成单次 console.log |
android_dlopen_ext spawn 兜底 | spawn 模式下 setImmediate 早于 SO 加载,getCryptoExport 全返 null,hook 全装不上 | dlopen 命中后 installAllHooks() |
| 一次性 export 单独 hook | BoringSSL 把 MD5(d,l,m) / HMAC(...) / EVP_Digest(...) 内部 Init/Update/Final 编译期内联,只 hook Update/Final 会出现"0 段 Update"怪象 | 直接 hook 一次性 export 本身 |
| 性能控制三件套 | TLS 解密路径上 Update 类 hook 一帧上千次,不限速直接 ANR | skipHugeUpdates + rateLimitPerSecond + showBacktrace 默认关 |
hideTestVectors 自检过滤 | AES / DES / RSA 库启动自检会用全零 key 与 NIST 测试向量打几次,污染输出 | key 内容匹配后隐藏 |
每一项的实现都不复杂,但踩过一遍才知道哪几项是必须的——上面的清单就是踩过的坑的索引。
五、输出格式解读
脚本跑起来后会吐出大量日志。真正的功夫是「怎么从日志读出业务模式」——不是去看每条 hook 命中,而是识别几种典型形态。下面五节按 AES / RSA / SHA-256 / HMAC / MD5 顺序展开五种输出形态,每节给一张真机截图 + 关键字段读法。
5.1 AES 输出
样例 App 跑出来一条典型的 AES 输出长这样:
native_crypto_monitor AES 命中
两条相关事件配对 —— 第一条 AES set_encrypt_key · 256 bits 抓密钥装载,第二条 AES_cbc_encrypt · ENCRYPT · 160 bytes 抓实际加密:
密钥字段的双行设计 —— 第一行用引号包裹显示 "fd387891254e6cedc4019ca0061ea6d9",因为 32 个字节全部落在 ASCII 可打印范围(0x30-0x66),脚本的 smartFormat 自动识别成 text 视图。第二行是同样字节的 hex 展开 6664333837...(首字节 0x66 = 'f'、0x64 = 'd'、0x33 = '3' ...)。
5.2 RSA 输出
RSA 比 AES 多一步——除了明密文,还要拿到公钥才能在 Python 里独立加密任意 payload。
native_crypto_monitor RSA 命中
这是 BN_mod_exp_mont · r = a^p mod m 的命中 —— BN_mod_exp_mont 是 BoringSSL 内所有 RSA 运算(公钥加密 / 公钥验签 / 私钥解密 / 私钥签名)的底层"模幂运算" helper。覆盖率比 RSA_public_encrypt 高一个数量级——后者只在业务直接调高级 API 时才命中,前者所有路径都走。
四个字段一一对应到 RSA 数学:
| 字段 | 含义 | 这条记录的值 |
|---|
模数 m | RSA modulus N | 2048 bit,开头 0xd33a629f... |
指数 p | 公钥 e 或私钥 d | 0x10001 = 65537 = 标准公钥指数 |
输入 a | 被加密 / 被验签的数据(已 padding) | 0x387c3db8... |
输出 r | 计算结果 = a^p mod m | 0x47c7ce3d... |
指数判读约定 —— 脚本对 指数 p 短于 16 个 hex 字符的视为公钥(e 通常是 65537),更长的认为是私钥 d(2048 bit)。这里 0x10001 是教科书级的公钥指数。
抓到 m + e 就可以直接构造 Python 公钥(任意 PKCS1 / NoPadding 明文都可加密),不需要 X.509 / PEM 序列化:
from Crypto.PublicKey import RSA
n = int("d33a629f0777b018f3fffeccc9a2c23a...", 16)
e = 0x10001
pubkey = RSA.construct((n, e))
5.3 SHA-256 输出
native_crypto_monitor SHA-256 命中
标签 SHA-256 (EVP) · 5 段 Update —— (EVP) 后缀表示业务走的是 EVP 高级 API(EVP_DigestInit_ex + EVP_DigestUpdate × N + EVP_DigestFinal_ex),脚本通过 EVP_DigestFinal_ex 拿到最终摘要长度(32 字节 → SHA-256),通过 EVP_DigestUpdate 按 ctx 累积所有 Update 输入,Final 时一次性 dump。
5 个 ①②③④⑤ 圈数字是 printMultiInput 的多段标记。这里的输入序列展示了 图片缓存 key 派生的常见模式:
- ①
"false" ——某个布尔属性序列化 - ② 完整 URL(图片真实地址)
- ③ 32 字节 hex —— 大概率是 thumbnail 之类的 hash prefix
- ④
-com.tencent.image.rcbitmap.a#200767... —— 业务组件 ID - ⑤
android.support.rastermill.FrameSequenceDrawable —— Drawable 类名
复合 hash → 缓存 key。摘要 fc3ca943... 就是这个图片在本地缓存里的文件名前缀。
5.4 HMAC 输出
native_crypto_monitor HMAC 命中
同一帧抓到 HMAC 的三种形态 —— 这是脚本对 BoringSSL HMAC 系列 API 全覆盖的产物:
| 副标题 | 来源 hook | 含义 |
|---|
Native · oneshot | 业务直接调一次性 API HMAC(evp_md, key, klen, data, dlen, md, &mlen) | 一行代码出签名,典型业务调用 |
Native · N 段 Update | 业务三步:HMAC_Init_ex + HMAC_Update × N + HMAC_Final | 大块数据 / 流式签名 |
Native · 0 段 Update | HMAC_Final 命中但 HMAC_Update 没抓到 | BoringSSL 内部 Update 被 inline,我们的 export hook 只拦到 Final |
5.5 MD5 输出
native_crypto_monitor MD5 命中
输入与摘要字段含义同第 5.3 节 SHA-256,按 ctx 累积 N 段 Update + Final 16 字节摘要,不再赘述。
六、BoringSSL 与标准 OpenSSL 的差异
6.1 为什么需要单独讨论 BoringSSL
BoringSSL 是 Google 的 OpenSSL 分支,Android 系统自带的 libcrypto.so / libssl.so 实际上就是 BoringSSL。Flutter、Chromium WebView、Cronet 也使用各自编译的 BoringSSL。
BoringSSL 与标准 OpenSSL 的关键差异影响 Frida Hook:
| 差异点 | OpenSSL | BoringSSL |
|---|
| HMAC API | OpenSSL 1.1.0+ 推荐 HMAC_CTX_new / HMAC_CTX_free | 保留 OpenSSL 1.0.x 老风格的 HMAC_CTX_init / HMAC_CTX_cleanup |
| 符号可见性 | 大部分函数有导出符号 | 很多内部函数被 static 隐藏,不在导出表中 |
| SO 名称 | libcrypto.so.1.1 / libssl.so.1.1 | libcrypto.so / libssl.so(无版本后缀) |
| EVP_CIPHER_nid | 通过 EVP_CIPHER_nid 获取 | 同上,但部分版本改名为 EVP_CIPHER_get_nid |
| 内部函数名 | ssl3_read_bytes 等 | 名称可能不同,如 ssl_read_buffer_extend |
6.2 Hook BoringSSL 的注意事项
兼容查找的逻辑已经在第 4.1 节段 1 主脚本的 getEvpCipherName 里实现——它内部就是按以下顺序依次尝试:
EVP_CIPHER_CTX_cipher ← OpenSSL 1.x
EVP_CIPHER_CTX_get0_cipher ← OpenSSL 3.x / BoringSSL 新版
EVP_CIPHER_nid ← OpenSSL 1.x
EVP_CIPHER_get_nid ← OpenSSL 3.x
如果想看当前 SO 命中的是哪个名字,在 getEvpCipherName 里加一行 console.log('[diag] cipher getter =', getCryptoExport("EVP_CIPHER_CTX_cipher") ? "cipher" : "get0_cipher") 即可;自己另写脚本时把 getEvpCipherName 整段抄过去复用 fallback 即可,不必重新搭这套。想直接在 REPL 里枚举命名:
Process.findModuleByName("libcrypto.so").enumerateExports()
.filter(e => e.name.match(/^EVP_CIPHER/))
.map(e => e.name)
.sort();
6.3 Flutter App 的特殊处理
Flutter 内嵌的 BoringSSL 在 libflutter.so 中而不是系统的 libcrypto.so,本篇 native_crypto_monitor.js 默认只搜索 libcrypto.so,对 Flutter App 必须改搜索目标:
// 把第 4.1 段 1 主脚本里 CRYPTO_SOS 列表扩展为这个 helper
function findCryptoExport(name) {
var SOS = ["libcrypto.so", "libflutter.so", "libcronet.so"];
for (var i = 0; i < SOS.length; i++) {
var mod = Process.findModuleByName(SOS[i]);
if (!mod) continue;
var addr = mod.findExportByName(name);
if (addr) return addr;
}
return null;
}
判断 App 是否为 Flutter 的最快方法:
console.log(Process.findModuleByName("libflutter.so") ? "[*] Flutter App" : "[*] 非 Flutter");
Dart 端的 package:cryptography / package:pointycastle 最终都落到内嵌 BoringSSL,所以 Flutter App 的所有 Native 加密都集中在 libflutter.so。Cronet(Chromium 网络栈)同样内嵌一份 BoringSSL,部分大厂 App 用 Cronet 做网络层时也要看 libcronet.so。
七、自研算法的识别与分析
7.1 如何判断是自研算法
当以上所有 Hook 都没有输出,但 App 确实在做加密(抓包可见密文)时,很可能是自研加密。
进一步确认:
// 检查 App 的 SO 是否导入了标准加密库函数
(function() {
var appModules = Process.enumerateModules().filter(function(m) {
return m.path.indexOf("/data/") !== -1;
});
appModules.forEach(function(mod) {
var cryptoImports = [];
mod.enumerateImports().forEach(function(imp) {
if (imp.name.match(/EVP_|AES_|SHA|MD5|HMAC|DES_|RSA_/)) {
cryptoImports.push(imp.name);
}
});
if (cryptoImports.length > 0) {
console.log("[" + mod.name + "] 加密导入 (" + cryptoImports.length + " 个):");
cryptoImports.forEach(function(f) { console.log(" " + f); });
} else {
console.log("[" + mod.name + "] 无加密库导入 → 可能自研加密");
}
});
})();
7.2 IDA 中识别常见算法
当确认是自研加密后,需要用 IDA/Ghidra 打开 SO 文件进行静态分析:
AES 的特征:
- 包含 AES S-Box 常量表(256 字节的查找表,以
0x63, 0x7c, 0x77, 0x7b 开头) - 有 10/12/14 轮循环(分别对应 AES-128/192/256)
- IDA 插件
FindCrypt 可以自动识别
DES/3DES 的特征:
- 包含 DES 初始置换表(IP 表)和最终置换表(FP 表)
- S-Box 是 8 个 4×16 的二维数组
SM4 的特征:
- 包含 SM4 S-Box(以
0xD6, 0x90, 0xE9, 0xFE 开头,与 AES 不同) - 32 轮循环
- 系统密钥 FK = {0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc}
MD5 的特征:
- 初始值
0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 - 64 个 T 表常量
SHA-256 的特征:
- 初始值
0x6a09e667, 0xbb67ae85, 0x3c6ef372, ... - 64 个 K 表常量
# 使用 IDA FindCrypt 插件
# 安装: 将 findcrypt-yara 插件复制到 IDA plugins 目录
# 使用: Edit → Plugins → FindCrypt
# 它会自动扫描 SO 中的算法常量并标记
7.3 Memory.scan 搜索算法常量
不用 IDA 也能做初步判断——在 Frida 中搜索已知的算法常量:
// find_crypto_constants.js
// 在 SO 内存中搜索常见加密算法的特征常量
(function() {
var targets = {
"AES S-Box": "63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76",
"SM4 S-Box": "d6 90 e9 fe cc e1 3d b7 16 b6 14 c2 28 fb 2c 05",
"MD5 Init": "01 23 45 67 89 ab cd ef fe dc ba 98 76 54 32 10",
"DES IP": "3a 32 2a 22 1a 12 0a 02"
};
var appModules = Process.enumerateModules().filter(function(m) {
return m.path.indexOf("/data/") !== -1;
});
appModules.forEach(function(mod) {
console.log("\n[*] 扫描 " + mod.name + " (" + (mod.size / 1024).toFixed(1) + " KB)");
Object.keys(targets).forEach(function(name) {
var matches = Memory.scanSync(mod.base, mod.size, targets[name]);
if (matches.length > 0) {
matches.forEach(function(m) {
var offset = m.address.sub(mod.base);
console.log(" [FOUND] " + name + " @ " + mod.name + "+0x" + offset.toString(16));
});
}
});
});
})();
如果搜索到 AES S-Box 但没有 EVP 导入——说明 SO 自己实现了 AES。接下来你需要在 IDA 中找到引用该 S-Box 的函数,那个函数就是 AES 加密/解密的入口。然后用 Frida 的 Interceptor.attach 通过偏移地址 Hook 它,捕获参数。
在样例 App 上跑这个脚本,能看到一个典型的「商业加固 SO + 现代 BoringSSL」扫描结果:
find_crypto_constants 在 QQ 音乐上的真机扫描结果
输出解读 —— 头部块 [*] 扫描 libxxx.so (NNN KB) 后跟两类结果:
[SKIP] AES S-Box / SM4 S-Box / MD5 Init / DES IP — access violation accessing 0xXXXX —— 商业加固 SO(libturingbase / libtmesec / libDownloadProxy 等)把 .rodata 段标成 PROT_NONE,Memory.scan 触发访问异常,脚本 try/catch 静默跳过。这是预期行为,不是脚本 bug,扫不动的 SO 不阻塞后续扫描。[FOUND] AES S-Box @ libstar.so+0x961f8 / [FOUND] AES S-Box @ libostar.so+0x98a1b / [FOUND] SM4 S-Box @ libTPCore-master.so+0x94c328 —— 真正命中:libstar.so 内含 AES S-Box(自实现 AES)、libTPCore-master.so 内含 SM4 S-Box(国密算法)。
7.4 Hook 自研加密函数
假设通过 IDA 分析确定了自研 AES 函数的偏移和参数:
// hook_self_research_aes.js
// IDA 分析结果(本例):
// 函数偏移 0x4A8C,签名 int encrypt(const u8* key, const u8* iv,
// const u8* input, int input_len,
// u8* output)
//
// 本片段使用 Frida 内置 hexdump,无外部依赖,可独立运行。
// 想要带调用栈输出?见底部注释行;想用 §4.1 段 1 主脚本的 safeDump/getNativeStack,
// 把那两个函数复制过来即可。
var mod = Process.findModuleByName("libnative.so");
if (!mod) {
console.log("[!] libnative.so 未加载,请确认 SO 名(用 Process.enumerateModules() 列出)");
} else {
var funcAddr = mod.base.add(0x4A8C);
Interceptor.attach(funcAddr, {
onEnter: function(args) {
this.key = args[0];
this.iv = args[1];
this.input = args[2];
this.inputLen = args[3].toInt32();
this.output = args[4];
},
onLeave: function(retval) {
var outLen = retval.toInt32();
console.log("\n[自研 AES] libnative.so+0x4A8C len=" + this.inputLen + "->" + outLen);
console.log(" 密钥:");
console.log(hexdump(this.key, { length: 16, header: false }));
console.log(" IV:");
console.log(hexdump(this.iv, { length: 16, header: false }));
console.log(" 输入(前 128 字节):");
console.log(hexdump(this.input, { length: Math.min(this.inputLen, 128), header: false }));
console.log(" 输出(前 128 字节):");
console.log(hexdump(this.output, { length: Math.min(outLen, 128), header: false }));
// 想看调用栈?去掉下面三行注释:
// console.log(" 调用栈:");
// console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
// .map(DebugSymbol.fromAddress).join("\n"));
}
});
console.log("[*] 自研 AES Hook 已加载 @ libnative.so+0x4A8C");
}
如何从 IDA 拿到这个 0x4A8C 偏移? 第 7.3 节扫到 AES S-Box 在 SO 内某个地址后,在 IDA 中右键 Jump to xref to operand,跳到引用该 S-Box 的函数,函数入口的 RVA(相对于 SO 基址的偏移)就是这里的 0x4A8C。把参数签名按 IDA 的类型推断(Y 键)猜出来,就能写 Hook。
八、Java + Native 双层监控
实战中最好的做法是同时加载 Java 和 Native 两个监控脚本,确保不遗漏任何加密操作:
frida -U -f com.example.app \
-l crypto_monitor.js \ # 第14篇:Java 层
-l native_crypto_monitor.js \ # 本篇:Native 层
--no-pause
两个脚本不冲突——一个 Hook Java 类,一个 Hook Native 函数,各管各的层。
九、适用边界:三类例外场景
本篇覆盖的是「标准库 + 符号未 strip + SO 未加固」的理想场景。实战中你会遇到三类例外:
第一类,符号被 strip。SO 里 AES_cbc_encrypt、MD5_Update、EVP_* 等函数不在导出表里,findExportByName 返回 null。识别信号:本篇主脚本启动横幅中 [OK] 行寥寥无几,enumerateExports() 输出里看不到任何 AES_* / EVP_* / RSA_* 命名,但 enumerateImports() 还能见到——说明业务 SO 引用了加密符号、但目标 SO 把它们藏起来了。需要用算法常量特征定位:AES S-box 首字节 63 7C 77 7B、Rcon 表 01 02 04 08 10 20 40 80 1B 36、MD5 IV 67 45 23 01、SHA-256 IV 6A 09 E6 67。IDA 配 FindCrypt 插件能自动标注这些常量,再通过交叉引用回溯到使用它们的函数。第七章已给出 Frida 端 Memory.scanSync 在运行时直接搜索常量的方案。
第二类,SO 被加固(360、爱加密、梆梆等)。SO 文件磁盘内容是壳代码,真正的加密逻辑解密后才出现在内存里,findExportByName 拿到的可能是壳的桩函数甚至 null。识别信号:Process.enumerateModules() 里看到 libjiagu(360)/ libshell(腾讯乐固)/ libDexHelper(梆梆)/ libnesec(网易易盾)/ libsec2023 / libsec2024(爱加密年份命名)/ libtmesec 之类的"壳" SO 与业务 SO 并存;或者 Memory.scan 在业务 SO 的 .rodata 上触发 access violation(参考第 7.3 节 [SKIP] 输出形态)。需要先做内存 dump、修复 ELF 头(常用 010 Editor 把原始加固 SO 的头复制过来)、再用 SoFixer 还原成可静态分析的形态。
第三类,算法被魔改或自实现。S-box 被换了、轮数被改了、用了查表法白盒,常量识别也失效。识别信号:第 7.3 节常量扫描全无命中、抓包却看到明显的加密数据;或扫到 S-box 但偏移落在非标准位置(如紧贴一段循环展开的 NEON 汇编、或字节序与官方表恰好不一致)。这种场景下 Hook 思路不变(还是定位函数、抓输入输出),但需要先把魔改逻辑搞清楚才能在 Python 里复现。
这三种进阶场景的完整脱壳和反魔改流程,留给第21篇《Native Hook 入门》专门讲。
总结
Java 层算法自吐没输出但抓包确认有加密,加密就在 Native 层。一条标准路径:
- 两套 API 都要覆盖:EVP 高级(现代代码、Conscrypt、Flutter)+
AES_* / RSA_* / MD5_* 低级(老代码、定制 BoringSSL)——目标 App 走哪条事先不知道,主脚本同时挂上。 - 入口定位用 RegisterNatives:jadx 找
native 方法 → Hook libart.so!RegisterNatives 拿到 SO 偏移 → IDA 跳过去看调用的是哪个 crypto API。看到标准函数名直接 Hook,不必深入业务代码。 native_crypto_monitor.js 一次覆盖六类:EVP + HMAC + AES + RSA + MD5 + Base64,配 safeAttach 多副本装钩、isTlsNoise 握手噪声过滤、BN_mod_exp_mont 四元组 RSA 公钥提取、ANSI 256 色对齐第 14 篇视觉——加载即用,是日常分析的第一枪。- 加载脚本没输出 = 自研或加固:IDA FindCrypt + Frida
Memory.scanSync 走 AES S-Box / SM4 S-Box / MD5 IV / SHA-256 IV 常量定位;strip / 加固 / 魔改三类进阶场景见第 21 篇《Native Hook 入门》。 - 双层加载:
crypto_monitor.js(Java 层,第 14 篇)+ native_crypto_monitor.js(Native 层,本篇)同时挂上,是确认"加密在哪一层"最快的姿势。
📦 获取本篇脚本
native_crypto_monitor.js 完整版(单文件 1100+ 行,EVP / HMAC / AES / RSA / MD5 / Base64 六类全覆盖,含 safeAttach 多副本装钩、isTlsNoise TLS 噪声过滤、android_dlopen_ext spawn 兜底、BN_mod_exp_mont 完整四元组、ANSI 256 色对齐第 14 篇视觉、性能控制三件套)以及配套的 hook_registernatives.js / find_crypto_constants.js / check_app_crypto_imports.js / enum_evp_cipher_exports.js / hook_self_research_aes.js + README 使用说明,已统一打包:
- 关注本公众号
- 私信回复关键词「脚本」
回复内含本系列与其它系列(Unidbg / SO 逆向 / ARM 汇编 ……)的脚本汇总,长期维护更新。