xhs分析——shield字段

1.分析目标

要分析的字段:shield。

2.抓包

这个字段在很多请求中有出现,比如登录验证码。

image-20250530103310169

(ps:其实一般先抓包,然后确定分析目标,只不过在网上能找到分析shield的博客,不用孤军奋战!第一次分析大厂app,找个有资料分析的,hhhh,当然~虽然有其它人的博客,但我还是先自己分析,有不会的再看博客~)

3.基本分析

通过frida-ps获得包名。

image-20250530093701399

从Manifest文件中获取App类名。

image-20250530093929614

看了一下,似乎没有加壳,因为什么内容都看得见,比如IndexActivityV2。

image-20250530103415270

先直接在jadx中搜索,观察能否找到协议字段shield。

image-20250530103929768

并没有找到,但感觉有一些类名、字段名比较可疑。——先暂时放着,如果其它方法用不了再来查看这些类与字段。

再尝试使用frida去hook hashMap,一般开发的时候,会用hashMap存储通信协议字段。

打印的调用链有点长。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
[shield] shield
[value] XYAAQAAgAAAAEAAABTAAAAUzUWEe0xG1IbD9/c+qCLOlKGmTtFa+lG434Je+FUTaRHxIzkyONuSp3/qeYJz8Mt3Jx+2fc6EAwYGGHebLP31H5m0rFOBPBvvrx/G4l575l4LPk3
java.lang.Throwable
at java.util.HashMap.put(Native Method)
at qd9.h1.intercept(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.a.intercept(SourceFile:5)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.m.intercept(SourceFile:6)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.d.intercept(SourceFile:7)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.h.intercept(SourceFile:4)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at sf3.b.b(SourceFile:1)
at sf3.b.intercept(SourceFile:25)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.r.b(SourceFile:1)
at wd8.r.intercept(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at com.xingin.shield.http.XhsHttpInterceptor.intercept(Native Method)
at com.xingin.shield.http.XhsHttpInterceptor.intercept(SourceFile:8)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at ae8.b.c(SourceFile:2)
at ae8.a.intercept(SourceFile:3)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.l.intercept(SourceFile:13)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.s.intercept(SourceFile:13)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.u.intercept(SourceFile:6)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at rd9.c.intercept(SourceFile:15)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at ne7.c.intercept(SourceFile:11)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.c.intercept(SourceFile:2)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at qe7.b.intercept(SourceFile:3)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at ve9.d.intercept(SourceFile:8)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at yy8.m.intercept(SourceFile:26)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at on1.b.intercept(SourceFile:2)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at on1.a.intercept(SourceFile:2)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at jf9.c.intercept(SourceFile:1)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at qd9.k.intercept(SourceFile:3)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at yd8.a.intercept(SourceFile:1)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at qd9.p1.intercept(SourceFile:1)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at nj8.j.intercept(SourceFile:5)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at nj8.g.intercept(SourceFile:5)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at hw5.b.intercept(SourceFile:2)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at rd9.e.intercept(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at rd9.b.intercept(SourceFile:12)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at rd9.d.intercept(SourceFile:26)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at yy8.b.intercept(SourceFile:2)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.o.intercept(SourceFile:33)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at ve9.e$a.intercept(SourceFile:4)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at jf9.l.intercept(SourceFile:8)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at wd8.w.intercept(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:10)
at okhttp3.internal.http.RealInterceptorChain.proceed(SourceFile:1)
at okhttp3.RealCall.getResponseWithInterceptorChain(SourceFile:13)
at okhttp3.RealCall.execute(SourceFile:8)
at retrofit2.OkHttpCall.execute(SourceFile:8)
at wc8.b.e(SourceFile:14)
at io.reactivex.Observable.subscribe(SourceFile:14)
at wc8.a.e(Unknown Source:23)
at io.reactivex.Observable.subscribe(SourceFile:14)
at ms9.n0.e(Unknown Source:17)
at io.reactivex.Observable.subscribe(SourceFile:14)
at ms9.l3$b.run(Unknown Source:6)
at kd8.a.run(SourceFile:3)
at ud7.a.run(SourceFile:2)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at uh8.h.F(Unknown Source:12)
at uh8.h.run(SourceFile:9)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:920)

除此之外,进程还在执行一段时间后终止了。

image-20250530105524482

我决定先解决反调。

4.反调

先用常规办法,看是否能解决。

先hook库的加载,观察哪个库加载后会导致进程退出。

下面这个结果是通过hook System.loadLibrary的结果。

image-20250530110426774

感觉不太对,就打印了一条hook记录。

换一个方式hook,这回hook dlopen和android_dlopen_ext。

image-20250530121032468

在加载了libredpreload.so、libmsaoaidsec.so后,退出了。还记得bilibili和豆瓣用的也是libmsaoaidsec.so做反调。

当时的解决方式是:hook掉检测线程或者删掉这个库(如果小红薯没额外对这个库做检查的话),考虑到重新签名太麻烦了,还是hook线程吧。

image-20250530123104099

将这3个线程的目标函数替换掉,等待了一段时间,发现进程不崩溃了。

image-20250530123804548

然后整理一个绕过检测的hook脚本模板,方便之后写其它脚本,这里就不展示了。

5.追踪字段shield

过完反调,回来继续分析shield字段。

避开sdk的函数,先追踪qd9.h1.intercept。

image-20250530124447630

在函数intercept中,找到2个调用put的位置。

image-20250530130314460

这里的intercept代码可以分成2块,上面一块在拦截和修改请求(Request),下面一块在拦截和处理响应(Response)。

hook一下函数intercept,观察原本的请求体和修改后的请求体。

我的脚本只获得了输入参数,输出结果却打印不出来——说明hook引发了某些问题,导致调用原本的intercept的时候,陷入了死循环,导致方法stack溢出。

image-20250530134630822

又或者是下面这样的报错。(搜了一下,大致原因是:有不可序列化的内容在输出结果里)

image-20250530135606186

但不管怎么说,通过输入参数,我们发现shield已经在传入时就加密好了,说明qd9.h1.intercept并不是我们要找的加密点。

观察了一下前面的调用栈,发现存在shield的字段,追踪过去看看。

1
2
at com.xingin.shield.http.XhsHttpInterceptor.intercept(Native Method)
at com.xingin.shield.http.XhsHttpInterceptor.intercept(SourceFile:8)

hook后发现输入参数没有shield。

image-20250530141942891

而hook wd8.r.intercept,发现输入参数存在shield参数。

image-20250530142603508

说明,大概率是在com.xingin.shield.http.XshHttpInterceptor.intercept添加了字段shield。

image-20250530142527896

至此,锁定了加密点。

6.加密分析

猜测应该是根据this.cPtr生成shield,然后往chain上添加。

image-20250530143432846

而this.cPtr的来头有点复杂,咱们不管,直接hook JNI函数intercept,查看j10的值。

image-20250530144122340

多次打印,发现j10不会轻易发生改变,j10=494045072144。

image-20250530145440602

hook RegisterNatives,观察动态注册的函数地址,找到intercept在libxyass.so的偏移是0x9e184。

PS:一开始脚本写错逻辑了,我还以为是静态注册的,,ԾㅂԾ,,然后写了一个遍历文件夹下所有so的脚本,获得导出表,然后筛选我们的intercept——就当作在这里提供一个找静态注册JNI函数的思路吧。

PPS:要找动态注册的函数,还有一个思路,根据JNI在Java的方法名,拿到ArtMethod,然后根据ArtMethod获得JNI函数在Native层的地址,然后判断这个地址在哪个模块,而偏移量则是Native层的地址 - 模块基址。

image-20250530152836580

用IDA打开后,修改一下参数名与类型。

image-20250530154553559

不知道到底在调用哪个函数,大概率是关于JNIEnv的函数。

image-20250530155837197

根据计算,off_AD430 + 0x753016A0 == 0x6E13C,看样子是封装了CallObjectMethodV。

问题来了,参数传递的时候,chain为什么会是a2?a2应该是jobject类型。

image-20250530160824936

而且,qword_B1740等全局变量尚未被初始化,因此,我决定dump一个so文件下来看看。

image-20250530165045013

然后使用工具修复dump下来的elf文件。

image-20250530165303554

打开IDA,来到0x9e184,可以发现qword_B1740等全局变量已经回填了。

image-20250530183503982

image-20250530183841508

然而,这些jmethodID咱肯定是看不懂的。——这里有3个思路。

  1. 将jmethodID转换成对应的函数名。(难点:先获取jmethodID,再考虑如何转换)

  2. hook JNI函数,打印JNI函数调用序列。(难点:如何筛选sub_9E184中执行的JNI函数)

  3. Unidbg的模拟执行可以打印调用的JNI函数序列。(难点:补环境)

网上的博客都是基于Unidbg进行分析,既然别人用Unidbg,那这里我就用frida吧。——自己分析才更具挑战性嘛。

获取JNI调用链

Ⅰ.通过jmethodID获得函数名

jmethodID在内存中实际上是指向ArtMethod结构的指针,也就是说,通过jmethodID可以获得ArtMethod在内存的位置。在libart中有一个函数叫PrettyMethod,往这个函数输入ArtMethod对象,可以打印出对应的函数名,而frida中就内置了这个函数的api。

至此,还有一个问题需要解决——PrettyMethod返回函数名存储在c++的std::string对象中,要对这个string进行合理的解析,才能获得最终的函数名。

安卓的arm64下,std::string类型一般占24字节,也就是3* pointerSize。

需要存储小的字符串时,一般会将字符串直接存在std::string的内存空间中,最多可以存24字节;而需要存储大的字符串时,会另外开辟一处堆空间,用来专门存放字符串的内容,而std::string的原24字节,会用来存储这样的结构:

  • 一个指向堆上字符数据的指针 (char* data)。

  • 字符串的当前长度 (size_t length)。

  • 当前分配的内存容量 (size_t capacity)。

总结一下,我们输入一个jmethodID后,把它作为参数传给PrettyMethod后,需要对返回的string进行解析,获得函数名字符串地址,然后打印。

PS:至于如何区分小字符串存储还是大字符串存储,这里就不说咯。

下图是一个实现效果。

image-20250531143219800

在IDA中添加注释。

image-20250531143445034

Ⅱ.hook JNI调用

github上有大佬写过hook jni调用的frida脚本了,其原理是:hook上libart库中的函数——ArtMethod::Invoke。

ArtMethod::Invoke是ART中负责调用方法的核心函数,无论是Java方法还是JNI方法,最终都会通过Invoke执行。通过对这个函数的hook,可以获得整个进程的方法调用日志。

然而日志太杂乱了,需要筛选,我们只针对libxyass.so中的方法调用,通过反射,可以获取某个类的某个方法的jmethodID,然后反射执行;因此,我们只需要打印调用栈,只要在调用栈中,某个调用的地址在libxyass.so的映射范围内,就将这条日志记录下来。

大佬写的源代码没有过滤的代码,无过滤的、大量的console.log会导致进程卡死,无法获得我们想要的内容,于是我做了一些改动。

image-20250531152135241

其中地址0x6e1b4在CallObjectMethodV_函数的映射范围内。

image-20250531152215305

和我们第1个方法的日志结果一模一样。

image-20250531152310852

分析shield加密点

为了获得加密函数的trace,这里有3种思路:

  1. 使用IDA的trace功能;
  2. 使用frida-stalker的实现trace;
  3. 使用unidbg实现trace。

我已经尝试过了第1、2种方式,耗时一天,无论怎么修改脚本,脚本始终跑得不稳定,最多一次trace了20w条指令,进程就崩溃了。

在这20w条指令中,没有看到shield字段的影子,那只能使用unidbg了,麻了。

Unidbg补环境

先照着其它项目的模板,搭建基础环境。

1.获得ShieldLogger对象

跑起来后,遇到的第一个问题。

Unidbg的JNI实现调用AbstractJni子类中的getStaticObjectField来解析此字段,但Unidbg的原生实现并不能正确解析,需要子类进行覆写,也就是在我们创建的类中进行覆写。

image-20250602092607201

找到getStaticOjectField的实现,然后模仿它的写法。

1
2
3
4
5
6
7
8
@Override
public DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
switch (signature){
case "com/xingin/shield/http/ContextHolder->sLogger:Lcom/xingin/shield/http/ShieldLogger;":
return vm.resolveClass("com/xingin/shield/http/ShieldLogger").newObject(null);
}
return super.getStaticObjectField(vm, dvmClass, signature);
}

然后JNI_OnLoad顺利执行完了。

还记得这张图,在调用intercepte之前,需要先后执行initializeNative和initialize。

image-20250530144122340

通过上述这2个函数,对so中的静态变量和传入的cPtr进行初始化。

image-20250530143432846

因此,先要调用initializeNative和initialize。通过hook,发现initialize的参数始终是main。

image-20250602095412021

先创建3个变量。

image-20250602095651106

然后在构造函数中进行初始化。

image-20250602095718995

然后创建函数initializeNative和initialize。

image-20250602094439606

调用这2个函数,接着进行补环境。

2.模拟nativeInitializeStart执行

image-20250602100137366

Unidbg 尝试调用 ShieldLogger 类中定义的原生方法 nativeInitializeStart (一个无参数的 void 方法),但该方法未在您的 AbstractJni 子类中实现或正确处理。

image-20250602101257293

不做处理,直接返回。

image-20250602101313387

3.模拟defaultCharset执行

AbstractJni的函数callStaticObjectMethodV未能对java.nio.charset.Charset类上的静态方法defaultCharset()正确解析,因此需要补环境,模拟defaultCharset。

在这里,我们直接调用Charset的defaultCharset,然后转换成DvmObject的类型传回去。

image-20250602105910172

4.获得sDeviceID

我们需要获得设备id,然后转换成DvmObject类型传过去。

image-20250602110612112

通过全局搜索,查找类com.xingin.shield.http.ContextHolder。

由于sDeviceId是一个静态变量,可以直接通过类名获取。——518dfe96-7f6f-370a-8e80-f94f8838e6de

image-20250602111607731

然后覆盖AbstractJni的getStaticObjectField。

对于常用的java内置的数据结构,unidbg封装了特别的函数,比如StringObject,就不需要dvmClass.newObject来转换。

image-20250602111830894

5.获得sAppId

image-20250602112110292

与第4个补环境的流程一样。——sAppId = -319115519

image-20250602112306831

覆写。

image-20250602112515324

6.模拟nativeInitializeEnd执行

image-20250602113059847

同样不做处理,直接返回。

image-20250602113325760

7.模拟initializeStart()执行

image-20250602113535063

image-20250602113701419

8.获得SharedPreferences对象

image-20250602135837739

根据报错提示,我们要返回构造好一个SharedPreferences对象。

SharedPreferences 是 Android 提供的一种轻量级的数据存储机制。它允许应用程序以键值对 (key-value pairs) 的形式持久化存储一些简单的数据类型(如布尔值、浮点数、整数、长整数和字符串)。每个 Android 应用都可以有自己的 SharedPreferences 文件。这些文件存储在应用的私有目录(shared_prefs目录)下,默认情况下其他应用无法访问。它是一个接口 (Interface)。这意味着它定义了一套操作规范(比如如何读取数据 getString(), getInt(),如何写入数据 edit().putString().commit() 等),但具体的实现则由 Android 系统提供。

Context.getSharedPreferences(name, mode) 是获取或创建特定名称的 SharedPreferences 实例的标准途径。你调用这个方法,传入你想要操作的配置文件的名字,然后系统会返回一个实现了 SharedPreferences 接口的对象,你可以通过这个对象来读写数据。

补环境的代码如下。

image-20250602141349585

注意,这里的newObject(getSharedPreferences_arg0)并不是调用构造函数,含义如下图所示。

image-20250602141224286

9.实现函数SharedPreferences->getString()

image-20250602141607771

上一步中,获得了SharedPreferences对象,这里需要通过getString获得value。

通过unidbg,可以打印arg0的值,是“s”。

image-20250602142002516

因此,我们去看看xhs私有目录下有没有名为s的文件。——这些文件在原apk中没有,属于服务器下发给客户端的。

image-20250602142530501

image-20250602142720277

android/content/SharedPreferences->getString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;的逻辑是这样的:输入2个字符串arg0、v2,输出1个字符串v1;这个函数会获取键arg0对应的值,如果没有,默认返回v2,如果找到了建arg0的值,返回v1。

先通过unidbg获取访问了哪些键。

image-20250602144138579

已知main是不存在,s.xml里就放了一个main_hmac,所以可以这么写。

image-20250602144524947

为了方便观察,我把返回的结果也打印了。

image-20250602144729643

10.模拟Base64Helper->decode()执行

image-20250602144914722

需要返回解码后的字节数组。

可以调用Base64类进行解码,然后通过unidbg对Byte数组的封装函数,返回回去。

image-20250602145728193

11.模拟initializedEnd()执行

一样对initializedEnd()不做处理。

image-20250602164905684

12.至此,完成了对initializeNative()和initialize()的调用,接下来对intercepte进行主动调用

image-20250602165136924

13.构造一个空的chain对象

添加chain。

image-20250602175649847

调用get_shield()。

image-20250602175726431

14.模拟com/xingin/shield/http/ShieldLogger->buildSourceStart()执行

image-20250602175843374

补环境。

image-20250602175950460

15.下载okhttp3框架,模拟okhttp3/Interceptor$Chain->request()执行

遇到报错。

image-20250602180840982

在pom.xml中,添加okhttp3的包,注意缩进。

1
2
3
4
5
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>

保存,并构造request对象。

image-20250602180806704

补环境。这里的request是一种标记,可以通过DvmObject.getValue()取出来request的值。

image-20250602181715577

16.模拟okhttp3/Interceptor$Chain->request()执行

image-20250602181311120

这里的request.url也是一种标记,可以通过DvmObject.getValue()取出来request.url()的值。

image-20250602181727986

17.模拟执行okhttp3/HttpUrl->encodedPath()

image-20250602182110508

需要通过getValue取出具体的值,然后调用okhttp3.HttpUrl的encodedQuery(),将返回值转换为DvmObject类型返回去,由于这里是返回字符串,有专门的封装函数StringObject。

image-20250602182317628

18.模拟执行okhttp3/HttpUrl->encodedPath()

image-20250602182607946

一样的原理。

image-20250602182602826

19.空字符串异常

image-20250602182843174

根据提示,这里的httpUrlObj_encodedQuery.encodedQuery()返回的是空字符串,即null。

image-20250602182910797

方法encodedQuery是用来返回url请求?后面参数用的,而我们的url没有添加?。

image-20250602183041973

20.模拟执行okhttp3/Request->body()

image-20250602183118874

复杂对象都用newObject打上标记即可。

1
2
case "okhttp3/Request->body()Lokhttp3/RequestBody;":
return vm.resolveClass("okhttp3/RequestBody").newObject(request.body());

21.模拟执行okhttp3/Request->headers()

image-20250602183451505

1
2
case "okhttp3/Request->headers()Lokhttp3/Headers;":
return vm.resolveClass("okhttp3/Headers").newObject(request.headers());

22.模拟构造函数okio/Buffer-><init>()

image-20250602191745460

在AbstractJni.newObjectV中,实现了很多构造函数。

现在需要往里面添加okio.Buffer类的构造函数。

观察原有的构造函数实现,其实是往newObject里面存放构造函数后的初始值。

image-20250602192421858

而okio/Buffer-><init>()是无参构造函数,所以代码应该这么写。

image-20250602192939068

23.模拟okio/Buffer->writeString(Ljava/lang/String;Ljava/nio/charset/Charset;)Lokio/Buffer;

image-20250602193106537

函数writeString的返回值是Lokio/Buffer,也就是说,我们可以取出Buffer实例,取出2个参数,然后调用writeString,最后将结果转换成DvmObject类型。

image-20250602193747833

24.模拟okhttp3/Headers->size()I

image-20250602193837056

补环境。

image-20250602194159163

25.模拟执行okhttp3/Headers->name(I)Ljava/lang/String;

image-20250602194250684

已经熟练补环境了。

image-20250602194501997

26.模拟执行okhttp3/Headers->value(I)Ljava/lang/String;

image-20250602194609745

补环境。

image-20250602194728717

27.模拟执行okhttp3/RequestBody->writeTo(Lokio/BufferedSink;)V

image-20250602194931038

这里不能像以前一样直接返回return了,得先完成目标操作,再return。

image-20250602195806272

28.模拟执行com/xingin/shield/http/ShieldLogger->buildSourceEnd()V

image-20250602195956838

补环境。

image-20250602200318384

29.模拟执行com/xingin/shield/http/ShieldLogger->calculateStart()V

image-20250602200341328

补环境。

image-20250602200309079

30.模拟执行okio/Buffer->clone()Lokio/Buffer;

image-20250602200445181

补环境。

image-20250602200937658

31.模拟执行okio/Buffer->read([B)I

image-20250602201000240

补环境。

image-20250602201559098

32.模拟执行com/xingin/shield/http/ShieldLogger->calculateEnd()V

image-20250602201700874

补环境的代码如下。

image-20250602201749046

33.模拟执行okhttp3/Request->newBuilder()Lokhttp3/Request$Builder;

image-20250602201831543

补环境的代码如下。

1
2
3
4
5
6
case "okhttp3/Request$Builder->header(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;":
Request.Builder builderObj_header = (Request.Builder) dvmObject.getValue();
String header_arg0 = vaList.getObjectArg(0).getValue().toString();
String header_arg1 = (String)vaList.getObjectArg(1).getValue();
builderObj_header.header(header_arg0,header_arg1);
return dvmObject;

34.模拟执行okhttp3/Request->newBuilder()Lokhttp3/Request$Builder;

image-20250602201932209

补环境的代码如下。

1
2
3
case "okhttp3/Request->newBuilder()Lokhttp3/Request$Builder;":
Request requestObj_newBuilder = (Request) dvmObject.getValue();
return vm.resolveClass("okhttp3/Request$Builder").newObject(requestObj_newBuilder.newBuilder());

35.模拟执行okhttp3/Request$Builder->build()Lokhttp3/Request;

image-20250602202115517

补环境的代码如下。

image-20250602202240171

36.模拟执行okhttp3/Interceptor$Chain->proceed(Lokhttp3/Request;)Lokhttp3/Response;

image-20250602202326256

这里不需要模拟proceed的执行,因为proceed用于发送请求,说明字段shield已经添加好了,我们不关注发包,只关注shield是如何加密的。

image-20250602202508983

37.模拟状态码

image-20250602202616539

直接返回200。

image-20250602202657482

至此,总算是模拟完成了——37个要补充的地方,快累死了。

38.获得shield

image-20250602202919661

然后打印的是null。

注意到,之前我们在模拟环境的时候,总会使用newObject创建一个新的Request对象返回去,request的值其实始终没改变,因此,我们需要知道,最后一次返回Request类型的对象是哪个函数。

image-20250602203351055

来到这个代码的地方,添加一句为request赋值的语句,更新request的值。

image-20250602203429152

再次查看shield,这回有结果了。

image-20250602203508023

全部代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
package com.xhs;


import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.api.Binder;
import com.github.unidbg.linux.android.dvm.api.Bundle;
import com.github.unidbg.linux.android.dvm.api.ServiceManager;
import com.github.unidbg.linux.android.dvm.api.Signature;
import com.github.unidbg.linux.android.dvm.array.ByteArray;
import com.github.unidbg.linux.android.dvm.wrapper.DvmBoolean;
import com.github.unidbg.linux.android.dvm.wrapper.DvmInteger;
import com.github.unidbg.memory.Memory;

import okhttp3.*;
import okio.Buffer;
import okio.BufferedSink;
import org.apache.commons.codec.binary.Base64;

import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.security.*;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;



public class XHS_shield extends AbstractJni {
public static AndroidEmulator emulator; // 静态属性,以后对象和类都可以直接使用
public static Memory memory; // 内存
public static VM vm; // 虚拟机
public static Module module; // 要加载的模块

public static DvmClass XhsInterceptor = null;
public static DvmObject XhsInterceptorObject;
public static long cPtr;
public static DvmObject chain = null;

private String url;
public Request request;

public XHS_shield(){
// 64位虚拟设备,进程名为XHS
emulator = AndroidEmulatorBuilder.for64Bit().setProcessName("XHS").build();
// 获得内存对象
memory = emulator.getMemory();
// 设置安卓sdk版本
memory.setLibraryResolver(new AndroidResolver(23));
// 创建虚拟机
vm = emulator.createDalvikVM(new File("unidbg-android/src/test/resources/xhs_pack/xhs.apk"));
// 开启JNI互动
vm.setJni(this); // 后期补环境会用,把要补的环境写到当前类中,执行这个代码即可,但是必须要继承AbstractJni
// 加载目标so
DalvikModule dm = vm.loadLibrary("xyass", true);
// 调用jni_onload
dm.callJNI_OnLoad(emulator);

module = dm.getModule(); // 把so加载到内存后,可以获得基地址、偏移量,该变量代指so文件

// 解析XhsInterceptor
XhsInterceptor = vm.resolveClass("com/xingin/shield/http/XhsHttpInterceptor");
// 获得一个空chain
// chain = vm.resolveClass("okhttp3/Interceptor$Chain").newObject(null);

// 构造url和request
url = "https://edith.xiaohongshu.com/api/sns/v4/user/login/password?";
request = new Request.Builder()
.url(url)
.addHeader("xy-direction","88")
.addHeader("X-B3-TraceId","4b4bdb48881d080f")
.addHeader("x-xray-traceid","cb1441a76a4a081ecd469a26f3706ad0")
.addHeader("xy-scene","fs=0&point=1932")
.addHeader("x-legacy-did","f10f91f68-7db7-3a36-80c9-703abad8fee2")
.addHeader("x-legacy-fid","17442981081013d20c9158a0a9b29abbcbb1ed726f5b")
.addHeader("x-legacy-sid","session.1744300626655701629150")
.addHeader("x-mini-gid","7c0d68e8647a5438a2394bbad364c2bdf31b98794735944077bbfc55")
.addHeader("x-mini-s1","ABsAAAABKjIUDN/qnVyFrjdjCBjTNhmOGE0SjnzqstjuHWyj5wVhUjSgHTzsuqoPKuiiOhD4JesIeK1AX8w=")
.addHeader("x-mini-sig","a619346fd57b0f2494eb8e197cf8fe6bb33cd62241a67e03343700de0ab8b8ff")
.addHeader("x-mini-mua","eyJhIjoiRUNGQUFGMDEiLCJjIjo0MSwiayI6ImQzNjkxYjYzMGJhODNmODljZTM0M2Q5NDlmODU2NTcyNzY5NTg2YTA5YzhmNTJhMjgzNWNiYjhjZjYxZTAwMTciLCJwIjoiYSIsInMiOiJkZmQ0OTI4ZWZlNTA5NDg1MTg5ZmJjOGEwMGRmMmMwYyIsInQiOnsiYyI6NDgsImQiOjQsImYiOjAsInMiOjQwOTgsInQiOjE3NDY3MTQwLCJ0dCI6WzFdfSwidSI6IjAwMDAwMDAwMmMwOGQwYWI0ZDdlNjExMjM4NmU2YmEyOTljZjAzNzIiLCJ2IjoiMi44LjUifQ.r2k40ztxFkHb9YD9W-r8ktVwWcyCuRLf0MmPFBWxV-9voigXNb_Bkvc3Vf8X3qs2wwcX2CpuPWIpylpfB9dwGR_CB3GAZrG1NpfKMjoDZh3Q6VAtuROrlVc7eAi-SqcGE-Z7GoU5tDth4LC9Fg0lbTIa6w6T6WTD_7QUlGroY8huQKkU26M7Whhv7nIFZQtkleznZtw276pAfbuvqyM9zMJb6TN4vqSrPeRRBBIn-7vgTNYF5pS7sF3yeshHP9BbS5QgFACF2I5iBo-z2dSMJkPeyPltMlabkqqJcf4fAjbqECYOCZusmkHXTfFk7-bwTYTW2TzeFFOBYMXNwBWKeOXe4aRqCcqbkjQQzPnJ3EHL-rzXCnKPv7Uf63MfEVxq9LqG398kwYNbUfkRdiGeM_X3OrwBLQ54cmA9jQa25L5jNzUo_2s1dS4Heg1EsEkLtNhAD9zDYDmjRpY6lbe94i_5oWLux8USIomK6_-TbvoEm1w3A51MwcJMVtiop-klqFRHEkazDUQA-76XS1Ogptxo9JpFSSYQt-S6Wet8nvg344UHUuhmXx7bb-gPYepYR6CQOt6EuCNOmLUIDLo0GgniHf2VJNQieSDxuYUIO3EGOXiwzxxB4Lv0TeSRTdqc8U7bLOb-GPeh49my4YWFHVcWEsqRO55EawZF0MSL4aekkiC5ZMm1SpoaJ60XKKVhNfG7sYf7LmIs6kMd3tNFFjvs3CFNlFgu58falTh13_W-sGsMZS3e-Cvlh6PKHic4l36rF2NH1xa2XmbY_VJzQyPm6tlD57IX89HocmwL1emORGPUx6EkhEkgbU-ZiXKcXmEC4W3njsliIc4HY18MurQmn2szKA4Kyl0Yo05igx5DlpJedTiALaaczSuaPr9Ko5xbcrSeFzFPc682BPkwZ1z8H-Hb5ljHDi2wqdvHepBVf8f96SbPEobd52wRjKh4hgdwxuUrKiM4JD3fxlCheGkLvMByCEXL3pP3oMMk7Bjblf-a_qw8cPdjzj_XOzrlcr-gWq1y79AyoyIF3CEri04uJEvnauaNNqLYOjWUYidJiuHdfpj0hQ3o-tEv19hiA4Qx_Bn_qG8BQEpVgksCTu9iwrIaeCjCZ486-waE09M.")
.addHeader("xy-common-params","fid=17442981081013d20c9158a0a9b29abbcbb1ed726f5b&gid=7c0d68e8647a5438a2394bbad364c2bdf31b98794735944077bbfc55&device_model=phone&tz=Asia%2FShanghai&channel=Vivo&versionName=8.77.0&deviceId=10f91f68-7db7-3a36-80c9-703abad8fee2&platform=android&sid=session.1744300626655701629150&identifier_flag=4&project_id=ECFAAF&x_trace_page_current=login_full_screen_pwd_page&lang=zh-Hans&app_id=ECFAAF01&uis=light&teenager=0&active_ctry=CN&cpu_name=Qualcomm+Technologies%2C+Inc+SM8150&dlang=zh&launch_id=1744436392&overseas_channel=0&mlanguage=zh_cn&folder_type=none&auto_trans=0&t=1744436385&build=8770299&holder_ctry=CN&did=2d351bbef1db838c5fcd6b67a4aa1db5")
.addHeader("User-Agent","Dalvik/2.1.0 (Linux; U; Android 13; Pixel 4 XL Build/TP1A.221005.002.B2) Resolution/1440*3040 Version/8.77.0 Build/8770299 Device/(Google;Pixel 4 XL) discover/8.77.0 NetType/WiFi")
.addHeader("Referer","https://app.xhs.cn/")
.build();

}

@Override
public DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
switch (signature){
case "com/xingin/shield/http/ContextHolder->sLogger:Lcom/xingin/shield/http/ShieldLogger;":
return vm.resolveClass("com/xingin/shield/http/ShieldLogger").newObject(null);
case "com/xingin/shield/http/ContextHolder->sDeviceId:Ljava/lang/String;":
return new StringObject(vm, "518dfe96-7f6f-370a-8e80-f94f8838e6de");
}
return super.getStaticObjectField(vm, dvmClass, signature);
}

@Override
public void callVoidMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList) {
if("com/xingin/shield/http/ShieldLogger->nativeInitializeStart()V".equals(signature)){
System.out.println("nativeInitializeStart() trap!");
return;
}
if("com/xingin/shield/http/ShieldLogger->nativeInitializeEnd()V".equals(signature)){
System.out.println("nativeInitializeEnd() trap");
return;
}
if("com/xingin/shield/http/ShieldLogger->initializeStart()V".equals(signature)){
System.out.println("initializeStart() trap");
return;
}
if("com/xingin/shield/http/ShieldLogger->initializedEnd()V".equals(signature)){
System.out.println("initializedEnd() trap");
return;
}
if("com/xingin/shield/http/ShieldLogger->buildSourceStart()V".equals(signature)){
System.out.println("buildSourceStart trap");
return;
}
if("okhttp3/RequestBody->writeTo(Lokio/BufferedSink;)V".equals(signature)){
System.out.println("writeTo trap");
RequestBody requestBody = (RequestBody) dvmObject.getValue();
BufferedSink bufferedSink = (BufferedSink) vaList.getObjectArg(0).getValue();
if(requestBody == null){
return;
}
try {
requestBody.writeTo(bufferedSink);
} catch (IOException e) {
throw new RuntimeException(e);
}
return;
}
if("com/xingin/shield/http/ShieldLogger->buildSourceEnd()V".equals(signature)){
return;
}
if("com/xingin/shield/http/ShieldLogger->calculateStart()V".equals(signature)){
return;
}
if("com/xingin/shield/http/ShieldLogger->calculateEnd()V".equals(signature)){
return;
}

super.callVoidMethodV(vm, dvmObject, signature, vaList);
}

@Override
public DvmObject<?> callStaticObjectMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {
switch(signature){
case "java/nio/charset/Charset->defaultCharset()Ljava/nio/charset/Charset;": {
return dvmClass.newObject(Charset.defaultCharset());
}
case "com/xingin/shield/http/Base64Helper->decode(Ljava/lang/String;)[B":{
String getString_arg0 = vaList.getObjectArg(0).getValue().toString();
System.out.println("Base64Helper arg0: " + getString_arg0);
return new ByteArray(vm, Base64.decodeBase64(getString_arg0));
}
}
return super.callStaticObjectMethodV(vm, dvmClass, signature, vaList);
}

@Override
public int getStaticIntField(BaseVM vm, DvmClass dvmClass, String signature) {
if("com/xingin/shield/http/ContextHolder->sAppId:I".equals(signature)){
return -319115519;
}
return super.getStaticIntField(vm, dvmClass, signature);
}

@Override
public DvmObject<?> callObjectMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList){
switch (signature){
case "android/content/Context->getSharedPreferences(Ljava/lang/String;I)Landroid/content/SharedPreferences;":{
String getSharedPreferences_arg0 = vaList.getObjectArg(0).getValue().toString();
System.out.println("arg0 = " + getSharedPreferences_arg0);
return vm.resolveClass("android/content/SharedPreferences").newObject(getSharedPreferences_arg0);
}
case "android/content/SharedPreferences->getString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;":{
if(dvmObject.getValue().toString().equals("s")){ // 获取getSharedPreferences_arg0
String getString_arg0 = vaList.getObjectArg(0).getValue().toString(); // 获取键
String getString_arg1 = vaList.getObjectArg(1).getValue().toString(); // 获得默认值
System.out.println("key: " + getString_arg0);
System.out.println("default_value: " + getString_arg1);

if (getString_arg0.equals("main_hmac")) {
System.out.println("SharedPreferences -> getString(): " + "Msm7d4eAmQVoQie97ci+UCY3fL6YKxBwT2qSuID0IOxYqWkpyRFdN6bM2LvQTlPzdQ5qkkJx/T2QMa8V/z+zkKAqh1mZevC+GYm8UJDPuZKbSI/ROWnKwlTiCvIMHEyY");
return new StringObject(vm,"Msm7d4eAmQVoQie97ci+UCY3fL6YKxBwT2qSuID0IOxYqWkpyRFdN6bM2LvQTlPzdQ5qkkJx/T2QMa8V/z+zkKAqh1mZevC+GYm8UJDPuZKbSI/ROWnKwlTiCvIMHEyY");
}else{
System.out.println("SharedPreferences -> getString(): " + getString_arg1);
return new StringObject(vm, getString_arg1);
}
}
}
case "okhttp3/Interceptor$Chain->request()Lokhttp3/Request;":
return vm.resolveClass("okhttp3/Request").newObject(request);
case "okhttp3/Request->url()Lokhttp3/HttpUrl;":
return vm.resolveClass("okhttp3/HttpUrl").newObject(request.url());
case "okhttp3/HttpUrl->encodedQuery()Ljava/lang/String;":{
HttpUrl httpUrlObj_encodedQuery = (HttpUrl) dvmObject.getValue();
return new StringObject(vm, httpUrlObj_encodedQuery.encodedQuery());
}
case "okhttp3/HttpUrl->encodedPath()Ljava/lang/String;":{
HttpUrl httpUrl = (HttpUrl) dvmObject.getValue();
return new StringObject(vm, httpUrl.encodedPath());
}
case "okhttp3/Request->body()Lokhttp3/RequestBody;":
return vm.resolveClass("okhttp3/RequestBody").newObject(request.body());
case "okhttp3/Request->headers()Lokhttp3/Headers;":
return vm.resolveClass("okhttp3/Headers").newObject(request.headers());
case "okio/Buffer->writeString(Ljava/lang/String;Ljava/nio/charset/Charset;)Lokio/Buffer;":{
Buffer buffer = (Buffer) dvmObject.getValue(); // 取出实例
// 取出参数
String arg0 = vaList.getObjectArg(0).getValue().toString();
Charset arg1 = (Charset) vaList.getObjectArg(1).getValue();
// 调用writeString
return vm.resolveClass("okio/Buffer").newObject(buffer.writeString(arg0, arg1));
}
case "okhttp3/Headers->name(I)Ljava/lang/String;":{
int arg0 = vaList.getIntArg(0);
return new StringObject(vm, ((Headers)dvmObject.getValue()).name(arg0));
}
case "okhttp3/Headers->value(I)Ljava/lang/String;":{
int arg0 = vaList.getIntArg(0);
return new StringObject(vm, ((Headers)dvmObject.getValue()).value(arg0));
}
case "okio/Buffer->clone()Lokio/Buffer;":{
return vm.resolveClass("okio/Buffer").newObject(((Buffer)dvmObject.getValue()).clone());
}
case "okhttp3/Request$Builder->header(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;":{
Request.Builder builderObj_header = (Request.Builder) dvmObject.getValue();
String header_arg0 = vaList.getObjectArg(0).getValue().toString();
String header_arg1 = (String)vaList.getObjectArg(1).getValue();
builderObj_header.header(header_arg0,header_arg1);
return dvmObject;
}
case "okhttp3/Request->newBuilder()Lokhttp3/Request$Builder;":{
Request requestObj_newBuilder = (Request) dvmObject.getValue();
return vm.resolveClass("okhttp3/Request$Builder").newObject(requestObj_newBuilder.newBuilder());
}
case "okhttp3/Request$Builder->build()Lokhttp3/Request;":{
Request.Builder builderObj_build= (Request.Builder) dvmObject.getValue();
Request requestObj_build = builderObj_build.build();
request = requestObj_build;
return vm.resolveClass("okhttp3/Request").newObject(requestObj_build);
}
case "okhttp3/Interceptor$Chain->proceed(Lokhttp3/Request;)Lokhttp3/Response;":
return vm.resolveClass("okhttp3/Response").newObject(null);



}
return super.callObjectMethodV(vm, dvmObject, signature, vaList);
}

