ks/sig3分析
分析字段:__NS_sig3
初步分析
查壳/查反调等
利用工具,对apk进行一个初步判断。
似乎没有壳,那就不用脱壳了。

即便不做什么处理,我root后的手机,还是可以正常打开快手。
定位__NS_sig3的生成函数
先在jadx中搜索,看看能否直接找到__NS_sig3的相关代码;如果不行,再试试hook HaspMap的put。
直接搜到了,那就直接再jadx中追踪了。

从这段代码中,可以知道__NS_sig3的长度是32字节。

hook函数m160592a,获得request。

下面这些请求路径不需要字段__NS_sig3。
1 | "/rest/system/startup", "/rest/n/system/abtest/config", "/rest/system/keyconfig", "/rest/n/system/realtime/startup", "/rest/n/live/config/startup", "/rest/n/feed/hotFast", "/rest/n/feed/selectionFast", "/rest/n/encourage/startup", "/rest/zt/share/system/startup", "/rest/system/startup", "/rest/im/wd/user/startup/push"; |
在分析__NS_sig3的加密流程之前,注意到,加密函数m160594c的输入是encodePath + str,encodePath不必多说,而这里的str是一个长度为32的字符串(相当于16字节,大概率是md5),接下来分析它是从哪里来的。
sig
下列图表示我的追踪“轨迹”。


假如要使用Sig3,需要清空bodyParam。
用IDA打开getClock对应的libcore。
函数sub_1908出现了md5魔数。——md5_init。
函数sub_1934是md5_update,函数sub_1A4C是md5_transform。
函数sub_19C0是md5_final。
所以,sig就是urlParams、bodyParams、sdk_version等数据经过md5处理的结果,这里就不去验证是不是标准md5了。
函数m160599c
下列图为追踪顺序。


追踪函数m33365b。






在函数doCommandNative处hook,打印调用栈。

