某咖啡app协议字段分析——sign、q

分析流程

首先打开JEB观察分析结果,看到存在com.qihoo.util,说明是数字壳。

从Manifest可以知道,包名是com.lucky.luckyclient。

接下来先进行脱壳,我之前刷了一个aosp10的脱壳机,正好试试。

脱壳

将app装入脱壳机,在某个指定文件中输入该app的包名,等待脱壳完毕即可。

image-20250519151242871

将脱完后的内容放入jadx,已经可以看到很多java层的代码了。

image-20250519152942187

接下来尝试抓包,我抓包工具还挺多的,reqable或Charles都行,试试Charles好了。

抓包

先观察电脑ip。

image-20250519154135717

将手机连上同一个局域网,并将代理设置为主机的ip,端口则填Charles监听的端口。——因此,代理地址是192.168.1.188:9999。

image-20250519154503684 image-20250519154806363

要分析的这个请求是为了获得AuthCode(授权码),根据网上的博客,接下来开始分析signq字段的加密流程。

image-20250519160845900

分析加密流程

sign

在jadx直接搜索sign,观察是否能直接找到关于字段sign的代码,事实证明,并不可以。

image-20250519161524126

一般在开发中,存储字段会使用到标准sdk的HashMap,因此,接下来可以hook HashMap的put,观察进程是否会调用HashMap.put将sign字段存储起来。

1
2
3
4
5
6
7
8
9
10
11
function call_HashMap() {
Java.perform(function () {
var hashMap = Java.use("java.util.HashMap");
hashMap.put.implementation = function (a, b) {
if (a != null && a.equals("sign")) { console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()))
console.log("hashMap.put: ", a, b);
}
return this.put(a, b);
}
})
}

打印的结果如下。

image-20250519162507367

很自然地,通过调用方法栈,我们下一个分析的点就是 getRequestParams

com.lucky.lib.http2.AbstractLcRequest.getRequestParams 拿到jadx中搜一搜。

image-20250519162956867

结合我们之前打印的结果,StubApp.getString2 在把字符串解密后,返回值字符串。

hook一下,查看返回值,代码如下。

1
2
3
4
5
6
function get_str(num){
Java.perform(function(){
const StubApp = Java.use("com.stub.StubApp");
console.log("[num] ", num, " -> ", "[decrypt_str] ", StubApp.getString2(num));
})
}

打印的结果如下。

image-20250519164034161

在jadx中加点注释。

image-20250519164527033

我们的目标是 sign,它的值是这样得来的,也就是说,要先获得cid、uid、q的值。

1
C9267r.m20563a(cid, uid, q2)

直接写脚本获取cid、uid、q,我们的目标是分析 sign 的加密流程。

1
2
3
4
5
6
7
8
9
10
Java.perform(function() {
const R = Java.use("com.lucky.lib.http2.r");
R.a.implementation = function(cid, uid, q){
console.log("[cid] ", cid);
console.log("[uid] ", uid);
console.log("[q] ", q);
var res = this.a(cid, uid, q);
return res;
}
});

执行后报错了,说是找不到类 com.lucky.lib.http2.r,可能是因为Frida使用的类加载器不对?经过尝试,这个类并不是一开始就加载的类,类未加载的时候hook不上。

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
function get_cid_uid_q(){
Java.perform(function() {
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
// 尝试使用当前 loader 加载类,不要设置全局 loader
const R = Java.use("com.lucky.lib.http2.r", { classLoader: loader });
// console.log("[SUCCESS] Found com.lucky.lib.http2.r with loader: " + loader);

R.a.overload('java.lang.String', 'java.lang.String', 'java.lang.String').implementation = function(cid, uid, q) {
console.log("[Hooked R.a] Reached!");
console.log("[cid] ", cid);
console.log("[uid] ", uid);
console.log("[q] ", q);

// 暂时不要调用原始方法,先看hook本身是否稳定
//console.log("Original method R.a will not be called for now to test stability.");
var res = this.a(cid, uid, q);
console.log("[res from " + loader + "] ", res);
return res;

// 根据原始方法的返回类型返回一个默认值,如果是void则不需要返回
// 如果不知道返回类型,可以暂时返回 null
return null;
};
// 如果希望只hook一次,可以在这里添加逻辑停止枚举或标记已hook
} catch (e) {
console.log("[FAIL] Could not find com.lucky.lib.http2.r with loader: " + loader);
// 如果错误不是 ClassNotFoundException,打印出来看看
if (!e.message.includes("java.lang.ClassNotFoundException")) {
console.error("Error while trying to use class with loader " + loader + ": " + e);
}
}
},
onComplete: function() {
console.log("Class loader enumeration complete.");
}
});
});
}

setImmediate(get_cid_uid_q);

等待一段时间,等到类加载了再进行hook即可。

image-20250519183249268

cid、uid基本是不变的(除非换账号、删缓存),唯一会变的是q。

image-20250519183502080

之后分析的时候,需要将q固定,这里先不动它。

点进 C9267r.m20563a(cid, uid, q2) 查看,大致逻辑可以总结成下面这个式子,key和value代入uid、cid、q即可。

1
C9247c.f237976b.m20087a("<key>=<value>;<key>=<value>;<key>=<value>;");

image-20250519184802371

点进 C9247c.f237976b.m20087a 查看。

image-20250519191252914

