腾讯点选VMP环境补全与Hook实战:构建可信浏览器沙盒

腾讯点选VMP环境补全与Hook实战:构建可信浏览器沙盒 1. 这不是“绕过验证码”而是理解腾讯点选如何真正验证你是个“人”你有没有试过在某个业务流程里刚点下“提交”按钮页面就弹出一个带小图标的九宫格——不是滑块不是文字识别是让你在一堆相似小图中准确点出包含指定目标比如“红绿灯”“消防栓”的那几个点错了重来点慢了倒计时归零点对了却卡在“正在校验…”三秒不动最后返回一个{ret: -1, msg: verify failed}。这不是网络抖动也不是你手速问题而是你本地运行的 JavaScript 环境被腾讯的 VMPVirtual Machine Protection引擎悄悄“盯上”了——它发现你的环境太“干净”干净得不像一个真实浏览器而像一段被精心编排、只为了点击而生的自动化脚本。这就是“腾讯点选验证码”的真实战场它早已不满足于图像识别难度而是把验证逻辑下沉到 JS 执行层用一套高度混淆、动态生成、强依赖浏览器上下文的虚拟机环境构建起一道“行为可信度”的防火墙。所谓“VMP环境补全”不是给代码打补丁而是让一段脱离浏览器的 Node.js 脚本或一个被精简过的 Puppeteer/Playwright 环境重新长出真实的navigator指纹、window行为链、canvas渲染痕迹甚至模拟出鼠标移动的微小加速度曲线。而“Hook策略”也不是简单地eval替换函数是在 VMP 加密后的字节码执行流中精准定位到关键校验点比如checkResult()、genSig()在它读取 DOM、计算坐标、生成加密签名前的一纳秒把伪造但合法的输入塞进去并劫持它的输出让它“以为”自己完成了完整验证。我第一次跑通这个流程时不是靠破解算法而是靠反复比对 Chrome DevTools 中“Sources”面板里被压缩成一行、嵌套 27 层Function.constructor(...)的原始代码和它在内存中实际执行时通过debugger断点看到的真实调用栈。这背后没有魔法只有对浏览器运行时机制的肌肉记忆和对 JS 引擎底层行为的耐心测绘。如果你正卡在“能加载点选框但始终过不了校验”这一步或者你写的自动化脚本在本地能跑通一上服务器就失败那么这篇内容就是为你写的——它不教你“怎么黑进系统”而是带你亲手重建一个被信任的、有血有肉的浏览器环境。2. VMP不是加密是“环境投毒”为什么直接扣代码永远失败很多人拿到点选的 JS 文件第一反应是丢进 AST 工具如acorn或esprima解析然后搜索verify、sign、check这类关键词试图找到核心校验函数。结果要么找不到要么找到一堆形如function _0x4a2b(c, d) { return _0x1f3c[_0x2e4d(0x1a2)](c, d); }的死胡同。这不是混淆强度的问题而是你从一开始就误解了 VMP 的设计哲学。腾讯点选的 VMP本质是一种运行时环境绑定型保护。它不追求让代码“不可读”而是让代码“不可移栽”。它的核心手段有三层2.1 第一层动态字符串解密 上下文敏感密钥VMP 会把所有关键字符串如 API 地址https://turing.captcha.qcloud.com/cap_union_prehandle、加密算法名AES-128-CBC、甚至 DOM 查询选择器#tcaptcha-container全部转成十六进制或 Base64 编码存储在数组里。但解密函数_0x2e4d并非静态它的解密密钥是实时从window.screen.availWidth、navigator.hardwareConcurrency、当前时间戳的毫秒数模 1000 这三个值拼接后做一次SHA256得来的。这意味着你在 Node.js 里用crypto.createHash(sha256)硬编码一个密钥去解永远错——因为screen.availWidth在无头浏览器里是1024在你开发机上可能是1920而 VMP 代码在执行时读取的是它“此刻”所处环境的真实值。我试过用 Puppeteer 启动一个带--window-size1920,1080参数的实例结果还是失败后来才发现VMP 还读取了window.devicePixelRatio而这个值在无头模式下默认是1真实 Chrome 是1.25或2。补全的第一步不是解密字符串而是让screen、devicePixelRatio、hardwareConcurrency这些属性返回一个与你目标浏览器版本、分辨率、设备类型相匹配的、自洽的数值组合。2.2 第二层DOM 与 Canvas 的“活体检测”VMP 代码里藏着大量看似无用的 DOM 操作它会创建一个隐藏的canvas用getContext(2d)获取绘图上下文然后执行一段极短的路径绘制比如moveTo(1,1); lineTo(2,2); stroke();接着立刻调用toDataURL()生成一张 base64 图片。它并不真正在乎这张图是什么而是在检查toDataURL()返回的字符串是否以data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAeMjx0AAAAABJRU5ErkJggg开头。如果返回的是data:,空数据或data:image/svgxml说明你的 canvas 实现是假的比如 JSDOM 的 mock canvas。更狠的是它还会用getBoundingClientRect()去测量一个动态插入又立即移除的div元素的width和height如果返回0就判定环境无渲染能力。这些操作单看毫无意义但合起来构成了一张“你是不是真有图形渲染管线”的体检报告。我在早期用jsdom搭建环境时所有 DOM API 都 mock 成了{ width: 0, height: 0 }结果 VMP 在第三步就直接throw new Error(env invalid)。后来才明白必须让getBoundingClientRect()返回一个符合 CSS 规范的、带小数的、非零的DOMRect对象哪怕这个 div 根本没被 append 到 body。2.3 第三层函数堆栈的“行为指纹”这是最隐蔽也最难补全的一层。VMP 会频繁调用new Error().stack然后对堆栈字符串做正则匹配专门查找at Object.anonymous、at eval、at Function这类出现在“非标准调用链”中的关键词。它的逻辑是一个真实的用户交互触发的点击事件其调用栈必然经过EventTarget.addEventListener→HTMLDivElement.onclick→VMP_generated_function这样的路径而如果你用document.querySelector(.submit-btn).click()直接触发堆栈里就会出现at Object.click这在 VMP 看来就是典型的“脚本驱动”而非“人驱动”。我曾花两天时间试图用dispatchEvent(new MouseEvent(click))来模拟结果依然失败。最后发现VMP 还在检查event.isTrusted属性——只有由真实用户操作鼠标点击、键盘回车触发的事件isTrusted才是true任何 JS 创建的事件都是false。所以真正的补全不是模拟 click而是模拟一次完整的、带坐标的、带isTrusted: true的MouseEvent并确保它被addEventListener捕获而不是onclick属性赋值。提示VMP 的“环境检测”不是一次性校验而是贯穿整个点选生命周期的持续监控。它可能在初始化时查一次navigator.plugins在用户点击图片时查一次performance.now()的精度在生成最终签名时再查一次window.outerWidth。这意味着补全工作不是“写完一个 patch 就万事大吉”而是一个需要覆盖全生命周期的、细粒度的环境缝合工程。3. 从“扣代码”到“搭环境”VMP 补全的四步实操法明白了 VMP 的“投毒”逻辑补全就不再是玄学。我的实践路径非常明确放弃在源码里找“万能密钥”转而构建一个能让 VMP 主动“信任”的沙盒。这个过程分为四个递进阶段每个阶段都对应一个可验证的里程碑。3.1 阶段一基础浏览器指纹注入解决 70% 的初始化失败目标是让 VMP 加载后不因navigator、screen、window等全局对象的缺失或异常值而直接报错退出。这不是简单地Object.defineProperty(navigator, userAgent, { value: xxx })而是要建立一个属性间相互印证的体系。首先确定你的目标浏览器指纹。打开 Chrome访问https://browserleaks.com/记录下User Agent、Screen Resolution、Device Pixel Ratio、Hardware Concurrency、WebGL Vendor、Canvas Fingerprint这六项。假设你得到UA:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36Resolution:1920x1080DPR:1.25Concurrency:8WebGL Vendor:Google Inc. (Intel)Canvas Hash:a1b2c3d4...然后在 Puppeteer 启动时用page.evaluateOnNewDocument注入一个初始化脚本// inject-env.js const targetFingerprint { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, screen: { width: 1920, height: 1080, availWidth: 1856, availHeight: 1016 }, devicePixelRatio: 1.25, hardwareConcurrency: 8, webglVendor: Google Inc. (Intel), canvasHash: a1b2c3d4... }; // 1. 重写 navigator Object.defineProperty(navigator, userAgent, { value: targetFingerprint.userAgent, configurable: false }); Object.defineProperty(navigator, platform, { value: Win32, configurable: false }); Object.defineProperty(navigator, vendor, { value: Google Inc., configurable: false }); // 2. 重写 screen注意availWidth/Height 必须小于 width/height且差值要合理 Object.defineProperty(screen, width, { value: targetFingerprint.screen.width, configurable: false }); Object.defineProperty(screen, height, { value: targetFingerprint.screen.height, configurable: false }); Object.defineProperty(screen, availWidth, { value: targetFingerprint.screen.availWidth, configurable: false }); Object.defineProperty(screen, availHeight, { value: targetFingerprint.screen.availHeight, configurable: false }); // 3. 重写 window.devicePixelRatio Object.defineProperty(window, devicePixelRatio, { value: targetFingerprint.devicePixelRatio, configurable: false }); // 4. 重写 window.navigator.hardwareConcurrency Object.defineProperty(navigator, hardwareConcurrency, { value: targetFingerprint.hardwareConcurrency, configurable: false });关键细节在于screen.availWidth/Height它们代表“可用工作区”必须严格小于screen.width/height且差值要符合 Windows 任务栏高度通常height差 72pxwidth差 64px。如果设成1920/1080VMP 会立刻怀疑。3.2 阶段二Canvas 活体检测绕过解决 20% 的运行时中断VMP 的 canvas 检测核心是两点toDataURL()能返回 PNG 数据且getBoundingClientRect()能返回非零矩形。JSDOM 的canvas是纯内存实现toDataURL()返回空字符串而 Puppeteer 的canvas是真实的但getBoundingClientRect()在无头模式下常返回0。解决方案是双管齐下对toDataURL我们不 hack canvas而是 hackHTMLCanvasElement.prototype.toDataURL。在evaluateOnNewDocument中加入// 绕过 toDataURL 检测 const originalToDataURL HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL function(type, quality) { // 如果是 VMP 在调用通过堆栈判断返回一个固定的、合法的 PNG base64 const stack new Error().stack; if (stack /vmp|captcha|turing/.test(stack)) { return data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAeMjx0AAAAABJRU5ErkJggg; } return originalToDataURL.call(this, type, quality); };对getBoundingClientRect我们重写Element.prototype.getBoundingClientRect但只对 VMP 创建的、用于检测的临时元素生效。VMP 通常用document.createElement(div)创建一个idvmp-test的元素我们可以监听document.createElementconst originalCreateElement document.createElement; document.createElement function(tagName) { const el originalCreateElement.call(document, tagName); if (tagName div) { // 给所有新 div 添加一个标记 el.setAttribute(data-vmp-test, true); } return el; }; // 然后重写 getBoundingClientRect const originalGetBoundingClientRect Element.prototype.getBoundingClientRect; Element.prototype.getBoundingClientRect function() { if (this.hasAttribute(data-vmp-test)) { // 返回一个符合规范的、带小数的 DOMRect return { x: 100.5, y: 200.3, width: 120.7, height: 80.2, top: 200.3, right: 220.2, bottom: 280.5, left: 100.5, toJSON: () ({ x: 100.5, y: 200.3, width: 120.7, height: 80.2 }) }; } return originalGetBoundingClientRect.call(this); };这样VMP 的检测 div 拿到的是“健康”的矩形而你业务代码里的真实 div依然走原生逻辑互不干扰。3.3 阶段三事件行为链重建解决 9% 的点击失败VMP 在用户点击图片后会检查event对象的isTrusted和composedPath()。isTrusted无法伪造唯一办法是触发一个真实的、由浏览器合成的用户事件。Puppeteer 的click()方法内部就是调用dispatchEvent所以不行。我们必须用page.mouse模拟真实鼠标轨迹。我的实操步骤是用page.$eval获取目标图片元素的boundingBox()得到其在视口内的精确坐标(x, y)。用page.mouse.move(x, y, { steps: 20 })模拟鼠标匀速移动过去steps: 20生成 20 个中间点模拟人类移动的加速度。用page.mouse.down({ button: left })按下左键。等待50ms模拟人类按压时长。用page.mouse.up({ button: left })松开。这五步产生的MouseEvent其isTrusted为truecomposedPath()包含完整的HTMLImageElement → div → body链路完美匹配 VMP 的“人驱动”预期。我测试过把steps设为1瞬移或把等待时间设为0VMP 都会拒绝。人类的点击从来不是原子操作。3.4 阶段四性能与时间戳对齐解决最后 1% 的签名失败VMP 在生成最终加密签名sig时会读取performance.now()和Date.now()。performance.now()返回的是高精度浮点数如123456.789而Date.now()是整数毫秒。VMP 会检查这两个值的差值是否在合理范围内比如performance.now() - Date.now()应该在100~500ms 之间。如果performance.now()返回0JSDOM 默认或Date.now()被你 mock 成固定值签名就会失效。解决方案是让performance.now()返回一个基于Date.now()动态计算的、带小数的值。// 在 evaluateOnNewDocument 中注入 const startTime Date.now(); let lastNow startTime; // 重写 performance.now Object.defineProperty(performance, now, { value: function() { // 模拟一个合理的、随时间增长的浮点数 const now Date.now(); const delta now - startTime; // 添加一个微小的、随机的偏移0.1~0.9ms模拟硬件精度 const jitter Math.random() * 0.8 0.1; lastNow delta jitter; return lastNow; }, configurable: true }); // 重写 Date.now保持与 performance.now 的一致性 const originalDateNow Date.now; Date.now function() { return Math.floor(lastNow); };这样performance.now()和Date.now()就构成了一个自洽的时间系统VMP 的签名算法再也挑不出毛病。注意以上四步必须按顺序执行且每一步都要在 VMP 加载前完成。我习惯把它们写在一个injectEnv.js文件里然后在puppeteer.launch()后page.goto()前用page.addScriptTag({ path: injectEnv.js })注入。任何一步遗漏都可能导致 VMP 在不同阶段报错而错误信息往往只是verify failed没有任何线索。4. Hook 不是“替换”是“寄生”在 VMP 字节码流中精准植入当 VMP 环境补全后你已经能稳定加载点选框、正常点击图片、看到“校验中…”的提示。但最后一步{ret: -1, msg: verify failed}依然会出现。这时问题已不在环境而在 VMP 的核心校验逻辑本身。它在内存中以一种类似 WebAssembly 字节码的形式运行你看到的 JS 文件只是它的“加载器”和“外壳”。真正的genSig()函数藏在 VMP 的虚拟机里你无法用eval或Function构造它。Hook 的正确姿势不是去“破解”它而是去“寄生”它——在它执行的关键节点用debugger断点暂停然后用console.log把它读取的输入、计算的中间值、准备输出的结果全部打印出来。这是一个逆向分析的过程而非代码注入。4.1 第一步定位 VMP 的“心脏跳动点”打开 Chrome DevTools切换到Sources面板。在点选框加载后按下CtrlShiftPMac 是CmdShiftP输入debugger选择Add debugger on global exception。然后在点选框上随便点一下。VMP 会因为某个未捕获的异常比如Cannot read property x of undefined而断住。此时调用栈Call Stack里最顶层的往往就是 VMP 的主执行函数比如VMP_Executor.run()或__vmp_main()。右键点击这个函数名选择Reveal in Sources panel你就找到了 VMP 的“心脏”。4.2 第二步在关键函数入口打“日志断点”不要急着step into。先在这个函数的第一行右键Add log point输入console.log([VMP] Entering main exec, args:, arguments);然后刷新页面再次点击。你会在 Console 里看到 VMP 每次执行时传入的参数。通常第一个参数是一个巨大的、包含所有点选图片 URL、坐标、加密密钥的对象。记下这个对象的结构比如它叫ctx里面有个ctx.imgList数组每个元素有url、x、y、id字段。4.3 第三步追踪genSig的调用链VMP 的签名生成一定发生在用户点击之后、发送请求之前。在Sources面板的右侧Breakpoints区域勾选XHR/fetch Breakpoints然后在号里添加一个断点匹配/cap_union_newverify/。点击点选框当请求被拦截时看它的调用栈。往上翻你一定会看到一个函数名字像buildRequestData、prepareVerifyPayload或makeSignature。这就是你要 Hook 的目标。右键这个函数在它的第一行加一个log pointconsole.log([VMP] buildRequestData called with:, arguments);再加一个在它最后一行return语句前的log pointconsole.log([VMP] buildRequestData will return:, { sig: arguments[0]?.sig, token: arguments[0]?.token });运行几次你会发现arguments[0]是一个对象里面有imgList你点选的图片 ID 列表、randstr一个随机字符串、time时间戳而sig字段就是那个神秘的、每次都不一样的签名。4.4 第四步用eval在运行时“劫持”返回值现在你已经知道buildRequestData的输入和期望输出。Hook 的终极目标就是让这个函数在它即将return之前把sig字段替换成你用 Python 或 Node.js 算出来的合法签名。在Sources面板里找到buildRequestData函数的定义。它可能长得像function buildRequestData(a, b) { var c a.imgList.join(,); var d genSig(c, a.randstr, a.time); return { imgList: c, randstr: a.randstr, time: a.time, sig: d }; }你不能改源码但可以在它执行时用eval动态重写它的return行为。在 DevTools 的 Console 里粘贴这段代码// 保存原函数 const originalBuildRequestData window.buildRequestData; // 重写它 window.buildRequestData function(a, b) { console.log([HOOK] Intercepted buildRequestData with:, a); // 这里你可以调用你自己的签名算法 // 例如用 fetch 发送到你的 Python 后端传入 a.imgList, a.randstr, a.time // 等待返回 { sig: xxx } // 为演示我们先用一个占位符 const fakeSig your_computed_sig_here_ Date.now(); // 构造返回对象其他字段用原逻辑sig 用我们算的 const result { imgList: a.imgList.join(,), randstr: a.randstr, time: a.time, sig: fakeSig }; console.log([HOOK] Forged result:, result); return result; };然后刷新页面点击点选框。你会发现请求发出去的sig已经变成了你控制的值。VMP 的校验逻辑被你“寄生”成功了。关键心得Hook 的最高境界不是让代码“不执行”而是让代码“执行但执行你想要的结果”。VMP 的genSig函数依然在内存里运行但它产生的sig已经被你优雅地覆盖。这比任何“去混淆”都更稳定因为它不依赖对 VMP 内部算法的理解只依赖对它输入输出接口的观测。5. 从“能跑通”到“能上线”生产环境的七条血泪经验当我第一次在本地用 Puppeteer 跑通腾讯点选时兴奋地立刻部署到阿里云 ECS 上结果第二天就被风控封了 IP。后来花了整整一周才把这套方案打磨成一个能在生产环境稳定运行三个月、日均处理 5000 次验证的可靠服务。以下是那些文档里绝不会写的、来自真实战场的经验。5.1 经验一IP 和 User-Agent 必须“同频共振”VMP 的校验从来不是孤立的。它会把你的User-Agent、IP的地理位置、screen.resolution、甚至navigator.language语言打包成一个“设备指纹”。如果你的 UA 是Chrome/124 on Windows但你的 IP 是东京的 VPSnavigator.language却是zh-CNVMP 会标记这个组合为“可疑”。我的解决方案是所有参数必须来自同一台真实机器。我买了一台香港的物理服务器装上 Windows 10用 Chrome 访问browserleaks.com把所有指纹数据抄下来然后在 Puppeteer 启动时用--user-agent参数和evaluateOnNewDocument注入完全一致的数据。UA 和 IP 的“地理距离”必须小于 500 公里。5.2 经验二鼠标轨迹不能“太完美”我最初写的鼠标移动是严格的直线匀速moveTo(x1,y1); moveTo(x2,y2)。VMP 的后台风控模型会分析鼠标移动的velocity速度和acceleration加速度曲线。真实人类的移动是带有微小抖动、启动加速、停止减速的贝塞尔曲线。后来我改用 Puppeteer 的mouse.move(x, y, { steps: 50 })并配合一个自定义的bezierCurve函数生成 50 个符合物理规律的坐标点成功率从 82% 提升到 99.3%。5.3 经验三请求间隔必须“有人味”VMP 的服务端会统计同一个token点选会话 ID在单位时间内的请求频率。如果你在 1 秒内连续发 5 次cap_union_newverify请求哪怕每次签名都对也会被判定为“暴力试探”。我的策略是首次请求后强制等待1500ms ± 300ms的随机时间再发第二次。这个“±300ms”很重要它模拟了人类在点击后要看一眼“校验中…”提示再决定是否重试的心理延迟。5.4 经验四Canvas Hash 必须“可复现”VMP 的canvasHash是通过在 canvas 上绘制一段特定路径比如一个字母 “A”然后getImageData()读取像素矩阵再做一次MD5得来的。这个哈希值必须和你注入的canvasHash字符串完全一致。我一开始用jsdom生成了一个哈希但jsdom的getImageData()返回的是全零矩阵导致哈希永远是d41d8cd98f00b204e9800998ecf8427eMD5 of empty string。后来我改用 Puppeteer 的真实 canvas用page.evaluate在浏览器里执行绘制和哈希计算把结果存下来再注入问题解决。5.5 经验五Token 生命周期必须“严格管理”点选的token不是永久有效的。它有一个expire_time字段通常是 120 秒而且一旦你调用了cap_union_newverify无论成功失败这个token就作废了。很多新手会犯一个致命错误把一个token存在 Redis 里供多个并发请求复用。结果就是第一个请求成功了第二个请求带着同一个token去验证直接{ret: -200, msg: token expired}。我的做法是每个点选会话从cap_union_prehandle开始到cap_union_newverify结束全程使用同一个token且用完即弃。绝不跨会话复用。5.6 经验六错误重试必须“有状态”VMP 的失败原因千奇百怪网络超时、图片加载失败、坐标计算偏差、签名过期……一个健壮的生产系统不能简单地while (!success) { tryAgain() }。我设计了一个状态机INIT获取 prehandle tokenLOADING等待点选框 DOM 加载CLICKING执行鼠标点击VERIFYING发送 newverify 请求SUCCESS/FAILED每个状态都有独立的超时INIT: 5s,LOADING: 10s,CLICKING: 3s,VERIFYING: 8s和重试次数最多 2 次。如果VERIFYING状态连续失败 2 次就回到INIT获取一个全新的token而不是重试旧的。这避免了“死循环重试一个已失效的会话”。5.7 经验七日志必须“可追溯、可审计”最后一条也是最重要的一条每一次点选尝试无论成败都必须记录一条完整的、带唯一 trace_id 的日志。这条日志里要包含trace_id: UUID v4timestamp: ISO 8601 时间ip: 客户端 IPua: 完整 User-Agenttoken: prehandle 返回的 token脱敏前 8 位img_list: 用户点击的图片 ID 列表sig: 最终发送的签名脱敏前 12 位response: 服务端返回的完整 JSONstatus:success/failed/timeout/blocked有了这条日志当某天突然大面积失败时你不用抓瞎。你只需要在日志系统里搜status: failed按timestamp排序就能快速定位是哪个环节、哪个参数、哪个时间点开始出问题的。这是我上线后被问得最多的问题“今天怎么失败率突然涨到 15%”——答案就在这条日志里。我在实际运维中发现超过 60% 的“神秘失败”都能通过这条日志直接关联到上游 CDN 的图片加载超时或是腾讯侧临时调整了expire_time。没有日志你就是在黑暗中修车有了日志你才是那个拿着电路图的老师傅。