先说清楚一件事:拿到一个陌生接口,发现请求里藏着个叫phantom-token的参数,研究它的生成逻辑,再用 Python 把这个逻辑原样实现出来——这种活儿干多了之后,整个过程其实是有固定套路可循的。这篇复盘就记录一次我针对某 OTA 平台(标题里说的是携程,就按这个场景讲)酒店搜索场景的完整实战过程,涉及抓包分析、参数定位、调用栈回溯、算法还原和 Python 纯算实现五个环节,适合已经会基础抓包、正准备往参数逆向深挖的朋友参考。整个过程不算复杂,但里面的判断思路和排查技巧比单点技术本身更值钱。
1. 项目背景与整体思路设计
1.1 这个 token 是什么,为什么要逆向它
先解释一下phantom-token是个什么东西。在浏览器里正常打开酒店搜索页,页面会向后端发一堆异步请求,其中一个请求的请求头或者请求参数里会带上一段看起来像乱码的字符串,名字就叫phantom-token。它本质上是一个由前端 JS 动态生成的签名值,后端拿到后会按照同一套规则重新计算一遍,比对一致才把数据返回给你。
我为什么要去逆向它?起因是我想做酒店价格监控和比价方面的技术验证,需要在本地脚本里重新构造搜索请求。直接拿浏览器 Cookie 去请求很快就会发现,光有 Cookie 不够,缺少这个 token,服务器直接返回 403 或者一段“参数错误”的提示。也就是说,只有把 token 的生成逻辑搞清楚,才能在脱离浏览器的环境下构造出合法请求。这个场景其实非常典型,做接口调试、价格采集、数据研究的人大概率都撞上过类似的东西。
从技术角度看,这个东西值得拆解的原因有几点:一是它出现在高频核心搜索接口上,普适性比较强;二是它的生成往往依赖多个变量(路径、参数、时间戳、盐值等),涉及前端签名算法设计的一般思路;三是它能很好地串起“抓包 -> 定位 -> 还原 -> 复现”这条完整的学习链条。这篇文章不讨论任何绕过风控批量抓数据的事,只讲纯技术上的分析方法和通用实现路径。
1.2 逆向目标与整体流程拆解
在动手之前,我把目标定得很明确:写一段 Python 代码,传入和浏览器相同的请求参数,能在本地算出和浏览器一致的phantom-token,并且用这个 token 能成功拿到接口返回的数据。这个目标可以拆成三条验收标准:
- 算法确定性:相同的入参,必须产出相同的 token。
- 环境无关性:纯 Python 计算,不依赖浏览器环境,不需要执行 JS。
- 请求可用性:生成的 token 配合正常 Cookie,能拿到和浏览器一致的数据。
整体流程我分成了六个阶段,每条线上都有对应的产出物:
| 阶段 | 核心动作 | 产出物 |
|---|---|---|
| 抓包分析 | 定位携带 phantom-token 的请求,记录请求头、参数、Cookie | 完整请求快照 |
| 特征判断 | 分析 token 长度、字符集、依赖变量 | token 特征画像 |
| 定位生成逻辑 | 搜索 JS 关键字、Hook 关键函数 | 可疑函数清单 |
| 断点验证 | 打断点确认参数来源和拼装顺序 | 参数拼接规则 |
| 算法还原 | 用 Python 实现签名算法,跑通一致性 | 可运行 Python 代码 |
| 真实请求验证 | 带 token 发请求,比对返回结果 | 最终验证截图 |
这个流程看起来好像每个点都有人讲过,但真正执行的时候,每一步都有容易卡住的小地方。下面我按实际推进顺序,把每个环节的关键细节和踩坑记录展开说。
2. 抓包分析与 token 特征判断
2.1 从网络面板到请求定位
我习惯先用 Chrome DevTools 的 Network 面板做第一轮分析,因为浏览器环境里能直接看到完整请求上下文。打开酒店搜索页,输入一个城市和日期,点搜索,等列表加载完,切到 Network 面板,按Fetch/XHR筛选,就能看到一批异步请求。这个时候不要急着翻,先在过滤框输入hotel或者search缩小范围,逐个点开看响应内容,找到那个返回酒店列表数据的接口。
确认接口之后,把请求头从头到尾过一遍。我发现请求头里有一个自定义 Header,名字就叫phantom-token,后面跟着一串 64 位的小写十六进制字符串。类似这种:
phantom-token: 8f3a2b1c9e4d5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b这个发现很重要,因为它决定了后续的逆向路线。64 位 hex 字符串,第一反应就是 SHA-256 的输出,但也不排除是两次 MD5 拼接或者自定义哈希。第二反应是去对比不同请求之间的 token 变化规律,看看它到底跟哪些变量绑定。
顺便说一句,抓包工具有很多选择,Charles、Fiddler、Burp Suite 各有各的适用场景。浏览器 DevTools 适合快速定位,但如果你想改包重放,建议上 Fiddler 或 Charles,配合本地代理能方便地截断、修改、重发请求。我这次主要用 DevTools 做定位,用 Fiddler 做改包验证。
2.2 三个特征决定逆向路线
拿到 token 样本之后,我总结了三类特征,基本决定了后面怎么走:
第一是长度和字符集。64 位、纯小写 hex,大概率是某种摘要算法的产物。如果字符串里同时有大写字母、小写字母和数字,还有+、/、=这种字符,那就要考虑 Base64 编码的哈希值;如果还有下划线和中划线,那就可能是自定义编码。特征判断能帮你排除错误方向,避免在一个 Base64 字符串上硬套 MD5 逻辑。
第二是变化规律。我刷新了几次页面,发现每次请求的 token 都不一样。然后我把搜索条件从“北京”改成“上海”,token 也变了。再然后我清掉部分 Cookie 重新刷新,token 还是会变,但把时间前后差了五分钟的两次请求对比,看不出哪里有关联性。这说明 token 至少依赖当前请求参数和某个时间因子。
第三是依赖关系。这一点要靠改包验证。我用 Fiddler 把请求里的 query 参数改掉一个字符,不重新生成 token,直接放行,后端返回参数校验失败。这说明 query 参数是参与签名计算的。接着我改动了请求头里的 Cookie 中的一个关键值(具体哪个值下面会讲),同样不改 token,也报了签名错误。这就说明 token 的计算输入里还有 Cookie 相关的因子。
这三类特征一出来,逆向路线基本就清晰了:先找生成函数,再确认拼装规则,最后写 Python 复现。没有特征判断直接去翻 JS,很容易被混淆代码带偏,浪费大量时间。
2.3 参数的联动观察
在正式打开 JS 文件之前,我还做了一步操作:用 Fiddler 的自动响应(AutoResponder)功能,把某些参数改成固定值,观察 token 的输出变化。比如我把请求 URL 里的checkin日期固定成2025-06-01,连续请求几次,发现 token 的中间段有肉眼可见的规律性变化,但整体每次都不同,说明计算里掺杂了时间戳或者随机数。
这里有一个很重要的判断:如果 token 每次都不同,但差异只集中在尾部几个字符,那大概率是末尾拼了一个随机字符串;如果差异出现在整段字符串上,那可能是入口参数里带了一个随机值,比如 UUID 或设备 ID。这个细节很影响后续的断点调试策略,因为你要重点观察的变量完全不一样。
我用一个简单办法验证随机因子:在 Console 里手动执行页面暴露的加密函数(如果碰巧挂在了 window 上),连续调用两次,如果输出完全一样,说明随机因子要么不参与,要么被缓存了;如果输出不一样,说明每次调用都会取新的随机值。我这次踩到的平台函数名不是直接暴露的,所以这步没走通,但不代表这个思路没用,反而提醒我要用更底层的方式去定位。
3. Hook 定位与调用栈回溯
3.1 用 Hook 快速锁定生成函数
既然 token 是一段 64 位 hex,那生成它的函数八成是哈希函数,或者是先做序列化再做哈希。我打开 Sources 面板,在全局搜索里输入phantom,大概率能搜到一堆结果,可能是变量名、注释、字符串,也可能就是生成逻辑本体。但直接搜关键字有个问题:混淆压缩后的 JS 里,很多变量名都被重命名了,关键字不一定能搜到。
更可靠的办法是 Hook。Hook 的核心思路是:在目标函数执行前后插入一段我们自己的代码,把入参和返回值打印出来。常见哈希函数包括md5、sha256、sha1,它们的实现通常会挂在某个工具对象上,比如CryptoJS.MD5、window.md5等。我一般会先写一个通用 Hook 脚本,在页面加载前注入,拦截常见的哈希入口。
以 MD5 为例,Hook 代码可以这样写:
(function () { // 假设页面把 md5 挂载在 window 上,常见于引入某个加密库之后 const originalMd5 = window.md5; if (!originalMd5) { console.log('[Hook] window.md5 不存在,尝试其他入口'); return; } window.md5 = function (...args) { const result = originalMd5.apply(this, args); console.log('[Hook MD5] 入参:', JSON.stringify(args), '=> 输出:', result); return result; }; })();这段脚本通过 Chrome DevTools 的 Overrides 功能或者油猴脚本注入,跑起来之后刷新页面,Console 里就会不断打印所有 MD5 调用的入参和输出。如果发现某个输出的值正好等于请求头里那个phantom-token,那就直接锁定目标了。
不过这里有个坑:你提前不知道它是用 MD5 还是 SHA-256,甚至可能是自定义的哈希算法,所以光 Hook 一个函数不够。我最后的做法是同时 Hook 了CryptoJS.SHA256、CryptoJS.MD5、window.btoa、JSON.stringify这几个高频入口,从结果里筛。实际执行下来,Hook 输出里还真有一条哈希调用的输出和请求包的 token 完全一致,这就算锁定成功了。
3.2 调用栈回溯:从出口找入口
锁定了哈希函数的调用点之后,下一个问题是:这个哈希函数的入参是从哪拼出来的?要回答这个问题,就得看调用栈。在刚才的 Hook 脚本里加一行console.trace(),重新刷新页面,Console 里会打印出完整的调用栈:
console.log('[Hook SHA256] 入参:', JSON.stringify(args), '=> 输出:', result); console.trace();调用栈里会显示一层层的函数调用关系,比如at Object.sha256、at generatePhantomToken、at requestInterceptors之类的函数名。看到类似generatePhantomToken这种名字,基本就接近真相了。有的压缩代码里函数名会被替换成单个字母,这时候就要靠堆栈里的文件 URL 和行号来定位。
找到之后,我在 Sources 面板里跳转到对应行,打上断点,然后重新触发搜索请求。断点命中后,Watch 面板里查看当前作用域的变量,能直接看到某几个变量名带着time、query、token、salt之类的关键词。我本地的经验是,签名函数一般不会超过五十行,核心逻辑就是“拼字符串 -> 做哈希 -> 返回结果”。
3.3 断点验证:参数从哪来
断点验证是逆向过程中最容易出细节错误的一步。我第一次还原算法的时候,以为签名输入只有请求路径和时间戳,结果跑出来怎么都对不上。后来在断点里仔细对比,才发现在哈希之前还拼了一个固定的盐值字符串,以及 Cookie 里的deviceId。
我建议到了这一步,先不要急着关掉断点,而是手动把作用域里的关键变量抄下来,包括路径、排序后的参数串、时间戳、盐值、设备 ID、随机数等。然后回到 Console,手动执行一次拼接和哈希,如果算出来的结果和请求头里的 token 完全一致,就说明你已经完整还原了生成逻辑。这一步通过了,后面写 Python 就只是翻译工作而已。
有个细节值得提一下:有的平台会把时间戳放到签名串的中间而不是开头,参数排序方式也可能是按照 ASCII 码排序而不是浏览器里看到的顺序。这些都是需要通过断点反复对比变量值来确认的,不能想当然。
4. 核心算法还原与 Python 纯算实现
4.1 参数拼接规则复现
通过断点验证,我最终还原出的签名输入规则大致是这样的(平台细节做过脱敏处理,但结构是可信的):
sign_input = request_path + sorted_query_string + device_id + timestamp + salt其中sorted_query_string是对 URL 上的查询参数按照参数名做字典序排序之后,拼成key=value&key=value的格式;timestamp是毫秒级时间戳;salt是从 JS 里抠出来的一个固定字符串。最后对sign_input做 SHA-256,输出 64 位 hex 就是phantom-token。
这个结构很典型,很多平台的签名都是类似的套路:路径 + 排序参数 + 设备标识 + 时间戳 + 盐值,然后做一次或多次摘要。区别只在于拼接顺序和盐值内容。还原的时候要特别留意参数拼接顺序,因为哪怕少一个&号,算出来的值就完全不一样。
查询参数排序这一步,我踩过一个坑:JS 里的URLSearchParams排序规则和 Python 里的urlencode不完全一致。JS 对某些字符的编码方式跟 Python 默认行为不同,比如空格在 JS 里会编码成%20,但 Python 的urlencode默认会编码成+。解决方法是统一用urllib.parse.quote手动处理,并且显式指定quote_via=quote,保证两边行为一致。
4.2 哈希算法的选择与验证
还原逻辑之后,我先不急着写完整代码,而是先用 Python 在交互环境里验证一次哈希算法选型是否正确。根据 token 长度是 64 位 hex,SHA-256 是头号候选,但也有可能是 SHA-512 截断,或者两次 MD5 拼接。验证方法很简单:把从断点抄下来的输入串依次用几种常见哈希算一遍,看哪个输出和浏览器里的 token 相等。
我用的验证代码如下:
import hashlib sign_input = "/api/hotel/search?checkin=2025-06-01&checkout=2025-06-02&city=beijingdevice_id_xxx1718000000000a1b2c3d4" # 注意这行是演示用,实际请从断点里抄完整输入串 print("MD5 :", hashlib.md5(sign_input.encode()).hexdigest()) print("SHA1 :", hashlib.sha1(sign_input.encode()).hexdigest()) print("SHA256 :", hashlib.sha256(sign_input.encode()).hexdigest()) print("SHA512 :", hashlib.sha512(sign_input.encode()).hexdigest())跑完之后,SHA-256 的输出正好和浏览器抓到的 token 一致,哈希选型就定下来了。这里有个小技巧:如果你在断点里拿到的输入串是完整的,可以直接在 Python 里算完和抓包值比对,这一步在任何环境里都能做,不需要依赖浏览器。
4.3 引入随机因子后的适配
我这次遇到的平台,生成 token 时还掺杂了一个随机字符串,导致每次请求的 token 都不完全一样。起初我以为随机字符串也会参与签名,后来在断点里观察发现,随机字符串只在入口参数中短暂存在,并不会进入最终哈希输入。换句话说,签名对随机值并不敏感,真正参与计算的只有路径、参数、设备 ID、时间戳和盐值。
但我也处理过另一种情况:某个平台的 token 生成逻辑里确实拼了Math.random().toString(36)的结果,导致同一秒内发多次请求,token 也是不同的。遇到这种随机因子参与签名的场景,调试的时候可以把 JS 里的随机函数替换成固定值,比如Math.random = () => 0.123456789,再做对照实验,就能判断随机值是否真的影响输出。Python 侧如果也要带随机性,用secrets.token_hex(8)之类的方式生成即可。
这个适配步骤比较考验细节,因为随机因子出现的位置和生命周期都不同。建议在恢复逻辑的时候,先确认随机值参与不参与签名,再决定是否在 Python 代码里模拟它。
4.4 完整 Python 实现示例
整个签名逻辑还原完成后,我把它封装成了一个简单的客户端类。这里给出一个可运行的示例,参数名和盐值都做了脱敏,重点看思路和结构:
import hashlib import secrets import time from urllib.parse import urlencode, quote class PhantomClient: def __init__(self, device_id: str = "", salt: str = ""): self.device_id = device_id self.salt = salt @staticmethod def _sort_query(params: dict) -> str: return urlencode(sorted(params.items()), quote_via=quote) @staticmethod def _timestamp() -> str: return str(int(time.time() * 1000)) def generate_token(self, path: str, params: dict) -> str: sorted_query = self._sort_query(params) raw = f"{path}{sorted_query}{self.device_id}{self._timestamp()}{self.salt}" return hashlib.sha256(raw.encode()).hexdigest() def build_headers(self, path: str, params: dict) -> dict: return { "User-Agent": "Mozilla/5.0 ...", "phantom-token": self.generate_token(path, params), "Referer": "https://example.com/hotel/list", } if __name__ == "__main__": client = PhantomClient( device_id="device_id_xxx", salt="a1b2c3d4e5f67890" ) params = { "city": "beijing", "checkin": "2025-06-01", "checkout": "2025-06-02", } token = client.generate_token("/api/hotel/search", params) print("phantom-token:", token)这段代码的核心逻辑只有四行:排序参数、拼字符串、算时间戳、做哈希。剩下的都是工程封装。真实使用的时候,设备 ID 需要从 Cookie 或者请求头里取,盐值要提前从 JS 里分析出来,路径也要和你抓包时看到的接口路径保持一致。
4.5 用真实请求验证复现结果
代码写完不算完,必须用真实请求验证。我在 Python 里用requests库组装了一个完整请求,头信息包括 User-Agent、Cookie、Referer,以及刚才生成的phantom-token,请求参数和浏览器一致。发送之后,服务端返回的响应状态码是 200,返回体里是正常的酒店列表数据。
到这里,整个逆向闭环就算打通了。但这里我想强调一点:验证阶段用的请求频率一定要低,间隔拉长一些,定位是“技术可行性验证”,不是“压测”。我在实践里通常每次请求间隔五秒以上,连续验证几次没问题就不再发了。
这种验证方式的优点很明显:它证明你的 Python 实现和浏览器里的算法完全一致,后续如果要调整参数,只需要重新生成 token 即可,不需要再依赖浏览器环境。至于请求频率、代理策略这些东西,属于另一个层面的问题,不在这次讨论范围内。
5. 踩坑记录与问题排查
5.1 token 一直失效?先检查这三点
我在还原过程中遇到过几次“明明算法对了,但请求还是失败”的情况。复盘下来,绝大多数问题出在以下三点,建议按顺序排查:
- 时间戳是否一致。签名里的时间戳是生成 token 那一刻的毫秒值,如果脚本运行环境的时间和服务器时间偏差过大,哪怕算法完全正确,后端也会判定签名过期。我习惯在请求前用网络时间协议校正本地时钟,或者从服务器响应头里读时间再做偏移修正。
- 设备 ID 是否和 Cookie 对应。很多签名会把设备 ID 和 Cookie 绑定,生成 token 时用了 A 设备 ID,请求时却带着 B 设备的 Cookie,必然失败。写代码时要从同一个来源取这两个值,不要手工拼接。
- 参数顺序和编码是否一致。URL 查询参数的排序和编码方式必须和 JS 里保持一致,尤其是特殊字符的转义。我遇到过因为一个
+和%20的差异导致签名对不上的情况,这类问题最隐蔽,也最花时间。
5.2 调试时误改环境导致的偏差
用 Chrome DevTools 的 Local Overrides 做本地覆盖时,很容易因为修改了 JS 文件导致页面行为异常。我踩过一次坑:想给哈希函数加日志,结果改错了闭合括号,整个文件语法错误,页面直接白屏。排查了半天才发现是 Overrides 文件的问题,而不是平台的服务端出 bug。
建议把原本的 JS 文件先备份一份,修改时只加日志不改逻辑,改完先在浏览器里确认页面正常,再做断点验证。这个习惯看起来简单,但能省掉大量排查时间。
5.3 浏览器指纹与自动化检测
有些平台的风控不止看 token,还会校验浏览器的指纹信息,包括navigator.webdriver标志、Canvas 指纹、WebGL 渲染信息等。如果 token 是在浏览器里通过自动化工具生成的,这些指纹信息很可能会被服务端识别出来。但纯 Python 实现则不存在这个问题,因为你是在本地计算 token,不依赖浏览器环境,服务的接收方看到的就是一个普通请求。
不过纯 Python 实现也有自己的弱点,比如 TLS 指纹和 HTTP/2 指纹和真实浏览器不同。如果遇到特别严格的风控,还需要进一步处理,但这就属于另一个层面的对抗了。我的态度是:学习技术原理可以,但实际应用时要注意边界,不要在未经授权的情况下对线上服务做高频请求。
5.4 频率控制与合规边界
这部分我必须强调一下:逆向技术本身是中性的,它的价值在于帮助你理解接口签名机制、研究加密算法原理、验证数据完整性和做安全研究。比如你可以拿这套方法去分析自己负责的接口是否存在签名缺陷,或者用于自动化测试场景下构造合法请求。这些都是正当用途。
但反向使用就有问题了,比如绕过平台风控批量抓取用户隐私数据、恶意刷接口、干扰服务正常运行。这些行为既违反平台规则,也可能触碰法律红线。这篇博文只讨论纯技术的分析方法和通用实现路径,不鼓励任何违规使用。合规的边界很简单:你要么是平台方授权的研究人员,要么是在自己的测试环境里复现,要么只是学习算法原理。超出这个范围的使用,请务必仔细评估风险。
5.5 常见问题速查表
最后把这次实战中可能遇到的问题整理成一张速查表,方便以后排查:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 请求返回 403 | token 生成时间距请求时间过长 | 缩短生成与发送之间的间隔,校正系统时间 |
| 返回“参数错误” | query 参数排序或编码不一致 | 抓包对比参数串,确认排序规则 |
| 返回“签名无效” | 盐值或拼接顺序还原错误 | 重新断点验证输入串,逐字符比对 |
| 返回“设备异常” | 设备 ID 与 Cookie 不对应 | 从同一上下文中提取设备 ID 与 Cookie |
| 请求频率过高被限流 | 触发服务端频控 | 降低请求频率,增加随机延时 |
| 页面白屏 | Local Overrides 改坏了 JS | 恢复备份文件,重新添加日志 |
这张表是我多次逆向实践中沉淀下来的通用排查模板,遇到具体问题的时候照着查,能少走不少弯路。
6. 一点延伸思考
这次携程酒店phantom-token的逆向,本质上是一道“给定输入输出,反推函数逻辑”的题目。互联网产品的前端签名方案五花八门,有的是 MD5 加盐,有的是 SHA-256 拼接,有的还会套一层对称加密,但分析方法都是一样的:先定位入口,再还原数据流,最后用目标语言重写。
我个人的经验是,这个领域最值钱的不是某个具体平台的算法,而是那一套从抓包到断点验证的方法论。它能平滑迁移到很多类似的场景,无论是 Web 端还是 App 端,核心思维都一样——只是具体工具不同而已。比如 App 端把浏览器 DevTools 换成抓包工具,把 JS Hook 换成 Frida,流程骨架是通用的。
最后分享一个小习惯:做这种逆向,一定要把每一步的验证动作保留下来,尤其是断点里确认过的参数拼接串和输出值。我碰到过几次,调试的时候是对的,过了几天再跑突然不对,翻日志才发现是平台改了盐值或者签名规则。工具链会过期,但方法论不会过期——把当时的关键输入输出记下来,下次算法一变,你可以很快定位到变的是哪一段。