@Override
public DvmObject<?> newObjectV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {
switch (signature){
case "okio/Buffer-><init>()V":{
return dvmClass.newObject(new Buffer());
}
}
return super.newObjectV(vm, dvmClass, signature, vaList);
}

@Override
public int callIntMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList) {
switch (signature){
case "okhttp3/Headers->size()I":{
return ((Headers) dvmObject.getValue()).size();
}
case "okio/Buffer->read([B)I":{
Buffer buffer = (Buffer) dvmObject.getValue();
byte[] bytes = (byte[]) vaList.getObjectArg(0).getValue();
return buffer.read(bytes);
}
case "okhttp3/Response->code()I":{
return 200;
}
}
return super.callIntMethodV(vm, dvmObject, signature, vaList);
}


// 调用initialNative
public void initializeNative(){
XhsInterceptor.callStaticJniMethod(emulator, "initializeNative()V");
}

// 调用initialize
public void initialize(){
XhsInterceptorObject = XhsInterceptor.newObject(null);
cPtr = XhsInterceptorObject.callJniMethodLong(emulator, "initialize(Ljava/lang/String;)J", "main");
System.out.println("cPtr = " + cPtr);
}

// 调用intercept
public void get_shield(){
chain = vm.resolveClass("okhttp3/Interceptor$Chain").newObject(null);
DvmObject<?> response = XhsInterceptorObject.callJniMethodObject(emulator, "intercept(Lokhttp3/Interceptor$Chain;J)Lokhttp3/Response;", chain, cPtr);
System.out.println("response = " + response);
}




