一次尝试某APP签名算法逆向追踪:从抓包到SO层
最近在逆向某App的登录接口,抓包发现请求头里有个签名,看起来挺有意思,决定完整追踪一下它的生成过程,下面是我一步步的记录。
第一步:burp包上的证据痕迹 标出疑似加密
authorization OAuth api_sign=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (标出)疑似加密 找
看到这一长串,第一反应是:这八成是个签名算法。那就顺着这个字段往下追吧。
第二步:反编译提取1关键词搜索
OAuth api_sign= (疑似的加密)
用Jadx打开APK,直接搜”api_sign”,看看能不能定位到关键代码。
第三步:文本凑格式 找架构相似 找出关键函数

apiProccessModel4.apiSign = str;if (str != null) {apiProccessModel4.request.addHeader(“Authorization”, “OAuth api_sign=” + str);}
可见str就是那个疑似加密
运气不错,一下就找到了。这里有个apiSign被赋值为str,然后塞进Header里。那这个str就是我们要找的加密值。
第四步:

str=b.b() 带=号的直接定义
找str对应的意思
前面有 string str =”
一大堆过程定义
但是后面有str = b.b(context2, e10, apiProccessModel3.tokenSecret, apiProccessModel3.url);
直接表达
往上翻了一下,前面一堆初始化代码,但真正的赋值在这里:str是从一个b.b()方法来的。传了context、参数、tokenSecret和url进去。
第五步:看return

