抖音Web逆向(一):__ac_signature算法还原

admin 2026-08-11 05:05:35 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细记录了抖音Web逆向中__ac_signature算法的还原过程。通过黑盒差分分析与白盒插桩技术,作者成功用纯Python复现了该签名算法。核心方法包括使用Playwright控制浏览器、锁定随机源、以及通过输出差分确定结构。该研究为Web安全逆向提供了实用方法论。 综合评分: 90 文章分类: 逆向分析


cover_image

抖音 Web 逆向(一):__ac_signature 算法还原

原创

泡泡以安 泡泡以安

泡泡以安

2026年7月28日 13:24 新加坡

在小说阅读器读本章

去阅读

这篇是笔者通过 AI 把抖音 web 的 __ac_signature 从头啃下来的过程记录。目标很直接:window.byted_acrawler.sign("", nonce) 吐出来的那串 __ac_signature(长这样 _02B4Z6wo00f01vmnX1wAAIDD9wE8WneXenb5h1vAANtH2e),笔者要用纯 Python 还原出来,而且对着线上真实 acrawler 逐字节对得上。

免责声明:本文所涉及的内容仅供学习、交流,请勿将其用于非法用途!!

速览:这篇怎么读

一句话。 抖音 web 每个接口都要一个叫 __ac_signature 的签名,它由页面里一段被虚拟机保护的 JS 生成。这篇要做的,就是把这段黑盒 JS 彻底看穿,用一个纯 Python 函数复现它,且对线上逐字节一致。

它为什么难? 三样叠一起:

① 算法不是明文 JS,是编译成字节码、跑在一台自定义虚拟机(JSVMP)上;

② 它要读浏览器指纹(canvas / WebGL / UA),脱离真浏览器根本算不出;

③ 时间戳过了雪崩哈希,输入动一丁点、输出就全变,没法靠猜。

拨开虚拟机,其实很朴素。 核心就是拿 (时间戳, url, UA, nonce, 指纹) 这几个数据,反复做一种逐字符的字符串哈希,再把几个哈希结果按位切一切、查一张自定义 base64 表,拼成 47 个字符。

主线一条线,两条腿走:

  1. 侦察地形(第 2 章)——门在哪、字节今天有没有把入口换掉。
  2. 造一台可控签名机(第 4 章)——把线上代码关进一个完全受控的浏览器,锁死时间和随机源,做到同输入必得同输出。这是后面一切差分的地基。
  3. 黑盒看输出(第 5 章)——先不碰算法,光靠”改一位输入、看哪几位输出跟着变”,把 47 个字符切成 7 段、认出编码方式。
  4. 白盒挖算术(第 6 章)——黑盒到雪崩哈希就到头了,于是钻进虚拟机给它插桩、把每条指令执行时的栈拍成快照,再从快照里把哈希公式和被哈希的原文一个个抠出来。
  5. 拼起来验证(第 7、9 章)——黑盒的结构 × 白盒的算术,一拼就是完整算法,最后跑 100 组逐字节对拍。

逆向方法论主线

整套路子一句话记住:黑盒定结构(输出怎么排),白盒挖算术(每段怎么算)。

0. 环境搭建

# 1) Python 3.9+;建虚拟环境(可选)
python3 -m venv venv && source venv/bin/activate

# 2) 装 Playwright 并下载它自带的 Chromium
pip install playwright
playwright install chromium        # 下载浏览器内核到 ~/Library/Caches/ms-playwright

# 3) 本机若装了 Google Chrome,后面用 channel="chrome" 会更像真人(强烈建议)
ls "/Applications/Google Chrome.app" 2>/dev/null && echo "有 Chrome"

# 4) 建工作目录(本仓库对应 ac_signature/,脚本统一从该目录根运行)
mkdir -p ~/douyin/web/ac_signature/artifacts/scripts && cd ~/douyin/web/ac_signature

为什么非要真浏览器?因为 __ac_signature 的算法要读浏览器指纹(navigator/canvas/WebGL),这些只有真浏览器给得出真值。所以笔者这套思路不是”把算法抠出来单独跑”,而是让线上代码在一个完全受控的浏览器里跑,当成一个黑盒签名机来用(喂 nonce、ts,吐签名)。

1. 目标:__ac_signature 是什么

抖音 web 的一大堆接口(首页、搜索、用户页、详情)都要 Cookie / 参数里带 __ac_signature。它的生成链路是这样:

服务端下发 __ac_nonce
        │
        ▼
window.byted_acrawler.sign("", __ac_nonce)   ← JSVMP 字节码,读 (ts, nonce, 浏览器指纹)
        │
        ▼
__ac_signature(47 字符,_02B4Z6wo00f01…) → 写回 Cookie → reload 进真实页面

难点在于算法不是明文 JS,而是被编译成字节码、跑在一台自定义虚拟机(JSVMP)上;指纹又深度参与哈希,没法简单 hook 出一个独立函数直接调。

2. 哪个 URL 直接吐 acrawler

for u in https://www.iesdouyin.com/ https://www.douyin.com/user/self https://sso.douyin.com/; do
  echo "== $u =="; curl -sS -D - "$u" -o /tmp/p.html \
    -A "Mozilla/5.0 (…) Chrome/127.0.0.0 Safari/537.36" 2>&1 | grep -iE '^set-cookie|content-length'
  grep -oiE '__ac_nonce|byted_acrawler|_wafchallengeid' /tmp/p.html | sort -u
done

命中:

== https://www.douyin.com/user/self ==
content-length: 72914
set-cookie: __ac_nonce=06a671e43007373ac5f57; Path=/; Max-Age=1800; Secure; SameSite=None
set-cookie: __ac_nonce=06a671e43007373ac5f57; Path=/; Max-Age=1800
__ac_nonce
byted_acrawler

/user/self 直接 set 了 __ac_nonce,HTML 里也有 byted_acrawler——这就是签发 __ac_signature 的那个经典跳转页。

