pdd分析
目录
目标:anti_token
版本:7.85
方法:info3

2agf541ILhsYWHL8xTRBUPNLVt0vkpMFUDMMe0w7W1+bYt2mi188Jm0ZbvG+2xLpX9w
2ag开头的这串token值
参数定位
hook NewStringUTF,并打印调用堆栈


定位到so是 libpdd_secure.so
偏移为0x1ea40
native方法为info3

hook看下入参

long类型是是时间戳,三参str是固定值zViMp2Gx
随便拿一组数据
DeviceNative.info3 is called: context=android.app.ContextImpl@ffc87da, j14=1764768452533, str=zViMp2Gx
DeviceNative.info3 result=2agrkNsQuOsKezPdFVShcT2bJCorfdaaKJgTXu6jxdmlUZP3gzkACbO76NEWjP9PKzb
模拟执行
补环境
搭好框架后先模拟执行 JNI_OnLoad
报错

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;
}

接下来主动调用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);
}
执行,依旧环境报错

com/xunmeng/pinduoduo/secure/EU->gad()Ljava/lang/String;
主动调用拿到结果
case"com/xunmeng/pinduoduo/secure/EU->gad()Ljava/lang/String;":{
return new StringObject(vm,"bd38492026e38671");
}

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);
}

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;
}
}

com/xunmeng/pinduoduo/secure/EU->ix()Z 程序本身的方法,依旧主动调用
case"com/xunmeng/pinduoduo/secure/EU->ix()Z":{
return true;
}

成功得到结果,但是结果并不固定
固定模拟结果
回头看一下JNI函数调用的日志,发现关键点:

生成随机 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

得到固定的结果2agrkNsQuOsKezPdFVShcT2bKzWeAJs+dHFcSK1E7Xa98J4MCDF80uIwxv5vPrJHdsB
算法还原
Base64
2agrkNsQuOsKezPdFVShcT2bKzWeAJs+dHFcSK1E7Xa98J4MCDF80uIwxv5vPrJHdsB
这一串结果像是经由base64加密
先hook一下memcpy函数看内存中能否监控到该值的传输

在 0x406600c0 这块内存被传入了四个字节的数据,其中前三个字符2ag对应结果值的前三个字符
再监控一下0x406600c0这块内存的写入
emulator.traceWrite(0x406600c0L,0x406600c0L + 0x50);

这里能监控到大量的逐字节写入
0x72,0x6b正好对应结果的hex值,操作指令的地址是 0x183db8 0x183dd0 0x183df4 0x183df8,四个一组
定位到函数 sub_183D74

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

这也是标准的base64编码表
hook该函数拿到入参和结果,判断是否为标准base64


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

编码结果数据如上
验证一下

确认是标准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);
依旧监控地址写入

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

定位到这里,v13[1] = *v14,函数混淆得还是比较严重
这里能看出些端倪
v21 = malloc(0xB0uLL);
AES-128 需要 11 组轮密钥,每组 16 字节,总共0xB0字节,v21像是存放扩展后的轮密钥的缓存区
- 跟进
sub_1816D8(&a13, v14, v21, 10LL);:


函数首先进行大量异或操作获得很多索引值,然后对byte_190A5C[]进行索引取值
其中取16次索引值也对应着AES-128分组大小
很像是AES中字节替换的过程

点进byte_190A5C一看,确实是标准AES的S-Box (S盒)
__asm { BR X6 },后面行移位和列混淆逻辑隐藏在混淆中
a13 = veorq_s8(*v14, v19[1]);
veorq_s8 是 ARM NEON 指令,用于 128位(16字节)数据的异或运算
它将 *v14(上一块密文 或 IV 向量)与 v19[1](当前明文块)进行异或,结果a13就是每一轮的初始明文块
这是AES 的CBC模式的标准操作:Input = Plaintext ^ IV
- 看
sub_18140C(v15, v21, 10LL, 4LL);:

这里取出四个字节,改变顺序,非常符合密钥扩展中的逻辑
对应参数,v15是原始密钥,v21存放扩展后的密钥,10为AES-128 的轮数,4是密钥长度字数(8字节)


拿到原始密钥 pdd_aes_180121_1

一参这块内存开始为空,存放扩展后的密钥
再对 sub_1816D8 hook拿到参数


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

拿到前0x10字节的结果
分析得到v14是 IV向量,v19是初始明文块,hook得到初始值

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

0x26个字节,最后zViMp2Gx对应最开始java层的三参字符串
IV在x20寄存器中

对应固定的16字节UUID
验证一下

是标准AES-128,CBC模式
初始数据分析
最后追一下这0x26个字节怎么来的

081f bd38492026e38671 0000019ae4658fb577e76cdf0ea6ffc6 fc8c6000 7a56694d70324778
最后8字节
对应初始三参 zViMp2Gx

开始2字节
081F固定
0x3-0xA字节

bd38492026e38671

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

定位到sub_170BFC

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

hook到这个函数,一参为反转前的值
看调用栈

[0x040000000][0x040159c3c][ libpdd_secure.so][0x159c3c]
[0x040000000][0x040023b40][ libpdd_secure.so][0x023b40]
[0x040000000][0x0400207c8][ libpdd_secure.so][0x0207c8]
没看出什么,因为这里x0=0x19ae4658fb5,在trace中应该是有迹可循的
直接在trace结果代码中搜索

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

static native方法一参类型是 JNIEnv*,二参类型是jclass
对于info3这个方法,三参x2类型是jobject,context,四参jlong就是 j13

所以是四参时间戳的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

同理trace代码中直接搜索

最早在这里出现

从sp+0x78的地方加载两个0x8字节的值分别到x8和x9寄存器,这两个值相加就是结果值
一个值0xea6ffc6,一个值0x77e76cdf00000000
监控下sp+0x78这块内存的写入

追下0x77e76cdf00000000

"lsl x9, x26, #0x20" x9=0x330e x26=0x77e76cdf => x9=0x77e76cdf00000000
将x26逻辑左移32位结果存到x9寄存器中
追0x77e76cdf


有混淆反编译效果不好所以直接看汇编
.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

这里先把x8的值0x8e170赋给x0,作为seed,生成一组伪随机数
.text:0000000000022418 STR X0, [SP,#arg_78]
结果存入内存中

拿到第一个伪随机数值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)

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

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

拿到另一个种子0x68b6ad2a

python验证也没问题
- 最后找两个种子0x8e170和0x68b6ad2a的来源

依旧trace中搜索
第一次出现是从 sp+0x188 的内存地址取出来的值
搜索这块内存的引用

IDA打开看一下

看到gettimeofday函数
反应过来我是之前就在unidbg中固定了currentTimeMillis()返回的时间戳,所以unidbg模拟gettimeofday函数时返回的也是固定的值
定位到 src/main/java/com/github/unidbg/unix/UnixSyscallHandler.java



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

fc8c6000
uuid的hashcode
总结
- 初始数据构成:
081F + (EU.gad()返回值) + (j13_hex) + (当前时间秒、微秒分别作为种子生成的两组四字节伪随机数) + (uuid的hashcode) + zViMp2Gx
- 算法:
AES-128(CBC) + Base64
均为标准实现