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

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

str=b.b() 带=号的直接定义
找str对应的意思
前面有 string str =''
一大堆过程定义
但是后面有str = b.b(context2, e10, apiProccessModel3.tokenSecret, apiProccessModel3.url);
直接表达
往上翻了一下,前面一堆初始化代码,但真正的赋值在这里:str是从一个b.b()方法来的。传了context、参数、tokenSecret和url进去。
5 看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()里。
6 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(),继续跟。
7 看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(),这层套一层的,有点耐心慢慢跟。
8 继续看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(),看来关键还在后面。
9 继续看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()看起来有点东西。
10 重点!!!

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。
11 果然找到了

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
12 打开ida 放入"libkeyinfo"文件 因为是jni开发 所以直接搜java_
找到对应的函数打开 按F5把汇编转C
导入jni.h文件
13 右键点击a1函数 --> convert to struct --> JNIEnv 然后hide codes
IDA里定位到对应的native函数,F5转成伪代码,再把第一个参数转成JNIEnv结构体,就能看懂逻辑了。
13 右键点击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结果,和静态分析完全一致。
14 调用链全貌流程图
到这里整个追踪路径就清晰了。我画了个流程图,方便一眼看清全局:
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层定位 → 算法还原。
15 最后分析下来是HMAC-SHA256,密钥硬编码在SO文件的.rodata段里。
在IDA里跟进gsNav函数,看到调用了OpenSSL的HMAC_Init_ex、HMAC_Update、HMAC_Final系列函数,参数传递中有一个固定的buffer,指向.rodata段。提取出来一看,就是硬编码的密钥。
16 深度延伸:这个算法的安全性评价与可能的绕过思路
安全性评价:
算法本身:HMAC-SHA256是安全的,目前没有有效碰撞攻击
实现层面:密钥硬编码是典型的安全缺陷。一旦so文件被提取,密钥就暴露了,攻击者可以本地伪造任意签名
防御建议:密钥应存放在更安全的位置,如:
服务端下发(动态令牌)
白盒加密方案
TEE/安全环境存储
可能的绕过思路(仅用于防御视角思考):
直接提取密钥:从.rodata段拿到密钥,本地计算签名
Hook HMAC函数:用Frida hook HMAC_Final,直接拿到计算结果
整体替换SO:把so文件整个替换成自己的版本,返回任意签名
动态调试篡改:在gsNav返回前修改返回值
防御方的对抗思路:
加反调试(ptrace、线程检查)
对关键函数做混淆/虚拟机保护
运行时校验so完整性
与服务端配合做二次校验(如签名+时间戳+随机数)
17 方法论总结:这次的经验下次怎么用
这次追踪的过程,其实可以抽象成一个通用的签名算法逆向框架:
阶段 操作 关键点
抓包定位 找到可疑字段 重点关注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层定位到算法。虽然绕了一点,但每一步都有迹可循。
合规提示
本分析仅用于安全技术研究,所有数据均已脱敏,请勿用于非法用途。在进行类似分析时,请确保拥有合法授权,遵守《网络安全法》《数据安全法》等相关法律法规。
感谢大家支持!临时会员努力转正中,有问题欢迎交流~
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!