观察 this.f237669e.mo15913a ,this代表对象,f237669e 是this对象的接口对象,通过这个接口对象调用接口中声明的方法。

image-20250519191500562 image-20250519191524953

如果想hook一个接口或者抽象方法,需要找到实现的地方进行hook,这跟frida是如何hook java层方法的原理有关。——简单来说,一个Java方法对应一个ArtMethod,我们要找到实现Java方法的ArtMethod,抽象方法和接口都没有实际的CodeItem。

因此,我们要找到实现了这个接口的类,但是,这又有一个问题,CryptoHelper似乎并没有继承其它的类,没有间接实现接口的可能;那还有两个可能:一是赋值的时候用匿名类,在赋值的时候,同时实现了接口;二是定义一个实现好了接口的类,然后作为参数传能够给赋值 f237669e 的方法。

image-20250519192312985

交叉引用这个接口对象 f237669e,可以注意到,只有构造函数方法m20086a可能为接口对象赋值了。

image-20250519192915818

交叉引用方法m20086a,会发现:没有任何被Java层代码调用的地方,那这个函数可能是在native层被调用了(通过FindClass、CallStaticMethod等函数调用),所以在jadx中查不到。

接着交叉引用构造函数,这回有收获了。

image-20250519193321999

分别去查看C8118a、C9123b等类,查看它们关于接口的实现方式,结果如下。

C8118a的实现。

image-20250519193455565

可以注意到,有点像base64编码(但标准的base64没有_符号),去试试解码。——试了后发现,都是不可显示的字符,用Hex也没看出什么特征。

image-20250519195428064

之后发现,这里面的_需要换成/,不过之后再说吧。

C9123b的实现。

image-20250519193855028

C9234b的实现有点绕。

继续追踪f237975a。

image-20250519194259042

根据交叉引用,它只会通过方法m20437c得到值,因此继续追踪方法m20437c。

image-20250519194422403

查看成员变量f238026j,进行交叉引用。

image-20250519194501863

发现它只会在方法m20538g进行调用,继续交叉引用。

image-20250519194551662

image-20250519194632237

因此,C9234b的实现是这样的。

image-20250519194736625

C11110a的实现。

image-20250519195626309

C11112c的实现

image-20250519195706646

回到方法 m20087a

md5_crypt( str.getBytes(), num),这里的num是0/1/2中的一个,大概率是1,因为根据上述解密,似乎其它的类似base64编码的字节码,在解码后是不可视的字节码?无法和coffee、tea进行比较。

image-20250519201707502

这里的md5_crypt是native函数,不妨hook一下,看看i2的值是什么。

image-20250519202206855

代码如下。

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
function hook_md5_crypt(){
Java.perform(function() {
const CryptoHelper = Java.use("com.luckincoffee.safeboxlib.CryptoHelper");

CryptoHelper.md5_crypt.implementation = function(arr, i2){
console.log(`--- md5_crypt called ---`);
console.log(`[i2] ${i2}`);

console.log(`[bArr] byte array of length ${arr.length}`);

// 遍历字节数组,并尝试转换为ASCII字符
let hexString = '';
let asciiString = '';
const bytesPerLine = 16; // 每行显示的字节数,方便查看

for (let i = 0; i < arr.length; i++) {
const byte = arr[i];
// 将有符号字节转换为无符号整数 (0-255)
const unsignedByte = byte & 0xff;

// 将字节值转换为十六进制字符串,方便对比
hexString += unsignedByte.toString(16).padStart(2, '0') + ' ';

// 判断是否为可打印的ASCII字符 (通常范围是 32 到 126)
if (unsignedByte >= 32 && unsignedByte <= 126) {
asciiString += String.fromCharCode(unsignedByte);
} else {
// 如果不是可打印字符,用点或其他符号代替
asciiString += '.';
}

// 每隔 bytesPerLine 打印一行,或者在数组结束时打印剩余部分
if ((i + 1) % bytesPerLine === 0 || i === arr.length - 1) {
// 为了对齐,给较短的行添加空格填充
while (hexString.length < bytesPerLine * 3) { // 2 chars + 1 space per byte
hexString += ' ';
}
console.log(` ${hexString} | ${asciiString}`);
hexString = '';
asciiString = '';
}
}

console.log(`--- Calling original md5_crypt ---`);
// 调用原始的 native 方法并返回结果
const result = this.md5_crypt(arr, i2);
console.log(`--- Original md5_crypt returned ---`);

return result;
};
});
}

setImmediate(hook_md5_crypt);

如果多截几张图,就会发现,i2基本恒为1,符合我们的猜测,bArr数组其实就是cid、q、uid放在一块。

image-20250519210039726

接下来进入native层分析md5_crypt对应的函数,将libcryptoDD.so放入IDA中。

image-20250519210317230

进来后,先来看看导出表,我们的目标大概率是android_native_md5。

同时,发现存在很多.datadiv_decodexxxxxx的函数,应该是解密字符串用的。

image-20250519210441038

基本都在.init_array段上,先不着急分析了,先写个脚本,将解密后的so文件进行dump,这样便于之后的分析。

image-20250519210536785

最后一个datadiv函数的地址是0x1B61C。可以在hook这个函数的onLeave回调进行dump。

