文章概览
这是一份针对某 App Android 客户端的接口签名 / 加密分析记录。
围绕一次普通的招聘信息请求,把请求参数里的 sig 和 sp 两条加密链路从 Java 层一路追到 so 里,最后把它们落到一行可复现的公式上。
[!warning] Hook 不到怎么办
App 具备热修复,热修复后类名 / 函数体可能被替换。如果原本能 hook 的点突然失效,先卸载重装再试,再考虑方向是否错了,避免在错误方向上反复挣扎。
整篇文章读完,需要建立的思路其实就这两行:
后面所有章节都是为了把这两行公式一步步打实——从 Java 入口、native 函数、算法识别,到密钥来源都对得上。
读到这里先不用记函数名。建议带着这张表往下看,每追到一处就在心里打个钩,整篇笔记就不容易在长链路里丢线索。
正式开拆之前,先把样本请求和环境状态摸清楚。这样后面不管看到哪个 Java 调用点、哪个 native 函数,心里都知道自己到底是在追什么。
⽤ ApkCheckPack 进行APK扫描:
APP没加固,但是有挺多检测的,这里使用的rusda魔改frida,可以直接过掉frida检测
这一段对后面挺关键的。简单说就是:APP 本体没有明显加固,但是环境检测和热修复都不少,所以后面如果有些点 hook 不到,别急着怀疑方向错了,也要考虑是不是热修复或者检测逻辑在影响。
reqable抓包
点开一个招聘信息进行抓包:
其中请求参数:
先粗略分析一下:
sp参数很长,说明包含大量信息,加上我们请求包中只有这一个参数比较长,大概率说明里面包含着具体的招聘信息之类的
sig由v3.0+32位字符串组成,v3.0猜测可能表示某种加密方式,后的字符串有可能是md5;也有可能是v3+33位字符串组成
先从 sig 这条线开始。这里我不急着直接进 native,而是先在 Java 层把候选点筛一遍,确认谁真的参与了生成,再顺着调用链一路追到 JNI 和 so 里。
Jadx中搜索"sig"字符串:

仔细查看便可知道前5个调用其他厂家的SDK,我们只需要关注后面四个就行
先把可疑点都过一遍,别一开始就拍脑袋认定某一个。这里的思路很简单,先看谁长得像,再用 hook 去确认谁真的会在请求过程中跑起来。
d4.d.d():

可以看到这里不仅有sig还有sp参数,感觉有概率是这里,可以尝试hook一下
hook代码:
g6.h.c():第二第三个地方都是此函数调用

也可以看到,同时又有sig和sp,但是上方为V1.0,看样⼦是v1.0肯定不对了;
net.bosszhipin.base.m.e():

这里瞧着也想,尝试hook一下
hook代码:
将两个hook代码一起运行,看看情况
注:APP存在热修复,如果hook不到需卸载重新安装就行
贴一段hook显示:
目前来看只有com_twl_signer_a.h()被调用了
走到这里,Java 层其实已经比较清楚了。前面几个点看起来都像,但真正跑起来并且把我们关心的参数带出来的,是 com_twl_signer_a.h(),所以后面就顺着这个点继续往下追。
顺着此函数追一下
调用链:
->com_twl_signer_a.h()
->YZWG.signature()
->com.twl.signer.YZWG.nativeSignature()

追到了native函数,这里就是加密入口了
往上看,可以找到so文件入口libyzwg.so

去找一下本地libyzwg.so拖入IDA中进行分析
优先分析JNI_OnLoad()
在这里找到了初始化函数地址

点进去发现没有nativeSignature相关的函数名称,挨个查看函数,发现做了大量混淆无法分析

既然静态看不到清楚的 JNI 导出名,那这里就别硬抠了,先换个更稳的思路:直接去 hook RegisterNatives。这样可以先把“Java 层的 nativeSignature() 最终注册到了 so 里的哪个函数”这件事打实,先把 native 入口钉住,后面再谈算法。
接下来进行hook一下动态注册了哪些函数
拦截 JNI 的 RegisterNatives 调用,把“某个 Java 类动态注册了哪些 native 方法、这些 native 方法对应哪个 so 里的哪个函数地址”全部打印出来。
hook代码:
hook的输出有点多,过滤一下libyzwg.so相关的内容:

找到了nativeSignature函数,并且函数地址也对得上sub_21864()

