某音x-tt-session-dtrait 算法逆向复盘

文章目录

    • 声明
    • [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 版本
  • 一组浏览器环境特征压缩后的 dtrait blob
  • 外层 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)
  • centralStringFeatures
  • edgeStringFeatures
  • centralDTrait
  • edgeDTrait

核心流程可以概括为:

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:浏览器环境小抄

通过扰动实验确认了一批字段来源。方法很朴素:

  1. 临时 patch 一个浏览器 API
  2. 调用 getOnlineSourceWithLog()
  3. 对比哪个字段变化
  4. 恢复 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 FontFacedocument.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,而不是立刻全量模拟浏览器

完整复刻所有浏览器指纹当然更优雅,但优雅经常意味着工期和风险一起上涨。

这次采用的是分阶段路线:

  1. 先确认外层 RSA/AES 可复现
  2. 再确认 inner blob 编码可复现
  3. 使用成功样本的 source profile 打通链路
  4. 后续再逐步把 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

它的逆向路线可以概括为:

  1. 从成功样本确认格式
  2. 拆分 header 三段
  3. 验证 RSA/AES 外层
  4. 解码 inner dtrait 结构
  5. 定位 SDK source profile
  6. 用扰动实验确认字段来源
  7. 用 Python 复现生成链路
  8. 接入请求构建流程

最后的感悟:

加密字符串越长,越要先拆结构。

逆向不是和字符串较劲,而是把它还原成输入、过程和输出。

当一坨 header 变成一个函数,事情就开始可控了。

相关推荐
如烟花的信页1 天前
*花顺cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang1 天前
SpiderDemo 第1题:请求头检测挑战 —— Disable cache 缓存头与请求特征差异
js逆向·爬虫逆向·spiderdemo·tls指纹·请求头检测·disable cache
Amo Xiang2 天前
全国新书网 —— AES/CBC 双向加解密
js逆向·python爬虫·cryptojs·前端加密·aes加解密·pycryptodome
如烟花的信页2 天前
加速乐cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang2 天前
福建公共资源交易平台 —— MD5 签名 + AES 响应解密
js逆向·python爬虫·md5·cryptojs·前端加密·axios拦截器·aes解密
Amo Xiang3 天前
JS 逆向系统进阶路线:专栏总纲与文章导航
javascript·js逆向·前端加密·爬虫逆向·反爬虫
Amo Xiang3 天前
新华社客户端 —— 3DES 双向加解密
js逆向·python爬虫·cryptojs·3des·前端加密
冰履踏青云3 天前
十年饮冰,热血难凉 | JS逆向为爱发电
js逆向
嫂子的姐夫4 天前
047-MD5:飞卢网
爬虫·python·js逆向·逆向