☰
059-jsvmp:香港携程 phantom-token(补环境纯算+服务端真实验收)
2026/11/4 12:07:33 网站建设 项目流程

案例地址:携程香港酒店列表

声明:本文只做学习研究分享,请控制请求频率,不要给站点添麻烦;文中的 token、cookie 都是当次抓包的临时值,已做截断,不能直接使用。

这个案例我一开始也以为是普通的 AES:请求头里挂一个phantom-token,长度一千多字符,看着像密文。真正跟下来才发现,它不是“加密一段固定明文”,而是携程的 sdt SDK 对整个请求体做的一次签名,返回值还带随机量。也就是说,你没法拿线上 token 去“对答案”,只能让本地算出来的 token 去服务端跑一遍,看它认不认。

这篇就按我实际操作的顺序写:先抓包看到 token,再在 Sources 里找到写 token 的那行代码,顺着脚本地址找到 JSVMP,最后用“补环境全扣”的方式在 Node 里跑原版脚本,并用服务端返回的酒店数据做验收。

一、先看现象:请求头里的 phantom-token

打开酒店列表页,按 F12 调出开发者工具,切到Network(网络)面板,勾上Fetch/XHR。

然后在页面上往下滚,让列表加载更多,面板里会出现一条:

POST https://hk.trip.com/restapi/soa2/34951/fetchHotelList

点开它,切到Headers(标头),往下拉到Request Headers(请求标头)。除了常见的accept、content-type、referer,还有两个值得注意的:

  • x-ctx-wclient-req:32 位十六进制,同一轮里每次请求都不一样;
  • phantom-token:1006-common-开头,后面跟一长串 Base64 字符。

我这一轮抓到的 token 是 1,486 字符,请求体 2,955 字节,响应 200、105,922 字节,里面有 13 家酒店。

先把两个基础概念说清楚,不然后面容易懵。

第一,Network 面板是“请求的录像”。浏览器每发一个请求,它都会记下来,包括 URL、方法、请求头、请求体、响应。你看到的fetchHotelList,就是列表页往下滚时,前端为了拿下一页酒店而发的接口。

第二,Sources 面板是“网页加载过的所有 JS 的仓库”。左边是文件目录,右边是源码。网页 HTML 里的<script src="xxx.js">会让浏览器一打开就下载执行,所以文件一开始就在;但现在的网站喜欢把 JS 拆成很多 chunk(代码块),按需加载。你在 Sources 里没看到某个文件,不代表它不存在,可能只是还没被加载。

这一步的结论很简单:目标参数是phantom-token,不是 cookie,也不是x-ctx-wclient-req。后面我们要找的是“谁把这个值写进请求头”。

二、定位:token 是谁写进去的

在 Sources 面板按Ctrl+Shift+F全局搜索phantom-token。注意,是全局搜索,不是当前文件搜索。搜出来会有几处命中,其中两处最关键:

一处在一个app/list/error-*.js里,代码大概是:

if ("function" == typeof window.signature) try { (v = window.signature(r.body)) ? (o["phantom-token"] = v, ...) : ...; } catch (y) { ... }

另一处在一个68002321-*.js里:

p = window.signature(e.options.body ?? {}); e.options.headers["phantom-token"] = p;

两处的意思一样:发请求之前,把请求体交给window.signature,返回值塞进phantom-token请求头。所以 token 不是随机数,也不是从 cookie 里抄的,它和请求体是绑定的。

这里插一句 chunk 的事。你在 Network 里会看到很多_next/static/chunks/...js,这些是 Next.js 打包出来的代码块。页面打开时只加载当前路由需要的 chunk,所以同一个站点,不同页面看到的 JS 文件可能不一样。找调用点时,不要只盯着一个文件,搜关键字更靠谱。

接着搜window.signature是从哪来的。在 chunk 里能看到loadSDT这个函数,它做的事情很朴素:

var t = document.createElement("script"); t.onload = function () { e(void 0) }; t.onerror = function () { e(void 0) }; t.src = a.SDT_JS; document.body.appendChild(t);

也就是动态插一个<script>,把SDT_JS指向的脚本加载进来。SDT_JS的常量长这样:

t.SDT_JS = "//ak-s.tripcdn.cn/.../sdt.1006-common.min.<hash>.js";