走到这里,入口其实已经锁定了。也就是说,后面不需要再在一堆混淆函数里到处猜“谁可能是 nativeSignature”,直接盯住 sub_21864() 往下拆就行。
入口锁定完,下一步就不是继续找函数名了,而是看这个函数里面到底在做什么。这里我的思路是先找算法特征,如果能先看出它更像摘要还是更像加密,后面的 hook 验证就会更有针对性。
进去查看,做了ollvm混淆,尝试去找一下算法特征

函数sub_1C714()有点可疑,进去查看一下
发现开头申请了0x21(33)大小的内存

接着走->sub_2A5A4()->xmmword_3BFC70 发现了md5特征

[!note] 算法对照框:MD5
把 sub_1C714 / sub_2A5A4 这套实现"看着像 MD5"做成"就是 MD5",靠的是下面这套对照。下次在别的样本里看到一样的特征,可以照搬这套判断。
可迁移结论:以后看到"输出固定 32 hex / 周边 malloc(0x21) / 命中那 4 个魔数"中任意两条同时出现,基本可以直接按 MD5 入手验证,不必再花时间硬拆。
再加上之前sig=v3.0+32位字符串的猜测,可以怀疑是MD5加密,是不是魔改的还需要验证一下
静态特征看到这里,其实方向已经很像 MD5 了。但这种时候我一般不会直接下结论,还是要回到 hook,把关键参数和返回值抠出来,确认是不是我们想的那套拼接逻辑。
也就是说,到这一步我们只是把“算法方向”猜得比较准了,还不能说已经彻底闭环。真正能把结论坐实的,还是得看运行时参数:输入到底是什么、返回值是什么、中间有没有固定串、固定串和 key 是怎么拼进去的。
接下来,hook一下sub_21864()和sub_1C714的参数和返回值
hook代码:
结果:
对比可以看出sub_1C714()的参数arg0 = (sub_21864()的参数arg2 + 某个32位字符串 + sub_21864()的参数arg3)。经过多次hook发现,这个32位字符串是不变的。
退回到sub_21864(),分析一下传入sub_1C714()的dest参数最终是怎么生成的

这里先将src拷贝到dest中,然后又将qword_3D1F98拷贝到dest后面完成一次字符串拼接

这里做了最后一次拼接将arg3(jstring) = 97d850b014a8dadddfc2a0cbfeca5e71拼接给dest,原因:仔细分析可以发现: v51==v6==a4,a4是sub_21864()的第四个参数,也是nativeSignature(byte[] bArr, String str)的第二个参数(一般来说虚拟机在调用 native 方法时,会自动多传两个参数,第一个和第二个参数分别为:JNIEnv *env 和 jobject thiz /jclass clazz)
通过多次hook,验证了qword_3D1F98为本地固定32位字符串:a308f3628b3f39f7d35cdebeb6920e21
现在验证一下是否为md5加密:
提取的字符串:

验证结果正确
这一步我要补的是:nativeSignature(byte[] bArr, String str) 里的 str 到底从哪来。
之所以这个点还得回头补,是因为前面虽然已经把 sig 的拼接关系和 MD5 逻辑打实了,但如果 secretKey 来源没交代清楚,整条链就还差最后一环。
接下来就顺着 str 这条线往上追,看它到底是谁传进来的:
com.twl.signer.nativeSignature(byte[] bArr, String str)
追一下调用链发现:

从这里能先确认,str 是通过 LBase.getSecretKey() 取出来的。
追一下LBase.getSecretKey()的调用链:->gd0.a.getSecretKey():java.lang.String
gd0.a 是个接口类,接下来就别停在接口上,继续找真正干活的实现类:

继续追一下:
-> com.hpbr.bosszhipin.base.getSecretKey()
-> com.hpbr.bosszhipin.data.manager.p.t()
-> com.bszp.kernel.account.AccountHelper.getSecretKey()

静态看到这里,其实已经能读出它的大概逻辑了:优先从当前账号对象里拿 secretKey;如果当前 account 为空,再从账号仓库/账号管理器里取。两个 getSecretKey() 本质上还是落在同一套账号数据上,只是取对象的入口不同。
不过这里最好还是补一手 hook,把“调用链是这么走的”和“运行时拿到的 key 确实一致”都对上。
hook 一下:

我这里重新卸载安装了一遍,key 变化了,但这不影响判断。实战里真正要盯的是:这里 hook 到的 key,和前面 sub_21864 里参与签名拼接的 key,能不能对上。