public static void main(String[] args) {
XHS_shield shield = new XHS_shield();
shield.initializeNative();
shield.initialize();
shield.get_shield();
System.out.println("shield = " + shield.request.headers().get("shield"));
}
}

补环境补了好久,幸好有大佬们的博客,在补环境的过程中,我也逐渐学会了各式各样的unidbg补环境的方法,今天先歇一歇,明天开始trace指令流,分析加密点。——2025.6.2,晚上20:36。

Unidbg trace

获得日志

代码如下:

1
2
3
4
5
6
7
8
9
10
11
static void traceCode(){
String traceFile = "unidbg-android/src/test/java/com/xhs/traceCode.log"; // 输出的路径
PrintStream traceStream = null; // 打印流
try {
traceStream = new PrintStream(new FileOutputStream(traceFile), true);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
// traceCode 对代码进行监控
emulator.traceCode(module.base, module.base + module.size).setRedirect(traceStream);
}

然后从JNI_OnLoad开始trace的话,日志存了120w条指令;而从sub_9E184开始trace,只有1700多行,我们主要关注的是sub_9E184的逻辑。

image-20250603101528525

注意到,这里把寄存器变化的值打印出来了,假如某寄存器存放着字符串的地址,这里也只是打印地址,而不会打印字符串,这样的日志不便于观察。

因此,我们需要修改traceCode的逻辑。

修改Unidbg,获得带有字符串信息的日志

注意到,日志会出现符号“=>”,于是在整个项目中搜索这个符号。然后发现,文件RegAccessPrinter.java就是我要们要修改的文件。

我在这里设定,每次读取寄存器的内容,当初字符串地址进行访问,如果读取后发现了连续3个可视字符,就当成字符串进行处理,打印整个字符串。

在Unidbg中,读取指定内存区域的字节又该怎么做呢?——我记得Unidbg动调可以访问内存地址,指令是“m”,于是在整个项目搜索“m”,然后发现是用Pointer类型进行访问,于是代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Method to check if the register value points to a readable string and print it
private void checkAndPrintString(long address, StringBuilder builder) {
try {
// 获取指向指定地址的 Pointer 对象
Pointer pointer = UnidbgPointer.pointer(emulator, address);
if (pointer == null) {
return; // 地址无效,直接返回
}

// 从地址读取 256 字节的数据
byte[] bytes = pointer.getByteArray(0, 256);

// 检查是否为可打印字符串并提取
if (isPrintableString(bytes)) {
String str = extractString(bytes);
builder.append(" (string: \"").append(str).append("\")");
}
} catch (Exception e) {
// 忽略内存不可读或无效的情况
}
}

// Check if the first 3 bytes are printable ASCII characters
private boolean isPrintableString(byte[] bytes) {
if (bytes.length < 3) return false;
for (int i = 0; i < 3; i++) {
if (bytes[i] < 32 || bytes[i] > 126) {
return false;
}
}
return true;
}

// Extract the full string until a non-printable character or null terminator
private String extractString(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
if (b >= 32 && b <= 126) {
sb.append((char) b);
} else {
break; // Stop at first non-printable character
}
}
return sb.toString();
}

