JS逆向实战:通过原型链补环境破解京东h5st签名算法
2026/9/15 3:55:02 网站建设 项目流程

1. 从一次实际拦截说起:h5st到底卡住了什么

做爬虫或者自动化采集的朋友,大概率都遇到过这种情况:代码在本地跑得好好的,请求一上生产,返回的却是{"error":{"message":"参数错误"}}或者干脆给你一个空列表。如果你碰的是京东系的页面,十有八九问题出在一个叫h5st的参数上。

h5st,全称是H5 Signature,是京东前端安全体系里非常核心的一个签名参数。它出现在很多接口的请求头或者请求体里,看着只是一串经过URL编码的长字符串,但在这串字符背后,是一整套包含指纹采集、环境检测、时间戳校验、签名算法加密的完整流程。

我第一次正式和它对上,是一年多前接到一个采集任务——需要从某个京东页面拿商品数据。最开始我以为只要模拟一下请求头就能搞定,结果请求发过去,h5st校验直接把我拦在门外。后来打开浏览器DevTools仔细看,才发现每个请求里都带着一个动态变化的h5st,而且它的长度、位置、生成逻辑会随着时间不断更新。换句话说,这不是一个固定的token,而是由页面内的一段JS实时计算出来的。

这个时候,逆向的常规思路就出来了:把生成h5st的那段JS从页面里抠出来,放到Node.js环境里去执行,让它自己在本地模拟出同样的签名结果,然后拼到请求里。理论上这样就能完全绕开需要真实浏览器交互的限制,用纯代码模拟出合法的签名。

但问题在于,这段JS不是普通的业务代码,它经过了高度混淆,而且在运行时会大量访问浏览器环境提供的API。你把代码抠出来了,它却检测不到windowdocumentnavigator这些对象,或者检测到的对象跟真实浏览器不符,直接就罢工了。这时候就需要一项技术——原型链补环境

这篇文章要讲的,就是我在逆向京东h5st签名算法时,如何通过分析和补齐JavaScript原型链,让原本只能在浏览器里运行的代码,平滑地在Node.js环境里跑起来。文章会从h5st的生成机制讲起,再深入到为什么要补环境、原型链检测的原理是什么,最后给出我实际操作的完整步骤和踩坑记录。

如果你正在研究JS逆向,或者被某个网站的签名参数卡住了,这篇文章的思路和技巧大概率能帮到你。我不打算写得像教科书,而是尽量还原我当时从分析到上手的整个过程。

2. 先理解h5st的生成逻辑,才能对症下药

2.1 h5st生成流程的初步拆解

在动手写代码之前,我先花了不少时间去看h5st到底是怎么被生成的。虽然它是混淆后的JS,但借助Chrome DevTools的Sources面板配合格式化工具,能大致梳理出它的骨架。

h5st的生成通常包含这几个关键环节:

  • 采集运行环境的指纹信息,比如浏览器版本、屏幕分辨率、语言设置、时区、Canvas指纹、WebGL信息等。
  • 将采集到的信息加上当前时间戳,转成字符串。
  • 对上一步的字符串做编码处理,常见的是先做一次encodeURIComponent,再通过一系列自定义的位运算进行变换。
  • 最后通过一个加密函数,把原始字符串变成最终的签名。

这里还有一个关键点:h5st在每次请求时都要重新生成,不能复用。因为它里面绑定了时间戳,一旦时间窗口过期,服务器就会拒绝请求。所以必须保证跑在Node里的这段代码能像在浏览器里一样,每次调用都能获取到实时的时间,并且完成同样的运算。

我第一次尝试是把包含h5st算法的那段代码原封不动地放到Node里,然后用jsdom模拟windowdocument。结果一跑,直接就报错:

window is not defined

这还算好的,等我手动补了一个基础的window对象之后,代码又报新的错,什么navigator is not defineddocument.createElement is not a function,一轮一轮地补,补到后来发现算法内部根本不是我缺什么补什么那么简单。它会在运行过程中动态判断当前环境是否像真实的浏览器,一旦发现对象长得不对,直接就走进了错误的逻辑分支,给你返回一个看起来很合理但实际上完全错误的签名。

这时候我才意识到,单纯地往global上挂几个变量是不够的。JS里很多环境检测不是简单地判断“你存不存在”,而是会通过原型链去检查某个对象是否符合真实的浏览器特征。所以,才需要深入理解原型链,按它的规则去“补全”环境。

2.2 为什么我们不能拿Node直接硬跑

很多人会问,我不补环境,直接改算法逻辑,把它的加密过程用Python或别的语言重写一遍行不行?理论上是可以的,但实际上成本极高。

h5st的算法每隔一段时间就会更新一次,改写了其中的参数组合、加密顺序,甚至加减乘除的位运算规则。如果你是用Python逻辑去复刻这一套,每次算法一变你就得重新分析、重新实现,是一个没完没了的工作。

相比之下,直接让原始JS运行起来的办法就聪明多了。算法的更新不影响我们,因为我们跑的就是它自己最新的代码。永远和线上页面保持一致。要做到这一点,核心就是让这段JS认为自己是在一个真实的浏览器环境里运行。而让它产生这种“错觉”的关键,就是补齐它访问到的所有对象、属性和方法,尤其是那些通过原型链继承下来的部分。

就像你把一个习惯了Windows操作系统的程序员,突然换到Linux环境,如果只是给他一个相似的命令行提示符,他敲几个常用命令就会露馅。要让他觉得还在Windows上,你得把文件夹结构、环境变量、磁盘盘符全都模拟好。JS逆向里的补环境,做的就是这件事。

3. 原型链:环境检测最喜欢盯上的地方

3.1 环境检测的三个常见维度

要搞清楚怎么补环境,先得知道JS代码都是怎么检测“我到底在不在浏览器里”的。我总结了三个最常见的维度:

第一个是全局对象检测。判断windowdocumentnavigatorlocation等对象是否存在。这是最基础的检测,也最容易骗过。

第二个是属性和方法检测。比如navigator.userAgent是不是预期的值,document.createElement能不能正常创建节点,window.getComputedStyle存不存在。这些也相对好补,往相应对象上挂函数就行。

第三个就难搞了——原型链检测。JS里的很多对象并不是孤立的,它们各自继承自某个原型对象。举个例子,你在浏览器里创建一个div元素,这个元素不仅有自己直接定义的属性,还能通过原型链一层层往上找到HTMLElement.prototypeElement.prototypeNode.prototype,一直到Object.prototype里的各种方法。

混淆后的JS很可能会这样检测:

function isBrowser() { // 检测某个对象是否继承了特定的原型 return Object.getPrototypeOf(document.createElement('div')).toString() === '[object HTMLDivElement]'; }

如果你只是简单地搞了一个document对象,并为它手动添加了createElement方法,返回一个普通的{},那么Object.getPrototypeOf拿到的是Object.prototype,toString打印出来的就是[object Object],跟真实浏览器返回的[object HTMLDivElement]就完全对不上。算法检测到对不上,就会认为当前环境是伪造的。

再比如,有些检测会用instanceof来判断某个对象是不是某个构造函数的实例。你手动创建的对象,它的原型链上根本没有Element.prototype这一层,instanceof结果就是false,直接就露馅了。

3.2 浏览器原型链的特殊之处

聊到这里,我干脆把浏览器里常见的原型链梳理一遍。熟悉DOM操作的朋友应该都知道,创建一个div节点,它的原型链是这样一路继承下来的:

div → HTMLDivElement.prototype → HTMLElement.prototype → Element.prototype → Node.prototype → EventTarget.prototype → Object.prototype

每次从document.createElement('div')返回的对象,都能往上找到这么多层原型。每一层上又挂着不同的方法,比如appendChildNode.prototype上,getAttributeElement.prototype上,clickHTMLElement.prototype上。

如果你只是还原子节点对象本身,而不管它的原型链,很多涉及方法查找的调用就会失败。因为JS在调用div.appendChild(x)时,首先会看div自身有没有appendChild,没有就去找它的原型,也就是HTMLDivElement.prototype,还找不到就继续往上层找。如果原型链不完整,这个方法就找不着,直接抛TypeError: div.appendChild is not a function

此外,还有一个容易忽略的细节:每一种元素节点的原型都不一样div的原型是HTMLDivElementa标签的原型是HTMLAnchorElementcanvas的原型是HTMLCanvasElement。光是一个createElement方法,你让它创建不同类型的元素,返回的对象的原型链都不同。这在指纹采集和Canvas画图时尤其关键,很多网站会创建一个canvas节点来画图、读取像素数据,从而生成独特的浏览器指纹。

我当时在做h5st分析的时候就发现,代码会隐式地触发一些看似无关的浏览器API,比如document.createElement('canvas').getContext('2d'),这分明是要拿Canvas指纹。所以补环境不光要把对象补全,还要保证这些对象的方法能返回符合预期的结果。

4. 补齐原型链环境的实操过程

4.1 先“裸跑”一次,看它到底缺什么

理论讲完,下面进入动手环节。我最开始的做法非常简单粗暴——把从浏览器里抠出来的包含h5st算法的JS,直接放到Node里跑一遍,然后把报错信息一条条记录下来。

这里有一个小技巧:不要一次性把几百行代码塞进去。先观察这个JS里面有没有一个明显的入口函数,通常经过分析之后会发现它会被挂载到某个全局变量上或某个自定义对象上。我会把入口函数单独拎出来,在Node里调用它,就好比在浏览器控制台里直接调用一样。

我第一次调用时,报错信息是这样一串:

ReferenceError: window is not defined at Object.<anonymous> (/Users/x/project/h5st/index.js:10:5)

这时候你就要开始分析:报错的这一行,是在什么上下文里访问window的?是初始化时的全局访问,还是某个函数内部的访问?不同的位置会影响你补环境的方式。像是初始化时就访问window,你必须在执行代码之前就定义好global.window;如果是某个函数内部访问的,那只要保证函数运行时有这个对象就行。

我一般会先看看代码的整体结构。如果整个文件是一个自执行函数(IIFE),那我就在外面,先定义global.window = global,把window指向Node的全局对象,这样可以骗过最基础的存在性检测。然后再跑一遍,看下一个报错是什么。

4.2 逐项补齐Window和Navigator

第二轮跑的时候,报错变成了:

TypeError: navigator is not defined

好,这个好办。我直接在入口JS执行前,往上挂一个navigator对象:

global.navigator = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', language: 'zh-CN', languages: ['zh-CN', 'zh'], platform: 'Win32', vendor: 'Google Inc.', cookieEnabled: true, };

但要注意,navigator对象里其实还有很多其他的属性和方法,比如pluginsmimeTypeshardwareConcurrencydeviceMemorymaxTouchPoints等,这些也可能被算法检测到。保险起见,尽量在navigator里把这些常见的属性都放进去。

补完这个再跑,又报document is not defined。这次没有之前那么简单了,因为document对象的方法和属性实在太多,我总不能一个个手写。

我当时查了一下别人的做法,有人直接用jsdom库,直接整一个模拟浏览器环境,用起来确实省事。但jsdom有它的问题——生成的很多对象并不是真实的浏览器实现,原型链结构跟真实浏览器有差异。对基础的环境检测够用,但遇到专门找茬的混淆代码,照样会露馅。

在h5st这个场景,我当时选择的是手动构造一个轻量的document对象,只实现JS运行过程中真正用到的那些方法,比如createElementgetElementByIdquerySelectoraddEventListener等。

global.document = { createElement(tagName) { // 根据不同的tagName,返回对应原型链的对象 }, getElementById(id) { return null; }, addEventListener() {}, removeEventListener() {}, documentElement: { style: {}, clientWidth: 1920, clientHeight: 937, }, body: {}, cookie: '', title: '', readyState: 'complete', referrer: '', };

4.3 按正确的继承顺序构造div、canvas等元素的原型链

这一步是整个补环境过程的核心,也是最费时间的部分。因为createElement返回的对象,需要跟真实浏览器一样,拥有完整的多层原型链。

我当时写了一个createElement的辅助函数,思路是这样:

function createProxyPrototype(name) { const proto = Object.create(HTMLElement.prototype); Object.defineProperty(proto, 'constructor', { value: function () {}, writable: true, configurable: true, }); return proto; } const elementPrototypes = { div: 'HTMLDivElement', canvas: 'HTMLCanvasElement', a: 'HTMLAnchorElement', img: 'HTMLImageElement', span: 'HTMLSpanElement', input: 'HTMLInputElement', form: 'HTMLFormElement', button: 'HTMLButtonElement', // 其他标签按需补充 };

然后,我需要先定义最底层的几个原型对象:

const EventTargetProto = { addEventListener: function () {}, removeEventListener: function () {}, dispatchEvent: function () { return true; }, }; const NodeProto = Object.create(EventTargetProto); NodeProto.appendChild = function () { return this; }; NodeProto.removeChild = function () { return this; }; NodeProto.cloneNode = function () { return this; }; NodeProto.contains = function () { return true; }; const ElementProto = Object.create(NodeProto); ElementProto.getAttribute = function (name) { return this.attributes ? this.attributes[name] || null : null; }; ElementProto.setAttribute = function (name, value) { if (!this.attributes) this.attributes = {}; this.attributes[name] = String(value); }; ElementProto.querySelector = function () { return null; }; ElementProto.querySelectorAll = function () { return []; }; const HTMLElementProto = Object.create(ElementProto); HTMLElementProto.click = function () {}; HTMLElementProto.focus = function () {}; HTMLElementProto.blur = function () {}; HTMLElementProto.style = {};

有了这些基础原型之后,再按标签映射关系,让createElement(tagName)返回的对象设置成对应的原型:

global.document.createElement = function (tagName) { const lowerTag = String(tagName).toLowerCase(); const protoName = elementPrototypes[lowerTag] || 'HTMLUnknownElement'; let proto; if (protoName === 'HTMLDivElement') { proto = Object.create(HTMLElementProto); } else if (protoName === 'HTMLCanvasElement') { proto = Object.create(HTMLElementProto); // canvas需要单独处理getContext proto.getContext = function (type) { if (type === '2d') { return createCanvasRenderingContext2D(); } return null; }; proto.toDataURL = function () { return 'data:image/png;base64,xxxx'; }; } // ...其他类型 const element = {}; Object.setPrototypeOf(element, proto); // 设置一些常用属性 element.nodeType = 1; element.nodeName = lowerTag.toUpperCase(); element.tagName = lowerTag.toUpperCase(); element.attributes = {}; element.style = {}; element.children = []; element.classList = { add: function () {}, remove: function () {}, contains: function () { return false; }, toggle: function () {}, }; return element; };

这里有个我踩过的坑值得单独说一下:不要用Object.create(HTMLElementProto)去创建原型,然后又直接给返回的对象设置一大堆自有属性,结果把原型链上的方法给覆盖了。我当时就是因为图省事,直接给对象加了一个getAttribute函数,忘了它是原型链上应该有的方法,导致某个检测逻辑通过hasOwnProperty('getAttribute')判断时结果不一样,进而走错了分支。

正确的做法是,元素对象自身只放实例相关的东西,比如nodeTypetagNameattributesstylechildren这些,而像getAttributeappendChildquerySelector这类方法,尽量放在对应的原型对象上。这样才更接近真实浏览器的结构。

4.4 处理Canvas指纹和其他容易被忽略的细节

前面说过,h5st这种签名算法会采集Canvas指纹。如果你补的canvas.getContext('2d')只是返回一个空对象,代码在调用ctx.measureText()ctx.fillText()ctx.getImageData()的时候就会报错。

我的处理办法是,让getContext('2d')返回一个Mock的2D上下文对象:

function createCanvasRenderingContext2D() { const context = { canvas: {}, fillStyle: '#000000', strokeStyle: '#000000', font: '10px sans-serif', textAlign: 'start', textBaseline: 'alphabetic', measureText: function (text) { return { width: String(text).length * 7, }; }, fillText: function () {}, strokeText: function () {}, beginPath: function () {}, arc: function () {}, fill: function () {}, stroke: function () {}, getImageData: function (x, y, w, h) { return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h, }; }, toDataURL: function () { return 'data:image/png;base64,xxxx'; }, }; return context; }

measureText返回的宽度,我用字符串长度 * 7做一个简单的模拟。因为对于指纹采集来说,最终还是会将整个canvas序列化成为一个固定字符串,所以只要每次返回的宽度值稳定,算法生成的指纹就是稳定的。也就是说,不需要跟真实浏览器里完全一致,只需要在本地运行环境里保持一致性即可。

类似容易被忽略的细节还有:

  • window.screen对象,包含widthheightcolorDepthpixelDepth
  • window.devicePixelRatio
  • window.innerWidthinnerHeightouterWidthouterHeight
  • window.location对象,包含hrefhostnamepathname等。
  • Date.prototype.getTimezoneOffset,时区信息也是指纹的一部分。

因为这些属性值会直接影响签名结果,所以我都会尽量按照一个真实的Chrome浏览器的参数来填写。

5. 常见问题与排查技巧实录

5.1 补完环境后签名仍然不对,问题出在哪里

就算你按照上面的思路把环境补得差不多了,签名还是会经常出现不对的情况。我整理了一个排查顺序表,分享给遇到同样问题的朋友:

现象可能原因排查方向
直接抛ReferenceError缺全局对象或变量记录报错栈,查看是哪一行在哪个作用域访问的
TypeError: xxx is not a function原型链上缺少对应方法把完整原型链打印出来,看方法的挂载位置是否正确
运行不报错,但签名跟浏览器里不一致某个环境参数的值不对,算法走了不同分支在浏览器里和Node里分别打印相同中间变量,逐个对比
签名本地能生成,但请求被拒时间戳偏移或算法内部随机数不一致检查Date.now()是否正常,检查Math.random()是否被篡改
算法跑得很慢或卡住可能存在死循环或加载额外资源查看代码是否尝试发送网络请求或加载子资源

我最常遇到的是第二种和第三种情况。第二种报错还算好的,因为错误信息能直接告诉你缺什么。最怕的是第三种——不报错,但是结果不对。这种情况只能靠对比。

5.2 用浏览器和Node双端对比输出,精确定位差异

在h5st这个项目里,我摸索出一个很好用的排查方法:在浏览器控制台和Node环境里,同时打印算法执行到每一步时的中间变量,然后逐个对比

具体怎么操作呢?因为h5st算法是混淆的,直接加log很难。我通常是把它的入口函数用toString()打印出来,然后自己手动在关键步骤插上console.log,再分别在浏览器和Node里跑,对比输出的差异。

比如入口算法里会有一个参数叫fp,是拼接了环境指纹、时间、随机数之类的字符串。我会在本地先把fp固定成一个写死的值,这样算法中后面的加密环节就是一个确定性的输入输出。然后分别在浏览器和Node里用同一个fp去跑后面的加密函数,对比结果是否一致。

如果一致,说明加密函数本身没问题,问题出在指纹采集或fp的拼装上。如果不一致,那就说明加密函数内部依赖了某些环境状态,比如Date.now()Math.random()或者别的什么全局变量,还得继续往下拆。

这个方法虽然原始,但在排查复杂混淆代码时非常有效。它的核心思想是:通过固定输入,缩小问题范围,把不确定性逐步消除

5.3 一点独家心得:不要试图一次补完所有环境

最后说点个人的实在话。很多刚开始搞JS逆向的朋友,一上来就想把环境补得尽善尽美,把所有浏览器API都模拟了,觉得一劳永逸。但我个人的经验是,千万不要这么做

补环境的正确打开方式是“按需补齐”——它报什么错,你就补什么;你发现它检测了什么,你再补什么。因为你不可能预判混淆代码的每一个刁钻检测点,而且在补的过程中,你反而能更好地理解这段算法到底在依赖哪些浏览器特性,这对于后续的逆向分析反而更有帮助。

我自己在补h5st环境的时候,前前后后改了几十次文件,每一次都是因为新的报错或新的结果不一致。这个过程虽然烦琐,但每次定位问题、解决问题的成就感也是真实的。等最后一次,我拿着从Node里生成的h5st去请求接口,服务器返回200和正常数据的那一刻,确实有一种“跋涉终于到终点”的感觉。

如果你正在研究这类签名算法的逆向,希望你也能耐心地把环境补到“以假乱真”的程度。这个过程本身,就是对JavaScript原型链、闭包、异步执行机制的一次绝佳实战训练。

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

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

立即咨询