本文章内容仅用于逆向学习,请勿用于黑产行为,如有侵权,请联系本人删除,未经本人允许,不可转载。
前言
这是笔者第一篇关于APP逆向的文章,文笔过于青涩QwQ,还望海涵,属于入门系列文章(大师傅们请绕道QvQ)
抓包分析
QUIC降级:
QUIC是基于 UDP 协议 的
主流抓包工具,本质上是建立一个 HTTP/HTTPS 代理服务器。它们主要工作在 TCP 协议 之上
因此需要让它放弃使用UDP而使用TCP,因此要先降级处理
正常抓包只有okhttpd走代理,要先降级抓包,jadx搜索cronetConfig找到对应设置配置的地方,打开libageon.so并搜索字符串enable_quic可以找到处理配置的地方
clazz.c.implementation = function(a,b){
showStacks();
console.log("[c]args1-> ",a," ","args2-> ",b);
if(a == "cronetConfig"){
var replace = "{\"enable_quic\": false, \"enable_http2\": false}"
var retval1 = this.c(a,replace);
console.log("[replace]retval-> ",retval1);
return retval1;
}
var retval3 = this.c(a,b);
console.log("[original]retval-> ",retval3);
return retval3;
}
然后就可以正常抓到带sig3的包了
使用reqable进行抓包
Java层浅析
字符串搜索sig3很容易找到传入逻辑,就不多说了(在其他例子中可能会出现没找到的情况,可以通过hook hash.put或者其他可能的java方法,很喜欢通过这些方法将header放进去,例如下面这段代码)
Java.perform(function () {
var HashMap = Java.use("java.util.HashMap");
HashMap.put.overload('java.lang.Object', 'java.lang.Object').implementation = function (key, value) {
if (key !== null && key.toString() === "__NS_sig3") {
showStacks();
console.log("[+] Value (__NS_sig3): " + value);
return this.put(key, value);
}
return this.put(key, value);
};
console.log("hook win -> ",HashMap);
});
[vd6.r.b]r.b is called: str=/rest/n/feed/selectionfb4e77xxxxxxx2541
[vd6.r.b]r.b result=cedfaf8xxxxx59b97998f
Java层入口传入/rest/n/feed/selectionfb4e77xxxxx2541
其中/rest/n/feed/selection是API接口,fb4e77xxxxxxx2541是sig的值
sig指纹
sig是通过传入的request得到的
sig是通过ce6.d$d.d获取的,拿一个栗子
[ce6.d$d.d]ce6.d$d.d is called: abi=androidApiLevel=android_os=app=appver=boardPlatform=bottom_navigation=browseType=c=cdid_tag=clientRealReportData=client_key=cold=coldStart=cold_launch_time_ms=count=country_code=cs=darkMode=ddpi=deviceBit=device_abi=did=did_gt=did_tag=earphoneMode=edgeRecoBit=edgeRerankConfigVersion=egid=ftt=grant_browse_type=hotfix_ver=injectTask=isOpenAutoPlay=is_background=isp=iuid=kcv=keyconfig_state=kpf=kpn=language=max_memory=mod=nbh=net=newOc=newUserAction=newUserRefreshTimes=oDid=oc=os=page=pcursor=pv=rdid=realShowPhotoIds=recoReportContext=sbh=seid=sh=slh=socName=source=sw=sys=teenageAge=thermal=totalMemory=uQaTag=ud=userRecoBit=ver=videoModelCrowdTag=result=
扔给gemini分析一下
第一部分:设备唯一标识 (Device Identifiers)
这些参数用于唯一标识一台设备,是风控最关注的部分。
| Key |
Value |
说明 |
| abi |
****** |
CPU 架构指令集 (Application Binary Interface)。 |
| boardPlatform |
****** |
芯片平台代号 (这里指 Google Tensor 芯片)。 |
| cdid_tag |
****** |
设备 ID 相关的标签,用于区分 ID 类型或状态。 |
| ddpi |
****** |
屏幕像素密度 (Density DPI)。 |
| deviceBit |
****** |
设备位宽标识 (可能指 32/64 位或其他特性)。 |
| device_abi |
****** |
同 abi,设备支持的指令集。 |
| did |
****** |
核心设备 ID (Device ID),最关键的风控标识。 |
| did_gt |
****** |
Device ID 生成的时间戳 (Generate Time)。 |
| did_tag |
****** |
另一个设备 ID 标签。 |
| egid |
****** |
扩展全局 ID (Extended Global ID),长指纹字符串。 |
| max_memory |
****** |
App 可使用的最大内存限制 (MB)。 |
| mod |
****** |
手机型号 (Model)。 |
| oDid |
****** |
原始设备 ID (Original Device ID),可能指未重置前的 ID。 |
| rdid |
****** |
随机设备 ID (Random Device ID)。 |
| socName |
****** |
芯片型号名称 (System on Chip)。 |
| sw |
****** |
屏幕宽度 (Screen Width)。 |
| sh |
****** |
屏幕高度 (Screen Height)。 |
| thermal |
****** |
热状态/温度信息。 |
| totalMemory |
****** |
设备总内存 (MB)。 |
第二部分:系统与环境信息 (System & Env)
这些参数描述 App 运行的软件环境。
| Key |
Value |
说明 |
| androidApiLevel |
****** |
Android SDK 版本 (33 对应 Android 13)。 |
| android_os |
****** |
操作系统标识 (0 可能代表原生 Android)。 |
| app |
****** |
App 类型标识 (0 通常指主 App)。 |
| appver |
****** |
App 详细版本号,签名计算的关键。 |
| channel (c) |
****** |
App 安装渠道 (VIVO 应用商店)。 |
| country_code |
****** |
国家代码 (中国)。 |
| cs |
****** |
可能指 Cold Start (冷启动) 的缩写,或者是某种开关状态。 |
| darkMode |
****** |
深色模式状态。 |
| earphoneMode |
****** |
耳机模式状态 (0: 未插入)。 |
| hotfix_ver |
****** |
热修复版本号。 |
| is_background |
****** |
是否在后台运行 (1: 是)。 |
| isp |
****** |
运营商信息 (Internet Service Provider)。 |
| keyconfig_state |
****** |
键值配置状态。 |
| kpf |
****** |
平台标识 (Kwai Platform Form - Android Phone)。 |
| kpn |
****** |
产品标识 (Kwai Product Name - 快手主站)。 |
| language |
****** |
系统语言。 |
| net |
****** |
网络状态。 |
| newOc |
****** |
新渠道标识 (New Original Channel)。 |
| oc |
****** |
原始渠道 (Original Channel)。 |
| os |
****** |
操作系统名称。 |
| sbh |
****** |
状态栏高度 (Status Bar Height)。 |
| sys |
****** |
系统版本名称。 |
| ver |
****** |
App 大版本号。 |
第三部分:业务与请求参数 (Business Logic)
这些参数随用户的具体操作(如刷新首页)而变化。
| Key |
Value |
说明 |
| bottom_navigation |
****** |
是否显示底部导航栏。 |
| browseType |
****** |
浏览类型 (4 可能指推荐流/发现页)。 |
| clientRealReportData |
****** |
客户端实时上报数据容器。 |
| client_key |
****** |
客户端密钥/标识。 |
| cold |
****** |
是否冷启动状态。 |
| coldStart |
****** |
明确的冷启动标记。 |
| cold_launch_time_ms |
****** |
冷启动时间戳。 |
| count |
****** |
请求返回的数据条数 (这里指请求 6 个视频)。 |
| edgeRecoBit |
****** |
边缘推荐相关位标识。 |
| edgeRerankConfigVersion |
****** |
边缘重排配置版本。 |
| ftt |
****** |
First Time Token 或类似首次标识。 |
| grant_browse_type |
****** |
授权浏览类型 (初始化)。 |
| injectTask |
****** |
注入任务标识。 |
| isOpenAutoPlay |
****** |
是否开启自动播放。 |
| iuid |
****** |
可能是 IMEI 或其他用户唯一 ID。 |
| kcv |
****** |
Key Config Version (配置版本)。 |
| nbh |
****** |
Navigation Bar Height (导航栏高度)。 |
| newUserAction |
****** |
JSON数据:新用户的交互行为记录(点击、关注、点赞均为空)。 |
| newUserRefreshTimes |
****** |
新用户刷新次数。 |
| page |
****** |
当前页码。 |
| pcursor |
****** |
分页游标 (Page Cursor),通常配合 feed 流使用。 |
| pv |
****** |
Page View 标记。 |
| realShowPhotoIds |
****** |
真实展示过的视频 ID 列表 (逗号分隔),用于去重。 |
| recoReportContext |
****** |
JSON数据:推荐上报上下文,包含 GPS 权限、刷新 ID、电池电量等详细环境信息。 |
| seid |
****** |
Session ID (会话 ID)。 |
| slh |
****** |
可能指搜索栏高度或其他布局高度。 |
| source |
****** |
来源标识。 |
| teenageAge |
****** |
青少年模式年龄设置。 |
| uQaTag |
****** |
QA 测试标签。 |
| ud |
****** |
User Data 或某种用户状态标识。 |
| userRecoBit |
****** |
用户推荐位标识。 |
| videoModelCrowdTag |
****** |
视频模型人群标签。 |
然后就是一个标准MD5就能获取到649xxxxx879d(之所以跟第一次的不同是因为这不是同一个例子🤣)
sig3传入