3. 目标解剖 + 抽取脚本

3.1 把 /user/self 抓全并劈出内联脚本

curl -sS "https://www.douyin.com/user/self" -o artifacts/user_self.html \
  -A "Mozilla/5.0 (…) Chrome/127.0.0.0 Safari/537.36"

# split_scripts.py —— 把 HTML 里所有 <script> 劈成独立文件
import&nbsp;re
html=open("artifacts/user_self.html",encoding="utf-8",errors="replace").read()
for&nbsp;i,(attrs,body)&nbsp;in&nbsp;enumerate(re.findall(r'<script\b([^>]*)>(.*?)</script>',&nbsp;html,&nbsp;re.S|re.I)):
&nbsp;&nbsp;&nbsp;&nbsp;marks=[m&nbsp;for&nbsp;m&nbsp;in&nbsp;["_$jsvmprt","byted_acrawler",".sign(","484e4f4a"]&nbsp;if&nbsp;m&nbsp;in&nbsp;body]
&nbsp;&nbsp;&nbsp;&nbsp;open(f"artifacts/scripts/s{i:02d}.js","w").write(body)
&nbsp;&nbsp;&nbsp;&nbsp;print(f"s{i:02d}.js  len={len(body):>7}  marks={marks}")

# 输出
s00.js&nbsp;&nbsp;len=&nbsp;&nbsp;71725&nbsp;&nbsp;marks=['_$jsvmprt',&nbsp;'484e4f4a']&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# JSVMP 解释器 + 字节码
s01.js&nbsp;&nbsp;len=&nbsp;&nbsp;&nbsp;1090&nbsp;&nbsp;marks=['byted_acrawler',&nbsp;'.sign(']&nbsp;&nbsp;# 胶水:init + sign + 写cookie + reload

3.2 胶水层 s01.js 印证调用链

window.byted_acrawler.init({aid:99999999,dfp:0});
var&nbsp;__ac_nonce&nbsp;=&nbsp;_f2("__ac_nonce"),
&nbsp;&nbsp;&nbsp;&nbsp;__ac_signature&nbsp;=&nbsp;window.byted_acrawler.sign("",&nbsp;__ac_nonce);&nbsp;&nbsp;&nbsp;// ← 核心调用
_f3("__ac_signature",&nbsp;__ac_signature);&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// 写 cookie
window.location.reload();&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// 算完就刷新

3.3 核心 s00.js 确认是 JSVMP

