pdd分析

目标:anti_token

版本:7.85

方法:info3

image-20251203211031401

2agf541ILhsYWHL8xTRBUPNLVt0vkpMFUDMMe0w7W1+bYt2mi188Jm0ZbvG+2xLpX9w

2ag开头的这串token值

参数定位

hook NewStringUTF,并打印调用堆栈

image-20251203211935507

image-20251203213806635

定位到so是 libpdd_secure.so

偏移为0x1ea40

native方法为info3

image-20251203212458104

hook看下入参

image-20251203212720207

long类型是是时间戳,三参str是固定值zViMp2Gx

随便拿一组数据

DeviceNative.info3 is called: context=android.app.ContextImpl@ffc87da, j14=1764768452533, str=zViMp2Gx
DeviceNative.info3 result=2agrkNsQuOsKezPdFVShcT2bJCorfdaaKJgTXu6jxdmlUZP3gzkACbO76NEWjP9PKzb

模拟执行

补环境

搭好框架后先模拟执行 JNI_OnLoad

报错

image-20251203214248095

com/xunmeng/core/log/Logger->i(Ljava/lang/String;Ljava/lang/String;)V

因为返回值是void,所以直接return即可,无需返回任何对象

case"com/xunmeng/core/log/Logger->i(Ljava/lang/String;Ljava/lang/String;)V":{
    // 因为返回值是 V (Void),直接 return 即可,不需要返回任何对象
    return;
}

image-20251203215401306

接下来主动调用info3方法

对于java的八大基本数据类型(byte,char,int,short,long,double,float)等不用做处理,可以直接传递,

long j14 = 1764768452533L; 
public void info3() {
    DvmClass SecurityTool = vm.resolveClass("com.xunmeng.pinduoduo.secure.DeviceNative");
    StringObject strObj = new StringObject(vm, "zViMp2Gx");
    DvmObject<?> context = vm.resolveClass("android.app.ContextImpl").newObject(null); // context
    long j14 = 1764768452533L;  //准备 long 参数 (直接定义 long 即可,末尾加 L)
    String result = SecurityTool.callStaticJniMethodObject(emulator, "info3(Landroid/content/Context;JLjava/lang/String;)Ljava/lang/String;", context, j14, strObj).getValue().toString();
    System.out.println("模拟结果是:" + result);
}

执行,依旧环境报错

image-20251203220055332

com/xunmeng/pinduoduo/secure/EU->gad()Ljava/lang/String;

主动调用拿到结果

case"com/xunmeng/pinduoduo/secure/EU->gad()Ljava/lang/String;":{
    return new StringObject(vm,"bd38492026e38671");
}

image-20251203221314741

java/lang/String->replaceAll(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

这是 Native 层调用了 Java 的实例方法 String.replaceAll 来处理字符串(通常用于去除空格、特殊字符或格式化数据)

case"java/lang/String->replaceAll(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;":{
    // 1. 获取 "this" 对象 (即被操作的字符串)
    // dvmObject 就是调用该方法的 String 对象
    StringObject self = (StringObject) dvmObject;
    String originalStr = self.getValue();

    // 2. 获取参数
    // 第一个参数: regex (正则表达式)
    DvmObject<?> regexObj = vaList.getObjectArg(0);
    String regex = (String) regexObj.getValue();

    // 第二个参数: replacement (替换后的内容)
    DvmObject<?> replacementObj = vaList.getObjectArg(1);
    String replacement = (String) replacementObj.getValue();

    // 3. 执行真正的 Java 逻辑
    // 直接调用宿主 Java 的 replaceAll 即可
    String resultStr = originalStr.replaceAll(regex, replacement);

    System.out.println("[JNI] String.replaceAll 执行: '" + originalStr + "' -> regex: '" + regex + "' -> replacement: '" + replacement + "' => 结果: " + resultStr);

    // 4. 返回新的 StringObject
    return new StringObject(vm, resultStr);
}

image-20251203222043347

java/lang/String->hashCode()I

case"java/lang/String->hashCode()I":{
        // 1. 获取当前 String 对象
        StringObject strObj = (StringObject) dvmObject;
        String str = strObj.getValue();

        // 2. 调用宿主 Java 的 hashCode
        // Java String 的 hashCode 算法是标准的,直接调用即可
        int hash = str.hashCode();

        System.out.println("[JNI] String.hashCode() called for: '" + str + "' => " + hash);

        return hash;
    }
}