我这一轮实际加载的是sdt.1006-common.min.e2757e3a...js,81,159 字符,只有一行。打开一看,开头是function GreatHorn(),末尾是一大串 Base64。到这一步就可以确认:这不是普通混淆,是JSVMP。

三、别硬啃 VM:先判断它是不是 JSVMP

JSVMP 说人话就是:网站把真正的逻辑编译成一套“字节码”,再用一个解释器去执行。你看到的GreatHorn就是解释器,"b": "PwEGAQ..."那串就是字节码。它会在运行时把字符串、函数、分支一点点还原出来。

这种代码的特征很明显:

  • 文件通常只有一行,长度几万到几十万字符;
  • 开头有一个大函数(名字可能叫GreatHorn、AuroraExecution、_0x...),里面全是switch、数组、_unknown_xxx变量;
  • 末尾调用解释器,传进去一个 Base64 字符串;
  • 你在里面搜不到几个有意义的字符串,因为字符串是运行时解码的。

遇到这种代码,有两条路。

第一条是硬啃:用 AST 还原字符串、反混淆、追 opcode,把 VM 拆开。这条路能学到东西,但成本很高,而且站点一更新,之前的劳动可能白费。

第二条是“补环境全扣”:不重写 VM,而是给原版脚本准备一个能跑起来的浏览器环境,让它自己把window.signature装上,我们只调用这个入口。这个案例我选第二条,因为我们的目标很明确——拿到window.signature(body)的返回值,而不是研究 VM 的每一条指令。

四、补环境:让原版 SDK 自己挂上 window.signature

“补环境”听起来玄,其实就一句话:原版 JS 在浏览器里能拿到的对象,在 Node 里也给它一份。它用window,我们就给window;它用document.createElement,我们就给一个能返回对象的createElement;它读navigator.userAgent、screen.width,我们就填上合理的值。

先看代码。下面是53_sign.js的核心部分,不是伪代码,是这次实际跑通的版本:

global.window = global; global.self = global; global.location = { href: "https://hk.trip.com/hotels/list?...", ... }; global.navigator = { userAgent: "Mozilla/5.0 ... Chrome/156.0.0.0 Safari/537.36", platform: "Win32", language: "zh-HK", languages: ["zh-HK", "zh", "en"], hardwareConcurrency: 8, webdriver: false, plugins: [], mimeTypes: [], }; global.document = { createElement(tag) { return { tagName: tag.toUpperCase(), style: {}, ... }; }, body: { appendChild() {}, removeChild() {} }, head: { appendChild() {}, ... }, ... }; const sdtSource = fs.readFileSync("53_sdt_1006.js", "utf8"); (0, eval)(sdtSource); // 执行原版脚本,内部会挂 window.signature if (typeof global.signature !== "function") { throw new Error("window.signature 没挂上"); } const token = global.signature(body); // body 必须和真实请求逐字节一致

几个关键点,逐个说。

global.window = global:Node 里没有浏览器的window,但原版脚本会往window上挂东西。把 Node 的global直接当window用,是最省事的做法。这样脚本里window.signature = ...,外面就能用global.signature取到。

global.location:SDK 可能会读当前页面的 URL,用来判断域名、语言、路径。我们把真实页面的 URL 填进去,不要随便写about:blank。

global.navigator:UA、平台、语言、CPU 核数、webdriver这些是最常被读的。这里不需要“伪造得跟真浏览器一模一样”,但至少要让脚本能正常走下去。这个案例里,SDK 没有卡得很死,最小环境就能跑。

global.document:loadSDT会document.createElement("script"),所以createElement必须返回一个带style、appendChild、setAttribute的对象;document.body、document.head也要有appendChild,否则脚本加载那一步会报错。

然后fs.readFileSync读入53_sdt_1006.js,(0, eval)(sdtSource)执行。执行完检查typeof global.signature,如果是function,说明补环境成功。

最后一行global.signature(body)是签名入口。body必须是真实请求的原始字符串,不是JSON.parse之后再JSON.stringify的对象——字段顺序、空格、转义只要有一点不一样,算出来的 token 就不是给这份请求用的。

第一次跑通时,我拿{"a":1}试了一下,返回的是1006-common-...,长度 1,441 字符。到这里,本地签名器就活了。

五、相互证明:不是“看着像”,而是服务端真的认