第一个是监控计算签名耗时(可能会拿来做检测?)
第二个是关键计算sig3函数
最后走的是com.kuaishou.android.security.internal.crypto.e.c方法,但是从jadx看该java层代码被严重混淆,jadx无法正确反汇编,因此直接阅读smali
通过gemini可以初步恢复成:
public String c(String str, boolean z, String str2) throws KSException {
// 1. 热修复检查 (PatchProxy) - Smali 开头部分
if (PatchProxy.isSupport(e.class)) {
return PatchProxy.accessDispatch(...);
}
try {
// 2. 构造请求对象 (Request Builder)
// 对应 Smali: Lcom/kuaishou/android/security/internal/plugin/n;->b()
n.a builder = n.b();
// 设置 AppKey, Map, Int 等基础参数
builder.a(b.i().j().a())
.a((Map) null)
.b(0);
// 设置传入的参数 z 和 str2
builder.a(z); // 对应 p2
builder.e(str2); // 对应 p3
builder.b(""); // 设置某个默认为空的字段
// 【关键】将输入的 str 转为 byte[] 放入请求对象
// 对应 Smali: getBytes("UTF-8")
byte[] strBytes = str.getBytes(Charset.forName("UTF-8"));
builder.a(strBytes);
// 构建最终请求对象 n
n requestObj = builder.a();
// 3. 获取插件接口 (Plugin Interface)
// 对应 Smali: Lcom/kuaishou/android/security/internal/plugin/bundle/d;
e dispatch = e.a(this.context);
d pluginInterface = dispatch.g();
if (pluginInterface == null) {
throw new KSException("SecurityGuardManager... return null");
}
// 4. 【核心调用】 调用接口方法 f,传入命令字 "0335"
// 对应 Smali: const-string v0, "0335"
// invoke-interface {p3, p1, v0}, ...->f(L.../n;Ljava/lang/String;)Z
boolean success = pluginInterface.f(requestObj, "0335");
// 5. 第一次尝试获取结果
if (success && requestObj.g() != null && requestObj.g().a().length > 0) {
// 成功,跳转到返回逻辑
} else {
// 失败,记录日志 "signPlus return enull"
// 6. 【重试逻辑】 再次调用一次 f
success = pluginInterface.f(requestObj, "0335");
}
// 7. 处理最终结果
if (success && requestObj.g() != null) {
byte[] resultBytes = requestObj.g().a();
if (resultBytes.length > 0) {
// 将结果字节数组转回 String 返回
String resultStr = new String(resultBytes);
return resultStr;
}
}
// 失败抛出异常
throw new KSException("signPlus return enull final");
} catch (Exception e) {
// 异常上报逻辑...
throw new KSException(...);
}
}
invoke-interface {p3, p1, v0}, Lcom/kuaishou/android/security/internal/plugin/bundle/d;->f(Lcom/kuaishou/android/security/internal/plugin/n;Ljava/lang/String;)Z
经过分析调用的是com.kuaishou.android.security.internal.crypto.j.f方法,
j.f is called: nVar=SecurityGuardParamContext{paramMap=null, appKey=d7b7d042-d4f2-4012-be60-d97ff2429c17, requestType=0, reserved1=null, reserved2=null, input=[47, 114, 101, 115, xxxxQwQxxxx ,99, 101, 52, 101, 99, 100, 49, 102, 97, 100, 48, 51, 99], output=com.kuaishou.android.security.internal.plugin.n$b@fd4eede, errorCode=0, privateKey=[], sdkId=, isInnerInvoke=false, did=}, str=0335
j.f result=true

