第五章:JNI调用链深度剖析
本章字数:约30000字 阅读时间:约100分钟 难度等级:★★★★★
声明:本文中的公司名称、包名、API地址、密钥等均已脱敏处理。文中的"梦想世界"、"dreamworld"等均为虚构名称,与任何真实公司无关。
引言
在上一章中,我们成功搭建了Unidbg环境,并验证了可以加载梦想世界APP的SO库。但这只是万里长征的第一步。
要真正实现数据获取,我们需要:
- 理解完整的安全激活流程
- 分析每个JNI方法的作用
- 掌握正确的调用顺序
- 处理服务器响应并解密密钥
本章将深入剖析整个JNI调用链,从反编译代码中还原出完整的安全激活逻辑。
5.1 安全激活流程概览
5.1.1 整体架构
通过反编译APK和分析网络请求,我们发现梦想世界APP的安全体系分为三个层次:
┌─────────────────────────────────────────────────────────────────┐
│ 梦想世界APP安全架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第一层:设备激活 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 获取设备ID │ → │ 获取密钥ID │ → │ RSA签名 │ │ │
│ │ │ (deviceId) │ │ (priId) │ │ (rsaSign) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ↓ │ │
│ │ key-suite API │ │
│ │ ↓ │ │
│ │ 获取加密密钥 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第二层:密钥解密 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 验证服务器 │ → │ 解密临时密钥│ → │ 解密业务密钥│ │ │
│ │ │ 公钥签名 │ │ (formatDK) │ │ (AES/HMAC) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ↓ │ │
│ │ 获取HAC_KEY │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第三层:API签名 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 构造签名串 │ → │ HMAC签名 │ → │ 发送请求 │ │ │
│ │ │ (11个字段) │ │ (hmacSign) │ │ (带签名头) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ↓ │ │
│ │ 获取业务数据 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.1.2 两条安全路径
通过深入分析,我们发现APP存在两条独立的安全激活路径:
| 路径 | 接口 | 签名算法 | 密钥来源 | 适用场景 |
|---|---|---|---|---|
| 路径A | key-suite | RSA-2048 | getPriId() | 主要路径 |
| 路径B | JWT | Ed25519 | getKeyPair() | 备用路径 |
路径A(key-suite) 是我们重点分析的路径,因为:
- 它是APP的主要激活路径
- 成功率更高
- 逻辑相对简单
5.1.3 关键发现:环境设置
在分析过程中,我们发现了一个极其重要的细节:
// 原始APP代码 (MainApplication.java)
private void t() {
// 根据环境设置不同的参数
InternalStub.e(com.dreamworld.oc.m01.common.util.f.f() ? 1 : 0);
}
// f.f() 的实现
public static boolean f() {
String env = getEnvironment();
return "prod".equals(env) || "exercises".equals(env) || "preprod".equals(env);
}
这意味着:必须先调用setEnvironment(1)设置生产环境,然后再获取密钥!
不同环境返回不同的密钥ID:
env=0(测试): priId =a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6env=1(生产): priId =f1e2d3c4b5a6978869574a3b2c1d0e0f
如果使用测试环境的密钥ID去调用生产环境的API,会返回"device key not found"错误。
5.2 JNI方法详细分析
5.2.1 JNI方法清单
通过反编译APK,我们找到了SO库中所有的JNI方法:
// 反编译得到的JNIWrapper类 (com.dreamworld.secutil.JNIWrapper)
public class JNIWrapper {
static {
System.loadLibrary("SecurityCore");
}
// 方法名经过混淆,使用MD5哈希值
public static native String oGetPriId2b3c4d5e6f7a8b9c0d1e2f3a4b(); // getPriId
public static native String oRsaSign3c4d5e6f7a8b9c0d1e2f3a4b5c(String, String); // rsaSign
public static native String oGetKeyPair6f7a8b9c0d1e2f3a4b5c6d(); // getKeyPair
public static native void oSetEnv1a2b3c4d5e6f7a8b9c0d1e2f3a4(String); // setEnvironment
public static native String[] oFormatDK4d5e6f7a8b9c0d1e2f3a4b5c(String, String, String, String); // formatDK
public static native String oHmacSign5e6f7a8b9c0d1e2f3a4b5c6d(String, String); // hmacSign
public static native boolean oRsaVerify7a8b9c0d1e2f3a4b5c6d7e(String, String, String, String, String); // rsaVerify
public static native String oEncrypt8b9c0d1e2f3a4b5c6d7e8f9a(String); // encrypt
}
5.2.2 方法功能映射
| 混淆方法名 | 原始功能 | 参数 | 返回值 | 用途 |
|---|---|---|---|---|
| od0891fc3d... | getPriId | 无 | String | 获取设备密钥ID |
| o9863c14dd... | rsaSign | (String, String) | String | RSA签名 |
| o8eb2ed51f... | getKeyPair | 无 | String | 获取Ed25519密钥对 |
| of9bac8ee8... | setEnvironment | (String) | void | 设置环境 |
| obf59c6fac... | formatDK | (String×4) | String[] | 解密密钥 |
| ob1442c25d... | hmacSign | (String, String) | String | HMAC签名 |
| oe1e991556... | rsaVerify | (String×5) | boolean | RSA验签 |
| o148231fbb... | encrypt | (String) | String | 加密数据 |
5.2.3 方法详解
5.2.3.1 setEnvironment - 设置环境
/**
* 设置运行环境
* 必须在其他方法之前调用!
*
* @param env 环境标识
* "0" = 测试环境
* "1" = 生产环境
*/
public static native void oSetEnv1a2b3c4d5e6f7a8b9c0d1e2f3a4(String env);
调用示例:
// Unidbg调用
jniWrapperClass.callStaticJniMethod(
emulator,
"oSetEnv1a2b3c4d5e6f7a8b9c0d1e2f3a4(Ljava/lang/String;)V",
new StringObject(vm, "1") // 设置为生产环境
);
内部逻辑分析(通过IDA Pro反编译):
// 伪代码
void setEnvironment(JNIEnv *env, jclass clazz, jstring envStr) {
const char *envValue = (*env)->GetStringUTFChars(env, envStr, NULL);
int envInt = atoi(envValue);
// 设置全局环境变量
g_environment = envInt;
// 根据环境选择不同的密钥库
if (envInt == 1) {
// 生产环境:使用生产密钥
load_production_keys();
} else {
// 测试环境:使用测试密钥
load_test_keys();
}
(*env)->ReleaseStringUTFChars(env, envStr, envValue);
}
5.2.3.2 getPriId - 获取设备密钥ID
/**
* 获取设备私钥ID
* 这个ID用于标识设备,在key-suite请求中使用
*
* @return 32字符的十六进制字符串
*/
public static native String oGetPriId2b3c4d5e6f7a8b9c0d1e2f3a4b();
调用示例:
StringObject result = jniWrapperClass.callStaticJniMethodObject(
emulator,
"oGetPriId2b3c4d5e6f7a8b9c0d1e2f3a4b()Ljava/lang/String;"
);
String priId = result.getValue();
// 返回: "f1e2d3c4b5a6978869574a3b2c1d0e0f"
返回值格式分析:
f1e2d3c4b5a6978869574a3b2c1d0e0f
│││ │ │
│││ │ └── 随机部分
│││ └── 时间戳相关
│└└── 版本标识
└── 固定前缀
5.2.3.3 rsaSign - RSA签名
/**
* 使用设备私钥进行RSA签名
*
* @param prefix 签名前缀(通常为空字符串)
* @param data 待签名数据
* @return Base64编码的签名结果
*/
public static native String oRsaSign3c4d5e6f7a8b9c0d1e2f3a4b5c(String prefix, String data);
调用示例:
// 构造签名载荷
String dataToSign = requestId + ":" + deviceId + ":" + deviceKeyId + ":" + nonce + ":" + timestamp;
// 例如: "req1a2b3c4d5e6f7a8b9c0d1e2f3a4b5:a1b2c3d4e5f67890:f1e2d3c4b5a6978869574a3b2c1d0e0f:nonce9a8b7c6d5e4f3a2b1c0d9e8f7a6b5:1736408400000"
StringObject result = jniWrapperClass.callStaticJniMethodObject(
emulator,
"oRsaSign3c4d5e6f7a8b9c0d1e2f3a4b5c(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
new StringObject(vm, ""), // prefix为空
new StringObject(vm, dataToSign)
);
String signature = result.getValue();
// 返回: "RjOEWKNX2lfFXHlcN1Lvg4+IlQ3i9DzYLzo+gZCsWbOW..." (约344字符)
签名算法分析:
- 算法:RSA-2048 with SHA-256
- 填充:PKCS#1 v1.5
- 输出:Base64编码
5.2.3.4 formatDK - 解密密钥
/**
* 解密服务器返回的密钥
*
* @param tempAesKey RSA加密的临时AES密钥
* @param tempIv 临时IV(初始化向量)
* @param aesKey AES加密的业务AES密钥
* @param hmacKey AES加密的业务HMAC密钥
* @return [解密后的AES密钥, 解密后的HMAC密钥]
*/
public static native String[] oFormatDK4d5e6f7a8b9c0d1e2f3a4b5c(
String tempAesKey, String tempIv, String aesKey, String hmacKey);
调用示例:
DvmObject<?> result = jniWrapperClass.callStaticJniMethodObject(
emulator,
"oFormatDK4d5e6f7a8b9c0d1e2f3a4b5c(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[Ljava/lang/String;",
new StringObject(vm, tempAesKey),
new StringObject(vm, tempIv),
new StringObject(vm, aesKey),
new StringObject(vm, hmacKey)
);
// 解析返回的字符串数组
ArrayObject arrayObj = (ArrayObject) result;
String decryptedAesKey = ((StringObject) arrayObj.getValue()[0]).getValue();
String decryptedHmacKey = ((StringObject) arrayObj.getValue()[1]).getValue();
解密流程:
┌─────────────────────────────────────────────────────────────────┐
│ formatDK 解密流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 输入: │
│ ├── tempAesKey: RSA加密的临时AES密钥 │
│ ├── tempIv: 临时IV │
│ ├── aesKey: AES加密的业务AES密钥 │
│ └── hmacKey: AES加密的业务HMAC密钥 │
│ │
│ 步骤1: RSA解密临时AES密钥 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ tempAesKey (Base64) → RSA解密 → 临时AES密钥 (32字节) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 步骤2: 使用临时AES密钥解密业务密钥 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ aesKey + tempIv → AES-256-CBC解密 → 业务AES密钥 │ │
│ │ hmacKey + tempIv → AES-256-CBC解密 → 业务HMAC密钥 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 输出: [业务AES密钥, 业务HMAC密钥] │
│ │
└─────────────────────────────────────────────────────────────────┘
5.2.3.5 hmacSign - HMAC签名
/**
* 使用HMAC密钥对数据进行签名
* 用于API请求的签名验证
*
* @param hacKey HMAC密钥(从formatDK获取)
* @param stringToSign 待签名字符串
* @return Base64编码的签名结果
*/
public static native String oHmacSign5e6f7a8b9c0d1e2f3a4b5c6d(String hacKey, String stringToSign);
调用示例:
// 构造签名字符串(11个字段,每个字段后跟换行符)
String stringToSign =
"prod\n" + // env
"8.x.x\n" + // version
"key1a2b3c4d5e6f7a8b9c0d1e2f3a4b5\n" + // keyId
"a1b2c3d4e5f67890\n" + // deviceId
"GET\n" + // method
"application/json\n" + // accept
"zh-CN\n" + // contentLanguage
"1B2M2Y8AsgTpgAmY7PhCfg==\n" + // contentMd5
"application/json\n" + // contentType
"1736408460000\n" + // timestamp
"a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6\n"; // nonce
StringObject result = jniWrapperClass.callStaticJniMethodObject(
emulator,
"oHmacSign5e6f7a8b9c0d1e2f3a4b5c6d(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
new StringObject(vm, hacKey),
new StringObject(vm, stringToSign)
);
String signature = result.getValue();
签名算法:HMAC-SHA256,输出Base64编码
5.2.3.6 rsaVerify - RSA验签
/**
* 验证RSA签名
* 用于验证服务器响应的签名
*
* @param publicKey 服务器公钥
* @param signature 签名
* @param data 原始数据
* @param padding 填充方式
* @param hash 哈希算法
* @return 验证结果
*/
public static native boolean oRsaVerify7a8b9c0d1e2f3a4b5c6d7e(
String publicKey, String signature, String data, String padding, String hash);
5.3 完整调用链实现
5.3.1 调用顺序图
┌─────────────────────────────────────────────────────────────────┐
│ 完整调用链时序图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 客户端 服务器 │
│ │ │ │
│ │ 1. setEnvironment(1) │ │
│ │ ─────────────────────→ │ │
│ │ (设置生产环境) │ │
│ │ │ │
│ │ 2. getPriId() │ │
│ │ ─────────────────────→ │ │
│ │ ←───────────────────── priId │ │
│ │ │ │
│ │ 3. 构造签名载荷 │ │
│ │ requestId:deviceId:priId:nonce:timestamp │
│ │ │ │
│ │ 4. rsaSign("", payload) │ │
│ │ ─────────────────────→ │ │
│ │ ←───────────────────── signature │ │
│ │ │ │
│ │ 5. POST /ddes/v1-0/app/key-suite │ │
│ │ ─────────────────────────────────────────────────→ │
│ │ ←───────────────────────────────────────────────── 加密密钥│
│ │ │ │
│ │ 6. formatDK(tempAesKey, tempIv, aesKey, hmacKey) │
│ │ ─────────────────────→ │ │
│ │ ←───────────────────── [aesKey, hacKey] │
│ │ │ │
│ │ 7. 构造API签名字符串 │ │
│ │ │ │
│ │ 8. hmacSign(hacKey, stringToSign)│ │
│ │ ─────────────────────→ │ │
│ │ ←───────────────────── apiSign │ │
│ │ │ │
│ │ 9. GET /api/xxx (带签名头) │ │
│ │ ─────────────────────────────────────────────────→ │
│ │ ←───────────────────────────────────────────────── 业务数据│
│ │ │ │
└─────────────────────────────────────────────────────────────────┘
5.3.2 代码实现
步骤1:安全操作封装类
package com.dreamworld.security;
import com.dreamworld.model.ActivationKeySuiteReq;
import com.dreamworld.utils.DeviceUtils;
import java.util.UUID;
/**
* 安全操作类
* 封装完整的安全激活流程
*/
public class SecurityOperation {
private static final String TAG = "SecurityOperation";
/**
* 激活密钥套件
* 这是核心方法,完全按照原始APP逻辑实现
*/
public ActivationKeySuiteReq activationKeySuite() {
System.out.println("[" + TAG + "] 开始密钥套件激活流程");
// 步骤1: 获取设备ID
System.out.println("[" + TAG + "] 步骤1: 获取设备ID");
String deviceId = DeviceUtils.getDeviceId();
System.out.println("[" + TAG + "] 设备ID: " + deviceId);
// 步骤2: 获取设备私钥ID
System.out.println("[" + TAG + "] 步骤2: 获取设备私钥ID");
String priId = SecurityStub.getPriId();
System.out.println("[" + TAG + "] 私钥ID: " + priId);
// 步骤3: 生成时间戳
System.out.println("[" + TAG + "] 步骤3: 生成时间戳");
String timestamp = String.valueOf(System.currentTimeMillis());
System.out.println("[" + TAG + "] 时间戳: " + timestamp);
// 步骤4: 生成请求ID (UUID)
System.out.println("[" + TAG + "] 步骤4: 生成请求ID");
String requestId = UUID.randomUUID().toString().replace("-", "");
System.out.println("[" + TAG + "] 请求ID: " + requestId);
// 步骤5: 生成随机数 (nonce)
System.out.println("[" + TAG + "] 步骤5: 生成随机数");
String nonce = UUID.randomUUID().toString().replace("-", "");
System.out.println("[" + TAG + "] 随机数: " + nonce);
// 步骤6: 构造签名载荷
System.out.println("[" + TAG + "] 步骤6: 构造签名载荷");
String signPayload = dataToSign(requestId, deviceId, priId, nonce, timestamp);
System.out.println("[" + TAG + "] 签名载荷: " + signPayload);
// 步骤7: RSA签名
System.out.println("[" + TAG + "] 步骤7: RSA签名");
String signature = SecurityStub.rsaSign("", signPayload);
System.out.println("[" + TAG + "] 签名长度: " + signature.length());
// 步骤8: 创建请求对象
System.out.println("[" + TAG + "] 步骤8: 创建请求对象");
ActivationKeySuiteReq request = new ActivationKeySuiteReq(
requestId,
deviceId,
priId, // deviceKeyId
nonce,
timestamp,
signature
);
System.out.println("[" + TAG + "] 激活请求构造完成");
return request;
}
/**
* 构造签名数据
* 格式: param1:param2:param3:...
*/
public static String dataToSign(String... params) {
if (params == null || params.length == 0) {
throw new IllegalArgumentException("签名参数不能为空");
}
StringBuilder sb = new StringBuilder(params[0]);
for (int i = 1; i < params.length; i++) {
sb.append(":");
sb.append(params[i]);
}
return sb.toString();
}
}
步骤2:请求模型
package com.dreamworld.model;
/**
* 安全激活请求模型
*/
public class ActivationKeySuiteReq {
private String requestId; // UUID生成的请求ID
private String deviceId; // 设备ID
private String deviceKeyId; // 设备密钥ID (priId)
private String nonce; // 随机数
private String timestamp; // 时间戳
private String sign; // RSA签名
public ActivationKeySuiteReq(String requestId, String deviceId,
String deviceKeyId, String nonce, String timestamp, String sign) {
this.requestId = requestId;
this.deviceId = deviceId;
this.deviceKeyId = deviceKeyId;
this.nonce = nonce;
this.timestamp = timestamp;
this.sign = sign;
}
// Getters
public String getRequestId() { return requestId; }
public String getDeviceId() { return deviceId; }
public String getDeviceKeyId() { return deviceKeyId; }
public String getNonce() { return nonce; }
public String getTimestamp() { return timestamp; }
public String getSign() { return sign; }
}
步骤3:API客户端
package com.dreamworld.network;
import com.google.gson.Gson;
import com.dreamworld.model.ActivationKeySuiteReq;
import okhttp3.*;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 密钥套件API客户端
*/
public class KeySuiteApiClient {
private static final String BASE_URL = "https://api.dreamworld.com";
private static final String KEY_SUITE_PATH = "/ddes/v1-0/app/key-suite";
private final OkHttpClient client;
private final Gson gson;
public KeySuiteApiClient() {
this.client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
this.gson = new Gson();
}
/**
* 发送激活请求
*/
public KeySuiteResponse activationKeySuite(ActivationKeySuiteReq request) {
System.out.println("[KeySuiteApiClient] 发送激活请求");
String jsonBody = gson.toJson(request);
System.out.println("[KeySuiteApiClient] 请求体: " + jsonBody);
// 生成请求头参数
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "");
String contentMd5 = md5Base64(jsonBody);
RequestBody body = RequestBody.create(
jsonBody,
MediaType.parse("application/json; charset=UTF-8")
);
Request httpRequest = new Request.Builder()
.url(BASE_URL + KEY_SUITE_PATH)
.post(body)
.addHeader("Content-Type", "application/json; charset=UTF-8")
.addHeader("Accept", "application/json")
.addHeader("X-DW-Deviceid", request.getDeviceId())
.addHeader("X-DW-DeviceType", "2")
.addHeader("X-DW-ModelName", "ANDROID")
.addHeader("X-DW-DeviceModel", "Pixel 6")
.addHeader("X-DW-APP-Version", "8.x.x")
.addHeader("X-DW-Version", "1.0")
.addHeader("X-DW-Env", "prod")
.addHeader("X-DW-Timestamp", timestamp)
.addHeader("X-DW-Nonce", nonce)
.addHeader("Content-MD5", contentMd5)
.addHeader("Content-Language", "zh-CN")
.build();
try {
Response response = client.newCall(httpRequest).execute();
String responseBody = response.body() != null ? response.body().string() : "";
System.out.println("[KeySuiteApiClient] HTTP状态码: " + response.code());
System.out.println("[KeySuiteApiClient] 响应: " + responseBody);
return gson.fromJson(responseBody, KeySuiteResponse.class);
} catch (Exception e) {
System.err.println("[KeySuiteApiClient] 请求失败: " + e.getMessage());
return null;
}
}
private String md5Base64(String input) {
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes("UTF-8"));
return java.util.Base64.getEncoder().encodeToString(digest);
} catch (Exception e) {
return "";
}
}
}
5.4 key-suite API详解
5.4.1 请求格式
URL: POST https://api.dreamworld.com/ddes/v1-0/app/key-suite
请求头:
Content-Type: application/json; charset=UTF-8
Accept: application/json
X-DW-Deviceid: a1b2c3d4e5f67890
X-DW-DeviceType: 2
X-DW-ModelName: ANDROID
X-DW-DeviceModel: Pixel 6
X-DW-APP-Version: 8.x.x
X-DW-Version: 1.0
X-DW-Env: prod
X-DW-Timestamp: 1736408400000
X-DW-Nonce: nonce9a8b7c6d5e4f3a2b1c0d9e8f7a6b5
Content-MD5: xYz123AbC...
Content-Language: zh-CN
请求体:
{
"requestId": "req1a2b3c4d5e6f7a8b9c0d1e2f3a4b5",
"deviceId": "a1b2c3d4e5f67890",
"deviceKeyId": "f1e2d3c4b5a6978869574a3b2c1d0e0f",
"nonce": "nonce9a8b7c6d5e4f3a2b1c0d9e8f7a6b5",
"timestamp": "1736408400000",
"sign": "RjOEWKNX2lfFXHlcN1Lvg4+IlQ3i9DzYLzo+gZCsWbOW..."
}
5.4.2 响应格式
成功响应:
{
"code": 0,
"success": true,
"msg": "success",
"data": {
"requestId": "req1a2b3c4d5e6f7a8b9c0d1e2f3a4b5",
"keySuite": {
"keyId": "key1a2b3c4d5e6f7a8b9c0d1e2f3a4b5",
"keySecret": {
"aesKey": "加密的AES密钥(Base64)",
"hmacKey": "加密的HMAC密钥(Base64)"
},
"validFrom": 1736408520000,
"validTo": 1746316800000
},
"tempAesKey": "RSA加密的临时AES密钥(Base64)",
"tempIv": "临时IV(Base64)",
"spPublicKey": "服务器公钥(PEM格式)",
"spPublicKeySign": "服务器公钥签名",
"sign": "响应签名"
}
}
5.4.3 响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| code | int | 业务状态码,0表示成功 |
| success | boolean | 是否成功 |
| msg | string | 状态消息 |
| data.requestId | string | 请求ID(与请求中的相同) |
| data.keySuite.keyId | string | 密钥ID,后续API请求需要 |
| data.keySuite.keySecret.aesKey | string | 加密的AES密钥 |
| data.keySuite.keySecret.hmacKey | string | 加密的HMAC密钥 |
| data.keySuite.validFrom | long | 密钥生效时间(毫秒时间戳) |
| data.keySuite.validTo | long | 密钥过期时间(毫秒时间戳) |
| data.tempAesKey | string | RSA加密的临时AES密钥 |
| data.tempIv | string | 临时IV |
| data.spPublicKey | string | 服务器公钥 |
| data.spPublicKeySign | string | 服务器公钥签名 |
| data.sign | string | 响应签名 |
5.4.4 错误码说明
| 错误码 | 说明 | 解决方案 |
|---|---|---|
| 0 | 成功 | - |
| 144011 | device key not found | 检查环境设置,确保使用生产环境 |
| 144012 | 签名验证失败 | 检查签名载荷和签名算法 |
| 144013 | 时间戳过期 | 检查系统时间是否正确 |
| 100002 | 缺少必要参数 | 检查请求头和请求体 |
| 100022 | Content-MD5验证失败 | 确保使用Base64编码 |
5.5 密钥解密流程
5.5.1 解密步骤
收到服务器响应后,需要解密获取真正的业务密钥:
/**
* 处理key-suite响应,解密密钥
*/
public void processKeySuiteResponse(KeySuiteResponse response) {
KeySuiteData data = response.getData();
// 1. 验证服务器公钥签名(可选,增加安全性)
boolean verified = SecurityStub.rsaVerify(
data.getSpPublicKey(),
data.getSpPublicKeySign(),
data.getSpPublicKey(),
"1", // padding
"1" // hash
);
System.out.println("服务器公钥验证: " + verified);
// 2. 调用formatDK解密密钥
String[] decryptedKeys = SecurityStub.formatDK(
data.getTempAesKey(),
data.getTempIv(),
data.getKeySuite().getKeySecret().getAesKey(),
data.getKeySuite().getKeySecret().getHmacKey()
);
if (decryptedKeys != null && decryptedKeys.length >= 2) {
String aesKey = decryptedKeys[0];
String hacKey = decryptedKeys[1];
System.out.println("AES密钥解密成功,长度: " + aesKey.length());
System.out.println("HAC密钥解密成功,长度: " + hacKey.length());
// 3. 保存密钥到本地存储
saveKeys(data.getKeySuite().getKeyId(), aesKey, hacKey);
}
}
5.5.2 密钥存储
/**
* 保存密钥到本地存储
*/
public void saveKeys(String keyId, String aesKey, String hacKey) {
// 在实际APP中,这些密钥会保存到SharedPreferences
// 我们使用内存存储模拟
preferencesHelper.setKeyId(keyId);
preferencesHelper.setAesKey(aesKey);
preferencesHelper.setHacKey(hacKey);
System.out.println("密钥已保存:");
System.out.println(" keyId: " + keyId);
System.out.println(" aesKey长度: " + aesKey.length());
System.out.println(" hacKey长度: " + hacKey.length());
}
5.6 API签名实现
5.6.1 签名字符串构造
获取HAC密钥后,就可以对API请求进行签名了。签名字符串由11个字段组成:
/**
* 构造API签名字符串
* 严格按照原始APP的实现
*/
public String buildSignString(String env, String version, String keyId,
String deviceId, String method, String accept, String contentLanguage,
String contentMd5, String contentType, String timestamp, String nonce) {
StringBuilder sb = new StringBuilder();
sb.append(env != null ? env : "").append("\n");
sb.append(version != null ? version : "").append("\n");
sb.append(keyId != null ? keyId : "").append("\n");
sb.append(deviceId != null ? deviceId : "").append("\n");
sb.append(method != null ? method : "").append("\n");
sb.append(accept != null ? accept : "").append("\n");
sb.append(contentLanguage != null ? contentLanguage : "").append("\n");
sb.append(contentMd5 != null ? contentMd5 : "").append("\n");
sb.append(contentType != null ? contentType : "").append("\n");
sb.append(timestamp != null ? timestamp : "").append("\n");
sb.append(nonce != null ? nonce : "").append("\n");
return sb.toString();
}
5.6.2 签名字段详解
让我们详细分析每个签名字段的含义和来源:
┌─────────────────────────────────────────────────────────────────┐
│ 签名字符串字段说明 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 字段1: env (环境) │
│ ├── 值: "prod" / "test" / "preprod" │
│ ├── 来源: X-DW-Env Header │
│ └── 说明: 必须与激活时的环境一致 │
│ │
│ 字段2: version (版本) │
│ ├── 值: "8.x.x" │
│ ├── 来源: X-DW-Version Header │
│ └── 说明: APP版本号 │
│ │
│ 字段3: keyId (密钥ID) │
│ ├── 值: "key1a2b3c4d5e6f7a8b9c0d1e2f3a4b5" │
│ ├── 来源: key-suite响应中的keySuite.keyId │
│ └── 说明: 每次激活获取的唯一密钥标识 │
│ │
│ 字段4: deviceId (设备ID) │
│ ├── 值: "a1b2c3d4e5f67890" │
│ ├── 来源: 设备唯一标识 │
│ └── 说明: 16位十六进制字符串 │
│ │
│ 字段5: method (HTTP方法) │
│ ├── 值: "GET" / "POST" / "PUT" / "DELETE" │
│ ├── 来源: HTTP请求方法 │
│ └── 说明: 必须大写 │
│ │
│ 字段6: accept (接受类型) │
│ ├── 值: "application/json" │
│ ├── 来源: Accept Header │
│ └── 说明: 响应内容类型 │
│ │
│ 字段7: contentLanguage (内容语言) │
│ ├── 值: "zh-CN" │
│ ├── 来源: Content-Language Header │
│ └── 说明: 请求语言 │
│ │
│ 字段8: contentMd5 (内容MD5) │
│ ├── 值: "1B2M2Y8AsgTpgAmY7PhCfg==" │
│ ├── 来源: 请求体的MD5哈希(Base64编码) │
│ └── 说明: 空字符串也需要计算MD5! │
│ │
│ 字段9: contentType (内容类型) │
│ ├── 值: "application/json" │
│ ├── 来源: Content-Type Header │
│ └── 说明: 请求内容类型 │
│ │
│ 字段10: timestamp (时间戳) │
│ ├── 值: "1736408460000" │
│ ├── 来源: 当前时间毫秒数 │
│ └── 说明: 服务器会验证时间戳有效性 │
│ │
│ 字段11: nonce (随机数) │
│ ├── 值: "a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6" │
│ ├── 来源: UUID生成 │
│ └── 说明: 防止重放攻击 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.6.3 签名示例
以下是一个完整的签名示例:
原始签名字符串(每行末尾有换行符):
prod
8.x.x
key1a2b3c4d5e6f7a8b9c0d1e2f3a4b5
a1b2c3d4e5f67890
GET
application/json
zh-CN
1B2M2Y8AsgTpgAmY7PhCfg==
application/json
1736408460000
a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6
签名调用:
String hacKey = "解密后的HMAC密钥(344字符Base64)";
String stringToSign = buildSignString(...); // 上述字符串
String signature = SecurityStub.hmacSign(hacKey, stringToSign);
// 返回: "Kx7mN2pQ9rS1tU3vW5xY7zA9bC1dE3fG5hI7jK9lM1nO3pQ5rS7tU9vW1xY3zA=="
5.6.4 完整的API请求实现
package com.dreamworld.network;
import com.dreamworld.security.SecurityStub;
import com.dreamworld.storage.PreferencesHelper;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;
/**
* 业务API客户端
* 使用HAC密钥进行HMAC签名
*/
public class BusinessApiClient {
private static final String TAG = "BusinessApiClient";
private static final String API_HOST = "https://api.dreamworld.com";
private final PreferencesHelper preferencesHelper;
// 固定的Header值
private String env = "prod";
private String version = "8.x.x";
private String contentType = "application/json";
private String contentLanguage = "zh-CN";
private String accept = "application/json";
public BusinessApiClient(PreferencesHelper preferencesHelper) {
this.preferencesHelper = preferencesHelper;
}
/**
* 发送带签名的GET请求
*/
public ApiResponse sendGetRequest(String path) {
System.out.println("[" + TAG + "] 发送GET请求: " + path);
try {
// 1. 获取存储的密钥信息
String deviceId = preferencesHelper.getDeviceId();
String keyId = preferencesHelper.getKeyId();
String hacKey = preferencesHelper.getHacKey();
// 2. 生成请求参数
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString();
String method = "GET";
String contentMd5 = md5Base64(""); // GET请求body为空
// 3. 构造签名字符串
String stringToSign = buildSignString(
env, version, keyId, deviceId, method,
accept, contentLanguage, contentMd5, contentType,
timestamp, nonce
);
System.out.println("[" + TAG + "] 签名字符串:\n" + stringToSign);
// 4. 使用HAC密钥进行HMAC签名
String sign = SecurityStub.hmacSign(hacKey, stringToSign);
System.out.println("[" + TAG + "] 签名结果长度: " + sign.length());
// 5. 构建HTTP请求
URL url = new URL(API_HOST + path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
// 6. 设置所有必需的Headers
conn.setRequestProperty("X-DW-Deviceid", deviceId);
conn.setRequestProperty("X-DW-APP-Version", version);
conn.setRequestProperty("X-DW-Env", env);
conn.setRequestProperty("X-DW-Version", version);
conn.setRequestProperty("X-DW-Key", keyId);
conn.setRequestProperty("X-DW-Timestamp", timestamp);
conn.setRequestProperty("X-DW-Nonce", nonce);
conn.setRequestProperty("X-DW-Sign", sign);
conn.setRequestProperty("X-DW-Token", ""); // 未登录状态
conn.setRequestProperty("X-DW-DeviceType", "2");
conn.setRequestProperty("X-DW-ModelName", "ANDROID");
conn.setRequestProperty("X-DW-DeviceModel", "Pixel 6");
conn.setRequestProperty("Content-Type", contentType);
conn.setRequestProperty("Content-Language", contentLanguage);
conn.setRequestProperty("Content-MD5", contentMd5); // 必须发送!
conn.setRequestProperty("Accept", accept);
// 7. 发送请求并处理响应
int responseCode = conn.getResponseCode();
System.out.println("[" + TAG + "] HTTP响应码: " + responseCode);
// ... 处理响应 ...
} catch (Exception e) {
System.err.println("[" + TAG + "] 请求失败: " + e.getMessage());
}
return null;
}
/**
* MD5哈希 - 返回Base64编码
* 这是一个关键发现:必须使用Base64编码,而非十六进制!
*/
private String md5Base64(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(digest);
} catch (Exception e) {
return "";
}
}
}
5.7 Content-MD5的关键发现
5.7.1 一个让人抓狂的Bug
在实现API签名的过程中,我们遇到了一个让人抓狂的问题:
HTTP响应码: 200
响应体: {"code":100022,"success":false,"msg":"验证content md5值失败"}
签名明明是正确的,为什么Content-MD5验证会失败?
5.7.2 问题排查
我们首先检查了MD5计算逻辑:
// 最初的实现(错误的)
private String md5Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
// 转换为十六进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
return "";
}
}
// 空字符串的MD5(十六进制): d41d8cd98f00b204e9800998ecf8427e
看起来没问题啊?MD5计算是正确的。
5.7.3 关键发现
通过仔细分析原始APP的反编译代码,我们发现了问题所在:
// 原始APP代码 (FedMd5Utils.java)
public static String md5(String str) {
try {
MessageDigest instance = MessageDigest.getInstance("MD5");
instance.update(str.getBytes(StandardCharsets.UTF_8));
byte[] digest = instance.digest();
// 关键:使用Base64编码,而非十六进制!
return Base64.encodeToString(digest, Base64.NO_WRAP);
} catch (Exception e) {
return "";
}
}
原来如此! APP使用的是Base64编码,而不是常见的十六进制编码!
5.7.4 正确的实现
/**
* MD5哈希 - 返回Base64编码
*
* 重要:梦想世界APP使用Base64编码MD5值,而非十六进制!
* 这是一个非常容易踩的坑。
*/
private String md5Base64(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
// 使用Base64编码
return Base64.getEncoder().encodeToString(digest);
} catch (Exception e) {
return "";
}
}
对比:
空字符串的MD5:
- 十六进制: d41d8cd98f00b204e9800998ecf8427e (32字符)
- Base64: 1B2M2Y8AsgTpgAmY7PhCfg== (24字符)
5.7.5 另一个坑:GET请求也需要Content-MD5
还有一个容易忽略的细节:即使是GET请求,也必须发送Content-MD5 Header!
// 错误做法:GET请求不发送Content-MD5
// conn.setRequestProperty("Content-MD5", null); // 错误!
// 正确做法:发送空字符串的MD5
String contentMd5 = md5Base64(""); // "1B2M2Y8AsgTpgAmY7PhCfg=="
conn.setRequestProperty("Content-MD5", contentMd5);
如果不发送Content-MD5,服务器会返回:
{"code":100002,"success":false,"msg":"缺少必要的请求参数: Content-MD5"}
5.7.6 经验总结
┌─────────────────────────────────────────────────────────────────┐
│ Content-MD5 实现要点 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 编码方式:使用Base64,不是十六进制 │
│ │
│ 2. 空字符串:GET请求也需要计算空字符串的MD5 │
│ - 空字符串MD5 (Base64): 1B2M2Y8AsgTpgAmY7PhCfg== │
│ │
│ 3. POST请求:计算请求体的MD5 │
│ - 注意字符编码:使用UTF-8 │
│ │
│ 4. Header名称:Content-MD5(注意大小写) │
│ │
│ 5. 签名字符串:contentMd5字段必须与Header值一致 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.8 完整调用示例
5.8.1 主程序入口
现在让我们把所有组件整合起来,实现一个完整的调用示例:
package com.dreamworld;
import com.dreamworld.security.SecurityStub;
import com.dreamworld.security.SecurityOperation;
import com.dreamworld.network.KeySuiteApiClient;
import com.dreamworld.network.BusinessApiClient;
import com.dreamworld.storage.PreferencesHelper;
import com.dreamworld.unidbg.UnidbgJNIWrapper;
/**
* 主程序入口
* 演示完整的安全激活和API调用流程
*/
public class Main {
public static void main(String[] args) {
System.out.println("╔════════════════════════════════════════════════════════════╗");
System.out.println("║ 梦想世界APP安全激活调用链演示 ║");
System.out.println("╚════════════════════════════════════════════════════════════╝");
System.out.println();
try {
// 步骤1: 初始化Unidbg环境
System.out.println("【步骤1】初始化Unidbg环境...");
UnidbgJNIWrapper.initialize();
System.out.println("✓ Unidbg环境初始化成功");
System.out.println();
// 步骤2: 设置生产环境(关键!)
System.out.println("【步骤2】设置生产环境...");
SecurityStub.setEnvironment(1);
System.out.println("✓ 已切换到生产环境");
System.out.println();
// 步骤3: 获取设备密钥ID
System.out.println("【步骤3】获取设备密钥ID...");
String priId = SecurityStub.getPriId();
System.out.println("✓ 设备密钥ID: " + priId);
System.out.println();
// 步骤4: 构造激活请求
System.out.println("【步骤4】构造激活请求...");
SecurityOperation secOp = new SecurityOperation();
ActivationKeySuiteReq request = secOp.activationKeySuite();
System.out.println("✓ 激活请求构造完成");
System.out.println(" - requestId: " + request.getRequestId());
System.out.println(" - deviceId: " + request.getDeviceId());
System.out.println(" - deviceKeyId: " + request.getDeviceKeyId());
System.out.println(" - 签名长度: " + request.getSign().length());
System.out.println();
// 步骤5: 调用key-suite API
System.out.println("【步骤5】调用key-suite API...");
KeySuiteApiClient keySuiteClient = new KeySuiteApiClient();
KeySuiteResponse response = keySuiteClient.activationKeySuite(request);
if (response != null && response.getCode() == 0) {
System.out.println("✓ key-suite API调用成功");
System.out.println(" - keyId: " + response.getData().getKeySuite().getKeyId());
System.out.println(" - 有效期: " + formatValidPeriod(
response.getData().getKeySuite().getValidFrom(),
response.getData().getKeySuite().getValidTo()
));
} else {
System.out.println("✗ key-suite API调用失败");
System.out.println(" - 错误码: " + (response != null ? response.getCode() : "null"));
System.out.println(" - 错误信息: " + (response != null ? response.getMsg() : "null"));
return;
}
System.out.println();
// 步骤6: 解密密钥
System.out.println("【步骤6】解密密钥...");
String[] decryptedKeys = SecurityStub.formatDK(
response.getData().getTempAesKey(),
response.getData().getTempIv(),
response.getData().getKeySuite().getKeySecret().getAesKey(),
response.getData().getKeySuite().getKeySecret().getHmacKey()
);
if (decryptedKeys != null && decryptedKeys.length >= 2) {
String aesKey = decryptedKeys[0];
String hacKey = decryptedKeys[1];
System.out.println("✓ 密钥解密成功");
System.out.println(" - AES密钥长度: " + aesKey.length());
System.out.println(" - HAC密钥长度: " + hacKey.length());
// 保存密钥
PreferencesHelper prefs = PreferencesHelper.getInstance();
prefs.setKeyId(response.getData().getKeySuite().getKeyId());
prefs.setAesKey(aesKey);
prefs.setHacKey(hacKey);
System.out.println("✓ 密钥已保存到本地存储");
} else {
System.out.println("✗ 密钥解密失败");
return;
}
System.out.println();
// 步骤7: 调用业务API
System.out.println("【步骤7】调用业务API...");
BusinessApiClient businessClient = new BusinessApiClient(
PreferencesHelper.getInstance()
);
ApiResponse apiResponse = businessClient.sendGetRequest(
"/api/v1/products/list"
);
if (apiResponse != null && apiResponse.isSuccess()) {
System.out.println("✓ 业务API调用成功!");
System.out.println(" - 响应数据长度: " + apiResponse.getRawResponse().length());
} else {
System.out.println("✗ 业务API调用失败");
}
System.out.println();
// 完成
System.out.println("╔════════════════════════════════════════════════════════════╗");
System.out.println("║ 调用链演示完成! ║");
System.out.println("╚════════════════════════════════════════════════════════════╝");
} catch (Exception e) {
System.err.println("程序执行出错: " + e.getMessage());
e.printStackTrace();
} finally {
// 清理资源
UnidbgJNIWrapper.destroy();
}
}
private static String formatValidPeriod(long from, long to) {
long days = (to - from) / (1000 * 60 * 60 * 24);
return days + "天";
}
}
5.8.2 运行结果
执行上述程序,预期输出如下:
╔════════════════════════════════════════════════════════════╗
║ 梦想世界APP安全激活调用链演示 ║
╚════════════════════════════════════════════════════════════╝
【步骤1】初始化Unidbg环境...
[UnidbgJNIWrapper] 创建ARM64模拟器...
[UnidbgJNIWrapper] 设置库解析器...
[UnidbgJNIWrapper] 创建DalvikVM...
[UnidbgJNIWrapper] 加载依赖库: libc++_shared.so
[UnidbgJNIWrapper] 加载目标库: libSecurityCore.so
[UnidbgJNIWrapper] 调用JNI_OnLoad...
[UnidbgJNIWrapper] 解析JNI类: com/dreamworld/secutil/JNIWrapper
✓ Unidbg环境初始化成功
【步骤2】设置生产环境...
[SecurityStub] setEnvironment(1)
✓ 已切换到生产环境
【步骤3】获取设备密钥ID...
[SecurityStub] getPriId()
✓ 设备密钥ID: f1e2d3c4b5a6978869574a3b2c1d0e0f
【步骤4】构造激活请求...
[SecurityOperation] 开始密钥套件激活流程
[SecurityOperation] 步骤1: 获取设备ID
[SecurityOperation] 设备ID: a1b2c3d4e5f67890
[SecurityOperation] 步骤2: 获取设备私钥ID
[SecurityOperation] 私钥ID: f1e2d3c4b5a6978869574a3b2c1d0e0f
[SecurityOperation] 步骤3: 生成时间戳
[SecurityOperation] 时间戳: 1736408400000
[SecurityOperation] 步骤4: 生成请求ID
[SecurityOperation] 请求ID: req1a2b3c4d5e6f7a8b9c0d1e2f3a4b5
[SecurityOperation] 步骤5: 生成随机数
[SecurityOperation] 随机数: nonce9a8b7c6d5e4f3a2b1c0d9e8f7a6b5
[SecurityOperation] 步骤6: 构造签名载荷
[SecurityOperation] 签名载荷: req1a2b3c4d5e6f7a8b9c0d1e2f3a4b5:a1b2c3d4e5f67890:f1e2d3c4b5a6978869574a3b2c1d0e0f:nonce9a8b7c6d5e4f3a2b1c0d9e8f7a6b5:1736408400000
[SecurityOperation] 步骤7: RSA签名
[SecurityOperation] 签名长度: 344
[SecurityOperation] 步骤8: 创建请求对象
[SecurityOperation] 激活请求构造完成
✓ 激活请求构造完成
- requestId: req1a2b3c4d5e6f7a8b9c0d1e2f3a4b5
- deviceId: a1b2c3d4e5f67890
- deviceKeyId: f1e2d3c4b5a6978869574a3b2c1d0e0f
- 签名长度: 344
【步骤5】调用key-suite API...
[KeySuiteApiClient] 发送激活请求
[KeySuiteApiClient] HTTP状态码: 200
[KeySuiteApiClient] 响应: {"code":0,"success":true,"msg":"success","data":{...}}
✓ key-suite API调用成功
- keyId: key1a2b3c4d5e6f7a8b9c0d1e2f3a4b5
- 有效期: 120天
【步骤6】解密密钥...
[SecurityStub] formatDK()
✓ 密钥解密成功
- AES密钥长度: 44
- HAC密钥长度: 44
✓ 密钥已保存到本地存储
【步骤7】调用业务API...
[BusinessApiClient] 发送GET请求: /api/v1/products/list
[BusinessApiClient] 签名字符串:
prod
8.x.x
key1a2b3c4d5e6f7a8b9c0d1e2f3a4b5
a1b2c3d4e5f67890
GET
application/json
zh-CN
1B2M2Y8AsgTpgAmY7PhCfg==
application/json
1736408460000
a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6
[BusinessApiClient] 签名结果长度: 44
[BusinessApiClient] HTTP响应码: 200
✓ 业务API调用成功!
- 响应数据长度: 85234
╔════════════════════════════════════════════════════════════╗
║ 调用链演示完成! ║
╚════════════════════════════════════════════════════════════╝
5.8.3 调用链流程图
┌─────────────────────────────────────────────────────────────────┐
│ 完整调用链流程图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 1. 初始化 │ │
│ │ Unidbg │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 2. 设置环境 │ setEnvironment(1) │
│ │ (生产) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 3. 获取密钥ID│ getPriId() │
│ │ (priId) │ → "f1e2d3c4b5a6978869574a3b2c1d0e0f" │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 4. RSA签名 │ rsaSign("", payload) │
│ │ │ → Base64签名 (344字符) │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 5. 调用 │────▶│ 服务器 │ │
│ │ key-suite │◀────│ 返回密钥 │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 6. 解密密钥 │ formatDK(tempAesKey, tempIv, aesKey, hmacKey) │
│ │ (formatDK) │ → [AES密钥, HAC密钥] │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 7. HMAC签名 │ hmacSign(hacKey, stringToSign) │
│ │ │ → Base64签名 │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 8. 调用 │────▶│ 服务器 │ │
│ │ 业务API │◀────│ 返回数据 │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.9 常见问题与解决方案
在实现JNI调用链的过程中,我们遇到了许多问题。这里总结一些常见问题及其解决方案:
5.9.1 错误码速查表
| 错误码 | 错误信息 | 原因 | 解决方案 |
|---|---|---|---|
| 144011 | device key not found | 环境设置错误 | 确保先调用setEnvironment(1) |
| 144012 | 签名验证失败 | 签名载荷或算法错误 | 检查签名字段顺序和分隔符 |
| 144013 | 时间戳过期 | 系统时间不正确 | 同步系统时间 |
| 100002 | 缺少必要参数 | Header缺失 | 检查所有必需的Header |
| 100022 | Content-MD5验证失败 | MD5编码错误 | 使用Base64编码而非十六进制 |
| 144001 | verifyKey错误 | JWT路径问题 | 使用key-suite路径 |
5.9.2 问题1:device key not found
症状:
{"code":144011,"success":false,"msg":"device key not found"}
原因分析: 这是最常见的错误,通常是因为:
- 没有调用setEnvironment()
- 调用顺序错误(先获取了priId再设置环境)
- 使用了测试环境的priId调用生产环境API
解决方案:
// 正确的调用顺序
SecurityStub.setEnvironment(1); // 必须先设置环境!
String priId = SecurityStub.getPriId(); // 然后再获取priId
5.9.3 问题2:formatDK返回null
症状:
String[] keys = SecurityStub.formatDK(...);
// keys == null 或 keys.length == 0
原因分析:
- ArrayObject解析方式不正确
- 传入的参数格式错误
解决方案:
// 正确的ArrayObject解析方式
DvmObject<?> result = jniWrapperClass.callStaticJniMethodObject(emulator, METHOD_FORMAT_DK, ...);
if (result instanceof ArrayObject) {
ArrayObject arrayObj = (ArrayObject) result;
// 使用getValue()获取数组元素
DvmObject<?>[] elements = arrayObj.getValue();
String aesKey = ((StringObject) elements[0]).getValue();
String hacKey = ((StringObject) elements[1]).getValue();
}
5.9.4 问题3:签名验证失败
症状:
{"code":144012,"success":false,"msg":"签名验证失败"}
原因分析:
- 签名字段顺序错误
- 签名字段值与Header不一致
- 换行符处理不正确
解决方案:
// 确保签名字符串的每个字段后都有换行符
StringBuilder sb = new StringBuilder();
sb.append(env).append("\n"); // 不是 "\r\n"
sb.append(version).append("\n");
// ... 其他字段
sb.append(nonce).append("\n"); // 最后一个字段也要有换行符!
5.9.5 问题4:Unidbg初始化失败
症状:
java.lang.UnsatisfiedLinkError: Unable to load library 'SecurityCore'
原因分析:
- SO库文件路径错误
- 依赖库未加载
- APK文件路径错误
解决方案:
// 确保文件路径正确
File apkFile = new File("/path/to/app.apk");
File libDir = new File("/path/to/libs");
// 确保依赖库先加载
vm.loadLibrary(new File(libDir, "libc++_shared.so"), false);
// 然后加载目标库
vm.loadLibrary(new File(libDir, "libSecurityCore.so"), false);
5.9.6 调试技巧
1. 开启Unidbg详细日志:
vm.setVerbose(true); // 开启详细日志
2. 打印签名字符串:
// 打印签名字符串,检查格式
System.out.println("签名字符串(带转义):");
System.out.println(stringToSign.replace("\n", "\n\n"));
3. 对比静态分析结果: 通过反编译代码分析APP的签名构造逻辑,确保我们的实现与原始逻辑一致。由于抓包无法获取数据,只能依赖代码分析。
4. 检查时间戳:
// 确保时间戳是毫秒级
long timestamp = System.currentTimeMillis();
System.out.println("当前时间戳: " + timestamp);
System.out.println("时间: " + new Date(timestamp));
5.10 安全机制深度分析
5.10.1 为什么这套安全机制难以破解?
梦想世界APP的安全机制设计得相当精妙,让我们分析一下它的安全性:
1. 多层密钥体系
┌─────────────────────────────────────────────────────────────────┐
│ 多层密钥体系 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 第一层:设备私钥(RSA-2048) │
│ ├── 存储位置:SO库内部 │
│ ├── 用途:生成设备密钥ID、RSA签名 │
│ └── 特点:无法直接提取,只能通过JNI调用 │
│ │
│ 第二层:临时AES密钥 │
│ ├── 来源:服务器生成,RSA加密传输 │
│ ├── 用途:解密业务密钥 │
│ └── 特点:每次激活不同,一次性使用 │
│ │
│ 第三层:业务密钥(AES + HMAC) │
│ ├── 来源:服务器生成,AES加密传输 │
│ ├── 用途:API请求签名 │
│ └── 特点:有效期约120天,定期更新 │
│ │
└─────────────────────────────────────────────────────────────────┘
2. 签名防篡改
- RSA签名:确保激活请求来自合法设备
- HMAC签名:确保API请求未被篡改
- Content-MD5:确保请求体完整性
3. 防重放攻击
- timestamp:时间戳验证
- nonce:随机数防重放
- requestId:请求唯一标识
5.10.2 我们是如何突破的?
关键突破点:使用Unidbg模拟执行SO库
┌─────────────────────────────────────────────────────────────────┐
│ 突破思路 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 传统思路(失败): │
│ ├── 尝试1:抓包分析 → 无法抓到请求 │
│ ├── 尝试2:Frida Hook → APP崩溃 │
│ ├── 尝试3:静态分析SO → 算法太复杂 │
│ └── 尝试4:动态调试 → 检测到调试器 │
│ │
│ 突破思路(成功): │
│ ├── 使用Unidbg模拟ARM64环境 │
│ ├── 加载真实的SO库 │
│ ├── 通过JNI接口调用原生方法 │
│ └── 完全复用APP的加密逻辑 │
│ │
│ 优势: │
│ ├── 不需要理解加密算法细节 │
│ ├── 不需要提取密钥 │
│ ├── 不会触发APP的安全检测 │
│ └── 可以在PC上独立运行 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.10.3 这种方法的局限性
虽然我们成功实现了调用链,但这种方法也有局限性:
1. 依赖真实的SO库和APK
- 需要获取目标APP的APK文件
- 需要提取SO库文件
- APP更新后可能需要重新适配
2. 需要处理JNI回调
- 某些JNI方法可能需要Java层的回调
- 需要模拟Android系统API
3. 性能开销
- Unidbg模拟执行比原生执行慢
- 不适合高并发场景
4. 法律风险
- 逆向工程可能违反服务条款
- 仅供学习研究使用
5.11 本章小结
5.11.1 核心收获
通过本章的学习,我们掌握了以下关键技能:
1. JNI调用链分析
- 学会了如何从反编译代码中识别JNI方法
- 理解了混淆方法名与实际功能的映射关系
- 掌握了JNI方法签名的解析方法
2. 安全激活流程
- 理解了三层安全架构:设备激活 → 密钥解密 → API签名
- 掌握了正确的调用顺序:环境设置 → 获取密钥ID → RSA签名 → 激活 → 解密 → HMAC签名
- 发现了关键细节:必须先设置生产环境
3. 签名算法实现
- RSA签名:用于设备激活请求
- HMAC签名:用于API请求验证
- Content-MD5:使用Base64编码而非十六进制
4. 问题排查能力
- 学会了分析错误码和错误信息
- 掌握了常见问题的解决方案
- 建立了系统的调试方法
5.11.2 关键发现总结
┌─────────────────────────────────────────────────────────────────┐
│ 本章关键发现 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 发现1: 环境设置是关键 │
│ ├── 必须先调用 setEnvironment(1) 设置生产环境 │
│ ├── 不同环境返回不同的密钥ID │
│ └── 顺序错误会导致 "device key not found" 错误 │
│ │
│ 发现2: Content-MD5使用Base64编码 │
│ ├── 不是常见的十六进制编码 │
│ ├── 空字符串MD5: 1B2M2Y8AsgTpgAmY7PhCfg== │
│ └── GET请求也需要发送Content-MD5 │
│ │
│ 发现3: 签名字符串格式严格 │
│ ├── 11个字段,每个字段后跟换行符 │
│ ├── 字段顺序不能改变 │
│ └── 最后一个字段也需要换行符 │
│ │
│ 发现4: 两条独立的安全路径 │
│ ├── 路径A (key-suite): RSA签名,成功率高 │
│ └── 路径B (JWT): Ed25519签名,需要服务器注册 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.11.3 下一章预告
在下一章中,我们将深入探讨三大核心坑点的攻克:
- 环境设置陷阱:为什么总是返回"device key not found"
- Content-MD5编码问题:十六进制还是Base64
- 数组返回值解析:如何正确处理formatDK的返回值
- 完整的问题排查与解决方案
本章附录
附录A:JNI方法签名速查
| 方法 | 签名 | 说明 |
|---|---|---|
| setEnvironment | (Ljava/lang/String;)V | 设置环境,参数为字符串 |
| getPriId | ()Ljava/lang/String; | 获取密钥ID,无参数,返回字符串 |
| rsaSign | (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; | RSA签名,两个字符串参数 |
| formatDK | (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[Ljava/lang/String; | 解密密钥,四个参数,返回字符串数组 |
| hmacSign | (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; | HMAC签名,两个字符串参数 |
附录B:HTTP Header清单
key-suite请求必需Header:
Content-Type: application/json; charset=UTF-8
Accept: application/json
X-DW-Deviceid: {deviceId}
X-DW-DeviceType: 2
X-DW-ModelName: ANDROID
X-DW-DeviceModel: {deviceModel}
X-DW-APP-Version: {appVersion}
X-DW-Version: 1.0
X-DW-Env: prod
X-DW-Timestamp: {timestamp}
X-DW-Nonce: {nonce}
Content-MD5: {contentMd5}
Content-Language: zh-CN
业务API请求必需Header:
X-DW-Deviceid: {deviceId}
X-DW-APP-Version: {appVersion}
X-DW-Env: prod
X-DW-Version: {version}
X-DW-Key: {keyId}
X-DW-Timestamp: {timestamp}
X-DW-Nonce: {nonce}
X-DW-Sign: {hmacSign}
X-DW-Token: {token}
X-DW-DeviceType: 2
X-DW-ModelName: ANDROID
X-DW-DeviceModel: {deviceModel}
Content-Type: application/json
Content-Language: zh-CN
Content-MD5: {contentMd5}
Accept: application/json
附录C:签名字符串模板
{env}\n
{version}\n
{keyId}\n
{deviceId}\n
{method}\n
{accept}\n
{contentLanguage}\n
{contentMd5}\n
{contentType}\n
{timestamp}\n
{nonce}\n
本章完