大家好!如果你和我一样,对App内部那些神秘的动态参数(比如图片URL后面跟着的一长串像密码一样的东西)充满好奇,那么这份文档可能会给你一些启发。最近我尝试分析了一款漫画App的图片URL,想弄明白其中 key 这个参数是怎么来的。过程有点曲折,但也很有趣,希望能把我的探索经历和思考过程分享给大家,特别是刚接触逆向分析的朋友们(比如我)。
程序下载地址:https://wwrf.lanzout.com/i05dy2xe7qdg
(后缀zip改apk安装)
我们的核心目标:
彻底搞清楚下面这个示例图片URL中,动态参数 key 是如何生成的:
https://cdn-image.tumanapp.com/1/1164/cpt/199120/2u6kcp.webp?key=15798fe300ee5c57d36de715065de7c0&time=...&ed=...&v=...&i=...&p=...
(其他参数如 time, ed, v, i, p 虽然存在,但我们本次的焦点是 key。)

01.jpg (109.42 KB, 下载次数: 0)
下载附件
2025-5-28 16:29 上传
为什么只关注key呢?因为我发现,每次请求不同的漫画页面,这个key都会变,而且初步观察它似乎并不是通过某个简单的API接口直接返回给我们的。这就意味着,如果我们想做一些比如对图片资源进行研究的操作,就必须弄懂它的生成规则。所以,唯一的办法,似乎就是潜入App的内部代码看个究竟了——也就是我们常说的“逆向工程”。
一、 初步侦查:从URL和已知信息入手
1. key参数的“第一印象”:
我们先来仔细端详一下目标URL中的key参数:key=15798fe300ee5c57d36de715065de7c0。
这是一个32个字符的十六进制字符串。这种格式在计算机世界里非常具有标志性,它通常会让我们立刻联想到MD5哈希值。
MD5是一种广泛使用的密码哈希函数,它可以将任意长度的输入数据(比如一串文本)转换成一个固定长度的128位(通常表示为32位十六进制数)的“数字指纹”。如果key确实是MD5值,那么我们的任务就变成了找到是哪些原始数据被MD5运算后得到了这个key。
2. “顺藤摸瓜”:使用Jadx-GUI搜索图片域名
要分析App的行为,我们首先需要它的代码。我使用了Jadx-GUI这款工具,它可以反编译Android应用的APK安装包文件,将其内部的Dalvik字节码转换回近似可读的Java源代码。
既然我们的目标与图片URL有关,一个很自然的切入点就是图片URL中的域名部分:cdn-image.tumanapp.com。App代码里肯定有地方引用了这个域名来加载图片。
于是,我在Jadx-GUI中打开了目标App的APK文件,并使用其全局搜索功能,搜索字符串 cdn-image.tumanapp.com。