最后走到了这个函数
public static native Object doCommandNative(int i4, Object[] objArr);
是一个so层实现的函数
[com.kuaishou.android.security.internal.plugin.k.a]k.a is called: i4=10418, objArr=[Ljava.lang.String;@8d18d16,d7b7d042-d4f2-4012-be60-d97ff2429c17,-1,false,com.yxcorp.gifshow.App@9da18de,,false,
[com.kuaishou.android.security.internal.plugin.k.a]k.a result=5c4d3d1xxxxxe81366709050b1d
快手采用了非常经典的“通用分发”模式。它没有为每个功能写一个 JNI 函数
而是写了一个通用的 doCommandNative,通过整数 ID (i4) 来区分要执行什么功能
Ljava.lang.String;@8d18d16这里存储了处理过的字符串MD5(sig)
d7b7d042-d4f2-4012-be60-d97ff2429c17是快手AppKey
com.yxcorp.gifshow.App@9da18de为Native层留一个调用Java方法的对象
Native层浅析
入口
Hook registerNative函数获取
[+] Captured RegisterNatives for: com.kuaishou.android.security.internal.dispatch.JNICLibrary
Method count: 5
-----------------------------------------
[TARGET FOUND] Method: doCommandNative
Signature: (I[Ljava/lang/Object;)Ljava/lang/Object;
Absolute Address: 0x75f4981680
Module: libkwsgmain.so
Base Address: 0x75f4940000
!!! OFFSET: 0x41680 !!!
-----------------------------------------
在libkwsgmain.so

jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
JUMPOUT(0x4631C);
}
其实就是一个goto指令,直接patch BR X9-> B 0x4631C,IDA就会自己生成一个sub_4631C函数
该软件的花指令基本都是这个,非常简单,只需要patch一个指令即可,可以写个脚本批量匹配特征值进行patch,但是我懒,所以都是手动patch
字符串没找到但是可以通过Hook的结果直接得知对应函数是sub_41680
先unidbg搭架子,把函数运行起来先
unidbg部分在网上就很多了,基本拿来都能直接用,这里就不贴了
64位想正常运行read需要将
unidbg-android/src/main/java/com/github/unidbg/virtualmodule/android/AndroidModule.java
里的throw new BackendException();改为return read(emulator, vm);才可以正常运行
发现多次运行相同的参数结果却不相同,猜测在运行过程中调用了获取时间戳的函数,增加了随机性,在分析之前需要先固定随机
修改gettimeofday64
protected long currentTimeMillis() {
return 1960949932368L;//System.currentTimeMillis();
}
这样每次运行的结果都是c5d4a4xxxxxxxxx77e3909c9284
unidbg配合静态分析
目标函数存在大量ollvm混淆的代码,D-810貌似有点bug,所以我写了一个配合unidbg模拟执行然后nop掉一些无用逻辑的IDA插件
https://github.com/s1nec-1o/TraceClean
如果有用,球球star⭐
sub_11BDC函数
调用前:
x0=0x404d80e0 x1=0x404d3240 x2=0x30 x3=0xbffff528 x4=0xbffff540
>-----------------------------------------------------------------------------<
[19:44:54 838]x0=RW@0x404d80e0, md5=388eb550700dae4d7b8c77fce6fd7868, hex=d8fd06400000000050f6ffbf0000000090f5ffbf00000000670000000000000041000000000000003600000000000000404e4e40000000000000000000000000380307400000000050904d4000000000e0814d400000000000824d4000000000d80207400000000080404e4000000000
size: 112
0000: D8 FD 06 40 00 00 00 00 50 F6 FF BF 00 00 00 00 ...@....P.......
0010: 90 F5 FF BF 00 00 00 00 67 00 00 00 00 00 00 00 ........g.......
0020: 41 00 00 00 00 00 00 00 36 00 00 00 00 00 00 00 A.......6.......
0030: 40 4E 4E 40 00 00 00 00 00 00 00 00 00 00 00 00 @NN@............
0040: 38 03 07 40 00 00 00 00 50 90 4D 40 00 00 00 00 8..@....P.M@....
0050: E0 81 4D 40 00 00 00 00 00 82 4D 40 00 00 00 00 ..M@......M@....
0060: D8 02 07 40 00 00 00 00 80 40 4E 40 00 00 00 00 ...@.....@N@....
^-----------------------------------------------------------------------------^
>-----------------------------------------------------------------------------<
[19:46:25 591]RW@0x404e4e40, md5=bd4xxxx4102fcf47, hex=2f7265xxxx65637xxxxx4343634623738xxxx000000000000000xxxx33930396339323834
size: 112
0000: 2F 72 65 73 74 2F 6E 2F 66 65 65 64 2F 73 65 6C /rest/n/feed/sel
xxx
0030: 32 37 62 61 32 37 00 00 00 00 00 00 00 00 00 00 27ba27..........
0040: 63 35 64 34 61 34 38 37 39 64 33 34 32 63 66 63 c5d4a4879d342cfc
xxx
0060: 33 38 32 64 37 37 65 33 39 30 39 63 39 32 38 34 382d77e3909c9284
^-----------------------------------------------------------------------------^
>-----------------------------------------------------------------------------<
[19:45:00 748]x1=RW@0x404d3240, md5=27bxxxxxxf6de3, hex=63356xxxxxx43865386637323965383736663338326437376533393039633932383430663933386334663039393561383363396266333166306336343332323538390000000000000000000000000000000034623462383431373330666131313837
size: 112
0000: 63 35 64 34 61 34 38 37 39 64 33 34 32 63 66 63 c5d4a4879d342cfc
xxxxx
0040: 39 62 66 33 31 66 30 63 36 34 33 32 32 35 38 39 9bf31f0c64322589
0050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0060: 34 62 34 62 38 34 31 37 33 30 66 61 31 31 38 37 4b4b841730fa1187
^-----------------------------------------------------------------------------^
>-----------------------------------------------------------------------------<
[19:45:11 154]x3=unidbg@0xbffff528, md5=6b63f389a162b301ff80879932d5cf04, hex=b2cbbfab951b87030ee89e8c9d6e9a5fec65610e885c1f09010000000300000000000000040000000000000000000000002007400000000000200740000000003c0000000000000000404e400000000000000000000000000000000000000000000000000000000000cf0700eec9b64f
size: 112
0000: B2 CB BF AB 95 1B 87 03 0E E8 9E 8C 9D 6E 9A 5F .............n._
0010: EC 65 61 0E 88 5C 1F 09 01 00 00 00 03 00 00 00 .ea..\..........
0020: 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 ................
0030: 00 20 07 40 00 00 00 00 00 20 07 40 00 00 00 00 . .@..... .@....
0040: 3C 00 00 00 00 00 00 00 00 40 4E 40 00 00 00 00 <........@N@....
0050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0060: 00 00 00 00 00 00 00 00 00 CF 07 00 EE C9 B6 4F ...............O
^-----------------------------------------------------------------------------^
>-----------------------------------------------------------------------------<
[19:45:13 548]x4=unidbg@0xbffff540, md5=3e1e49c8021db65491c22c54bd370bb3, hex=010000000300000000000000040000000000000000000000002007400000000000200740000000003c0000000000000000404e400000000000000000000000000000000000000000000000000000000000cf0700eec9b64f220000000000000000f6ffbf000000003100000000000000
size: 112
0000: 01 00 00 00 03 00 00 00 00 00 00 00 04 00 00 00 ................
0010: 00 00 00 00 00 00 00 00 00 20 07 40 00 00 00 00 ......... .@....
0020: 00 20 07 40 00 00 00 00 3C 00 00 00 00 00 00 00 . .@....<.......
0030: 00 40 4E 40 00 00 00 00 00 00 00 00 00 00 00 00 .@N@............
0040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0050: 00 CF 07 00 EE C9 B6 4F 22 00 00 00 00 00 00 00 .......O".......
0060: 00 F6 FF BF 00 00 00 00 31 00 00 00 00 00 00 00 ........1.......
^-----------------------------------------------------------------------------^
调用后:
>-----------------------------------------------------------------------------<
[19:47:13 553]x0=RW@0x404d3300, md5=6fc600b2f4392f46ab880d69b03eb31f, hex=6335xxxxx6438653866xxxxx37376533393039633932383400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
size: 112
0000: 63 35 64 34 61 34 38 37 39 64 33 34 32 63 66 63 c5d4a4879d342cfc
xxxxx
0020: 33 38 32 64 37 37 65 33 39 30 39 63 39 32 38 34 382d77e3909c9284
0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
^-----------------------------------------------------------------------------^
可以发现甚至在调用这个函数之前就已经存在结果了,那么肯定在之前就有赋值操作了!

会发现其实就是v100,但是v100没有任何直接赋值的操作!
<img src="https://cdn.jsdelivr.net/gh/s1nec-1o/photo@main/img/202512162224647.png" alt="image-20251214201725361" style="zoom:50%;" />
第二个显然就不可能,第一个就是之前的原语!
我开始怀疑是不是我插件的BUG了?但是这么简单的插件怎么可能有问题呢,unidbg检测一下这个地址附近的值
emulator.traceWrite(0x404e4e80,0x404e4e80+0x30);
[20:12:51 483] Memory WRITE at 0x404e4e80, data size = 1, data value = 0x63, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e81, data size = 1, data value = 0x35, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e82, data size = 1, data value = 0x64, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e83, data size = 1, data value = 0x34, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e84, data size = 1, data value = 0x61, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
xxxxxxx
[20:12:51 483] Memory WRITE at 0x404e4e8f, data size = 1, data value = 0x63, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e90, data size = 1, data value = 0x38, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
xxxxxx
[20:12:51 483] Memory WRITE at 0x404e4e96, data size = 1, data value = 0x38, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e97, data size = 1, data value = 0x66, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e98, data size = 1, data value = 0x37, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 483] Memory WRITE at 0x404e4e99, data size = 1, data value = 0x32, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
xxxx
[20:12:51 484] Memory WRITE at 0x404e4ea5, data size = 1, data value = 0x37, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 484] Memory WRITE at 0x404e4ea6, data size = 1, data value = 0x65, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 484] Memory WRITE at 0x404e4ea7, data size = 1, data value = 0x33,
xxxx
[20:12:51 484] Memory WRITE at 0x404e4eaf, data size = 1, data value = 0x34, PC=RX@0x40013bf0[libkwsgmain.so]0x13bf0, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 484] Memory WRITE at 0x404e4eb0, data size = 1, data value = 0x00, PC=RX@0x40013c34[libkwsgmain.so]0x13c34, LR=RX@0x40013bd0[libkwsgmain.so]0x13bd0
[20:12:51 485] Memory WRITE at 0x404e4e80, data size = 8, data value = 0x3738346134643563, PC=RX@0x401dc17c[libc.so]0x1c17c, LR=RX@0x4000d908[libkwsgmain.so]0xd908
[20:12:51 485] Memory WRITE at 0x404e4e88, data size = 8, data value = 0x6366633234336439, PC=RX@0x401dc17c[libc.so]0x1c17c,
xxxxx
[20:12:51 485] Memory WRITE at 0x404e4ea8, data size = 8, data value = 0x3438323963393039, PC=RX@0x401dc18c[libc.so]0x1c18c, LR=RX@0x4000d908[libkwsgmain.so]0xd908
根据这个trace可以找到调用逻辑
sub_A720->sub_1E5B8->sub_3D5F4->sub_13B1C
其中有几个函数的部分函数调用是不会调用的,直接可以nop
大致知道调用顺序了,那么开始分析吧,从尾巴开始往上分析
HMAC-SHA256
sub_3D5F4
这里的v31十分可疑