import&nbsp;re
s=open("artifacts/scripts/s00.js").read()
h=re.findall(r'[0-9a-f]{500,}',&nbsp;s)[0]&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 唯一超长 hex
print("字节码:",&nbsp;len(h)//2,&nbsp;"字节")&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# => 30179 字节
print("magic:",&nbsp;bytes.fromhex(h[:16]))&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# => b'HNOJ@?RC'

484e4f4a403f5243 = ASCII HNOJ@?RC,是 acrawler JSVMP 的固定特征。派发循环长这样(第 6 章要给它插桩):

if(!I)for(;O<E;){var&nbsp;j=parseInt(""+b[O]+b[O+1],16);O+=2;var&nbsp;A=3&(x=13*j%241);…}
//  栈=S  栈指针=R  两级 2-bit 派发=13*j%241  字符串表 XOR 解码=r^i.p[P]

顺手数了下 s00.js 里的指纹关键词——createElement/getContext/toDataURL/webgl/userAgent/[native code] 全是 0。也就是说这份线上 acrawler 不自造环境,直接读真实浏览器。

3.4 路线确定

到此可以定性了:标准 JSVMP,超大文件 + 派发解释器 + 30KB 字节码 + magic 齐活。笔者不打算反编译字节码,两条腿走:

  • 黑盒(第 4-5 章):把线上 acrawler 跑成一个可控签名机,从签名差分出输出结构。
  • 白盒(第 6 章):给解释器插桩、栈快照,把哈希算术从字节码里挖出来。

整条主线五步(就是开头速览里那张方法论图),后面逐一展开。

4. 造签名机:让签名确定、可控、可复现

要做差分和还原,前提是同输入必得同输出。线上直接跑有三个不确定性挡路:

Date.now() 取当前时间;

② 可能用 Math.random()/crypto.getRandomValues()

③ s01 算完立刻 reload,而且签名依赖 location(url 会进哈希),不能随便找个空白页跑。

笔者的解法是:拦截 douyin 那个 URL,只把 s00.js 挂在 douyin 域名下的一个极简页里跑,同时把随机源全锁死。

# oracle.py
import&nbsp;time
from&nbsp;playwright.sync_api&nbsp;import&nbsp;sync_playwright
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
S00=open("artifacts/scripts/s00.js").read()
PAGE=("<!doctype html><html><head><meta charset=utf-8></head>"
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"<body><canvas id=c></canvas><script>"+S00+"</script></body></html>")
INIT=r"""
// 可控时间戳
window.__T=1700000000000;
(function(){var R=Date;function F(a,b,c,d,e,f,g){switch(arguments.length){
&nbsp;case 0:return new R(window.__T);case 1:return new R(a);case 2:return new R(a,b);
&nbsp;case 3:return new R(a,b,c);case 4:return new R(a,b,c,d);case 5:return new R(a,b,c,d,e);
&nbsp;case 6:return new R(a,b,c,d,e,f);default:return new R(a,b,c,d,e,f,g);}}
&nbsp;F.now=function(){return window.__T;};F.parse=R.parse;F.UTC=R.UTC;F.prototype=R.prototype;window.Date=F;})();
// 可控随机数
Math.random=function(){return 0;};
try{if(window.crypto&&crypto.getRandomValues)crypto.getRandomValues=function(a){for(var i=0;i<a.length;i++)a[i]=0;return a;};}catch(e){}
"""
with&nbsp;sync_playwright()&nbsp;as&nbsp;p:
&nbsp;&nbsp;&nbsp;&nbsp;br=p.chromium.launch(channel="chrome",headless=False,args=["--disable-blink-features=AutomationControlled"])
&nbsp;&nbsp;&nbsp;&nbsp;ctx=br.new_context(user_agent=UA,locale="zh-CN",timezone_id="Asia/Shanghai")
&nbsp;&nbsp;&nbsp;&nbsp;# 关键①:随机源确定化,页面脚本前生效
&nbsp;&nbsp;&nbsp;&nbsp;ctx.add_init_script(INIT)
&nbsp;&nbsp;&nbsp;&nbsp;# 关键②:拦截,返回自己的极简页
&nbsp;&nbsp;&nbsp;&nbsp;ctx.route("https://www.douyin.com/user/self",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;lambda&nbsp;r:r.fulfill(status=200,content_type="text/html; charset=utf-8",body=PAGE))
&nbsp;&nbsp;&nbsp;&nbsp;pg=ctx.new_page();&nbsp;pg.goto("https://www.douyin.com/user/self",wait_until="load");&nbsp;time.sleep(0.5)
&nbsp;&nbsp;&nbsp;&nbsp;def&nbsp;sign(n,t):&nbsp;return&nbsp;pg.evaluate("([n,t])=>{window.__T=t;return window.byted_acrawler.sign('',n);}",[n,t])
&nbsp;&nbsp;&nbsp;&nbsp;print(sign("06a61b9b600a388fc6b29",1700000000000))
&nbsp;&nbsp;&nbsp;&nbsp;br.close()

三个点缺一不可:channel="chrome" 拿真人指纹,ctx.route 保住 douyin 的 locationadd_init_script 让覆盖在页面脚本之前生效。

跑一批 (nonce, ts) 看看:

sign(06a61b9b600a388fc6b29,&nbsp;1700000000000)&nbsp;=&nbsp;_02B4Z6wo00f01vmnX1wAAIDD9wE8WneXenb5h1vAANtH2e
sign(06a61b9b600a388fc6b29,&nbsp;1700000001000)&nbsp;=&nbsp;_02B4Z6wo00f01RF.hLwAAIDAH9nnuolw7lkRX4AAACEO27
sign(06a61b9b600a388fc6b29,&nbsp;1700000000000)&nbsp;=&nbsp;_02B4Z6wo00f01vmnX1wAAIDD9wE8WneXenb5h1vAANtH2e&nbsp;&nbsp;&nbsp;←&nbsp;与第1条同
sign(0123456789abcdef01234,&nbsp;1700000000000)&nbsp;=&nbsp;_02B4Z6wo00f01vmnX1wAAIDD9wE8WneWN2L5h1vAANtH5e
sign(ffffffffffffffffffff0,&nbsp;1721000000000)&nbsp;=&nbsp;_02B4Z6wo00f015Ukl1QAAIDCm4L0UMMWz.eVBJPAAIPlb6

光这几条就能看出三件事:

① 可复现(第 1、3 条同输入完全相同);

② ts 一动就雪崩(+1 秒几乎全变,说明 ts 过了哈希混淆);

③ nonce 只影响局部(同 ts 换 nonce 只有后段变)。

5. 黑盒:看清输出长什么样

这一章不借任何旧资料,只用签名机的输入输出,测出 __ac_signature 这 47 个字符里谁是常量、谁随 ts、谁随 nonce,再用编码分析切出精确的字段边界。三步走:

① 先粗看哪几位在变(5.1);

② 再认出编码是自定义 base64(5.2);

③ 最后精确切成 7 段(5.3)。

5.1 第一步:差分出大致结构

采两组样本:固定 nonce 变 ts(6 个 ts)、固定 ts 变 nonce(6 个 nonce)。逐列看哪些字符会变:

def&nbsp;diffcols(sigs):
&nbsp;&nbsp;&nbsp;&nbsp;# 返回"会变"的列下标集合
&nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;{i&nbsp;for&nbsp;i&nbsp;in&nbsp;range(len(sigs[0]))&nbsp;if&nbsp;len({s[i]&nbsp;for&nbsp;s&nbsp;in&nbsp;sigs})>1}
# 随 ts 变的列
tsc&nbsp;=&nbsp;diffcols(ts变的6条)
# 随 nonce 变的列
nc&nbsp;&nbsp;=&nbsp;diffcols(nonce变的6条)
kind="".join("B"&nbsp;if&nbsp;i&nbsp;in&nbsp;tsc&nc&nbsp;else&nbsp;"T"&nbsp;if&nbsp;i&nbsp;in&nbsp;tsc&nbsp;else&nbsp;"N"&nbsp;if&nbsp;i&nbsp;in&nbsp;nc&nbsp;else&nbsp;"C"&nbsp;for&nbsp;i&nbsp;in&nbsp;range(47))

真实输出:

idx&nbsp;:&nbsp;01234567890123456789012345678901234567890123456
sig&nbsp;:&nbsp;_02B4Z6wo00f01vmnX1wAAIDD9wE8WneXenb5h1vAANtH2e
kind:&nbsp;CCCCCCCCCCCCCCTTTTTTCCCCTTTTTTTTBBBBTTTTCCTTTBB&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;(C常量&nbsp;T随ts&nbsp;N随nonce&nbsp;B两者)

连续段:
&nbsp;&nbsp;[&nbsp;0:14]&nbsp;'_02B4Z6wo00f01'&nbsp;&nbsp;常量&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;←&nbsp;版本+固定前缀
&nbsp;&nbsp;[14:20]&nbsp;'vmnX1w'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;随ts
&nbsp;&nbsp;[20:24]&nbsp;'AAID'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;常量&nbsp;&nbsp;←&nbsp;★&nbsp;内嵌常量:说明这里是某个"固定高位"透出来的
&nbsp;&nbsp;[24:32]&nbsp;'D9wE8Wne'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;随ts
&nbsp;&nbsp;[32:36]&nbsp;'Xenb'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;ts+nonce
&nbsp;&nbsp;[36:40]&nbsp;'5h1v'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;随ts
&nbsp;&nbsp;[40:42]&nbsp;'AA'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;常量&nbsp;&nbsp;←&nbsp;★&nbsp;又一处固定高位
&nbsp;&nbsp;[42:45]&nbsp;'NtH'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;随ts
&nbsp;&nbsp;[45:47]&nbsp;'2e'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;ts+nonce&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;←&nbsp;尾(依赖前面全部)

大致结构出来了:常量前缀 | 一大片 ts 区 | 中间掺了 nonce 的区 | 一个依赖全部的尾。

值得单独记一笔的是 AAIDAA 这种字段内部的常量子串。它们其实是某个固定高位比特透出来的信号——后面会看到 AAID 正是 64 位里那个固定头 8240 的高位,AA 则是 h_url%65521 的值恒小于 65521、高位永远是 0,编码出来就是 base64 的 A。以后再看到字段里嵌着一段死活不变的常量,第一反应就该是”这里藏了个固定的高位常量”。

差分能给的也就到粗地形为止了。比如 ts 区里 10 个字符全在变,你看不出它其实是 5+5 两段。要精确到字段,得靠下一步的编码分析。

5.2 第二步:认出编码为自定义 base64(值→字符)

把大量签名里出现过的字符去重统计,恰好 64 种(A-Z a-z 0-9 加两个符号)——这基本就是 6 位一组的 base64 系编码了。不过”64 种字符”只给出字符集合,不给索引顺序(谁是 0 谁是 63)。顺序这么定:

  • 末两位是哪两个符号,观测直接给答案。把所有签名出现过的字符去重列出来,那 64 种恰好是 A-Za-z0-9 外加 -.——没有 +/_。所以字母表末两位就是 -.+/-_ 那两套字母表直接排除(一个含 . 的签名根本不可能由它们产出)。
  • 前 62 个用标准 base64 顺序 A-Za-z0-9A=0…Z=25, a=26…z=51, 0=52…9=61),几乎所有 base64 变体都这么约定。
  • 只剩一个自由度:-. 谁是 62 谁是 63。拿一条含 . 的签名一判就定——用 §4 里 ts=1700000001000 那条 _02B4Z6wo00f01RF.hLwAA…,它的 V0 = sig[14:19] = RF.hL(正好带 .)。这条 ts 的内部 v3l>>2 = 286783563(管线算出来的,见 §6)。两种顺序各 dec 一次比对:
  dec('RF.hL',&nbsp;&nbsp;'-'=62&nbsp;'.'=63)&nbsp;=&nbsp;286783563&nbsp;&nbsp;==&nbsp;v3l>>2&nbsp;?&nbsp;&nbsp;True&nbsp;&nbsp;&nbsp;&nbsp;←&nbsp;只有这套成立
  dec('RF.hL',&nbsp;&nbsp;'.'=62&nbsp;'-'=63)&nbsp;=&nbsp;286779467&nbsp;&nbsp;==&nbsp;v3l>>2&nbsp;?&nbsp;&nbsp;False

于是第 62 位是-,第 63 位是.(注意不是 URL-safe 的_)。第 6 章从 VM 的字符串表/编码闭包里也能直接读到同一张表,正好双向印证。

ALPH = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-."
#        0..25 大写      26..51 小写         52..61 数字      62='-' 63='.'

怎么确认它是「值→字符,每 6 位一组」,而不是标准 base64(字节数组)?两个判据:

  • 前缀后首个变化字符会按字母表顺序递变——固定 nonce 微调 ts,某段最低位字符沿 …9-. 顺序进位,说明是「整数低 6 位查表」。
  • 段长和位宽自洽——常量前缀后是 5、5、6、5、5、5 共 31 字符的 body;5*6=306*6=36 位,恰好是 30/30/36/30/30/30 位的 6 个字段(为什么正好这么切,§6.5 反解坐实)。
def&nbsp;enc(v,&nbsp;nb):&nbsp;&nbsp;&nbsp;&nbsp;# 整数 v → nb 位,高到低每 6 位一个字母
&nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;"".join(ALPH[(v&nbsp;>>&nbsp;(nb-6-6*i))&nbsp;&&nbsp;63]&nbsp;for&nbsp;i&nbsp;in&nbsp;range(nb//6))
def&nbsp;dec(s):&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 逆:字符串 → 整数
&nbsp;&nbsp;&nbsp;&nbsp;v=0
&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;c&nbsp;in&nbsp;s:&nbsp;v=(v<<6)|ALPH.index(c)
&nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;v

5.3 第三步:切出精确字段(14 + 5+5 + 6 + 5+5 + 5 + 2)

把粗地形按 base64 的 6 位边界对齐,就是最终字段图:

_02B4Z6wo00f01&nbsp;│&nbsp;V0&nbsp;&nbsp;V1&nbsp;│&nbsp;&nbsp;V2&nbsp;&nbsp;│&nbsp;V3&nbsp;&nbsp;V4&nbsp;│&nbsp;V5&nbsp;│&nbsp;tail
└──&nbsp;PREFIX(14)─┘&nbsp;&nbsp;(5+5)&nbsp;&nbsp;&nbsp;(6)&nbsp;&nbsp;&nbsp;&nbsp;(5+5)&nbsp;&nbsp;&nbsp;(5)&nbsp;&nbsp;&nbsp;(2)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;30+30&nbsp;&nbsp;&nbsp;&nbsp;36&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;30+30&nbsp;&nbsp;&nbsp;30&nbsp;&nbsp;&nbsp;&nbsp;校验&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;14+5+5+6+5+5+5+2&nbsp;=&nbsp;47
def&nbsp;seg(sig):
&nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;dict(V0=sig[14:19],V1=sig[19:24],V2=sig[24:30],
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;V3=sig[30:35],V4=sig[35:40],V5=sig[40:45],tail=sig[45:47])

对上大致结构:V0V1 是 ts 区(其中 V1=sig[19:24]=wAAID 透出固定头 8240 的高位,所以内嵌 AAID),V2 是指纹区(后面会证是 FP_CONST ⊕ v3l),V3、V4 是掺 nonce 区,V5 因为 h_url 是小值所以 AA 开头,tail 依赖全部。字段结构测绘到此为止,但每个字段”具体怎么算”还是黑盒——交给第 6 章。

6. 白盒:把算术从字节码里读出来

黑盒到这就到头了:ts 是雪崩哈希(+1 秒全变),没法逆推。想拿到算法,只能进 VM 看内部算术。办法是给解释器的派发循环插桩,逐 opcode 快照栈,再让 Python 从快照里零假设认出哈希。

这一章是全篇最长的,先给张分步地图,免得读着读着迷路——六小步,一步一个产出:

  • 6.0 在 71KB 混淆里找到该下手插桩的地方;
  • 6.1 插桩,把每条指令执行时的栈拍成快照;
  • 6.2 从快照里反解出哈希用的乘子(不预设、纯观测);
  • 6.3 顺着哈希链,把”到底哪些字符串被哈希了”一条条读出来;
  • 6.4 把整条管线拼出来;
  • 6.5 收最后一环——那些内部大整数是怎么切成签名字段的。

读不动的时候,每小节看开头一两句结论就够,公式和代码细节可以回头再抠。

6.0 先在 71KB 混淆里找到插桩点

动手插桩前先回答四个问题:解释器在哪、栈/指令指针叫什么、哪里会切走优化路径、关键常量是不是明文。全程 grep,不用读懂整个文件。

Q1 · 确认里面有解释器,字节码是数据。_$jsvmprt

import&nbsp;re
s=open("artifacts/scripts/s00.js").read()
print([m.start()&nbsp;for&nbsp;m&nbsp;in&nbsp;re.finditer(r'_\$jsvmprt',&nbsp;s)])&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# => [56, 9860]

两处:56_$jsvmprt=function(b,e,f){…}(定义,也就是解释器本体),9860 是调用 _$jsvmprt("484e4f4a…30KB hex…", [natives])(第 1 参是 hex 字节码/数据,第 2 参是 natives 表)。要插桩的就是那个 function。

Q2 · 找派发循环,认出栈。 JSVMP 派发循环的通用长相:一个 for 每轮读 2 个 hex 字符算出 opcode。搜 parseInt(…16)

print([m.start()&nbsp;for&nbsp;m&nbsp;in&nbsp;re.finditer(r'parseInt\("\"\+b\[O\]\+b\[O\+1\],16\)',&nbsp;s)])&nbsp;&nbsp;&nbsp;# => [2812]
print(s[2790:2960])
w,S=[],R=0;&nbsp;…&nbsp;var&nbsp;x,z,O=e,E=O+2*f;
if(!I)for(;O<E;){var&nbsp;j=parseInt(""+b[O]+b[O+1],16);O+=2;var&nbsp;A=3&(x=13*j%241);&nbsp;…&nbsp;S[++R]=…&nbsp;C=S[R--]&nbsp;…}

一眼就能对号:S=[] 是栈、R 是栈指针(满屏 S[++R]/S[R--])、O 是指令指针(字节偏移,O+=2 每次吃 2 hex)、j 是当前 opcode、13*j%241 是两级派发。锚点就定在 var j=parseInt(""+b[O]+b[O+1],16);O+=2;

Q3 · 找优化路径 I 及其切换点,不然会漏抓循环。 派发被 if(!I)for(...) 包着,那必然有条镜像的 if(I)for(...)

print(s[s.find('j=B[O]')-16:s.find('j=B[O]')+40])&nbsp;&nbsp;&nbsp;# if(I)for(;O<E;){j=B[O];O+=2; … }  @6273

也就是说 VM 有两套等价派发:!I(每次现解析 hex,慢)和 I(直接读预解码字节数组 B 里的 opcode,快)。字符级哈希是紧循环,也就是反复后向跳转,VM 会切到 I 路径优化——你只插桩 hex 锚点的话,哈希每一轮迭代全漏掉。所以得先禁掉切换点,逼它全程走 hex。搜 I=1

print([m.start()&nbsp;for&nbsp;m&nbsp;in&nbsp;re.finditer(r'I=1,F\(b,e,2\*f\),O\+=2\*z-2;break',&nbsp;s)])&nbsp;&nbsp;&nbsp;# => [4705, 5119]

两处后向跳转(s(b,O)<0 即跳转偏移为负时 I=1,F(...) 切 I 路径),把 I=1,F(b,e,2*f),O+=2*z-2;break 换成 O+=2*z-2;continue(留在 hex 循环继续)。再加上 K 里第二次调用的切换点(B[e]?G(…,1):G(…,0) → 强制走 G(…,0))。

Q4 · 确认关键常量不是明文,所以非动态挖不可。

print(s.count('65599'),&nbsp;s.count('65521'))&nbsp;&nbsp;&nbsp;# => 0 0

乘子、模都不是明文,藏在字节码常量表里,运行时用 r^i.p[P] XOR 解出来。这就是为什么必须栈快照——常量和算术只在运行时现形。

四问答完,插桩点全部定位(锚点 + 两处 I 切换 + K 切换),进 6.1。

6.1 给派发循环插桩

按 6.0 定位到的点,先禁掉 I 路径(3 处替换)逼全程走 hex,再在锚点后注入栈快照。

s00=open("artifacts/scripts/s00.js").read()
# ① 禁后向跳转切 I 路径(2 处)
s00=s00.replace("I=1,F(b,e,2*f),O+=2*z-2;break",&nbsp;"O+=2*z-2;continue")
# ② 禁 K 里第二次调用切 I 路径
s00=s00.replace("return i&&!B[e]&&F(b,e,2*f),B[e]?G(b,e,f,0,o,c,null,1)[1]:G(b,e,f,0,o,c,null,0)[1]",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"return G(b,e,f,0,o,c,null,0)[1]")
# ③ 锚点后注入:记录 [opcode j, 指令偏移, 栈指针R, 栈顶S[R], 次顶S[R-1]]
anchor='var j=parseInt(""+b[O]+b[O+1],16);O+=2;'
s00=s00.replace(anchor,&nbsp;anchor+
&nbsp;&nbsp;'if(glb.__tr){var _a=S[R],_b=S[R-1];glb.__tr.push([j,O-2,R,'
&nbsp;&nbsp;'(typeof _a==="number"?_a:null),(typeof _b==="number"?_b:null)]);}',&nbsp;1)

把这段 s00 套进第 4 章的签名机(glb 就是签名机页面的全局 window;签名前先 glb.__tr=[],跑完再读回 window.__tr),跑一次 sign("06a61b9b600a388fc6b29", 1700000000000),签完把 window.__tr dump 成 artifacts/hash_trace.json——这整套”插桩 + 跑 + 导出”就是 code/trace_hash.py(轨迹每行 = [opcode j, 偏移O, 栈指针R, S[R], S[R-1]]):

sig&nbsp;(no-trace)&nbsp;:&nbsp;_02B4Z6wo00f01vmnX1wAAIDD9wE8WneXenb5h1vAANtH2e
sig&nbsp;(traced)&nbsp;&nbsp;&nbsp;:&nbsp;_02B4Z6wo00f01vmnX1wAAIDD9wE8WneXenb5h1vAANtH2e&nbsp;&nbsp;opcodes:&nbsp;10209
禁I路径后签名不变&nbsp;&&nbsp;插桩不改签名:&nbsp;True

禁 I 路径后签名逐字节不变,反过来也证明了 I 路径就是纯优化,笔者只需要实现 hex 路径就够了。整套「禁快路 → 锚点拍栈快照 → 出轨迹」的流程如下:

JSVMP 插桩流程

6.2 零假设①:反解哈希乘子 K

先把轨迹读进来,抽出「每 opcode 的栈顶序列 tops」和「所有数值栈值 allvals」(后面都靠它俩):

import&nbsp;json
from&nbsp;collections&nbsp;import&nbsp;Counter
MASK=0xFFFFFFFF
trace=json.load(open("artifacts/hash_trace.json"))["trace"]&nbsp;&nbsp;&nbsp;# 每行 = [opcode j, 偏移O, R, S[R], S[R-1]]

tops=[]&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 每 opcode 的栈顶 S[R](非数值记 None)
allvals=[]&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 所有数值栈值(S[R] 与 S[R-1])
for&nbsp;j,O,R,a,b&nbsp;in&nbsp;trace:
&nbsp;&nbsp;&nbsp;&nbsp;tops.append(int(a)&MASK&nbsp;if&nbsp;isinstance(a,(int,float))&nbsp;and&nbsp;float(a).is_integer()&nbsp;else&nbsp;None)
&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;v&nbsp;in&nbsp;(a,b):
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;isinstance(v,(int,float))&nbsp;and&nbsp;float(v).is_integer():&nbsp;allvals.append(int(v)&MASK)

哈希如果是「逐字符 h = f(h, c)」,中间态会反复压栈。两条线索去认它。

一是数魔数——乘子/模会作为操作数反复压栈,统计高频大常量:

for&nbsp;v,n&nbsp;in&nbsp;Counter(allvals).most_common():
&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;v>1000&nbsp;and&nbsp;n>=5:&nbsp;print(v,&nbsp;"x",&nbsp;n)
# 输出:
#   65599  x263   ← 反复出现的大常量,疑似乘子
#   8240   x6     ← 疑似固定高位头
#   65521  x5     ← 疑似模

这三个数后面反复冒头,先混个脸熟:65599 是 SDBM 系那个经典字符串哈希乘子;65521 是最大的 16 位质数,acrawler 老拿它当模,把 32 位哈希压回 16 位塞进小槽;8240 是个固定往高位填的头。各自的身份后面会一一坐实。

二是解方程反解 K——设 h_{i+1} = ((h_i ^ c) * K) & MASK,c 是可打印字符。相邻栈顶 (a,b)、对每个 c 解 K = b · inv(a^c) mod 2³²inv 是模 2³² 逆元,仅当 a^c 为奇数才存在)。先看天真版怎么翻车:

def&nbsp;inv(a):&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# a 的模 2^32 逆(a 必奇),牛顿迭代 6 次足够覆盖 32 位
&nbsp;&nbsp;&nbsp;&nbsp;x=1
&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;_&nbsp;in&nbsp;range(6):&nbsp;x=(x*(2-a*x))&MASK
&nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;x

kc=Counter()
vals=[v&nbsp;for&nbsp;v&nbsp;in&nbsp;tops&nbsp;if&nbsp;v&nbsp;is&nbsp;not&nbsp;None]
for&nbsp;i&nbsp;in&nbsp;range(len(vals)-1):
&nbsp;&nbsp;&nbsp;&nbsp;a,b=vals[i],vals[i+1]
&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;c&nbsp;in&nbsp;range(32,127):
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;t=(a^c)&MASK
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;t&1:&nbsp;kc[(b*inv(t))&MASK]+=1
print(kc.most_common(3))
# => [(0, 21964), (1, 313), (2863311531, 221)]   ← K=0 刷屏!

翻车的原因是栈里全是 0b=0 时对任意 a 都解出 K=0,真值被淹了。修一下——只取 a,b 都是大值(真哈希态)的配对,把退化的排掉:

kc=Counter()
seq=[(i,v)&nbsp;for&nbsp;i,v&nbsp;in&nbsp;enumerate(tops)&nbsp;if&nbsp;v&nbsp;is&nbsp;not&nbsp;None&nbsp;and&nbsp;v>0xFFFF]&nbsp;&nbsp;&nbsp;# 只留大值
for&nbsp;idx&nbsp;in&nbsp;range(len(seq)-1):
&nbsp;&nbsp;&nbsp;&nbsp;i,a=seq[idx]
&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;jdx&nbsp;in&nbsp;range(idx+1,&nbsp;min(idx+6,&nbsp;len(seq))):&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 向后看 5 个
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;k,b=seq[jdx]
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;k-i>30:&nbsp;break
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;c&nbsp;in&nbsp;range(32,127):
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;t=(a^c)&MASK
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;t&1:&nbsp;kc[(b*inv(t))&MASK]+=1
print(kc.most_common(2))
# => [(65599, 482), (1, 276)]   ← K=65599 碾压

两条线索都指向 K = 65599(SDBM/gawk 系那个经典字符串哈希乘子)。乘子是从轨迹里零假设反解出来的,没用任何先验。

6.3 零假设②:让链条自己拼出被哈希的字符串

有了 K,就能顺着 h_{i+1}=((h_i^c)*K)&MASK 在栈顶序列里贪心找最长链,把每一步的 c 读出来——它们会拼成被哈希的原文:

K=65599
def&nbsp;extend(h,&nbsp;pos):&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 从值 h @ pos 贪心延伸最长可打印链
&nbsp;&nbsp;&nbsp;&nbsp;s=""
&nbsp;&nbsp;&nbsp;&nbsp;while&nbsp;True:
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;nxt=None
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;c&nbsp;in&nbsp;range(32,127):
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;want=((h^c)*K)&MASK
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;for&nbsp;k&nbsp;in&nbsp;range(pos+1,&nbsp;min(pos+50,len(tops))):&nbsp;&nbsp;&nbsp;# 近处找 want
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;tops[k]==want:&nbsp;nxt=(c,k,want);&nbsp;break
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;nxt:&nbsp;break
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;not&nbsp;nxt:&nbsp;break
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;c,k,want=nxt;&nbsp;s+=chr(c);&nbsp;h=want;&nbsp;pos=k
&nbsp;&nbsp;&nbsp;&nbsp;return&nbsp;s,h

# 驱动:从每个位置都试着起一条链,收 >=4 的、去重,再滤掉"是更长链后缀"的碎片
chains=[];&nbsp;seen=set()
for&nbsp;i,v&nbsp;in&nbsp;enumerate(tops):
&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;v&nbsp;is&nbsp;None:&nbsp;continue
&nbsp;&nbsp;&nbsp;&nbsp;st,end=extend(v,i)
&nbsp;&nbsp;&nbsp;&nbsp;if&nbsp;len(st)>=4&nbsp;and&nbsp;(v,st)&nbsp;not&nbsp;in&nbsp;seen:
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;seen.add((v,st));&nbsp;chains.append((v,st,end))
chains.sort(key=lambda&nbsp;x:-len(x[1]))
final=[c&nbsp;for&nbsp;c&nbsp;in&nbsp;chains&nbsp;if&nbsp;not&nbsp;any(c[1]!=d[1]&nbsp;and&nbsp;c[1]&nbsp;in&nbsp;d[1]&nbsp;for&nbsp;d&nbsp;in&nbsp;chains)]
for&nbsp;v,st,end&nbsp;in&nbsp;final:&nbsp;print(f"seed={v:>10}&nbsp;&nbsp;{st!r}&nbsp;&nbsp;-> {end}")

挖出的全部长链(每条都过 H(seed,s)==end 校验;seed 带 ±1 的碎片是贪心起点的噪声,取最长的看):

seed=&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;'1700000000www.douyin.com/user/self'&nbsp;&nbsp;->&nbsp;1349395609&nbsp;&nbsp;✓
seed=&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;?&nbsp;&nbsp;'35393725126615'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;->&nbsp;2642226918&nbsp;&nbsp;✓
seed=&nbsp;2642226918&nbsp;&nbsp;'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like …'&nbsp;&nbsp;✓

读出来的这几条直接把整条管线暴露了:

  1. seed=0 → "1700000000www.douyin.com/user/self":这是 str(ts_s) 紧接着 host+pathname。第二段哈希拿第一段结果当种子,链条从 0 一路走完,等价 H(0, str(ts_s)+url)——这就当场证明了链式种子,也直接读出 url = location.host + location.pathnamewww.douyin.com + /user/self)。
  2. "35393725126615" = str(v3),其中 v3 = (8240<<32)|low(正是 5.1 里那个固定高位 8240),它的哈希是 h2 = 2642226918
  3. seed=2642226918("=h2") → "Mozilla/5.0 (Macintosh; …":h_ua 用 h2 当种子,哈希的是带 Mozilla/ 前缀的完整 UA。(nonce 也从 h2 出发,同理。)

这套「反解乘子 → 顺链读原文」画成图是这样:

从栈快照反解哈希链

6.4 把管线拼出来(全部来自轨迹)

ts_s&nbsp;&nbsp;&nbsp;=&nbsp;ts_ms&nbsp;//&nbsp;1000
h_ts&nbsp;&nbsp;&nbsp;=&nbsp;H(0,&nbsp;str(ts_s))&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;链①前半
h_url&nbsp;&nbsp;=&nbsp;H(h_ts,&nbsp;host&nbsp;+&nbsp;pathname)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;链①后半(种子=h_ts)→&nbsp;轨迹读出&nbsp;1349395609
low&nbsp;&nbsp;&nbsp;&nbsp;=&nbsp;ts_s&nbsp;^&nbsp;(h_url&nbsp;%&nbsp;65521)&nbsp;*&nbsp;65521&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;构造见下方反解
v3&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;=&nbsp;(8240&nbsp;<<&nbsp;32)&nbsp;|&nbsp;low&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;高位头&nbsp;8240(轨迹里的常量&nbsp;+&nbsp;str(v3)&nbsp;链②)
h2&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;=&nbsp;H(0,&nbsp;str(v3))&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;链②终值&nbsp;2642226918
h_ua&nbsp;&nbsp;&nbsp;=&nbsp;H(h2,&nbsp;完整UA)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;链③(种子=h2)
h_non&nbsp;&nbsp;=&nbsp;H(h2,&nbsp;nonce)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;#&nbsp;链④(种子=h2)
v3i&nbsp;&nbsp;&nbsp;&nbsp;=&nbsp;((h_ua&nbsp;%&nbsp;65521)&nbsp;<<&nbsp;16)&nbsp;|&nbsp;(h_non&nbsp;%&nbsp;65521)&nbsp;&nbsp;&nbsp;#&nbsp;h_ua/h_non&nbsp;各取&nbsp;%65521&nbsp;后高低&nbsp;16&nbsp;位拼(拼法在&nbsp;§6.5&nbsp;反解)