将checkAndPrintString移动到函数print内部。

image-20250603121620460

同时,由于调用UnidbgPointer.Pointer需要Emulator对象,所以还得往类RegAccessPrinter添加成员变量。

image-20250603122310517

既然改了构造函数,还需要在调用构造函数的地方,把Emulator对象传进去。

image-20250603122457201

重新运行,程序能正常跑了。

日志中出现了我们想看到的字符串。

image-20250603122547995

寻找加密点

通过带有字符串信息的日志可以知道:

  1. shield最早出现在哪条指令;
  2. shield的缓冲区地址在哪。

于是,有2个思路去寻找加密点:

  1. 根据shield最早出现的指令,朝着之前的指令进行溯源;
  2. 在shield的缓冲区地址设置读写断点。

怎么看都是第2种方式更简单。

找到缓冲区地址:0x1240c000。——因为是unidbg模拟的缓冲区,所以缓冲区地址不会变。

image-20250603124025662

我们的shield长度是134个字节,内容为:

XYAAQAAgAAAAEAAABTAAAAUzUWEe0xG1IbD9/c+qCLOlKGmTtFa+lG434Je+FUTaRHxIzkyONuSp3/qeYJz8Mt3Jx+2fc6EAwYGGHebLP31H5m0rHnmtyNNSyC55+4O2fp4TDk

unidbg提供了api,可以对写操作进行监控。——traceWrite。

image-20250603130831459

可以发现,偏移0x4b024处在将正确的值写回去了。

分析加密算法

先在IDA中,来到0x4b024看看。

image-20250603132235532

用frida hook一下,查看memcpy复制的内容,发现与最后的shield相差了2个字节,有2个字节(XY)不见了。

image-20250603133116946

根据之前traceWrite日志,可以发现0x4af74单独对0x1240c000前2个字节进行了修改。

image-20250603133810826

根据观察,0x4af70对前2个字节做了赋值,而0x4b020对132个字节进行了赋值。

image-20250603135404795

而且,对a1进行交叉引用,发现没有任何修改a1的地方;查看a1被传入的地方,只发现了简单的加解密,也就是说,前2个字节是固定的,根据直接的traceWrtie日志,固定为XY,因此我们分析的重点在v16。

image-20250603134655647

而v16似乎涉及加密。

image-20250603134245833

在Unidbg中,对v16涉及的memcpy下断点,查看源地址是什么。(已知目的地址是0x1240c002)

image-20250603142532252

可以看到,源地址是0x1240c0a0,于是在0x1240c0a0下写断点。

发现,0x1240c0a0在libxyass.so偏移0x82890的地方被写入数据。

image-20250603144957562

注意到,shield有点像是base64编码后的结果,而且0x82890的代码又是每轮取出v136的3个字节,然后赋给v138的4个字节,这是base64编码的特征。

image-20250603150611119

在IDA中,来到汇编层,X10应该是:指向未经过base64编码的数据的地址。

image-20250603151336773

在Unidbg中下个断点,然后查看这里的值。

image-20250603151739554

对这里的内容进行base64编码,跟我们最终的shield后134字节一模一样,坐实了这里是base64编码。

image-20250603151957773

注意到,这个数据的地址是0x12411000,对这里下一个写断点。(编码前是99字节)

image-20250603152310130

注意到,99个字节的前16个字节的LR指向libxyass.so偏移0x49da0的位置,而后面83个字节来自偏移0x49db4的位置。

image-20250603152839854

来到IDA中。

image-20250603153216623

先后查看两个memcpy的源地址(下断点),然后对源地址下写断点。

先看前16字节的memcpy的x1在哪,由于我hook的地址是0x49D9C,有些数据不是我们想要的,我们只需要前16个字节是00 04 00 02…的源地址。

image-20250603154925545

第4次进入该函数后,终于找到前16个字节的源地址是0xe4ffefd9。

image-20250603155121052

再来找后83个字节的x1在哪。——找到后83个字节在0x123dc060。

image-20250603155314815

0x10个字节(I)

通过IDA的伪代码分析,v15是前16个字节,它来自于a1+1或者a1+16的位置。

image-20250603161446117

hook函数sub_49CE4,查看a1内容。

image-20250603161608228

确定v15来自a1+1。

image-20250603162158928

查看交叉引用,从哪一个过来的呢。

image-20250603162635148

根据trace日志,可以发现是从0x4a2e0跳转过来的。

image-20250603163817199

地址0x4a2e0属于函数sub_4a278,hook一下函数的入参,可以发现x0和x1分别指向16字节和83字节,而x2代表x1的长度。

这里继续观察 16个字节,16个字节位于栈0xe4ffefd8上。

image-20250605093906663

因此对0xe4ffefd8下一个写断点,注意到0x8271C、0x8272C等地址会往里写目标字节。

image-20250605100625749

来到0x8271C,位于函数sub_81F00的范围内。

下图,我对17个字节进行了分析(第0个字节是0x20,0x20并不在目标16字节内,它另有作用)。

第0、3-4、5-8、9-12字节分析

v124用来描述v127(53个字节)的长度,所以v124是0x53,经过bswap32后变成0x53 00 00 00。

image-20250605125053638

image-20250605101334210

因此,这17个字节的格式是这样的:

20 ?? ?? ?? ?? 00 00 00 01 53 00 00 00 ?? ?? ?? ??

再来判断第1-4个字节是如何来的。

image-20250603181820765

假设v155是0xAB CD EF GH,左移16位后,0xEF GH 00 00,或上一个2后,0xEF GH 00 02,然后swap32交换次序,0x02 00 GH EF,也就是说,有2个字节是固定的,GH EF相当于原来的v155的低2个字节做了交换。

当前的v157是这样的:

20 ?? ?? 00 02 00 00 00 01 53 00 00 00 ?? ?? ?? ??

第13-16字节分析

暂时先不分析第1-2字节,这个难度有点高,先来看最后4个字节。

image-20250605144331650

对v161进行交叉引用,发现v161 = v35。

image-20250605144421784

阅读下图,v161获得了sub_4A278的返回值,我们上面的图中,也出现了sub_4A278。

image-20250605144526867

对sub_4A278进行分析,发现它是一个类似memcpy的函数。

image-20250605145136195

不同的是,target是std::string类型,而src是char数组。

image-20250605152800396

string类型是24个字节,之前提到过string类型会对短字符串做sso优化,存储短字符串和长字符串时,string采用的结构不同。

v3 & 1,即target的最低位与1做&,如果(v3&1 != 0)为true,则说明当前当前的string存储着长字符串,采用__long结构;而反之为false,则采用__short结构。

当遇到一个string类型,先判断第0位是否为1,如果是1,则采取__long结构,不用担心第0位的1会影响__cap_的值,__cap_被规定需要2字节对齐,所以第0位单纯是flag;而如果第0位是0,则采取__short结构,该结构会使用高7位表示data的大小,舍弃掉flag的1位,因为data就23个字节,最后一个字节还要存\x00,所以data的实际存储空间大小就22字节,用7位足够表示了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
+--------+----------------+----------+
|address | __short | __long |
+--------+----------------+----------+
| 0 | __size_ / __lx | |
+--------+----------------+ |
| 1 | | |
| 2 | | |
| 3 | | __cap_ |
| 4 | | |
| 5 | | |
| 6 | | |
| 7 | | |
+--------| |----------+
| 8 | | |
| 9 | | |
| 10 | | |
| 11 | __data_ | __size_ |
| 12 | | |
| 13 | | |
| 14 | | |
| 15 | | |
+--------| |----------+
| 16 | | |
| 17 | | |
| 18 | | |
| 19 | | __data_ |
| 20 | | |
| 21 | | |
| 22 | | |
| 23 | | |
+--------+----------------+----------+

注意到,在函数sub_81F00中,一共调用了4次sub_4A278,而v34 = sub_4A278是第3次调用,也就是说,v34里面累积存放了4个字符串——原本有1个,再通过sub_4A278追加了3个。

image-20250605155300448

在Unidbg中模拟执行,对函数sub_4A278进行hook,可以发现:一共就调用了4次sub_81F00,所以基本可以确认,这4次调用都来自于sub_81F00。

第一次调用sub_4A278。

x0指向string类型对象,x1指向char数组,x2是char数组的长度。

image-20250605160706893

第1个字节是0x21,它最低位是1,说明是__long类型;

第1个8字节是0x21,由于对齐,算作0x20,也就是说有32个字节的缓冲区;

第2个8字节说明缓冲区已经用了0x18个字节,也就是用掉了24个字节;

第3个8字节是缓冲区(堆)的地址。

image-20250605161157351

查看堆里面存放的内容,正好24个字节。

image-20250605161634428

待添加的字符串存在x1指向的地址中。

image-20250605162735679

第二次调用sub_4A278。

x0指向string类型对象,x1指向char数组,x2是char数组的长度。

image-20250605161855783

查看x0的内容。