是通过23578的a2来赋值

这里会发现调用的是下面的两个函数
通过插件能首先发现具有SHA256特征,因此尝试SHA256,但与结果存在较大差异性,然后发现这个函数多传入了两个可疑参数,经过比对分析,发现是HMAC的性能优化的实现,为了避免每次计算签名都重复进行Key ^ 0x36和Key ^ 0x5C的运算,程序预先计算好了这两个状态保存在内存中,经过unidbg打印出对应的值就能看出来,密钥:vWqd4fRXxXxxxxxxxxxeRitxT7VwbK
然后就能写出下面的脚本:
import hmac
import hashlib
secret_key = b"vWqd4fRxxxxxxxitxT7VwbK"
data = b"/rest/n/feed/selection62ccedxxxxxxba27"
signature = hmac.new(secret_key, data, hashlib.sha256).hexdigest()
print(f"Calculated: {signature}")
assert signature == "e46ed2xxxxxxxxxx52c08"
一般来说如果看到是SHA256但输出却不是标准SHA256,有以下几种情况:
- HMAC-SHA256(概率最高)
- 输入被加Salt或者对输入进行了预处理(前后加Salt、转hex、大小端、特殊字符)
- 魔改初始向量(最好判断)
- 魔改轮常量(修改K表,找0x428A2F98找不到就是K表被改了)
- 魔改逻辑/位移量(修改Sigma或Ch/Maj的位移数,很难判断,需要一行一行比对汇编逻辑)
- 输出后处理(trace一下,也好判断)
- .......
然后看到03D5F4函数