轨迹只直接给出 str(v3)="35393725126615"(即 v3,拆出 v3h=8240v3l=low),但 low 是怎么来的?黑盒反解交叉验证一下:v3lh_url%65521 都已知(前者是 v3 低 32 位,后者是 V5),代入 ts_s' = v3l ^ (h_url%65521)*65521

# ts=1700000000000 -> ts_s' = 1700000000   (精确等于真 ts_s!)
# ts=1721000000000 -> ts_s' = 1721000000

精确还原出真 ts_s,说明 low = ts_s ^ (h_url%65521)*65521ts_s=ts_ms//1000、头 8240 全都对。到这,核心哈希 H(seed,s)=((h^c)*65599)&0xFFFFFFFF、链式管线、所有输入字符串,全从 VM 挖出来了。整条管线(含每步真实中间值)如下:

哈希管线数据流

6.5 反解字段公式:内部大整数怎么切进 body

还差最后一环:管线算出 v3(=v3l|v3h<<32)v3ih_url 这些内部大整数,它们怎么变成 V0..V5 的?拿一条已知签名,dec 出每个字段的整数,直接和内部值比对,比特切法就现形了。用 §4 那条 sign("06a61b9b600a388fc6b29",1700000000000)(内部 v3l=0xbe69d7d7, v3h=8240, v3i=0x9de5de9d, h_url%65521=56135):