hook一下函数doCommandNative的参数,看看是什么。
1 | String str2 = (String) this.f46796k.mo33427a().mo33476a( |

定位生成函数位于的so(&& 去除花指令)
跑一下脚本,看看doCommandNative在哪个so里。

1 | [RegisterNatives] java_class: com.kuaishou.android.security.internal.dispatch.JNICLibrary name: doCommandNative sig: (I[Ljava/lang/Object;)Ljava/lang/Object; fnPtr: 0xa3cd0435 fnOffset: 0xa3cd0435 libkwsgmain.so!0x46435 callee: 0xa3cd77fb libkwsgmain.so!0x4d7fb |
位于libkwsgmain.so,偏移0x46435。
好像存在一些加密代码?试一下从内存中dump下来,然后修补。
dump。

修复。

突然想起来,偏移0x46435,说明是thumb指令,应该从0x46434开始算起。
上来就是BL跳转。

在sub_B764中,LR = 0x46448,而R0是未知的,需要回到0x46434查找。
R0 = 0x1fa。

R1 = [0x46c30]


而0x46448 + 0x5C60 == 0x4C0A8,PC = 0x4C0A8。
然而,0x4C0A8处也没有函数定义,这样不好分析。

然而,在最新版本的快手中,不是这个样子的方式跳转的。
重新跑了1次hook RegisterNatives的脚本。
1 | [RegisterNatives] java_class: com.kuaishou.android.security.internal.dispatch.JNICLibrary name: doCommandNative sig: (I[Ljava/lang/Object;)Ljava/lang/Object; fnPtr: 0x7bb8184cd4 fnOffset: 0x7bb8184cd4 libkwsgmain.so!0x40cd4 callee: 0x7bb818a164 libkwsgmain.so!0x46164 |
BR混淆。

上述的计算,可以整理成:
1 | X0, X1放入栈中 |
只要我们知道X30的值,就可以利用keypatch将BR X9改成B xxxx。
JNI_OnLoad也是这样的混淆。

直接用一个IDA脚本,遍历so文件。——获得3个操作数,然后在BR X9处进行修改。
1 | import keystone |
执行完后是这样的。

Unidbg补环境
先搭最基本的架子。
1 | package com.ks; |
对照着之前的调用参数,添加调用doCommandNative的代码。

1 | void get_Sig3(){ |
报错如下,提示返回值是null。
一般这种情况,都是调用so接口的时候,没有初始化导致的。
1 | Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.github.unidbg.linux.android.dvm.DvmObject.getValue()" because the return value of "com.github.unidbg.linux.android.dvm.DvmClass.callStaticJniMethodObject(com.github.unidbg.Emulator, String, Object[])" is null |
libkwsgmain.so里面,一共注册了5个函数,把它们hook起来,不过似乎并没有什么关联。
1 | [RegisterNatives] java_class: com.kuaishou.android.security.internal.dispatch.JNICLibrary name: doCommandNative sig: (I[Ljava/lang/Object;)Ljava/lang/Object; fnPtr: 0x7bb8184cd4 fnOffset: 0x7bb8184cd4 libkwsgmain.so!0x40cd4 callee: 0x7bb818a164 libkwsgmain.so!0x46164 |
同时,注意到Command ID也是有不同的,打印一下args[2]。

可以发现,在第一时间内只会调用1次command ID = 0x28ac,这大概率是初始化的一部分。
所以,加一个执行10412的函数。
1 | void moduleInit(){ |
之后遇到缺少类的错误。
1 | public void callStaticVoidMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) { |
仍然报错空指针,对比了一些和其它人博客的区别。
补了一句↓,这样就可以正常跑出结果了。
1 | new AndroidModule(emulator, vm).register(memory); |

每跑一次,都会有一个新结果,为了固定结果,需要将时间戳、随机数导致的,修改下面这个文件的currentTimeMillis()。
1 | src/main/java/com/github/unidbg/unix/UnixSyscallHandler.java |
固定一下,时间固定成1751095318144。

结果固定为c4d5a086d88864b88c8c8f8ef7c6668e830cc8fe919d9385。

trace指令流。
1 | void traceCode(){ |
分析
在文件traceCode.log中,追踪结果“c4d5a086d88864b88c8c8f8ef7c6668e830cc8fe919d9385”。——这里的字符串是之前分析小红书的时候,修改Unidbg得到的。
第一次出现的地址在:0x13b9c,位于函数sub_13b54内。

函数sub_13B54将result中的结果提取到a2中,a2是一个string类型(从sso优化看出来的)。
观察trace日志,结果位于[x0+0x28]的位置,而x0是调用sub_13b54传入的入参。

在函数sub_3CC28内调用了sub_13B54,对这个函数进行分析,发现a1是v9的来源,因此hook函数sub_3cc28。

查看sub_3cc28的引用。
根据日志,发现是从0x44c8c跳转过来的。

0x44c8c不在定义的函数内,于是我在日志中,划分了一个函数。
这个函数的栈不平衡,但勉强可以用来分析,总比纯看汇编方便。

观察v98,注意到,循环次数是23次,但最后的结果是24字节。

对着地址0x44c04下断点。

下图是第一次执行到0x44c04的结果,会发现,最后一个字节已经赋了正确的值。
最后一个字节是单独赋值的,可以关注一下这里的v101。
v101的高8位是:v48的低8位 | v49的高8位,v48源于前23个字节累加然后取负。


而v49来自一个略微复杂的计算。
1 | v49 = ((unsigned __int64)qword_70910 >> 53) & 0x10 | ((unsigned __int64)qword_70910 >> 54) & 0x20 | ((unsigned __int64)qword_70910 >> 44) & 0x40 | v46 | 0xD00; |
对红色框里的v98进行hook,查看24个字节在异或之前是什么样的。

1 | 41 51 27 00 59 08 e7 3a 01 00 00 00 7e 4e ed 04 16 98 5f 68 00 0d 00 85 |

改变输入
有3个可以改变的输入,url、appkey、时间戳,观察改变输入后,内容有什么不同。
urlObj
输入:”xianyu” -> “x14nuy”。
v49的值未变,0xd00 -> 0xd00。
v98的12-15字节发生了变化,最后1个字节之所以变了,是因为前23字节存在变化。
1 | 41 51 27 00 59 08 e7 3a 01 00 00 00 |7e 4e ed 04| 16 98 5f 68 00 0d 00 85 |
appkey
输入:”d7b7d042-d4f2-4012-be60-d97ff2429c17” -> “d7b7d042-d4f2-4012-60be-d97ff2429c17”。
不能改,改了就报错。
时间戳
输入:”1751095318144L” -> “1751095318145L”。
v49的值未变,0xd00 -> 0xd00。
v98的值未变,可能是时间戳改动太小了?
1 | 41 51 27 00 59 08 e7 3a 01 00 00 00 7e 4e ed 04 16 98 5f 68 00 0d 00 85 |
输入:”1751095318144L” -> “1751105319144L”。
v49的值未变,0xd00 -> 0xd00。
v98的4-7和16-17字节发生了变化,最后1个字节之所以变了,是因为前23字节存在变化。
1 | 41 51 27 00 |59 08 e7 3a| 01 00 00 00 7e 4e ed 04 |16 98| 5f 68 00 0d 00 85 |
如果输入改成”1111111111111L”。
v49的值未变,0xd00 -> 0xd00。
v98的4-7和16-19字节发生了变化,最后1个字节之所以变了,是因为前23字节存在变化。
1 | 41 51 27 00 |59 08 e7 3a| 01 00 00 00 7e 4e ed 04 |16 98 5f 68| 00 0d 00 85 |
urlObj和时间戳
输入:”xianyu” -> “x14nuy”。
输入:”1751095318144L” -> “1111111111111L”。
v49的值未变,0xd00 -> 0xd00。
v98的4-7、12-15、16-19字节发生了变化
1 | 41 51 27 00 |59 08 e7 3a| 01 00 00 00 |7e 4e ed 04| |16 98 5f 68| 00 0d 00 85 |
16-19字节
注意到,参与计算的时间戳是以s为单位。

1751095318144L是微秒,换算成秒的话,值为1751095318L,即0x68 5f 98 16,对应于v98的第16-19字节。
20-23字节
前面分析了,v49的值恒定为0xd00,不管输入的urlObjc和时间戳如何变化,都为这个值。
1 | v49 = ((unsigned __int64)qword_70910 >> 53) & 0x10 | ((unsigned __int64)qword_70910 >> 54) & 0x20 | ((unsigned __int64)qword_70910 >> 44) & 0x40 | v46 | 0xD00; |
((unsigned __int64)qword_70910 >> 53) & 0x10实际取了qword_70910第57位,作为v49的第4位(从0开始)。
((unsigned __int64)qword_70910 >> 54) & 0x20实际取了qword_70910第59位,作为v49的第5位。
((unsigned __int64)qword_70910 >> 44) & 0x40实际取了qword_70910第50位,作为v49的第6位。
hook了一下,查看qword_70910和v46的值。
qword_70910 = x8 = 0xc001000000000000,所以20-22字节固定为0x000D00。
而最后8位(第23字节)为前面数的总和(超过255取负)。

我怀疑这里的dword_70910是混淆,干扰计算用的。
12-15字节
修改urlObj,可以使得12-15字节发生变化。

在函数sub_120D8处进行hook,

x0应该是我们传入的数据,但不知道被做了什么处理。
x2指向的内容也不是明文。
CRC32
经过搜索,x2指向的内容是crc32的魔数,B71DC104是标准crc32算法的查找表中预计算好的一个魔数,说明函数sub_120d8跟crc32算法相关。——并且是标准的crc32算法。

AES
那么,x0的48字节是什么呢?往地址0x124ff000下写断点。

再hook,打印源地址。

再下写断点。
存在花指令,导致地址0x1E7FC不在定义的函数内。
IDA识别后,把0x1E7FC也算入函数sub_1E32C的范围内了。
追踪src。
hook函数sub_1E07C。

等函数sub_1E07C执行完,再打印x3、x4的内容。
sub_1E07C执行完后,出现了目标48字节。
大概率是传入的第0、1个参数,它们与这48字节的生成相关。
看看第0、1个参数,x1的内容有点像加密后的字符串。
进入函数sub_1E07C,关注函数sub_26024。
根据hook的结果,sub_26024只执行了1次。

a4指向v18
v17指向一块堆内存,v18通过赋值,也指向这块内存。
其中,v24是从0开始取,每次自增1,换算过来,v25每次移动16字节。

根据分析,锁定了sub_25980。(sub_251F4没执行,它代表着AES解密,相反,sub_25980代表着加密;函数sub_2640C用来设置执行加密还是解密)
下面是hook函数sub_25980所得到的参数。
x1指向待加密的数据,这里的数据大小是0x30,本来是0x20,多了0x10的填充。
x2指向加密结束的堆内存,起始地址是0x124d3330。

执行完3次sub_25980后,v25指向的区域是这样的,这是我们目标的48个字节。

用了什么加密呢?大概率是白盒AES,因为通过插件FindCrypt,找到了AES。

回到函数sub_26024,将输入改成2个一样的内容。——判断CBC还是EBC。
1 | debugger.addBreakPoint(module.base + 0x1E224, new BreakPointCallback() { |
函数执行前,x1已经指向xianyuxianyuisme的内容。

执行后,发现结果一模一样,说明采用EBC模式。(无IV)
之前hook函数sub_25980时,填充值是0x10,符合PKCS#7的填充方式。
在下面else的代码块内,下了1个断点,发现每当执行1次函数sub_25980,这个代码块会执行36次。

为什么是36次呢?标准的AES-128有10轮运算,前9轮完全一样,每1轮需要对每列进行列变换,而共有4列,因此前9轮共有36轮列变化,刚好对应else内的代码块执行了36次。
为了更方便分析函数sub_259B8,用d810去除一下控制流平坦化。
过于清晰了。

为了得到密钥,需要使用DFA攻击,在第8轮列混淆之后,第9轮列混淆之前,改变1个字节。
状态矩阵在a1。

而sub_24f78是行位移操作,第9次行位移执行时,正是差分攻击的好时机。
编写脚本,进行差分攻击。
获得第10轮密钥。

得到初始密钥。

试验一下,操作没问题,和之前的hook的结果一样。

HMAC-SHA256
进入AES加密之前,“xianyu”作为输入,会生成下面这32字节。
现在追踪这32字节哪里来的。
早在函数sub_26024的时候,x1就指向了这32字节。

往回溯源,又可以找到,a2源于函数

hook函数sub_1DEC0,会发现encrypt_data还不是目标32字节。
而a4里面,存放着的是明文xianyu。
点入函数sub_1DEC0进行分析,在函数217D8找到了SHA256的字眼。

交给大模型进行分析,分析这个是一个HMAC-SHA256函数,a5、a6是opad、ipad也确认了这一点,下图异或的密钥反了,但结果一致也说明是opad/ipad。


用到了HMAC-SHA256,密钥如下,但不知道有没有魔改。

经过判断是标准的HMAC-SHA256。

总结
做个小总结,输入“xianyu”经过HMAC-SHA256,变成32字节,又经过AES变成48字节(填充也有16字节),最后经过CRC32变成4字节。
4-7字节
第4-7字节和时间戳有关,而赋值的地方在这里。

交叉引用,大概率是在JNI_OnLoad里执行写的。

在JNI_OnLoad可以看到,随机种子是time(0),被固定了,所以随机数也被固定了,至此分析结束。
