ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

安卓逆向实战:从HTTPS抓包到So层签名算法还原

安卓逆向实战:从HTTPS抓包到So层签名算法还原 做逆向分析这行最怕遇到的就是“抓包工具打开满屏HTTPS密文愣是不知道从哪下手”。前阵子我正好在啃一个头部短视频应用的协议也就是TK系列App的抓包与So层逆向从抓包、协议分析一路做到native层算法还原踩了不少坑也沉淀了一套可复用的思路。这篇就把整个过程中最核心的东西拆开了讲重点放在“怎么从流量一步步追到So层函数又怎么把算法还原出来”内容包括抓包环境搭建、签名参数定位、IDA静态分析、Frida动态验证、unidbg模拟执行还有最后用Python复现算法的完整闭环。无论你是刚接触安卓逆向的新手还是被某款App签名算法卡住的同行这篇应该都能给你一些能直接落地的参考。1. 项目背景一个“抓不到、看不懂、剥不开”的三层难题1.1 为什么普通抓包对TK无效先说结论这类应用在传输层做了非常完整的防护你光靠Fiddler或者Charles挂个代理根本拿不到有用的明文请求。第一次抓包时我打开Fiddler手机上访问App看到的基本全是443的TLS握手记录点开请求体之后全是乱码页面也直接提示网络异常。原因有两个。第一个是证书校验。Android 7.0以后系统默认不再信任用户安装的CA证书也就是说你把Fiddler的证书装到手机里对大多数App来说等于没装底层网络库根本不认。第二个是SSL Pinning。TK这层更狠客户端在native层自己实现了证书校验逻辑不只是系统TrustManager那一套它会在TLS握手时校验服务端证书指纹一旦发现证书不是它预期的那个直接中断连接。这就是为什么很多教程里教的“导入证书”在这里根本走不通。真正可用的思路是“绕过校验让流量明文落地”。做法有两条路一是Hook掉Java层和native层的证书校验逻辑让App不再对证书做校验二是在root设备上用tcpdump抓原始流量配合Wireshark做协议分析这个适合在常规抓包完全失效时兜底。我这次是先用前者拿到明文请求再用后者做交叉验证。1.2 整体路线从抓包样本到算法复现整个项目听起来目标很多实际上是一条清晰的链路完整过程可以拆成五个环节抓包拿样本、协议分析定位加密参数、Java层定位Native调用入口、So层静态逆向还原算法、动态验证并复现。每一步都有承上启下的关系哪一环断了后面都做不下去。我先把路线图提前放在这里方便你有个整体概念。第一步抓包目标是拿到完整可读的HTTPS请求看清每个Header和Body字段第二步协议分析目标是确定哪些字段是动态的、哪些是固定不变的找出最可疑的那个签名参数第三步Java层定位通过反编译和Hook找到代码里生成签名的地方再追到它调用的native函数第四步So层逆向用IDA加载目标So文件通过字符串、导入表、JNI动态注册等信息定位关键函数逐段还原加密逻辑第五步动态验证用Frida在真机上验证函数输入输出用unidbg在电脑上模拟执行最后用Python复写算法跑出和App一模一样的签名才算彻底拿下来。每一步都有一些值得展开说的细节下面逐个聊。2. 抓包环节先把请求流量完整地拿到手2.1 环境准备Android 7以上证书信任的坑抓包环境的搭建是第一步也有很多细节需要注意。这里我推荐直接用Fiddler或者Charles哪个顺手用哪个。我习惯用Charles界面干净过滤功能也足够。搭建时先在PC端开启HTTPS抓包设置代理端口然后让手机和PC处于同一局域网把WiFi代理指向PC的IP和端口手机需要访问一个带证书下载链接的页面Charles是chls.pro/sslFiddler是http://ip:端口下载并安装CA证书。这里必须提一个很容易踩的坑Android 7.0以上默认不信任用户CA证书只信任系统证书。如果你只是把CA装成“用户证书”那么对TK这种强制校验的应用来说完全无效。解决办法取决于设备情况。如果手机已经root最简单粗暴的办法是直接把CA证书转换成系统证书格式复制到/system/etc/security/cacerts目录里注意PEM文件名必须是subject_hash_old.0的格式改完还要给644权限。如果手机没有root那就得靠第二步里的Hook方案把证书校验逻辑绕过去再让App信任用户证书。证书转换的步骤也不复杂先把证书导出为PEM格式然后用openssl x509 -inform PEM -subject_hash_old -in cert.pem算出hash值把文件重命名为hash.0push到系统目录即可。这一步做完以后打开系统设置在“信任的凭据”里能看到自己的CA证书整个抓包环境才算真正搭好。2.2 绕过SSL Pinning让HTTPS变成明文证书装好了下一步是绕过SSL Pinning。这里的核心思路是Hook住应用代码里所有检查证书的地方让校验逻辑直接短路。最常见的Hook点是TrustManagerImpl和OkHttp的CertificatePinner前者是系统网络栈的证书校验入口后者是OkHttp库自己的校验逻辑。我之前用的方案是Frida 一段精简的JS脚本挂在okhttp3.CertificatePinner的check方法上直接空实现同时HookTrustManagerImpl的checkServerTrusted让它不管遇到什么证书都直接返回成功。代码大概长这样Java.perform(function () { var CertificatePinner Java.use(okhttp3.CertificatePinner); CertificatePinner.check.overload(java.lang.String, java.util.List).implementation function (hostname, peerCerts) { console.log([] Bypass CertificatePinner: hostname); return; }; var TrustManagerImpl Java.use(com.android.org.conscrypt.TrustManagerImpl); TrustManagerImpl.checkServerTrusted.overload([B, java.lang.String, java.lang.String, java.lang.String, java.util.List).implementation function () { return; }; });实际跑起来以后网页和App的请求都能在Charles里看到明文了。但需要注意的是TK这套应用除了OkHttp之外还有一部分流量走的是自己的native网络库Java层的Hook覆盖不到。这种情况下就需要配合native层的Hook通常是在SSL_read、SSL_write这些OpenSSL函数上做拦截解密之后的数据就是明文流量。这个思路和抓包是同一个目的只不过操作对象从Java层下沉到了native层。2.3 兜底思路tcpdump抓原始流量辅助分析如果Java层Hook和native层Hook都做了还是拿不到某些接口的明文这时候就别死磕了换个思路直接在root设备上用tcpdump抓包把原始流量导出成pcap文件再丢到Wireshark里分析。这个方法虽然看不到HTTPS解密后的内容但在判断“某些请求是不是真的走了独立通道”“域名解析是否正常”“TCP连接序列是怎样的”这些场景下非常有用。抓包命令很简单tcpdump -i any -s 0 -w /sdcard/capture.pcap跑一段时间后CtrlC结束把pcap文件拉到电脑上Wireshark打开以后先用tls.handshake.type 1过滤出ClientHello可以看到客户端请求了哪些域名再配合http2过滤能大概看出请求的流向。这里顺便说一个我实际用过的排查场景有次某接口在Charles里完全看不到任何请求但App功能却是正常的判断它一定走了独立通道。后来就是用tcpdump抓包发现这个域名走的是另一个IP直连根本没有经过本机代理。这种情况下Charles当然看不到只能靠原始流量分析来定位网络层的问题。所以TCP层抓包虽然拿不到明文但在定位“流量走没走代理”“有没有绕过抓包工具”这些问题上是不可替代的。2.4 抓到以后先看什么请求头里的加密字段明文请求拿到手以后第一件事不是闷头分析而是先把请求头里的字段全列出来逐个判断它们的含义和动态性。我会先把同一个接口的请求连发几次对比请求头之间的差异。TK这类App的请求头里有几个字段一看就是动态的一个是时间戳每次请求都会变通常叫timestamp或者藏在URL里还有一个是设备相关ID比如device_id、iid、install_id这些最后是那种命名风格异常、看起来完全不像标准头的字段比如X-Gorgon、X-Bogus、A-Bogus、X-SS-STUB之类这些十有八九就是native层算出来的签名也正是后面逆向的重点目标。拿到一个请求样本以后我会习惯性把所有Header、URL参数、Body参数全部保存下来整理成一张清单标记清楚哪些是常量、哪些是动态值、哪些可能参与签名计算。这个习惯很重要因为后续做控制变量实验、判断签名范围的时候靠的就是这份清单能省掉大量重复抓包的时间。3. 协议分析定位加密参数的生成逻辑3.1 请求结构拆解先按可变性分类拿到了一批请求样本后进入协议分析阶段。这个阶段的核心目标只有一个找出哪个字段是必须破解的以及它到底受哪些字段影响。我的做法是把请求里的参数拆成三大类。第一类是固定常量比如App版本号、平台标识、SDK版本每次请求都一样第二类是上下文变量比如时间戳、随机数、设备ID每次请求都可能不同第三类是派生值也就是我们最关心的签名或加密字段它和前面的字段存在某种依赖关系它的值会根据其他参数的变化而变化。以抓到的某个典型请求为例URL是/api/feed/?type0max_time1712304000count20Header里有timestamp、device_id、X-GorgonBody是一个JSON里面还嵌套了一些加密的子字段。初步判断timestamp和max_time是同一个时间戳的两次出现X-Gorgon极有可能就是基于URL参数、Body和时间戳生成的签名。3.2 控制变量实验确定参与签名的字段接下来就是我最常用的“控制变量法”。具体操作是固定住其他参数只改变一个参数的值观察签名有没有变化从而判断这个参数是否参与签名计算。我举个例子。第一次实验只修改请求头里的timestamp其他都不动结果发现X-Gorgon的值变了说明时间戳参与了签名计算。第二次实验只修改URL里某个和请求内容无关的参数结果发现签名也跟着变说明URL的完整路径或查询字符串也进了签名输入。第三次实验只修改device_id结果一样签名也变了。再试一次只修改Body里的某个业务字段发现没有变化说明这个字段不在签名范围内。连续多次实验之后基本可以把参与签名计算的字段范围确定下来。我当时得出的结论是签名输入大概等于“请求路径 查询字符串 时间戳 设备ID Body的一个摘要”具体的拼接顺序和分隔符还得靠So层逆向才能确定。这一步的价值在于它告诉我们逆向的时候不该在无关字段上浪费精力只需要盯着签名函数对哪几个输入做处理就够了。3.3 从Java层找到Native调用反编译与Hook定位确定密文字段以后下一步是在代码里找它的生成位置。这个过程分两条线可以并行。第一条线是静态反编译。用jadx或GDA打开APK先全局搜索关键字比如X-Gorgon、sign、signature找到引用它的Java代码跟踪它是个方法调用还是一个字符串拼接。由于签名通常通过JNI调用native层实现Java层一般会有一个System.loadLibrary(xxx)的加载语句紧接着调用某个native方法方法名一般是nativeGetSign或者getSignFromNative之类。第二条线是动态Hook。如果静态搜索不顺利可以直接用Frida Hook所有可疑的Java方法打印参数和返回值。可以先Hook常见网络库的请求创建方法比如OkHttp的Request$Builder.build()看它往外吐什么。也可以直接枚举目标类的所有方法凡是返回值长得像签名的全部打印出来。实际逆向的时候我一般是两种方式并行静态搜索提供线索动态Hook做验证互相补充很快能定位到native函数的声明位置和所在So文件。4. So层静态逆向用IDA把核心算法啃下来4.1 解包定位So别在错误文件上浪费时间Java层的调用点找到之后接下来进入正题So层逆向。第一步是打开APK把lib/arm64-v8a目录下的So文件全部列出来再结合Java层的System.loadLibrary调用锁定目标So。这里的经验是不要看见一个So就一头扎进去先检查它的导出函数和字符串判断它是不是包含加解密逻辑。如果某个So导出的函数名大多是Java_com_xxx_yyy_nativeSign这种格式那大概率就是它如果导出的都是so库本身的基础函数那可以先放一边。我当时锁定的是一批以安全、加密相关命名的So比如libmetasec.so、libtersafe.so、libcms.so这类。不同的App命名差异很大但功能上基本都是一样的负责对请求做签名、对某些字段做加密。这个阶段不用急着深挖先把So拽进IDA看一下有没有符号、有没有字符串、导入表长什么样心里有个数。4.2 找函数入口导出函数与JNI动态注册So层逆向最卡人的地方往往不是算法本身而是找不到函数入口。很多App为了隐藏逻辑不会把native函数作为JNI导出函数留在.dynsym表里而是用RegisterNatives动态注册函数名只存在于So的只读数据区甚至被加密混淆。这种情况下你在IDA的导出表里根本看不到Java_com_xxx格式的函数。解决办法是换个思路从JNI_OnLoad入手。So被加载时会调用JNI_OnLoad如果So使用动态注册在这个函数里一定能看到RegisterNatives的调用。RegisterNatives的第二个参数是一张结构体数组数组里每一项包含Java方法名、签名、native函数指针。在F5的伪代码里你会看到类似这样的逻辑static const JNINativeMethod methods[] { { nativeSign, (Ljava/lang/String;Ljava/lang/String;J)Ljava/lang/String;, (void*)sub_12345 }, { nativeEncrypt, ([B[B)[B, (void*)sub_23456 }, };这段代码的价值特别大它直接告诉你Java层的nativeSign对应So里的sub_12345而且顺带把参数格式都交代清楚了。看到这个结构整个逆向就有了明确的切入点。所以静态逆向的第一步一律先看JNI_OnLoad再看导出函数这个顺序不能反。4.3 识别算法类型从常量到结构的判断方法找到目标函数以后接下来就是还原它的加密逻辑。具体到代码层面先用IDA的F5把汇编转成伪代码然后观察几类东西。第一类是标准算法常量。加密算法基本都有标志性的常量只要在代码里看到这些就能判断大概用了什么算法。TEA/XTEA的delta常量是0x9E3779B9MD5的初始值是0x67452301、0xEFCDAB89、0x98BADCFE、0x10325476CRC32的查表常量是0xEDB88320AES的S盒开头是0x63, 0x7c, 0x77, 0x7b。如果在代码的只读数据区看到这些常量基本可以断定核心算法类别。有了这个判断后面的分析就有了方向。第二类是运算结构。标准算法里的移位、异或、查表、多轮循环特征都很明显。比如TEA会有一串sum delta的循环MD5会有一大段以FF、GG、HH、II为代表的轮函数AES会涉及S盒查找和列混合等操作。看到这些结构就能初步确认算法的骨架。我用一个表格把这几年逆向常用的算法特征整理一下方便查阅算法特征常量或结构识别要点MD50x67452301, 0xEFCDAB894个32位初始值 64轮循环SHA10x67452301, 0xEFCDAB895个32位初始值 80轮循环TEA/XTEA0x9E3779B9sum累加 多次右移/左移异或AESS盒 0x63, 0x7c, 0x77字节代换 行移位 列混合CRC320xEDB88320大表查表计算RC40~255初始化置换KSA/PRGA两段循环不过现在很多App不会直接用标准算法而是在标准算法基础上做了魔改比如改了轮数、改了初始常量、改了S盒。所以看到标准算法特征只能说明骨架具体参数必须逐行对照代码确认。这种魔改算法在逆向里很常见也别太慌核心思路还是先按标准算法框架去套再找出和标准实现不同的地方。4.4 对抗OLLVM与字符串加密静态分析卡住时怎么办静态分析最崩溃的环节是遇到混淆。TK这一级别的AppSo层基本都上了OLLVM控制流平坦化、虚假控制流、字符串加密几乎是标配。F5出来的伪代码会变得又长又绕充斥着switch分支和永远不成立的条件判断函数体动辄几千行一眼看去根本摸不到逻辑。我的处理方案有几步。第一步先别急着分析整个函数用angr或者直接靠Frida动态插桩先搞清楚函数的输入输出和关键中间状态让动态分析为静态分析带路。第二步利用OLLVM的生成特点找真实控制流平坦化之后的代码块之间会有一个switch分发器通过乱序的state变量控制流程。在IDA里可以通过Findcrypt插件或手动搜索大块常量组合来定位这些结构然后通过动态调试记录state跳转顺序重建真实流程。第三步字符串加密的问题相对好办很多字符串在调用点之前会有一个解密函数直接在解密函数返回后的位置下断点动态调试时就能看到明文字符串。说到底静态分析不是万能的遇到强混淆时动态调试和unidbg反而是更高效的工具。这也是为什么我一直强调So层逆向必须静态和动态结合着来哪条路通畅就走哪条。5. 动态验证与算法复现让逆向结论真正落地5.1 Frida Hook验证输入输出静态分析得到初步结论以后不能直接信。我的习惯是用Frida先做一次完整的输入输出验证确保自己理解的算法逻辑和真实行为完全一致。具体做法是写一段Frida脚本Hook住目标native函数调用前记录参数调用后记录返回值。比如目标函数是nativeSign(String url, String body, long timestamp)脚本可以这样写var sign Module.findExportByName(libmetasec.so, Java_com_xxx_nativeSign); Interceptor.attach(sign, { onEnter: function (args) { var env args[0]; var str Java.vm.getEnv(); this.url Java.use(java.lang.String).$new(Java.vm.getEnv().getStringUtfChars(args[2], null)); this.body Java.use(java.lang.String).$new(Java.vm.getEnv().getStringUtfChars(args[3], null)); this.ts args[4].toInt32(); console.log([] url this.url body this.body ts this.ts); }, onLeave: function (retval) { var result Java.vm.getEnv().getStringUtfChars(retval, null); console.log([] sign result); } });跑完以后把某次调用的输入输出保存下来然后在本地重复实验用同一组参数算出自己的结果如果两边一致说明算法理解正确如果不一致就对着差异去排查。这个方法屡试不爽它能快速验证你的静态分析结论到底对不对。5.2 unidbg离线模拟不依赖真机跑通So真机调试最大的问题是效率低而且某些So会检测调试器、检测Frida处理起来麻烦。这里我是强烈推荐unidbg的它能在PC上直接模拟Android的JNI环境和CPU指令加载目标So并调用native函数完全不需要真机。用unidbg的基本流程是在Java里创建一个AndroidEmulator实例加载目标So调用JNI_OnLoad然后通过JNI_OnLoad里拿到的JavaVM和JNIEnv调用目标函数传入构造好的参数拿到返回值。下面的代码是一个简化示例emulator AndroidEmulator.builder() .setProcessName(com.example) .build(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); vm emulator.createDalvikVM(); vm.setJni(new MyJni()); DalvikModule dm vm.loadLibrary(metasec, false); dm.callJNI_OnLoad(emulator); DvmObject? url DvmObject.createObject(java.lang.String, /api/feed/?type0); DvmObject? body DvmObject.createObject(java.lang.String, {\count\:20}); DvmObject? result dm.callJNI(Java_com_xxx_nativeSign, vm.addLocalObject(url), vm.addLocalObject(body), 1712304000L); System.out.println(result.getValue());unidbg对逆向效率的提升非常明显不需要每次调参数都去真机上点一遍直接在本地写Java代码改参数、跑函数、看输出调试循环从“改代码-部署-点App”变成了“改参数-运行-看输出”。而且unidbg还能配合traceCode功能做指令级trace在OLLVM混淆面前一条条trace指令的行为比人眼在IDA里盯伪代码快得多。当然unidbg也不是万能的它需要在初始化时补齐So依赖的系统库比如libc.so、liblog.so、libdl.so等如果缺少某些自定义So还会报错。不过这些都是经验问题多跑几次就熟了。uniDBG在我这次的逆向里承担了最重的验证工作某个算法的最终参数确认就是靠unidbg跑出来的。5.3 Python复现签名逻辑并回归验证算法逻辑确认无误之后最后一步是把整个签名流程用Python复现出来写成一个独立模块。这个模块不依赖App、不依赖So只依赖标准库方便后续做自动化调用和回归测试。以一个简化版的签名逻辑为例假设它的计算过程是把请求路径、时间戳、设备ID拼接成字符串再做自定义MD5然后Base64编码最后把结果中的某些字符替换成固定字符。复现的Python代码大致是import hashlib import base64 import time def generate_sign(path: str, device_id: str, timestamp: int None) - str: if timestamp is None: timestamp int(time.time()) raw f{path}device_id{device_id}timestamp{timestamp}keySECRET_KEY_PLACEHOLDER md5_bytes hashlib.md5(raw.encode(utf-8)).digest() b64_str base64.b64encode(md5_bytes).decode(utf-8) return b64_str.replace(, -).replace(/, _).rstrip() if __name__ __main__: print(generate_sign(/api/feed/?type0, 1234567890, 1712304000))这里我特意把SECRET_KEY_PLACEHOLDER留作说明实际逆向中这个key要么在So的某个只读区里要么是通过设备信息动态生成的前期分析的时候需要格外留意。写出Python模块之后拿它和unidbg跑出的结果做批量对比随机生成几百组不同参数如果每组签名都和App计算一致这个算法就算真正复现完成了。6. 实战中的典型坑与排查记录6.1 反调试误伤调试器刚附加就被踢So层逆向最让人上头的问题就是反调试。我第一次尝试用IDA动态调试时刚附加进程不到三秒进程就退出或被系统杀死。后来排查发现目标So在JNI_OnLoad阶段就创建了一个线程定期检查/proc/self/status里的TracerPid只要发现不是0立刻执行自杀逻辑。这是最常见的反调试手法处理方式是先静态分析出反调试代码的位置动态调试时用patch绕过或者直接把检测逻辑nop掉。另外还有检测Frida的比如扫描/proc/self/maps里有没有frida相关路径所以实际使用Frida时我会用frida-server的随机端口模式并且改掉默认的frida-agent文件名能规避掉大部分简单检测。6.2 找不到Java层对应的Native方法有时候在Java层已经看到了System.loadLibrary(metasec)但搜遍So的导出表都找不到Java_com_xxx这个时候多半是动态注册。解决方法是回到JNI_OnLoad在IDA里找到RegisterNatives的调用把结构体数组里的方法名和函数指针一一对应出来。还有更隐蔽的情况RegisterNatives的方法名和签名被加密静态分析只能看到它从某个内存地址读取字符串。针对这个动态调试是最快的直接在RegisterNatives函数的参数位置下断点内存里就能直接看到解密之后的Java方法名不用逆向加密逻辑也能拿到表。6.3 签名结果差几个字节字节序和编码的坑费了好大劲把算法逻辑梳理通了结果本地复现出来的签名和App实际算出来的不一样这个情况非常多。最常出问题的地方是字节序和字符编码。比如MD5的摘要结果在标准实现里是按字节输出但某些魔改版本会以32位整数形式做了大小端交换导致输出顺序不同。还有字符串拼接时的编码究竟用的UTF-8还是UTF-16对最终摘要结果影响非常大。排查这类问题我一般先把算法输入逐字节打印出来用hexdump对照App进程里拿到的原始输入找出第一个不一样的字节往往问题就出现在那附近。6.4 常见问题速查表最后把这次实战中遇到的高频问题整理成一张速查表方便以后快速定位。现象可能原因处理建议抓包工具看不到明文请求SSL Pinning或native证书校验Hook CertificatePinner/TrustManager或Hook SSL_readAndroid 7以上装了证书仍失败用户证书不被信任把CA转成系统证书并放到/system/etc/security/cacertsSo导出表找不到Java_函数使用RegisterNatives动态注册从JNI_OnLoad入手找RegisterNatives调用IDA附加进程即退出TracerPid或ptrace反调试patch检测代码或改用unidbg做模拟执行Frida Hook不上native函数frida-agent被检测或端口问题随机端口 重命名agent文件本地复现签名与App不一致字节序、编码或拼接顺序错误逐字节对比输入定位第一个差异点unidbg加载So报缺少库依赖的系统库未补齐按崩溃日志逐个添加libc/liblog/libdl和自定义依赖这些坑几乎每个做So层逆向的人都会遇到提前知道解决方案比临时去翻源码高效得多。最后再分享一点个人体会做这类逆向最怕的是上来就对着反汇编代码硬怼。正确的方式永远是“先用业务逻辑和协议分析缩小范围再用动态验证给静态分析带路”抓包拿样本是为了定位目标协议分析是为了缩小攻击面动态Hook是为了给静态分析提供线索unidbg是为了提高验证效率。每一步都有明确的目的整套链路走完那个看起来不可破解的签名算法也就没那么神秘了。希望这篇能帮你少走一些弯路。
返回列表