我靠,我写好了脚本,尝试了好多遍为什么一dump就“非法指令”退出,突然想起来,”libcryptoDD.so”是32位的so,不仅要修改linker64 -> linker,还需要修改call_constructor的偏移量。

但似乎改了后,还是会崩溃,那就应该是有反调了,但我没注意反调在哪,跑了挺久,也没退出,可能是对某些关键函数的hook做了检测吧,之后再去找找看。

image-20250519220328748

既然这样,不在第一时间dump了,等so文件稳定了再dump。

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
var module_name = "libcryptoDD.so"; // 可选:目标库名称,用于过滤

function hook_dlopen() {
var dlopenPtr = Module.findExportByName(null, "dlopen");
if (!dlopenPtr) {
console.error("[!] dlopen not found.");
return;
}
console.log("[+] Hooking dlopen at " + dlopenPtr);
Interceptor.attach(dlopenPtr, {
onEnter: function(args) {
var libraryPath = Memory.readUtf8String(args[0]);
console.log("[dlopen] Loading library: " + libraryPath);
if (libraryPath.includes(module_name)) {
console.log("[!] Target library " + module_name + " loaded, exiting...");
Process.exit(0);
}
}
});
}

function hook_android_dlopen_ext() {
var androidDlopenExtPtr = Module.findExportByName(null, "android_dlopen_ext");
if (!androidDlopenExtPtr) {
console.error("[!] android_dlopen_ext not found.");
return;
}
console.log("[+] Hooking android_dlopen_ext at " + androidDlopenExtPtr);
Interceptor.attach(androidDlopenExtPtr, {
onEnter: function(args) {
var libraryPath = Memory.readUtf8String(args[0]);
var flags = args[1].toInt32();
console.log("[android_dlopen_ext] Loading library: " + libraryPath + ", flags: 0x" + flags.toString(16));
if (libraryPath.includes(module_name)) {
console.log("[!] Target library " + module_name + " loaded, exiting...");
Process.exit(0);
}
}
});
}

function main() {
Java.perform(function() {
hook_dlopen();
hook_android_dlopen_ext();
console.log("[*] Hooking dlopen and android_dlopen_ext initialized.");
});
}

function dump_so(so_name){
let secmodule = Process.findModuleByName(so_name);
console.log("[name] ", so_name);
console.log("[base] ", secmodule.base.toString(16));
console.log("[size] ", secmodule.size.toString(16));
dump(secmodule.base, secmodule.size);

}

function dump(begin_addr, dump_size){
// console.log("[name] ", module_name);
// console.log("[base] ", begin_addr);
// console.log("[size] ", "0x" + dump_size.toString(16));
var file_path = "/data/data/com.lucky.luckyclient/zzc_0x" + begin_addr.toString(16) + "_0x" + dump_size.toString(16) + ".so";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(begin_addr), dump_size, 'rwx');
var libso_buffer = ptr(begin_addr).readByteArray(dump_size);
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump] ", file_path);
}
}

setImmediate(main);

执行结果如下图所示。

image-20250519221122233

然后将它pull出来,用修复工具修复(别用soFixer,可能导入表是乱的),展示一下修复后两个so文件的区别,明显可以看到,有一些字符串已经解密了。

image-20250519222654556

继续分析这个so文件,写一个脚本hook上RegisterNatives,判断一下md5_crypt对应的native函数地址是哪个。

还没打印到那一步,就已经退出了。

我尝试hook了libc标准库的常见函数,如:open、openat、strstr等函数,没发现对frida检测。

image-20250519223425284

还有一个问题,无法实现对call_constructor的hook,根据调用栈,会发现报错源来自frida的agent,是frida版本的问题吗?

image-20250520101558428

之后再来分析,先去分析数字免费壳,再来分析这个付费壳。

image-20250520132734558

之后我发现,换了一个方式hook RegisterNatives就能打印md5_crypt的注册地址了?

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
// 获取 RegisterNatives 函数的内存地址,并赋值给addrRegisterNatives。
function main(){
var addrRegisterNatives = null;
var symbols = Module.enumerateSymbolsSync("libart.so");
for (var i = 0; i < symbols.length; i++) {
var symbol = symbols[i];
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {

addrRegisterNatives = symbol.address;
console.log("RegisterNatives is at ", symbol.address, symbol.name);
break
}
}
if (addrRegisterNatives) {
Interceptor.attach(addrRegisterNatives, {
onEnter: function (args) {
var env = args[0]; // jni对象
var java_class = args[1]; // 类
var class_name = Java.vm.tryGetEnv().getClassName(java_class);
var taget_class = "com.luckincoffee.safeboxlib.CryptoHelper"; //111 某个类中动态注册的so
if (class_name === taget_class) {
console.log("\n[RegisterNatives] method_count:", args[3]);
var methods_ptr = ptr(args[2]);
var method_count = parseInt(args[3]);
for (var i = 0; i < method_count; i++) {
// Java中函数名字的
var name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
// 参数和返回值类型
var sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
// C中的函数内存地址
var fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));
var name = Memory.readCString(name_ptr);
var sig = Memory.readCString(sig_ptr);
var find_module = Process.findModuleByAddress(fnPtr_ptr);
// 地址、偏移量、基地址
var offset = ptr(fnPtr_ptr).sub(find_module.base);
console.log('class_name:',class_name,"name:", name, "sig:", sig,'module_name:',find_module.name ,"offset:", offset);
}
}
}
});
}
}