接下来是最关键的一步:怎么证明本地算出来的 token 是对的。

先说一个反直觉的点:同一个 body,连续签两次,token 不一样。我试过,长度会在 1,440 到 1,500 之间浮动。这是因为 SDK 内部带了时间戳、随机数、计数器之类的量。所以你不能拿“本地 token 和线上 token 字符串相等”来证明,这条路走不通。

正确的证明方式是做对照实验,让服务端来判:

  • 用一份页面还没请求过的新 body(比如把 pageIndex 往后挪两页);
  • 第一发:用页面自己的window.signature(body)生成 token,作为阳性对照;
  • 第二发:用本地 Node 算出来的 token;
  • 第三发:用随便写的假 token,作为阴性对照。

其他条件全部一样:同一个页面、同一份 cookie、同一个请求体,只换 token。

我这一轮的结果:

请求用的 tokentoken 长度HTTP酒店数响应大小
页面自己签名1,483 字符20013 家98,419 字节
本地 Node 重算1,442 字符20013 家93,941 字节
假 token(1006-common-AAAA)—2000 家433 字节
重放上一轮真实 token1,486 字符2000 家432 字节

这个表的信息量很大。

页面自己签名能换出 13 家,说明这份 body 是有效的,服务端也认这个会话;本地 Node 重算也能换出 13 家,说明我们的补环境和签名逻辑没有跑偏;假 token 只拿到 433 字节的ResponseStatus空壳,说明服务端确实在校验phantom-token,不是摆设。

重放那一行更有意思:把上一轮抓到的真实 token 原样再发一次,返回 0 家。不是 body 的问题——同一份 body 换成刚算出来的本地 token,就能出 13 家。所以 token 有时效性,或者和这一次请求绑定,不能囤着反复用。结论就是:每次请求前现算,别缓存 token。

这里还有一个我顺手做的小实验:旧脚本53.js生成的是1004-common-开头的 token,我拿它去请求,服务端也返回了 13 家。也就是说,前缀版本号不是有效性标准,1004 和 1006 都能过。那为什么还要用线上的 1006?因为线上页面已经加载 1006,它的内部字段、环境读取逻辑可能已经变了;跟着线上版本走,最稳。判断“对不对”的唯一标准,是服务端认不认,不是前缀好不好看。

真实换出来的数据长这样,12 家酒店,89,693 字节:

六、把链路写成能跑的脚本

证明归证明,最后还是要落成一个能重复跑的脚本。我把它拆成两个文件:

  • 53_sign.js:补环境 + 执行原版53_sdt_1006.js,负责“body → token”;
  • 53_phantom_token.py:用 Playwright 打开真实页面,拿会话、拿一份真实 body 模板,调用 Node 签名,再在页面里发请求、存数据。

为什么不全部用requests?我试过把浏览器的 cookie 原样搬到requests,再带本地 token 去请求:有时能出数据,有时只回空壳;同样的 body 放到页面里fetch,成功率明显更高。这说明服务端除了 token,还会看会话侧的东西,比如 cookie、TLS、请求头,而且和 body 是不是新鲜也有关。与其在requests里一项项凑,不如让浏览器自己发请求——token 仍然是本地算的,这一点没变。

脚本的主流程如下(节选):

# 1. Playwright 打开页面,等它自己发 fetchHotelList page.goto(PAGE_URL, wait_until="domcontentloaded") page.wait_for_timeout(4000) for _ in range(6): page.mouse.wheel(0, 3500) page.wait_for_timeout(1500) if bodies: break # 2. 拿一份真实 body 当模板,pageIndex 往后挪两页 template = json.loads(bodies[-1]) template["paging"]["pageIndex"] += 2 body_text = json.dumps(template, separators=(",", ":"), ensure_ascii=False) # 3. Node 本地签名 token = sign_body(body_text) # 4. 在页面里 fetch:本地 token vs 假 token result = page.evaluate("""async ({body, token, fakeBody, fakeToken, wclient}) => { const go = async (b, t) => { const r = await fetch('/restapi/soa2/34951/fetchHotelList', { method: 'POST', headers: { 'accept': 'application/json', 'content-type': 'application/json', 'locale': 'zh-HK', 'currency': 'HKD', 'cookieOrigin': 'https://hk.trip.com', 'x-ctx-wclient-req': wclient, 'phantom-token': t, }, body: b, }); const text = await r.text(); const json = JSON.parse(text); const list = (json.data && json.data.hotelList) || []; return { status: r.status, bytes: text.length, hotels: list.length, json }; }; const local = await go(body, token); const fake = await go(fakeBody, fakeToken); return { local, fake }; }""", {...}) # 5. 保存数据,打印 MATCH

真实运行输出:

[1] 请求体 2796 字节,pageIndex=4 [2] 本地签名 1006-common-ctkWnFecGyGQwb...,长度 1443 [3] 本地 token:HTTP 200,12 家酒店,89693 字节 [4] 假 token :HTTP 200,0 家酒店,434 字节 [5] 第一家:歷山酒店(共 12 家) [6] 数据已保存:53_hotels.json MATCH: True

运行方式:

# 需要 Node、Python、Playwright,以及本机 Chrome 或 Edge python 53_phantom_token.py

53_sign.js也可以单独用:

printf '%s' "$BODY" | node 53_sign.js

它只输出 token,不打印 body,不保存 cookie,方便接到别的流程里。

七、踩坑记录

这个案例我踩的坑,比写出来的多。挑几个最有代表性的。

1. 旧脚本不是“算法错了”,是“环境过期了”

目录里原来的53.js生成的是1004-common,而线上已经加载1006-common。我一开始以为版本不对肯定过不了,结果拿 1004 的 token 去请求,服务端也认。真正让旧53.py跑不出数据的是另外两件事:cookie 是 3 月份抓的,请求体里日期写死在 4 月。这种“算法没错、会话过期”的情况很常见,别一上来就怀疑自己补环境补错了。

2. 同一个 body,token 每次都不一样

第一次看到长度在 1,441、1,443、1,446 之间跳,我以为是环境不稳定。后来才想明白,SDK 里带随机量是正常的。不要拿字符串相等当验收标准,要看服务端返回。

3. 重放 token 会失败

抓到一个 token 就想存起来反复用,在这个案例里行不通。token 有时效/绑定,重放只回空壳。每次请求现算,反而更简单。

4. body 必须逐字节一致

JSON.parse之后再JSON.stringify,字段顺序、空格、ensure_ascii都可能变。签名的输入必须是你真正要发出去的那串字符串,最好从头到尾只序列化一次。

5. 页面自己的 token 也可能失效

我在 Playwright 里试过:同一页面,window.signature(body)返回的 token 去请求,偶尔是 0 家;换成本地 Node 刚算的 token,反而出 13 家。原因是页面里的 SDK 已经调用过很多次,内部状态/计数器可能和当前请求对不上。所以不要迷信“页面自己生成的就一定对”,每次用干净的环境现算。

6.requests不是万能的

requests和页面fetch的结果经常不一致:同一份 cookie,requests有时只回空壳,页面里fetch成功率更高。这不是 token 算错了,而是会话侧(cookie/TLS/请求头/body 新鲜度)的校验。遇到这种情况,先别怀疑算法,换成页面内fetch验证一次,能快速排除“算法问题”还是“会话问题”。

7. 不要花时间去解密 token

token 的 Base64 解开是二进制,不是明文 JSON。它内部大概率是压缩/加密后的结构,硬解性价比很低。我们的目标是“让服务端认”,不是“把 token 拆开看”。只要本地 token 能换出真实数据,签名链路就是通的。

八、小结

这个案例的核心不是某个 AES key,而是三件事:

第一,先找到 writer。搜索phantom-token,找到window.signature(r.body)那一行,确认参数是“整个请求体”,不是某个字段。

第二,判断代码形态。看到GreatHorn、switch、_unknown_xxx、末尾 Base64,就知道是 JSVMP。能补环境全扣,就别硬啃 VM;我们的目标是拿window.signature,不是研究虚拟机。

第三,用服务端做验收。token 带随机量,没法字符串对齐;那就做阳性/阴性对照:页面自签能出数据,本地重算也能出数据,假 token 出不来,三条同时成立,才算证明。

最后提醒一句:这类站点的风控参数会更新,脚本里的 sdt 文件、UA、页面 URL 都可能变。真正可复用的不是某一段代码,而是这套“定位 writer → 判断形态 → 补环境 → 服务端验收”的流程。

如有问题请及时提出,加油加油。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询