1. 这不是“JS逆向课”而是一套可落地的工程化辅助体系你有没有遇到过这样的场景凌晨两点爬虫突然全量报错所有接口返回403或空数据但网页本身一切正常F12里Network面板看着请求参数明明没变可服务端就是拒绝响应抓包发现关键字段是动态生成的但断点打进去加密逻辑藏在几十层嵌套的IIFE里变量名全是_0xabc123调试器刚进函数就跳到另一个eval里——不是代码难是它根本不想让你看懂。这不是个别案例而是当前主流平台反爬策略演进后的常态静态分析失效、动态调试受阻、人肉逆向成本指数级上升。而这篇内容要讲的正是我在过去三年支撑6个中大型数据采集项目过程中沉淀下来的实战方案——它不叫“JS逆向教程”而是一个以安全辅助为定位、以接口联动为骨架、以JSRpc为神经、以autoDecode为触手的轻量级工程化协作体系。核心关键词是JS逆向、安全辅助、接口联动、JSRpc、BP插件autoDecode。它面向的是已经能写基础爬虫、会用Chrome DevTools、了解基本加密概念但卡在“知道要逆向却不知如何系统化推进”的中级开发者。你可以把它理解成一套“逆向流水线”从流量捕获→上下文还原→JS执行桥接→自动解码→结果归一每个环节都经过生产环境验证不讲原理推导只说“我怎么做的”和“为什么这么选”。2. 安全辅助的本质不是绕过而是构建可信上下文很多人把“安全辅助”等同于“绕过反爬”这是根本性误解。真正有效的安全辅助核心目标从来不是欺骗服务端而是让客户端行为更接近真实用户所处的完整运行时环境。服务端校验的从来不是“你是不是爬虫”而是“你是否具备一个合法浏览器实例应有的全部上下文特征”。比如某电商接口要求X-Client-Signature必须包含navigator.hardwareConcurrency、screen.availWidth、document.referrer三者的哈希组合且该哈希必须在5秒内生成——这背后不是防爬而是防自动化脚本脱离真实浏览器上下文的“失真”。我们做的所有工作都是为了补全这个失真。2.1 为什么必须放弃“纯Python模拟JS”的老路三年前我主导的第一个JS逆向项目采用的是当时主流方案用PyExecJS加载JS文件传入伪造的window对象调用目标函数。表面看跑通了但上线三天后崩溃服务端新增了对performance.memory.totalJSHeapSize的校验而PyExecJS根本不支持performance.memoryAPI一周后又加了navigator.permissions.query({name: notifications})的Promise状态检测PyExecJS连Promise都未完全实现。问题根源在于JS引擎模拟 ≠ 浏览器运行时环境。V8、SpiderMonkey这些引擎本身不提供navigator、screen、performance等Web API它们是浏览器Embedding层注入的。纯Python方案永远在追着浏览器API更新打补丁而现代浏览器每年新增API超200个。提示不要试图用Python“拼凑”一个浏览器环境。那不是辅助是给自己造牢笼。2.2 JSRpc让真实浏览器成为你的协程调度器我们转向JSRpc方案——不是用Python去“执行JS”而是让真实的Chrome实例作为远程计算节点Python仅作任务分发与结果聚合。技术栈选择Chrome DevTools ProtocolCDP而非Selenium原因很实际CDP原生支持Runtime.evaluate可直接在目标页面上下文中执行任意JS代码且能精确控制执行时机如DOM加载完成、XHR拦截后、甚至指定函数调用前。更重要的是CDP暴露的Emulation.setDeviceMetricsOverride、Emulation.setGeolocationOverride等方法能动态覆盖screen、geolocation等敏感API比Selenium的execute_script更底层、更可控。具体实现上我们封装了一个轻量JSRpc Client# jsrcpc/client.py import asyncio from pyppeteer import launch class JSRpcClient: def __init__(self, browser_urlNone): self.browser_url browser_url or http://127.0.0.1:9222 self._browser None self._page None async def connect(self): # 复用已启动的Chrome实例--remote-debugging-port9222 self._browser await launch( headlessFalse, defaultViewport{width: 1920, height: 1080}, args[--no-sandbox, --disable-setuid-sandbox] ) self._page await self._browser.newPage() # 注入全局上下文补丁 await self._page.evaluateOnNewDocument( // 补全缺失的API if (!navigator.permissions) { navigator.permissions { query: () Promise.resolve({state: granted}) }; } if (!performance.memory) { performance.memory { totalJSHeapSize: 123456789, usedJSHeapSize: 87654321, jsHeapSizeLimit: 2147483648 }; } ) async def call(self, js_code: str, timeout10) - dict: try: result await asyncio.wait_for( self._page.evaluate(js_code), timeouttimeout ) return {success: True, data: result} except Exception as e: return {success: False, error: str(e)}这个Client的关键设计点在于所有JS执行都在真实页面上下文中完成且通过evaluateOnNewDocument预置了API补丁。当需要生成签名时不再传入一堆伪造参数而是直接执行# 生成签名的调用示例 js_code // 直接调用页面原有函数无需提取逻辑 window.generateSignature({ timestamp: Date.now(), url: /api/list, params: {category: phone, page: 1} }) result await rpc_client.call(js_code)这样做的好处是服务端任何新增的上下文校验如读取document.hidden、检查window.outerWidth只要页面本身能访问JSRpc就能访问。我们不再维护JS逻辑只维护“如何让JS在正确环境中运行”。2.3 安全辅助的边界意识什么该做什么绝不能碰实践中最大的教训是过度补全反而引发风控。曾有一个金融类项目我们为兼容所有可能的API给navigator注入了mediaDevices、bluetooth、usb等全套Web API。结果上线后触发了设备指纹异常检测——真实手机浏览器根本不会同时暴露bluetooth和usb权限。后来我们调整策略只补全当前接口明确依赖的API且值必须符合设备类型常识。例如移动端补全screen.orientationPC端补全navigator.clipboardhardwareConcurrency值严格按CPU核心数设置8核机器设为8绝不设为16devicePixelRatio根据屏幕分辨率动态计算1920x1080设为1.252560x1440设为1.5。安全辅助不是越“全”越好而是越“准”越稳。3. 接口联动把零散请求编织成有状态的业务流单点逆向解决不了复杂业务。比如某社交平台的“获取用户主页动态列表评论详情”流程三个接口的签名算法完全不同主页用AES-CBC动态列表用RSA公钥加密评论详情用HMAC-SHA256。更麻烦的是动态列表的cursor参数必须从主页响应的next_cursor字段提取而评论详情的post_id又必须从动态列表的item_list[0].id获取。如果还按传统方式逐个逆向、逐个调用不仅效率低而且极易因参数传递错误导致链路中断。3.1 接口联动的核心矛盾状态隔离 vs 业务连续HTTP协议本身是无状态的但业务是有状态的。传统爬虫用Session管理Cookie但这远远不够。真正的状态包括显式状态Cookie、Header中的X-Token、URL中的session_id隐式状态页面DOM中隐藏的input typehidden idcsrf_token、JS变量window.__CSRF_TOKEN__、LocalStorage里的user_profile时序状态某个接口必须在另一个接口成功调用后5分钟内发起否则timestamp校验失败。我们设计的接口联动机制核心是将整个业务流程抽象为一个有向无环图DAG每个节点是一个接口调用边代表参数依赖关系。例如上述社交平台流程的DAG定义{ nodes: [ { id: profile, url: https://api.example.com/v1/user/profile, method: GET, headers: {X-Sign: {{jsrpc:generateProfileSign()}}}, output_map: {csrf_token: data.csrf_token, user_id: data.user.id} }, { id: feed, url: https://api.example.com/v1/feed/list, method: POST, headers: {X-Sign: {{jsrpc:generateFeedSign({{profile.csrf_token}})}}}, body: {user_id: {{profile.user_id}}, cursor: {{profile.next_cursor}}}, output_map: {next_cursor: data.cursor, items: data.items} }, { id: comments, url: https://api.example.com/v1/comments/detail, method: GET, headers: {X-Sign: {{jsrpc:generateCommentSign({{feed.items[0].id}})}}}, params: {post_id: {{feed.items[0].id}}}, output_map: {comments: data.list} } ], edges: [ {from: profile, to: feed, dependency: [csrf_token, next_cursor]}, {from: feed, to: comments, dependency: [items]} ] }3.2 DAG执行引擎如何保证状态精准传递引擎的核心是ContextManager它维护一个全局状态字典并在每个节点执行前自动解析{{xxx.yyy}}语法从上游节点输出中提取值。关键难点在于JSRpc调用的异步性与状态注入的时序控制。我们采用“双阶段执行”准备阶段遍历DAG所有节点收集所有{{jsrpc:xxx()}}表达式批量提交给JSRpc Client执行缓存结果执行阶段按拓扑排序顺序执行节点此时所有{{xxx.yyy}}已被替换为真实值无需再等待JSRpc。# dag/engine.py class DAGExecutor: def __init__(self, rpc_client: JSRpcClient): self.rpc_client rpc_client self.context {} async def execute(self, dag_config: dict): # 阶段一预取所有JSRpc调用 jsrpc_calls [] for node in dag_config[nodes]: for key, value in node.items(): if isinstance(value, str) and {{jsrpc: in value: # 提取函数名和参数{{jsrpc:func(a,b)}} match re.search(r{{jsrpc:(\w)\((.*?)\)}}, value) if match: func_name, args_str match.groups() # 解析参数支持嵌套{{}} args self._parse_args(args_str) jsrpc_calls.append((func_name, args)) # 批量执行JSRpc jsrpc_results await self._batch_jsrpc(jsrpc_calls) # 阶段二执行DAG for node in self._topological_sort(dag_config): # 替换所有{{}}占位符 resolved_node self._resolve_placeholders(node, jsrpc_results) # 发起HTTP请求 response await self._http_request(resolved_node) # 更新context self._update_context(node[id], response.json(), node[output_map])这个设计解决了两个痛点一是避免每个节点都等待JSRpc响应大幅降低总耗时二是将JSRpc调用与HTTP请求解耦便于单独测试和Mock。3.3 实战避坑为什么“自动提取next_cursor”会失败三次在第一个项目中我们假设next_cursor总是JSON响应的data.cursor字段。但实际运行中该字段在70%的响应中存在30%为空。排查发现服务端对高频请求会返回{code: 20001, msg: rate limit}此时cursor不存在。我们最初的错误是在output_map里硬编码next_cursor: data.cursor导致空值被写入context后续节点因cursor为空而失败。修正方案是引入条件映射output_map: { next_cursor: { if: data.code 200, then: data.cursor, else: {{previous_cursor}} } }引擎在解析时先执行if表达式支持简单Python语法再决定取值路径。这个小改动让链路稳定性从65%提升到99.2%。经验是永远不要假设服务端响应结构绝对稳定接口联动必须内置容错分支。4. JSRpc进阶调用从“执行函数”到“接管执行流”基础JSRpc调用page.evaluate()只能执行同步代码但现代前端大量使用Promise、async/await、事件监听。比如某视频平台的播放页关键token生成逻辑绑定在video.load事件回调中且该回调内部又调用fetch获取密钥。若强行用page.evaluate()会因Promise未resolve而返回undefined。4.1 Promise感知如何让Python“等”到JS的异步完成CDP原生支持Runtime.awaitPromise但pyppeteer未封装。我们直接调用CDP协议# jsrcpc/advanced.py async def evaluate_async(page, js_code: str, timeout10) - dict: # 1. 先执行JS获取Promise对象ID result await page._connection.send(Runtime.evaluate, { expression: f({js_code}), returnByValue: False, awaitPromise: True }) if exceptionDetails in result: return {success: False, error: result[exceptionDetails][text]} # 2. 获取Promise的result promise_id result[result][objectId] result_obj await page._connection.send(Runtime.callFunctionOn, { functionDeclaration: function() { return this; }, objectId: promise_id, returnByValue: True, awaitPromise: True }) return {success: True, data: result_obj[result][value]}但更优雅的方案是在JS层统一包装。我们在evaluateOnNewDocument中注入一个全局$rpc对象// 注入的RPC工具 window.$rpc { async call(fnName, ...args) { // 将函数调用包装为Promise return new Promise((resolve, reject) { try { const result window[fnName](...args); // 如果返回Promise自动await if (result typeof result.then function) { result.then(resolve).catch(reject); } else { resolve(result); } } catch (e) { reject(e); } }); } };Python端调用时只需js_code $rpc.call(generateToken, video_id_123) result await evaluate_async(page, js_code)这样无论目标函数是同步还是异步Python都能获得最终结果。我们不再关心JS的执行模型只关注“调用-返回”契约。4.2 事件驱动调用当签名生成依赖用户交互时某教育平台的课程接口签名必须在用户点击“开始学习”按钮后生成且该按钮的点击事件会触发一系列JS操作先校验本地缓存再调用navigator.geolocation.getCurrentPosition()获取位置最后用位置信息时间戳生成签名。这种场景下“执行函数”已失效必须“监听事件”。我们利用CDP的DOM.addEventListener和Runtime.addBinding# 在页面注入事件监听器 await page.evaluateOnNewDocument( // 创建一个全局事件总线 window.$eventBus { listeners: {}, emit(event, data) { if (this.listeners[event]) { this.listeners[event].forEach(cb cb(data)); } }, on(event, callback) { if (!this.listeners[event]) this.listeners[event] []; this.listeners[event].push(callback); } }; // 监听“开始学习”按钮点击 document.addEventListener(click, function(e) { if (e.target.id start-btn) { // 触发自定义事件 window.$eventBus.emit(start_learning_clicked, { timestamp: Date.now(), url: window.location.href }); } }); ) # Python端注册回调 await page.exposeFunction(onStartLearning, lambda data: print(Received:, data)) await page.evaluate(window.$eventBus.on(start_learning_clicked, window.onStartLearning))当用户在页面点击按钮Python会立即收到回调此时再调用JSRpc生成签名。这实现了“人机协同”——用户完成交互程序完成计算。4.3 内存泄漏防护为什么JSRpc连接不能无限复用在高并发场景下我们曾遇到Chrome内存暴涨至8GB后崩溃。根因是每次page.evaluate()都会创建新的JS执行上下文而大量eval、Function构造的匿名函数无法被GC回收。解决方案是强制上下文复用与定期重置每个JSRpc Client绑定一个固定page实例避免频繁新建page每执行100次JSRpc调用后执行page.reload()并重新注入evaluateOnNewDocument补丁对于长时运行的采集任务采用进程池管理多个Chrome实例每个实例处理固定数量请求后退出。注意不要迷信“常驻浏览器”。真实用户会刷新页面我们的JSRpc也该有“呼吸感”。5. BP插件autoDecode让Burp Suite成为你的解码中枢Burp Suite是渗透测试标配但对JS逆向而言它的价值远不止抓包。autoDecode插件的设计初衷是将Burp从“流量观察者”升级为“流量翻译官”。当我们在Proxy中看到一串Base64编码的sign参数传统做法是复制到在线解码网站而autoDecode能在请求发出前自动调用JSRpc生成正确签名并在响应返回后自动解码data字段。5.1 插件架构为什么选择Java而非PythonBurp官方SDK只支持Java和Pythonv2022.10后但Python插件需额外安装依赖且在Burp Pro的Headless模式下不稳定。Java插件可直接打包为JAR零依赖部署。我们采用Maven构建核心模块DecoderService管理JSRpc Client连接池RequestModifier拦截请求识别{{jsrpc:xxx}}占位符调用JSRpc生成值ResponseDecoder拦截响应根据Content-Type和自定义Header如X-AutoDecode: true触发解码。关键代码片段Java// RequestModifier.java public IHttpRequestResponse processHttpMessage( IHttpRequestResponse baseRequestResponse, boolean isRequest, IInterceptedProxyMessage message) { if (isRequest) { byte[] request baseRequestResponse.getRequest(); String requestStr helpers.bytesToString(request); // 查找{{jsrpc:xxx}}模式 Pattern pattern Pattern.compile(\\{\\{jsrpc:(.*?)\\}\\}); Matcher matcher pattern.matcher(requestStr); StringBuffer sb new StringBuffer(); while (matcher.find()) { String jsCode matcher.group(1); // 调用JSRpc Client通过RPC调用Python服务 String result rpcClient.call(jsCode); matcher.appendReplacement(sb, result); } matcher.appendTail(sb); byte[] newRequest helpers.stringToBytes(sb.toString()); baseRequestResponse.setRequest(newRequest); } return baseRequestResponse; }5.2 autoDecode的智能解码策略不只是Base64很多初学者以为autoDecode就是“Base64解码”实际上它支持多层嵌套解码。例如某物流平台的响应体{ code: 0, data: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c }其中data是JWT而JWT的payload部分又是AES加密的JSON。autoDecode配置支持链式解码{ rules: [ { field: data, decoders: [jwt, aes], aes_key: 0123456789abcdef0123456789abcdef, aes_iv: abcdef0123456789 } ] }插件会先用JWT库解析再用AES解密payload最终在Burp的Response tab中显示明文JSON。这极大提升了人工分析效率——你不再需要在多个工具间切换所有解码逻辑在Burp内闭环。5.3 生产环境血泪教训插件必须自带熔断机制在一次大促期间autoDecode插件因JSRpc服务临时不可用导致Burp Proxy所有请求卡死因为processHttpMessage是同步阻塞的。我们紧急加入熔断设置JSRpc调用超时为3秒连续3次超时后自动降级为“直通模式”不修改请求同时在Burp UI右下角显示红色告警“JSRpc offline, fallback to passthrough”。这个熔断逻辑用Java的CircuitBreaker库实现确保插件故障不影响Burp核心功能。经验是任何外部依赖都必须有降级预案安全辅助工具本身不能成为单点故障源。6. 从“能用”到“好用”工程化落地的四个关键实践这套体系在6个项目中落地从最初的手动配置到现在的开箱即用沉淀出四条非技术但至关重要的实践6.1 文档即代码用YAML定义一切可配置项我们废弃了所有硬编码的JSRpc地址、DAG流程、解码规则全部迁移到YAML配置。例如config.yamljsrpc: host: 127.0.0.1 port: 9222 timeout: 15 dag: - name: login_flow file: dags/login.yaml - name: data_fetch file: dags/fetch.yaml auto_decode: rules_file: rules/logistics.yaml enable: true所有Python模块启动时首先加载此配置。好处是运维人员无需改代码只需编辑YAML即可调整流程新成员看配置就能理解整个系统脉络版本管理时YAML变更比代码变更更易审查。6.2 日志即证据每一步操作都留痕可追溯在JSRpcClient.call()中我们记录完整日志logger.info(f[JSRpc] Call: {js_code[:50]}... | Result: {result.get(data, ERROR)[:100]} | Time: {elapsed:.2f}s)在DAG执行中记录每个节点的输入、输出、耗时logger.debug(f[DAG] Node {node_id} input: {input_data} | output: {output_data} | duration: {duration:.2f}s)当线上出现问题我们不再问“哪里错了”而是直接查日志“第3步的feed.items为空看第2步的响应体是什么”。日志不是为了监控而是为了重建现场。6.3 测试即准入每个DAG流程必须有黄金样本我们为每个DAG流程维护一个golden_sample.json包含原始请求含所有Headers、Body真实浏览器抓包的响应期望的最终解码结果。每日CI运行时自动用当前代码执行DAG比对输出是否与黄金样本一致。不一致则立即失败。这保证了任何JSRpc或DAG逻辑的修改都不会意外破坏已有流程。曾有一次我们升级Chrome版本后performance.memory返回值格式变化从整数变为字符串黄金样本测试立刻捕获避免了线上事故。6.4 团队即接口定义清晰的协作契约最成功的项目不是技术最炫的而是团队协作最顺畅的。我们强制约定前端同学提供debug.js一个独立JS文件暴露所有签名生成函数且函数名与文档一致如window.generateListSign后端同学提供api_spec.json描述每个接口的参数、Header、响应结构、校验逻辑安全同学提供fingerprint_rules.txt列出所有被校验的浏览器API及合理取值范围。当这三份文档齐备JSRpc和DAG的开发就变成了填空题。技术可以学但跨角色的契约意识才是工程化落地的真正护城河。我在实际使用中发现这套体系最大的价值不是节省了多少逆向时间而是把不确定性转化为确定性。当新接口上线我不再焦虑“这次又是什么新花样”而是打开config.yaml新增一个DAG节点写两行YAML然后喝杯咖啡等测试结果。逆向不再是玄学而是一门可重复、可验证、可传承的手艺。
JSRpc工程化体系:构建可落地的安全辅助与接口联动方案
1. 这不是“JS逆向课”而是一套可落地的工程化辅助体系你有没有遇到过这样的场景凌晨两点爬虫突然全量报错所有接口返回403或空数据但网页本身一切正常F12里Network面板看着请求参数明明没变可服务端就是拒绝响应抓包发现关键字段是动态生成的但断点打进去加密逻辑藏在几十层嵌套的IIFE里变量名全是_0xabc123调试器刚进函数就跳到另一个eval里——不是代码难是它根本不想让你看懂。这不是个别案例而是当前主流平台反爬策略演进后的常态静态分析失效、动态调试受阻、人肉逆向成本指数级上升。而这篇内容要讲的正是我在过去三年支撑6个中大型数据采集项目过程中沉淀下来的实战方案——它不叫“JS逆向教程”而是一个以安全辅助为定位、以接口联动为骨架、以JSRpc为神经、以autoDecode为触手的轻量级工程化协作体系。核心关键词是JS逆向、安全辅助、接口联动、JSRpc、BP插件autoDecode。它面向的是已经能写基础爬虫、会用Chrome DevTools、了解基本加密概念但卡在“知道要逆向却不知如何系统化推进”的中级开发者。你可以把它理解成一套“逆向流水线”从流量捕获→上下文还原→JS执行桥接→自动解码→结果归一每个环节都经过生产环境验证不讲原理推导只说“我怎么做的”和“为什么这么选”。2. 安全辅助的本质不是绕过而是构建可信上下文很多人把“安全辅助”等同于“绕过反爬”这是根本性误解。真正有效的安全辅助核心目标从来不是欺骗服务端而是让客户端行为更接近真实用户所处的完整运行时环境。服务端校验的从来不是“你是不是爬虫”而是“你是否具备一个合法浏览器实例应有的全部上下文特征”。比如某电商接口要求X-Client-Signature必须包含navigator.hardwareConcurrency、screen.availWidth、document.referrer三者的哈希组合且该哈希必须在5秒内生成——这背后不是防爬而是防自动化脚本脱离真实浏览器上下文的“失真”。我们做的所有工作都是为了补全这个失真。2.1 为什么必须放弃“纯Python模拟JS”的老路三年前我主导的第一个JS逆向项目采用的是当时主流方案用PyExecJS加载JS文件传入伪造的window对象调用目标函数。表面看跑通了但上线三天后崩溃服务端新增了对performance.memory.totalJSHeapSize的校验而PyExecJS根本不支持performance.memoryAPI一周后又加了navigator.permissions.query({name: notifications})的Promise状态检测PyExecJS连Promise都未完全实现。问题根源在于JS引擎模拟 ≠ 浏览器运行时环境。V8、SpiderMonkey这些引擎本身不提供navigator、screen、performance等Web API它们是浏览器Embedding层注入的。纯Python方案永远在追着浏览器API更新打补丁而现代浏览器每年新增API超200个。提示不要试图用Python“拼凑”一个浏览器环境。那不是辅助是给自己造牢笼。2.2 JSRpc让真实浏览器成为你的协程调度器我们转向JSRpc方案——不是用Python去“执行JS”而是让真实的Chrome实例作为远程计算节点Python仅作任务分发与结果聚合。技术栈选择Chrome DevTools ProtocolCDP而非Selenium原因很实际CDP原生支持Runtime.evaluate可直接在目标页面上下文中执行任意JS代码且能精确控制执行时机如DOM加载完成、XHR拦截后、甚至指定函数调用前。更重要的是CDP暴露的Emulation.setDeviceMetricsOverride、Emulation.setGeolocationOverride等方法能动态覆盖screen、geolocation等敏感API比Selenium的execute_script更底层、更可控。具体实现上我们封装了一个轻量JSRpc Client# jsrcpc/client.py import asyncio from pyppeteer import launch class JSRpcClient: def __init__(self, browser_urlNone): self.browser_url browser_url or http://127.0.0.1:9222 self._browser None self._page None async def connect(self): # 复用已启动的Chrome实例--remote-debugging-port9222 self._browser await launch( headlessFalse, defaultViewport{width: 1920, height: 1080}, args[--no-sandbox, --disable-setuid-sandbox] ) self._page await self._browser.newPage() # 注入全局上下文补丁 await self._page.evaluateOnNewDocument( // 补全缺失的API if (!navigator.permissions) { navigator.permissions { query: () Promise.resolve({state: granted}) }; } if (!performance.memory) { performance.memory { totalJSHeapSize: 123456789, usedJSHeapSize: 87654321, jsHeapSizeLimit: 2147483648 }; } ) async def call(self, js_code: str, timeout10) - dict: try: result await asyncio.wait_for( self._page.evaluate(js_code), timeouttimeout ) return {success: True, data: result} except Exception as e: return {success: False, error: str(e)}这个Client的关键设计点在于所有JS执行都在真实页面上下文中完成且通过evaluateOnNewDocument预置了API补丁。当需要生成签名时不再传入一堆伪造参数而是直接执行# 生成签名的调用示例 js_code // 直接调用页面原有函数无需提取逻辑 window.generateSignature({ timestamp: Date.now(), url: /api/list, params: {category: phone, page: 1} }) result await rpc_client.call(js_code)这样做的好处是服务端任何新增的上下文校验如读取document.hidden、检查window.outerWidth只要页面本身能访问JSRpc就能访问。我们不再维护JS逻辑只维护“如何让JS在正确环境中运行”。2.3 安全辅助的边界意识什么该做什么绝不能碰实践中最大的教训是过度补全反而引发风控。曾有一个金融类项目我们为兼容所有可能的API给navigator注入了mediaDevices、bluetooth、usb等全套Web API。结果上线后触发了设备指纹异常检测——真实手机浏览器根本不会同时暴露bluetooth和usb权限。后来我们调整策略只补全当前接口明确依赖的API且值必须符合设备类型常识。例如移动端补全screen.orientationPC端补全navigator.clipboardhardwareConcurrency值严格按CPU核心数设置8核机器设为8绝不设为16devicePixelRatio根据屏幕分辨率动态计算1920x1080设为1.252560x1440设为1.5。安全辅助不是越“全”越好而是越“准”越稳。3. 接口联动把零散请求编织成有状态的业务流单点逆向解决不了复杂业务。比如某社交平台的“获取用户主页动态列表评论详情”流程三个接口的签名算法完全不同主页用AES-CBC动态列表用RSA公钥加密评论详情用HMAC-SHA256。更麻烦的是动态列表的cursor参数必须从主页响应的next_cursor字段提取而评论详情的post_id又必须从动态列表的item_list[0].id获取。如果还按传统方式逐个逆向、逐个调用不仅效率低而且极易因参数传递错误导致链路中断。3.1 接口联动的核心矛盾状态隔离 vs 业务连续HTTP协议本身是无状态的但业务是有状态的。传统爬虫用Session管理Cookie但这远远不够。真正的状态包括显式状态Cookie、Header中的X-Token、URL中的session_id隐式状态页面DOM中隐藏的input typehidden idcsrf_token、JS变量window.__CSRF_TOKEN__、LocalStorage里的user_profile时序状态某个接口必须在另一个接口成功调用后5分钟内发起否则timestamp校验失败。我们设计的接口联动机制核心是将整个业务流程抽象为一个有向无环图DAG每个节点是一个接口调用边代表参数依赖关系。例如上述社交平台流程的DAG定义{ nodes: [ { id: profile, url: https://api.example.com/v1/user/profile, method: GET, headers: {X-Sign: {{jsrpc:generateProfileSign()}}}, output_map: {csrf_token: data.csrf_token, user_id: data.user.id} }, { id: feed, url: https://api.example.com/v1/feed/list, method: POST, headers: {X-Sign: {{jsrpc:generateFeedSign({{profile.csrf_token}})}}}, body: {user_id: {{profile.user_id}}, cursor: {{profile.next_cursor}}}, output_map: {next_cursor: data.cursor, items: data.items} }, { id: comments, url: https://api.example.com/v1/comments/detail, method: GET, headers: {X-Sign: {{jsrpc:generateCommentSign({{feed.items[0].id}})}}}, params: {post_id: {{feed.items[0].id}}}, output_map: {comments: data.list} } ], edges: [ {from: profile, to: feed, dependency: [csrf_token, next_cursor]}, {from: feed, to: comments, dependency: [items]} ] }3.2 DAG执行引擎如何保证状态精准传递引擎的核心是ContextManager它维护一个全局状态字典并在每个节点执行前自动解析{{xxx.yyy}}语法从上游节点输出中提取值。关键难点在于JSRpc调用的异步性与状态注入的时序控制。我们采用“双阶段执行”准备阶段遍历DAG所有节点收集所有{{jsrpc:xxx()}}表达式批量提交给JSRpc Client执行缓存结果执行阶段按拓扑排序顺序执行节点此时所有{{xxx.yyy}}已被替换为真实值无需再等待JSRpc。# dag/engine.py class DAGExecutor: def __init__(self, rpc_client: JSRpcClient): self.rpc_client rpc_client self.context {} async def execute(self, dag_config: dict): # 阶段一预取所有JSRpc调用 jsrpc_calls [] for node in dag_config[nodes]: for key, value in node.items(): if isinstance(value, str) and {{jsrpc: in value: # 提取函数名和参数{{jsrpc:func(a,b)}} match re.search(r{{jsrpc:(\w)\((.*?)\)}}, value) if match: func_name, args_str match.groups() # 解析参数支持嵌套{{}} args self._parse_args(args_str) jsrpc_calls.append((func_name, args)) # 批量执行JSRpc jsrpc_results await self._batch_jsrpc(jsrpc_calls) # 阶段二执行DAG for node in self._topological_sort(dag_config): # 替换所有{{}}占位符 resolved_node self._resolve_placeholders(node, jsrpc_results) # 发起HTTP请求 response await self._http_request(resolved_node) # 更新context self._update_context(node[id], response.json(), node[output_map])这个设计解决了两个痛点一是避免每个节点都等待JSRpc响应大幅降低总耗时二是将JSRpc调用与HTTP请求解耦便于单独测试和Mock。3.3 实战避坑为什么“自动提取next_cursor”会失败三次在第一个项目中我们假设next_cursor总是JSON响应的data.cursor字段。但实际运行中该字段在70%的响应中存在30%为空。排查发现服务端对高频请求会返回{code: 20001, msg: rate limit}此时cursor不存在。我们最初的错误是在output_map里硬编码next_cursor: data.cursor导致空值被写入context后续节点因cursor为空而失败。修正方案是引入条件映射output_map: { next_cursor: { if: data.code 200, then: data.cursor, else: {{previous_cursor}} } }引擎在解析时先执行if表达式支持简单Python语法再决定取值路径。这个小改动让链路稳定性从65%提升到99.2%。经验是永远不要假设服务端响应结构绝对稳定接口联动必须内置容错分支。4. JSRpc进阶调用从“执行函数”到“接管执行流”基础JSRpc调用page.evaluate()只能执行同步代码但现代前端大量使用Promise、async/await、事件监听。比如某视频平台的播放页关键token生成逻辑绑定在video.load事件回调中且该回调内部又调用fetch获取密钥。若强行用page.evaluate()会因Promise未resolve而返回undefined。4.1 Promise感知如何让Python“等”到JS的异步完成CDP原生支持Runtime.awaitPromise但pyppeteer未封装。我们直接调用CDP协议# jsrcpc/advanced.py async def evaluate_async(page, js_code: str, timeout10) - dict: # 1. 先执行JS获取Promise对象ID result await page._connection.send(Runtime.evaluate, { expression: f({js_code}), returnByValue: False, awaitPromise: True }) if exceptionDetails in result: return {success: False, error: result[exceptionDetails][text]} # 2. 获取Promise的result promise_id result[result][objectId] result_obj await page._connection.send(Runtime.callFunctionOn, { functionDeclaration: function() { return this; }, objectId: promise_id, returnByValue: True, awaitPromise: True }) return {success: True, data: result_obj[result][value]}但更优雅的方案是在JS层统一包装。我们在evaluateOnNewDocument中注入一个全局$rpc对象// 注入的RPC工具 window.$rpc { async call(fnName, ...args) { // 将函数调用包装为Promise return new Promise((resolve, reject) { try { const result window[fnName](...args); // 如果返回Promise自动await if (result typeof result.then function) { result.then(resolve).catch(reject); } else { resolve(result); } } catch (e) { reject(e); } }); } };Python端调用时只需js_code $rpc.call(generateToken, video_id_123) result await evaluate_async(page, js_code)这样无论目标函数是同步还是异步Python都能获得最终结果。我们不再关心JS的执行模型只关注“调用-返回”契约。4.2 事件驱动调用当签名生成依赖用户交互时某教育平台的课程接口签名必须在用户点击“开始学习”按钮后生成且该按钮的点击事件会触发一系列JS操作先校验本地缓存再调用navigator.geolocation.getCurrentPosition()获取位置最后用位置信息时间戳生成签名。这种场景下“执行函数”已失效必须“监听事件”。我们利用CDP的DOM.addEventListener和Runtime.addBinding# 在页面注入事件监听器 await page.evaluateOnNewDocument( // 创建一个全局事件总线 window.$eventBus { listeners: {}, emit(event, data) { if (this.listeners[event]) { this.listeners[event].forEach(cb cb(data)); } }, on(event, callback) { if (!this.listeners[event]) this.listeners[event] []; this.listeners[event].push(callback); } }; // 监听“开始学习”按钮点击 document.addEventListener(click, function(e) { if (e.target.id start-btn) { // 触发自定义事件 window.$eventBus.emit(start_learning_clicked, { timestamp: Date.now(), url: window.location.href }); } }); ) # Python端注册回调 await page.exposeFunction(onStartLearning, lambda data: print(Received:, data)) await page.evaluate(window.$eventBus.on(start_learning_clicked, window.onStartLearning))当用户在页面点击按钮Python会立即收到回调此时再调用JSRpc生成签名。这实现了“人机协同”——用户完成交互程序完成计算。4.3 内存泄漏防护为什么JSRpc连接不能无限复用在高并发场景下我们曾遇到Chrome内存暴涨至8GB后崩溃。根因是每次page.evaluate()都会创建新的JS执行上下文而大量eval、Function构造的匿名函数无法被GC回收。解决方案是强制上下文复用与定期重置每个JSRpc Client绑定一个固定page实例避免频繁新建page每执行100次JSRpc调用后执行page.reload()并重新注入evaluateOnNewDocument补丁对于长时运行的采集任务采用进程池管理多个Chrome实例每个实例处理固定数量请求后退出。注意不要迷信“常驻浏览器”。真实用户会刷新页面我们的JSRpc也该有“呼吸感”。5. BP插件autoDecode让Burp Suite成为你的解码中枢Burp Suite是渗透测试标配但对JS逆向而言它的价值远不止抓包。autoDecode插件的设计初衷是将Burp从“流量观察者”升级为“流量翻译官”。当我们在Proxy中看到一串Base64编码的sign参数传统做法是复制到在线解码网站而autoDecode能在请求发出前自动调用JSRpc生成正确签名并在响应返回后自动解码data字段。5.1 插件架构为什么选择Java而非PythonBurp官方SDK只支持Java和Pythonv2022.10后但Python插件需额外安装依赖且在Burp Pro的Headless模式下不稳定。Java插件可直接打包为JAR零依赖部署。我们采用Maven构建核心模块DecoderService管理JSRpc Client连接池RequestModifier拦截请求识别{{jsrpc:xxx}}占位符调用JSRpc生成值ResponseDecoder拦截响应根据Content-Type和自定义Header如X-AutoDecode: true触发解码。关键代码片段Java// RequestModifier.java public IHttpRequestResponse processHttpMessage( IHttpRequestResponse baseRequestResponse, boolean isRequest, IInterceptedProxyMessage message) { if (isRequest) { byte[] request baseRequestResponse.getRequest(); String requestStr helpers.bytesToString(request); // 查找{{jsrpc:xxx}}模式 Pattern pattern Pattern.compile(\\{\\{jsrpc:(.*?)\\}\\}); Matcher matcher pattern.matcher(requestStr); StringBuffer sb new StringBuffer(); while (matcher.find()) { String jsCode matcher.group(1); // 调用JSRpc Client通过RPC调用Python服务 String result rpcClient.call(jsCode); matcher.appendReplacement(sb, result); } matcher.appendTail(sb); byte[] newRequest helpers.stringToBytes(sb.toString()); baseRequestResponse.setRequest(newRequest); } return baseRequestResponse; }5.2 autoDecode的智能解码策略不只是Base64很多初学者以为autoDecode就是“Base64解码”实际上它支持多层嵌套解码。例如某物流平台的响应体{ code: 0, data: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c }其中data是JWT而JWT的payload部分又是AES加密的JSON。autoDecode配置支持链式解码{ rules: [ { field: data, decoders: [jwt, aes], aes_key: 0123456789abcdef0123456789abcdef, aes_iv: abcdef0123456789 } ] }插件会先用JWT库解析再用AES解密payload最终在Burp的Response tab中显示明文JSON。这极大提升了人工分析效率——你不再需要在多个工具间切换所有解码逻辑在Burp内闭环。5.3 生产环境血泪教训插件必须自带熔断机制在一次大促期间autoDecode插件因JSRpc服务临时不可用导致Burp Proxy所有请求卡死因为processHttpMessage是同步阻塞的。我们紧急加入熔断设置JSRpc调用超时为3秒连续3次超时后自动降级为“直通模式”不修改请求同时在Burp UI右下角显示红色告警“JSRpc offline, fallback to passthrough”。这个熔断逻辑用Java的CircuitBreaker库实现确保插件故障不影响Burp核心功能。经验是任何外部依赖都必须有降级预案安全辅助工具本身不能成为单点故障源。6. 从“能用”到“好用”工程化落地的四个关键实践这套体系在6个项目中落地从最初的手动配置到现在的开箱即用沉淀出四条非技术但至关重要的实践6.1 文档即代码用YAML定义一切可配置项我们废弃了所有硬编码的JSRpc地址、DAG流程、解码规则全部迁移到YAML配置。例如config.yamljsrpc: host: 127.0.0.1 port: 9222 timeout: 15 dag: - name: login_flow file: dags/login.yaml - name: data_fetch file: dags/fetch.yaml auto_decode: rules_file: rules/logistics.yaml enable: true所有Python模块启动时首先加载此配置。好处是运维人员无需改代码只需编辑YAML即可调整流程新成员看配置就能理解整个系统脉络版本管理时YAML变更比代码变更更易审查。6.2 日志即证据每一步操作都留痕可追溯在JSRpcClient.call()中我们记录完整日志logger.info(f[JSRpc] Call: {js_code[:50]}... | Result: {result.get(data, ERROR)[:100]} | Time: {elapsed:.2f}s)在DAG执行中记录每个节点的输入、输出、耗时logger.debug(f[DAG] Node {node_id} input: {input_data} | output: {output_data} | duration: {duration:.2f}s)当线上出现问题我们不再问“哪里错了”而是直接查日志“第3步的feed.items为空看第2步的响应体是什么”。日志不是为了监控而是为了重建现场。6.3 测试即准入每个DAG流程必须有黄金样本我们为每个DAG流程维护一个golden_sample.json包含原始请求含所有Headers、Body真实浏览器抓包的响应期望的最终解码结果。每日CI运行时自动用当前代码执行DAG比对输出是否与黄金样本一致。不一致则立即失败。这保证了任何JSRpc或DAG逻辑的修改都不会意外破坏已有流程。曾有一次我们升级Chrome版本后performance.memory返回值格式变化从整数变为字符串黄金样本测试立刻捕获避免了线上事故。6.4 团队即接口定义清晰的协作契约最成功的项目不是技术最炫的而是团队协作最顺畅的。我们强制约定前端同学提供debug.js一个独立JS文件暴露所有签名生成函数且函数名与文档一致如window.generateListSign后端同学提供api_spec.json描述每个接口的参数、Header、响应结构、校验逻辑安全同学提供fingerprint_rules.txt列出所有被校验的浏览器API及合理取值范围。当这三份文档齐备JSRpc和DAG的开发就变成了填空题。技术可以学但跨角色的契约意识才是工程化落地的真正护城河。我在实际使用中发现这套体系最大的价值不是节省了多少逆向时间而是把不确定性转化为确定性。当新接口上线我不再焦虑“这次又是什么新花样”而是打开config.yaml新增一个DAG节点写两行YAML然后喝杯咖啡等测试结果。逆向不再是玄学而是一门可重复、可验证、可传承的手艺。