image-20251203222901436

com/xunmeng/pinduoduo/secure/EU->ix()Z 程序本身的方法,依旧主动调用

case"com/xunmeng/pinduoduo/secure/EU->ix()Z":{
    return true;
}

image-20251203223137420

成功得到结果,但是结果并不固定

固定模拟结果

回头看一下JNI函数调用的日志,发现关键点:

image-20251203224900692

生成随机 UUID

JNIEnv->CallStaticObjectMethodV(class java/util/UUID, randomUUID() => java.util.UUID@59309333)

这里调用了 Java 标准库生成了一个随机的 UUID 对象。每次执行,这个对象都不一样。

获取 UUID 字符串

JNIEnv->CallObjectMethodV(java.util.UUID@59309333, toString() => "8db1d22f-a5f7-4fe4-a10c-eaaef3cc83fb")

造成结果不固定的根本原因在于 Native 层调用了 java.util.UUID.randomUUID() 生成了一个随机字符串

字符串经过处理后变成随机的32位hex字符串

想把结果值固定,可以hook randomUUID,返回一个固定的 UUID

case"java/util/UUID->randomUUID()Ljava/util/UUID;":{
                // 创建一个固定的 UUID,例如全是 0,或者你自己指定的一个值
                // 对应 toString() 就是 "00000000-0000-0000-0000-000000000000"
                UUID fixedUuid = UUID.fromString("00000000-0000-0000-0000-000000000000");

                System.out.println("[JNI] Hook randomUUID 为固定值: " + fixedUuid);

                // 注意:这里需要返回一个 DvmObject 包装 Java 的 UUID 对象
                // Unidbg 会自动处理这个对象的后续方法调用(如 toString)
                return vm.resolveClass("java/util/UUID").newObject(fixedUuid);
            }

这里直接固定为0

image-20251203225300864

得到固定的结果2agrkNsQuOsKezPdFVShcT2bKzWeAJs+dHFcSK1E7Xa98J4MCDF80uIwxv5vPrJHdsB

算法还原

Base64

2agrkNsQuOsKezPdFVShcT2bKzWeAJs+dHFcSK1E7Xa98J4MCDF80uIwxv5vPrJHdsB

这一串结果像是经由base64加密

先hook一下memcpy函数看内存中能否监控到该值的传输

image-20251204134408863

在 0x406600c0 这块内存被传入了四个字节的数据,其中前三个字符2ag对应结果值的前三个字符

再监控一下0x406600c0这块内存的写入

emulator.traceWrite(0x406600c0L,0x406600c0L + 0x50);

image-20251204134635506

这里能监控到大量的逐字节写入

0x72,0x6b正好对应结果的hex值,操作指令的地址是 0x183db8 0x183dd0 0x183df4 0x183df8,四个一组

定位到函数 sub_183D74

image-20251204135201142

正好对应四条单字节写入指令

image-20251204135244637

这也是标准的base64编码表

hook该函数拿到入参和结果,判断是否为标准base64

image-20251204135747285

image-20251204135800584

一参是初始数据的指针,二参是数据长度,三参是最后编码结果保存的地址

image-20251204135921636

编码结果数据如上

验证一下

image-20251204121626785

确认是标准base64

AES-128

现在看初始0x30字节数据怎么来的

0000: AE 43 6C 42 E3 AC 29 EC CF 74 55 52 85 C4 F6 6C    .ClB..)..tUR...l
0010: AC D6 78 02 6C F9 D1 C5 71 22 B5 13 B5 DA F7 C2    ..x.l...q"......
0020: 78 30 20 C5 F3 4B 88 C3 1B F9 BC FA C9 1D DB 01    x0 ..K..........

emulator.traceWrite(0x40653120L,0x40653120L + 0x30);

依旧监控地址写入

image-20251204143340231

分六轮写入,每次写入8个字节

image-20251204143359265

定位到这里,v13[1] = *v14,函数混淆得还是比较严重

这里能看出些端倪

v21 = malloc(0xB0uLL);

AES-128 需要 11 组轮密钥,每组 16 字节,总共0xB0字节,v21像是存放扩展后的轮密钥的缓存区

  • 跟进sub_1816D8(&a13, v14, v21, 10LL);:

image-20251204194938090

image-20251204195007780

函数首先进行大量异或操作获得很多索引值,然后对byte_190A5C[]进行索引取值

其中取16次索引值也对应着AES-128分组大小

很像是AES中字节替换的过程

image-20251204195206778

点进byte_190A5C一看,确实是标准AES的S-Box (S盒)

__asm { BR X6 },后面行移位和列混淆逻辑隐藏在混淆中

  • a13 = veorq_s8(*v14, v19[1]);

veorq_s8ARM NEON 指令,用于 128位(16字节)数据的异或运算

它将 *v14(上一块密文 或 IV 向量)与 v19[1](当前明文块)进行异或,结果a13就是每一轮的初始明文块

这是AES 的CBC模式的标准操作:Input = Plaintext ^ IV

  • sub_18140C(v15, v21, 10LL, 4LL);

image-20251204201105009

这里取出四个字节,改变顺序,非常符合密钥扩展中的逻辑

对应参数,v15是原始密钥,v21存放扩展后的密钥,10为AES-128 的轮数,4是密钥长度字数(8字节)

image-20251204201835458

image-20251204201725451

拿到原始密钥 pdd_aes_180121_1

image-20251204201750005

一参这块内存开始为空,存放扩展后的密钥

再对 sub_1816D8 hook拿到参数

image-20251204202247617

image-20251204202307672

那么二参v14就是IV,三参v21是扩展后的密钥

image-20251204203053288

拿到前0x10字节的结果

分析得到v14是 IV向量,v19是初始明文块,hook得到初始值

image-20251204203545701

IDA反编译显示值存储在x27寄存器中

image-20251204203619433

0x26个字节,最后zViMp2Gx对应最开始java层的三参字符串

IV在x20寄存器中

image-20251204203945397

对应固定的16字节UUID

验证一下

image-20251204204137706

是标准AES-128,CBC模式

初始数据分析

最后追一下这0x26个字节怎么来的

image-20251204204359738

081f bd38492026e38671 0000019ae4658fb577e76cdf0ea6ffc6 fc8c6000 7a56694d70324778

最后8字节

对应初始三参 zViMp2Gx

image-20251203212720207

开始2字节

081F固定

0x3-0xA字节

image-20251204205255856

bd38492026e38671

image-20251204205309097

JNI日志显示是 com/xunmeng/pinduoduo/secure/EU.gad() 方法的返回值

直接写入内存

0xB-0x1A字节

0000019ae4658fb5 77e76cdf0ea6ffc6

前8字节

0000019ae4658fb5

[21:08:30 568]TargetAddr: 406571b8  OriginAddr: 406571c0, md5=df4f8d238121afd4723d8526203feecb, hex=0000019ae4658fb5
size: 8
0000: 00 00 01 9A E4 65 8F B5                            .....e..
^-----------------------------------------------------------------------------^
[21:08:30 568] Memory WRITE at 0x406571b8, data size = 8, data value = 0xb58f65e49a010000, PC=RX@0x4032c1b4[libc.so]0x1c1b4, LR=RX@0x40170cb8[libpdd_secure.so]0x170cb8

image-20251204213149850

定位到sub_170BFC

image-20251204213217572

*(_DWORD *)v11 = bswap32(a1) 取a1的低32位,字节反转(转成大端序)存入v11中

image-20251204213957817

hook到这个函数,一参为反转前的值

看调用栈

image-20251204214018873

[0x040000000][0x040159c3c][ libpdd_secure.so][0x159c3c]
[0x040000000][0x040023b40][ libpdd_secure.so][0x023b40]
[0x040000000][0x0400207c8][ libpdd_secure.so][0x0207c8]

没看出什么,因为这里x0=0x19ae4658fb5,在trace中应该是有迹可循的

直接在trace结果代码中搜索

image-20251204215821390

定位到是最外层函数的三参

image-20251204220453716

static native方法一参类型是 JNIEnv*,二参类型是jclass

对于info3这个方法,三参x2类型是jobject,context,四参jlong就是 j13

image-20251204220545348

所以是四参时间戳的HEX

后8字节

77e76cdf0ea6ffc6

[21:08:30 569]TargetAddr: 406571b8  OriginAddr: 406571c0, md5=5cb568d3ad557441f9567a96fe5d6036, hex=77e76cdf0ea6ffc6
size: 8
0000: 77 E7 6C DF 0E A6 FF C6                            w.l.....
^-----------------------------------------------------------------------------^
[21:08:30 569] Memory WRITE at 0x406571b8, data size = 8, data value = 0xc6ffa60edf6ce777, PC=RX@0x4032c1b4[libc.so]0x1c1b4, LR=RX@0x40170cb8[libpdd_secure.so]0x170cb8