这个函数感觉就是C++的某个库函数,涉及到流的操作,但是其实就是一个toHex函数
将类似0xC5->0x63 0x35
通过跟踪trace发现这是较早出现结果的地方
执行过三次这个toHex函数,第一次是HMAC-SHA256的写入,第二次也是某个算法,第三次就是最后的结果了,从最后一次看


但是发现结果是24位的显然是不对的,结果应该是48位才对,应该是后面还有拼接吧
不过这24位确实是结果
0:
__int64 *sub_2BD20()
{
unsigned __int8 v0; // w8
v0 = atomic_load((unsigned __int8 *)&byte_72808);
if ( (v0 & 1) == 0 && __cxa_guard_acquire((__guard *)&byte_72808) )// 线程安全,确保只执行一次初始化操作
{
qword_727A0 = (__int64)&off_702A8;
xmmword_727F8 = 0u;
xmmword_727E8 = 0u;
xmmword_727D8 = 0u;
xmmword_727C8 = 0u;
__cxa_atexit((void (*)(void *))&sub_301A4, &qword_727A0, &off_71000);
__cxa_guard_release((__guard *)&byte_72808);
}
return &qword_727A0;
}
这个函数是对全局this指针的初始化,并返回this指针,经过trace发现这里返回结果[3]+8的值都是固定的22
因此0是固定的0x51412200
1:
貌似也是固定的0x1db5ae7f
3:
wbAES
先dump参数
x0=
cab68c9xxxxxxxxxb3ebe0ed8ea4aab0c1d5f4519f8d19c4948f
这个x0是第二次toHex的入参
x1是0x30就是len
x2是CRC32b_poly_Constant_57C78
那就先看x0是怎么获取的