第1个8字节是0x21,由于对齐,算作0x20,也就是说有32个字节的缓冲区。——因为之前32个字节,原本的字符串长度是24个字节,第一次调用时需要添加了7个字节,而缓冲区剩下的8个字节足够容纳,不需要重新申请空间。

第2个8字节由于添加了7个字节,由0x18 + 0x7变成了0x1F。

第3个8字节由于不用重新申请空间,所以空间地址和第一次调用时的堆地址一样,没变。

image-20250605162022804z

查看堆里面存放的内容,可以看到已经添加上8770299了。

image-20250605162517804

第三次调用sub_4A278。

image-20250605162832317

第一个字节是0x51,说明是__long类型。

第1个8字节,0x51视作0x50,说明新申请的缓冲区有80字节的大小。

第2个8字节,0x43表示已经使用了0x43个字节。

第3个8字节,0x12407000是新缓冲区的地址。

image-20250605162931543

18dfe96-7f6f-370a-8e80-f94f8838e6de已经被复制到缓冲区里了。

image-20250605163247658

sub_4A278的返回值是string对象的地址,也就是说,v161指向这4个字符串。

image-20250605163551389

再也就是说,这里的v123实际上是在获得长度,而4个字符串的长度分别是0x18 + 0x7 + 0x24 + 0x10,和为0x53。

image-20250605144331650

所以16字节的第9-12和13-16都是53 00 00 00,都代表着v127的长度。

为什么v156是17字节,而非16个字节,第0个字节为什么是0x20?很明显了,0x20说明是短字符串存储,经过sso优化,直接存储在对象内部,不用开辟堆空间。

砍去一个0不用,剩下的7位表示data部分使用的字节数,0x0010000就是是十进制16,也就是目标16个字节的长度。

image-20250605164202847

言归正传,前17个字节的格式是这样的。

20 ?? ?? 00 02 00 00 00 01 53 00 00 00 53 00 00 00

只剩下第1-2字节未解决了。

第1-2字节分析

第1-2字节与v154有关。

image-20250605165305661

而v154与a4有关。

image-20250605165352929

查看函数0x81F00的引用,来到sub_6EFF0。

image-20250605165650062

v5与a3相关。

image-20250605165701019

函数sub_6EFF0有2个引用。

image-20250605165738153

先不着急追踪引用,先在Unidbg中hook函数sub_6EFF0。

发现*a3 = 0x4。

image-20250605170228483

搜索trace记录,判断函数sub_6EFF0来自于哪个函数调用。

跳转到0x9ef04。

image-20250605175855282

v134是传入的a3。

image-20250605180034095

在0x9ef04下断点,查看x2,因为v134是一个指针,所以用mx2查看里面的内容。

image-20250605183215314

v134与v13有关,v13通过多个调用获得初始化。

image-20250605184132923

我之前实现过:通过jmethodID获得函数名,因此可以识别这里调用了什么。

image-20250605205752942

经过判断,调用了类的初始化函数okio.Buffer.<init>,还调用了writeString。

尝试分析,这里究竟写入了什么。这里我hook上了0x6E13C,0x6E13C封装了CallObjectMethodV,同时根据args[2](jmethodID)筛选调用的函数,打印写入的字符串。

1
2
3
4
5
6
7
8
9
10
11
12
function getStr(){
var module = Process.getModuleByName("libxyass.so");
Interceptor.attach(module.base.add(0x6E13C), {
onEnter: function(args){
if(prettyMethod(args[2], 0) == ("okio.Buffer.writeString")){
var va1 = args[3];
var env = Java.vm.getEnv();
console.log("[str]", ptr(env.getStringUtfChars(va1)).readCString());
}
}
});
}

写了很多东西。

image-20250605210301978

用Unidbg也可以实现类似的效果,写的时候打印一下即可。

image-20250605210419813

然后下个断点,判断执行前后能否打印字符串。

成功打印字符串“/api/sns/v4/user/login/password”,但发现了点小问题。

image-20250605210639382

观察我补的环境,发现这里传入了一个新的dvmObject,可能是因为这个原因。

image-20250605210713560

改成这样子。

image-20250605210800510

然后再跑一次,这回jobject的值一致了。

image-20250605210955734

最后v134应该指向栈上地址:0xe4fff640,但没找到v134被修改的地方。

只能说,只看伪代码果然不行,接下来看汇编了,前面的内容供读者图一乐,错误经验也是经验,主要是展示我个人的分析流程。

往栈地址0xe4fff640下一个写断点,发现是0x9eddc往0xe4fff640里写了0x4。

image-20250605214533648

在0x9eddc下一个断点,判断一下Q3、Q2哪个存放了0x4。

image-20250605214730765

q3存放0x4。

image-20250605214912867

而Q3 = [X24 + 252],往0x9EDC4下断点,查看X24+252和是多少,然后给X24+252下一个写断点。

image-20250605215043885

x24 = 0x123df000,因此在0x123df0fc下一个写断点。

image-20250605215318136

下了断点后,发现是0x920AC的指令再往0x123df0fc里写0x4。

而w8的值由LDR w8,[x8]得来的。

image-20250605215941273

而x8的值是0x123dc060。

image-20250605220036345

再在0x123dc060下一个写断点,找是谁往0x123dc060里写入的0x4。

地址0x9aab8的指令往0x123dc060里写0x4。

image-20250605220348565

分析一波,如图所示。

image-20250605222117031

可以下个断点在0x9AAA4,查看每次取的16字节来自于哪里。

image-20250605222306362

Q1的16字节来自栈上基址(且x19指向的)0xe4fff200、Q0的16字节来自栈上基址(且x22指向的)0xe4fff148。

Q1和Q0只有低字节不一样,Q1是0x31,Q0是0x35。

image-20250606093410755 image-20250606093422089

先为0xe4fff200下一个写断点,判断这16个字节(31 01 32…17 66 39)是什么时候写入的。

image-20250606093939190

发现是0x92830的指令在往里面写,跳转过去看看。

Q0 = [x8 + x10],在0x92820下一个断点,查看x8与x10的值。

image-20250606094216659

image-20250606094418843

得出0x12019A30。

image-20250606094442596

Unidbg的映射基地址是0x1200000,所以这里的偏移指向so文件的0x19A30。

发现这里是.rodata节的内容,也就是说,Q1的16字节,来自于文件中硬编码的16字节。

image-20250606094555033

再追踪Q0的16字节从何而来,对0xe4fff148下一个写断点。

注意到,似乎对每个字节赋值的地址不同。

image-20250606100952982

这里先追踪Q0最低字节,因为Q1和Q0只在这里有所不同。

来到0x9A064。

image-20250606101533687

对这一块代码进行还原,发现有点复杂啊。

image-20250604131821514

借鉴了一下其它人的分析,Q0的16个字节来自于变种AES加密,因此这里先放一放,先去分析另外53个字节。

0x53个字节

前面提到,0x53个字节其实是分为4块,每块的字节数分别是0x18、0x7、0x24、0x10,

通过hook函数sub_4A278便可获得这4块内容。

下图是第3次调用sub_4A278的位置,可以在这边下一个断点,观察4个字符串未加密前是什么。

image-20250606110401435

这是加密前的内容,位于0x1240c000。

image-20250606111137178

在对第四次调用sub_4A278的位置下断点,这个时候里面的内容虽然也是0x53个字节,但已经加密了。

image-20250606111756494

加密点应该在第3次调用与第四次调用之间。

在0x123dc060下写断点,指令0x8235C处往里面写了0x35,0x8235C位于函数sub_81F00内。

image-20250606115920475

跳转到0x8235C,看不出加密算法。

image-20250606120453309

慢慢分析。

先对sub_81F00进行hook,打印入参。

image-20250606121200628

image-20250606121138961

(x0)a1 = 1。(疑似固定值)

image-20250606130046944

(x1)a2是个字符数组,代表build,如下。

0xE表示sso存储,占用了7个字节。

image-20250606121335072 image-20250606132842937

image-20250606122006533

(x2)a3来自于AppId。

image-20250606130146772 image-20250606130210235

(x3)a4为0x4;——之前q1、q2异或的0x4。

(x4)a5是个string类型;

image-20250606121826620

存放了device_id。

image-20250606121842021

(x5)a6=0xe4fff521,似乎存放了16个字节。

image-20250606130901638

(x6)a7 = 0x10,似乎代表a6的大小。

image-20250606130328066

(x7)a8=0x38663439662d3038,存放了8个字符“80-f94f8”。

image-20250606131755330

似乎是device_id的一部分。

image-20250606132002394

改一下变量名。

image-20250606132559512

结合入参进行改名。

image-20250606134204767

插入一个结构体,方便分析。

image-20250606134959994

接着往下分析。

image-20250606135258040

点进xmmword_19AA0,0x21说明了是长字符串存储,缓冲区大小是0x20个字节,已经占用了0x18个字节。(v157的前16个字节来自于xmmword_19AA0,缓冲区的地址并不是通过v157=xmmword_19AA0得到的)

image-20250606135337277

v157指向红色框中的这18个字节。

image-20250606111137178

接着往下分析。

image-20250606143311760

观察变量“ptr_18bytes_AND_build_id_AND_device_id_b”的值,它的值其实在之前就清空了,这里应该是复用了,因为不知道它在加密部分是什么含义,暂时没改变量名。

hook上0x821B0的位置,访问x9所指向的内存地址,查看内容有什么变化。(根据计算,有32次循环,记作0-31)

image-20250606144449746

第0次循环。

image-20250606144721289

image-20250606144805944

在执行了几次循环后,如下图所示。

从第8个字节开始,0x0、0x1…依次递增,一次循环可以初始化8个4字节,而执行32次,也就是说,从0一直到(32*8 - 1),也就是0x0到0xff。

image-20250606145022167

而!RC4流密码中,在初始化状态向量时,SBox就是这么得来的,SBox的初始化的伪代码如下。

一般的RC4中,SBox是字节数组,而这里是4字节为单位的数组。

1
2
3
for (int i = 0; i < 256; i++) {
S[i] = i;
}

如果猜想是对的话,待会应该还有一段用密钥对S盒进行置换的过程,暂时先将“ptr_18bytes_AND_build_id_AND_device_id_b”重命名为“SBox”。

继续往下分析。

image-20250606151139575

待会再去hook获得密钥,先来看看这个S盒置换算法。

下面这段代码基本可以说明,就是RC4算法,而且密钥长度为13——0到12。

image-20250606154725222

循环64次,说明每次循环交换4次。

image-20250606154738763

hook一下,得到密钥看看。

image-20250606155337349

x8=0x1201b48d,说明密钥在文件偏移0x1b48d的位置。

试图用字符串“std::abort();”作掩护,hhhh。

image-20250606155703863

拿去试一下,发现是个非常标准的rc4加密。

image-20250606163952156

至此,0x53个字节分析完了。

0x10个字节(Ⅱ)

还记得,在**16个字节(Ⅰ)**中,我们分析得出,16个字节的格式如下:

20 ?? ?? 00 02 00 00 00 01 53 00 00 00 53 00 00 00

当时最大的问题是:?? ??的值由Q1与Q0异或得来,Q1是硬编码在文件中的,而Q0的值,当时只追踪了最低字节。

当时为了得出Q0的最低字节从何而来,我逆了一下算法,但涉及到的缓冲区有点多。

image-20250604131821514

所以在这一小节,我们的目标之一是还原Q0,还有一个目标是分析绿色框里的16个字节从何而来。

(我好像没解释过红色框,红色框的前4个字节是函数sub_81F00传入的a1,是固定值;再4个字节是app_id,0x2是根据传Q1与Q0异或的0x4判断得到的;0x7代表build的大小,0x24代表device_id的大小,0x10代表未知的16个字节)

image-20250606111137178

HMAC-MD5

这16个字节是函数sub_81F00的入参a6,对应的缓冲区地址是0xe4fff521。

image-20250606130901638

在0xe4fff521下一个写断点。

image-20250606175216983

地址0x6f768处的指令对0xe4fff521进行了写。

0xe4fff521的值由Q0得到,Q0的值由[X29,#-0x18]得到,所以在0x6F758下断点(或者看日志)。

image-20250606175711711

x29=0xe4fff470,所以X29 - 0x18是0xE4FFF458。

image-20250606175911851

在0xE4FFF458下写断点,发现是0x92fd8。

image-20250606180338505

地址0x92fd8位于函数sub_92F2C范围内。

image-20250606181133194

我们需要的16字节存储在a2中,a2由v2赋值。

image-20250606181450989

w8是上图的a2,a2的值地址0x92FCC获得,所以在0x92FCC下一个断点,查看X20的值。

image-20250606182624432

x20的值是0x123df480。

image-20250606182803621

在x20下写断点。

可以看到,偏移0x93f74对0x123df480进行了写操作。

image-20250606183329672

来到0x93f74。

image-20250607095231225

对应于汇编的伪代码。

image-20250607095624055

现在0x93F74下个断点,发现这个函数执行了很多次,迟迟等不到我们要看的内容,于是统计一下这个函数执行的次数。

image-20250607100823567

同时,分析算法,发现一个很有意思的点。

假设HIDWORD(v4) = R,那么LODWORD(v4)也等于R,也就是说64位的高32位和低32位是一样的,都为R。

而v4 >> 17可以写成(R << 32 | R) >> 17,进一步可以写成((R << 32) >> 17) | R >> 17。

(R << 32) >> 17的结果是 R 左移(32 - 17 = 15)位,并占据了结果的高位部分。

R >> 17的结果是 R 右移17位,占据了结果的低位部分。

v5是一个32位的数据类型,所以当上述的两者通过或组合在一块,就相当于将R循环右移了17位。

而循环右移17位,等同于循环左移15位。

(注意这里的<<都是逻辑左移和逻辑右移,而不是循环左移和循环右移)

image-20250607105851010

md5算法有4轮运算:F、G、H、I,每一轮运算要执行16次(因为有16个子分组),在I运算中,涉及循环左移6、10、15、21位;除此之外,I运算涉及异或、取反、或等操作。

image-20250607111129485 image-20250607111219863

而在sub_93F14中,发现了循环左移15位,还有循环左移21位,同时发现了一些异或、取反的操作,又考虑到这个函数执行了16次,这大概率是md5算法的II运算。

image-20250607111353373

还发现了md5的魔数,按常理来说,II运算是第4轮运算,魔数ABCD早被修改了,但编译器似乎没对寄存器x0进行操作,魔数一直存在x0中。

image-20250607113630363

更加坐实了这是一个魔改的md5算法。——为什么说是魔改,之后会说。

image-20250607113813785

分析trace日志中,0x93F74出现的地方,对应上调用了16次函数sub_93F14。

image-20250607124335780

在第16次的时候,w28出现了未知16字节中的4个字节。

image-20250607124755707

在第16次调用的trace记录,w27是魔数,w28的值来自于[0xe4fff2c0 - 0x34],即[0xE4FFF28C]。

要同时追踪w27和w28吗?其实不用。

image-20250608132421218

下图是第1次调用的trace记录,w27是一个魔数,所以只追踪w28。

image-20250607140241493

第三轮运算最终的a、b、c、d的第四轮运算的初始a、b、c、d。

image-20250608140802816

在0xe4fff1f0-0x34处下一个写断点,观察里面的值来自于哪,大概率是md5的HH轮运算传过来的。

image-20250608142243297

偏移0x94534位于函数sub_944A8内,符合md5的特征,看样子还是II运算。

image-20250608142607568

结合上图和下图,有点像是MD5的第60、61、62、63、64轮运算。

image-20250608142854656

结合标准的md5算法,md5的63、64轮明显被魔改了。

image-20250608152840554

有一个疑惑,既然这里是md5的第63、64轮加密,为什么函数sub_93F14调用了16次?我原本以为,这里魔改的II运算,结果似乎只涉及63、64轮加密,前面的分析有些误打误撞,不过确认了,使用的加密算法是md5,涉及一些变异。

回到函数sub_92F2C,对函数进行hook,判断v2是从什么变成16个字节。

image-20250608155657798

发现要加密的内容是16个字节。

image-20250608165240684

对函数sub_92F2C进行分析,静态分析下,可能会调用了2次sub_93390,第1次调用是为了处理空间不足的情况,因为md5的每个分组是512位,要留出最后64位记录信息长度,所以实际能用来存储的空间只有56字节。

image-20250608165909885

创建结构体,对变量进行修改。

0x80是填充值,数据长度要小于等于55,因为至少有1个字节是填充值。

image-20250608165050501

接下来,分析函数sub_93390,它大概率是在进行md5加密,根据统计,一共执行了5次sub_93390。——这也无法解释为什么调用了sub_93F14共计16次,再放一会吧。

image-20250608170636507

而我hook函数sub_92F2C,这个函数执行了2次,而且每一次数据的量都没有超过55字节,说明还有其它3处地方调用了sub_93390,进行md5加密。

搜索trace记录,从第1次调用开始看起。

image-20250608171823863

第一次调用来自于0x92eec。

image-20250608183325917

这里的sub_93390没有看到参数列表。

image-20250608183652512

根据汇编,大概有3个参数,因此可以修改函数签名。

image-20250608183908545

而且这里的v3,一看就是之前分析的那个结构,修改变量类型。

image-20250608184300820

hook函数sub_93390,看看返回值和入参。——返回值可以通过入参a1的前16字节获得。

根据大量的0x36,可以判断这是一个hmac-md5算法。

image-20250608201229798

整理一下,5次调用sub_93390的入参、返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// 第1次调用
// x0
0000: 76 54 32 10 FE DC BA 98 89 AB CD EF 01 23 45 67 vT2..........#Eg
// x1(key与0x36的结果)
0000: 6A 3E 23 D6 BC E6 B7 B9 C6 D2 4B D7 6C 78 02 D9 j>#.......K.lx..
0010: 72 84 55 82 03 93 5F 6E 20 B3 32 87 75 82 F9 8D r.U..._n .2.u...
0020: 38 43 90 71 D4 5C B9 1A 8D 63 39 3D F7 94 59 C7 8C.q.\...c9=..Y.
0030: 7C 27 5A FD 1D 68 77 A5 BE 4D 91 14 E5 8D 0E 51 |'Z..hw..M.....Q
// x2
0x1
// 返回值
0000: 39 0A 80 0F EC FE 50 43 EE 54 1F 5B 06 9C 47 8B 9.....PC.T.[..G.

// 第2次调用
// x0
0000: 76 54 32 10 FE DC BA 98 89 AB CD EF 01 23 45 67 vT2..........#Eg
// x1(key与0x5C的结果)
0000: 00 54 49 BC D6 8C DD D3 AC B8 21 BD 06 12 68 B3 .TI.......!...h.
0010: 18 EE 3F E8 69 F9 35 04 4A D9 58 ED 1F E8 93 E7 ..?.i.5.J.X.....
0020: 52 29 FA 1B BE 36 D3 70 E7 09 53 57 9D FE 33 AD R)...6.p..SW..3.
0030: 16 4D 30 97 77 02 1D CF D4 27 FB 7E 8F E7 64 3B .M0.w....'.~..d;
// x2
0x1
// 返回值
0000: 29 9A 8E 4B 77 48 EA 98 E3 E3 DF 8C C7 D2 4B 4A )..KwH........KJ