setImmediate(main)
// 动态注册函数地址
// frida -U -f com.lucky.luckyclient -l hook_so_register.js

原先的脚本如下,区别在于:上面这个脚本在RegiterNatives的地方加了个taget_class的限制,那问题应该是注册了太多函数,导致打印崩溃了(?),先不管,之后分析壳的时候再看。

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
function find_RegisterNatives(params) {
let symbols = Module.enumerateSymbolsSync("libart.so");
let addrRegisterNatives = null;
for (let i = 0; i < symbols.length; i++) {
let symbol = symbols[i];

//_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {
addrRegisterNatives = symbol.address;
console.log("RegisterNatives is at ", symbol.address, symbol.name);
hook_RegisterNatives(addrRegisterNatives)
}
}

}

function hook_RegisterNatives(addrRegisterNatives) {

if (addrRegisterNatives != null) {
Interceptor.attach(addrRegisterNatives, {
onEnter: function (args) {
console.log("[RegisterNatives] method_count:", args[3]);
let java_class = args[1];
let class_name = Java.vm.tryGetEnv().getClassName(java_class);
//console.log(class_name);

let methods_ptr = ptr(args[2]);

let method_count = parseInt(args[3]);
for (let i = 0; i < method_count; i++) {
let name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
let sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
let fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));

let name = Memory.readCString(name_ptr);
let sig = Memory.readCString(sig_ptr);
let symbol = DebugSymbol.fromAddress(fnPtr_ptr)
console.log("[RegisterNatives] java_class:", class_name, "name:", name, "sig:", sig, "fnPtr:", fnPtr_ptr, " fnOffset:", symbol, " callee:", DebugSymbol.fromAddress(this.returnAddress));
}
}
});
}
}

setImmediate(find_RegisterNatives);

打印的结果如下,md5_crypt对应着的地址是sub_1a981。

image-20250520135811852

我开了两个IDA对照着看,因为dump下来的文件虽然恢复了某些代码,但IDA的反汇编的伪代码也变了,没原来的so好读。

根据对sub_1a981的分析,发现输入数据输入数据的长度在sub_13E3C进行调用,sub_13E3C的函数名是md5。

image-20250520144716225

v24很有可能就是md5的返回值,可以写个脚本试试。

1
2
3
4
5
6
7
8
9
10
11
12
13
function hook_md5(){
var module_base = Module.findBaseAddress("libcryptoDD.so");
Interceptor.attach(module_base.add(0x13E3c), {
onEnter: function(args){
this.data = args[0];
this.data_len = args[1];
this.digest = args[2];
console.log("[data]:\n", hexdump(args[0], {length: args[1]}));
}, onLeave: function(retval){
console.log("[digest]:\n", hexdump(args[2], {length: 64}));
}
});
}

肯定是有反调,不然这种程度的hook,不至于引起进程崩溃。

image-20250520162306897

接下来用unidbg模拟执行。

代码如下,从这套代码中,可以学会如何下断点(甚至是动调),如何模拟一个native层函数的执行。下断点的方式有2种,一是Unidbg提供的接口断点,二是Unicorn提供的断点,代码里都有涉及。

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
package com.luckincoffee.safeboxlib;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Emulator;
import com.github.unidbg.Module;
import com.github.unidbg.arm.backend.Unicorn2Factory;
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.array.ByteArray;
import com.github.unidbg.linux.android.dvm.jni.ProxyClassFactory;
import com.github.unidbg.memory.Memory;
import com.github.unidbg.utils.Inspector;
import com.github.unidbg.arm.context.RegisterContext;
import com.github.unidbg.debugger.BreakPointCallback;
import com.github.unidbg.debugger.Debugger;
import com.sun.jna.Pointer;
import unicorn.ArmConst;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class CryptoHelper extends AbstractJni {
private final AndroidEmulator emulator;
private final DvmClass cCryptoHelper;
private final VM vm;
private final Module module;

public CryptoHelper() {
emulator = AndroidEmulatorBuilder.for32Bit()
.addBackendFactory(new Unicorn2Factory(true))
.setProcessName("com.lucky.luckyclient")
.build();
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM(new File("../file/rx.apk"));
vm.setJni(this);
vm.setDvmClassFactory(new ProxyClassFactory());
vm.setVerbose(true);
DalvikModule dm = vm.loadLibrary(new File("unidbg-android/src/test/resources/example_binaries/armeabi-v7a/libcryptoDD.so"), true);
module = dm.getModule();
cCryptoHelper = vm.resolveClass("com/luckincoffee/safeboxlib/CryptoHelper");
dm.callJNI_OnLoad(emulator);
}

private void md5_hook() {
Debugger debugger = emulator.attach();

// Hook md5 function at 0x13E3C: md5(indata_jarray, initial_len, v25)
debugger.addBreakPoint(module.base + 0x14D6E, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
// System.out.println("Breakpoint hit: md5 at 0x" + Long.toHexString(address));
// RegisterContext context = emulator.getContext();
//
// // Parameters: ARM32 registers R0-R2
// long jarray = context.getLongArg(0); // R0: indata_jarray (jbyteArray)
// long initial_len = context.getLongArg(1); // R1: initial_len (jint)
// long v25 = context.getLongArg(2); // R2: v25 (pointer, possibly jbyteArray)
//
// System.out.println("md5 Parameters: jarray=0x" + Long.toHexString(jarray) +
// ", initial_len=" + initial_len +
// ", v25=0x" + Long.toHexString(v25));
//
// // Parse input jbyteArray
// if (jarray != 0) {
// ByteArray byteArray = (ByteArray) vm.getObject((int) jarray);
// if (byteArray != null) {
// Inspector.inspect(byteArray.getValue(), "Input jbyteArray");
// }
// }
//
// // Parse v25 (assuming it's a pointer to output buffer)
// if (v25 != 0) {
// Pointer cipherText = context.getPointerArg(2);
// Inspector.inspect(cipherText.getByteArray(0, 32), "cipherText");
// }
//
// // Continue execution, return false to stop at breakpoint for manual debugging
return false;
}
});