public static String b(Context context, TreeMap<String, String> treeMap, String str, String str2) {if (treeMap != null && TextUtils.isEmpty(treeMap.get(ApiConfig.SKEY))) {treeMap.put(ApiConfig.SKEY, f(context, new String[0]));}return a(context, treeMap, str);
点进b.b()看一眼,它调了a()方法,看来真正的逻辑在a()里。
第六步:return + .apiSign(context, treeMap, str) 实际函数调用

private static String a(Context context, TreeMap<String, String> treeMap, String str) {try {if (VCSPCommonsConfig.getContext() == null) {VCSPCommonsConfig.setContext(context);}String apiSign = VCSPSecurityBasicService.apiSign(context, treeMap, str);if (!TextUtils.isEmpty(apiSign)) {return apiSign;
a()方法里调了VCSPSecurityBasicService.apiSign(),继续跟。
第七步:看return

public static String apiSign(Context context, TreeMap<String, String> treeMap, String str) throws Exception {if (context == null) {context = VCSPCommonsConfig.getContext();}return VCSPSecurityConfig.getMapParamsSign(context, treeMap, str, false);
apiSign()里又调了getMapParamsSign(),这层套一层的,有点耐心慢慢跟。
第八步:继续看return 代码比较长 拉下一些看

public static String getMapParamsSign(Context context, TreeMap<String, String> treeMap, String str, boolean z10) {String str2 = null;if (treeMap == null) {return null;}boolean z11 = false;Set<Map.Entry<String, String>> entrySet = treeMap.entrySet();if (entrySet != null) {Iterator<Map.Entry<String, String>> it = entrySet.iterator();while (true) {if (it == null || !it.hasNext()) {break;}Map.Entry<String, String> next = it.next();if (next != null && next.getKey() != null && ApiConfig.USER_TOKEN.equals(next.getKey()) && !TextUtils.isEmpty(next.getValue())) {z11 = true;break;}}}if (z11) {if (TextUtils.isEmpty(str)) {str = VCSPCommonsConfig.getTokenSecret();}str2 = str;}return getSignHash(context, treeMap, str2, z10);
getMapParamsSign()代码有点长,但最后return的是getSignHash(),看来关键还在后面。
第九步:继续看return
public static String getSignHash(Context context, Map<String, String> map, String str, boolean z10) {try {return gs(context.getApplicationContext(), map, str, z10);} catch (Throwable th2) {VCSPMyLog.error(clazz, th2);return "error! params invalid";}}
getSignHash()里调了gs(),这个gs()看起来有点东西。
第十步:重点!!!

private static String gs(Context context, Map<String, String> map, String str, boolean z10) {try {if (clazz == null || object == null) {synchronized (lock) {initInstance();}}if (gsMethod == null) {gsMethod = clazz.getMethod("gs", Context.class, Map.class, String.class, Boolean.TYPE);}return (String) gsMethod.invoke(object, context, map, str, Boolean.valueOf(z10));} catch (Exception e10) {e10.printStackTrace();return "Exception gs: " + e10.getMessage();} catch (Throwable th2) {th2.printStackTrace();return "Throwable gs: " + th2.getMessage();}}private static void initInstance() {if (clazz == null || object == null) {try {int i10 = KeyInfo.f69594a;clazz = KeyInfo.class;object = KeyInfo.class.newInstance();} catch (Exception e10) {e10.printStackTrace();}}}
哇塞,这里居然是反射调用!initInstance()里把clazz赋值为KeyInfo.class,然后通过反射调用gs()。难怪前面一直找不到具体实现,原来是藏在这里了。
尝试了getMethod(“gs”, Context.class, Map.class, String.class, Boolean.TYPE); 但是内部自带函数
尝试了invoke(object, context, map, str, Boolean.valueOf(z10)); 但是内部自带函数
可以看到 initInstance()函数在上面被调用 找 initInstance()函数定义位置在下面 gs来自clazz 都殊途同归到看下面 clazz = KeyInfo.class;
就是说gs找不到直接定义表达 但是可以推断来自KeyInfo
反射绕了一圈,最后还是指向KeyInfo类。那好,直接去看KeyInfo。
第十一步:果然找到了

public static String gs(Context context, Map<String, String> map, String str, boolean z10) {try {try {return gsNav(context, map, str, z10);} catch (Throwable th2) {return "KI gs: " + th2.getMessage();}} catch (Throwable unused) {SoLoader.load(context, LibName);return gsNav(context, map, str, z10);}}private static native String gsNav(Context context, Map<String, String> map, String str, boolean z10);
可以看到最后到native层了 JNI开发 那去把apk改成zip 去文件夹里面找so文件拿ida看。
看到native关键字就懂了——算法在SO层。gs()里调了gsNav(),这个gsNav()是native方法,得去SO里找了。
public class KeyInfo {private static final String LibName = “keyinfo”;
文件名这里 到ida去搜 带lib
第十二步:打开ida 放入”libkeyinfo”文件 因为是jni开发 所以直接搜java_
找到对应的函数打开 按F5把汇编转C,导入jni.h文件
第十三步:右键点击a1函数 –> convert to struct –> JNIEnv 然后hide codes
IDA里定位到对应的native函数,F5转成伪代码,再把第一个参数转成JNIEnv结构体,就能看懂逻辑了。
右键点击a1函数 –> convert to struct –> JNIEnv 然后hide codes
IDA里定位到对应的native函数,F5转成伪代码,看到核心逻辑:

这段代码调了两次j_getByteHash(就是HMAC-SHA256),中间有一次字符串拼接,最后返回第二次hash的结果。
为了验证,用Frida hook一下:
var addr = Module.findExportByName("libkeyinfo.so", "getByteHash");console.log(addr);Interceptor.attach(addr, {onEnter: function(args) {this.x1 = args[2];this.x2 = args[3];},onLeave: function(retval) {console.log("--------------------");console.log(Memory.readCString(this.x1));console.log(Memory.readCString(this.x2));console.log(Memory.readCString(retval));}});
运行后每次请求都会打印出输入数据、密钥和hash结果,和静态分析完全一致。
第十四步:调用链全貌流程图
到这里整个追踪路径就清晰了。我画了个流程图,方便一眼看清全局:
text抓包发现 api_sign↓搜索 "api_sign" 定位到 apiProccessModel4.apiSign = str↓str = b.b()↓b.b() → a()↓a() → VCSPSecurityBasicService.apiSign()↓apiSign() → VCSPSecurityConfig.getMapParamsSign()↓getMapParamsSign() → getSignHash()↓getSignHash() → gs()↓gs() 反射调用 → KeyInfo.gs()↓KeyInfo.gs() → gsNav() (native)↓libkeyinfo.so → 算法实现
这个图的好处是:以后再遇到类似问题,你可以直接套这个分析框架——抓包定位 → Java层追踪 → 识别反射/动态加载 → SO层定位 → 算法还原。
第十五步:最后分析下来是HMAC-SHA256,密钥硬编码在SO文件的.rodata段里。
在IDA里跟进gsNav函数,看到调用了OpenSSL的HMAC_Init_ex、HMAC_Update、HMAC_Final系列函数,参数传递中有一个固定的buffer,指向.rodata段。提取出来一看,就是硬编码的密钥。
第十六步:深度延伸:这个算法的安全性评价与可能的绕过思路
安全性评价:
算法本身:HMAC-SHA256是安全的,目前没有有效碰撞攻击
实现层面:密钥硬编码是典型的安全缺陷。一旦so文件被提取,密钥就暴露了,攻击者可以本地伪造任意签名
防御建议:密钥应存放在更安全的位置,如:
服务端下发(动态令牌)
白盒加密方案
TEE/安全环境存储
可能的绕过思路(仅用于防御视角思考):
直接提取密钥:从.rodata段拿到密钥,本地计算签名
Hook HMAC函数:用Frida hook HMAC_Final,直接拿到计算结果
整体替换SO:把so文件整个替换成自己的版本,返回任意签名
动态调试篡改:在gsNav返回前修改返回值
防御方的对抗思路:
加反调试(ptrace、线程检查)
对关键函数做混淆/虚拟机保护
运行时校验so完整性
与服务端配合做二次校验(如签名+时间戳+随机数)
第十七步:方法论总结:这次的经验下次怎么用
这次追踪的过程,其实可以抽象成一个通用的签名算法逆向框架:
阶段 操作 关键点
-
抓包定位 找到可疑字段 重点关注Authorization、sign、token等
-
静态搜索 反编译搜关键词 搜字段名、赋值语句、类名
-
调用链追踪 从赋值点往上追 注意反射、动态加载、JNI
-
反射识别 找到Class.forName或getMethod 反射是常见混淆手段,看到就警觉
-
JNI定位 找到native方法和so名字 loadLibrary是关键线索
-
SO分析 IDA打开,定位JNI函数 先搜Java_包名类名,再F5
-
算法还原 识别密码学函数调用 HMAC、AES、RSA家族函数特征明显这个框架以后可以复用:
换个App,同样的套路
换个算法(AES/RSA),流程一样
遇到其他混淆(Obfuscator、DexGuard),先剥壳再套这个框架
整个追踪过程到此结束。从抓包开始,一路追到Java层,再通过反射找到KeyInfo类,最后进SO层定位到算法。虽然绕了一点,但每一步都有迹可循。
合规提示本分析仅用于安全技术研究,所有数据均已脱敏,请勿用于非法用途。在进行类似分析时,请确保拥有合法授权,遵守《网络安全法》《数据安全法》等相关法律法规。

看雪ID:FinSectech
https://bbs.kanxue.com/user-home-1070028.htm

# 往期推荐


球分享

球点赞

球在看

点击阅读原文查看更多
夜雨聆风