// 第3次调用
// x0
0000: 39 0A 80 0F EC FE 50 43 EE 54 1F 5B 06 9C 47 8B 9.....PC.T.[..G.
// x1
0000: 2F 61 70 69 2F 73 6E 73 2F 76 34 2F 75 73 65 72 /api/sns/v4/user
0010: 2F 6C 6F 67 69 6E 2F 70 61 73 73 77 6F 72 64 66 /login/passwordf
0020: 69 64 3D 31 37 34 34 32 39 38 31 30 38 31 30 31 id=1744298108101
0030: 33 64 32 30 63 39 31 35 38 61 30 61 39 62 32 39 3d20c9158a0a9b29
0040: 61 62 62 63 62 62 31 65 64 37 32 36 66 35 62 26 abbcbb1ed726f5b&
0050: 67 69 64 3D 37 63 30 64 36 38 65 38 36 34 37 61 gid=7c0d68e8647a
0060: 35 34 33 38 61 32 33 39 34 62 62 61 64 33 36 34 5438a2394bbad364
0070: 63 32 62 64 66 33 31 62 39 38 37 39 34 37 33 35 c2bdf31b98794735
0080: 39 34 34 30 37 37 62 62 66 63 35 35 26 64 65 76 944077bbfc55&dev
0090: 69 63 65 5F 6D 6F 64 65 6C 3D 70 68 6F 6E 65 26 ice_model=phone&
00A0: 74 7A 3D 41 73 69 61 25 32 46 53 68 61 6E 67 68 tz=Asia%2FShangh
00B0: 61 69 26 63 68 61 6E 6E 65 6C 3D 56 69 76 6F 26 ai&channel=Vivo&
00C0: 76 65 72 73 69 6F 6E 4E 61 6D 65 3D 38 2E 37 37 versionName=8.77
00D0: 2E 30 26 64 65 76 69 63 65 49 64 3D 31 30 66 39 .0&deviceId=10f9
00E0: 31 66 36 38 2D 37 64 62 37 2D 33 61 33 36 2D 38 1f68-7db7-3a36-8
00F0: 30 63 39 2D 37 30 33 61 62 61 64 38 66 65 65 32 0c9-703abad8fee2
0100: 26 70 6C 61 74 66 6F 72 6D 3D 61 6E 64 72 6F 69 &platform=androi
0110: 64 26 73 69 64 3D 73 65 73 73 69 6F 6E 2E 31 37 d&sid=session.17
0120: 34 34 33 30 30 36 32 36 36 35 35 37 30 31 36 32 4430062665570162
0130: 39 31 35 30 26 69 64 65 6E 74 69 66 69 65 72 5F 9150&identifier_
0140: 66 6C 61 67 3D 34 26 70 72 6F 6A 65 63 74 5F 69 flag=4&project_i
0150: 64 3D 45 43 46 41 41 46 26 78 5F 74 72 61 63 65 d=ECFAAF&x_trace
0160: 5F 70 61 67 65 5F 63 75 72 72 65 6E 74 3D 6C 6F _page_current=lo
0170: 67 69 6E 5F 66 75 6C 6C 5F 73 63 72 65 65 6E 5F gin_full_screen_
0180: 70 77 64 5F 70 61 67 65 26 6C 61 6E 67 3D 7A 68 pwd_page&lang=zh
0190: 2D 48 61 6E 73 26 61 70 70 5F 69 64 3D 45 43 46 -Hans&app_id=ECF
01A0: 41 41 46 30 31 26 75 69 73 3D 6C 69 67 68 74 26 AAF01&uis=light&
01B0: 74 65 65 6E 61 67 65 72 3D 30 26 61 63 74 69 76 teenager=0&activ
01C0: 65 5F 63 74 72 79 3D 43 4E 26 63 70 75 5F 6E 61 e_ctry=CN&cpu_na
01D0: 6D 65 3D 51 75 61 6C 63 6F 6D 6D 2B 54 65 63 68 me=Qualcomm+Tech
01E0: 6E 6F 6C 6F 67 69 65 73 25 32 43 2B 49 6E 63 2B nologies%2C+Inc+
01F0: 53 4D 38 31 35 30 26 64 6C 61 6E 67 3D 7A 68 26 SM8150&dlang=zh&
0200: 6C 61 75 6E 63 68 5F 69 64 3D 31 37 34 34 34 33 launch_id=174443
0210: 36 33 39 32 26 6F 76 65 72 73 65 61 73 5F 63 68 6392&overseas_ch
0220: 61 6E 6E 65 6C 3D 30 26 6D 6C 61 6E 67 75 61 67 annel=0&mlanguag
0230: 65 3D 7A 68 5F 63 6E 26 66 6F 6C 64 65 72 5F 74 e=zh_cn&folder_t
0240: 79 70 65 3D 6E 6F 6E 65 26 61 75 74 6F 5F 74 72 ype=none&auto_tr
0250: 61 6E 73 3D 30 26 74 3D 31 37 34 34 34 33 36 33 ans=0&t=17444363
0260: 38 35 26 62 75 69 6C 64 3D 38 37 37 30 32 39 39 85&build=8770299
0270: 26 68 6F 6C 64 65 72 5F 63 74 72 79 3D 43 4E 26 &holder_ctry=CN&
0280: 64 69 64 3D 32 64 33 35 31 62 62 65 66 31 64 62 did=2d351bbef1db
0290: 38 33 38 63 35 66 63 64 36 62 36 37 61 34 61 61 838c5fcd6b67a4aa
02A0: 31 64 62 35 38 38 70 6C 61 74 66 6F 72 6D 3D 61 1db588platform=a
02B0: 6E 64 72 6F 69 64 26 62 75 69 6C 64 3D 38 37 37 ndroid&build=877
02C0: 30 32 39 39 26 64 65 76 69 63 65 49 64 3D 35 31 0299&deviceId=51
02D0: 38 64 66 65 39 36 2D 37 66 36 66 2D 33 37 30 61 8dfe96-7f6f-370a
02E0: 2D 38 65 38 30 2D 66 39 34 66 38 38 33 38 65 36 -8e80-f94f8838e6
02F0: 64 65 66 73 3D 30 26 70 6F 69 6E 74 3D 31 39 33 defs=0&point=193
0300: 32 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 2...............
// x2
0xc
// 返回值
0000: 81 97 7E 83 36 B7 43 2D 01 34 A6 A4 34 6C 32 06 ..~.6.C-.4..4l2.

// 第4次调用
// x0
0000: 81 97 7E 83 36 B7 43 2D 01 34 A6 A4 34 6C 32 06 ..~.6.C-.4..4l2.
// x1
0000: 32 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 2...............
0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0030: 00 00 00 00 00 00 00 00 08 1A 00 00 00 00 00 00 ................
// x2
0x1
// 返回值
0000: 8D 67 06 8C 91 18 85 7B FB 78 DB 01 59 F2 09 69 .g.....{.x..Y..i

// 第5次调用
// x0
0000: 29 9A 8E 4B 77 48 EA 98 E3 E3 DF 8C C7 D2 4B 4A )..KwH........KJ
// x1
0000: 8D 67 06 8C 91 18 85 7B FB 78 DB 01 59 F2 09 69 .g.....{.x..Y..i
0010: 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0030: 00 00 00 00 00 00 00 00 80 02 00 00 00 00 00 00 ................
// x2
0x1
// 返回值
0000: 72 4B A0 52 97 97 13 0E 0B C3 00 2F BF C2 C7 5F rK.R......./..._

注意到,第5次调用sub_93390的返回值正是我们需要的16字节。

同时发现:

  1. 第1次调用的返回值,是第3次加密的魔数值——0x36与key异或作为输入,魔数是标准md5魔数;
  2. 第2次调用的返回值,是第5次加密的魔数值——0x5C与key异或作为输入,魔数是标准md5魔数;
  3. 第3次调用的返回值,是第4次加密的魔数值——明文url + xy-common-params + xy-direction + platform + build + deviceId + xy-scene作为输入,魔数是第1次调用的返回值;
  4. 第4次调用的返回值,是第5次加密的输入——第4次调用的输入是第3次加密中,末尾不足64字节的部分;如果第3次调用刚好满足每组都是64字节,第4次调用的输入将是0x80开头,然后在最后的8个字节中,会描述前面的所有块的大小。
  5. 第5次加密就是将第4次的16字节进行了填充得到最终的16字节。

单独看每次调用,其实每次调用都是魔改MD5算法,而将5次魔改MD5的调用合在一起,就是一个HMAC-MD5调用,整个流程应该这样子理解:

第1次调用魔改MD5,是对**(密钥 ^ 0x36)的结果进行的魔改MD5运算,这一次调用的目的是获得用作第3次调用的魔数(16字节),这16字节并非是用来作为K’**,再次说明:密钥是64字节,不需要经过MD5运算获得16字节,再填充至64字节,这里进行MD5仅仅是为了获得第3次调用的魔数,因为第3次调用是第1次调用的延续。

image-20250608213042711

暂时不管第2次调用。

第3次调用魔改MD5,是对明文进行计算MD5的值,明文长度为0x301字节,魔数是第1次调用的结果(16字节)。

image-20250608213356686

按照HMAC-MD5计算方式,K’是密钥,ipad是64个字节的0x36,两者异或后还是64字节,然后拼接上明文,再进行魔改的MD5运算,这时候,输入MD5运算的长度是0x301+0x40,即0x341字节。

image-20250608213042711

而MD5运算,一次处理64字节,如果最后一组不满64字节,则填充448位,最后8字节用来表示这一次输入MD5运算的位数是多少。0x341个字节,即输入了0x1A08位数据。

image-20250608214219574

第4次调用,单独处理1个字节,因为0x341个字节,可以分为13个64字节分组和单独1个字节,第4次调用就是处理这个字节的,注意下图,0x32后填充了一个0x80(即后面填充一个1和“无数”个0),而最后8字节是0x1A08,代表着处理的位数。

image-20250608214446344

将第1、3、4次调用放在一块看,最后获得了HMAC-MD5这一步的结果。↓

image-20250608213042711

第2次调用是获得(K’ ^ 0x5C)的MD5运算后的结果(16字节),用来作为第5次调用的魔数(因为在附加连接的时候,是K’在前面,说白了,魔数代表着前面几次MD5运算的结果)。

image-20250608214939057

第5次调用,是计算H(K’ ^ 0x5C || H(K’ ^ 0x36 || 明文)) ,输入是(K’ ^ 0x5C)|| (第1、3、4次调用的结果),已知(K’ ^ 0x5C)是64字节,而(第1、3、4次调用的结果)肯定是16字节,所以一共是80字节。

下图中0x80是填充,最后8字节是0x280,即80字节。

image-20250608215911675

到这里,也能理解之前为什么调用了16次sub_93F14。因为第1-5次调用sub_93390,分别执行了1、1、12、1、1次魔改MD5运算。

之所以说这是魔改MD5,是因为在个别轮次的运算,不是标准MD5的FF、GG、HH、II。

点开sub_93390。

image-20250609103836334

根据日志,对sub_93390调用链进行分析,MD5一共64轮运算,将每轮运算依次与标准MD5进行对比。

image-20250609112747484

在日志中,追踪BR跳转,sub_94970是第1个较为“完整”的函数。(这里的完整指的是:有些函数的伪代码在IDA的分析下,显示为直接BR跳转;而这个函数会先执行一些操作再跳转)

根据动调,发现前4行代码加载了魔数,第5行代码加载了待加密数据的地址,具体内容写在注释上了…函数sub_94970似乎在为MD5的第一轮运算做准备。

image-20250609135943423

接下来,来到函数sub_95080,这应该是md5的第一轮加密。循环左移7位变成了6位。

image-20250609144742615

hook了一下v7+132的位置应该是T盒,但并不是标准的T盒。

image-20250609150053963

函数sub_95654,为明文的第2、3个字做准备。

image-20250609152424760

函数sub_93DD8中,MD5第二轮加密。循环左移12位变成了循环左移13位。

image-20250609154735081

具体的不在这里一一分析,做个总结。

魔改左移操作数

第43轮的左移操作数被改,在地址0x93AC8处,原操作数为 16,被改为 11

第42轮的左移操作数被改,在地址0x93A90处,原操作数为11 ,被改为 16

第41轮的左移操作数被改,在地址0x93A78处,原操作数为 4,被改为 23

第40轮的左移操作数被改,在地址0x94368处,原操作数为 23,被改为 4

第14轮的左移操作数被改,在地址0x9480C处,原操作数为 12,被改为 13

第11轮的左移操作数被改,在地址0x94E00处,原操作数为 17,被改为 16

第8轮的左移操作数被改,在地址0x93888处,原操作数为 22,被改为 20

第4轮的左移操作数被改,在地址0x952E4处,原操作数为 22,被改为 21

第2轮的左移操作数被改,在地址0x93E28处,原操作数为 12,被改为 13

第1轮的左移操作数被改,在地址0x950F0处,所属函数sub_95080原操作数为 7 ,被改为 6

魔改T值

标准MD5的T值是:

1
2
3
4
5
6
7
8
9
const unsigned int t[] = {
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391};

而魔改的T值是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0000: 56 B7 C9 E9 79 A4 1B D7 DB 81 10 24 D9 88 10 68    V...y......$...h
0010: AF F7 14 9B B1 5B 1F FF BE D7 1C 88 22 61 66 66 .....[......"aff
0020: 93 61 66 F6 9E 63 19 A6 21 09 14 49 EE CE 1D C1 .af..c..!..I....
0030: AF 0F 1C F5 2A C6 17 47 13 46 10 A9 01 95 16 FD ....*..G.F......
0040: 00 25 00 F6 53 14 74 02 91 E6 21 D2 C9 11 00 E2 .%..S.t...!.....
0050: E6 CD 61 22 97 0D D5 F2 ED 14 5A 42 05 E9 77 A2 ..a"......ZB..w.
0060: F9 A3 77 F2 D9 12 6F 62 9A 4C 2A 92 48 F2 39 12 ..w...ob.L*.H.9.
0070: 00 00 00 00 40 B3 40 C0 51 5A 5E 26 00 00 10 E9 ....@.@.QZ^&....
0080: 5D 10 3F D6 D6 07 57 C3 42 39 FC FF 91 D6 7C 97 ].?...W.B9....|.
0090: 44 EA BC A4 A9 CF DC 4B 70 BC BC BE C6 7E 8C 28 D......Kp....~.(
00A0: 60 4B CC F6 95 10 EC D4 FA 27 AC EA E5 88 DC E6 `K.......'......
00B0: 39 D0 DC D9 05 1D 8C 04 F9 7C A2 1F 90 F2 39 12 9........|....9.
00C0: 00 00 00 00 22 61 9D 6D 65 56 AC C4 1C 39 E5 FD ...."a.meV...9..
00D0: 44 22 29 F4 A7 23 94 AB 39 A0 93 F5 C3 59 5B 65 D")..#..9....Y[e
00E0: 97 FF 2A 45 7D 24 EF F5 D1 5D 84 85 92 CC 0C 85 ..*E}$...]......
00F0: E0 26 99 F9 C0 F2 39 12 00 00 00 00 D6 A9 97 EF .&....9.........
0100: 4F 7E 99 F9 14 43 99 A9 82 7E 53 C5 A1 11 08 45 O~...C...~S....E
0110: A6 11 08 45 35 F2 3A BD 91 D3 86 EB

为什么魔改后的T表比标准T表大呢?——多了7个4字节。

在函数sub_93BA4中,执行了第26、27、28轮运算,v0[59]是第27轮的t,v0[62]是第28轮的t,中间跳过了v0[60]、v0[61],也就是2个4字节。

image-20250609155821938

在函数sub_951A8中,执行了第45、46、47轮运算。

image-20250609160301578

而在函数sub_93A4C中,执行了第42、43、44轮运算,第45轮的t是v1[79]。

image-20250609160958764

v1和v5基址一样,v1[79]到v5[82],跳过了2个4字节,也就是8字节。

在函数sub_93CB4中,执行了第56、57、58轮运算,又跳过了3个4字节。

image-20250609160740608

总共跳过了2 + 2 + 3个4字节,共计7个4字节,因此魔改后的T表多了7个4字节。

入参顺序修改

假设HH的签名是HH(A,B,C,D,Mi,s,Ti)。

40轮:37轮结果对应为A位置,36轮结果对应B,38-39分别对应C-D,明文块取下标13

41轮:36轮结果对应为A位置,39轮结果对应B,38-40分别对应C-D,明文块取下标10

42轮:39轮结果对应为A位置,38轮结果对应B,40-41分别对应C-D,明文块取下标3

43轮:38轮结果对应为A位置,40轮结果对应B,41-42分别对应C-D,明文块取下标0

以第40轮的入参为例子。

标准MD5的情况下,原本第40轮的bcda分别来自于第36、39、38、37轮。

image-20250609163752074

在函数sub_957D4中,v1-200存放着第36轮的结果b。

image-20250609164111974

在函数sub_936C0中:

v2-196存放着第37轮的结果a。

v2-192存放着第38轮的结果d。

v2-188存放着第39轮的结果c。

image-20250609164347432

正常的第40轮,应该是:HH(v-200, v-188, v-192, v-196,M10,23,tj),展开后应该是这样子:

a = (v-188) + (((v-200) + H(v-188, v-192, v-196) + M10 + tj) <<23)。

在函数sub_94344中是:HH(v-196, v-200, v-192, v-188, M13, 4, tj)。

image-20250609174510357

image-20250609174519494

同时发现,如果对第40轮取的明文进行打印,对应的M是第13块(下标),而非第10块,这里不过多赘述了。

image-20250609175346841

魔改AES,还原Q0

现在只剩下最后2个问题:

一是之前Q0与Q1异或的结果是0x4,Q1是硬编码在文件中的,而Q0则涉及很多内容,对栈、堆下断点,一点一点追踪Q0的内容十分困难。

image-20250604131821514

二是HMAC-MD5的密钥是哪里来的?

根据后来的结果,只要解决问题二,问题一就顺带解决了。

x1指向的内容,是密钥与0x36异或的结果。

1
2
3
4
5
6
7
8
9
10
11
12
// 第1次调用
// x0
0000: 76 54 32 10 FE DC BA 98 89 AB CD EF 01 23 45 67 vT2..........#Eg
// x1(key与0x36的结果)
0000: 6A 3E 23 D6 BC E6 B7 B9 C6 D2 4B D7 6C 78 02 D9 j>#.......K.lx..
0010: 72 84 55 82 03 93 5F 6E 20 B3 32 87 75 82 F9 8D r.U..._n .2.u...
0020: 38 43 90 71 D4 5C B9 1A 8D 63 39 3D F7 94 59 C7 8C.q.\...c9=..Y.
0030: 7C 27 5A FD 1D 68 77 A5 BE 4D 91 14 E5 8D 0E 51 |'Z..hw..M.....Q
// x2
0x1
// 返回值
0000: 39 0A 80 0F EC FE 50 43 EE 54 1F 5B 06 9C 47 8B 9.....PC.T.[..G.

再次异或0x36,获得key。

1
2
3
4
5c 08 15 e0 8a d0 81 8f f0 e4 7d e1 5a 4e 34 ef 
44 b2 63 b4 35 a5 69 58 16 85 04 b1 43 b4 cf bb
0e 75 a6 47 e2 6a 8f 2c bb 55 0f 0b c1 a2 6f f1
4a 11 6c cb 2b 5e 41 93 88 7b a7 22 d3 bb 38 67

存放(密钥^0x36)的栈地址是0xe4fff320,在这里下一个写断点。

image-20250608201229798

偏移0x91708的位置。

image-20250609181802867

说明密钥来自于[x25, #0x24]往后的64个字节。

image-20250609182441095

地址是0x123e7024。

image-20250609182616292

对0x123e7024下一个写断点。

image-20250609182703626

偏移0x916d0。

image-20250609182802659

在0x926B8下断点,获得X24的值。

image-20250609184353374

再对0x123e2000下一个写断点。

image-20250609184832996

这里的偏移是0x911c8,0x1c1f8是libc的偏移,所以这里看LR寄存器。

image-20250609185526029

hook 0x911C4,获取源地址。

image-20250609185748505

再在0xe4fff644下写断点,看看哪里往里写。

image-20250609185904231

偏移0x9eddc。

image-20250609190228611

在0x9edc4下断点,查看x24和x8.

image-20250609190344879

在0x123df100下写断点。

image-20250609190715406

偏移0x91c84。

image-20250609191030509

在0x91C80下断点,查看src。

image-20250609191128512

在0x123dc070下个写断点。

image-20250609191218148

偏移0x9aab8,之前分析第1-2字节的时候,遇到过这个偏移。

image-20250609191250304

密钥来源于Q1、Q0的16字节异或的结果。

image-20250609191321658

在0x9AAB4下断点,查看Q0、Q1的值。

image-20250609200150702

一共经历了6次地址0x9AAB4。

第1轮异或的结果是0x4,是我们之前想探究的2个字节的来源。

第2、3、4、5轮异或的结果合在一块,是密钥的64字节。

第6轮异或的结果像是PKCS7填充方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 1
q0=0x3966170766667a666108020434320135
q1=0x3966170766667a666108020434320131
// 结果
q0=0x4

// 2
q0=0xbf8a86b75c5aa6988a18500d97aec16e
q1=0x50bec8edbd2742680599808777bbc932
// 结果
q0=0xef344e5ae17de4f08f81d08ae015085c

// 3
q0=0x57ef40c30996ef5928798ead0a1f8562
q1=0xec20f480b8926a4f70102b98be7c3726
// 结果
q0=0xbbcfb443b10485165869a535b463b244

// 4
q0=0x23cec11b0d7991d1bd27b2b6ecfdc56
q1=0xf3534ed0bbd8cca6375d11c92969a958
// 结果
q0=0xf16fa2c10b0f55bb2c8f6ae247a6750e

// 5
q0=0xf78b842c37084a18aebc2f6959061f3f
q1=0x90b33fff15af31903dfd7142926a0e75
// 结果
q0=0x6738bbd322a77b8893415e2bcb6c114a

// 6
q0=0x82a9df8040ac9909aee06a8949973ab0
q1=0x92b9cf9050bc8919bef07a9959872aa0
// 结果
q0=0x10101010101010101010101010101010

注意到,x19里面的地址,都是从x24里面取出来的。

image-20250609200122406

x24指向的内容如下。

image-20250609200009519

监控一下0x120b4000。

image-20250609201052310

发现监控不到。。。

在IDA的伪代码中进行追踪。

image-20250609201701983

image-20250609201718228

image-20250609201736475

打印一下result。

image-20250609201816219 image-20250609201833193

后面发现,这里的x24源于hmac_main的值,hmac_main进行base64解码后的内容和它一模一样。

image-20250609200009519

前面0x40字节在异或后是hmac-md5的密钥,而0x40->0x50字节抑或后是pkcs7填充的内容。

再对x22指向的0xe4fff148进行监控。(Q0)

之前追踪过0x9a064,找不到结果,这回追踪LR。

image-20250609205604237

写的操作都发生在sub_994AC中,sub_994AC累积执行了6次。

image-20250609205746004

偏移0x9a9cc位于函数sub_9A728,对这个函数分析,发现这个函数实现了一个对称分组密码加密和解密操作,使用模式是CBC。

if(a6)分支实现了CBC加密模式。

image-20250609210746105

if(a3)分支实现了CBC解密模式。

image-20250609210816395

根据分析,a5是IV,即初始化向量(盐),这是之前Q1的值

image-20250609211022402

image-20250609211109670

hook函数sub_994AC,查看它的入参。

注意到,第1次执行,对“32 C9 BB 77 87 80 99 05 68 42 27 BD ED C8 BE 50”解密,获得了“35 01 32 34 04 02 08 61 66 7A 66 66 07 17 66 39”,而“35 01 32 34 04 02 08 61 66 7A 66 66 07 17 66 39”正是0x9AAB4第一轮异或的Q0!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
第1次执行
X0
32 C9 BB 77 87 80 99 05 68 42 27 BD ED C8 BE 50
X1
58 97 27 12 0A 00 00 00 00 00 00 00 00 00 00 00
返回值(第2次执行时的X1)
35 01 32 34 04 02 08 61 66 7A 66 66 07 17 66 39

第2次执行
X0
26 37 7C BE 98 2B 10 70 4F 6A 92 B8 80 F4 20 EC
X1
35 01 32 34 04 02 08 61 66 7A 66 66 07 17 66 39
返回值(第3次执行时的X1)
6E C1 AE 97 0D 50 18 8A 98 A6 5A 5C B7 86 8A BF

第3次执行
X0
58 A9 69 29 C9 11 5D 37 A6 CC D8 BB D0 4E 53 F3
X1
6E C1 AE 97 0D 50 18 8A 98 A6 5A 5C B7 86 8A BF
返回值(第4次执行时的X1)
62 85 1F 0A AD 8E 79 28 59 EF 96 09 C3 40 EF 57

第4次执行
X0
75 0E 6A 92 42 71 FD 3D 90 31 AF 15 FF 3F B3 90
X1
62 85 1F 0A AD 8E 79 28 59 EF 96 09 C3 40 EF 57
返回值(第5次执行时的X1)

第5次执行
X0
A0 2A 87 59 99 7A F0 BE 19 89 BC 50 90 CF B9 92
X1
56 DC CF 6E 2B 7B D2 1B 1D 99 D7 B0 11 EC 3C 02
返回值(第6次执行时的X1)
3F 1F 06 59 69 2F BC AE 18 4A 08 37 2C 84 8B F7

第6次执行
X0
9B 48 8F D1 39 69 CA C2 54 E2 0A F2 0C 1C 4C 98
X1
3F 1F 06 59 69 2F BC AE 18 4A 08 37 2C 84 8B F7
返回值
b0 3a 97 49 89 6a3 e0 ae 09 99 ac 40 80 df a9 82

sub_9A728采用了CBC模式,而函数sub_994AC每次处理又是16字节,这大概率是一个AES加解密。sub_994AC的第一个参数的待解密的16字节,第二个参数存放结果,第三个参数是轮密钥。

s.xml文件里存放的是密文。

image-20250609200009519

第1个16字节作为密文0,在AES解密后生成中间值[0],中间值[0]就是35 01 32 34 04 02 08 61 66 7A 66 66 07 17 66 39,中间值[0]再与盐IV “31 01 32 34 04 02 08 61 66 7A 66 66 07 17 66 39”进行异或,生成明文[0]——0x4。

所以说,这里的明文0x4是固定的。

image-20250610101307202

第2个16字节作为密文1,在AES解密后生成中间值[1],中间值1就是6E C1 AE 97 0D 50 18 8A 98 A6 5A 5C B7 86 8A BF,中间值[1]再与密文0(32 C9 BB 77 87 80 99 05 68 42 27 BD ED C8 BE 50)进行异或,生成明文[1]——0xef344e5ae17de4f08f81d08ae015085c。

image-20250610101528397

…以此类推,最后获得的内容是:

1
2
3
4
5
6
7
8
9
10
11
// 明文[0],固定字节
q0=0x4

// 明文[1-4],用作HMAC-MD5的密钥
q0=0xef344e5ae17de4f08f81d08ae015085c
q0=0xbbcfb443b10485165869a535b463b244
q0=0xf16fa2c10b0f55bb2c8f6ae247a6750e
q0=0x6738bbd322a77b8893415e2bcb6c114a

// PKCS7填充
q0=0x10101010101010101010101010101010

也就是说,xhs服务器那边发来的xml文件,其实藏了0x4和MD5的密钥。

接下来还有2个疑惑AES是否魔改?密钥是什么?

为了解答疑惑,需要追踪函数sub_994AC,这个函数专门负责AES解密,难点在于,这个函数与魔改MD5一样,频繁通过BR跳转到别的函数,代码片段零零散散。

image-20250610104537106

根据分析,每一次执行sub_994AC,最终都会调用下面这些函数。

0-3次循环都一样,第4次循环出现变化;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
994AC
9a260 // 0-3字节:轮密钥加
99e6c // 4-7字节:轮密钥加
9a40c // 8-11字节:轮密钥加
9a574 // 12-15字节:轮密钥加
995e8
99c40<----循环0
996d8 // 查表优化
9a620 // 查表优化
99a7c
99564
9a6ac
9a2cc
9991c<>
9a0a0
9a364
99898
9978c
9a160
99ee0
99f74
9969c
99c40<----循环1
996d8
9a620
99a7c
99564
9a6ac
9a2cc
9991c<>
9a0a0
9a364
99898
9978c
9a160
99ee0
99f74
9969c
99c40<----循环2
996d8
9a620
99a7c
99564
9a6ac
9a2cc
9991c<>
9a0a0
9a364
99898
9978c
9a160
99ee0
99f74
9969c
99c40<----循环3
996d8
9a620
99a7c
99564
9a6ac
9a2cc
9991c<>
9a0a0
9a364
99898
9978c
9a160
99ee0
99f74
9969c
99c40<----循环4
996d8
9a620
99a7c
99564
9a6ac
9a2cc
9991c<>
9a4a0<差异点>
9984c
9a00c

同时,之前提到的0x9a064位于函数sub_9A00C内部,0x9a064会在每次循环中,会修改下图的X22寄存器指向的地址,这间接改变了Q0的内容,而Q0指向解密后的中间值(之所以称作中间值,是因为CBC模式下,中间值还要和上一轮密文进行异或,才能得到最后的明文),这说明了执行到sub_9A00C的时候,已经完成了AES解密。

image-20250605222117031

开始分析上述函数——从sub_9A260开始。

hook函数sub_994AC的时候,第3个参数是轮密钥。

image-20250610150634104

hook函数sub_9A260,发现下面的情况。

image-20250610151800401

这说明sub_9A260在对密文进行“轮密钥加”。

image-20250610151117804

初始密钥

加密与解密的流程是反过来的,既然a3是轮密钥,可以倒推回初始密钥。

image-20250610180411680

在0xE4FFF398下一个写断点,观察到偏移0x981b4。

image-20250610181026760

image-20250610181924741

在偏移0x981A0处下一个断点,观察x2+x8。

image-20250610182010991

往0xe4fff2f8下一个写断点。

image-20250610182136699

偏移0x96a44,位于函数sub_96A14中。

image-20250610182337520

hook一下函数sub_96A14,打印a1,a1是device_id的前16字节。

image-20250610182455586

因此,aes密钥的种子是device_id。

解密——Td表(未改动)

稍微分析了一下,AES解密采取的方式是查T表,查表的话AES的解密速率会快很多,但没有一般的特征了(行位移、字节替换等),所以不好分析。

image-20250610161717758

来到函数sub_996d8,0x6a5fcc9b是标准Td[0]表的4个字节,打印一下T表。

image-20250610162437314

和标准的Td[0]表一模一样。

image-20250610164121960

另外3个表会基于Td[0]进行生成,简单来说就是对基础表中的每一个32位数值执行字节的循环右移,因此可以根据Td[0]生成另外3个T表。经过对比,4个T表都是标准的。

密钥扩展

这里,我们为了追踪密钥扩展的函数,可以追踪初始密钥。

追踪sub_96A14的执行流,寻找对device_id进行处理加密,最终获得初始密钥的位置。

根据分析,在函数sub_96A14、sub_95A3C、sub_97100、sub_960F8中,完成了对初始密钥的构建。

image-20250610194708525

已知,初始密钥是W[3:0],是根据device_id异或4个固定的4字节生成的;(0-3轮密钥)

而接下来的第4-39轮密钥是根据查表的方式生成的。

image-20250610200719014

image-20250610200923629

在日志中,发现了一个规律——最左边框框里面的轮密钥,统一由函数sub_980ac查表获得;左2的框框统一由函数sub_97490查表获得,左3的框框统一由函数sub_97980获得、左4的框框统一由sub_973a8查表获得。

image-20250610204851290

image-20250610204754405

image-20250610204723149

image-20250610204634679

这4个函数都只执行9次,生成了W[39:4],此时,还未知W[43:40]的生成方式,但应该是正常的标准扩展方式。

正常的密钥扩展如下图所示。

image-20250610185453688

我们在日志中搜索W[43:40]的轮密钥——比如:搜索0x1070c115,然后来到第1次出现的地方。注意到,eor的w9和w12中并没有出现0xf0106B39,如果是标准的扩展算法,应该会出现0xf0106B39的。

image-20250610211445431

image-20250610214036112

image-20250610214119895

image-20250610214218532

可以看到,T函数明显被修改了,除此之外,W[43:40]的结果本来是两个轮密钥异或的结果,但在上图中,由一个轮密钥和一个未知的4字节异或得到的。

密钥扩展中最后4轮密钥是如何生成的?涉及到查S盒、字节替换、Rcon等——需要交叉引用96b08、96d5c。

这里简单过一下,具体算法还原先不做,快秋招了,得做点其它的事情,补充一下开发能力QwQ,害,怎么感觉这么难呢,我好菜。

以W[40]为例,W[40] = *(_DWORD *)(a1 + 60) = *(_DWORD *)(a1 + 52) ^ *(_DWORD *)(a1 + 56);

image-20250610221100210

需要hook一下,获得a1+52、a1+44、a1+48等值。

image-20250610221500239

它们的值又来源于sub_97230。

image-20250610221602792

然后又来源于sub_97100。

image-20250610221712663

又可以追回到sub_96A14。

image-20250610221857418

大概意思是,W[43-40]与初始密钥相关,涉及S盒、字节交换、rcon。

具体关于逆T表的工作,等到以后吧,算法还原耗时但一定能解开,hhhhh。

总结

2025/0610/22:23,脑子有点晕,希望能早点找到工作。

参考

jidong大佬的公众号。