// debugger.addBreakPoint(module.base + 0x1AB92, new BreakPointCallback() {
// @Override
// public boolean onHit(Emulator<?> emulator, long address) {
// System.out.println("Breakpoint hit: END at 0x" + Long.toHexString(address));
// return true;
// }
// });
//
// // Hook doMD5sign function at 0x14D54: doMD5sign(v41, initial_len + 20, &v53)
// debugger.addBreakPoint(module.base + 0x14D54, new BreakPointCallback() {
// @Override
// public boolean onHit(Emulator<?> emulator, long address) {
// System.out.println("Breakpoint hit: doMD5sign at 0x" + Long.toHexString(address));
// RegisterContext context = emulator.getContext();
//
// // Parameters: ARM32 registers R0-R2
// long v41 = context.getLongArg(0); // R0: v41 (possibly jbyteArray)
// long len = context.getLongArg(1); // R1: initial_len + 20 (jint)
// long v53 = context.getLongArg(2); // R2: &v53 (pointer to output buffer)
//
// System.out.println("doMD5sign Parameters: v41=0x" + Long.toHexString(v41) +
// ", len=" + len +
// ", v53=0x" + Long.toHexString(v53));
//
// // Parse v41 (assuming it's a jbyteArray)
// if (v41 != 0) {
// ByteArray byteArray = (ByteArray) vm.getObject((int) v41);
// if (byteArray != null) {
// Inspector.inspect(byteArray.getValue(), "Input v41 (jbyteArray)");
// }
// }
//
// // Parse v53 (output buffer)
// if (v53 != 0) {
// Pointer cipherText = context.getPointerArg(2);
// Inspector.inspect(cipherText.getByteArray(0, 32), "cipherText");
// }
//
// // Continue execution
// return true;
// }
// });

// // Add return hook using CodeHook to capture return value
// emulator.getBackend().hook_add_new(new com.github.unidbg.hook.HookListener() {
// @Override
// public void hook(unicorn.Unicorn u, long address, int size, Object user) {
// // Not used for entry hook in this case
// }
//
// @Override
// public void onReturn(unicorn.Unicorn u, long address, int size, Object user) {
// if (address == module.base + 0x13E3C) {
// long retValue = u.reg_read(ArmConst.UC_ARM_REG_R0).longValue();
// System.out.println("md5 returned: 0x" + Long.toHexString(retValue));
// if (retValue != 0) {
// ByteArray retByteArray = (ByteArray) vm.getObject((int) retValue);
// if (retByteArray != null) {
// Inspector.inspect(retByteArray.getValue(), "md5 return jbyteArray");
// }
// }
// } else if (address == module.base + 0x14D54) {
// long retValue = u.reg_read(ArmConst.UC_ARM_REG_R0).longValue();
// System.out.println("doMD5sign returned: 0x" + Long.toHexString(retValue));
// if (retValue != 0) {
// ByteArray retByteArray = (ByteArray) vm.getObject((int) retValue);
// if (retByteArray != null) {
// Inspector.inspect(retByteArray.getValue(), "doMD5sign return jbyteArray");
// }
// }
// }
// }
// }, module.base + 0x13E3C, module.base + 0x14D54, null);
}

private void call_md5() {
List<Object> args = new ArrayList<>(10);
args.add(vm.getJNIEnv());
args.add(0);
String my_bytes = "cid=1;uid=1;q=1;";
args.add(vm.addLocalObject(new ByteArray(vm, my_bytes.getBytes())));
args.add(1);
Number retNum = module.callFunction(emulator, 0x1a981, args.toArray());
ByteArray retByteArr = (ByteArray) vm.getObject(retNum.intValue());
String md5result = new String(retByteArr.getValue(), StandardCharsets.UTF_8);
System.out.println("md5 result = " + md5result);
}

private void call_hooked_function() {
List<Object> args = new ArrayList<>(10);
args.add(vm.addLocalObject(new ByteArray(vm, "test_hook".getBytes())));
args.add(16); // initial_len
args.add(vm.addLocalObject(new ByteArray(vm, new byte[32]))); // v25: empty buffer
Number retNum = module.callFunction(emulator, 0x13E3C, args.toArray());
ByteArray retByteArr = (ByteArray) vm.getObject(retNum.intValue());
if (retByteArr != null) {
String result = new String(retByteArr.getValue(), StandardCharsets.UTF_8);
System.out.println("md5 function result = " + result);
}
}

