第六章:三大核心坑点攻克

25 阅读21分钟

第六章:三大核心坑点攻克

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

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


引言

在上一章中,我们完整剖析了JNI调用链的实现细节。然而,从理论到实践的道路上,我们遇到了三个让人抓狂的核心问题:

  1. 环境设置陷阱:为什么总是返回"device key not found"?
  2. Content-MD5编码之谜:为什么MD5验证总是失败?
  3. ArrayObject解析困境:为什么formatDK返回的数组无法正确解析?

这三个问题,每一个都让我们卡了很长时间。本章将详细记录我们是如何一步步攻克这些难关的,希望能帮助读者在遇到类似问题时少走弯路。


6.1 坑点一:环境设置陷阱

6.1.1 问题现象

当我们第一次尝试调用key-suite API时,满怀期待地等待响应,结果却是:

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

"device key not found"?设备密钥找不到?我们明明调用了getPriId()获取到了密钥ID啊!

// 我们的初始代码
String priId = SecurityStub.getPriId();
System.out.println("获取到的priId: " + priId);
// 输出: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6

// 构造请求并发送...
// 结果: device key not found

6.1.2 排查过程

第一步:检查priId格式

我们首先怀疑是priId格式有问题:

String priId = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
System.out.println("priId长度: " + priId.length());  // 32
System.out.println("是否全为十六进制: " + priId.matches("[0-9a-f]+"));  // true

格式看起来没问题,32位十六进制字符串。

第二步:静态分析代码寻找线索

既然抓包抓不到(会崩溃或无数据),我们只能回到静态分析。仔细审查反编译代码,发现一个关键细节——APP在启动时有一个初始化调用:

// MainApplication.java
public class MainApplication extends Application {
    
    @Override
    public void onCreate() {
        super.onCreate();
        // ... 其他初始化代码 ...
        t();  // 关键调用!
    }
    
    private void t() {
        // 根据环境设置参数
        InternalStub.e(com.dreamworld.oc.m01.common.util.f.f() ? 1 : 0);
    }
}

InternalStub.e()是什么?让我们继续追踪:

// InternalStub.java
public class InternalStub {
    public static void e(int env) {
        JNIWrapper.oSetEnv1a2b3c4d5e6f7a8b9c0d1e2f3a4(String.valueOf(env));
    }
}

原来InternalStub.e()就是调用setEnvironment()

再看f.f()的实现:

// f.java
public static boolean f() {
    String env = getEnvironment();
    return "prod".equals(env) || "exercises".equals(env) || "preprod".equals(env);
}

真相大白:同一个SO库被测试环境和生产环境共用,APP在启动时会根据环境配置调用setEnvironment(),生产环境传入1,测试环境传入0。而我们在调用getPriId()之前没有先调用setEnvironment(1)设置生产环境!

6.1.3 验证假设

让我们验证这个假设:

// 测试1:不设置环境,直接获取priId
String priId1 = SecurityStub.getPriId();
System.out.println("未设置环境的priId: " + priId1);
// 输出: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6

// 测试2:设置测试环境后获取priId
SecurityStub.setEnvironment(0);
String priId2 = SecurityStub.getPriId();
System.out.println("测试环境的priId: " + priId2);
// 输出: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6

// 测试3:设置生产环境后获取priId
SecurityStub.setEnvironment(1);
String priId3 = SecurityStub.getPriId();
System.out.println("生产环境的priId: " + priId3);
// 输出: f1e2d3c4b5a6978869574a3b2c1d0e0f  ← 这才是生产环境的密钥ID!

找到原因了! 不同环境返回不同的priId,而服务器只认生产环境的priId!默认情况下(不调用setEnvironment或传入0),返回的是测试环境的密钥ID,这个ID在生产服务器上没有对应的deviceKey记录。

6.1.4 深入分析:为什么会这样设计?

这种设计其实很巧妙:

┌─────────────────────────────────────────────────────────────────┐
│                    环境隔离设计                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  测试环境 (env=0)                                                │
│  ├── priId: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6                    │
│  ├── 服务器: test-api.dreamworld.com                            │
│  └── 用途: 开发测试,不影响生产数据                              │
│                                                                  │
│  生产环境 (env=1)                                                │
│  ├── priId: f1e2d3c4b5a6978869574a3b2c1d0e0f                    │
│  ├── 服务器: api.dreamworld.com                                 │
│  └── 用途: 正式用户使用                                          │
│                                                                  │
│  安全意义:                                                       │
│  ├── 即使攻击者获取了测试环境的密钥,也无法访问生产环境          │
│  ├── 测试环境和生产环境完全隔离                                  │
│  └── 增加了逆向分析的难度                                        │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.1.5 解决方案

