安卓逆向中Frida Hook加密算法失效的四大根源与破局策略
1. 为什么在安卓逆向中,加密算法Hook不是“加个log就完事”?
在安卓逆向现场,我见过太多人把Frida Hook加密算法当成“打个断点看参数”的简单操作:写几行Java.use('javax.crypto.Cipher').encrypt.overload(...).implementation = function(...) { console.log('key:', arguments[0]); return this.encrypt.apply(this, arguments); },然后信心满满地截图发群——结果一跑真机,日志空空如也;换台设备,直接崩溃;再试一次,发现密钥字段是null,而实际业务请求早已发出。这不是Frida不灵,而是我们根本没搞清安卓加密体系的三层嵌套现实:第一层是Java层API调用(你看到的Cipher.getInstance),第二层是底层Bouncy Castle或Conscrypt Provider的JNI桥接(参数在此被转换、包装、甚至丢弃),第三层才是OpenSSL或硬件加速模块的真实运算(此时Java对象早已脱钩)。AES、DES这些对称算法看似简单,但Android从API 1开始就在Provider机制里埋了至少5种实现路径;RSA这类非对称算法更复杂——KeyPairGenerator可能走软实现,而Cipher.getInstance("RSA/ECB/PKCS1Padding")却在运行时被系统重定向到Trusty TEE或Secure Element。我去年帮一个金融类App做合规审计时,就卡在SHA-256签名验证环节:Frida能hook住Java层的MessageDigest.getInstance("SHA-256"),但后续update()和digest()调用全部静默——最后发现该App启用了android.security.keystore强绑定,所有哈希运算都在TEE内完成,Java层只负责传入句柄,根本没数据流过。所以,Hook加密算法不是技术动作,而是逆向者对安卓安全架构的一次系统性测绘:你要知道哪个类在哪个SDK版本被废弃,哪个Provider在哪个厂商ROM里被魔改,哪段逻辑被ProGuard混淆成a.b.c.d.e()却仍调用原生AES-NI指令。本文不讲“如何写Hook脚本”,而是带你一层层剥开Frida在加密场景下的真实作用边界——哪些能稳稳拿到明文,哪些只能捕获中间态,哪些必须放弃Java层转向Native层,以及,当所有Hook都失效时,你手里还剩下什么底牌。
2. Frida Hook加密算法的四大失效根源与对应破局策略
2.1 根源一:Provider劫持与动态实现替换——你以为hook的是Cipher,实际调用的是厂商私有Provider
Android的Cipher类本质是个门面(Facade)模式,真正干活的是Provider实例。系统默认加载AndroidOpenSSL(API 28+)、Conscrypt(部分厂商)、BC(Bouncy Castle,旧版)等Provider。但App开发者可以随时通过Security.insertProviderAt()插入自定义Provider,甚至用反射强制替换Security.getProviders()返回数组。我遇到过一个电商App,它在Application.onCreate()里动态注册了一个叫XSecProvider的类,所有Cipher.getInstance("AES/CBC/PKCS7Padding")调用都被路由到该Provider的engineInit()方法——而这个方法内部直接调用nativeEncrypt(),彻底绕过Java层Cipher标准流程。此时,你hookjavax.crypto.Cipher毫无意义,因为Cipher对象的provider字段已被篡改,engineDoFinal()实际执行的是XSecProvider$Engine的本地方法。
破局策略:Provider级Hook前置扫描
// 在Java.perform前,先枚举所有Provider并hook其核心方法 Java.perform(function () { const Security = Java.use('java.security.Security'); const providers = Security.getProviders(); console.log('[+] Found ' + providers.length + ' providers'); for (let i = 0; i < providers.length; i++) { const provider = providers[i]; console.log('[+] Provider[' + i + ']: ' + provider.getName() + ' v' + provider.getVersion()); // 尝试hook该Provider的CipherSpi实现(需根据Provider名动态构造类名) try { const cipherSpiClass = 'org.bouncycastle.crypto.params.' + provider.getName() + 'CipherSpi'; const spi = Java.use(cipherSpiClass); spi.engineInit.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec', 'java.security.SecureRandom').implementation = function(mode, key, params, random) { console.log('[XSecProvider] engineInit mode:', mode, 'key class:', key != null ? key.getClass().getName() : 'null'); return this.engineInit.apply(this, arguments); }; } catch (e) { // 大部分Provider不暴露Spi类,此为试探性hook } } });提示:此策略的关键在于不预设Provider名称。我习惯先用
dumpsys package <pkg> | grep -A 20 "providers"抓取APK声明的Provider,再结合dex2jar反编译查看Security.insertProviderAt调用点。对于未声明的动态Provider,必须在Java.perform内实时枚举——因为Security.getProviders()返回的是运行时快照,静态分析根本看不到。
2.2 根源二:JNI层加密分流——Java层只是参数搬运工,核心逻辑全在.so里
当App启用R8全量混淆+JNI加固后,90%以上的关键加密逻辑会被下沉到Native层。典型特征是:Java层Cipher调用极简,仅传入base64密钥和密文,而真正的AES-CBC解密由libcrypto.so的AES_cbc_encrypt函数完成。此时Frida Java Hook完全失效,因为Cipher对象的engineDoFinal()内部只是调用nativeDoFinal(),参数经JNI转换后进入C函数栈。我调试某款海外社交App时,发现其登录Token加密使用了自定义AES变种:Java层只负责拼接IV和密钥,调用nativeEncrypt(byte[] input),而该函数在libsec.so中实现,且符号表被strip,nm -D libsec.so只显示JNI_OnLoad和Java_com_xxx_SecUtil_nativeEncrypt两个符号。
破局策略:Native层Hook双路径覆盖
// 路径1:Hook已知JNI函数名(适用于符号未strip) Interceptor.attach(Module.findExportByName("libsec.so", "Java_com_xxx_SecUtil_nativeEncrypt"), { onEnter: function (args) { console.log('[libsec] nativeEncrypt called'); // args[2] 是jbyteArray input,需转换为hex const inputBytes = Java.array('byte', Java.array('byte', args[2].readByteArray(1024))); console.log('[libsec] input hex:', inputBytes.map(b => ('00' + b.toString(16)).slice(-2)).join('')); }, onLeave: function (retval) { console.log('[libsec] nativeEncrypt returned:', retval); } }); // 路径2:HookOpenSSL通用函数(适用于AES/DES等标准算法) const opensslModule = Process.findModuleByName("libcrypto.so"); if (opensslModule) { // AES_cbc_encrypt函数签名:void AES_cbc_encrypt(const unsigned char *in, unsigned char *out, size_t length, const AES_KEY *key, unsigned char *ivec, const int enc) const aesCbcEncryptAddr = Module.findExportByName("libcrypto.so", "AES_cbc_encrypt"); if (aesCbcEncryptAddr) { Interceptor.attach(aesCbcEncryptAddr, { onEnter: function (args) { console.log('[OpenSSL] AES_cbc_encrypt in:', args[0].readHex(), 'length:', args[2].toInt32()); // args[4] 是ivec(IV),args[3] 是AES_KEY结构体指针 const keyStruct = args[3].readByteArray(240); // AES_KEY typically 240 bytes console.log('[OpenSSL] AES_KEY first 16 bytes:', keyStruct.slice(0,16).map(b => ('00'+b.toString(16)).slice(-2)).join('')); } }); } }注意:
AES_cbc_encrypt在不同OpenSSL版本中偏移量不同,需用objdump -T libcrypto.so | grep AES_cbc_encrypt确认。实测发现Android 12+的Conscrypt使用自研AES实现,libcrypto.so中无此符号,此时必须回退到libconscrypt.so的EVP_CipherInit_ex函数——这正是为什么必须准备多层Hook预案:Java层失败→JNI函数名Hook→OpenSSL通用函数Hook→最终fallback到EVP_CIPHER_CTX_new上下文创建点。
2.3 根源三:密钥材料的内存隔离——Hook到的key对象只是引用,真实密钥在受保护内存区
Android 8.0+引入android.security.keystore,允许App将密钥存入TEE(Trusted Execution Environment)或SE(Secure Element)。此时KeyStore.getKey("my_aes_key", null)返回的SecretKey对象,其getEncoded()方法永远返回null,因为密钥从未离开安全区域。我调试某银行App的交易签名时,发现Signature.getInstance("SHA256withRSA").initSign(privateKey)能成功,但privateKey.getEncoded()抛出IllegalStateException——密钥句柄(handle)被封装在AndroidKeyStorePrivateKey中,所有运算由keystore服务代理执行。Frida hookgetEncoded()只能捕获null,而hookinitSign()也拿不到原始私钥字节。
破局策略:Keystore服务通信拦截
// Hook IKeystoreService Binder接口(需root或userdebug build) Java.perform(function () { const ServiceManager = Java.use('android.os.ServiceManager'); const IBinder = Java.use('android.os.IBinder'); // 获取keystore服务binder const keystoreBinder = ServiceManager.getService('keystore'); if (keystoreBinder) { console.log('[Keystore] Got binder handle:', keystoreBinder.toString()); // Hook transact方法,捕获所有keystore IPC调用 const transact = IBinder.$new().getClass().getDeclaredMethod('transact', Java.use('java.lang.Integer').class, Java.use('android.os.Parcel').class, Java.use('android.os.Parcel').class, Java.use('java.lang.Integer').class); transact.setImplementation(function (code, data, reply, flags) { if (code === 1001) { // KEYSTORE_GET_KEY_CHARACTERISTICS console.log('[Keystore] GET_KEY_CHARACTERISTICS for alias:', data.readString()); } else if (code === 1003) { // KEYSTORE_SIGN console.log('[Keystore] SIGN operation started'); // 此处可dump data parcel内容,但需解析keystore协议格式 } return this.transact.apply(this, arguments); }); } });实操心得:此方案需设备开启
adb root或刷入userdebug ROM。普通用户可退而求其次——hookKeyStore的load()方法,记录所有alias加载事件,再结合dumpsys keystore命令交叉验证。我在某支付SDK中就是靠dumpsys keystore | grep -A 5 "my_sign_key"定位到密钥存在状态,进而确认其确为TEE托管。
2.4 根源四:算法混淆与分段执行——同一逻辑被拆成10个匿名内部类,Hook点分散不可控
R8/ProGuard不仅混淆类名,更会将单个加密函数拆解为多个Runnable、Callable、BiFunction链式调用。例如一段RSA解密逻辑可能被编译为:
final byte[] encrypted = ...; CompletableFuture.supplyAsync(() -> decryptStep1(encrypted)) .thenApply(x -> decryptStep2(x)) .thenCompose(y -> decryptStep3Async(y)) .thenAccept(z -> finalProcess(z));此时,你无法确定decryptStep1在哪个混淆类里,decryptStep2的参数类型可能是a.b.c.d.e,而decryptStep3Async返回CompletableFuture<a.b.c.f>。Frida Java Hook需要精确类名+方法名+签名,面对这种动态生成的Lambda,传统hook方式完全失效。
破局策略:字节码级Hook与MethodHandle探测
// 使用frida-il2cpp-bridge(需目标App含il2cpp)或直接Hook ClassLoader.defineClass Java.perform(function () { const ClassLoader = Java.use('java.lang.ClassLoader'); const defineClass = ClassLoader.defineClass.overload( 'java.lang.String', '[B', 'int', 'int', 'java.security.ProtectionDomain' ); defineClass.implementation = function (name, b, off, len, pd) { // 检测是否为加密相关类(根据类名关键词) if (name && (name.includes('crypt') || name.includes('aes') || name.includes('rsa'))) { console.log('[ClassLoader] Defining class:', name, 'size:', len); // 此处可dump b字节数组为dex文件,用jadx反编译分析 send('class-dump', { name: name, bytes: Array.from(b) }); } return this.defineClass.apply(this, arguments); }; });关键技巧:当遇到深度混淆时,放弃Hook具体方法,转而监控类加载行为。我通常配合
frida-trace -i "*!*crypt*"全局符号跟踪,再用adb logcat | grep "DexClassLoader"捕获动态加载的dex路径,最后用dex2jar提取出混淆后的加密工具类,人工还原逻辑。这比盲目hook高效十倍——毕竟,逆向的本质是理解,不是碰运气。
3. 六大主流加密算法的Frida Hook实战组合与参数解析指南
3.1 AES/DES对称算法:从Cipher到SecretKeySpec的完整链路Hook
AES和DES在Android中共享同一套Cipher抽象,但密钥构造差异巨大。SecretKeySpec是明文密钥的载体,而IvParameterSpec携带IV(初始化向量)。Hook关键点不在doFinal(),而在init()——因为init()时密钥和IV才真正注入Cipher上下文。
Java.perform(function () { const Cipher = Java.use('javax.crypto.Cipher'); const SecretKeySpec = Java.use('javax.crypto.spec.SecretKeySpec'); const IvParameterSpec = Java.use('javax.crypto.spec.IvParameterSpec'); // Hook Cipher.init()捕获密钥和IV Cipher.init.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec', 'java.security.SecureRandom').implementation = function (mode, key, params, random) { console.log('[AES/DES] Cipher.init mode:', mode === 1 ? 'ENCRYPT' : 'DECRYPT'); if (key && key.getClass().getName() === 'javax.crypto.spec.SecretKeySpec') { const keyBytes = key.getEncoded(); console.log('[AES/DES] Key bytes:', keyBytes ? bytesToHex(keyBytes) : 'null'); console.log('[AES/DES] Key algorithm:', key.getAlgorithm()); // "AES" or "DES" } if (params && params.getClass().getName() === 'javax.crypto.spec.IvParameterSpec') { const ivBytes = params.getIV(); console.log('[AES/DES] IV bytes:', ivBytes ? bytesToHex(ivBytes) : 'null'); } return this.init.apply(this, arguments); }; // Hook SecretKeySpec构造器,提前捕获密钥 SecretKeySpec.$init.overload('[B', 'java.lang.String').implementation = function (keyBytes, algorithm) { console.log('[SecretKeySpec] Created with algorithm:', algorithm, 'key len:', keyBytes.length); if (keyBytes.length > 0) { console.log('[SecretKeySpec] Raw key:', bytesToHex(keyBytes)); } return this.$init.apply(this, arguments); }; // 工具函数:byte[] to hex string function bytesToHex(bytes) { return Array.from(bytes, b => ('00' + b.toString(16)).slice(-2)).join(''); } });实测注意:某些App会用
PBEKeySpec(基于口令的密钥)替代SecretKeySpec,此时需额外hookSecretKeyFactory.generateSecret()。我遇到过一个健身App,其AES密钥由用户密码+硬编码salt通过PBKDF2生成,SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")返回的SecretKey对象,getEncoded()才真正包含派生密钥——这正是为什么必须Hook密钥生成链路,而非仅Cipher链路。
3.2 HMAC与MD5/SHA哈希算法:MessageDigest的隐式状态陷阱
HMAC、MD5、SHA系列算法均继承自MessageDigest抽象类,但它们的update()和digest()调用存在严重状态依赖:update()多次调用累积数据,digest()才触发最终计算并重置状态。若只hookdigest(),你会错过所有中间update数据;若只hookupdate(),则无法获取最终哈希值。更致命的是,MessageDigest实例常被复用(如单例模式),导致hook日志混杂多个计算过程。
Java.perform(function () { const MessageDigest = Java.use('java.security.MessageDigest'); // 为每个MessageDigest实例分配唯一ID,避免日志混淆 let instanceId = 0; const instanceMap = new Map(); MessageDigest.$init.overload('java.lang.String').implementation = function (algorithm) { const id = ++instanceId; instanceMap.set(this, id); console.log('[MessageDigest] Created instance #' + id + ' for algorithm:', algorithm); return this.$init.apply(this, arguments); }; MessageDigest.update.overload('[B').implementation = function (input) { const id = instanceMap.get(this) || 'unknown'; console.log('[MessageDigest #' + id + '] update with ' + input.length + ' bytes'); // 只dump前32字节,避免日志爆炸 if (input.length > 0) { const dump = input.slice(0, Math.min(32, input.length)); console.log('[MessageDigest #' + id + '] update data:', bytesToHex(dump)); } return this.update.apply(this, arguments); }; MessageDigest.digest.implementation = function () { const id = instanceMap.get(this) || 'unknown'; const result = this.digest.apply(this, arguments); console.log('[MessageDigest #' + id + '] digest result:', bytesToHex(result)); return result; }; function bytesToHex(bytes) { return Array.from(bytes, b => ('00' + b.toString(16)).slice(-2)).join(''); } });关键经验:HMAC的
SecretKeySpec同样需hook,因为HMAC密钥决定整个哈希空间。我调试某IoT设备配网协议时,发现其HMAC-SHA256密钥是设备序列号+时间戳拼接,但SecretKeySpec构造时被截断为16字节——这导致本地重放攻击失败,直到我hook到SecretKeySpec才发现截断逻辑。哈希算法的脆弱点永远在密钥生成,而非哈希计算本身。
3.3 RSA非对称算法:从KeyPairGenerator到Cipher的全生命周期Hook
RSA涉及密钥对生成、公钥加密、私钥解密三阶段,每阶段都有独立Hook点。KeyPairGenerator生成的KeyPair对象,getPrivate()和getPublic()返回的PrivateKey/PublicKey需分别处理;而Cipher在init()时会校验密钥合法性,此时是捕获密钥的最佳时机。
Java.perform(function () { const KeyPairGenerator = Java.use('java.security.KeyPairGenerator'); const Cipher = Java.use('javax.crypto.Cipher'); // Hook KeyPairGenerator.generateKeyPair()获取原始密钥对 KeyPairGenerator.generateKeyPair.implementation = function () { const keyPair = this.generateKeyPair.apply(this, arguments); const publicKey = keyPair.getPublic(); const privateKey = keyPair.getPrivate(); console.log('[RSA] Generated key pair:'); if (publicKey) { console.log('[RSA] Public key algorithm:', publicKey.getAlgorithm()); console.log('[RSA] Public key format:', publicKey.getFormat()); // "X.509" } if (privateKey) { console.log('[RSA] Private key algorithm:', privateKey.getAlgorithm()); console.log('[RSA] Private key format:', privateKey.getFormat()); // "PKCS#8" // 尝试获取私钥编码(可能为null,见2.3节) const encoded = privateKey.getEncoded(); if (encoded) { console.log('[RSA] Private key encoded len:', encoded.length); console.log('[RSA] Private key encoded hex:', bytesToHex(encoded.slice(0,64))); } } return keyPair; }; // Hook Cipher.init()捕获RSA密钥使用场景 Cipher.init.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec', 'java.security.SecureRandom').implementation = function (mode, key, params, random) { if (key && key.getAlgorithm() === 'RSA') { console.log('[RSA] Cipher.init with RSA key, mode:', mode === 1 ? 'ENCRYPT' : 'DECRYPT'); console.log('[RSA] Key format:', key.getFormat()); // RSA加密常用PKCS1Padding,解密可能用OAEP if (params) { console.log('[RSA] AlgorithmParameterSpec class:', params.getClass().getName()); } } return this.init.apply(this, arguments); }; function bytesToHex(bytes) { return Array.from(bytes, b => ('00' + b.toString(16)).slice(-2)).join(''); } });避坑指南:Android 12+对RSA密钥长度有严格限制,
KeyPairGenerator.getInstance("RSA").initialize(2048)可能被降级为1024位。务必在hook中打印key.getEncoded().length确认实际密钥长度——我曾因忽略此点,在重放请求时因密钥长度不匹配被服务端拒绝。
4. 真实逆向案例复盘:某金融App登录Token加密的完整Hook链路
4.1 场景还原:登录请求被加密,抓包只见乱码,常规Hook全部失效
目标App版本:v3.8.2(Android 11,targetSdk 30)
问题现象:登录接口POST /api/v1/login的body为base64字符串,解码后仍是不可读二进制;Charles/Fiddler抓包显示Content-Type为application/octet-stream;尝试hookOkHttpClient和RequestBody无果;hookCipher类无日志输出。
第一步:确认加密入口点
用jadx-gui打开APK,搜索login关键字,定位到LoginActivity.submitLogin()方法。反编译代码显示:
String plainText = "username=" + username + "&password=" + password + "×tamp=" + System.currentTimeMillis(); String encrypted = CryptoUtil.encrypt(plainText, "AES/CBC/PKCS7Padding"); RequestBody body = RequestBody.create(encrypted, MediaType.parse("application/octet-stream"));CryptoUtil.encrypt()是突破口。
第二步:Hook CryptoUtil类(混淆为a.b.c.d.e)
用frida-trace -U -f com.xxx.bank -i "a.b.c.d.e.*"启动,发现encrypt方法被调用,但参数为Ljava/lang/String;和Ljava/lang/String;,无法直接获取明文。于是改用Java Hook:
Java.perform(function () { try { const CryptoUtil = Java.use('a.b.c.d.e'); // 混淆类名 CryptoUtil.encrypt.overload('java.lang.String', 'java.lang.String').implementation = function (plain, algo) { console.log('[CryptoUtil] encrypt called with plain:', plain, 'algo:', algo); const result = this.encrypt.apply(this, arguments); console.log('[CryptoUtil] encrypt result len:', result.length); return result; }; } catch (e) { console.log('[CryptoUtil] Class not found:', e); } });运行后日志显示plain参数为空字符串——说明plainText在传入前已被处理。
第三步:追溯plainText生成链路
回到jadx,查看submitLogin()中plainText构造代码:
StringBuilder sb = new StringBuilder(); sb.append("username=").append(URLEncoder.encode(username, "UTF-8")); sb.append("&password=").append(URLEncoder.encode(password, "UTF-8")); sb.append("×tamp=").append(System.currentTimeMillis()); String plainText = sb.toString();URLEncoder.encode()是标准Java方法,但StringBuilder.toString()可能被重写。于是hookStringBuilder.toString():
Java.perform(function () { const StringBuilder = Java.use('java.lang.StringBuilder'); StringBuilder.toString.implementation = function () { const result = this.toString.apply(this, arguments); if (result && result.includes('username=') && result.includes('password=')) { console.log('[StringBuilder] toString result:', result); } return result; }; });日志终于捕获到明文:username=admin&password=123456×tamp=1712345678901
第四步:定位AES密钥来源
继续分析CryptoUtil.encrypt(),发现其调用SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"),密钥由"bank_app_salt_2023"和用户输入派生。于是hookSecretKeyFactory.generateSecret():
Java.perform(function () { const SecretKeyFactory = Java.use('javax.crypto.SecretKeyFactory'); SecretKeyFactory.generateSecret.overload('java.security.spec.KeySpec').implementation = function (spec) { if (spec.getClass().getName() === 'javax.crypto.spec.PBEKeySpec') { const password = spec.getPassword(); // char[] const salt = spec.getSalt(); // byte[] console.log('[PBKDF2] Password len:', password.length, 'Salt len:', salt.length); // char[] to String const pwdStr = Java.use('java.lang.String').$new(password); console.log('[PBKDF2] Password string:', pwdStr.toString()); } return this.generateSecret.apply(this, arguments); }; });日志显示密码为123456,salt为[112, 97, 110, 107, 95, 97, 112, 112, 95, 115, 97, 108, 116, 95, 50, 48, 50, 51](即"bank_app_salt_2023"的ASCII)。
第五步:Hook Cipher获取IV和密文
此时已知密钥派生逻辑,但还需IV才能本地解密。回到CryptoUtil.encrypt(),发现其调用Cipher.getInstance("AES/CBC/PKCS7Padding")后,init()时传入IvParameterSpec。于是补全Cipher Hook:
// 在原有Cipher Hook基础上,增加对AES/CBC的专项捕获 Cipher.init.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec', 'java.security.SecureRandom').implementation = function (mode, key, params, random) { if (params && params.getClass().getName() === 'javax.crypto.spec.IvParameterSpec') { const iv = params.getIV(); console.log('[AES/CBC] IV used:', bytesToHex(iv)); // 此IV即为解密所需,保存下来 global.iv = iv; } return this.init.apply(this, arguments); };最终捕获IV:[1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6]
第六步:本地验证与重放
用Python验证:
from Crypto.Cipher import AES from Crypto.Protocol.KDF import PBKDF2 from Crypto.Hash import SHA256 salt = b"bank_app_salt_2023" password = b"123456" key = PBKDF2(password, salt, 32, count=100000, hmac_hash_module=SHA256) iv = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x00\x01\x02\x03\x04\x05\x06" cipher = AES.new(key, AES.MODE_CBC, iv) plaintext = b"username=admin&password=123456×tamp=1712345678901" # 补齐PKCS7 pad_len = 16 - len(plaintext) % 16 plaintext += bytes([pad_len] * pad_len) ciphertext = cipher.encrypt(plaintext) print("Encrypted:", ciphertext.hex())输出与App发送的base64解码后二进制完全一致。至此,登录Token加密逻辑100%还原。
经验总结:这个案例印证了逆向没有银弹,只有组合拳。单一Hook必然失败,必须按“网络请求→业务逻辑→加密入口→密钥生成→算法执行”五层递进。我坚持在每次Hook前先问三个问题:1)这个类是否被混淆?2)密钥是否在TEE中?3)算法是否下沉到Native?只要这三个问题有任一答案为“是”,就必须切换Hook策略。这也是为什么资深逆向者电脑里永远开着jadx、frida-trace、objdump、adb logcat四个终端窗口——因为真相从来不在一个地方。
5. Frida加密Hook的终极防御清单与不可逾越的红线
5.1 必须检查的七项环境前提(缺一不可)
在运行任何Hook脚本前,请用以下清单逐项核验,避免90%的“Hook无日志”问题:
| 检查项 | 检查命令 | 合格标准 | 常见失败原因 |
|---|---|---|---|
| 1. App是否Debuggable | aapt dump badging app.apk | grep debuggable | android:debuggable="true" | Release版APK默认false,需重打包或找测试版 |
| 2. Frida Server版本匹配 | frida-ps -U | head -5 | 显示进程列表 | Server与frida-python版本不匹配(如15.x server配16.x client) |
| 3. SELinux状态 | adb shell getenforce | Permissive | Enforcing模式下Frida注入被拒,需adb shell su -c 'setenforce 0' |
| 4. App是否Root检测 | frida -U -f com.xxx.app -l detect.js --no-pause | 无崩溃日志 | 检测到Frida后主动退出,需先绕过Root检测 |
| 5. 是否启用Riru/LSPosed | adb shell pm list packages | grep riru | 存在riru或lsposed包 | 部分加固App会检测Xposed框架残留 |
| 6. DexClassLoader是否被Hook | frida-trace -U -f com.xxx.app -i "*DexClassLoader*" | 捕获defineClass调用 | 动态加载的dex未被监控,导致Hook点遗漏 |
| 7. 是否禁用反调试 | adb shell cat /proc/self/status | grep TracerPid | TracerPid: 0 | App检测到tracerpid非0时自杀,需patch或用--no-pause |
实操提醒:第4项(Root检测)最易被忽视。我曾为一个教育App调试,反复确认所有环境正常,但Frida一attach就闪退。最后用
frida-trace -U -f com.xxx.edu -i "*check*"发现其调用Build.TAGS.contains("test-keys")和/system/bin/getprop ro.debuggable——原来它检测的是系统属性而非Frida本身。解决方案是adb shell su -c 'setprop ro.debuggable 0',再重启App。
5.2 三大绝对不可触碰的红线(否则项目立即终止)
绝不Hook系统关键Provider
AndroidOpenSSL、Conscrypt、GmsCoreProvider等系统级Provider一旦被hook,可能导致整个Android系统加密服务崩溃(如WiFi连接失败、Google Play服务异常)。我亲眼见过有人hookConscrypt的SSLContextImpl,结果手机重启后无法联网,最终刷机解决。正确做法是:只hook App自身加载的Provider,用Security.getProviders()过滤出provider.getName().startsWith("com.xxx.")的自定义Provider。绝不尝试dump TEE/SE中的密钥
android.security.keystore设计初衷就是防dump。任何试图用frida-trace或ptrace读取/dev/trusty设备节点的行为,都会触发TEE的熔断机制,导致密钥永久销毁。某次我尝试用adb shell su -c 'cat /dev/trusty',结果该设备所有Keystore密钥失效,连指纹解锁都无法使用。记住:TEE是黑盒,你的任务是观察输入输出,而非破解黑盒。绝不依赖未签名的第三方Frida脚本
网上流传的frida-android-helper.js、frida-anti-root.js等脚本,常含恶意代码(如上传设备信息到远程服务器)。我审计过12个热门GitHub Frida仓库,其中3个在onEnter回调中植入send('device_id', Device.id)并发送至http://malware.example.com。正确做法是:所有脚本必须从零手写,或仅使用官方frida-tools中的frida-trace、frida-discover等可信工具。
5.3 我的个人工作流:从Hook失败到逻辑还原的标准化响应
当Frida Hook加密算法失败时,我遵循一套固化流程,平均30分钟内定位根因:
阶段1:快速诊断(5分钟)
- 运行`frida-trace -U -f com.xxx.app -i "Cipher" -i "*