dec(V0)=798651893&nbsp;&nbsp;&nbsp;==&nbsp;v3l>>2&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;✓&nbsp;&nbsp;→&nbsp;V0&nbsp;=&nbsp;enc(v3l>>2,&nbsp;30)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# v3l 高30位
dec(V1)=805306883&nbsp;&nbsp;&nbsp;==&nbsp;((v3l&3)<<28)|(v3h>>4)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;✓&nbsp;&nbsp;→&nbsp;V1&nbsp;=&nbsp;enc(((v3l&3)<<28)|(v3h>>4),30)
dec(V2)=4257238806&nbsp;&nbsp;,&nbsp;&nbsp;dec(V2)&nbsp;^&nbsp;v3l&nbsp;=&nbsp;1135188161&nbsp;&nbsp;&nbsp;→&nbsp;&nbsp;&nbsp;V2&nbsp;=&nbsp;enc(FP_CONST&nbsp;^&nbsp;v3l,&nbsp;36)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# 那个常数=FP_CONST(第8章)
dec(V3)=662271911&nbsp;&nbsp;&nbsp;==&nbsp;v3i>>2&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;✓&nbsp;&nbsp;→&nbsp;V3&nbsp;=&nbsp;enc(v3i>>2,&nbsp;30)
dec(V5)=56135&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;==&nbsp;h_url%65521&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;✓&nbsp;&nbsp;→&nbsp;V5&nbsp;=&nbsp;enc(h_url%65521,&nbsp;30)
dec(V4)=468065647&nbsp;&nbsp;&nbsp;==&nbsp;(((v3i&0xF)<<28)|((524576^v3l)>>4))&nbsp;&&nbsp;0x3FFFFFFF&nbsp;&nbsp;&nbsp;✓&nbsp;&nbsp;→&nbsp;V4(见下注)
  • v3i 的拼法也是这么反解的(上面 V3/V4 里把它当已知用了,这里补它怎么来):v3idec(V3)<<2(低 2 位从 V4 补)得到 = 0x9de5de9d;拆成高低 16 位,和 6.3 读出的 h_ua/h_non(速查表:h_ua=614889485h_non=207627517)取模比对,一拍即合——
  v3i&nbsp;=&nbsp;0x9de5de9d
  v3i&nbsp;>>&nbsp;16&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# = 0x9de5 = 40421 == h_ua % 65521   (614889485 % 65521 = 40421)  ✓
  v3i&nbsp;&&nbsp;0xFFFF&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;# = 0xde9d = 56989 == h_non % 65521   (207627517 % 65521 = 56989)  ✓