hook代码:
所以这一步可以下结论:sig 里参与拼接的 secretKey 最终就是从账号对象里取出来的。到这里,sig 这条线才算从 Java 入口、native 摘要到 key 来源都彻底闭环。
sig 这条线到这里就算闭环了。前面先从 Java 层筛入口,确认真正跑起来的是 com_twl_signer_a.h();再顺着它追到 nativeSignature(),把固定串、bArr 和 secretKey 的关系都抠出来,最后再回头补齐 secretKey 的来源。
sig = V3.0 + md5(bArr + a308f3628b3f39f7d35cdebeb6920e21 + key)
其中bArr是访问页面的信息,key是账户自带的
如果后面要自己复现 sig,其实重点盯住两个位置就够了:Java 层的 com.twl.signer.a.h(),以及 native 层真正做摘要拼接的那段逻辑。
sp 这条线会比 sig 长不少,因为它不是单点摘要,而是一整条“处理原始请求信息”的流水线。所以这里还是先从 Java 层找入口,再追到 nativeEncodeRequest(),最后把 sub_209A4 里面每一步的作用串起来。
这一步我要先确认:请求里的 sp 到底是从哪个 Java 调用点真正发起的。
之所以还从 Java 层开始,不是因为 native 不重要,而是 sp 这条链明显更长。如果入口一开始就盯错,后面在 so 里越拆越容易跑偏。
先用 jadx 搜一下 "sp":

搜出来的结果和 sig 那边很像,所以前面 7 个还是先直接排掉。
剩下后面 4 个,在分析 sig 时其实已经踩过一轮坑了:第一个 hook 不到,第二第三个又是 v1.0,那这里就别重复试错了,直接盯最后一个函数:

这里调用了 com.twl.signer.a.d(),而且把前面已经确认过的 secretKey 也带进来了,所以它很像 sp 的真正入口。
先 hook 一下 com.twl.signer.a.d():

hook 结果里能直接看到,传进来的就是具体页面请求信息和 secretKey:

hook代码:
所以这一步可以先下结论:sp 这条线在 Java 层真正值得继续追的,就是 com.twl.signer.a.d()。后面就顺着它一路进 native,不再在别的候选点上浪费时间。
这一步我要确认的是:com.twl.signer.a.d() 往下最终落到 so 里的哪个 native 函数。
之所以要先把这个入口钉死,是因为后面 sp 的核心工作都在 native 里。如果不先把 native 入口锁准,后面看到一堆混淆函数会很难组织。
顺着往下追:
-> com.twl.signer.YZWG.encodeRequest()
-> com.twl.signer.YZWG.nativeEncodeRequest()
这里还是沿用前面 sig 里那套更稳的做法,继续看动态注册,把它在 so 里的落点先确定下来:

定位到之后,再去 so 里看 sub_209A4 的具体情况:

能看到它套了很多层 ollvm 混淆,所以这时候先别急着硬啃静态,先拿运行时参数确认这个点是不是我们要找的那个入口。
先 hook 一下 sub_209A4,看看参数能不能和 Java 层对上:
从这组参数就能确认,传进 native 的内容和 Java 层没变:第一个参数还是那串完整请求信息,第二个参数还是 key。也就是说,sub_209A4 就是我们后面要拆的那条主流水线入口。
hook代码:
这一步我要先解决一个阅读和分析里都很关键的问题:sub_209A4 里面这么多子函数,到底谁先谁后。
之所以现在不急着一个个抠细节,是因为这种长 native 链如果顺序没跑出来,后面就只能边猜边记,很容易又回到“随手分析记录”的状态。先把流程顺序理出来,后面每一步放到哪一层就清楚了。
看到sub_209A4函数里调用很多函数,提取出来一下,一共7个函数:
光看函数名现在还不知道它们各自干什么,但已经能先把它们当成一条待拆的流水线。接下来直接 hook 跟一下调用顺序,先回答“这 7 个点是怎么串起来跑的”:
hook结果:
hook代码看附件
顺序一跑出来,后面就好办了。现在可以先把这条链粗略理解成:前面两步更像是在准备密钥和整理原始数据,中间两步像是真正加密,后面三步更像是在把结果编码成最终请求里的 sp。接下来再按这个顺序逐段拆,读起来就不会乱。
这一步我先验证一件事:sp 后面真正要用的密钥,是不是在 sub_1C38C 这里拼出来的。
之所以先怀疑它,是因为它就卡在整条流水线的最前面,而且参数形态也很像“固定串 + key”这种组合。
先直接 hook 一下 sub_1C38C 的参数和返回值:

hook代码看附件
hook 结果配合静态看,sub_1C38C 确实是在把两个 32 位字符串拼起来,第二个已经能确认是 key。
接下来就该补第一个参数的来源了。看到 sub_1C38C(src) 里有 src 参数,顺着往上追会发现它是从 qword_3D1F98 取出来的,很像那个动态生成的固定字符串:a308f3628b3f39f7d35cdebeb6920e21

