文章目录
-
- 声明
- [1. 开场:它不像参数,更像一份体检报告](#1. 开场:它不像参数,更像一份体检报告)
- [2. 第一刀:把 x-tt-session-dtrait拆成三段](#2. 第一刀:把 x-tt-session-dtrait拆成三段)
- [3. 外层加密:RSA 包 AES key,AES 包 JSON](#3. 外层加密:RSA 包 AES key,AES 包 JSON)
- [4. 证书版本:远程缓存不一定是当前使用者](#4. 证书版本:远程缓存不一定是当前使用者)
- [5. 内层 `dtrait`:不是密文,是压缩后的特征表](#5. 内层
dtrait:不是密文,是压缩后的特征表) - [6. 运行时对象:找到 SDK 的中间态](#6. 运行时对象:找到 SDK 的中间态)
- [7. central 与 edge:一份原值,一份 MurmurHash3](#7. central 与 edge:一份原值,一份 MurmurHash3)
- [8. str_1..str_33:浏览器环境小抄](#8. str_1..str_33:浏览器环境小抄)
- [9. bool 字段:小开关也会进报告](#9. bool 字段:小开关也会进报告)
- [10. 为什么先使用成功 profile,而不是立刻全量模拟浏览器](#10. 为什么先使用成功 profile,而不是立刻全量模拟浏览器)
- [11. 常见坑位](#11. 常见坑位)
-
- [11.1 把完整 URL 当 path](#11.1 把完整 URL 当 path)
- [11.2 误用远程证书版本](#11.2 误用远程证书版本)
- [11.3 只复现外层,不管 inner dtrait](#11.3 只复现外层,不管 inner dtrait)
- [11.4 测试太猛](#11.4 测试太猛)
- [12. 验证 checklist](#12. 验证 checklist)
-
- [12.1 检查 header 三段](#12.1 检查 header 三段)
- [12.2 检查 RSA/AES 解码尺寸](#12.2 检查 RSA/AES 解码尺寸)
- [12.3 检查 inner blob 重建](#12.3 检查 inner blob 重建)
- [13. 总结](#13. 总结)
声明
本文章中所有内容仅供学习交流,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请私信我立即删除!
主题:
x-tt-session-dtrait生成逻辑分析与 Python 复现关键词:Web 逆向、浏览器指纹、RSA、AES-CBC、MurmurHash3、JSVMP、接口签名
x-tt-session-dtrait这个参数可以说是相当重要,因为它含有大量的环境指纹校验和关注点赞是绑定一起的,不然你会看到关注发送了也关注不上。
这个和xhs的xs-com有点类似,xhs是xs-com有环境指纹校验,不对的话,看着发包也返回成功自己能看到自己的关注列表里新增了,但是其实对方看不到自己粉丝增加了,就是这么神奇,还有发评论的接口也一样的,都有指纹强校验。
1. 开场:它不像参数,更像一份体检报告
这次分析的目标是请求头里的:
http
x-tt-session-dtrait: d0_xxx_yyy
第一次看到它时,直觉很容易把它归类成"又一个加密字符串"。但继续拆下去会发现,它不是单纯签名,也不是简单 token,而更像一份被加密封装的浏览器环境体检报告。
它里面包含:
- 当前接口 path
- 当前时间戳
- SDK 版本
- 一组浏览器环境特征压缩后的
dtraitblob - 外层 RSA/AES 加密包装
如果把普通签名比作门票,那 x-tt-session-dtrait 更像"门票 + 现场设备检测单 + 防拆封信封"。你只伪造门票不够,信封格式不对也会被拦。
2. 第一刀:把 x-tt-session-dtrait拆成三段
抓包成功样本里的 x-tt-session-dtrait 长这样:
text
d0_<rsa_part>_<aes_part>
按下划线切开:
python
prefix, rsa_part, aes_part = value.split("_", 2)
观察结果:
| 字段 | 含义 | 特征 |
|---|---|---|
prefix |
版本 | d0 |
rsa_part |
RSA 加密后的 AES key | base64,344 字符,解码后 256 字节 |
aes_part |
AES 加密后的 payload | base64,常见 472 字符,解码后含 IV + ciphertext |
这一步很关键。很多逆向分析卡住,是因为把整串字符串当作一个整体去猜。实际上它已经把结构写在脸上了:三段式,版本 + 密钥包装 + 数据密文。
先拆段,再谈算法。不要和整串硬刚,整串通常不会讲道理。
3. 外层加密:RSA 包 AES key,AES 包 JSON
继续分析后确认外层逻辑:
text
1. 生成 16 字节 AES key
2. 把 AES key 表示成 32 字符 hex 字符串
3. 用 RSA PKCS#1 v1.5 加密这个 AES key hex
4. 构造 JSON 明文 payload
5. 用 AES-CBC + PKCS7 加密 payload
6. 输出 d0_<rsa_part>_<aes_part>
Python 复现核心:
python
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
import base64
import json
import secrets
def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
pad_len = block_size - len(data) % block_size
return data + bytes([pad_len]) * pad_len
def rsa_encrypt_aes_key(aes_key_hex: str, public_key_pem: bytes) -> str:
key = RSA.import_key(public_key_pem)
cipher = PKCS1_v1_5.new(key)
encrypted = cipher.encrypt(aes_key_hex.encode("utf-8"))
return base64.b64encode(encrypted).decode("ascii")
def aes_cbc_encrypt_payload(aes_key_hex: str, plaintext: str) -> str:
key = bytes.fromhex(aes_key_hex)
iv = secrets.token_bytes(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(pkcs7_pad(plaintext.encode("utf-8")))
return base64.b64encode(iv + encrypted).decode("ascii")
最终封装:
python
def generate_x_tt_session_dtrait(dtrait_blob, path, public_key_pem):
aes_key_hex = secrets.token_hex(16)
payload = {
"dtrait": dtrait_blob,
"timestamp": int(time.time()),
"sdkVersion": "1.0.31",
"path": path,
}
plaintext = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
rsa_part = rsa_encrypt_aes_key(aes_key_hex, public_key_pem)
aes_part = aes_cbc_encrypt_payload(aes_key_hex, plaintext)
return f"d0_{rsa_part}_{aes_part}"
这里的 JSON 顺序也值得注意。样本中明文结构是:
json
{"dtrait":"...","timestamp":1780902375,"sdkVersion":"1.0.31","path":"/aweme/v1/web/commit/follow/user/"}
字典保持插入顺序,所以构造 payload 时按这个顺序写,最少能少给自己挖一个坑。
4. 证书版本:远程缓存不一定是当前使用者
分析时发现运行时缓存里可能存在远程证书,例如出现过类似 centralVersion = d1 的状态。但成功请求实际使用的是内置 d0 证书。
这也是 Web 逆向常见陷阱:
SDK 里"存在的配置"不一定等于"当前请求使用的配置"。
最后确认页面当前 header 生成使用的是 SDK 内置证书:
text
centralVersion: d0
edgeVersion: d0
urlVersion: 1.0.31
dTraitVersion: 0
Python 实现默认使用内置 d0 公钥。需要切换时再通过函数参数传入证书对象,而不是把远程缓存值写死。
5. 内层 dtrait:不是密文,是压缩后的特征表
外层搞定后,真正的重点来了:
json
{
"dtrait": "<inner_dtrait_blob>"
}
这个 dtrait 不是 RSA/AES 生成的,它是 SDK 根据浏览器环境特征生成的内层 blob。
我们拿到一个稳定样本后 base64 解码,发现其结构很规整:
text
20 00 00 00 01 d0 ...
继续对齐运行时数据后确认编码规则:
text
header = 20 00 00 00 01 d0
for feature_id in 32..64:
write 1 byte feature_id
write 4 bytes uint32 big-endian feature_value
也就是:
python
def build_dtrait_blob_from_string_features(string_features):
body = bytearray([0x20, 0x00, 0x00, 0x00, 0x01, 0xD0])
for feature_id in range(32, 65):
value = string_features[feature_id]
body.append(feature_id & 0xFF)
body.extend((int(value) & 0xFFFFFFFF).to_bytes(4, "big"))
return base64.b64encode(bytes(body)).decode("ascii")
示例:
text
feature_id = 32
value = 755324578
hex(value) = 0x2d0556a2
encoded:
20 2d 05 56 a2
这个发现很有用。因为一旦知道内层 blob 只是特征表编码,问题就从"逆一个神秘 blob"变成了"找到 33 个特征值从哪里来"。难度还在,但方向清楚了。
6. 运行时对象:找到 SDK 的中间态
分析过程中定位到几个关键运行时概念:
getOnlineSourceWithLog()updateFeature(source)centralStringFeaturesedgeStringFeaturescentralDTraitedgeDTrait
核心流程可以概括为:
text
getOnlineSourceWithLog()
-> source = { num, bool, str }
-> updateFeature(source)
-> addStringFeature(id, value)
-> build centralDTrait / edgeDTrait
其中 source.str 大致是:
json
{
"str_1": 755324578,
"str_2": 3751069531,
"str_3": 274206810,
"...": "...",
"str_33": 4173851467
}
SDK 内部把 str_1..str_33 映射到 feature id 32..64:
text
str_1 -> feature_id 32
str_2 -> feature_id 33
...
str_33 -> feature_id 64
Python 中对应:
python
string_features = {
32 + index - 1: source["str"][f"str_{index}"]
for index in range(1, 34)
}
7. central 与 edge:一份原值,一份 MurmurHash3
SDK 中存在两类特征:
text
centralStringFeatures[id] = value
edgeStringFeatures[id] = murmur3_x86_32(String(value))
验证样例:
python
murmur3_x86_32("755324578") == 106203325
也就是说:
centralDTrait使用原始 32 位特征值edgeDTrait使用对原值字符串做 MurmurHash3 后的值
本次 header 里的 x-tt-session-dtrait 使用的是 central 方向的 dtrait。复现时优先还原 central blob 即可。
这一步也解释了为什么直接对字段名、schema key、接口名做 hash 都对不上。
SDK hash 的不是 "str_1",也不是 "navigator.userAgent" 这种标签,而是采集器运行后得到的具体值。
8. str_1...str_33:浏览器环境小抄
通过扰动实验确认了一批字段来源。方法很朴素:
- 临时 patch 一个浏览器 API
- 调用
getOnlineSourceWithLog() - 对比哪个字段变化
- 恢复 API
一次只动一个变量。否则看起来很热闹,结论很难看。
确认结果如下:
| 字段 | 来源方向 |
|---|---|
str_1 |
CSS computed style keys / interface group |
str_2 |
DOMRect / 几何信息 |
str_3 |
Canvas 2D 指纹 |
str_4, str_5, str_6 |
CSS / system color / system font 相关 |
str_7, str_21 |
字体指纹子结果 |
str_10 |
FontFace、document.fonts |
str_11, str_12 |
Math 精度分支 |
str_13 |
SVG / DOM geometry |
str_14 |
navigator.connection.downlink/effectiveType |
str_15 |
navigator.language |
str_16 |
navigator.vendor |
str_17 |
navigator.platform |
str_18 |
navigator / screen 分组中的零值分支 |
str_19 |
navigator.userAgent |
str_22, str_23 |
speechSynthesis.getVoices() |
str_24 |
SVG geometry / font branch |
str_26, str_33 |
WebGL 支持与渲染结果 |
str_27 |
Intl.DateTimeFormat().resolvedOptions().timeZone |
str_28 |
Notification |
str_29 |
hardwareConcurrency/deviceMemory/maxTouchPoints |
str_30 |
screen.availWidth/availHeight |
str_31 |
screen.width/height |
str_32 |
colorDepth/pixelDepth/devicePixelRatio |
示例扰动:
javascript
const rawGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
if (type === "2d") return null;
return rawGetContext.call(this, type, ...args);
};
// 调 SDK 采集 source,观察 str_3
HTMLCanvasElement.prototype.getContext = rawGetContext;
如果 str_3 明显变化,就能确认 Canvas 2D 分支参与该字段。
9. bool 字段:小开关也会进报告
除了 str 特征,还有一组 bool:
| 字段 | 来源方向 |
|---|---|
bool_3 |
navigator.brave |
bool_4 |
navigator.cookieEnabled |
bool_5 |
navigator.doNotTrack |
bool_6 |
navigator.pdfViewerEnabled |
bool_7 |
navigator.serviceWorker |
bool_10 |
RTCPeerConnection |
虽然本次重点复现的是 string feature blob,但 bool 信息也属于 source 的一部分。
后续如果要完全动态生成 profile,这些字段也不能忽略。
10. 为什么先使用成功 profile,而不是立刻全量模拟浏览器
完整复刻所有浏览器指纹当然更优雅,但优雅经常意味着工期和风险一起上涨。
这次采用的是分阶段路线:
- 先确认外层 RSA/AES 可复现
- 再确认 inner blob 编码可复现
- 使用成功样本的 source profile 打通链路
- 后续再逐步把
str_1..str_33纯 Python 化
这样一来工程就降低了风险。
一口气把 Canvas、WebGL、FontFace、SpeechSynthesis、Intl、CSSOM 全部模拟完,再去看请求能不能通,很容易出现一个问题:失败时不知道是哪块错了。
先用成功 profile 打通主链路,至少能证明:
- 外层格式正确
- 公钥版本正确
- AES/RSA 逻辑正确
- path 与 timestamp 位置正确
- header 形态被服务端接受的概率更高
后续替换指纹来源时,每次只替换一小块,出问题也能定位。
全部解决之后加上abogus加密就可以正常发包,比如测试关注接口:

测试对方也可以看到自己粉丝增加,至此逆向才算成功了。
11. 常见坑位
11.1 把完整 URL 当 path
错误:
text
https://www.douyin.com/aweme/v1/web/commit/follow/user/
正确:
text
/aweme/v1/web/commit/follow/user/
path 字段参与 AES plaintext。它不是展示字段,写错就是写错。
11.2 误用远程证书版本
运行时看到 d1 不代表当前请求一定用 d1。要以实际 header 生成链路为准。
本次成功样本使用的是内置 d0。
11.3 只复现外层,不管 inner dtrait
RSA/AES 对了,只代表信封封好了。信封里如果塞的是错误 dtrait,照样不稳。
外层是包装,inner blob 才是环境特征核心。
11.4 测试太猛
这类接口不适合压测。登录态、行为频率、目标关系、风控策略都可能参与判断。
验证时建议:
- 本地先只构建,不发送
- 打印摘要,不打印敏感 cookie
- 真实请求低频验证
- 每次只改一个变量
12. 验证 checklist
本地可以按这个顺序验证:
12.1 检查 header 三段
python
value = generate_x_tt_session_dtrait_from_guan_profile(
"/aweme/v1/web/commit/follow/user/"
)
prefix, rsa_part, aes_part = value.split("_", 2)
print(prefix)
print(len(rsa_part), len(aes_part))
预期类似:
text
d0
344 472
12.2 检查 RSA/AES 解码尺寸
python
import base64
print(len(base64.b64decode(rsa_part))) # 256
print(len(base64.b64decode(aes_part))) # IV + ciphertext
12.3 检查 inner blob 重建
python
source = get_guan_success_source()
blob = build_dtrait_blob_from_source(source)
print(blob)
如果对照成功样本一致,说明 inner blob 编码没跑偏。
13. 总结
这次最有价值的不是拿到某一条可用 header,而是把不可维护的成功样本拆成了可维护的工程模块。
拆解前:
text
复制一整坨 headers
祈祷它还能用
失败了不知道哪里坏
拆解后:
text
source profile -> inner dtrait blob
inner dtrait + path + timestamp -> plaintext
plaintext -> AES-CBC
AES key -> RSA
final header -> d0_rsa_aes
每一步都能单独打印、单独验证、单独替换。
这就是爬虫逆向里非常重要的工程化思路:
不要只追求"跑通一次",要追求"坏了知道从哪修"。
x-tt-session-dtrait 看起来是一条很长的 header,实际可以拆成三层:
text
Header 三段格式:
d0_<rsa_part>_<aes_part>
AES plaintext:
{"dtrait":"...","timestamp":...,"sdkVersion":"1.0.31","path":"..."}
Inner dtrait:
header + feature_id 32..64 + uint32 feature values
它的逆向路线可以概括为:
- 从成功样本确认格式
- 拆分 header 三段
- 验证 RSA/AES 外层
- 解码 inner dtrait 结构
- 定位 SDK source profile
- 用扰动实验确认字段来源
- 用 Python 复现生成链路
- 接入请求构建流程
最后的感悟:
加密字符串越长,越要先拆结构。
逆向不是和字符串较劲,而是把它还原成输入、过程和输出。
当一坨 header 变成一个函数,事情就开始可控了。