于是坐实v3i = ((h_ua % 65521) << 16) | (h_non % 65521)。(和 lowh_url%65521一样,%65521 是 acrawler 把 32 位哈希压进 16 位槽的惯用手法。)

  • V0/V1/V3/V5 一比即中;V2 的 dec(V2)^v3l 得到一个跨所有样本恒定的数,那就是折叠后的指纹常量 FP_CONST(第 8 章反解它)。
  • V4 有个坑:公式值 ((v3i&0xF)<<28)|… 高位会溢出 30 位,而 enc(·,30) 只编码低 30 位,得 & 0x3FFFFFFF 才等于 dec(V4)。这也印证了字段就是「比特流切片」——大整数首尾拼成连续比特流再按 30/36 位切,边界不落在整值上(524576=2049*256+32 这个 V4 里的固定异或项,也是这么反解+跨样本恒定确认的)。

这套「比特流切片」画成图就一目了然——为什么会冒出 >>2<<28 这种怪移位:

比特流切片

到这里,输出结构(第 5 章)× 内部算术(第 6 章)拼完了,第 7 章直接组装成完整函数。

7. 完整算法

至此,逆向分析已经全部结束了,希望大家能学习到新的知识。代码部分为付费内容,有需要的朋友可以付费查看。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:泡泡以安 泡泡以安 泡泡以安《抖音 Web 逆向(一):_acsignature 算法还原》

评论:0   参与:  0