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实际调用的是厂商私有ProviderAndroid的Cipher类本质是个门面Facade模式真正干活的是Provider实例。系统默认加载AndroidOpenSSLAPI 28、Conscrypt部分厂商、BCBouncy 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中实现且符号表被stripnm -D libsec.so只显示JNI_OnLoad和Java_com_xxx_SecUtil_nativeEncrypt两个符号。破局策略Native层Hook双路径覆盖// 路径1Hook已知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); } }); // 路径2HookOpenSSL通用函数适用于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] 是ivecIVargs[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 (00b.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将密钥存入TEETrusted Execution Environment或SESecure 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返回CompletableFuturea.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的完整链路HookAES和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的全生命周期HookRSA涉及密钥对生成、公钥加密、私钥解密三阶段每阶段都有独立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.2Android 11targetSdk 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 timestamp 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 HookJava.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(timestamp).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; }; });日志终于捕获到明文usernameadminpassword123456timestamp1712345678901第四步定位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); }; });日志显示密码为123456salt为[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 bbank_app_salt_2023 password b123456 key PBKDF2(password, salt, 32, count100000, hmac_hash_moduleSHA256) 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 busernameadminpassword123456timestamp1712345678901 # 补齐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是否Debuggableaapt dump badging app.apk | grep debuggableandroid:debuggabletrueRelease版APK默认false需重打包或找测试版2. Frida Server版本匹配frida-ps -U | head -5显示进程列表Server与frida-python版本不匹配如15.x server配16.x client3. SELinux状态adb shell getenforcePermissiveEnforcing模式下Frida注入被拒需adb shell su -c setenforce 04. App是否Root检测frida -U -f com.xxx.app -l detect.js --no-pause无崩溃日志检测到Frida后主动退出需先绕过Root检测5. 是否启用Riru/LSPosedadb shell pm list packages | grep riru存在riru或lsposed包部分加固App会检测Xposed框架残留6. DexClassLoader是否被Hookfrida-trace -U -f com.xxx.app -i *DexClassLoader*捕获defineClass调用动态加载的dex未被监控导致Hook点遗漏7. 是否禁用反调试adb shell cat /proc/self/status | grep TracerPidTracerPid: 0App检测到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系统关键ProviderAndroidOpenSSL、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 *
安卓逆向中Frida Hook加密算法失效的四大根源与破局策略
发布时间:2026/5/23 15:54:32
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实际调用的是厂商私有ProviderAndroid的Cipher类本质是个门面Facade模式真正干活的是Provider实例。系统默认加载AndroidOpenSSLAPI 28、Conscrypt部分厂商、BCBouncy 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中实现且符号表被stripnm -D libsec.so只显示JNI_OnLoad和Java_com_xxx_SecUtil_nativeEncrypt两个符号。破局策略Native层Hook双路径覆盖// 路径1Hook已知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); } }); // 路径2HookOpenSSL通用函数适用于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] 是ivecIVargs[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 (00b.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将密钥存入TEETrusted Execution Environment或SESecure 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返回CompletableFuturea.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的完整链路HookAES和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的全生命周期HookRSA涉及密钥对生成、公钥加密、私钥解密三阶段每阶段都有独立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.2Android 11targetSdk 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 timestamp 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 HookJava.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(timestamp).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; }; });日志终于捕获到明文usernameadminpassword123456timestamp1712345678901第四步定位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); }; });日志显示密码为123456salt为[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 bbank_app_salt_2023 password b123456 key PBKDF2(password, salt, 32, count100000, hmac_hash_moduleSHA256) 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 busernameadminpassword123456timestamp1712345678901 # 补齐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是否Debuggableaapt dump badging app.apk | grep debuggableandroid:debuggabletrueRelease版APK默认false需重打包或找测试版2. Frida Server版本匹配frida-ps -U | head -5显示进程列表Server与frida-python版本不匹配如15.x server配16.x client3. SELinux状态adb shell getenforcePermissiveEnforcing模式下Frida注入被拒需adb shell su -c setenforce 04. App是否Root检测frida -U -f com.xxx.app -l detect.js --no-pause无崩溃日志检测到Frida后主动退出需先绕过Root检测5. 是否启用Riru/LSPosedadb shell pm list packages | grep riru存在riru或lsposed包部分加固App会检测Xposed框架残留6. DexClassLoader是否被Hookfrida-trace -U -f com.xxx.app -i *DexClassLoader*捕获defineClass调用动态加载的dex未被监控导致Hook点遗漏7. 是否禁用反调试adb shell cat /proc/self/status | grep TracerPidTracerPid: 0App检测到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系统关键ProviderAndroidOpenSSL、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 *