先交代一下背景。前段时间排查一个接口偶发 429 的问题,随手打开开发者工具把请求都过了一遍,结果在某几个核心接口的请求头里,发现了一个以前没见过的参数:hexin-v。值是一长串的字符串,很像 Base64,但结尾又没有常见的等号,中间还混着减号和下划线。第一反应是后端那边加的,但问了同事,都说没加过。再一看,同一个页面里其他普通接口根本不会带这个字段,只有几个敏感的核心接口会带——这基本可以断定,是页面里的 JavaScript 在请求发送前动态生成的一个签名参数。
后面花了两个晚上把它的生成逻辑整套逆向了出来,整个过程走了一遍“抓包 → 定位 → 还原 → 复现”的完整链路。这篇文章就记录这个 hexin-v 逆向小案例,也顺便分享一些 Web 逆向里通用的思路和工具,希望能给想入门前端逆向的朋友一点参考。为了表述方便,下面把目标站点统一叫“某顺”,懂的都懂,不点名,也不去碰任何具体接口数据。
1. 先摸清hexin-v这个参数的老底
1.1 用开发者工具把它的“长相”看清楚
遇到这种不明参数,第一件事不是急着搜代码,而是把它的表现观察清楚。我当时用的是 Chrome DevTools,直接切到 Network 面板,找到那几个带 hexin-v 的请求,把请求头完整展开,记录下关键字段。
| 请求头 | 示例值 | 特征 |
|---|---|---|
| hexin-v | dGhpcyBpcyBhIHNhbXBsZSBzaWdu... |
每次请求都不同 |
| Cookie | 省略不展示 | 部分接口必须携带 |
| User-Agent | Mozilla/5.0 ... |
与浏览器环境一致 |
| Referer | 站点页面地址 | 用于来源校验 |
只看单个值是完全看不出规则的,所以我把同一次会话内的多个请求、以及不同时间刷新后的请求分别抓了几组。默认情况下 Network 里的请求列表会被新请求刷掉,我习惯把 Preserve log 打开,这样刷新页面时之前的请求不会消失,方便对比。
1.2 多次刷新,观察参数变化规律
抓了几次请求之后,我总结出几个规律:
- 正常刷新页面,
hexin-v每次都变,没有固定值。 - 清掉 localStorage 之后再用无痕窗口刷新,
hexin-v的值变了,而且请求体里还会额外多出一个deviceId字段。 - 换一个浏览器内核访问,生成的
hexin-v也是不一样的。 - 同一个页面连续点击,短时间内生成的
hexin-v并不连续,看不到类似 +1 或时间戳那种肉眼可读的递增关系。
这些现象基本能说明两件事:第一,hexin-v 不是纯随机数,因为它能和某个设备标识产生关联;第二,它大概率依赖时间戳或随机因子,否则同一个浏览器每次请求的值应该是一样的。到这里我已经可以初步判断:算法里至少同时用到了“设备标识”和“时间相关量”,再叠加一层不可逆的摘要算法。
1.3 全局搜索源码,遇到第一道坎
接下来就是常规操作,在 Sources 面板里点击 Ctrl+Shift+F 全局搜索 hexin-v。搜索命中了十多个文件,但全部是压缩过后的 JS——整个文件就一行,变量名全是 _0x1a2b3c 这种格式。这就是典型的 JS 混淆特征,说明站点在构建时对前端代码做了保护。
新手到这里容易慌,其实不用。Chrome DevTools 的 Sources 面板里有个格式化按钮(就是那个 {} 图标),点一下之后压缩代码会展开成可读的多行结构,然后就能继续搜索和阅读了。当然,格式化之后变量名依然是 _0x 开头,可读性依然有限,但至少能找到字符串引用位置和函数边界。
另外一个非常实用的技巧是:搜索关键词时不要只搜 hexin-v,还可以搜 hexin、sign、timestamp、deviceId 相关字段。加密参数的名字可能会被拼接进字符串数组里,直接搜完整字符串可能搜不到,但搜关键子串往往命中的更多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从XHR断点到JS加密函数:调用链追踪
2.1 用XHR断点直接卡住发请求的位置
找到加密函数最直接的方式不是读代码,而是让浏览器把“正在发请求”这个动作暂停下来,然后顺着调用栈往上找。Chrome DevTools 里有个 XHR/fetch Breakpoints 功能,在 Sources 面板右侧,可以针对某个 URL 子串添加断点。
我当时的操作是:
- 切换到 Sources → XHR/fetch Breakpoints。
- 点击
+号,输入带hexin-v的那些接口路径里共有的关键字,比如quote或list一类的片段。 - 刷新页面,触发带
hexin-v的接口请求,代码会精确暂停在XMLHttpRequest.send附近。
这一步能立刻定位到“请求是从哪一行代码发出的”,之后再从调用栈里逐层往上看,找到到底谁在 headers 里塞了 hexin-v。这比在几十个 JS 文件里人肉搜索要快很多。
2.2 顺着 Call Stack 往下钻
断点命中后,右侧 Call Stack 面板会显示完整调用链,大致长这样:
text复制send (原生 xhr)
request (某请求库封装)
dispatchRequest (核心发送逻辑)
interceptors (请求拦截器)
xxxRequest (业务封装层)
0x2f4c (某个匿名函数)
一层层往下点,我最先在 interceptors 这一层看到了熟悉的代码:拦截器内部对配置对象做了判断,在满足特定条件时往 headers 里塞了一个字段:
javascript复制options.headers['hexin-v'] = getHexinV();
继续往上找 getHexinV 的定义位置,发现它在一个相对独立的闭包里,和业务代码完全隔离。这种结构说明 hexin-v 更像是一个被单独封装的“签名工具”,不是某个业务偶然写死的东西。
2.3 在生成函数上打断点,开始交互式验证
找到 getHexinV 之后,我在它内部插了一个断点,重新触发请求。命中断点后,用鼠标悬停变量,或者直接在 Console 里执行表达式,就能看到函数的输入输出。当时关键函数体只有这么几行(混淆后简化):
javascript复制var raw = [deviceId, ts, seed].sort().join('|');
var hash = md5(raw);
return base64url(hash);
到这里,其实整个算法的骨架已经清晰了:把设备标识、时间戳、固定密钥做拼接和排序,然后走 MD5,再做 Base64 变体编码。后面真正花时间的,是把这个逻辑从混淆代码里完整还原出来,并且保证在 Node.js 环境也能生成同样的值。
3. 核心算法还原:从混淆代码到可读实现
3.1 常见混淆手段拆解
在还原之前,先把混淆代码里常见的手段认清楚。某顺的前端 JS 用了至少四类混淆,这也是目前中大型站点最常用的套路:
- 变量名和函数名随机化:所有标识符都变成
_0x开头的短名,人工阅读非常费力。 - 字符串数组 + 位移:把代码里出现的所有字符串打散到一个数组中,访问时通过下标取,再经过一个“移位函数”还原出真正内容。
- 控制流平坦化:把正常的
if/else、顺序执行逻辑,统一改写成while+switch循环,通过改变控制变量来跳转到不同分支。 - 对象属性名动态拼接:
obj['hex' + 'in']这种方式,让全局搜索无法直接命中完整字符串。
不要被这些手法吓到。逆向这种事,很多时候不需要把全部代码看懂,只需要把“输入 → 处理 → 输出”这条链路打通即可。
3.2 还原思路:先Hook基础函数,再逐行翻译
我个人的习惯是:先跑通,再翻译。比起直接去读混淆代码,先在浏览器里 Hook 掉那些关键的基础函数,能让加密逻辑“自己开口说话”。
比如,大多数加密参数最终都会调用 btoa、atob、md5、Date.now 或 JSON.stringify 这类基础函数。只要在页面加载后立刻在 DevTools 的 Console 里执行一段 Hook 脚本,把这些函数的入参和出参打印出来,就能相当快地把算法摸清。
我当时在 Console 里执行过类似这样的代码:
javascript复制{
const origBtoa = window.btoa;
window.btoa = function (s) {
console.log('[btoa input]', s);
console.trace();
return origBtoa(s);
};
}
装好 Hook 之后,再触发一次带 hexin-v 的请求,Console 里会直接打印出传给 btoa 的明文以及调用栈。这个信息比猜混淆代码要可靠得多。
3.3 还原出来的核心逻辑(简化版)
经过来回验证,当时还原出来的核心逻辑大致如下。不同时间的版本可能不同,但思路是一致的:
javascript复制function createDeviceId() {
let d = localStorage.getItem('hexin_device_id');
if (!d) {
d = 'dev_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
localStorage.setItem('hexin_device_id', d);
}
return d;
}
function generateHexinV() {
const deviceId = createDeviceId();
const ts = Date.now();
const seed = 'SOME_STATIC_SEED'; // 这段字符串写死在JS里,被称为密钥
const raw = [deviceId, ts, seed].sort().join('|');
const md5Hex = md5(raw);
const b64 = utf8ToBase64(md5Hex);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
逐行解释一下它为什么这么设计:
- 设备指纹
deviceId:保证同一个浏览器发请求时“身份”稳定,服务端可以根据它做会话关联。 - 时间戳
ts:让每次请求生成的签名不一样,防止简单重放。 - 固定密钥
seed:写死在 JS 里的静态字符串,服务端用同一个字符串校验才能对得上。 - 先排序再拼接:
[deviceId, ts, seed].sort()这种方式会让拼接顺序不固定,增加逆向时看代码的难度。 - MD5 后再 Base64:MD5 本身是不可逆的摘要,Base64 只是为了让结果便于放进 HTTP 头。
- 替换
+/=:这是把标准 Base64 转成 Base64url 变体,避免 URL 解析时把参数截断或转义。
3.4 一个关键提醒:静态分析不一定够
上面这个逻辑看着简单,但真实情况下你仍然可能遇到几个坎:
seed不一定以明文形式出现在 JS 里,很可能经过字符串解码,甚至是放在某个 JSON 配置接口里下发。sort()的排序规则在浏览器和 Node.js 里可能不一致,默认按 Unicode 码点排序,如果原实现用了localeCompare,那结果就可能不同。- 有些版本还会夹带用户行为信息,比如鼠标移动轨迹、页面停留时长,这时候光靠 Node.js 模拟是不够的,需要借助无头浏览器来生成。
如果静态分析卡住了,我建议优先通过 Hook 的方式把第一步的 raw 字符串打印出来,然后直接在本地用同样的算法去 hash,再对比最终结果。一旦 raw 和最终的 hexin-v 都能对齐,基本就说明逻辑没理解错。
4. 用Node.js把hexin-v生成逻辑跑起来
4.1 准备项目与依赖
算法还原只是第一步,真正有价值的是脱离浏览器环境也能生成这个签名。这样可以用于自动化测试、接口联调、数据校验等场景。我用的环境是 Node.js 18,项目结构如下:
text复制hexin-v-reverse/
├── package.json
├── generate.js
└── request.js
初始化项目并安装依赖:
bash复制mkdir hexin-v-reverse
cd hexin-v-reverse
npm init -y
npm install axios crypto-js
axios 用来发起请求,crypto-js 用来做 MD5。如果你的运行环境不支持 crypto-js,也可以直接用 Node 内置的 crypto 模块,效果是一样的。
4.2 把localStorage模拟出来
Node.js 里没有 localStorage,所以要先写一个简单的 mock,保证代码在 Node 里不会直接抛异常:
javascript复制const storage = {};
global.localStorage = {
getItem(key) {
return storage[key] ?? null;
},
setItem(key, value) {
storage[key] = String(value);
},
removeItem(key) {
delete storage[key];
}
};
在实际项目里,deviceId 一般需要持久化。如果每次运行脚本都重新生成,服务端可能认为设备身份有变化,导致登录态或风控校验出问题。这里可以做成可配置,从文件或者环境变量里读取。
4.3 核心生成函数
下面是完整的 generate.js,包含了设备 ID 创建和 hexin-v 生成逻辑:
javascript复制const CryptoJS = require('crypto-js');
const storage = {};
global.localStorage = {
getItem(key) { return storage[key] ?? null; },
setItem(key, value) { storage[key] = String(value); }
};
const SEED = 'SOME_STATIC_SEED'; // 根据逆出的实际密钥替换
function createDeviceId() {
let d = localStorage.getItem('hexin_device_id');
if (!d) {
d = 'dev_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
localStorage.setItem('hexin_device_id', d);
}
return d;
}
function utf8ToBase64(str) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(str, 'utf8').toString('base64');
}
return btoa(unescape(encodeURIComponent(str)));
}
function generateHexinV() {
const deviceId = createDeviceId();
const ts = Date.now();
const raw = [deviceId, ts, SEED].sort().join('|');
const md5Hex = CryptoJS.MD5(raw).toString(CryptoJS.enc.Hex);
const b64 = utf8ToBase64(md5Hex);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
module.exports = { generateHexinV, createDeviceId };
初看起来逻辑很直接,但有几个细节必须注意:
Math.random()和Date.now()的组合在 Node 和浏览器里行为基本一致,但如果你要反复跑同一个设备,必须把deviceId持久化。sort()排序在字符串数组长度不一时不一定稳定,建议打印raw确认。utf8ToBase64里我优先用 Buffer 实现,依赖更少,避免浏览器里的btoa被编码问题坑到。
4.4 用生成结果发起真实请求
最后写一个 request.js,把生成的 hexin-v 放到请求头里,模拟浏览器行为去请求目标接口:
javascript复制const axios = require('axios');
const { generateHexinV, createDeviceId } = require('./generate');
createDeviceId(); // 确保设备ID已创建
const instance = axios.create({
baseURL: 'https://example.com/api', // 按实际接口替换
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/json'
}
});
instance.interceptors.request.use(config => {
config.headers['hexin-v'] = generateHexinV();
return config;
});
async function main() {
const res = await instance.get('/quote/list', {
params: { page: 1, size: 10 }
});
console.log(res.status, res.data);
}
main().catch(console.error);
注意,请求里最好连带 Cookie 一起带上。很多接口不仅校验 hexin-v,还会校验 Cookie 里的会话信息,如果 Cookie 缺失,服务端可能直接拒绝,这不是 hexin-v 本身能解决的。
5. 实测验证与踩坑复盘
5.1 第一次跑通是好事,但别高兴太早
我把这个脚本写好之后,第一次请求直接返回了 200,能看到正常的数据内容。那一刻确实很爽——因为这意味着 hexin-v 的生成逻辑已经复现成功了。
但过了几分钟再跑一次,就突然返回了 401。排查下来发现,问题不在签名,而在于我换了一个无痕窗口,Cookie 里没有登录态。其实这说明了很多接口采用“双重校验”:第一层是会话校验,第二层才是 hexin-v 签名校验。签名能过,但会话不过,同样白搭。
5.2 我踩过的几个坑汇总
把这次逆向过程中遇到的实际问题整理成了一张表,给后来人省点时间:
| 坑 | 现象 | 根因 | 解决办法 |
|---|---|---|---|
| 时间戳偏差 | 请求返回“签名过期” | 本地系统时间与服务器时间不一致 | 先从任意接口取服务器时间,再用服务器时间参与签名 |
| 设备ID丢失 | 换环境后同一会话失效 | localStorage 被清理,导致 deviceId 变化 | 将 deviceId 持久化保存到本地配置文件 |
| Base64 特殊字符 | 请求头被截断或解析异常 | + 和 / 在 URL 场景下有特殊含义 |
统一用 - 和 _ 替换,移除末尾 = |
| 排序规则不同 | 浏览器能用、Node 生成的签名不通过 | JS 的默认 sort 与 V8、Node 实现可能有差异 | 打印 raw 字符串做对比,固定排序规则 |
| Cookie 缺失 | 签名正确仍 401 | 请求未携带登录态 Cookie | 把浏览器里的 Cookie 导出到脚本请求头里 |
5.3 合规与边界提醒
这里必须多说一句,逆向分析本身是安全研究工作的一部分,我写这篇文章的目的是分享思路,不是鼓励谁去抓取别人网站的数据,更不建议用脚本去绕过付费、抓用户隐私或者做高并发压力测试。如果你只是做接口调试、自动化测试、爬虫学习,那没问题;但如果用于商业目标或违反平台规则,责任只能自己承担。合理的做法是在自己的测试账号、限流条件下做研究,不要影响线上系统。
5.4 几个个人经验心得
回头整理一下实际体会。逆向这种东西,80% 的时间花在“定位”上,真正还原算法往往反而是最快的部分。很多人一上来就对着混淆代码硬啃,效率很低;换个思路,用 XHR 断点定位入口,用 Hook 基础函数拿到运行时输入输出,整个算法就会自己浮出水面。
另外,如果你以后在别的网站上看到类似 hexin-v、sign、token 这种动态请求头,可以优先按这条链路走一遍:先抓包观察变化规律,再全局搜索字符串,搜不到就上 XHR 断点看调用栈,最后通过 Hook 基础 API 获取输入输出,进而还原算法并在 Node 里复现。
还有一个小技巧是:在还原算法时,尽量先打印 raw 和最终编码结果,再逐段对比。如果你发现浏览器生成的 hexin-v 和你脚本生成的不一样,不要急着改算法,先把中间变量打印出来对齐,通常问题出在拼接顺序、字符编码或者 localStorage 值不一致,而不是算法本身。
某顺这个 hexin-v 的例子,技术上没有特别深的地方,但它把“抓包 → 定位 → 还原 → 复现”这条链路完整地串了一遍。以后再遇到类似参数,我就知道该怎么下手了。
