公司动态
Android Native层逆向实战:Frida Hook与RPC调用链分析
1. 项目概述逆向分析zp_stoken的动机与价值最近在分析一个移动端应用时遇到了一个名为zp_stoken的关键参数。这个参数在后续的请求中扮演了类似身份令牌的角色其生成逻辑被封装在应用的Native层SO库中并且经过了混淆和加固静态分析难度极大。对于安全研究员、逆向工程师或者需要进行协议分析、自动化测试的开发者来说理解并获取这个zp_stoken的生成逻辑是绕不开的一环。直接的目标可能是为了构造合法的请求进行更深度的安全评估或者实现一些合规的自动化流程。传统的静态分析在遇到复杂的代码保护时往往力不从心。因此动态分析技术特别是Hook技术成为了破解这类黑盒逻辑的利器。本项目记录的就是一次完整的实战从定位zp_stoken的生成函数开始通过Frida进行Hook拦截和参数分析最终梳理出其内部可能存在的RPC远程过程调用流程并尝试模拟调用。整个过程涉及Android逆向、Frida脚本编写、RPC协议分析等多个环节希望能为遇到类似问题的朋友提供一个清晰的思路和可复现的参考。2. 核心思路与技术选型面对一个加固的Native函数我们的核心思路是“动态跟踪内外结合”。静态分析用于初步定位和了解结构动态Hook用于获取运行时数据流而协议分析则用于理解其完整的业务逻辑链。2.1 为什么选择Frida进行Hook在Android动态分析领域有几个主流工具Xposed、Frida、Substrate。本次选择Frida主要基于以下几点考量跨平台与易用性Frida支持Android、iOS、Windows、macOS等多个平台其基于Python和JavaScript的API对于大多数开发者来说学习曲线更平缓。编写一个Hook脚本通常只需要几行JS代码非常高效。无需重启与动态注入Frida的frida-server运行在目标设备上通过USB或网络连接可以随时将脚本注入到正在运行的进程中无需修改APK或重启设备这对调试和快速迭代至关重要。强大的拦截与操作能力Frida不仅可以拦截函数调用、读取参数和返回值还能修改参数、返回值甚至动态替换函数实现。这对于分析zp_stoken这种输入输出明确的函数再合适不过。对Native层Hook的良好支持通过Interceptor.attach我们可以轻松Hook SO库中的导出函数甚至未导出函数需计算偏移这是分析libzp_secure.so这类库的关键。相比之下Xposed主要专注于Java层对Native层的支持需要借助其他模块不够直接。而Substrate虽然强大但配置相对复杂社区活跃度也不及Frida。2.2 分析目标的初步定位在开始Hook之前我们需要知道Hook哪里。通常有几种方法定位zp_stoken的生成函数字符串搜索使用IDA Pro、Ghidra或strings命令在目标SO库中搜索“zp_stoken”或它的部分字节。如果该字符串作为参数或日志输出存在可能找到其附近的函数。调用栈分析在应用运行到使用zp_stoken的网络请求时通过调试器如LLDB或Frida的Backtracer来捕获调用栈回溯到Native层的源头函数。导入导出表分析查看SO库的导出函数名寻找与加密、令牌生成相关的函数名如generateToken、calculateSignature、getSToken等。即使函数名被混淆其参数类型和调用模式也可能有迹可循。假设我们通过字符串搜索在libzp_secure.so中找到了一个可疑的导出函数Java_com_zp_secure_SecurityHelper_getEncryptedToken。这个函数名暗示了它是一个JNI函数被Java层的SecurityHelper.getEncryptedToken()调用很可能就是我们的目标。3. 环境搭建与Frida基础Hook3.1 实验环境准备工欲善其事必先利其器。我们需要准备以下环境测试设备一台已Root的Android真机或一台Android模拟器如雷电模拟器。推荐使用真机避免模拟器可能存在的兼容性问题。确保设备上已安装目标应用。Frida环境PC端通过pip安装Frida和Frida-toolspip install frida frida-tools设备端根据设备CPU架构通常为arm64-v8a从Frida官网下载对应版本的frida-server推送到设备并赋予执行权限以后台方式运行。验证连接在PC上执行frida-ps -U如果能看到设备上的进程列表说明连接成功。分析工具IDA Pro或Ghidra用于静态辅助分析任何代码编辑器用于编写JS脚本。3.2 编写第一个Hook脚本我们的目标是Hook那个可疑的JNI函数。首先我们需要知道这个函数的完整签名。在JNI中一个常见的getEncryptedToken函数可能长这样jstring Java_com_zp_secure_SecurityHelper_getEncryptedToken(JNIEnv* env, jobject thiz, jstring input, jint timestamp);它接收一个字符串输入和一个时间戳返回一个加密后的字符串即zp_stoken。下面是一个基础的Frida Hook脚本// hook_zp_stoken.js Java.perform(function () { // 定位到目标类 var SecurityHelper Java.use(com.zp.secure.SecurityHelper); // Hook 目标方法 SecurityHelper.getEncryptedToken.implementation function (input, timestamp) { console.log(\[*] Hooking SecurityHelper.getEncryptedToken\); console.log(\ Input: \ input); console.log(\ Timestamp: \ timestamp); // 调用原函数获取返回值 var result this.getEncryptedToken(input, timestamp); console.log(\ Result (zp_stoken): \ result); // 打印调用栈有助于理解上下文 console.log(\ Call stack:\); console.log(Java.use(\android.util.Log\).getStackTraceString(Java.use(\java.lang.Exception\).$new())); return result; }; });使用命令frida -U -f com.zp.app -l hook_zp_stoken.js --no-pause注入脚本并启动应用。触发相关功能后我们能在控制台看到输入参数和输出的zp_stoken。注意在实际逆向中函数名和类名很可能被混淆。你可能看到的是a.a()或b.c.d()这样的形式。这就需要通过动态调试、监控Logcat或分析调用关系来逐步确定目标。有时关键逻辑并不在Java层而是Java层仅仅是一个包装实际工作都在Native的JNI函数里这时我们就需要将Hook目标转向Native库。4. 深入Native层Hook SO库函数当Java层的Hook只能获取输入输出而无法洞察内部算法时就必须深入Native层。假设我们已经通过静态分析或调用栈将目标锁定在libzp_secure.so的导出函数native_generate_token上。4.1 Hook Native导出函数使用Frida的Module.findExportByName来定位并Hook导出函数。// hook_native_token.js Interceptor.attach(Module.findExportByName(\libzp_secure.so\, \native_generate_token\), { onEnter: function (args) { console.log(\[*] native_generate_token called!\); // 假设函数签名: char* native_generate_token(const char* input, long long timestamp) // args[0] 是函数返回地址args[1]是第一个参数以此类推。 // 但请注意在ARM64上前8个参数可能通过寄存器X0-X7传递这里简化处理。 // 更可靠的方式是阅读反汇编代码确定参数顺序和类型。 // 将指针读成字符串 var inputPtr args[1]; var timestamp args[2].toInt64(); var inputStr Memory.readUtf8String(inputPtr); console.log(\ onEnter - Input: \ inputStr); console.log(\ onEnter - Timestamp: \ timestamp); // 保存参数以便在onLeave中对比 this.inputStr inputStr; this.timestamp timestamp; }, onLeave: function (retval) { // retval 是返回值指针 var resultPtr retval; var resultStr Memory.readUtf8String(resultPtr); console.log(\ onLeave - Result: \ resultStr); console.log(\ Stoken for (\ this.inputStr \, \ this.timestamp \) is: \ resultStr); } });这个脚本能拦截Native函数的调用和返回打印出参数和结果。但这里有一个关键点参数解析。args数组的索引依赖于调用约定Arm的AAPCS。错误的解析会导致读到错误的内存数据。最稳妥的方法是结合IDA反汇编的结果确定每个参数对应的寄存器或栈位置。4.2 处理复杂参数与结构体有时参数不是一个简单的字符串或整数而是一个指向结构体的指针。例如函数签名可能是int generate_token_complex(token_request* req, token_response* resp);这就需要我们解析内存中的结构体。首先需要通过逆向推测结构体布局例如typedef struct { char* app_id; char* device_id; int64_t timestamp; char nonce[16]; } token_request; typedef struct { char stoken[256]; int expires_in; int error_code; } token_response;对应的Frida Hook脚本需要更精细地操作内存Interceptor.attach(Module.findExportByName(\libzp_secure.so\, \generate_token_complex\), { onEnter: function (args) { var reqPtr args[1]; // 假设第一个参数是 token_request* var respPtr args[2]; // 第二个参数是 token_response* // 读取结构体字段 var appIdPtr Memory.readPointer(reqPtr); // 结构体第一个成员是指针 var deviceIdPtr Memory.readPointer(reqPtr.add(Process.pointerSize)); // 第二个成员指针 var timestamp Memory.readInt64(reqPtr.add(Process.pointerSize * 2)); var nonceBuf reqPtr.add(Process.pointerSize * 3); // 第三个成员是16字节数组 var appId Memory.readUtf8String(appIdPtr); var deviceId Memory.readUtf8String(deviceIdPtr); var nonceHex Memory.readByteArray(nonceBuf, 16).hex(); console.log([*] generate_token_complex Request:); console.log( AppId: ${appId}, DeviceId: ${deviceId}); console.log( Timestamp: ${timestamp}, Nonce: ${nonceHex}); // 保存respPtr以便onLeave时读取结果 this.respPtr respPtr; }, onLeave: function (retval) { var respPtr this.respPtr; // 读取 response 结构体 var stokenBuf respPtr; // 假设第一个成员是stoken字符数组 var stoken Memory.readUtf8String(stokenBuf); var expiresIn Memory.readInt(respPtr.add(256)); // 跳过256字节的stoken数组 var errorCode Memory.readInt(respPtr.add(256 4)); console.log([*] generate_token_complex Response:); console.log( Stoken: ${stoken}); console.log( ExpiresIn: ${expiresIn}, ErrorCode: ${errorCode}); console.log( Function return: ${retval}); } });通过这种方式我们可以窥探到zp_stoken生成过程中所有的输入要素和输出结果。5. 揭示内部流程从Hook到RPC调用链分析在成功Hook了核心的令牌生成函数后我们可能会发现这个函数内部并不是纯算法计算它还会发起网络请求也就是RPC调用去一个远端服务器获取某些种子数据或进行验证。这就是标题中“从Hook到RPC调用”的深层含义。5.1 识别RPC调用在Hook的函数onEnter和onLeave之间如果观察到有明显的延迟比如几百毫秒到几秒并且通过监控网络流量使用Frida的SocketHook或系统TrafficStats发现在此期间有特定的HTTPS请求发出那么基本可以断定函数内部进行了网络通信。我们可以进一步Hook底层的网络库函数来捕获这些请求。例如Hooklibc的connect、send、recv或者更上层的OpenSSL的SSL_write、SSL_read。// 示例Hook libc的send函数查看发送的数据 var sendFunc Module.findExportByName(\libc.so\, \send\); if (sendFunc) { Interceptor.attach(sendFunc, { onEnter: function (args) { var sockfd args[0].toInt32(); var buf args[1]; var len args[2].toInt32(); // 只关注我们感兴趣的socket可以通过fd或内容过滤 var data Memory.readByteArray(buf, len); console.log([*] libc.send called, fd${sockfd}, len${len}); // 简单判断是否为文本数据 if (len 1024) { try { console.log(\ Data (utf8): \ Memory.readUtf8String(buf)); } catch(e) { console.log(\ Data (hex): \ data.hex()); } } } }); }通过分析这些被Hook的网络数据我们可能看到发往某个特定域名如token.zp.com的POST请求请求体可能是JSON或Protobuf格式包含了设备信息、临时凭证等。而服务器的响应里则可能包含了用于生成最终zp_stoken的核心材料。5.2 构建完整的调用流程图结合多个Hook点我们可以梳理出完整的zp_stoken生成流程触发层App业务代码调用SecurityHelper.getEncryptedToken(input, timestamp)。JNI桥接层Java方法通过JNI调用Native函数native_generate_token。核心逻辑层native_generate_token函数内部a. 对输入参数进行初步处理拼接、编码。b.发起RPC请求1调用内部函数rpc_get_seed()向服务器seed.zp.com获取一个动态种子Seed。这个调用可能基于HTTP/HTTPS使用自定义的二进制协议。c. 接收并解析服务器返回的种子数据。d. 将输入参数、时间戳、设备指纹和刚获取的种子进行组合。e. 使用一个硬编码在SO中的密钥或算法可能是AES、HMAC-SHA256等对组合后的数据进行加密或签名生成一个中间令牌。f.发起RPC请求2将中间令牌发送给验证服务器verify.zp.com进行校验。g. 接收验证服务器的响应响应中包含了最终的zp_stoken和过期时间。返回层将最终的zp_stoken通过JNI返回给Java层再由App填入网络请求的Header中。这个过程揭示了zp_stoken并非单纯本地计算而是一个涉及“挑战-响应”或“种子-计算-验证”的在线过程安全性更高。5.3 模拟RPC调用与令牌生成分析清楚流程后我们的目标就从“获取”变成了“模拟”。我们需要复现RPC协议分析Hook到的网络请求和响应确定其协议格式JSON/Protobuf/自定义、接口地址、必要的请求头如User-Agent、Content-Type和认证方式。提取关键参数从Hook中获取所有必要的输入参数如固定的app_id、device_id生成规则、以及native_generate_token函数内部可能使用的其他隐藏参数。剥离加密算法通过Hook加密函数如OpenSSL的EVP_*系列函数或关键内存区域尝试定位加密密钥和算法模式。在某些情况下如果算法是标准且密钥被硬编码我们可以直接用Python的cryptography库复现。编写模拟客户端使用Python的requests库模拟整个RPC调用链先获取种子再计算中间令牌最后请求验证接口获得zp_stoken。# 模拟代码示例伪代码实际参数需根据Hook结果填充 import requests import hashlib import hmac import time def simulate_zp_stoken_generation(input_str): # 1. 获取动态种子 seed_response requests.post(\https://seed.zp.com/api/v1/seed\, json{\device_id\: get_device_id(), \timestamp\: int(time.time())}, headers{\User-Agent\: \ZP-App/1.0\}) server_seed seed_response.json()[\data\][\seed\] # 2. 组合数据并计算假设是HMAC-SHA256 message f\{input_str}|{int(time.time())}|{server_seed}\ # 假设通过Hook得到的硬编码密钥是 \secret_key_123\ (hex) key bytes.fromhex(\7365637265745f6b65795f313233\) hmac_obj hmac.new(key, message.encode(), hashlib.sha256) intermediate_token hmac_obj.hexdigest() # 3. 请求验证接口获取最终stoken verify_response requests.post(\https://verify.zp.com/api/v1/token\, json{\intermediate\: intermediate_token, \app_id\: \zp_app\}, headers{\User-Agent\: \ZP-App/1.0\}) zp_stoken verify_response.json()[\data\][\stoken\] expires_in verify_response.json()[\data\][\expires_in\] return zp_stoken, expires_in6. 实战中的疑难杂症与排查技巧逆向分析很少一帆风顺以下是几个常见问题及解决思路6.1 Hook失效或应用崩溃原因1函数签名错误。Native函数的参数数量、类型或调用约定判断错误导致访问了非法内存。排查使用IDA Pro仔细查看反汇编代码确认函数序言prologue和参数使用方式。对于ARM64注意是X0-X7寄存器传参超出部分用栈。原因2多线程或时序问题。Hook代码本身存在竞态条件或阻塞了关键线程。排查确保Hook脚本中的操作是线程安全的避免在onEnter/onLeave中执行耗时操作如同步网络请求。使用setImmediate或setTimeout将非关键操作异步化。原因3反调试/反Hook检测。应用可能检测Frida的存在如检测frida-server进程、端口、内存特征等。对策使用Frida的隐身模式重命名frida-server使用定制化的编译版本或者结合其他工具如ptrace进行绕过。6.2 无法定位关键函数策略1广度监控。如果不确定具体函数可以先对某个SO库的所有导出函数进行监控观察在触发zp_stoken生成时哪些函数被调用。Module.enumerateExports(\libzp_secure.so\).forEach(function(exp) { Interceptor.attach(exp.address, { onEnter: function(args) { console.log([*] Called: ${exp.name}); } }); });注意这会生成大量日志需要配合过滤条件。策略2关键地址下断点。在IDA中调试在已知的字符串引用如“zp_stoken”或相关函数交叉引用处下断点动态跟踪执行流。策略3跟踪内存访问。如果知道zp_stoken最终存储在某个全局变量或内存区域可以使用Frida的MemoryAccessMonitor来监控对该区域的所有读写操作反向定位到写入它的函数。6.3 RPC协议难以解析问题网络数据被加密或为自定义二进制格式无法直接阅读。方案1Hook加解密函数。在数据发送send/SSL_write前和接收recv/SSL_read后数据往往是明文的。找到应用自身的加解密函数进行Hook直接获取明文。方案2对比分析。多次捕获同一操作下的请求/响应数据包对比其差异推测协议结构。例如每次只有timestamp和某个nonce变化那么它们很可能就是协议中的字段。方案3寻找协议库。应用可能使用通用的协议库如Protocol Buffers (protobuf)、FlatBuffers或MessagePack。Hook这些库的序列化/反序列化函数如proto::MessageLite::SerializeToString可以直接获取结构化的对象。6.4 模拟请求被服务器识别现象自己模拟的请求返回错误码而App原生的请求正常。检查清单请求头是否完全复制了原生请求的所有Headers特别是User-Agent、X-Device-Id、Authorization等。签名参数请求中是否包含对请求体或时间戳的签名这个签名算法可能被我们遗漏了。证书绑定SSL PinningApp可能使用了证书绑定拒绝我们模拟客户端的请求。需要在Hook脚本中绕过或者将App的证书信任链提取出来用于我们的模拟客户端。环境指纹服务器可能校验了来自App的某些环境特征如Build.FINGERPRINT、传感器列表等这些信息需要在模拟请求中以参数形式携带。整个逆向分析zp_stoken的过程就像一场细致的侦探工作。从外层的API调用开始利用Frida这把“手术刀”层层深入揭开Native函数的面纱追踪内部的RPC调用链最终理解其完整的工作机制。这不仅是为了获取一个令牌更是对移动应用安全机制、协议设计的一次深度实践。每一次成功的逆向都建立在对大量细节的耐心捕捉和严谨推理之上。