image-20251204220850121

同理trace代码中直接搜索

image-20251204220913520

最早在这里出现

image-20251204221110245

从sp+0x78的地方加载两个0x8字节的值分别到x8和x9寄存器,这两个值相加就是结果值

一个值0xea6ffc6,一个值0x77e76cdf00000000

监控下sp+0x78这块内存的写入

image-20251204221521630

追下0x77e76cdf00000000

image-20251204221916532

"lsl x9, x26, #0x20" x9=0x330e x26=0x77e76cdf => x9=0x77e76cdf00000000

将x26逻辑左移32位结果存到x9寄存器中

0x77e76cdf

image-20251205165641733

image-20251205165709004

有混淆反编译效果不好所以直接看汇编

.lrand48是随机数生成函数,但是伪随机,其随机性依赖于种子初始化函数 srand48(long seed)

如果seed固定,那么生成的随机数序列就会固定,结果保存在x0寄存器中

所以0x77e76cdf就是lrand48伪随机函数生成的结果,那么必然有一个固定值的srand48(long seed)在前面被调用

.text:00000000000223C4                 MOV             X0, X8  ; seedval
.text:00000000000223C8                 BL              .srand48
.text:00000000000223CC                 BL              .lrand48

image-20251205170836891

这里先把x8的值0x8e170赋给x0,作为seed,生成一组伪随机数

.text:0000000000022418                 STR             X0, [SP,#arg_78]

结果存入内存中

image-20251205170800406

拿到第一个伪随机数值0xea6ffc6

python验证一下

def lrand48_sequence(seed_val, count=10):
    # 1. 初始化 (srand48 的逻辑)
    # 高32位是种子,低16位固定是 0x330E
    state = (seed_val << 16) + 0x330E

    # 线性同余生成器 (LCG) 参数,标准 drand48 系列常数
    a = 0x5DEECE66D
    c = 0xB
    mask = 0xFFFFFFFFFFFF  # 48位掩码

    print(f"[*] 初始种子: {hex(seed_val)}")
    print(f"[*] 初始状态: {hex(state)}")
    print("-" * 30)

    for i in range(count):
        # 2. 更新状态 (drand48/lrand48 的迭代公式)
        # state = (a * state + c) mod 2^48
        state = (a * state + c) & mask

        # 3. 提取结果 (lrand48 返回高 31 位)
        # 注意:lrand48 返回的是非负长整型 (0 ~ 2^31-1)
        result = state >> 17

        print(f"第 {i + 1} 次调用 lrand48() 返回: {result} (Hex: {hex(result)})")


lrand48_sequence(0x8e170)

image-20251205171012583

再找另一个伪随机数0x77e76cdf的种子

image-20251205171308952

这是 srand48 函数的地址,搜索这条汇编指令

image-20251205171509630

拿到另一个种子0x68b6ad2a

image-20251205171546118

python验证也没问题

  • 最后找两个种子0x8e1700x68b6ad2a的来源

image-20251205171706279

依旧trace中搜索

第一次出现是从 sp+0x188 的内存地址取出来的值

搜索这块内存的引用

image-20251205173204683

IDA打开看一下

image-20251205173302515

看到gettimeofday函数

反应过来我是之前就在unidbg中固定了currentTimeMillis()返回的时间戳,所以unidbg模拟gettimeofday函数时返回的也是固定的值

定位到 src/main/java/com/github/unidbg/unix/UnixSyscallHandler.java

image-20251205174454403

image-20251205174644682

image-20251205174656129

步骤十进制数值计算逻辑16进制结果 (Hex)
原始时间1756802346582输入的毫秒时间戳-
tv_sec (秒)1756802346/ 10000x68B6AD2A
tv_usec (微秒)582000(% 1000) * 10000x0008E170

0x1B-0x1E字节

image-20251204205833003

fc8c6000

uuid的hashcode

总结

  • 初始数据构成:

081F + (EU.gad()返回值) + (j13_hex) + (当前时间秒、微秒分别作为种子生成的两组四字节伪随机数) + (uuid的hashcode) + zViMp2Gx

  • 算法:

AES-128(CBC) + Base64

均为标准实现