前言

本文主要是逆向的APP层的。Web层的也是一样的 不同的是wasm 。

实际上调用也是非常简单。这里就简单说一下

from wasmtime import Linker, Module, Store

将wasm的方法导出 直接再封装计算下 就可以了。 算法应该都是一样的。这里不多说了

直接看APP流程。

这里逆向什么参数我就不说了每个请求头里都挂着一个 x-ds-pow-response

解码之后长这个样子

1
2
3
4
5
6
7
8
{
"algorithm": "DeepSeekHashV1",
"challenge": "xxxx",
"salt": "xxx",
"signature": "xxxx",
"answer": 1234123,
"target_path": "/api/v0/chat/completion"
}

其中只有answer是生成的

定位点

实际上和web 走的应该是同一个算法。web上走的是wasm

这里只分析算法 不研究流程。

Frida

1
2
3
4
5
6
7
8
9
Java.perform(function () {
var P = Java.use("com.deepseek.crypto.PowCalculator");
P.nativeCalculateDeepSeekHashV1Pow.implementation = function (base, challenge, difficulty) {
var r = this.nativeCalculateDeepSeekHashV1Pow(base, challenge, difficulty);
console.log("[POW] base=" + base + " challenge=" + challenge + " diff=" + difficulty + " => " + r);
return r;
};
console.log("[POW] hook installed, waiting for prefetch...");
});

如上 hook 没数据 原因应该是Java层应该是走了缓存信息了。PoW 是从缓存池里取的预取结果。

所以这里 我们简单点

直接 hook native 层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 找 librscrypto.so 里的 JNI 导出函数
Java.perform(function () {
var addr = Module.findExportByName("librscrypto.so",
"Java_com_deepseek_crypto_PowCalculator_nativeCalculateDeepSeekHashV1Pow");
console.log("native addr = " + addr);
Interceptor.attach(addr, {
onEnter: function (args) {
// args[0]=JNIEnv, args[1]=jobject, args[2]=base(jstring), args[3]=challenge, args[4]=difficulty(jlong)
this.base = Java.vm.getEnv().getStringUtfChars(args[2], null).readUtf8String();
this.chal = Java.vm.getEnv().getStringUtfChars(args[3], null).readUtf8String();
this.diff = args[4].toInt32();
},
onLeave: function (retval) {
console.log("[NATIVE POW] base=" + this.base + " challenge=" + this.chal +
" diff=" + this.diff + " => " + retval);
}
});
});

这里直接获取到了传参数据了

So

重点还是看下So层的数据

可谓是非常简单了

1
0x18E40  Java_com_deepseek_crypto_PowCalculator_nativeCalculateDeepSeekHashV1Pow

入口进入sub_1AB74 调用 get_string

1
v11 = sub_19BC0(challenge_ptr, challenge_len, base_ptr, base_len, difficulty);

注意参数顺序,JNI 进来是 (base, challenge, difficulty),到内层变成了 (challenge, base, difficulty),base 和 challenge 换了位置

乍一看

这个应该是sha3 至少是 编译时链接了 Rust 的 sha3 库。

看得到一堆 veorq_s8(NEON 异或)、一个吸收循环、block 大小 0x88(136),还有个常量 0x06

rate=136 正是 SHA3-256 的 rate(capacity 512),padding 字节 0x06 是 SHA3 的域分隔符.

由此可以判断诗歌 sha3-256

这段So主要

  • 一上来 bytes.fromhex(challenge),把 64 个 hex 字符解成 32 字节,这是比对目标 target
  • 主循环 nonce 从 0 数到 difficulty,每次把 base + str(nonce) 喂进哈希。
  • 哈希出来的 32 字节跟 target 逐字节全等就返回当前 nonce,数到头没找到返回 -1。

但是后面实际走了 标准 sha3

发现得到的值对应不上

基础值是:

1
2
3
4
base       = 4a5a3ebe482ca78553f1_1781258202446_
challenge = 8868f19fb77e23cf1fc41d1af76a552bce58a24517d7e0920e9848c2013eba7c
difficulty = 144000
answer = 102773

然后反推

1
hashlib.sha3_256(base + "102773")

对不上

这是 Rust keccak crate 的通用置换,圈数是参数传进来的。

标准 SHA3-256 必须是 24 圈,而这里传的是 23。

光少一圈还不够,轮常量(round constant)的取法也被动了。轮常量表在 0x8E78,dump 出来是标准的 24 个 Keccak RC。但 so 里访问它的方式是:

1
2
v3 = -8 * 23;                          // 从 -184 起步
RC_ptr = unk_8E78 + v3 + 192; // = unk_8E78 + 8

+8 字节,正好是跳过第一个 8 字节常量 RC[0],从 RC[1] 开始用,一直用到 RC[23]

最后验证确实是23圈

SHA3_256_魔改(base + “102773”) == challenge 一致

最后有个简单方法

1
2
3
var fn = new NativeFunction(soBase.add(0x19BC0),
'int64', ['pointer', 'uint32', 'pointer', 'uint32', 'int64']);
var r = fn(chBuf, 64, baBuf, baLen, int64(144000));

直接调用

结果

最后结果如下