通过sub_1E2C4函数获取
sub_26E44(v20, *a1);
sub_26A14(v20, HMAC_SHA256_final_1_1, HMAC_SHA256_final_len, src, src_len);
v20依赖全局变量,然后调用26A14

所以v24存储的就是我们在寻找的字符串,然后看到v24是malloc出来的堆内存0x404d3240trace看看
[16:15:11 186] Memory WRITE at 0x404d3240, data size = 1, data value = 0xca, PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
[16:15:11 186] Memory WRITE at 0x404d3241, data size = 1, data value = 0xb6, PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
[16:15:11 186] Memory WRITE at 0x404d3242, data size = 1, data value = 0x8c, PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
[16:15:11 186] Memory WRITE at 0x404d3243, data size = 1, data value = 0x9f, PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
[16:15:11 186] Memory WRITE at 0x404d3244, data size = 1, data value = 0x78,
xxx
PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
[16:15:11 188] Memory WRITE at 0x404d326e, data size = 1, data value = 0x94, PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
[16:15:11 188] Memory WRITE at 0x404d326f, data size = 1, data value = 0x8f, PC=RX@0x400265fc[libkwsgmain.so]0x265fc, LR=RX@0x4002646c[libkwsgmain.so]0x2646c
位于2636C函数中,初步判断为白盒AES
根据调试可以知道大致流程如下:
首先在进入这个函数之前会将HMAC-SHA256的结果进行填充\x10到0x30个字节
然后每0x10个字节进去这个函数一次,输出0x10个字节的密文
简单补个环境
private void call_AES() {
String hexInput = "E46ED2xxxxxxxxB349C97C5BF21F44519D2E6452C0810101010101010101010101010101010";
byte[] inputData = hexToByteArray(hexInput);
UnidbgPointer inputPtr = emulator.getMemory().malloc(inputData.length, true).getPointer();
inputPtr.write(inputData);
UnidbgPointer outputPtr = emulator.getMemory().malloc(inputData.length, true).getPointer();
UnidbgPointer arg1Ptr = emulator.getMemory().malloc(16, true).getPointer();
System.out.println("=== 开始分块加密 (3轮 x 16字节) ===");
for (int i = 0; i < 3; i++) {
int offset = i * 16; // 每次偏移 0, 16, 32
module.callFunction(emulator, 0x2636C,
arg1Ptr, // Arg1: 固定
inputPtr.share(offset), // Arg2: 输入指针偏移
outputPtr.share(offset) // Arg3: 输出指针偏移 (结果会自动拼接)
);
}
System.out.println("Full Ciphertext (Arg3):");
Inspector.inspect(outputPtr.getByteArray(0, inputData.length), "AES Result");
}
public static byte[] hexToByteArray(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i+1), 16));
}
return data;
}
$$
\begin{array}{l}\text{state} \leftarrow \text{plaintext} \\\text{AddRoundKey}(\text{state}, k_0) \\\text{for } r = 1 \dots 9 \\\quad \text{SubBytes}(\text{state}) \\\quad \text{ShiftRows}(\text{state}) \\\quad \text{MixColumns}(\text{state}) \\\quad \text{AddRoundKey}(\text{state}, k_r) \\\text{SubBytes}(\text{state}) \\\text{ShiftRows}(\text{state}) \\\text{AddRoundKey}(\text{state}, k_{10}) \\\text{ciphertext} \leftarrow \text{state}\end{array}
$$
判断白盒AES-128的逻辑如下:

这里有10轮的循环,符合AES-128的标准轮数

看到v3,经过简单分析,发现主循环中是4次循环,而我们输入的字节数是0x10,4*4矩阵,处理4次

这个得详细分析一下
*((_BYTE *)a1 + v10) = v33[v23] | (0x10 * v32[v20]);
*((_BYTE *)a1 + v9) = v27 | (16 * v21);
*((_BYTE *)a1 + v8) = v16 | (16 * v15);
v3 = v40 + 1;
*((_BYTE *)a1 + v7) = v24 | (16 * v31);
最后结果是通过两个半字节也就是一个hex拼接成一个字节
其中v33和v32是int8类型,但是前面的逻辑,它的值只会在0x00-0x0F,v27等虽然是int64但在此之前做过LOBYTE处理了,LOBYTE就是BYTE的低位,也就是半个字节
然后看到这个5AB70的表:

显然这是一个异或表,可以将a ^ b 转换成 第a行的第b列的值