02.png (73.74 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
搜索结果很快就定位到了一个名为 g.e.a.a.C0575a.invoke() 的方法。这里的类名和包名(g.e.a.a)看起来有些奇怪,这是因为开发者通常会使用代码混淆工具来保护他们的代码,这些工具会把有意义的类名、方法名、变量名替换成这种无规律的短字符,以增加逆向分析的难度。尽管如此,我们还是找到了线索。我们点击进入这个invoke()方法查看其具体内容。

03.png (112.44 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
进入方法后,我们发现这里确实定义了大量的应用配置信息,它们被存储在一个 LinkedHashMap(一种能保持元素插入顺序的哈希映射表)中。其中,我们确认了以下与图片URL相关的配置:
初步思考:在这个配置类中,我们找到了图片服务器的地址,但并没有直接发现 key 参数的生成逻辑。这符合预期,因为动态参数通常不会硬编码在静态配置中。key 的生成很可能依赖于更复杂的客户端算法,或者(可能性较低,根据我们最初的观察)是从主API服务器获取的。我们的下一步是继续在客户端代码中寻找线索。
二、 寻找签名算法与核心代码
1. 通过H0()返回的具体Model类:
这个时候就需要扯到device和ed了。其实这两个参数是一样的数值,多抓几次包就可以发现。
但是之后呢?难道就此终结了吗?不,我不死心又搜索了一下"获取章节列表(选择这个作为搜索是需要抓包观察+对于发送请求的时机的把握)

07.png (26.07 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
我们将代码向上翻,发现了一个似乎是用来提交某些信息的方法。

08.png (55.17 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
这里面的H0()引起我的注意,直接切换过去,看起来是BasePresenter.kt的反编译结果
package g.r.c.r;
import android.content.Context;
import androidx.lifecycle.Lifecycle;
import g.r.c.z.w;
import j.b0.d.t;
/* compiled from: BasePresenter.kt */
/* loaded from: classes2.dex */
public abstract class b {
public Context a;
public V b;
public M c;
public boolean e;
public final w d = new w();
/* renamed from: f, reason: collision with root package name */
public boolean f13012f = true;
public b(Context context) {
this.a = context;
}
public final void E0(V v) {
this.b = v;
this.f13012f = false;
L0(v);
}
public final void F0() {
this.e = true;
this.a = null;
G0();
}
public final void G0() {
this.b = null;
this.f13012f = true;
N0();
}
public final M H0() {
M m2 = this.c;
if (m2 != null) {
return m2;
}
M mM0 = M0();
t.c(mM0);
this.c = mM0;
return mM0;
}
public final V I0() {
V v = this.b;
t.c(v);
return v;
}
public final boolean J0() {
return this.e;
}
public final boolean K0() {
return this.f13012f;
}
public void L0(V v) {
}
public abstract M M0();
public void N0() {
}
public final void O0(Lifecycle.Event event) {
t.e(event, "event");
this.d.b(event);
if (event == Lifecycle.Event.ON_DESTROY) {
F0();
}
M m2 = this.c;
if (!(m2 instanceof a)) {
m2 = (M) null;
}
a aVar = m2;
if (aVar != null) {
aVar.G0(event);
}
}
public final Context getContext() {
Context context = this.a;
t.c(context);
return context;
}
}
H0()方法本身是一个工厂方法或者获取器,用于获取Model实例。那么我们可以大概总结一下这玩意的核心思路:
Presenter -> Model -> 网络请求库 -> API接口
2. 确定MD5生成:
那之后呢?我们再次观察抓包得到的这些发出的请求,发没发现,里面有一个叫做/cpt/的东西,我们再去搜索一下看看。

09.png (32.19 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
看上去是构建的完整的图片链接。
package com.junyue.novel.sharebean.reader;
import j.b0.d.t;
import j.i0.o;
/* compiled from: BookChapterBeanExt.kt */
/* loaded from: classes3.dex */
public final class BookChapterBeanExtKt {
public static final String a(BookChapterBean bookChapterBean, long j2, String str, String str2) {
t.e(bookChapterBean, "$this$getImageUrl");
t.e(str, "cartoonId");
if (str2 != null && o.s(str2, "static/nocpt.png", false, 2, null)) {
return '/' + str2;
}
return '/' + j2 + '/' + str + "/cpt/" + bookChapterBean.s() + '/' + str2;
}
}
之后搜索一下Md5.getMd5,别问,问就是找了半个小时找到的关键词(悲

10.png (59.37 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
public final List[B] F(String str) {
j.b0.d.t.e(str, "bookId");
String strA = g.r.c.z.b0.a(str);
j.b0.d.t.d(strA, Person.KEY_KEY);
return E(strA);
}
public final BookReadRecord G(String str) {
if (str == null) {
return null;
}
String strA = g.r.c.z.b0.a(str);
j.b0.d.t.d(strA, "Md5.getMd5(bookId)");
return H(strA);
}
发现都和一个叫做g.r.c.z.b0.a的调用有关,直接马不停蹄地搜索这个。
package g.r.c.z;
import android.util.Log;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/* compiled from: Md5.java */
/* loaded from: classes2.dex */
public class b0 {
public static final String a = "b0";
public static String a(String str) {
NoSuchAlgorithmException e;
StringBuilder sb;
StringBuilder sb2 = new StringBuilder();
try {
sb = new StringBuilder(new BigInteger(1, MessageDigest.getInstance("MD5").digest(str.getBytes())).toString(16));
while (sb.length()
分析一下:
[ol]
[/ol]
看来g.r.c.z.b0.a(String)就是用来计算输入字符串的MD5哈希值,并返回一个32位的十六进制小写字符串。
3. 之后呢?
我现在完全不知道到该如何搜索了,线索完全断了。怎么办?
整理一下我们现在知道什么吧。(key不一样无所谓的,我把两次抓包的结果不不小心搞混了)
4. 搞一下各种请求参数:
直接搜索&key=,

11.png (32.13 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
一把抓住,顷刻炼化
// 这是RtcClient方法
public final String F(long j2) throws Throwable {
// 获取应用上下文和一些配置信息
f.b.c.l.a d2 = f.b.c.l.a.d(); // 可能是一个全局的 ApplicationContext 或配置管理器实例
a.b c2 = d2.c(); // 从 d2 获取内部配置对象 c2
Context context = d2.getContext(); // 获取 Context
// 生成一个随机数 (0-65535)
String valueOf = String.valueOf((int) (Math.random() * 65535.0d));
// 关键的字符串拼接,用于计算签名的原始字符串 (我们称之为 preSignStr)
String str = "app_sign=" + Native.g() // Native.g() 是一个native调用,可能返回一个固定的key或设备相关信息
+ "&appid=" + c2.f() // c2.f() 可能返回 AppID (类似于 "108")
+ "&key=" + Native.f() // Native.f() 是另一个native调用,非常可疑!这可能就是密钥盐B!或者盐A的一部分
+ "&package_name=" + f.b.c.o.h.b(context) // 获取包名
+ "&rand=" + valueOf // 上面生成的随机数
+ "×tamp=" + j2; // 传入的时间戳 (可能是秒级)
// 对 preSignStr 进行某种哈希或编码 (我们称之为 sign)
String a2 = f.b.c.o.h.a(str); // f.b.c.o.h.a() 极有可能是一个哈希函数 (MD5?) 或HMAC函数
// 再次进行字符串拼接,构建最终的查询参数字符串
StringBuilder sb = new StringBuilder();
sb.append("appid=");
sb.append(c2.f()); // AppID
sb.append("&");
sb.append("rand=");
sb.append(valueOf); // 随机数
sb.append("&");
sb.append("timestamp=");
sb.append(j2); // 时间戳
sb.append("&");
sb.append("sign=");
sb.append(a2); // 上一步计算得到的签名 (sign)
sb.append("&");
sb.append("type=");
sb.append(f.b.c.o.i.d()); // 某个类型参数
sb.append("&");
sb.append("v=");
sb.append(f.b.c.o.i.g()); // 版本号 (可能就是 "1.0.0")
sb.append("&");
sb.append("p=");
sb.append(f.b.c.o.i.c()); // 平台参数 (可能就是 "8")
// 一个可选的调试信息?
if (f.b.c.l.a.d().c().p()) { // 检查某个配置开关
sb.append("&origin=");
sb.append(str.replace('&', '|').replace('=', '|')); // 将 preSignStr 也加入到参数中,但分隔符被替换了
}
return sb.toString(); // 返回最终的查询参数字符串
}
注意到``,直接抓住!
package f.b.c.o;
import android.content.Context;
import android.content.ContextWrapper;
import android.util.Log;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/* compiled from: Utils.java */
/* loaded from: classes.dex */
public final class h {
public static String a(String str) {
NoSuchAlgorithmException e;
StringBuilder sb;
StringBuilder sb2 = new StringBuilder();
try {
sb = new StringBuilder(new BigInteger(1, MessageDigest.getInstance("MD5").digest(str.getBytes())).toString(16));
while (sb.length()
f.b.c.o.h.a(String str) 就是MD5加密函数。这印证了我们在分析F(long j2)方法时的猜测。
现在注意到有一个+ "&key=" + Native.f(),还是直接仙人指路搜索过去。

12.png (36.8 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
打开第一个。
package cn.fxlcy.anative;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Base64;
import androidx.annotation.Keep;
import androidx.core.app.Person;
import com.kuaishou.weapon.p0.t;
import f.b.a.a;
import f.b.a.b;
import f.b.a.c;
import f.b.a.d;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.security.MessageDigest;
import okhttp3.Interceptor;
import okhttp3.Response;
import org.json.JSONException;
import org.json.JSONObject;
/* loaded from: classes.dex */
public class Native {
@Keep
public static final Object ALOCK = new Object();
@SuppressLint({"StaticFieldLeak"})
public static a a = null;
public static c b = null;
public static String c = "";
public static String d = null;
public static String e = "";
/* renamed from: f, reason: collision with root package name */
public static boolean f146f = true;
public static Response a(Interceptor.Chain chain) throws IOException {
return b.f(chain);
}
public static String b(String str) {
return new String(Base64.decode(str, 0));
}
public static byte[] c(byte[] bArr) {
if (bArr == null) {
return null;
}
return b.e(bArr);
}
public static Response d(Interceptor.Chain chain) throws IOException {
try {
synchronized (ALOCK) {
if (f146f) {
l();
f146f = false;
}
}
Response responseA = d.c.a(chain);
if (responseA == null) {
responseA = a(chain);
}
return responseA == null ? chain.proceed(chain.request()) : responseA;
} catch (Throwable th) {
if (th instanceof IOException) {
throw th;
}
throw new IOException(th);
}
}
public static String[] e(String str, boolean z) {
return b.g(str, z);
}
public static String f() {
return "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3VLHgbkFN0ebMaR4e0Dz6Z2mFexPBFKGqK0tuRhzu7XOrG92nKWfnublf2p1i22UN81whBLINjMttOuqW6fM9DCnAPTelud1zCXWYWIsv5Z19inJSG8vytJ7xg1dnfuRSRUkx11IE7bm0T/sM0sI4GgcktQJNSizyirHtuJjUUxxQabEhFkFeqQ5r+A69KjB5QkotCc4pG5lENyTARHGSsfaiJthaiH0yJ/8tUlyMgJ9H6/jbQg0wlLcEUzdfe2KuCPrTRzIzx4Cjm1JogT6JV2byvXpzAMC3O48LDiekJdVztg2Cj7E0cGrOsGs+IK6F7TWsKD/cIELTFhLz6dExQIDAQAB";
}
public static String g() {
return e;
}
public static void h(Context context) {
b = new c("3VLHgbkFN0ebMaR4e0Dz6Z2m".getBytes(), "FexPBFKG".getBytes(), Base64.decode("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3VLHgbkFN0ebMaR4e0Dz6Z2mFexPBFKGqK0tuRhzu7XOrG92nKWfnublf2p1i22UN81whBLINjMttOuqW6fM9DCnAPTelud1zCXWYWIsv5Z19inJSG8vytJ7xg1dnfuRSRUkx11IE7bm0T/sM0sI4GgcktQJNSizyirHtuJjUUxxQabEhFkFeqQ5r+A69KjB5QkotCc4pG5lENyTARHGSsfaiJthaiH0yJ/8tUlyMgJ9H6/jbQg0wlLcEUzdfe2KuCPrTRzIzx4Cjm1JogT6JV2byvXpzAMC3O48LDiekJdVztg2Cj7E0cGrOsGs+IK6F7TWsKD/cIELTFhLz6dExQIDAQAB", 0));
new b(context);
a = new a(context, false);
d = context.getFilesDir().getAbsolutePath();
PackageManager packageManager = context.getPackageManager();
String packageName = context.getPackageName();
try {
e = k((Build.VERSION.SDK_INT >= 28 ? packageManager.getPackageInfo(packageName, 134217728).signingInfo.getApkContentsSigners() : packageManager.getPackageInfo(packageName, 64).signatures)[0].toCharsString().getBytes());
} catch (Exception e2) {
throw new RuntimeException(e2);
}
}
public static long i() {
return System.currentTimeMillis() / 1000;
}
public static String j(byte[] bArr) {
StringBuilder sb = new StringBuilder();
for (byte b2 : bArr) {
sb.append(String.format("%02x", Byte.valueOf(b2)));
}
return sb.toString();
}
public static String k(byte[] bArr) {
try {
return j(MessageDigest.getInstance("MD5").digest(bArr));
} catch (Exception unused) {
return "";
}
}
public static void l() throws JSONException, IOException {
String str;
if (c.isEmpty()) {
try {
JSONObject jSONObjectC = a.c();
String string = new JSONObject(a.b(b(jSONObjectC.getString(t.a)) + b(jSONObjectC.getString("z")) + b(jSONObjectC.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 bD = a.d();
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
不看不知道,一看吓一跳!这个 Native 类里真是藏龙卧虎:
public static String f(): 这个方法让我大跌眼镜!它居然直接返回了一个硬编码的超长字符串!
public static String f() {
return "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3VLHgbkFN0ebMaR4e0Dz6Z2mFexPBFKGqK0tuRhzu7XOrG92nKWfnublf2p1i22UN81whBLINjMttOuqW6fM9DCnAPTelud1zCXWYWIsv5Z19inJSG8vytJ7xg1dnfuRSRUkx11IE7bm0T/sM0sI4GgcktQJNSizyirHtuJjUUxxQabEhFkFeqQ5r+A69KjB5QkotCc4pG5lENyTARHGSsfaiJthaiH0yJ/8tUlyMgJ9H6/jbQg0wlLcEUzdfe2KuCPrTRzIzx4Cjm1JogT6JV2byvXpzAMC3O48LDiekJdVztg2Cj7E0cGrOsGs+IK6F7TWsKD/cIELTFhLz6dExQIDAQAB";
}
这个字符串,和我最早在 g.e.a.a 配置文件里看到的 AP.PKEYPRE 的值一模一样!原来这个RSA公钥是被硬编码在这里,并通过 Native.f() 提供给其他代码使用的。在 RtcClient.F() 方法的 preSignStr 构造中,这个RSA公钥字符串就是作为 &key= 参数的值参与了签名计算。这意味着,这个RSA公钥字符串本身,也扮演了“盐”的角色,或者说是签名原始数据的一部分。
public static String m(String str): 这个方法更是重量级!它的实现逻辑,简直就是为图片URL的key和time参数量身定做的!
public static String m(String str) {
String str2 = c; // c 是一个静态字段,就是我们要找的 vuk (密钥盐)
if (str2 == null || str2.isEmpty()) {
throw new RuntimeException("vuk is not initialized");
}
int i2 = 0;
int i3 = 0;
while (i2
这个方法接收一个字符串str(我们推测是图片的完整URL),然后:
[ol]
这不就是图片URL里key(冒号前的MD5值)和time(冒号后的时间戳)的生成方式吗?!我感觉我离真相只有一步之遥了!
[/ol]
4. 追踪核心密钥盐 vuk (Native.c) 的诞生:
现在,所有的焦点都集中在了Native.m()方法中使用的那个神秘的静态字段c(即vuk密钥盐)上。它是从哪里来的呢?
通过查看Native类的代码,我发现Native.c是在一个名为Native.l()的静态方法中被初始化的。这个l()方法的逻辑,简直就是一场寻盐之旅:
[ol]
Native.l()会先判断Native.c是否为空,如果为空(意味着还未初始化),它才会执行后续的初始化逻辑。
它会调用f.b.a.a类的一个实例(这个实例在Native.h()中被创建并赋值给静态字段Native.a)的c()方法,即a.c()。
Frida闪亮登场:我们有动态分析工具Frida!它可以让我们在App运行时“hook”住指定的方法,观察它的输入参数和返回值。这回算是一把hook住,顷刻炼化。我立刻编写了一个简单的Frida脚本来hook f.b.a.a.c()方法。
Java.perform(function() {
var D1X1Class = Java.use('f.b.a.a');
D1X1Class.c.implementation = function() {
console.log("[+] Entered f.b.a.a.c()");
var originalResult = this.c();
if (originalResult !== null) {
console.log("[+] f.b.a.a.c() returned JSONObject: " + originalResult.toString());
} else {
console.log("[+] f.b.a.a.c() returned null");
}
return originalResult;
};
});
运行App并附加Frida脚本后,控制台打印出了f.b.a.a.c()的返回值:
[+] f.b.a.a.c() returned JSONObject: {"a":"ajM5TCt...", "k":"ZHBKK1c...", "z":"dmV5VjN...", "p":true, "v":true}

13.png (82.24 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
原来f.b.a.a.c()方法是从服务器(通过分析其Smali代码可知,它会向API基础URL/b/随机数发送一个带签名的POST请求)获取了一个JSON对象,这个JSON对象里包含了三个键"a", "k", "z",它们的值都是Base64编码的加密字符串。这应该就是承载着加密vuk信息的“宝箱”了!
Native.l()接下来会处理这个从f.b.a.a.c()获取到的JSONObject (jSONObjectC):
RSA解密,vuk现身:
Native.l()随后调用f.b.a.a实例的b(String base64Ciphertext, String rsaPublicKeyString)方法。第一个参数就是上一步得到的最终密文字符串;第二个参数则是一个硬编码在Native.l()方法内部的RSA公钥字符串 ("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCCX+ktw91mt0E6EgD...")。这个f.b.a.a.b()方法的作用就是使用这个公钥对密文进行RSA解密。
我再次祭出Frida,hook了f.b.a.a.b()方法。当App运行时,Frida清晰地捕获了调用,并打印出了传入的密文(与我根据上述逻辑手动模拟拼接并编码的结果一致)和那个硬编码的RSA公钥。最激动人心的时刻到来了——该方法解密后的返回值是一个JSON字符串:{"key":"123456"}!
代码在这里
Java.perform(function() {
var D1X1Class = Java.use('f.b.a.a');
D1X1Class.b.implementation = function(base64Ciphertext, rsaPublicKeyString) {
console.log("[+] f.b.a.a.b() called.");
console.log(" [-] Ciphertext (param1): " + base64Ciphertext);
console.log(" [-] RSA Public Key String (param2): " + rsaPublicKeyString); // 这个公钥是硬编码的
var decryptedResult = this.b(base64Ciphertext, rsaPublicKeyString);
console.log(" [+] Decrypted String (return value): " + decryptedResult);
return decryptedResult;
};
});

14.png (81.66 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
Native.l()最后从这个解密后的JSON字符串({"key":"123456"})中,提取出键名为Person.KEY_KEY(androidx.core.app.Person.KEY_KEY是一个AndroidX库中的常量,其值通常就是字符串"key")的字段的值。如果这个值包含换行符,则只取第一行。这个提取出来的字符串最终被赋值给Native类的静态字段c。
[/ol]
5. key参数在网络请求中的最终“加冕”:
我们已经知道了Native.m()如何生成key:time对,那么它们又是如何最终出现在图片URL中的呢?
通过进一步的代码追踪,我找到了f.b.a.c类(反编译自D1X4.java)。这个类似乎扮演着网络请求助手的角色:
6. Java代码模拟与最终的胜利!
掌握了以上所有信息——核心密钥盐vuk="123456",Native.m()生成key:time对的完整Java逻辑,以及Native.m()的输入是包含协议和主机名的完整URL——我终于可以胸有成竹地编写Java代码来模拟这个签名过程了。
我创建了一个KeyGenerator.java测试类,严格按照Native.m()的逻辑:
[ol]
[/ol]
当我运行这个Java测试代码后,控制台打印出了激动人心的结果:

15.png (42.16 KB, 下载次数: 0)
下载附件
2025-5-28 16:30 上传
Input to Native.m (simulated): https://cdn-image.tumanapp.com/1/1164/cpt/199120/2u6kcp.webp
Substring path used for signing: /1/1164/cpt/199120/2u6kcp.webp
String to sign (with original time): /1/1164/cpt/199120/2u6kcp.webp@1748338381@123456
Generated Key (with original time): 15798fe300ee5c57d36de715065de7c0
Original Key: 15798fe300ee5c57d36de715065de7c0
Is generated key with original time matching? true
true!完全匹配!我们成功了! 这一刻,所有的努力都得到了回报!目标App图片URL中key参数的生成机制被我们彻底搞清楚了!
这是模拟的代码
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class KeyGenerator {
// 密钥盐 vuk
private static final String VUK = "123456";
// MD5 计算函数 (模拟 Native.k() / f.b.c.o.h.a() / g.r.c.z.b0.a())
public static String calculateMd5(String input) {
if (input == null) {
return "";
}
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); // 使用UTF-8编码
BigInteger bigInt = new BigInteger(1, digest);
StringBuilder md5Hex = new StringBuilder(bigInt.toString(16));
while (md5Hex.length() generateKeyAndTime(String imagePath) {
if (imagePath == null || imagePath.isEmpty()) {
throw new IllegalArgumentException("Image path cannot be null or empty.");
}
// 1. 截取图片路径 (从第三个 '/' 之后开始)
String substringPath = "";
int slashCount = 0;
int startIndex = 0;
for (int i = 0; i (keyParam, timestampSeconds);
}
// 辅助类 Pair (如果你的Java版本低于某个版本,可能需要自己实现或使用其他库的Pair)
public static class Pair {
public final K key;
public final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
@Override
public String toString() {
return "Pair{" +
"key=" + key +
", value=" + value +
'}';
}
}
public static void main(String[] args) {
// KeyGenerator.java - main method
String fullImageUrlFromServer = "https://cdn-image.tumanapp.com/1/1164/cpt/199120/2u6kcp.webp"; // 使用完整URL
String originalTime = "1748338381"; // 原始URL中的time
String VUK = "123456";
// 模拟 Native.m() 的路径截取,现在作用于完整URL
String substringPathForOriginalTest = "";
int slashCount = 0;
int startIndex = 0;
for (int i = 0; i
快跟我一起高呼Java大法好!(逃
三、总结与后续思考
后续写得有点捞了,请见谅(因为马上就要上课了,匆匆结尾
本次对漫画App图片URL动态参数的逆向分析是一次结合了静态代码分析(使用Jadx-GUI阅读反编译的Java和Smali代码(smali代码过于逆天且冗长就没有放研究过程))和动态运行时分析(使用Frida进行方法hook和参数捕获)的综合实践。
key的生成流程总结:
[ol]
[/ol]
对于另一个动态参数ed,虽然本次分析未将其作为主要目标完全破解其生成源头,但根据其在同一设备上的固定性以及在相关网络请求头中的出现情况,我们推测它是一个在设备首次初始化时生成并存储的、与设备相关的唯一标识参数。其具体生成方式有待进一步分析com.junyue.basic.util.Apps.getDevicesId(Context)等相关代码。
本次逆向难度评级(新手视角):偏上 (★★★☆☆ ~ ★★★★☆)
作为我的第一次逆向尝试,这次经历的整体难度我个人认为是偏上。主要体现在以下几个方面:
尽管如此,也有一些降低难度的因素:
总的来说,这次逆向分析涉及了代码阅读、逻辑推理、加密算法识别、动态调试等多个方面,对于初学者来说,确实是一次很好的综合锻炼和学习机会。虽然过程有些“肝”,但最终成功破解的成就感是难以言表的。
心得体会:
这次探索再次证明了在逆向工程中: