于是进行复现并做学习记录。
功能初试
漫漫看是一个看漫画的APP,使用过程发现应该是个流氓APP,巨多广告,且划拉几页漫画就要看视频解锁。

image-20250604171001-iv4vse1.png (344.25 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
分析
尝试用bp抓包分析看漫画时的请求,无法抓到形如https://cdn-image.tumanapp.com的请求,但是使用reqable可以抓到,因为reqable是VPN机制,可以抓包网络层及以上的所有包,WiFi代理只能抓包应用层的包,VPN抓包机制更加全面。

image-20250604204848-qzltdmr.png (36.79 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
分析请求头,有参数key、time、v、i、ed、n、p,我们的分析目标就是参数key如何生成的。
GET /12/23701/cpt/92174/mf2ue3.webp?
key=79d1bec615471bf961ce4c9679b8e395&
time=1749040750&
v=1.0.0&
i=108&
ed=0a554bc163acb07b62a0009324fbe3021748595538810&
n=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODA1NjU0NTcsImlkZW50aXR5Ijo2MTIzNDIsIm5pY2UiOiJtYW5rYW5rYW4iLCJvcmlnX2lhdCI6MTc0OTAyOTQ1N30.F3ySfaVHYqnTYjlNJ04Gb8trzuxuee7YepP7G1osYSw&
p=8 HTTP/1.1
ed: 0a554bc163acb07b62a0009324fbe3021748595538810
Host: cdn-image.tumanapp.com
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.14.7
参数key的长度为32,第一反应可能是md5,根据原帖作者的分析key应该不是API接口返回的,那key只能是在apk内部生成的。jadx分析漫漫看.apk,搜索https://cdn-image.tumanapp.com,发现invoke()函数使用了cdn-image.tumanapp.com。

image-20250605111729-scq384c.png (140.42 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
分析了一下各个字段的含义,VE.RSION_NAME应该对应http请求里的v参数,AP.PID应该对应i参数,PL.ATFORMID应该对应p参数。但是没有参数key相关的代码。

image-20250605113057-yqfrf4b.png (73.79 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
查看调用invoke()函数的代码,挨个检查后没有发现与key生成相关的。

image-20250605172423-38j77x8.png (117.44 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
看来这条路走不通,尝试直接搜索&key=,发现了如下可疑代码。

image-20250605173811-ql29owm.png (27.18 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
起初我以为f.b.c.k.v0.F()方法里&key=这部分的代码就是请求cdn-image.tumanapp.com里的key参数,但其实并不是。f.b.c.k.v0.F()方法里的key是一个硬编码的字符串,在Native.f()方法实现可以看到。
//路径:f.b.c.k.v0.F
public final String F(long j2) throws Throwable {
f.b.c.l.a d2 = f.b.c.l.a.d();
a.b c2 = d2.c();
Context context = d2.getContext();
String valueOf = String.valueOf((int) (Math.random() * 65535.0d));
String str = "app_sign=" + Native.g() + //app sign
"&appid=" + c2.f() + //appid
"&key=" + Native.f() + //key?不知道这里的key是什么含义
"&package_name=" + f.b.c.o.h.b(context) + //包名
"&rand=" + valueOf + //随机数
"×tamp=" + j2;//时间戳
String a2 = f.b.c.o.h.a(str);//计算str的MD5,记为str_md5
StringBuilder sb = new StringBuilder();
sb.append("appid="); //appid
sb.append(c2.f());
sb.append("&");
sb.append("rand="); //随机数
sb.append(valueOf);
sb.append("&");
sb.append("timestamp=");//时间戳
sb.append(j2);
sb.append("&");
sb.append("sign=");//str_md5
sb.append(a2);
sb.append("&");
sb.append("type=");//和版本号有关,Versions.d()返回值为1
sb.append(Versions.d());
sb.append("&");
sb.append("v=");//和版本号有关,Versions.g()返回值为1.20220621
sb.append(Versions.g());
sb.append("&");
sb.append("p=");//和版本号有关,Versions.c()返回值为1
sb.append(Versions.c());
if (f.b.c.l.a.d().c().p()) {//根据p()的返回值决定是否添加origin参数
sb.append("&origin=");
sb.append(str.replace('&', '|').replace('=', '|'));
}
return sb.toString();
}
Native.f()方法返回了一个硬编码的值,和invoke()函数的AP.PKEYPRE参数一样,这个值应该是一个密钥,看长度盲猜是RSA的公钥。
public static String f() {
return "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3VLHgbkFN0ebMaR4e0Dz6Z2mFexPBFKGqK0tuRhzu7XOrG92nKWfnublf2p1i22UN81whBLINjMttOuqW6fM9DCnAPTelud1zCXWYWIsv5Z19inJSG8vytJ7xg1dnfuRSRUkx11IE7bm0T/sM0sI4GgcktQJNSizyirHtuJjUUxxQabEhFkFeqQ5r+A69KjB5QkotCc4pG5lENyTARHGSsfaiJthaiH0yJ/8tUlyMgJ9H6/jbQg0wlLcEUzdfe2KuCPrTRzIzx4Cjm1JogT6JV2byvXpzAMC3O48LDiekJdVztg2Cj7E0cGrOsGs+IK6F7TWsKD/cIELTFhLz6dExQIDAQAB";
}
从这一步开始,我开始参考原帖的方法(虽然我也不知道为啥要这么做),观察Native类的其他方法,发现m()似乎跟密钥有关。豆包说vuk可能是Verification Key(验证密钥), 作为拼接字符串的一部分参与哈希计算,生成的哈希值可能用于验证请求的来源或防止篡改,听起来vuk像盐值。。k()方法用于计算MD5值,m()方法的功能是将url路径、时间戳和vuk做MD5计算,然后与时间戳进行拼接,等价于MD5(url路径@时间戳@vuk):时间戳
public static String m(String str) {//url
String str2 = c;
if (str2 == null || str2.isEmpty()) {
throw new RuntimeException("vuk is not initialized");
}
int i2 = 0;
int i3 = 0;
while (i2 /user/admin
try {
if (str.charAt(i2) == '/') {
i3++;
}
if (i3 == 3) {
break;
}
i2++;
} catch (Exception e2) {
throw new RuntimeException(e2);
}
}
String substring = str.substring(i2);
String valueOf = String.valueOf(System.currentTimeMillis() / 1000);
return k((substring + "@" + valueOf + "@" + c).getBytes()) + ":" + valueOf;
}
vuk来源于c,所以要看看c在哪被赋值的。搜索发现,Native.l()会对c有赋值,然后d2与c按字节异或后写入vuk.kk,d2与str按字节异或后写入hd.kk文件。
public static void l() {
String str;
if (c.isEmpty()) {
try {
JSONObject c2 = a.c();
String string = new JSONObject(a.b(b(c2.getString(t.a)) + b(c2.getString("z")) + b(c2.getString("a")), "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCCX+ktw91mt0E6EgD+5DiWL8RZusboHw3a6PvINTjO6rRrO2Jv/AWVO9NatFn0ho5NGGg7UpHTVkgjYXWuapkEgvLNkQeqLX6ERHZomy2IiYo7xewiExf6zDVrDTDhCB1PRWPNROV7FtBMsiKrodVERC/+sra3XGgWOr1Ef8CsUwIDAQAB")).getString(Person.KEY_KEY);
if (string.split("\n").length > 1) {
c = string.split("\n")[0];
str = string.split("\n")[1];
} else {
c = string;
str = "android";
}
byte d2 = a.d();//获取包名的hashcode
File file = new File(d + "/vuk.kk");
if (!file.exists()) {
file.createNewFile();
}
FileWriter fileWriter = new FileWriter(file);
byte[] bytes = c.getBytes();
for (int i2 = 0; i2
OK,看看c是如何赋值的,重点分析下面这段代码。b()是一个base64解码函数,返回字符串类型,a.b()是一个RSA解密函数,第二个参数,也就是MIGf...QAB用于生成publicKey,而第一个参数b(c2.getString(t.a)) +b(c2.getString("z"))+b(c2.getString("a"))是被解密的密文,Person.KEY_KEY的值是“key”
String string = new JSONObject(
a.b(
b(c2.getString(t.a)) +
b(c2.getString("z")) +
b(c2.getString("a")),
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCCX+ktw91mt0E6EgD+5DiWL8RZusboHw3a6PvINTjO6rRrO2Jv/AWVO9NatFn0ho5NGGg7UpHTVkgjYXWuapkEgvLNkQeqLX6ERHZomy2IiYo7xewiExf6zDVrDTDhCB1PRWPNROV7FtBMsiKrodVERC/+sra3XGgWOr1Ef8CsUwIDAQAB")
).
getString(Person.KEY_KEY);
用frida hook JSONObject c2 = a.c();的a.c()方法,注意a.c()方法的原始方法路径是f.b.a.a.c() 。frida-16.4.10可成功运行如下hook代码,但是frida-14.2.18会报错退出。
function hook_Native_l_func(){
let D1X1 = Java.use("f.b.a.a");
D1X1["c"].implementation = function () {
console.log(`D1X1.c is called`);
let result = this["c"]();
if (result) {
console.log(`D1X1.c result=${result}`);
}else{
console.log(`D1X1.c result is null`);
}
// console.log(`D1X1.c result=${result}`);
return result;
};
}
function main(){
Java.perform(function(){
hook_Native_l_func();
});
}
setImmediate(main);

image-20250606164119-n79s3w3.png (27.42 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
打算查看f.b.a.a.c()的代码,但是jadx反编译失败。观察对应的smali代码发现该函数是向服务器发送请求获取数据,所以a、k、z的值均是从服务器获取的。

image-20250606173500-so5bq0w.png (40.23 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
用frida hook a.b()(RSA解密函数)方法,hook代码如下
//hook b函数(RSA解密)
function hook_b_func(){
let D1X1 = Java.use("f.b.a.a");
D1X1["b"].implementation = function (base64Ciphertext, rsaPublicKeyString) {
console.log(`D1X1.b is called: base64Ciphertext=${base64Ciphertext}, rsaPublicKeyString=${rsaPublicKeyString}`);
let result = this["b"](base64Ciphertext, rsaPublicKeyString);
console.log(`D1X1.b result=${result}`);
return result;
};
}
原来key的值是123456,测试了几次都是同一个值。。不知道为啥,frida 14.2.18死活不成功。
[M2102J2SC::com.manmankan.app ]-> D1X1.c is called
D1X1.c result={"a":"ajM5TCtwL0xQZVg4QmJrSHhVcGNJY2hQa0VSdlQyQUdRdEp2dERQN0YvMmMwOStGOUo5WHJqYmE4PQ==","k":"ZHBKK1cwRlhwS1BGN05temtrV1BzWW1aZitLNzZyeXE0aVRsUHVldDNLQ2ZjNnJSTWtYTFduakF1","z":"dmV5VjNUc1FQRXVML0IxQjVlVWhJSWg4aXpONVMwSzRZeVo5cFdiRWwzbDZlamh4SGhVNFcvNXJC","p":true,"v":true}
D1X1.b is called: base64Ciphertext=dpJ+W0FXpKPF7NmzkkWPsYmZf+K76ryq4iTlPuet3KCfc6rRMkXLWnjAuveyV3TsQPEuL/B1B5eUhIIh8izN5S0K4YyZ9pWbEl3l6ejhxHhU4W/5rBj39L+p/LPeX8BbkHxUpcIchPkERvT2AGQtJvtDP7F/2c09+F9J9Xrjba8=, rsaPublicKeyString=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCCX+ktw91mt0E6EgD+5DiWL8RZusboHw3a6PvINTjO6rRrO2Jv/AWVO9NatFn0ho5NGGg7UpHTVkgjYXWuapkEgvLNkQeqLX6ERHZomy2IiYo7xewiExf6zDVrDTDhCB1PRWPNROV7FtBMsiKrodVERC/+sra3XGgWOr1Ef8CsUwIDAQAB
D1X1.b result={"key":"123456"}
所以Native.c的值为"123456",Native.m()方法的盐值为"123456"。目前可以确认Native.m()方法返回值是MD5(url路径@时间戳@123456):时间戳,但这个格式和请求里面的key不太一致,冒号前面的内容可能是key的值。接着分析Native.m()的调用过程,发现了一个关键方法f.b.a.c.d(),i()方法是Native.m()方法的封装,用冒号分隔后,前半部分就是key的值,后半部分就是time的值。
public String d(String str, Map map) {
StringBuilder sb;
String sb2;
String[] split = i(str).split(":");
String str2 = "key=" + split[0] + "&time=" + split[1];
String str3 = "?";
if (str.contains("?")) {
str3 = "&";
if (str.endsWith("&")) {
sb2 = str + str2;
return a(sb2, map);
}
sb = new StringBuilder();
} else {
sb = new StringBuilder();
}
sb.append(str);
sb.append(str3);
sb.append(str2);
sb2 = sb.toString();
return a(sb2, map);
}
不过我们前面分析,请求参数有key、time、v、i、ed、n、p,剩余的v、i、ed、n、p是在哪里赋值的?继续跟踪d()方法的调用,找到了关键方法f()。拨开云雾见天日了!v、i、ed、n、p参数均在Auth.queryMap()方法中赋值了。到这里,已经可以确认Native.m()方法返回值的冒号之前的内容就是请求参数的key和time。
public Response f(Interceptor.Chain chain) throws IOException {
...
Map queryMap = Auth.queryMap();
...
String d = d(httpUrl, queryMap);
...
}
public final class Auth {
@Keep
public static boolean d() {
return false;
}
@Keep
public static Map queryMap() {
LinkedHashMap linkedHashMap = new LinkedHashMap();
linkedHashMap.put("v", Apps.e(App.r()));
linkedHashMap.put("i", "108");
linkedHashMap.put("ed", Apps.b());
if (User.k()) {
linkedHashMap.put("n", User.i());
}
linkedHashMap.put("p", GlobalSetting.UNIFIED_INTERSTITIAL_HS_AD);
return linkedHashMap;
}
}
编写Java代码还原一下key生成过程。
public class Main {
public static String MD5(byte[] input) throws NoSuchAlgorithmException {
byte[] md5 = MessageDigest.getInstance("MD5").digest(input);
StringBuilder sb = new StringBuilder();
for (byte b2 : md5) {
sb.append(String.format("%02x", b2));
}
return sb.toString();
}
public static String getKey(String url,String time) throws NoSuchAlgorithmException {
String vuk = "123456";
if (vuk == null || vuk.isEmpty()) {
throw new RuntimeException("vuk is not initialized");
}
int i2 = 0;
int i3 = 0;
while (i2

image-20250612164025-s3nmipe.png (17.16 KB, 下载次数: 0)
下载附件
2025-6-13 14:20 上传
体会
[ol]
jadx无法反编译的方法,可以分析其smali代码包含的字符串,推测其功能,如OKHttpClient表明该方法可能和网络请求有关系。
没有思路时,可以对存疑的方法、类进行全面功能审计,比如Native.m()就是审计分析出来的。
[/ol]