尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Frida动态Hook安卓SO库AES加密函数实战:获取密钥与IV

Frida动态Hook安卓SO库AES加密函数实战:获取密钥与IV 1. 项目概述与核心价值最近在分析一个安卓应用时遇到了一个棘手的问题它的核心业务数据在发送前被一个编译在SO共享对象库里的AES算法加密了。抓包工具抓到的全是密文常规的静态分析又因为代码混淆和SO库的复杂性而进展缓慢。这时候动态分析工具Frida就成了破局的关键。今天我就来复盘一下如何一步步用Frida Hook到这个SO库中的AES加密函数并成功拿到加密所需的密钥Key和初始化向量IV。这个过程不仅适用于AES其思路对于Hook其他加密算法如RSA、DES或SO库中的任意函数都有很强的借鉴意义。对于移动安全研究员、逆向工程师或者对应用内部机制感兴趣的朋友来说掌握这套方法意味着你能直接窥探到应用运行时最核心的数据处理逻辑。无论是为了安全审计、漏洞挖掘还是单纯想理解某个功能的实现这都是一项非常实用的技能。本文假设你已经对Frida有基本的了解比如知道如何安装、启动一个Frida Server并能用简单的脚本Attach到一个进程。我们将从目标分析开始一步步深入到脚本编写和问题排查分享我踩过的坑和总结出的技巧。2. 目标分析与Hook点定位2.1 理解目标SO库中的AES加密首先我们需要明确目标。在安卓上应用的核心或敏感逻辑常常会放在用C/C编写的原生代码中并编译成.so文件如libnative-lib.so。AES加密算法如果实现在这里其调用路径通常是从Java层通过JNIJava Native Interface触发的。我们的目标就是找到SO库中执行AES加密的那个函数。这个函数可能是一个标准的库函数如来自OpenSSL的AES_encrypt也可能是应用自己封装的一个函数例如Java_com_example_app_EncryptUtils_aesEncrypt。我们的任务就是定位到这个函数并监控它的输入明文、Key、IV和输出密文。2.2 静态分析与动态侦察在动手Hook之前做一些侦察工作能事半功倍。静态分析SO库使用objdump、readelf或IDA Pro等工具可以查看SO库导出的函数和字符串。搜索“AES”、“encrypt”、“decrypt”、“key”、“iv”等关键词可能会发现一些线索。例如如果使用了OpenSSL你可能会看到AES_set_encrypt_key、AES_cbc_encrypt等函数名。动态追踪JNI调用如果加密是从Java层发起的我们可以先Hook Java层的JNI接口。使用Frida的Java.use拦截可能的加密工具类。例如如果怀疑com.example.app.CryptoHelper类有一个native byte[] encrypt(byte[] data)方法可以先Hook它打印出它的参数并查看它具体调用了哪个SO库的哪个原生函数。这能为我们提供最精确的Hook目标。盲测与通用Hook如果静态分析没有头绪或者SO库被严重混淆我们可以采用更通用的方法。既然目标是AES那么它最终很可能会调用到底层一些固定的加解密函数。一个经典的策略是Hook内存分配和字符串操作函数观察加密前后数据的变化。但更直接有效的方法是直接Hook常见的加密库接口。这里分享一个我常用的技巧优先Hook标准库接口。很多应用即使自己封装了逻辑底层还是会调用像OpenSSL、BoringSSL或者系统自带的libcrypto.so中的函数。Hook这些标准函数的成功率非常高。例如OpenSSL的AES_encrypt、EVP_EncryptUpdate等。注意不同版本、不同编译配置的OpenSSL函数名和参数可能略有差异。最好能拿到目标SO库的符号信息或者通过反汇编工具确认函数签名。3. Frida Hook脚本的核心编写确定了Hook点接下来就是编写Frida的JavaScript脚本。这是整个过程中的核心。3.1 基础Hook模板首先我们来看一个Hook SO库中某个地址或函数名的基础模板。Java.perform(function () { console.log([*] Script loaded, starting native hook...); // 指定要Hook的SO库名称 var libname libtarget.so; // 指定要Hook的函数名如果知道的话 var funcname my_aes_encrypt; // 或者指定函数的相对地址例如从IDA中看到 var offset 0x1234; // 获取SO库的基地址 var libbase Module.findBaseAddress(libname); if (libbase) { console.log([] Found base address of, libname, :, libbase); // 计算函数的绝对地址基地址 偏移量 var funcaddr libbase.add(offset); // 或者直接通过函数名查找如果符号未剥离 // var funcaddr Module.findExportByName(libname, funcname); if (funcaddr) { console.log([] Target function address:, funcaddr); // 开始Hook Interceptor.attach(funcaddr, { // 函数进入时onEnter onEnter: function (args) { console.log(\n AES Encrypt Called ); // 在这里打印或保存参数 // args[0], args[1]... 对应函数的第一个、第二个参数 // 需要根据函数原型来解析 this.arg0 args[0]; // 例如保存明文数据指针 this.arg1 args[1]; // 例如保存密钥指针 // 可以打印指针地址 console.log(Plaintext ptr:, this.arg0); console.log(Key ptr:, this.arg1); }, // 函数离开时onLeave onLeave: function (retval) { // retval是函数的返回值 console.log(Ciphertext ptr (retval):, retval); // 可以根据之前保存的指针读取具体的内存内容 if (this.arg0) { var plaintext Memory.readByteArray(this.arg0, 16); // 假设读取16字节 console.log(Plaintext bytes:, plaintext); } console.log( AES Encrypt End \n); } }); } else { console.log([-] Could not find function address.); } } else { console.log([-] Could not find module:, libname); } });这个模板很简单找到模块计算函数地址然后附加一个拦截器Interceptor在函数进入和离开时执行我们的代码。3.2 针对AES加密函数的深度Hook上面的模板只是打印了指针。对于AES加密我们需要知道函数原型才能正确解析参数。假设我们通过分析确定目标函数是类似这样的C函数int aes_encrypt_cbc(const unsigned char *plaintext, int plaintext_len, const unsigned char *key, const unsigned char *iv, unsigned char *ciphertext);那么我们的Hook脚本就需要针对这个原型来写Interceptor.attach(funcaddr, { onEnter: function (args) { console.log(\n[] aes_encrypt_cbc Called); // 解析参数 // args[0]: plaintext pointer // args[1]: plaintext length (int) // args[2]: key pointer // args[3]: iv pointer // args[4]: ciphertext pointer (输出缓冲区) this.plaintextPtr args[0]; this.plaintextLen args[1].toInt32(); // 将指针转为整数 this.keyPtr args[2]; this.ivPtr args[3]; this.ciphertextPtr args[4]; // 读取并打印Key (AES-128为16字节256为32字节) var keyBytes Memory.readByteArray(this.keyPtr, 16); // 先假设16字节 console.log(Key (hex): ${Array.from(new Uint8Array(keyBytes)).map(b b.toString(16).padStart(2, 0)).join(:)}); // 读取并打印IV (通常16字节) var ivBytes Memory.readByteArray(this.ivPtr, 16); console.log(IV (hex): ${Array.from(new Uint8Array(ivBytes)).map(b b.toString(16).padStart(2, 0)).join(:)}); // 读取并打印明文 if (this.plaintextLen 0) { var plaintextBytes Memory.readByteArray(this.plaintextPtr, this.plaintextLen); console.log(Plaintext len: ${this.plaintextLen}); // 可以尝试以UTF-8解码如果是文本的话 try { var plaintextStr Memory.readUtf8String(this.plaintextPtr); console.log(Plaintext (str): ${plaintextStr}); } catch(e) { console.log(Plaintext (hex): ${Array.from(new Uint8Array(plaintextBytes)).map(b b.toString(16).padStart(2, 0)).join(:)}); } } }, onLeave: function (retval) { // 函数返回值可能是密文长度或状态码 console.log(Return value (ciphertext len/status): ${retval.toInt32()}); // 如果我们有输出缓冲区指针可以读取密文 if (this.ciphertextPtr) { // 假设加密后长度与输入相同对于CBC填充后可能不同这里简化 var ciphertextBytes Memory.readByteArray(this.ciphertextPtr, this.plaintextLen); console.log(Ciphertext (hex): ${Array.from(new Uint8Array(ciphertextBytes)).map(b b.toString(16).padStart(2, 0)).join(:)}); } console.log([] aes_encrypt_cbc End\n); } });这段脚本做了几件关键事解析参数根据假定的函数原型从args数组中取出各个参数的指针或值。读取内存使用Memory.readByteArray从指针处读取原始字节数据。格式化输出将字节数组转换为十六进制字符串便于阅读。对于明文还尝试了UTF-8解码这在分析文本数据时非常有用。记录上下文使用this.xxx在onEnter和onLeave之间传递信息如指针和长度。3.3 通用化与增强脚本在实际操作中我们可能不知道精确的函数原型或者需要Hook多个相关函数。我们可以编写更通用、更强大的脚本。策略一Hook加密库通用函数与其猜测应用自定义的函数不如直接Hook OpenSSL等库的通用函数。例如HookEVP_EncryptInit_ex,EVP_EncryptUpdate,EVP_EncryptFinal_ex这一系列函数它们被广泛使用并且参数中包含了算法、Key、IV等信息。// Hook OpenSSL的EVP_EncryptInit_ex它负责设置算法、Key和IV var EVP_EncryptInit_ex Module.findExportByName(libcrypto.so, EVP_EncryptInit_ex); if (EVP_EncryptInit_ex) { Interceptor.attach(EVP_EncryptInit_ex, { onEnter: function(args) { // args[0]: EVP_CIPHER_CTX *ctx // args[1]: const EVP_CIPHER *type (算法类型如AES-128-CBC) // args[2]: ENGINE *impl // args[3]: const unsigned char *key // args[4]: const unsigned char *iv this.keyPtr args[3]; this.ivPtr args[4]; if (this.keyPtr) { var key Memory.readByteArray(this.keyPtr, 32); // 读取可能的最大长度 console.log([EVP_EncryptInit_ex] Key: ${bytesToHex(key)}); } if (this.ivPtr) { var iv Memory.readByteArray(this.ivPtr, 16); console.log([EVP_EncryptInit_ex] IV: ${bytesToHex(iv)}); } } }); } // 一个简单的字节数组转十六进制字符串的工具函数 function bytesToHex(bytes) { var arr new Uint8Array(bytes); return Array.from(arr).map(b b.toString(16).padStart(2, 0)).join(:); }策略二追踪内存分配与数据流如果连标准库函数都找不到可以尝试Hookmalloc、memcpy等函数观察特定大小的内存分配和数据拷贝结合对加密前后数据块的观察来定位关键操作。这种方法更繁琐但作为最后的手段有时能奏效。实操心得在编写复杂Hook脚本时一定要做好错误处理。比如在读取内存前用Memory.isValid()检查指针是否有效避免脚本崩溃。另外将常用的功能如字节转十六进制、读取字符串封装成工具函数能让脚本更清晰。4. 实战操作流程与现场记录理论说再多不如一次实战。下面我模拟一次完整的操作流程。4.1 环境准备与目标启动环境一台已Root的安卓手机或模拟器如雷电模拟器安装好目标APP。电脑上安装好Frida和adb。启动Frida Serveradb shell su cd /data/local/tmp ./frida-server 端口转发adb forward tcp:27042 tcp:27042 adb forward tcp:27043 tcp:27043确定目标假设目标APP包名为com.example.secureapp。我们怀疑其登录请求的data字段被一个名为libencrypt.so的库加密。4.2 侦察与确认首先我们写一个简单的脚本来枚举APP加载的SO库和可能的JNI函数。// scout.js Java.perform(function() { // 列出所有已加载的模块 Process.enumerateModules({ onMatch: function(module){ if (module.name.indexOf(encrypt) ! -1 || module.name.indexOf(crypto) ! -1) { console.log([*] Found interesting module:, module.name, Base:, module.base, Size:, module.size); // 可以进一步枚举该模块的导出函数 // var exports Module.enumerateExports(module.name); // exports.forEach(exp console.log( - ${exp.name} at ${exp.address})); } }, onComplete: function(){} }); // 也可以Hook System.loadLibrary来知道何时加载了哪个库 var System Java.use(java.lang.System); var loadLibrary System.loadLibrary.overload(java.lang.String); loadLibrary.implementation function(libname) { console.log([] System.loadLibrary called: ${libname}); var result this.loadLibrary(libname); return result; }; });用Frida运行这个脚本frida -U -f com.example.secureapp -l scout.js --no-pause。观察输出我们确认了libencrypt.so被加载。4.3 静态分析辅助将libencrypt.so从设备中拉取到电脑adb pull /data/app/~~xxx~~/com.example.secureapp/lib/arm64-v8a/libencrypt.so .。 用IDA Pro打开搜索字符串“AES”发现了一些有趣的函数名如Java_com_example_secureapp_Crypto_aes128CbcEncrypt。同时也看到了对EVP_CIPHER_CTX_new,EVP_aes_128_cbc等OpenSSL函数的调用。这给了我们两个明确的Hook点一是JNI接口函数二是底层的OpenSSL函数。4.4 编写并执行主Hook脚本我们决定双管齐下。编写主脚本hook_aes.jsJava.perform(function () { console.log([*] AES Hook Script Started); // 1. Hook JNI接口函数如果知道的话 var jniFuncName Java_com_example_secureapp_Crypto_aes128CbcEncrypt; var jniFuncAddr Module.findExportByName(libencrypt.so, jniFuncName); if (jniFuncAddr) { console.log([] Found JNI function: ${jniFuncName} at ${jniFuncAddr}); Interceptor.attach(jniFuncAddr, { onEnter: function(args) { // JNI函数第一个参数是JNIEnv*第二个是jclass或jobject从第三个开始是业务参数 // 假设原型jbyteArray func(JNIEnv*, jclass, jbyteArray plaintext, jbyteArray key, jbyteArray iv) console.log(\n--- JNI AES Hook ---); var plaintextArray Java.vm.getEnv().getByteArrayElements(args[2], null); var keyArray Java.vm.getEnv().getByteArrayElements(args[3], null); var ivArray Java.vm.getEnv().getByteArrayElements(args[4], null); // 转换为js可读格式...代码略 }, onLeave: function(retval) { // 处理返回值 } }); } else { console.log([-] JNI function ${jniFuncName} not found, trying OpenSSL hooks.); } // 2. Hook OpenSSL EVP函数 (更通用) var libcrypto Module.findBaseAddress(libcrypto.so); if (libcrypto) { console.log([] libcrypto.so base:, libcrypto); // Hook EVP_EncryptInit_ex 来抓Key和IV var EVP_EncryptInit_ex_addr Module.findExportByName(libcrypto.so, EVP_EncryptInit_ex); if (EVP_EncryptInit_ex_addr) { console.log([] Hooking EVP_EncryptInit_ex at, EVP_EncryptInit_ex_addr); Interceptor.attach(EVP_EncryptInit_ex_addr, { onEnter: function(args) { this.ctx args[0]; this.keyPtr args[3]; this.ivPtr args[4]; if (this.keyPtr !this.keyPtr.isNull()) { var key Memory.readByteArray(this.keyPtr, 32); // 多读一些 console.log([EVP_Init] CTX:${this.ctx}, Key:${bytesToHex(key, 16)}); // 只打印前16字节 // 将key和ctx关联起来方便后续Update和Final时追踪 this.keyHex bytesToHex(key, 16); } if (this.ivPtr !this.ivPtr.isNull()) { var iv Memory.readByteArray(this.ivPtr, 16); console.log([EVP_Init] IV:${bytesToHex(iv, 16)}); this.ivHex bytesToHex(iv, 16); } } }); } // Hook EVP_EncryptUpdate 来抓明文和密文片段 var EVP_EncryptUpdate_addr Module.findExportByName(libcrypto.so, EVP_EncryptUpdate); if (EVP_EncryptUpdate_addr) { Interceptor.attach(EVP_EncryptUpdate_addr, { onEnter: function(args) { // args[0]: ctx, args[1]: out, args[2]: outl, args[3]: in, args[4]: inl var inPtr args[3]; var inLen args[4].toInt32(); if (inLen 0 inLen 1024) { // 避免打印过长数据 var input Memory.readByteArray(inPtr, inLen); console.log([EVP_Update] Input (${inLen} bytes): ${bytesToHex(input, inLen)}); // 可以尝试解码为字符串 try { var str Memory.readUtf8String(inPtr); if (str str.length inLen) { // 简单判断可能不准 console.log([EVP_Update] Input as string: ${str}); } } catch(e) {} } }, onLeave: function(retval) { var outLen Memory.readInt(retval.add(4)); // 第二个参数是输出长度指针 console.log([EVP_Update] Output length: ${outLen}); } }); } } else { console.log([-] libcrypto.so not found.); } }); function bytesToHex(bytes, maxLen) { var arr new Uint8Array(bytes); var len maxLen arr.length maxLen ? maxLen : arr.length; var hex []; for (var i 0; i len; i) { hex.push(arr[i].toString(16).padStart(2, 0)); } var result hex.join(:); if (len arr.length) result ...; return result; }4.5 运行与结果分析使用Spawn模式启动APP并注入脚本以确保在加密初始化前就完成Hookfrida -U -f com.example.secureapp -l hook_aes.js -o output.log在APP中执行登录操作。观察output.log文件我们看到了期待已久的输出[] Hooking EVP_EncryptInit_ex at 0x7a12b3c4d0 [EVP_Init] CTX:0x7a1f4c8000, Key:2b:7e:15:16:28:ae:d2:a6:ab:f7:15:88:09:cf:4f:3c [EVP_Init] IV:00:01:02:03:04:05:06:07:08:09:0a:0b:0c:0d:0e:0f [EVP_Update] Input (54 bytes): 7b:22:75:73:65:72:6e:61:6d:65:22:3a:22:74:65:73:74:75:73:65:72:22:2c:22:70:61:73:73:77:6f:72:64:22:3a:22:31:32:33:34:35:36:22:7d [EVP_Update] Input as string: {username:testuser,password:123456} [EVP_Update] Output length: 64Bingo我们成功抓取到了AES Key:2b7e151628aed2a6abf7158809cf4f3c(经典的AES-128示例Key)IV:000102030405060708090a0b0c0d0e0f明文JSON:{username:testuser,password:123456}输出密文长度: 64字节由于PKCS#7填充54字节明文加密后变为64字节至此我们已经完全掌握了此次AES加密的所有要素。5. 常见问题、排查技巧与避坑指南实战中不可能一帆风顺。下面是我总结的一些常见问题和解决方法。5.1 找不到目标SO库或函数问题Module.findBaseAddress返回null或者Module.findExportByName找不到函数。排查确认库是否加载先用Process.enumerateModules()或frida-ps -Ua确认目标进程是否真的加载了该SO库。有时库名可能带版本号如libencrypt.so.1。检查架构确保你的Frida Server和设备架构匹配arm, arm64, x86等。用adb shell getprop ro.product.cpu.abi查看。函数符号被剥离发布版本的SO库经常剥离符号表你只能看到地址看不到函数名。这时需要使用偏移量在IDA中查看目标函数的偏移量File Offset 或 RVA然后通过基地址 偏移量来计算绝对地址。使用特征码编写Frida脚本扫描内存寻找特定的指令序列特征码来定位函数。这更复杂但很有效。动态加载SO库可能在运行时通过dlopen动态加载。你需要Hookdlopen函数在库加载的瞬间进行Hook。var dlopen Module.findExportByName(null, dlopen); Interceptor.attach(dlopen, { onEnter: function(args) { var path args[0].readCString(); if (path path.indexOf(libencrypt.so) ! -1) { console.log([] dlopen called for: ${path}); this.hookAfterLoad true; } }, onLeave: function(retval) { if (this.hookAfterLoad !retval.isNull()) { // 库加载成功延迟一小段时间后执行Hook逻辑 setTimeout(function() { console.log([*] Attempting to hook functions after dlopen...); // 在这里调用你的Hook函数 }, 100); } } });5.2 Hook脚本导致应用崩溃或行为异常问题注入脚本后APP闪退或功能不正常。排查与解决参数解析错误这是最常见的原因。如果你Hook的函数原型猜错了错误地读取或修改了参数/返回值就会导致崩溃。对策仔细分析反汇编代码确认函数调用约定如ARM的寄存器传参规则和参数类型。初期可以只打印指针地址不读取内存内容观察是否稳定。内存访问违规在读取指针指向的内存前没有检查指针是否有效NULL或不可读。对策使用Memory.isValid()进行检查。if (this.keyPtr Memory.isValid(this.keyPtr, 16)) { var key Memory.readByteArray(this.keyPtr, 16); }线程安全问题Frida的onEnter和onLeave回调是在目标线程中执行的。如果你的脚本操作复杂、耗时可能会阻塞目标线程导致ANR应用无响应。对策将耗时的操作如网络请求、复杂计算放到setTimeout中异步执行或者只进行最简单的数据记录。Frida自身稳定性确保使用的是较新且稳定的Frida版本。老版本在某些设备或系统上可能存在兼容性问题。5.3 抓取到的数据是乱码或指针问题打印出来的Key或IV是一串看起来像内存地址的值如0x7a1f4c8000而不是实际的字节数据。原因与解决这说明你打印的是指针本身而不是指针指向的内容。你需要用Memory.readByteArray()去读取指针地址处的数据。另一个常见问题数据被编码或混淆。有些应用不会直接传递原始的Key和IV可能会经过Base64编码、异或运算或自定义的转换。对策观察抓取到的数据长度和模式。如果Key长度是24或44字节可能是Base64编码的。尝试在脚本中进行解码。或者继续向上追溯找到生成或加载这些Key/IV的函数。5.4 对抗与反调试问题目标APP检测到Frida并崩溃或退出导致Hook失败。反制措施重命名Frida Server将frida-server文件改名为其他名字如/data/local/tmp/dbus并运行。修改端口使用-l参数让Frida Server监听其他端口如./frida-server -l 0.0.0.0:8080然后在电脑端连接时指定端口frida -H 192.168.x.x:8080 ...。使用定制版或隐藏工具有些项目专门修改Frida以绕过检测。绕过反调试Hook APP自身的反调试检测函数使其总是返回失败。这需要你先分析出它的检测点在哪里。5.5 性能与日志管理问题Hook非常频繁的函数如每个网络请求都加密会产生海量日志拖慢速度且难以分析。优化条件过滤只在特定条件下打印日志例如当明文包含特定关键字username或当调用来自某个特定线程时。抽样输出每N次调用打印一次或者只记录到文件不打印到控制台。聚合数据只记录Key/IV和密文的对应关系或者计算哈希值避免重复输出相同内容。最后再分享一个终极技巧当你成功Hook并拿到Key和IV后最好能写一个独立的验证脚本用相同的Key、IV和算法如AES-128-CBCPKCS7填充去加密一段已知明文看生成的密文是否与Hook抓到的密文一致。这能100%确认你的分析是正确的并且拿到的参数是有效的。这个过程本身也是对加密知识的一次巩固。逆向工程就是这样一半是技术一半是耐心和细心每一次成功的Hook都是对程序运行逻辑的一次深刻理解。
返回列表