// private void call_doMD5sign() {
// List<Object> args = new ArrayList<>(10);
// args.add(vm.addLocalObject(new ByteArray(vm, "test_doMD5sign".getBytes())));
// args.add(34); // initial_len + 20
// args.add(vm.addLocalObject(new ByteArray(vm, new byte[32]))); // v53: empty buffer
// Number retNum = module.callFunction(emulator, 0x14D54, args.toArray());
// ByteArray retByteArr = (ByteArray) vm.getObject(retNum.intValue());
// if (retByteArr != null) {
// String result = new String(retByteArr.getValue(), StandardCharsets.UTF_8);
// System.out.println("doMD5sign function result = " + result);
// }
// }

public void destroy() throws IOException {
emulator.close();
}

public static void main(String[] args) {
CryptoHelper cryptoHelper = new CryptoHelper();
cryptoHelper.md5_hook();
cryptoHelper.call_md5();
// cryptoHelper.call_hooked_function();
// cryptoHelper.call_doMD5sign();
try {
cryptoHelper.destroy();
} catch (IOException e) {
e.printStackTrace();
}
}
}

注意,上述关于断点处的代码,return true(或者是false)代表着是否要动调,false代表着要动调,Unidbg会模拟程序执行到那,然后停住(有点类似于gdb),等待用户输入指令。

根据之前的分析,我们知道,md5_crypt对应的native层函数是 android_native_md5

根据对这个函数的分析,会发现传入的“cid=1;uid=1;q=1”(unidbg中固定了输入值)最终会传入函数 doMD5sign

image-20250520222934333

根据动调,比较java层得到的返回值和doMD5sign在函数退出时,digest指向的值,两者是一样的,因此这里的digest就是我们要的 android_native_md5 最后的返回值。不过注意,我们固定输入的initial_msg后,标准的md5返回值和digest里面的值是不一样的,这说明魔改了。

在doMD5sign中,可以发现还有个函数叫md5,对这个函数进行hook(frida用不了,用unidbg的hook),在函数执行前,打印寄存器r0、r1、r2的值(r2指向一块buf),然后在md5执行完后,查看buf的内容。

会发现,这个buf的内容是标准的md5结果。

操作如下——

固定输入。

image-20250520223622766

下断点。

image-20250520223708127

r0 = 0x12248000,r1 = 36,r2 = 0xe4fff5d8。

image-20250520223800781

查看r0指向的字符串,会发现除了固定的输入,还加了IV,因此r1是36,而非我们固定输入的长度。

image-20250520223851080

当前的mr2。

image-20250520223934899

给0x12014d72下断点,然后按c执行,继续跑。

image-20250520224006870

此时程序跑完了md5,再来看看buf里的内容。

image-20250520224201817

这里的9eed….正是标准的md5结果。

image-20250520224251421

接下来就是看doMD5sign是如何魔改的,魔改的主要逻辑在 bytesToInt。按顺序,每次操作md5结果的4个字节,将每个字节都放大了好多,然后最终通过操作,组成一个很大的数字,由于是signed int类型,有可能是负数,一旦是负数,就取绝对值。

image-20250520224616556

然后将取过绝对值的这个很大的数字化作字符串,存入缓冲区。——这里的byte_E0021指向字符串%d,可以在解密后的so文件看出来。

image-20250520224720896

image-20250520224859525

既然知道算法了,接下来就写个python脚本复现一下,这里踩了个坑,python的整型是很大的,因此一般不会出现负数,而c/c++中,32位的数若是很大,可能会溢出成负数,所以这里需要根据0x8000 0000判断当前得到的整型若是化成32位,是否是负数,若是,则转成正数,即减去0x1 0000 0000。

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
import hashlib

def bytesToInt(src, offset):
if len(src) < offset + 4:
raise ValueError(f"Source array too short for offset {offset}")

if v9_unsigned >= 0x80000000: # 检查是否是负数(在C语言中)
v9_signed = v9_unsigned - 0x100000000 # 转换为正确的补码负值
else:
v9_signed = v9_unsigned

return v9_signed

def doMD5sign(initial_msg):

# Calculate MD5 hash (16 bytes)
md5_hash = hashlib.md5(initial_msg).digest()

# Process 4 blocks (offsets 0, 4, 8, 12)
offsets = [0, 4, 8, 12]
strings = []

for offset in offsets:
raw_int_value = bytesToInt(md5_hash, offset) # 获取有符号的整数值

# 按照IDA伪代码的逻辑,在转为字符串前取绝对值
# 对应IDA伪代码中的 'if ( vX < 0 ) vY = -vX;'
abs_value = abs(raw_int_value)

# 将绝对值转换为字符串
strings.append(str(abs_value))
print(f"Offset {offset}: raw_int_value = {raw_int_value}, abs_value = {abs_value}")

# Concatenate strings
result = "".join(strings)

# Simulate malloc and qmemcpy
digest = result
length = len(digest)

print(f"Final digest: {digest}")
print(f"Length: {length}")

return length, digest

if __name__ == "__main__":
initial_msg = b"cid=1;uid=1;q=1;dJLdCJiVnDvM9JUpsom9"

try:
length, digest = doMD5sign(initial_msg)
except ValueError as e:
print(f"Error: {e}")

脚本跑完的结果如下。

image-20250520225434327

而Unidbg模拟的结果如下。

image-20250520225455464

至此,sign分析结束了。

