ARTICLE DETAIL

资讯详情

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

Android逆向实战:还原激励类App签到接口的签名算法

Android逆向实战:还原激励类App签到接口的签名算法 有一位做运营的朋友跟我抱怨说自己手机里那款“看视频赚金币”的App提现门槛越来越高想让我帮忙看看到底是哪里卡住了。我拿到手之后索性做了一轮完整的Android逆向案例分析目标很直接理清它的签到接口和任务上报接口的请求协议还原签名参数生成算法并验证能否在脱离App的情况下离线构造合法请求。整个过程走下来踩了不少坑也沉淀出一套可以复制到同类激励类App上的分析流程这里完整记录一下。先说清楚这类“看视频赚钱”App案例代号“青看点”以下所有包名、接口路径均做脱敏处理在技术架构上其实很有代表性客户端负责上报用户行为服务端下发金币和任务状态核心逻辑全部走HTTP接口。为了保护接口不被批量伪造请求体里几乎都带了一个sign签名字段。把这个签名算法还原出来就能理解整个客户端的防篡改思路。下面我会从抓包、脱壳、定位签名函数、还原算法到Frida动态验证一条线串下来。1. 目标选取与红线为什么是青看点这类App逆向边界在哪1.1 “看视频赚金币”App的技术共性这类App的商业模型很简单用户签到、看激励视频、完成每日任务客户端把“完成”这个动作上报到服务端服务端确认后往账户里加金币。金币攒到一定数量可以提现。为了控制成本服务端不可能完全信任客户端上报的内容所以每个请求都会被客户端加上一个签名参数。这个签名参数通常是把业务参数、时间戳、随机数甚至设备信息拼在一起再用某种摘要算法算出来的。服务端收到请求后用同样的规则重算一遍签名如果对不上就直接拒绝。正因如此这类App很适合作为逆向分析的学习样本接口语义明确签到、任务上报、提现查询全部一目了然签名算法有一定复杂度但又不至于像大厂核心App那样层层嵌套、vmp加固拉满商业壳和反调试存在但强度适中能完整覆盖“抓包→脱壳→静态分析→动态验证”的全流程。我这次给自己定的分析范围是定位签到接口和任务上报接口还原签名参数sign的生成逻辑离线复现请求验证请求能否被服务端正常接受。全程只研究我自己注册账号的数据不碰服务端、不改数据库、不批量刷量。1.2 必须先想清楚的合规边界在做任何逆向分析之前我建议先给自己划几条红线特别是这种带资金结算属性的App。第一只分析客户端本地代码不攻击服务端。接口探测、批量遍历、绕过风控这类操作严格避免。第二只拿自己账号的数据做验证拿到签名算法后用离线请求验证一次接口返回是否正常证明算法还原正确即可。第三不发布自动化刷量脚本不用于生产环境。第四文中出现的所有包名、类名、接口路径、密钥值我都会打码脱敏。这不是套话。逆向分析本身是安全研究里非常正常的手段用来学习协议设计、研究客户端防护、做漏洞挖掘都没有问题。一旦越界变成黑产工具性质就完全不同了。1.3 整体分析链路把整个项目拆开看核心就是下面这条链路抓包拿到请求明文 → 识别加密参数 → 用jadx静态分析定位签名函数 → 用Frida动态Hook确认参数入口 → 还原签名算法 → 离线构造请求 → 验证服务端响应后面三章基本就是这条链路的逐站展开。2. 从安装包到可读代码抓包、查壳、脱壳的完整链路2.1 先把HTTP明文看清楚Android 7证书信任问题拿到目标之后第一步永远是抓包。我电脑上开Charles手机走代理结果打开App之后看到的全是密密麻麻的加密请求HTTP层什么都分析不了。这是预料之中的事服务端通信用HTTPS证书校验还做了双向验证以外的“用户证书不信任”处理。Android 7开始系统默认不信任用户安装的CA证书只有系统证书才被信任。Charles的证书如果只以用户证书方式安装App里很多请求会直接报CERT_PATH_TRUSTED之类的错误或者干脆静默失败。解决办法有两个方向。方向一把Charles证书装进系统证书信任区。流程如下# 导出Burp/Charles证书为DER格式然后转成PEM openssl x509 -inform DER -in cacert.der -out cacert.pem # 计算证书的subject_hash_old值作为文件名 HASH$(openssl x509 -inform PEM -subject_hash_old -in cacert.pem | head -1) # 重命名证书文件放到系统证书目录 cp cacert.pem ${HASH}.0 # 推送到/system/etc/security/cacerts/需要root权限 adb root adb remount adb push ${HASH}.0 /system/etc/security/cacerts/方向二用Frida批量绕过证书校验业界常用方式是HookTrustManager和X509TrustManager的checkServerTrusted方法让它无条件信任任意证书。或者直接加载一个“JustTrustMe”之类的模块但实际项目中手动写脚本更可控。我实际用的是方向一。把证书装进系统区之后再用Charles打开App终于能看到完整的请求了SQL、业务路径、参数名全部明文可见。2.2 识别加固壳从入口确定脱壳方案抓到包只是开始。我掏出jadx直接打开安装包想按包名搜核心类结果搜索栏里敲了好几个关键字都一无所获。打开Application入口类一看整个方法体只剩一行System.loadLibrary(shell)类的名字也变成了类似com.qkd.protect.StubApplication的东西。这是很典型的商业壳特征DEX整体被加密真正的业务字节码全部隐藏在so层的脱壳逻辑里。静态分析直接被截断所以下一步是脱壳。我用的是frida-dexdump直接对运行中的进程做内存dump把已解密且加载进来的dex文件从内存里抠出来。命令流程大概是这样# 先把frida-server推送到Android设备并启动 adb push frida-server-16.x.x-android-arm64 /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server # 用frida-dexdump启动目标App并自动dump frida-dexdump -U -f com.qingkandian.demo -o dump/这里有个小技巧如果直接spawn模式失败可以先打开App再attach到进程dump。命令换成frida-dexdump -U -n 青看点。整个过程大约几十秒会dump出一堆dex文件内存里既有壳自身的代码也有还原出来的业务代码。这时候不要急着全部分析把dex按大小排序优先看体积最大、类最多的那一个或几个业务逻辑基本都在里面。2.3 把dex变成可读Java代码拿到dex后我重新拖进jadx这次能够正常搜索到业务类了。不过App做了中等强度的混淆和字符串加密类名和方法名大部分被改成a、b、c但部分工具类因为内部逻辑复杂保留了可读名字比如SignUtil、ApiConstants等等。刚开始我直接全局搜sign字符串结果跳出来上百条结果大部分都是无效噪音。后来我换了一种更有效的方式先在jadx里搜索抓包得到的接口路径/api/v1/user/signin定位到Retrofit的ApiService接口定义然后顺着这个接口往上找是谁调用了它谁在调用前构造了参数最终定位到RequestInterceptor这个OkHttp拦截器类。拦截器里的逻辑通常就是“读取参数→生成签名→追加到请求体”的地方。这一步是整条分析链路里最关键的转折点。一旦把目标从“SignUtil”缩小到“拦截器”剩下的工作就从大海捞针变成了顺着一条清晰的调用链往下走。3. 定位加密参数从流量入口反推出签名函数3.1 从请求包中拆出必须逆向的参数第二次抓包我已经能看到完整的明文请求了。拿签到接口举例请求长这样POST /api/v1/user/signin HTTP/1.1 Host: api.qingkandian.example Content-Type: application/x-www-form-urlencoded uid10086platformandroidapp_version2.1.3ts1690000000nonceAbCdEf123sign8F9A2B7C...拆开来看参数含义是否参与签名uid用户ID是platform平台标识是app_versionApp版本号是ts当前时间戳是nonce随机字符串是sign签名值由上述参数计算而来sign的值就是核心。服务端收到请求后会把其他所有参数连同服务端下发的密钥做一次同样的计算得到的结果如果和sign不一致请求就会被判定为非法。换句话说只要能还原sign生成算法就能离线构造一个服务端认可的正常请求。3.2 用jadx的“字符串和调用栈”双向夹击签名函数定位签名函数我用了“字符串搜索 调用栈反查”的组合。先看字符串层面在jadx里搜接口路径/api/v1/user/signin找到了定义在ApiService接口上的方法注解同时看到了它的POST声明。这说明网络层用的是Retrofit OkHttp。再看方法所在的类ApiService接口只是声明真正发出请求的是它背后的代理对象。Retrofit会在运行时生成实现类并在调用时套上OkHttp拦截器。顺着这个思路我在jadx的全局类列表里搜带有Interceptor字样的类果然找到了一个叫AuthInterceptor的内部类。AuthInterceptor的intercept方法代码逻辑很清晰Override public Response intercept(Chain chain) throws IOException { Request original chain.request(); HttpUrl url original.url(); TreeMapString, String params new TreeMap(); params.put(uid, UserManager.getUid()); params.put(platform, android); params.put(app_version, BuildConfig.VERSION_NAME); params.put(ts, String.valueOf(System.currentTimeMillis() / 1000)); params.put(nonce, generateNonce()); String sign SignUtil.sign(params); // 把sign追加到请求体或URL上 Request.Builder builder original.newBuilder(); // ... return chain.proceed(builder.build()); }签名的入口很直接SignUtil.sign(params)。到这里分析目标明确锁定在SignUtil这个类上。3.3 先用Frida确认Hook点是否命中在静态分析下一步之前我习惯先上一个Frida脚本确认这个Hook点真实有效避免算法都分析完了才发现找错了入口。Java.perform(function () { var SignUtil Java.use(com.qingkandian.demo.utils.SignUtil); SignUtil.sign.overload(java.util.TreeMap).implementation function (map) { var result this.sign(map); console.log([SignUtil.sign] params map.toString()); console.log([SignUtil.sign] sign result); return result; }; });启动App触发一次签到操作控制台立刻打出了参数列表和签名结果。比较抓包里的sign完全一致。这就说明SignUtil.sign就是请求签名的最终计算点。其实到这里核心算法已经近在眼前了。4. 还原签名算法Java层调用链与native层密钥获取4.1 Java层签名逻辑并不复杂排序拼接MD5锁定了SignUtil之后jadx里看它的源码逻辑很简单几乎是教科书级别的实现把传入的TreeMap按字典序升序排列排除值为空或为null的字段把所有键值对拼成k1v1k2v2形式的字符串最后拼接一个固定字符串keySECRET对整个字符串做MD5转大写得到sign。用伪代码表示就是这样public static String sign(TreeMapString, String params) { StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { String value entry.getValue(); if (value null || value.isEmpty()) { continue; } if (sb.length() 0) { sb.append(); } sb.append(entry.getKey()).append().append(value); } sb.append(key).append(SecretKeyProvider.getSecretKey()); return MD5(sb.toString()).toUpperCase(); }这套模式在大量App里都能见到不同产品的区别主要在于两个地方排序规则和密钥来源。有的App对每个字段单独加密后再拼接有的App还要混入nonce、timestamp做防重放但核心思路八九不离十。真正的问题是最后一行里的SecretKeyProvider.getSecretKey()。4.2 native层的取舍拿到key和算出key是两码事SecretKeyProvider.getSecretKey()点进去一看方法声明带了native关键字说明密钥不在Java层明文保存而是藏在了so文件里。到这一层逆向就有两条路线可以选。路线A直接动态Hook拿到运行时的真实密钥。如果so只是简单地返回一个字符串常量这个方案最快一条Frida脚本几秒钟就能拿到结果。路线B如果so内部做了反调试、反Frida检测那getSecretKey()的返回值可能是一个被篡改的假值直接Hook反而会被误导。这种情况下需要静态分析so的导出函数逻辑甚至要动态调试汇编复杂度直接上一个台阶。我这次遇到的情况比较友好直接HookgetSecretKey()返回的结果是一个看起来很像随机字符串的密钥值。我先在本地用它和抓包参数算了一遍签名再和抓包里的sign做对比完全一致。说明so没有设置“检测到被Hook就返回假密钥”的机制。即便如此我也把静态分析的路子走了一遍主要是为了确认密钥到底是从哪里来的。用IDA打开so定位到导出表里的Java_com_qingkandian_demo_security_SecretKeyProvider_getSecretKey看到函数开头就是一段memcpy从.rodata段拷贝数据后面跟着一个简单的异或解密异或的key是硬编码在汇编指令里的。换句话讲如果动态Hook不成功静态解出这个异或值也就是半小时的事。4.3 用Frida动态获取密钥再离线计算签名拿到密钥之后整个签名算法就完全掌握了。我用Frida把密钥和一次请求的参数全部导出来然后放到本地Python环境里离线重算。Frida脚本再简单不过Java.perform(function () { var SecretKeyProvider Java.use(com.qingkandian.demo.security.SecretKeyProvider); SecretKeyProvider.getSecretKey.implementation function () { var key this.getSecretKey(); console.log([getSecretKey] key); return key; }; });Python端离线计算签名import hashlib import time import random import string def build_sign(params: dict, secret: str) - str: new_params {k: v for k, v in params.items() if v not in (None, )} sorted_keys sorted(new_params.keys()) raw .join(f{k}{new_params[k]} for k in sorted_keys) raw raw key secret return hashlib.md5(raw.encode(utf-8)).hexdigest().upper() params { uid: 10086, platform: android, app_version: 2.1.3, ts: str(int(time.time())), nonce: AbCdEf123, } sign build_sign(params, 找到的真实密钥)把这串代码跑一遍和抓包得到的sign值比对完全一致说明离线复现已经成立。4.4 如果拿不到用unidbg在PC上模拟执行so这里补充一个更硬核的场景。如果so里的函数不是直接返回密钥而是把密钥作为输入参与加密或者函数内部有Frida检测一被Hook就返回空值这种情况下我一般会选择用unidbg。unidbg本质上是一个Java编写的内存仿真框架它能在Windows、Linux或macOS上直接加载并执行ARM架构的so文件不需要真机也不需要root。安全检测里最麻烦的ptrace、inline hook检测在unidbg环境下是不存在的因为它不是一个真正的进程根本没有被附加的痕迹。用unidbg调用so里的native方法核心流程就三步// 1. 创建Android模拟器实例 AndroidEmulator emulator AndroidEmulatorBuilder.for32Bit() .setProcessName(com.qingkandian.demo) .build(); // 2. 加载目标so文件 Memory memory emulator.getMemory(); Module module memory.loadLibrary(libSecret.so); // 3. 调用导出函数 Symbol symbol module.findSymbolByName(Java_com_qingkandian_demo_security_SecretKeyProvider_getSecretKey);简单理解unidbg就是一个“没有屏幕的模拟器”它把so文件跑在JVM进程里所有系统调用都由框架接管。对于这种“只需要调用一个函数拿返回值”的场景unidbg比真机Hook稳定得多。唯一的问题是初始化JNI环境稍微有点复杂需要处理JavaVM、JNIEnv这些对象但网上例程很多照着改就行。5. 用Frida做端到端验证Hook签名和网络层离线复现请求5.1 环境准备真机上跑通frida-server算法还原出来是一回事拿到整个请求流程里“实际发出的请求签名”是另一回事。这里就必须上Frida做端到端验证了。我用的环境是一台root过的老款Android设备Android 10系统稳定、可用性好电脑上有Python 3、frida-tools和frida-server版本号必须严格一致这个非常关键版本不一致会出现unable to connect to remote frida-server报错。启动流程如下# 推送frida-server到设备 adb push frida-server-16.x.x-android-arm64 /data/local/tmp/frida-server adb shell chmod 755 /data/local/tmp/frida-server # 启动frida-server adb shell /data/local/tmp/frida-server # 电脑端测试连接 frida-ps -U能列出一串进程列表就说明链路通了一半。5.2 同时Hook签名函数和网络层验证“所见即所得”只Hook签名函数还不够因为签名函数可能被多个地方调用或者有一个调用链是“在函数内部又改变了参数”这种情况下只打印函数入参会和真实请求对不上。所以我习惯同时Hook网络层观察实际发出的请求body。OkHttp的入口是OkHttpClient.newCall()Hook它就能拿到所有经过客户端的请求对象包括URL和请求体Java.perform(function () { var OkHttpClient Java.use(okhttp3.OkHttpClient); OkHttpClient.newCall.overload(okhttp3.Request).implementation function (request) { var req request.toString(); console.log([OkHttp] req); // 打印body var body request.body(); if (body ! null) { var Buffer Java.use(okio.Buffer); var buf Buffer.$new(); body.writeTo(buf); console.log([OkHttp Body] buf.readUtf8()); } return this.newCall(request); }; });这个脚本跑起来以后每次App向服务端发请求控制台都会实时打印出完整的URL和请求体内容。把请求体里的参数拉出来代入离线签名脚本重新算一遍再和请求体里的sign值比对。两边完全一致的时候才能确认整条算法链路没有遗漏。我这里还发现了一个有意思的细节任务上报接口的签名参数比签到接口多了一个task_id和duration但签名算法本身一模一样还是那套排序拼接逻辑。也就是说掌握了算法之后所有业务接口的签名都能离线生成。5.3 离线复现本地重放请求验证算法正确性离线复现是检验算法还原是否正确的最后一道关卡。我在Python环境构造了一个签到请求参数完全模拟真实客户端的格式签名用自己还原的算法生成然后直接发到测试环境其实是我自己账号的请求发送一次就好不反复发import requests url https://api.qingkandian.example/api/v1/user/signin payload { uid: 10086, platform: android, app_version: 2.1.3, ts: str(int(time.time())), nonce: AbCdEf123, } payload[sign] build_sign(payload, 找到的真实密钥) resp requests.post(url, datapayload) print(resp.status_code) print(resp.text)服务端返回了正常签到成功的数据。到这里签名算法还原基本坐实。但这里必须明确一个前提我只发了一次请求用于验证没有做任何批量、高频操作。拿到“离线构造请求”的能力目的是验证自己对协议的理解对不对而不是去薅平台的羊毛。真拿去刷任务账号会被风控封禁这种行为也完全越过了安全研究的边界。5.4 设备指纹问题的简单处理验证过程中还碰到一个情况服务端请求里除了sign还有一个device_id参数看起来像是一个设备指纹。我一开始没把这个参数放进离线请求里服务端直接返回了“非法设备”的错误。后来在jadx里搜索device_id找到了一个DeviceInfoProvider类它的作用基本等同于把IMEI、OAID、Android ID等设备标识拼接后做一次散列得到唯一的设备指纹。处理方式很简单Frida里Hook这个类固定返回一个测试值。Java.perform(function () { var DeviceInfoProvider Java.use(com.qingkandian.demo.utils.DeviceInfoProvider); DeviceInfoProvider.getDeviceId.implementation function () { return test-device-id-001; }; });然后重新抓包把真实返回的device_id也并进离线签名参数表里请求就正常了。需要说明的是这个device_id并不参与sign的计算它只是业务层用来识别设备的一个普通字段。服务端会用它与账号历史绑定关系做风控判断但签名本身不覆盖它。6. 踩坑清单与复盘方法论给同类App逆向留一份可复用笔记6.1 完整流程总结整个案例从分析到验证用了大概一个下午。把流程抽象出来就是这张表阶段核心工具关键产出抓包Charles/Burp 系统证书安装明文请求体识别sign参数查壳jadx入口分析确认为商业壳DEX加密脱壳frida-dexdump内存中还原业务dex静态分析jadx定位AuthInterceptor和SignUtil动态验证Frida确认签名函数入口和密钥返回值算法还原Python/Java伪代码排序拼接MD5离线生成合法签名端到端验证Frida Python回放服务端接受离线请求确认算法正确这套流程对“签到/任务上报”类App基本是通吃的。不同App可能加固不同、接口命名不同、签名算法多包几层但“抓包找参数→静态找入口→动态验证→离线复现”这条主链路不会变。6.2 我在这个案例里踩过的坑第一个坑jadx打开原始APK搜不到核心代码。原因就是商业壳把dex加密了我一开始还以为是jadx解析问题折腾了半天才发现是加固。建议拿到安装包先看Application入口类名如果发现类似StubApplication、ProxyApplication这一类壳特征类名就别在原始包上浪费精力直接上脱壳工具。第二个坑Android 7证书信任问题。把Charles证书装到用户信任区之后明文明明已经能在系统浏览器里解开但App的网络请求还是全部加密失败。后来反应过来是App自身使用了SSLContext并指定了自定义TrustManager系统信任区对它无效。最后是通过把证书push到系统证书目录才解决。如果你也碰到这个问题记得优先处理系统证书这块。第三个坑Frida Hook不到so里的native方法。一开始我把SecretKeyProvider整个类Hook了getSecretKey但是等了半天没有输出。后来发现是类已经被加载过我的脚本没等到类加载器就绪就执行了。换成Java.perform里加一个延迟重试或者用Java.enumerateClassLoaders遍历加载器再Java.use问题才解决。另一个原因是so导出符号可能被混淆静态看IDA才能找到真实符号名不建议直接靠getSecretKey这个名字硬刚。第四个坑签名的ts字段有效期很短。一开始离线复现签名算出来之后我隔了十几分钟才发请求服务端直接返回“签名过期”。看抓包才发现服务端对时间戳做了前后3分钟窗口校验。这种机制非常普遍离线脚本里一定要用当前实时时间重新生成ts不能复用抓包里的旧值。6.3 方法论级别的心得做完这个案例最大的体会是逆向分析真正值钱的不是某个App的具体sign算法而是判断“哪个参数值得逆”的敏感度以及从抓包到静态分析到动态验证的完整闭环能力。举几个自己沉淀下来的经验不要一上来就全局搜sign。先抓包把请求字段列出来找到真正的锚点参数再去jadx里反查。很多时候接口路径比参数名更好定位因为类名可以混淆但请求路径很难完全藏住。不要只依赖静态分析或只依赖动态Hook。两者结合效率最高。静态分析能告诉你有多少个调用方动态Hook能快速确认哪个调用方实际参与网络请求静态分析卡住时动态Hook往往能给你一个反推的起点。对加固和混淆抱平常心。商业壳、DEX加密、字符串加密、native密钥保护本质上都只是提高了逆向的门槛而不是让逆向变成不可能。只要业务逻辑在客户端的JVM或so里就一定有办法还原。评估工作量时真正要问的是“这个App把密钥藏在哪一层”而不是“这个App能不能被逆向”。聊到这儿整个“某青看点”的Android逆向案例就完整复盘完了。我后来把这套“抓包→脱壳→定位→还原→验证”的流程固化成了自己分析App时的标准三板斧后续遇到同类产品基本半天就能把主链路摸清楚。最后再分享一个小建议做这类分析时每一步都记录好工具版本、关键命令和操作截图因为坑总是相似的留一份笔记下次能省一半时间。
返回列表