修复非常简单,只需要在获取priId之前先设置环境:

// 正确的调用顺序
public void initialize() {
    // 步骤1:必须先设置生产环境!
    SecurityStub.setEnvironment(1);
    System.out.println("✓ 已设置生产环境");
    
    // 步骤2:然后再获取priId
    String priId = SecurityStub.getPriId();
    System.out.println("✓ 获取priId: " + priId);
    
    // 后续操作...
}

6.1.6 经验教训

┌─────────────────────────────────────────────────────────────────┐
│                    经验教训 #1                                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  问题: 忽略了APP的初始化流程                                     │
│                                                                  │
│  教训:                                                           │
│  1. 逆向分析时,不仅要关注目标方法,还要关注调用上下文           │
│  2. Application.onCreate() 中的初始化代码往往包含关键逻辑       │
│  3. 环境设置、配置初始化等"准备工作"容易被忽略                  │
│                                                                  │
│  方法论:                                                         │
│  1. 从APP入口开始,完整追踪初始化流程                            │
│  2. 记录所有native方法的调用顺序                                 │
│  3. 对比真实APP的行为,找出差异                                  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.2 坑点二:Content-MD5编码之谜

6.2.1 问题现象

解决了环境设置问题后,key-suite API终于返回成功了!我们拿到了密钥,开始调用业务API。

然而,新的问题出现了:

{
    "code": 100022,
    "success": false,
    "msg": "验证content md5值失败"
}

Content-MD5验证失败?我们明明计算了MD5啊!

// 我们的MD5计算代码
private String md5(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
String contentMd5 = md5("");
System.out.println("Content-MD5: " + contentMd5);
// 输出: d41d8cd98f00b204e9800998ecf8427e

这个MD5值是正确的啊,为什么服务器不认?

6.2.2 排查过程

第一步:检查MD5算法

我们首先验证MD5算法是否正确:

// 验证MD5算法
String testInput = "hello";
String md5Result = md5(testInput);
System.out.println("'hello'的MD5: " + md5Result);
// 输出: 5d41402abc4b2a76b9719d911017c592

// 使用在线工具验证:正确!

MD5算法没问题。

第二步:检查请求头

我们打印了完整的请求头:

Content-Type: application/json
Content-MD5: d41d8cd98f00b204e9800998ecf8427e
X-DW-Sign: Kx7mN2pQ9rS1tU3vW5xY7zA9bC1dE3fG5hI7jK9lM1nO3pQ5rS7tU9vW1xY3zA==
...

看起来也没问题。

第三步:深入分析反编译代码

我们找到了APP中计算Content-MD5的代码:

// FedMd5Utils.java
public class FedMd5Utils {
    
    public static String md5(String str) {
        try {
            MessageDigest instance = MessageDigest.getInstance("MD5");
            instance.update(str.getBytes(StandardCharsets.UTF_8));
            byte[] digest = instance.digest();
            // 注意这里!
            return Base64.encodeToString(digest, Base64.NO_WRAP);
        } catch (Exception e) {
            return "";
        }
    }
}

发现问题了! APP使用的是Base64.encodeToString(),而不是十六进制编码!

6.2.3 对比分析

让我们对比两种编码方式:

// 空字符串的MD5
byte[] digest = MessageDigest.getInstance("MD5").digest("".getBytes());

// 方式1:十六进制编码(我们的实现)
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
    sb.append(String.format("%02x", b));
}
String hexMd5 = sb.toString();
System.out.println("十六进制: " + hexMd5);
// 输出: d41d8cd98f00b204e9800998ecf8427e (32字符)

// 方式2:Base64编码(APP的实现)
String base64Md5 = Base64.getEncoder().encodeToString(digest);
System.out.println("Base64: " + base64Md5);
// 输出: 1B2M2Y8AsgTpgAmY7PhCfg== (24字符)

两种编码方式产生完全不同的结果!

┌─────────────────────────────────────────────────────────────────┐
│                    MD5编码方式对比                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  输入: "" (空字符串)                                             │
│                                                                  │
│  MD5字节数组 (16字节):                                           │
│  [d4, 1d, 8c, d9, 8f, 00, b2, 04, e9, 80, 09, 98, ec, f8, 42, 7e]│
│                                                                  │
│  十六进制编码 (32字符):                                          │
│  d41d8cd98f00b204e9800998ecf8427e                                │
│                                                                  │
│  Base64编码 (24字符):                                            │
│  1B2M2Y8AsgTpgAmY7PhCfg==                                        │
│                                                                  │
│  服务器期望: Base64编码                                          │
│  我们发送的: 十六进制编码                                        │
│  结果: 验证失败!                                                │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.2.4 为什么会犯这个错误?

这个错误很容易犯,原因如下:

  1. 惯性思维:大多数场景下,MD5都是用十六进制表示的
  2. HTTP规范模糊:HTTP规范中Content-MD5确实应该用Base64,但很多实现用十六进制
  3. 缺乏文档:服务器没有明确说明编码方式

6.2.5 解决方案

修改MD5计算方法,使用Base64编码:

/**
 * 计算MD5并返回Base64编码
 * 
 * 重要:梦想世界APP使用Base64编码,而非十六进制!
 */
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 "";
    }
}

// 使用示例
String contentMd5 = md5Base64(requestBody);
// 空字符串: 1B2M2Y8AsgTpgAmY7PhCfg==

6.2.6 另一个隐藏的坑

在解决了编码问题后,我们又遇到了一个问题:

{
    "code": 100002,
    "success": false,
    "msg": "缺少必要的请求参数: Content-MD5"
}

GET请求也需要Content-MD5?

是的!即使是GET请求,也必须发送Content-MD5 Header,值为空字符串的MD5:

// GET请求
String contentMd5 = md5Base64("");  // "1B2M2Y8AsgTpgAmY7PhCfg=="
conn.setRequestProperty("Content-MD5", contentMd5);

// POST请求
String contentMd5 = md5Base64(requestBody);
conn.setRequestProperty("Content-MD5", contentMd5);

6.2.7 经验教训

┌─────────────────────────────────────────────────────────────────┐
│                    经验教训 #2                                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  问题: 假设MD5使用十六进制编码                                   │
│                                                                  │
│  教训:                                                           │
│  1. 不要假设,要验证!找到原始代码确认编码方式                   │
│  2. HTTP规范中Content-MD5应该用Base64,但实现各异               │
│  3. 仔细阅读反编译代码,注意每一个细节                           │
│                                                                  │
│  方法论:                                                         │
│  1. 找到APP中计算该值的具体代码                                  │
│  2. 逐行分析,特别注意编码/解码操作                              │
│  3. 用相同的输入验证输出是否一致                                 │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.3 坑点三:ArrayObject解析困境

6.3.1 问题现象

key-suite API成功返回后,我们需要调用formatDK()解密密钥。然而:

// 调用formatDK
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)
);

// 尝试解析结果
if (result != null) {
    System.out.println("结果类型: " + result.getClass().getName());
    System.out.println("结果值: " + result.getValue());
}

输出:

结果类型: com.github.unidbg.linux.android.dvm.array.ArrayObject
结果值: [Ljava.lang.String;@1a2b3c4d

结果是一个ArrayObject,但是如何获取里面的字符串呢?

6.3.2 第一次尝试:直接转换

// 尝试1:直接转换为String数组
String[] keys = (String[]) result.getValue();
// 结果: ClassCastException!

失败!getValue()返回的不是String数组。

6.3.3 第二次尝试:使用length()

// 尝试2:获取数组长度
ArrayObject arrayObj = (ArrayObject) result;
int length = arrayObj.length();
System.out.println("数组长度: " + length);
// 输出: 2

// 尝试获取元素
Object element = arrayObj.getValue();
System.out.println("元素类型: " + element.getClass().getName());
// 输出: [Lcom.github.unidbg.linux.android.dvm.DvmObject;

数组长度是2,符合预期(AES密钥和HMAC密钥)。但getValue()返回的是DvmObject[],不是String[]

6.3.4 第三次尝试:遍历DvmObject数组

// 尝试3:遍历DvmObject数组
ArrayObject arrayObj = (ArrayObject) result;
DvmObject<?>[] elements = (DvmObject<?>[]) arrayObj.getValue();

for (int i = 0; i < elements.length; i++) {
    DvmObject<?> element = elements[i];
    System.out.println("元素" + i + "类型: " + element.getClass().getName());
    System.out.println("元素" + i + "值: " + element.getValue());
}

输出:

元素0类型: com.github.unidbg.linux.android.dvm.StringObject
元素0值: WkRNd1pUTTRNV1V0TkRrME5pMDBaR1F5TFRnM...
元素1类型: com.github.unidbg.linux.android.dvm.StringObject
元素1值: YUdGamEyVjVMVEV5TXpRMU5qYzRPVEF0WVdK...

成功了! 元素是StringObject类型,可以通过getValue()获取字符串值。

6.3.5 正确的解析方式

/**
 * 解析formatDK返回的数组
 */
public String[] parseFormatDKResult(DvmObject<?> result) {
    if (result == null) {
        System.err.println("formatDK返回null");
        return null;
    }
    
    if (!(result instanceof ArrayObject)) {
        System.err.println("返回值不是ArrayObject: " + result.getClass().getName());
        return null;
    }
    
    ArrayObject arrayObj = (ArrayObject) result;
    DvmObject<?>[] elements = arrayObj.getValue();
    
    if (elements == null || elements.length < 2) {
        System.err.println("数组元素不足: " + (elements != null ? elements.length : 0));
        return null;
    }
    
    String[] keys = new String[elements.length];
    for (int i = 0; i < elements.length; i++) {
        DvmObject<?> element = elements[i];
        if (element instanceof StringObject) {
            keys[i] = ((StringObject) element).getValue();
        } else {
            System.err.println("元素" + i + "不是StringObject: " + element.getClass().getName());
            keys[i] = null;
        }
    }
    
    return keys;
}

6.3.6 Unidbg数组处理详解

让我们深入理解Unidbg中数组的处理方式:

┌─────────────────────────────────────────────────────────────────┐
│                    Unidbg数组类型体系                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  JNI返回类型          Unidbg类型           getValue()返回        │
│  ─────────────────────────────────────────────────────────────  │
│  String              StringObject         String                 │
│  int                 IntObject            Integer                │
│  boolean             BooleanObject        Boolean                │
│  byte[]              ByteArray            byte[]                 │
│  String[]            ArrayObject          DvmObject<?>[]         │
│  Object[]            ArrayObject          DvmObject<?>[]         │
│                                                                  │
│  注意事项:                                                       │
│  1. ArrayObject.getValue() 返回 DvmObject<?>[]                  │
│  2. 需要遍历数组,逐个转换为具体类型                             │
│  3. 字符串数组的元素是 StringObject                              │
│  4. 对象数组的元素是 DvmObject                                   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.3.7 完整的formatDK调用封装

package com.dreamworld.security;

import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.array.ArrayObject;

/**
 * 安全方法封装类
 */
public class SecurityStub {
    
    private static UnidbgJNIWrapper wrapper;
    
    /**
     * 解密密钥
     * 
     * @param tempAesKey RSA加密的临时AES密钥
     * @param tempIv 临时IV
     * @param aesKey AES加密的业务AES密钥
     * @param hmacKey AES加密的业务HMAC密钥
     * @return [解密后的AES密钥, 解密后的HMAC密钥]
     */
    public static String[] formatDK(String tempAesKey, String tempIv, 
            String aesKey, String hmacKey) {
        
        System.out.println("[SecurityStub] formatDK()");
        System.out.println("  tempAesKey长度: " + (tempAesKey != null ? tempAesKey.length() : 0));
        System.out.println("  tempIv长度: " + (tempIv != null ? tempIv.length() : 0));
        System.out.println("  aesKey长度: " + (aesKey != null ? aesKey.length() : 0));
        System.out.println("  hmacKey长度: " + (hmacKey != null ? hmacKey.length() : 0));
        
        try {
            DvmObject<?> result = wrapper.callFormatDK(tempAesKey, tempIv, aesKey, hmacKey);
            
            if (result == null) {
                System.err.println("[SecurityStub] formatDK返回null");
                return null;
            }
            
            // 解析ArrayObject
            if (result instanceof ArrayObject) {
                ArrayObject arrayObj = (ArrayObject) result;
                DvmObject<?>[] elements = arrayObj.getValue();
                
                if (elements == null || elements.length < 2) {
                    System.err.println("[SecurityStub] 数组元素不足");
                    return null;
                }
                
                String[] keys = new String[2];
                
                // 获取AES密钥
                if (elements[0] instanceof StringObject) {
                    keys[0] = ((StringObject) elements[0]).getValue();
                    System.out.println("[SecurityStub] AES密钥解密成功,长度: " + keys[0].length());
                }
                
                // 获取HMAC密钥
                if (elements[1] instanceof StringObject) {
                    keys[1] = ((StringObject) elements[1]).getValue();
                    System.out.println("[SecurityStub] HMAC密钥解密成功,长度: " + keys[1].length());
                }
                
                return keys;
            } else {
                System.err.println("[SecurityStub] 返回值类型错误: " + result.getClass().getName());
                return null;
            }
            
        } catch (Exception e) {
            System.err.println("[SecurityStub] formatDK异常: " + e.getMessage());
            e.printStackTrace();
            return null;
        }
    }
}

6.3.8 调试技巧

在处理Unidbg返回值时,以下调试技巧很有用:

/**
 * 调试工具:打印DvmObject的详细信息
 */
public static void debugDvmObject(DvmObject<?> obj, String name) {
    System.out.println("=== 调试 " + name + " ===");
    
    if (obj == null) {
        System.out.println("  值: null");
        return;
    }
    
    System.out.println("  类型: " + obj.getClass().getName());
    System.out.println("  DVM类型: " + obj.getObjectType());
    
    Object value = obj.getValue();
    if (value != null) {
        System.out.println("  值类型: " + value.getClass().getName());
        
        if (value instanceof String) {
            String str = (String) value;
            System.out.println("  值长度: " + str.length());
            if (str.length() <= 50) {
                System.out.println("  值内容: " + str);
            } else {
                System.out.println("  值内容: " + str.substring(0, 50) + "...");
            }
        } else if (value.getClass().isArray()) {
            System.out.println("  数组长度: " + java.lang.reflect.Array.getLength(value));
        }
    }
    
    System.out.println("=== 调试结束 ===");
}

6.3.9 经验教训

┌─────────────────────────────────────────────────────────────────┐
│                    经验教训 #3                                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  问题: 不熟悉Unidbg的类型系统                                    │
│                                                                  │
│  教训:                                                           │
│  1. Unidbg有自己的类型包装体系,不能直接转换为Java类型           │
│  2. ArrayObject.getValue() 返回 DvmObject<?>[]                  │
│  3. 需要逐个元素转换为具体类型                                   │
│                                                                  │
│  方法论:                                                         │
│  1. 先打印返回值的类型,了解实际结构                             │
│  2. 查阅Unidbg源码,理解类型体系                                 │
│  3. 编写通用的调试工具,方便排查问题                             │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.4 其他踩过的坑

除了上述三大核心坑点,我们还遇到了一些其他问题,这里一并记录。

6.4.1 坑点四:签名字符串的换行符

问题现象

{
    "code": 144012,
    "success": false,
    "msg": "签名验证失败"
}

排查过程

我们对比了签名字符串,发现一个细微的差异:

// 我们的实现
String stringToSign = env + "\n" + version + "\n" + keyId + "\n" + ...;

// 打印出来检查
System.out.println("签名字符串:");
System.out.println(stringToSign);

输出看起来正常,但签名就是不对。

问题原因

最后一个字段后面也需要换行符!

// 错误的实现
String stringToSign = 
    env + "\n" +
    version + "\n" +
    keyId + "\n" +
    deviceId + "\n" +
    method + "\n" +
    accept + "\n" +
    contentLanguage + "\n" +
    contentMd5 + "\n" +
    contentType + "\n" +
    timestamp + "\n" +
    nonce;  // 最后没有换行符!

// 正确的实现
String stringToSign = 
    env + "\n" +
    version + "\n" +
    keyId + "\n" +
    deviceId + "\n" +
    method + "\n" +
    accept + "\n" +
    contentLanguage + "\n" +
    contentMd5 + "\n" +
    contentType + "\n" +
    timestamp + "\n" +
    nonce + "\n";  // 最后也要有换行符!

验证方法

// 打印签名字符串,显示换行符
System.out.println("签名字符串(带转义):");
System.out.println(stringToSign.replace("\n", "\n\n"));

// 检查最后一个字符
char lastChar = stringToSign.charAt(stringToSign.length() - 1);
System.out.println("最后一个字符: " + (lastChar == '\n' ? "换行符" : "'" + lastChar + "'"));

6.4.2 坑点五:时间戳精度

问题现象

{
    "code": 144013,
    "success": false,
    "msg": "时间戳过期"
}

排查过程

我们的时间戳明明是当前时间,为什么会过期?

// 检查时间戳
long timestamp = System.currentTimeMillis();
System.out.println("时间戳: " + timestamp);
System.out.println("对应时间: " + new Date(timestamp));

输出:

时间戳: 1736408400000
对应时间: Thu Jan 09 10:00:00 CST 2026

时间是对的啊?

问题原因

原来是我们在构造请求时,生成了时间戳,但在发送请求前又做了一些耗时操作,导致实际发送时时间戳已经"过期"了。

// 问题代码
String timestamp = String.valueOf(System.currentTimeMillis());
// ... 一些耗时操作(比如打印日志、构造请求体等)...
// 等到真正发送请求时,可能已经过了好几秒

// 解决方案:在发送请求前才生成时间戳
public void sendRequest() {
    // 在最后一刻生成时间戳
    String timestamp = String.valueOf(System.currentTimeMillis());
    String nonce = UUID.randomUUID().toString();
    
    // 立即构造签名并发送
    String sign = buildSign(timestamp, nonce, ...);
    // 立即发送请求
    sendHttpRequest(timestamp, nonce, sign, ...);
}

6.4.3 坑点六:Header大小写

问题现象

某些Header似乎没有生效。

问题原因

HTTP Header名称虽然规范上是大小写不敏感的,但某些服务器实现可能对大小写敏感。

// 可能有问题的写法
conn.setRequestProperty("x-dw-deviceid", deviceId);  // 全小写
conn.setRequestProperty("X-Dw-Deviceid", deviceId);  // 混合大小写

// 推荐的写法:与原始APP保持一致
conn.setRequestProperty("X-DW-Deviceid", deviceId);
conn.setRequestProperty("X-DW-APP-Version", version);
conn.setRequestProperty("X-DW-Env", env);

6.4.4 坑点七:字符编码

问题现象

包含中文的请求体签名验证失败。

问题原因

计算MD5时没有指定字符编码:

// 问题代码
byte[] digest = md.digest(input.getBytes());  // 使用默认编码

// 正确代码
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));  // 明确指定UTF-8

6.4.5 坑点八:Unidbg内存泄漏

问题现象

长时间运行后,程序越来越慢,最终OOM。

问题原因

每次调用都创建新的Emulator实例,没有复用:

// 问题代码
public String getPriId() {
    AndroidEmulator emulator = AndroidEmulatorBuilder.for64Bit().build();
    // ... 使用emulator ...
    // 没有关闭emulator!
    return result;
}

// 正确代码
public class UnidbgJNIWrapper {
    private static AndroidEmulator emulator;  // 单例复用
    
    public static void initialize() {
        if (emulator == null) {
            emulator = AndroidEmulatorBuilder.for64Bit().build();
            // ... 初始化 ...
        }
    }
    
    public static void destroy() {
        if (emulator != null) {
            emulator.close();
            emulator = null;
        }
    }
}

6.5 问题排查方法论

通过解决这些问题,我们总结出一套系统的问题排查方法论。

6.5.1 排查流程图

┌─────────────────────────────────────────────────────────────────┐
│                    问题排查流程                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────┐                                                │
│  │ 1. 复现问题  │  记录错误信息、错误码、上下文                  │
│  └──────┬───────┘                                                │
│         │                                                        │
│         ▼                                                        │
│  ┌──────────────┐                                                │
│  │ 2. 分析错误  │  理解错误码含义,缩小问题范围                  │
│  └──────┬───────┘                                                │
│         │                                                        │
│         ▼                                                        │
│  ┌──────────────┐                                                │
│  │ 3. 对比分析  │  与原始APP行为对比,找出差异                   │
│  └──────┬───────┘                                                │
│         │                                                        │
│         ▼                                                        │
│  ┌──────────────┐                                                │
│  │ 4. 查看源码  │  找到原始APP的相关代码,理解实现               │
│  └──────┬───────┘                                                │
│         │                                                        │
│         ▼                                                        │
│  ┌──────────────┐                                                │
│  │ 5. 验证假设  │  修改代码,验证问题原因                        │
│  └──────┬───────┘                                                │
│         │                                                        │
│         ▼                                                        │
│  ┌──────────────┐                                                │
│  │ 6. 修复问题  │  实施修复,完整测试                            │
│  └──────┬───────┘                                                │
│         │                                                        │
│         ▼                                                        │
│  ┌──────────────┐                                                │
│  │ 7. 记录经验  │  总结问题原因和解决方案                        │
│  └──────────────┘                                                │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.5.2 常用调试技巧

1. 详细日志

public class LogUtils {
    private static boolean DEBUG = true;
    
    public static void d(String tag, String msg) {
        if (DEBUG) {
            System.out.println("[" + tag + "] " + msg);
        }
    }
    
    public static void dumpBytes(String tag, byte[] bytes) {
        if (DEBUG) {
            StringBuilder sb = new StringBuilder();
            for (byte b : bytes) {
                sb.append(String.format("%02x ", b));
            }
            System.out.println("[" + tag + "] bytes: " + sb.toString());
        }
    }
    
    public static void dumpString(String tag, String str) {
        if (DEBUG) {
            System.out.println("[" + tag + "] string: "" + str + """);
            System.out.println("[" + tag + "] length: " + str.length());
            System.out.println("[" + tag + "] escaped: " + escape(str));
        }
    }
    
    private static String escape(String str) {
        return str.replace("\n", "\n")
                  .replace("\r", "\r")
                  .replace("\t", "\t");
    }
}

2. 请求/响应对比

public class RequestComparator {
    
    /**
     * 对比两个请求的差异
     */
    public static void compare(Map<String, String> expected, Map<String, String> actual) {
        System.out.println("=== 请求对比 ===");
        
        Set<String> allKeys = new HashSet<>();
        allKeys.addAll(expected.keySet());
        allKeys.addAll(actual.keySet());
        
        for (String key : allKeys) {
            String expectedValue = expected.get(key);
            String actualValue = actual.get(key);
            
            if (expectedValue == null) {
                System.out.println("  [多余] " + key + ": " + actualValue);
            } else if (actualValue == null) {
                System.out.println("  [缺失] " + key + ": " + expectedValue);
            } else if (!expectedValue.equals(actualValue)) {
                System.out.println("  [不同] " + key + ":");
                System.out.println("    期望: " + expectedValue);
                System.out.println("    实际: " + actualValue);
            } else {
                System.out.println("  [相同] " + key);
            }
        }
        
        System.out.println("=== 对比结束 ===");
    }
}

3. 签名验证工具

public class SignatureVerifier {
    
    /**
     * 验证签名字符串格式
     */
    public static void verify(String stringToSign) {
        System.out.println("=== 签名字符串验证 ===");
        
        String[] lines = stringToSign.split("\n", -1);  // -1保留空字符串
        System.out.println("行数: " + lines.length + " (期望: 12)");
        
        String[] fieldNames = {
            "env", "version", "keyId", "deviceId", "method",
            "accept", "contentLanguage", "contentMd5", "contentType",
            "timestamp", "nonce", "(空行)"
        };
        
        for (int i = 0; i < Math.max(lines.length, fieldNames.length); i++) {
            String fieldName = i < fieldNames.length ? fieldNames[i] : "未知";
            String value = i < lines.length ? lines[i] : "(缺失)";
            System.out.println("  " + fieldName + ": "" + value + """);
        }
        
        // 检查最后是否有换行符
        boolean endsWithNewline = stringToSign.endsWith("\n");
        System.out.println("以换行符结尾: " + endsWithNewline + " (期望: true)");
        
        System.out.println("=== 验证结束 ===");
    }
}

6.5.3 错误码速查表

错误码错误信息可能原因排查方向
144011device key not found环境设置错误检查setEnvironment调用
144012签名验证失败签名算法或参数错误检查签名字符串格式
144013时间戳过期时间戳生成时机不对检查时间戳生成位置
100002缺少必要参数Header缺失检查所有必需Header
100022Content-MD5验证失败MD5编码错误检查是否使用Base64
144001verifyKey错误JWT路径问题使用key-suite路径

6.6 从失败中学习

6.6.1 为什么这些坑这么难发现?

回顾这些问题,我们发现它们有一些共同特点:

1. 隐蔽性强

这些问题都不会导致程序崩溃,只是返回错误码。错误信息往往不够明确,需要深入分析才能找到原因。

2. 细节决定成败

  • 环境设置:一个参数的差异
  • MD5编码:Base64 vs 十六进制
  • 换行符:最后一个字段后面的换行符

这些都是很小的细节,但任何一个出错都会导致整个流程失败。

3. 缺乏文档

服务器端没有公开的API文档,所有细节都需要从反编译代码中推断。

6.6.2 如何避免类似问题?

1. 完整追踪初始化流程

// 不要只关注目标方法,要从APP入口开始追踪
// Application.onCreate() → 各种初始化 → 目标方法

2. 逐字节对比

// 对比原始APP和我们的实现,找出任何差异
// 包括:请求头、请求体、签名字符串等

3. 建立测试用例

// 为每个关键步骤建立测试用例
// 确保每个步骤的输出与原始APP一致

4. 记录所有尝试

// 记录每次尝试的修改和结果
// 避免重复踩坑

6.6.3 逆向工程的心态

┌─────────────────────────────────────────────────────────────────┐
│                    逆向工程心态                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. 耐心                                                         │
│     逆向工程是一个反复试错的过程,需要极大的耐心                 │
│                                                                  │
│  2. 细心                                                         │
│     任何一个小细节都可能是问题的关键                             │
│                                                                  │
│  3. 好奇心                                                       │
│     对每一个"为什么"都要追根究底                                │
│                                                                  │
│  4. 系统性思维                                                   │
│     建立完整的知识体系,而不是零散的技巧                         │
│                                                                  │
│  5. 记录习惯                                                     │
│     记录每一次尝试,积累经验                                     │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

6.7 本章小结

6.7.1 三大核心坑点回顾

坑点问题原因解决方案
坑点一device key not found未设置生产环境先调用setEnvironment(1)
坑点二Content-MD5验证失败使用十六进制而非Base64改用Base64编码
坑点三formatDK返回值解析失败不熟悉Unidbg类型系统正确解析ArrayObject

6.7.2 关键经验总结

1. 环境设置是关键

  • 必须先设置环境,再获取密钥
  • 不同环境返回不同的密钥ID
  • 生产环境和测试环境完全隔离

2. 编码方式要验证

  • 不要假设,要从源码确认
  • Content-MD5使用Base64编码
  • GET请求也需要Content-MD5

3. Unidbg类型系统

  • ArrayObject.getValue()返回DvmObject[]
  • 需要逐个元素转换为具体类型
  • 建立调试工具辅助排查

6.7.3 下一章预告

在下一章中,我们将把所有组件整合起来,实现完整的调用链

  • 从初始化到数据获取的完整流程
  • 错误处理和重试机制
  • 性能优化和资源管理
  • 生产环境部署考虑

本章附录

附录A:完整的错误处理代码

package com.dreamworld.utils;

/**
 * 错误处理工具类
 */
public class ErrorHandler {
    
    /**
     * 根据错误码获取解决建议
     */
    public static String getSuggestion(int errorCode) {
        switch (errorCode) {
            case 144011:
                return "请检查是否调用了setEnvironment(1)设置生产环境";
            case 144012:
                return "请检查签名字符串格式,确保每个字段后都有换行符";
            case 144013:
                return "请检查时间戳生成时机,确保在发送请求前生成";
            case 100002:
                return "请检查是否发送了所有必需的Header";
            case 100022:
                return "请检查Content-MD5是否使用Base64编码";
            case 144001:
                return "JWT路径问题,请使用key-suite路径";
            default:
                return "未知错误,请查看详细日志";
        }
    }
    
    /**
     * 处理API响应错误
     */
    public static void handleError(int code, String msg) {
        System.err.println("=== API错误 ===");
        System.err.println("错误码: " + code);
        System.err.println("错误信息: " + msg);
        System.err.println("解决建议: " + getSuggestion(code));
        System.err.println("===============");
    }
}

附录B:调试检查清单

□ 环境设置
  □ 是否调用了setEnvironment(1)?
  □ 调用顺序是否正确(先设置环境,再获取priId)?

□ 签名字符串
  □ 字段顺序是否正确?
  □ 每个字段后是否有换行符?
  □ 最后一个字段后是否有换行符?
  □ 字段值是否与Header一致?

□ Content-MD5
  □ 是否使用Base64编码?
  □ GET请求是否也发送了Content-MD5?
  □ 字符编码是否为UTF-8?

□ 时间戳
  □ 是否在发送请求前生成?
  □ 精度是否为毫秒?

□ Header
  □ 是否发送了所有必需的Header?
  □ Header名称大小写是否正确?
  □ Header值是否正确?

本章完