ps:unidbg之前没怎么用过,这个动调、断点还挺好用的,之后整理一套使用手册。

q

用之前写的hook HashMap的脚本,试试能不能找到q的加密点。

1
2
3
4
5
6
7
8
9
10
11
function call_HashMap() {
Java.perform(function () {
var hashMap = Java.use("java.util.HashMap");
hashMap.put.implementation = function (a, b) {
if (a != null && a.equals("q")) { console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()))
console.log("hashMap.put: ", a, b);
}
return this.put(a, b);
}
})
}

看来跟sign一样,都是这么存储字段的。

注意到,字段q的最后一个字节是=,那很有可能是base64(猜测,base64没有_和-)。

image-20250520230417092

先从com.lucky.lib.http2.r.a看起,又是这个函数,需要追踪str3的值从何而来。

image-20250521095921734

写个脚本hook getRequestParams,并且hook AbstractC3710a.toJSONString,打印一下这个q2字符串的内容。

image-20250521102012343

这个类是之后加载的,需要加载之后再hook。

image-20250521102914062

打印的内容有些多。

image-20250521103024248

然后将内容传入方法 C9247c.m20436b,再传入 f237976b.m20089c

image-20250521104203303

f237976b.m20089c 中,走localAESWork4Api(str.getBytes(), 0)。

image-20250521104819109

localAESWork4Api是CryptoHelper中的JNI函数,也注册在libcryptoDD.so。

根据符号名,可以看出来是aes白盒加密。

image-20250521105621185

android_native_wbaes_jni 中,可以找到函数 wbaes_decrypt_ecb,说明是aes的ecb模式加密,ecb模式没有IV。

image-20250521115216953

虚假控制流有点多,要不直接trace算了?再等等好了。

本次逆向的主要目标,就是掌握AES加密及白盒AES的破解方法之一——DFA。

先根据这篇博客回顾一下,AES加密的流程。

https://xiaoeeyu.github.io/2024/02/29/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81-AES/

以AES128为例,原理简单来说,如下。

输入:128位明文 + 128位密钥。

密钥扩展:生成11个128位(4x4字节)轮密钥。

初始轮:明文与第一个轮密钥异或。

普通轮:执行9轮(SubBytes(查表替换) → ShiftRows(字节循环左移) → MixColumns(列混合,矩阵乘法) → AddRoundKey(密钥异或))。

最终轮:执行1轮(SubBytes(查表替换) → ShiftRows(字节循环左移) → AddRoundKey(密钥异或))。

输出:128位密文。

其中,查表替换的S盒是固定的,而且只有一个,通过脚本,搜索S盒的特征就可以找到了。

交叉引用SBox。

image-20250521130346431

有2个函数调用了SBox。

sub_63A0,很明显的SubBytes特征。

*result = *((_BYTE *)RijnDael_AES_LONG_SBox + (*result & 0xF0) + (*result & 0xF))

等同于:result[i] = SBox[result[i]]。

image-20250521130953155

还有一处是sub_5C58,这个特征不太明显阿。有点像SubBytes,又有点像移位操作。

这毕竟不是标准AES,属于白盒AE,拿到了代码也不知道密钥在哪。

1
2
3
4
int __fastcall sub_5C58(unsigned int a1)
{
return (*((unsigned __int8 *)&RijnDael_AES_LONG_SBox[4 * ((a1 >> 4) & 0xF000000F)] + (a1 & 0xF)) | (*((unsigned __int8 *)RijnDael_AES_LONG_SBox + ((a1 >> 8) & 0xF0) + ((a1 >> 8) & 0xFF00000F)) << 8) | (*((unsigned __int8 *)&RijnDael_AES_LONG_SBox[4 * ((a1 >> 20) & 0xFFFFF00F)] + (HIWORD(a1) & 0xFFFF000F)) << 16)) ^ (*((unsigned __int8 *)RijnDael_AES_LONG_SBox + (HIBYTE(a1) & 0xF0) + (HIBYTE(a1) & 0xF)) << 24);
}

接下来,学习其它的博客,试试如何获得白盒AES的密钥,下面这个文章把DFA原理讲得很明白。

https://blog.quarkslab.com/differential-fault-analysis-on-white-box-aes-implementations.html

总结一下我的理解:

找到第8轮列混淆之后、第9轮列混淆之前(第十轮没有列混淆),然后修改1个字节(比特翻转,或者多修改点),这个修改的影响会被第9轮列混淆进行扩大,扩大到最终影响了输出的4个字节。

根据数学关系,可以列4个等式。

O和O’都是已知的,Z代表S-盒输入的差(A+X,即A异或X),根据对Y的假设,可以找到可能满足4个等式的Y0、Y1、Y2、Y3的候选者。

image-20250521154218635

再通过Y的候选者和已知的O,去获得可能的K(轮密钥的某个字节)的候选者。

image-20250521154520520

搞清楚DFA的原理以后,第一步,找到第8轮列混淆之后、第9轮列混淆之前进行故障注入。

回到 android_native_wbaes,看看有没有什么特征函数。——找到了一个行位移的函数。为了确保它是我们要找的行位移函数,试试它有没有执行10次,并且打印v42的内容,判断是否每次都有行位移的操作。

通过Unidbg完成上述的操作。

image-20250521160530312

下述是代码片段,num是我添加的成员变量。

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
private void call_aes() {
List<Object> args = new ArrayList<>(10);
// JNIEnv*
args.add(vm.getJNIEnv());
// jclass
args.add(0);
// 固定输入
String my_bytes = "{\"type\":1}";
args.add(vm.addLocalObject(new ByteArray(vm, my_bytes.getBytes())));
// 模式
args.add(0);
// 函数android_native_wbaes的偏移量0x1b1cd
Number retNum = module.callFunction(emulator, 0x1b1cd, args.toArray());
ByteArray retByteArr = (ByteArray) vm.getObject(retNum.intValue());
// 打印
byte[] resultBytes = retByteArr.getValue();
StringBuilder hexString = new StringBuilder();
for (byte b : resultBytes) {
hexString.append(String.format("%02X", b & 0xFF));
}
System.out.println("q result (hex) = " + hexString.toString());
}

private void q_hook() {
Debugger debugger = emulator.attach();
// Hook wbShiftRows function at 0x14F98
debugger.addBreakPoint(module.base + 0x14F98, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
num += 1;
System.out.println("wbShiftRows has been called: " + num);
return true;
}
});
}

可以看到,打印了10次,符合我们的预期。

image-20250521163609731

尝试打印每一次执行行位移之前的状态矩阵。

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
private void q_hook() {
Debugger debugger = emulator.attach();
// Hook wbShiftRows function at 0x14F98
debugger.addBreakPoint(module.base + 0x14F98, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
num += 1;
System.out.println("wbShiftRows has been called: " + num);

// 获取寄存器上下文
RegisterContext context = emulator.getContext();
// R0 通常是第一个参数(状态矩阵的指针)
Pointer stateMatrix = context.getPointerArg(0);
if (stateMatrix != null) {
// 读取 16 字节状态矩阵
byte[] stateBytes = stateMatrix.getByteArray(0, 16);
// 转为十六进制
StringBuilder hexString = new StringBuilder();
for (byte b : stateBytes) {
hexString.append(String.format("%02X ", b & 0xFF));
}
System.out.println("wbShiftRows state matrix (hex): " + hexString.toString());
}

return true; // 继续执行
}
});
}
image-20250521163920393

下面是q正常情况下的结果。——2FF0E1B44B413D51A083E55259E2179F

image-20250521165448510

修改第9次行位移的某个字节,收集10次差分故障攻击的结果。

image-20250521165638163

收集了16个错误的结果。

2F1BE1B4E6413D51A083E56059E21A9F

2FF0E1A84B41C451A0B1E55215E2179F

E7F0E1B44B413DE3A0837A525924179F

2FF0A8B44B2F3D511A83E55259E217C6

DCF0E1B44B413DBEA08316525911179F

2F68E1B436413D51A083E5CF59E2769F

96F0E1B44B413DACA083AB5259EA179F

89F0E1B44B413DE7A0831C525944179F

42F0E1B44B413D1CA083D9525974179F

2FF07FB44B6E3D514683E55259E21747

17F0E1B44B413D6CA0838A5259F3179F

2FF069B44BAA3D510283E55259E21754

D8F0E1B44B413D36A0834452590B179F

8AF0E1B44B413D27A0839752591E179F

24F0E1B44B413DB1A0839C5259E9179F

2FF0E16A4B414151A0A4E55293E2179F

接下来使用大佬写的攻击,通过差分结果、正常结果,还原轮密钥,项目地址:

https://github.com/SideChannelMarvels/JeanGrey/tree/master/phoenixAES

编写脚本得到第10轮子密钥。

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
#!/usr/bin/env python3
import phoenixAES

with open('tracefile', 'wb') as t:
t.write("""
2FF0E1B44B413D51A083E55259E2179F
2F1BE1B4E6413D51A083E56059E21A9F
2FF0E1A84B41C451A0B1E55215E2179F
E7F0E1B44B413DE3A0837A525924179F
2FF0A8B44B2F3D511A83E55259E217C6
DCF0E1B44B413DBEA08316525911179F
2F68E1B436413D51A083E5CF59E2769F
96F0E1B44B413DACA083AB5259EA179F
89F0E1B44B413DE7A0831C525944179F
42F0E1B44B413D1CA083D9525974179F
2FF07FB44B6E3D514683E55259E21747
17F0E1B44B413D6CA0838A5259F3179F
2FF069B44BAA3D510283E55259E21754
D8F0E1B44B413D36A0834452590B179F
8AF0E1B44B413D27A0839752591E179F
24F0E1B44B413DB1A0839C5259E9179F
2FF0E16A4B414151A0A4E55293E2179F
""".encode('utf8'))

phoenixAES.crack_file('tracefile')

结果是:869D92BBB700D0D25BD9FD3E224B5DF2。

image-20250521170559877

再用另一个大佬,从轮密钥还原原密钥。

https://github.com/SideChannelMarvels/Stark

需要自己编译源代码,编译完后,还原原密钥!

image-20250521181924901

至此,成功把密钥DFA出来了,下面这个结果和Unidbg模拟的一模一样。

image-20250521182009136

心得体会

以下是我从这次分析中,认为自己需要加强的点:

1.学习绕过反调;(分析壳)

2.学习Unidbg的用法,包括hook、补环境、补系统调用等操作;(读相关博客等)

3.利用符号执行,去除控制流平坦化;

4.学习Binary ninja。