可以掩盖AES的xor特征
sub_25938通过unidbg执行可以确定,一次执行AES执行了10次,然后详细分析就确定是行移位了
long double __fastcall sub_25938(_OWORD *a1)
{
__int64 v1; // x9
long double result; // q0
long double v3; // [xsp+8h] [xbp-18h]
__int64 v4; // [xsp+18h] [xbp-8h]
v1 = 0;
v4 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
do
{
*((_BYTE *)&v3 + v1) = *((_BYTE *)a1 + dword_5AB30[v1]);
++v1;
}
while ( v1 != 16 );
result = v3;
*(long double *)a1 = v3;
return result;
}
那么上述的查表操作就是字节代换+列混合+轮密钥加了
那么直接通过DFA Hook行移位函数,将其第一个参数随机替换一位
private void call_AES() {
String hexInput = "E46ED2A1xxxxxxxxx1F44519D2E6452C0810101010101010101010101010101010";
byte[] inputData = hexToByteArray(hexInput);
UnidbgPointer inputPtr = emulator.getMemory().malloc(inputData.length, true).getPointer();
inputPtr.write(inputData);
UnidbgPointer outputPtr = emulator.getMemory().malloc(inputData.length, true).getPointer();
UnidbgPointer arg1Ptr = emulator.getMemory().malloc(16, true).getPointer();
// System.out.println("=== 开始分块加密 (3轮 x 16字节) ===");
emulator.attach().addBreakPoint(module.base+0x2636C);
module.callFunction(emulator, 0x2636C,
arg1Ptr, // Arg1: 固定
inputPtr, // Arg2: 输入指针偏移
outputPtr // Arg3: 输出指针偏移
);
String res = bytesToHex(outputPtr.getByteArray(0,0x10));
System.out.println(res);
}
public void callDfa(){
Debugger debugger = emulator.attach();
debugger.addBreakPoint(module.base+0x25938,new BreakPointCallback() {
int num = 1;
@Override
public boolean onHit(Emulator<?> emulator, long address) {
UnidbgPointer pointer;
RegisterContext context = emulator.getContext();
pointer = context.getPointerArg(0);
if(num%9==0){
pointer.setByte(randint(0,15),(byte) randint(0,0xff));
}
// System.out.println("callDfa num=" + num);
num+=1;
return true;
}
});
}
public static int randint(int min,int max){
Random rand = new Random();
return rand.nextInt((max-min)+1)+min;
}
public static byte[] hexToByteArray(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i+1), 16));
}
return data;
}
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
int unsignedInt = b & 0xff;
String hex = Integer.toHexString(unsignedInt);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
}
然后在main中200次循环获取
剩余的就是使用工具获取密钥了
获取第10轮密钥:https://github.com/SideChannelMarvels/JeanGrey/tree/master/phoenixAES
获取真正的密钥:https://github.com/SideChannelMarvels/Stark
成功获取68455xxxxxxxx5A5476

成功获取密钥
因此可以得知CRC的x0就是通过标准AES ECB 128 加密 HMAC-SHA256 结果的值
cab68c9fxxxxxxx88de61509c5dxxxxxxxxc4948fe0ed8ea4aab0c1d5f4519f8d19c4948f
结果截取96个字符就是CRC的x0了
cab68c9fxxxxxxx88de61509c5dxxxxxxxxc4948f
CRC32
.rodata:0000000000057C78 CRC32b_poly_Constant_57C78 DCD 0x4C11DB7
.rodata:0000000000057C78 ; DATA XREF: sub_11BDC+BC↑o
.rodata:0000000000057C78 ; sub_11BDC+D4↑o ...
.rodata:0000000000057C7C DCD 0xFFFFFFFF
.rodata:0000000000057C80 DCD 0xFFFFFFFF
.rodata:0000000000057C84 DCD 0x101
__int64 __fastcall sub_120C4(unsigned __int8 *a1, __int64 a2, unsigned int *a3)
{
int v3; // w9
unsigned int v4; // w12
int v5; // w10
int v6; // w11
int v7; // w8
__int64 v8; // x10
unsigned __int8 v9; // w8
__int64 v10; // x11
int v11; // w11
int v12; // w10
int v14; // w0
int v15; // w8
__int64 v16; // [xsp+0h] [xbp-30h]
unsigned int *v17; // [xsp+8h] [xbp-28h]
int v18; // [xsp+14h] [xbp-1Ch]
unsigned __int8 *v19; // [xsp+18h] [xbp-18h]
v3 = a3[1];
if ( *((_BYTE *)a3 + 12) )
{
v4 = *a3;
v5 = 0;
v6 = 0;
do
{
++v5;
v7 = v4 & 1 | (2 * v6);
v4 >>= 1;
v6 = v7;
}
while ( (unsigned __int16)v5 < 0x20u );
for ( ; a2; ++a1 )
{
v3 ^= *a1;
v8 = 8;
do
{
--v8;
v3 = (v3 << 31 >> 31) & v7 ^ ((unsigned int)v3 >> 1);
}
while ( v8 );
--a2;
}
}
else
{
v9 = atomic_load((unsigned __int8 *)&byte_72638);
if ( (v9 & 1) == 0 )
{
v19 = a1;
v16 = a2;
v17 = a3;
v18 = a3[1];
v14 = __cxa_guard_acquire((__guard *)&byte_72638);
v3 = v18;
a2 = v16;
a3 = v17;
v15 = v14;
a1 = v19;
if ( v15 )
{
dword_72634 = 24;
__cxa_guard_release((__guard *)&byte_72638);
v3 = v18;
a1 = v19;
a2 = v16;
a3 = v17;
}
}
for ( ; a2; ++a1 )
{
v3 ^= *a1 << dword_72634;
v10 = 8;
do
{
--v10;
v3 = *a3 & (v3 >> 31) ^ (2 * v3);
}
while ( v10 );
--a2;
}
}
if ( *((unsigned __int8 *)a3 + 12) == *((unsigned __int8 *)a3 + 13) )
{
v11 = v3;
}
else
{
v12 = 0;
v11 = 0;
do
{
++v12;
v11 = v3 & 1 | (2 * v11);
v3 = (unsigned int)v3 >> 1;
}
while ( (unsigned __int16)v12 < 0x20u );
}
return v11 ^ a3[2];
}
根据a3的配置,可以知道这里执行的CRC32是标准的CRC32
即首先将a3的第一个4字节先进行32位的镜像反转
v4 = *a3;
v5 = 0;
v6 = 0;
do
{
++v5;
v7 = v4 & 1 | (2 * v6);
v4 >> = 1;
v6 = v7;
}
while ( (unsigned __int16)v5 < 0x20u );
这里就是将4字节铺平成32bit,然后第0bit和第31bit进行交换......
0x04C11DB7 -> 0xEDB88320
然后逐字节处理->逐位计算:
for ( ; a2; ++a1 )
{
v3 ^= *a1;
v8 = 8;
do
{
--v8;
v3 = (v3 << 31 >> 31) & v7 ^ ((unsigned int)v3 >> 1);
}
while ( v8 );
--a2;
}
模拟二进制除法(其实就是移位和异或)
v11 = v3;
return v11 ^ a3[2];
最后直接return v3 ^ 0xFFFFFFFF
可以直接调用python的zlib-crc32库,但是发现其代码量并不多,直接写逻辑即可
def sub_120C4_simulation(hex_string):
CONFIG_POLY = 0x04C11DB7 # *a3
CONFIG_INIT = 0xFFFFFFFF # a3[1]
CONFIG_XOR_OUT = 0xFFFFFFFF # a3[2]
FLAG_REF_IN = True
FLAG_REF_OUT = True
try:
data = bytes.fromhex(hex_string)
except ValueError:
return
v3 = CONFIG_INIT
if FLAG_REF_IN:
v4 = CONFIG_POLY
v5 = 0
v6 = 0
for _ in range(32):
v5 += 1
v7 = (v4 & 1) | ((v6 << 1) & 0xFFFFFFFF)
v4 >> = 1
v6 = v7
poly_reversed = v6
for byte in data:
v3 ^ = byte
v8 = 8
while v8 > 0:
v8 - = 1
if (v3 & 1) == 1:
v3 = (v3 >> 1) ^ poly_reversed
else:
v3 = (v3 >> 1)
else:
pass
if FLAG_REF_IN == FLAG_REF_OUT:
v11 = v3
else:
v11 = v3
result = v11 ^ CONFIG_XOR_OUT
return result & 0xFFFFFFFF
input_hex_std = "cab68c9fxxxxxxxxd8ea4aab0c1d5f4519f8d19c4948f"
crc_val_std = sub_120C4_simulation(input_hex_std)
print(f"输入 Hex: {input_hex_std}")
print(f"计算结果: {hex(crc_val_std).upper()}")
输入 Hex: cab68c9f7xxxxxxxa4aab0c1d5f4519f8d19c4948f
计算结果: 0XE40xx7FA
经过调试确认结果的一致!
4:

看着就像是时间戳
调试发现是

<img src="https://cdn.jsdelivr.net/gh/s1nec-1o/photo@main/img/202512162224664.png" alt="image-20251216142527223" style="zoom:25%;" />
就是上述我们填入的时间戳1960949932368L / 1000
2:

LDAXR W9, [X8]
STLXR W10, W9, [X8]
LD和ST分别是加载和载入,X是独占的语义,说明在这个期间X8指向的内存只有这条汇编可以改写,如果写入期间发现有其他的线程修改了这个内存地址,那么会返回1,继续读取然后自增,如果没有就直接返回0退出这个循环
其实说白了,就是一个自增器,确保内容的唯一性
而v68就是一个原子递增后的新序列号,这样每发一次包,就会生成一个唯一的请求ID!
猜测服务端接收时会按照这个ID来进行对抗
5:

这里调试发现结果都是0xd00(QwQ这里感觉如果是固定的就很简单,估计不同的功能这里是不一样的,但是有点复杂,这里只考虑当前情况)
可以做个简单的小总结
struct SignatureBlock {
int magic; // [0] 0x4151 (ASCII 'AQ') - 头部魔数 0x41512200
int user_id; // [1] 用户ID 或 盐值 (从 qword_72998 获取) 0x1dbxxe7f
int seq_id; // [2] 序列号 (原子自增 v68)
int crc32; // [3] 数据的校验和 (CRC32_120C4)
int timestamp; // [4] 当前时间戳 (gettimeofday)
int feature_mask;// [5] <--- 这里就是 v72 (特性掩码) 0xd00
};
之前以为到这里还没结束,其实是已经结束了,因为之前说是需要48个字节是因为默认其通过编码存储,但是发现都是通过Hex存储的,之后只需要整理一下写个纯算python即可

这里就是异或,还原一下即可,没什么难度。之后就可以按照这个写出sig3的纯算python了
贴一下这部分的代码:
def simulate_obfuscation(packed_data_bytes):
final_result = bytearray(packed_data_bytes)
v71 = 0
for i in range(23):
v73 = final_result[i]
v71 += v73
if v71 > 0xFF:
v71 = -v71
v72 = 0xd00
v71_32bit = v71 & 0xFFFFFFFF
shifted_v71 = (v71_32bit << 24) & 0xFFFFFFFF
new_int_val = v72 | shifted_v71
struct.pack_into('<I', final_result, 20, new_int_val)
v71_low_byte = v71 & 0xFF
for v74 in range(23):
xor_key = v71_low_byte ^ (v74 & 0xFF)
final_result[v74] ^= xor_key
return final_result
也不会特别困难,其中v72是之前的0xd00,有个可能会出现的问题就是range(23)而不是range(24)根据逆向可知其不会对最后一位进行异或处理
hmac = HMAC_SHA256(input_val)
aes_result = aes_ecb_encrypt(hmac, aes_key)
crc32_result = sub_120C4_simulation(aes_result)
packed_data = struct.pack('<6I',
magic,
user_id,
seq_id,
crc32_result,
time,
feature_mask)
result1 = simulate_obfuscation(packed_data)
总结
这个算法总的来说就是HMAC-SHA256 -> AES-128 -> CRC32
总体难度不高QwQ