第五章:JNI调用链深度剖析

31 阅读33分钟

第五章:JNI调用链深度剖析

本章字数:约30000字 阅读时间:约100分钟 难度等级:★★★★★

声明:本文中的公司名称、包名、API地址、密钥等均已脱敏处理。文中的"梦想世界"、"dreamworld"等均为虚构名称,与任何真实公司无关。


引言

在上一章中,我们成功搭建了Unidbg环境,并验证了可以加载梦想世界APP的SO库。但这只是万里长征的第一步。

要真正实现数据获取,我们需要:

  1. 理解完整的安全激活流程
  2. 分析每个JNI方法的作用
  3. 掌握正确的调用顺序
  4. 处理服务器响应并解密密钥

本章将深入剖析整个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存在两条独立的安全激活路径:

路径接口签名算法密钥来源适用场景
路径Akey-suiteRSA-2048getPriId()主要路径
路径BJWTEd25519getKeyPair()备用路径

路径A(key-suite) 是我们重点分析的路径,因为:

  1. 它是APP的主要激活路径
  2. 成功率更高
  3. 逻辑相对简单

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 = a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
  • env=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...getPriIdString获取设备密钥ID
o9863c14dd...rsaSign(String, String)StringRSA签名
o8eb2ed51f...getKeyPairString获取Ed25519密钥对
of9bac8ee8...setEnvironment(String)void设置环境
obf59c6fac...formatDK(String×4)String[]解密密钥
ob1442c25d...hmacSign(String, String)StringHMAC签名
oe1e991556...rsaVerify(String×5)booleanRSA验签
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 响应字段说明

字段类型说明
codeint业务状态码,0表示成功
successboolean是否成功
msgstring状态消息
data.requestIdstring请求ID(与请求中的相同)
data.keySuite.keyIdstring密钥ID,后续API请求需要
data.keySuite.keySecret.aesKeystring加密的AES密钥
data.keySuite.keySecret.hmacKeystring加密的HMAC密钥
data.keySuite.validFromlong密钥生效时间(毫秒时间戳)
data.keySuite.validTolong密钥过期时间(毫秒时间戳)
data.tempAesKeystringRSA加密的临时AES密钥
data.tempIvstring临时IV
data.spPublicKeystring服务器公钥
data.spPublicKeySignstring服务器公钥签名
data.signstring响应签名

5.4.4 错误码说明

错误码说明解决方案
0成功-
144011device key not found检查环境设置,确保使用生产环境
144012签名验证失败检查签名载荷和签名算法
144013时间戳过期检查系统时间是否正确
100002缺少必要参数检查请求头和请求体
100022Content-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 错误码速查表

错误码错误信息原因解决方案
144011device key not found环境设置错误确保先调用setEnvironment(1)
144012签名验证失败签名载荷或算法错误检查签名字段顺序和分隔符
144013时间戳过期系统时间不正确同步系统时间
100002缺少必要参数Header缺失检查所有必需的Header
100022Content-MD5验证失败MD5编码错误使用Base64编码而非十六进制
144001verifyKey错误JWT路径问题使用key-suite路径

5.9.2 问题1:device key not found

症状

{"code":144011,"success":false,"msg":"device key not found"}

原因分析: 这是最常见的错误,通常是因为:

  1. 没有调用setEnvironment()
  2. 调用顺序错误(先获取了priId再设置环境)
  3. 使用了测试环境的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

原因分析

  1. ArrayObject解析方式不正确
  2. 传入的参数格式错误

解决方案

// 正确的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":"签名验证失败"}

原因分析

  1. 签名字段顺序错误
  2. 签名字段值与Header不一致
  3. 换行符处理不正确

解决方案

// 确保签名字符串的每个字段后都有换行符
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'

原因分析

  1. SO库文件路径错误
  2. 依赖库未加载
  3. 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

本章完