所以这一步可以先下结论:sub_1C38C 的作用就是把固定 32 位字符串和 secretKey 拼起来,产出后面加密阶段真正要用的密钥材料。
这一步我要验证的是:原始请求在进入加密前,是不是先被单独做了一层压缩整理。
之所以先看 sub_1D444,是因为它紧跟在拼 key 之后,而且静态里已经露出了很明显的压缩函数特征。
先看静态,再 hook 一下 sub_1D444:

LZ4 是一种压缩算法,这里的逻辑是:先算一下 LZ4 压缩后最坏情况下最多会占多大空间,这是在给输出缓冲区做容量预估。然后将v47放进去进行压缩。
[!note] 算法对照框:LZ4
LZ4 在静态分析里特别好认,因为它有非常显眼的"先算最坏长度、再压缩"两步式签名。
可迁移结论:看到"先算最坏长度再写压缩"这个两步式签名,就该往 LZ4 / LZ4_HC 去试;复现时优先选 block 模式 + 自定义头,少踩 frame 帧头的坑。
hook一下sub_1D444
hook代码看附件
从返回值能看到,sub_1D444 返回的确实不是原始请求,而是“24字节 BZPBlock 头 + LZ4 压缩数据”;这次样本里原始 846 字节被压成了 629 字节,函数总返回长度是 653 字节。
所以这一步可以下结论:原始请求信息没有直接拿去做后续处理,而是先被整理成 BZPBlock + LZ4 这一层格式。后面再看到数据长度变化的时候,就不会一脸懵。
这一步我要验证的是:上一步得到的 BZPBlock + LZ4 数据,后面到底是怎么被加密的。
之所以先怀疑 sub_2E680 和 sub_2E91C,是因为它们一个先吃到 64 字节 key,一个紧接着处理 653 字节压缩结果,位置上就很像“密钥初始化 + 正文处理”这一组动作。
先把静态里的参数关系看清,再分别 hook 这两个函数:

结合代码分析,这里v53参数是长度,v62在后面函数中被调用,这里需要重点关注
hook sub_2E680:
hook代码看附件
结果:
通过多次hook前后对比,可以对比出arg0 和 返回值长度为256,但是暂时看不出来是什么
可以看到是将sub_1C38C的输出作为第二个参数s,长度为64
继续分析 sub_2E91C, 第二、三个参数都被 IDA 识别成同名变量 size_4(实际是两个不同变量,下文按 size_4_1, size_4_2 区分),它们在后面函数调用中需要重点关注;通过分析后面调用的代码逻辑,第四个参数 v56 是长度
hook sub_2E91C:
hook代码看附件
分析传入的参数可以发现,arg0是sub_2E680的返回值;
arg1/arg2是之前压缩函数sub_1D444的返回值。
分析函数结束的参数:
arg2被加密,加密前后长度没变化,基本可以判定为某个对称加密,arg0是可能和密钥相关,长度是256;
retval返回值是arg0;
结合以上两个函数sub_2E680和sub_2E91C,这里非常像RC4
sub_2E680(v62, s, v53) 很像 RC4 的 KSA(密钥调度)
sub_2E91C(v62, size_4_1, size_4_2, v56) 很像 RC4 的 PRGA(伪随机字节流生成并异或数据)
密钥是:a308f3628b3f39f7d35cdebeb6920e216b6b1f61f3de30128f8c02cd4eb34fba(qword_3D1F98+key)
所以这一步可以先下结论:sub_2E680 + sub_2E91C 基本就是一套 RC4 流程,前者负责把“固定串 + key”初始化成状态表,后者负责拿这个状态表去处理上一步的压缩结果。
不过这里我还是不想只停在“看着像 RC4”。真正能把这件事说实,是因为它和 RC4 的标准结构能一一对上。下面这段不用当成额外知识点去背,只要对着当前两个函数看,知道我为什么敢下这个判断就够了:
RC4 最经典的实现分两步:
输入:
输出:
伪代码:
输入:
输出:
伪代码:

总结:sub_2E680和sub_2E91C函数为RC4加密函数,密钥是:a308f3628b3f39f7d35cdebeb6920e216b6b1f61f3de30128f8c02cd4eb34fba
[!note] 算法对照框:RC4
上面那段 KSA / PRGA 伪代码已经把标准结构写出来了,这里再做一次"标准 vs 本案"的并排对照,方便以后只看一眼就能把两者对应上。
可迁移结论:看到"输出长度 256 的状态表 + 紧接着一个等长输入输出函数"这个组合,几乎可以直接按 RC4 入手;复现时遇到"前像后乱"立刻怀疑 key 拼接没拼全。
到这里为止,sp 这条线里“先拼密钥、再压缩、再加密”这三步已经站稳了。后面剩下的其实就是把密文怎么编码成最终请求里的那个长字符串补完整。
这一步我要验证的是:密文到了这里以后,是怎么一步步变成最终请求里的那个 sp 长字符串的。
之所以先看 sub_29D6C、sub_29E90、sub_1CEB8,是因为前面的压缩和加密已经闭环了,后面剩下的多半就是“算长度 -> 编码 -> 做字符替换”这一层收尾动作。
先从长度函数 sub_29D6C(v56) 开始看,它的参数就是压缩加密后的信息长度。
hook 一下:
hook代码看附件

对比下参数和输出,得出规律:retval ≈ arg0 * 4/3
那么大概是为下一步base64做准备,计算 Base64 编码后的输出缓冲区长度
[!note] 算法对照框:Base64(含 URL-safe 变体)
Base64 在静态里通常没有像 MD5 那种漂亮的魔数,但"长度规律 + 字母表替换"两条线几乎不会撒谎。
可迁移结论:看到"长度按 4/3 比例预估 + 输出字符落在 [A-Za-z0-9+/=] 或它的 URL-safe 变体"这两条同时出现,基本就是 Base64;最后那次字符替换不是新加密,只是变体,直接按表对照即可。
长度算出来之后,再看真正往缓冲区里写内容的 sub_29E90(v23, size_4, v56),这里传入的 size_4 就是压缩加密后的信息。
hook 一下:
hook代码看附件
这里验证了sub_29E90的作用是base64加密
到这里已经能看出来 sub_29E90 负责的是 Base64 编码,最后再看 sub_1CEB8(v23, v19) 怎么把结果收成最终可发的 sp。
直接 hook:
hook代码看附件
详细分析可以发现就是做了个替换,把 + 换成 - 、 / 换成 _ 、 = 换成 ~
所以这一步可以下结论:最后这一段不是又套了一层新加密,而是先做 Base64,再把 +、/、= 替换成 -、_、~,产出最终请求里的 sp。
sp 这条线收下来,其实就是一条很明确的流水线:先把固定 32 位字符串和 secretKey 拼起来当密钥,再把原始请求信息做成 BZPBlock + LZ4 压缩数据,然后用 RC4 去处理这段压缩结果,接着做 Base64,最后再把 +、/、= 分别替换成 -、_、~。
如果写成一句更容易复述的话,就是:
sp = 字符替换(Base64(RC4(BZPBlock + LZ4(原始请求信息), a308f3628b3f39f7d35cdebeb6920e21 + key)))
到这里 sp 也闭环了。前面看起来像是在一个个拆 sub_xxx,但真把顺序理清以后,这条链其实就是“拼密钥 -> 压缩 -> 加密 -> 编码 -> 替换字符”。
走到这里,sig 和 sp 的逻辑都已经分析清楚了。这一节不再做新的逆向工作,而是把前面的结论合到一份能跑的脚本里,验证我们读出来的链路是不是真的能算出和 App 一致的结果。
[!check] 这一节的目标
只用三方加密库 pycryptodome 和 LZ4 库 lz4,其余都是标准库:
为了让两条线分开看也能对照看,sig 和 sp 各自一份脚本,运行时都各自从 hook 日志里取自己需要的输入。必须用同一次请求抓到的 bArr / req_body / secretKey 才能比对——请求里 req_time / start_time 这些时间戳每次都不一样,secretKey 还会随账号重置而变。
[!warning] sig 的 bArr ≠ sp 的 req_body
这是复现时最容易卡住的一个点:两条线吃的输入并不相同。
它们来自同一次请求,但喂给 MD5 / RC4 的具体字节并不一样。如果发现 sig 完全对不上,先回去检查 bArr 是不是带了 path、req_body 是不是去掉了 path。
sig 单独一段,逻辑很短,直接照着前面那一行公式写就行:
[!warning] sig 容易踩的三个坑
这三件事少一件,结果都对不上 hook 抓到的值:
sp 这条线分五步:拼 RC4 key → 压缩 + BZPBlock 封装 → RC4 → Base64 → URL-safe 替换。每个函数都直接对应一个 sub_*,方便和正文章节互相印证。
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 1天前
被Mengz3编辑
,原因: