ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

WASM验证码逆向分析:从字节码到风控对抗

2026/9/16 10:41:39 拓冰建站 浏览量
WASM验证码逆向分析:从字节码到风控对抗 从第一次看到 PerimeterX 的按压验证码到真正把它背后的 WASM 模块拆开我大概花了两天时间。不是因为这个验证码本身有多难而是它把整个分析思路逼到了一个很别扭的位置你明知道核心逻辑就在那段.wasm里但浏览器不会给你任何方便面。传统 JS 混淆还能用格式化工具直接看个大概WASM 这层东西更像是一个只开放了输入输出接口的加密黑盒你必须在字节码的夹缝中一点点找回逻辑。这篇文章不是讲怎么绕过验证码而是从安全评估和风控对抗的角度梳理一套可复现的 WASM 分析流程帮助理解 PerimeterX 按压验证码的防护强度到底在哪以及它为什么能让大多数自动化脚本彻底失效。我平时的工作重点偏向风控对抗和反作弊所以拿到某个站点用 PerimeterX 防护时第一反应不是怎么打过去而是它到底把什么东西藏进了 WASM。这种分析需求其实很常见你需要判断现有验证码方案是否足够强或者验证自己的风控逻辑会不会被低成本自动化穿透。下面我把自己真正走通的分析路径、工具链、还有踩过的坑完整写出来希望对做安全研究、爬虫对抗或验证码评估的同行有帮助。1. WASM 成为验证码新阵地的原因1.1 传统 JS 验证码为什么越来越不顶用前几年大多数风控验证码走的是JS 加密 行为数据采集的路子比如把用户鼠标轨迹、点击坐标、时间戳汇总成一个 JSON再通过一段混得乱七八糟的 JS 扔给服务端验证。问题在于JS 再混淆终究是文本语言浏览器要执行它就必须把它翻译成可直接读取的指令。攻击者只需要打开 DevTools找到 Script 里的关键函数打断点、看作用域、hook 掉核心方法就能把整个逻辑剥出来。更致命的是JS 的混淆往往只做字符串加密和变量名替换函数边界还在、调用关系还在。只要定位到采集轨迹和发起请求的那个函数直接改写返回结果就能骗过服务端。所以你会发现很多用纯 JS 做的滑块验证码本质上只是给了攻击者一个逆向难度费点时间的错觉一旦被针对性分析防护基本形同虚设。1.2 WASM 给攻击者设置的三道门槛WASM 被验证码产品看中不是因为性能而是因为它在可读性方面天然碾压 JS。第一道门槛是二进制形态。WASM 加载到浏览器后DevTools 里看到的是一堆字节码即使你有 Source Map生产环境也不会轻易放出来。就算用wasm2wat转成文本看到的也是local.get、i32.add这种栈式指令没有任何变量名和注释可读性比汇编好不了太多。第二道门槛是栈式虚拟机模型。WASM 不像 x86 那样有寄存器可以来回倒腾数据它的中间结果全部压在一个隐形的栈上。这意味着单看一条指令根本没有意义你得把一连续的操作串起来才能推断它在算什么。函数参数、返回值、局部变量相互穿插阅读负担直接上升一个量级。第三道门槛是受限类型系统。WASM 里只有i32、i64、f32、f64等基础类型没有字符串、对象、数组。所有高级数据结构都被拍平为线性内存中的一段字节读写全靠偏移量。如果你拿到的模块里有一段 JSON它在内存里就是一堆地址和长度你需要先通过函数逻辑反推出结构体布局才能看懂它在解析什么。这三道门槛把大部分只会用现成爬虫框架的人挡在外面但并不意味着它是不可分析的。它设计的目标是提高分析成本而不是做到理论上不可逆。2. 按压验证码的运行链路与 WASM 介入点2.1 从页面加载到挑战下发理解 WASM 在验证码里的位置先从整个链路看起。用户访问受保护站点时PerimeteX 的 SDK 会先执行一段很轻的 JavaScript采集基本的浏览器指纹UA、Canvas、WebGL、时区、语言、WebRTC 网络信息等。这段采集结果会被叠加一个风险评分如果服务端觉得当前会话存在可疑痕迹就返回一个需要按压验证的挑战指令。然后前端动态创建一个按压按钮同时从服务端拿到一个无法直接预测的 challengeId 和随机种子。这个挑战页面本身不是一个独立的 HTML而是由 SDK 通过 DOM API 动态生成的目的是防止攻击者直接查看静态页面里的验证逻辑。2.2 用户按压动作如何被翻译成 WASM 输入关键的 WASM 模块在挑战下发时就已经加载进浏览器了只不过 UI 层在等待用户交互。当用户真正按下按钮SDK 会收集一系列事件数据按下时的坐标、鼠标/手指沿轨迹的采样点、每个采样点的时间间隔、压力值、接触面积。这些数据先由 JS 做一层预处理比如坐标归一化、轨迹分段、时间戳差值计算然后填充进一个连续的内存缓冲区。接着 SDK 调用 WASM 导出函数把缓冲区的地址和长度传进去。WASM 内部会对输入数据做更细粒度的特征提取再拼接上 challengeId、随机种子、固定魔数走一遍加密或签名流程最后生成一段 token 返回给 JS。这个 token 通常是一个看似随机的字符串它本质上是一个防篡改凭证服务端会通过验签来判断这个按压行为是不是真实用户产生的。2.3 返回 Token 的校验方式浏览器拿到 token 后通过一个独立的验证接口提交。服务端的校验逻辑是本次会话绑定的challengeId 在挑战下发时生成token 里包含的指纹摘要必须与初始采集的设备信息一致签名必须通过密钥验证时间戳必须在有效窗口内。正因为 WASM 参与了 token 的计算攻击者哪怕拿到了完整 JS也无法直接伪造出一个可用的 token。所以从防御视角看WASM 真正的作用不是让验证码无法逆向而是把核心签名逻辑和一体化指纹模型从 JS 挪到了一个更难提取的地方。分析这个模块就是为了搞清楚它内部到底用了哪些输入、哪些算法、以及是否存在可以被低成本篡改的漏洞。3. 构建 WASM 分析环境与工具链3.1 浏览器端捕获 WASM 模块第一步永远是拿到.wasm文件。打开目标站点触发按压验证码然后按 F12 切到 DevTools 的 Sources 面板在左侧树里找到 WebAssembly 目录通常能看到当前页面加载的 WASM 模块。模块名可能是哈希或者一长串随机字符串右键点击选Save或者直接在 Network 面板过滤mimeType: application/wasm也能抓到。如果页面加载即出发但你想再看一次验证码可以刷新页面然后在 Network 里找那个.wasm请求右键复制链接用curl下载到本地。还有一个小技巧直接在 Console 里执行WebAssembly.instantiateStreaming的 Promise 钩子能拿到模块实例但如果 SDK 已经执行过实例化你无法再从全局访问它。所以更稳妥的是在浏览器加载早期通过Performance录制或者在 Network 里拦截下载确保拿到的是原始字节码文件。3.2 静态反编译利器wabt 与 wasm-decompile拿到原始文件后的第一步是转成可读文本。这里我用的是 wabt 工具包wasm2wat challenge.wasm -o challenge.wat wasm-decompile challenge.wasm -o challenge.dcmpwasm2wat转出来的是标准 WAT 文本指令全适合精读。wasm-decompile会把 WASM 转成类似 C 代码的伪代码函数名保留为f1、f2这种编号局部变量名变成var0、var1整体可读性比 WAT 高很多适合快速浏览模块结构。我自己的习惯是先用wasm-decompile过一遍搞清楚模块里大致有哪些函数、哪些被导出、哪些被导入再针对核心函数用wasm2wat精读指令。wasm-objdump -x challenge.wasm这个命令会打印模块的各段信息包括导入导出表、全局变量、内存大小、数据类型段是所有分析的起点。3.3 动态分析Frida 与 DevTools 的 Hook 切入点静态分析只能看到骨架动态分析才能看到血肉。模块加载后我通常会先用 Frida 脚本 hook 住WebAssembly.instantiate和WebAssembly.Instance在实例生成时把导出函数和内存地址记录下来const originalInstantiate WebAssembly.instantiate; WebAssembly.instantiate function (buffer, imports) { console.log([*] WebAssembly.instantiate called, imports:, imports); return originalInstantiate(buffer, imports).then(instance { console.log([*] exports:, Object.keys(instance.instance.exports)); return instance; }); };这样做的目的是确定模块对外暴露了哪些函数。很多风控 WASM 模块的导出函数名会被混淆例如_0x12ab、f3之类但导入函数往往保留了一些线索比如Math.random、Date.now这种来自 JS 宿主环境的函数。只要看到导入表里有这些就能推测模块内部需要使用随机数和时间戳后续分析就从这两个点切入。4. 从字节码到可读逻辑的四步走4.1 用 wasm2wat 还原结构先盘点再精读不要一上来就埋头看函数体。第一件事是看模块整体结构打开challenge.wat后先找三个关键段import、memory、export。(import env Date_now (func $Date_now (result f64))) (import env Math_random (func $Math_random (result f64))) (memory (export memory) 1) (export get_token (func $get_token))从这段导入表就能看出模块依赖宿主环境的Date.now和Math.random说明它内部至少涉及时间戳和随机种子。再看导出函数get_token这几乎就是入口了。接下来要做的是把整个模块的调用图拉出来搞清楚get_token又调用了哪些内部函数。4.2 识别导入导出函数与内存布局WASM 模块不直接访问 DOM它的所有输入输出都要通过导入函数与宿主交换。常见导入函数包括随机数Math_random、crypto_getRandomValues时间相关Date_now、performance_now内存操作memory_grow调试输出console_log这些导入函数是天然的锚点。你在反编译结果里搜索这些函数名就能找到所有调用点进而定位到核心逻辑。内存布局方面动态分析比静态要高效得多。在 Frida 里拿到 memory 实例后直接转成字符串 dumpconst memory instance.exports.memory; const buf new Uint8Array(memory.buffer, 0, memory.buffer.byteLength); const str new TextDecoder().decode(buf); console.log(str);这样能看到内存里大量明文或半明文的数据比如 JSON 字符串、固定魔数、base64 编码的 token 分段。如果内存有加密 dump 时看到的是一堆乱码这时就要去找负责解密的函数。4.3 定位核心校验函数拿到反编译结果后核心校验函数有几个特征它会被导出函数直接或间接调用它内部会有大量比较指令比如i32.eq、i64.ne它可能会引用导入的随机函数或时间函数用于生成动态盐值它的调用频率通常高于普通辅助函数。一个非常实用的方法是用wasm-decompile输出伪 C 代码然后搜索导入函数名。比如搜索Math_random找到类似这样的代码f49() { var f64 Math_random(); var i32 (i32)f64 * 1000000000; return i32; }这说明模块内部用Math.random()的浮点值生成一个随机种子你可能需要用同样的方式生成并拼入 token。再沿着这个数值的使用点往下追就能找到用随机种子初始化加密状态的那段代码。4.4 栈机代码的阅读技巧如果像我一样会看 WAT 精读有几个经验看见i32.const 8后面跟i32.load通常是在读取内存地址偏移 8 的位置它可能是一个结构体字段。看见local.get 0local.get 2i32.add往往是在计算数组下标。call_indirect是函数指针调用对应高级语言里的虚函数或回调遇到这种函数时要提前看 table 段确定可调用的函数列表。读 WAT 不要试图从头读到位。我通常先读导出函数然后在它里面找到call指令把所有被调用的内部函数列出来手工画一个调用图。再逐个函数分析它做了什么类型的操作如数据搬运、位运算、循环校验。每分析完一个函数就把它的行为用一句话写在注释里最后拼起来就是一个可读的逻辑流程。5. 动态插桩让 WASM 模块自己说出秘密5.1 Hook 导入函数还原上下文静态分析最大的问题是不知道某个导入函数在真实执行时被传入了什么。通过 Frida 在浏览器进程中 hook 这些导入函数可以精确观察每次调用的参数和返回值Interceptor.attach(Module.findExportByName(null, env.Date_now), { onEnter(args) { console.log([Date_now] called); }, onLeave(retval) { console.log([Date_now] return, retval); } });不过更实用的位置是在 WASM 模块的导出函数上直接包装一层。你可以在WebAssembly.Instance创建后用 Proxy 包装 exports在每次调用导出函数时记录入参和返回值function hookWasmExports(instance) { const exports instance.exports; const handler { get(target, prop) { if (typeof target[prop] function) { return function (...args) { console.log([*] call ${prop}(${args.map(a 0x a.toString(16)).join(, )})); const result target[prop](...args); console.log([*] return 0x${result.toString(16)}); return result; }; } return target[prop]; } }; return new Proxy(exports, handler); }很多 WASM 导出函数使用整数指针作为字符串参数打印出来的结果是一串内存地址。没关系直接配合内存 dump就能把对应地址的内容还原成字符串。5.2 内存镜像转储与差分对比在动态分析中我最常用的手段是内存 diff。第一次在调用导出函数之前先把整个memory.buffer转存一份第二次调用导出函数后再转存一份。对比两份的差异所有新增或变化的字节就是这次调用产生的新数据。如果 token 在内存里生成那它一定在这堆 diff 字节中。function snapshotMemory(mem) { return new Uint8Array(mem.buffer.slice(0)); } const before snapshotMemory(mem); instance.exports.get_token(ptr, len); const after snapshotMemory(mem); // 对比 before 和 after找出差异区间如果这个区间里的内容像字符串可以直接用TextDecoder解码如果是乱码很可能经过了加密或者压缩这时候要回到静态分析寻找对这个区间进行读操作的函数。5.3 函数执行轨迹与耗时采样WASM 函数执行得非常快靠断点观察效率很低。更好的方式是用 DevTools 的 Performance 面板录制几秒操作然后放大到 WASM 函数的 call tree。方法名会显示为类似wasm-function[13]的形式后面的数字就是函数编号。通过观察每次验证码按压后哪些函数被重复调用、耗时占比最高就能锁定核心计算逻辑。更精细的做法是给核心函数加日志点。WASM 本身不支持插桩但可以在 Table 段里伪造函数指针的位置替换不过这操作复杂且容易触发崩溃。对大多数情况Performance 面板的调用时序足够了。6. 实战里真正让人头疼的几个坑6.1 控制流平坦化与字符串加密PerimeterX 的 WASM 模块普遍做了控制流平坦化。反编译后你会看到大量br_table指令它们被集中在一个主循环中通过一个状态变量不断跳转。原始代码的分支结构被彻底打散如果你强行按顺序读基本会绕晕。我的应对方式是先忽略调度器把每个block里的代码段单独提取出来分析出这一段实际上是原始函数的哪一步。比如找到一个i32.addi32.store的组合可能是字符串拼接找到i64.xori64.rotr的组合可能就是某种自定义加密。把这些片段一个个还原成伪代码再按数据流重新排序就能拼出原始逻辑。字符串加密也很常见。运行后内存里才有明文静态文件里全部是密文。这时候直接在动态阶段把内存 dump 下来搜索所有 UTF-8 或 ASCII 字符串通常就能看到challengeId、token、timestamp这些关键字段。6.2 反调试行为部分 WASM 模块会周期性调用Date.now和Math.random做自校验如果发现前后时间差过大或者随机数分布异常就可能进入陷阱分支主动返回错误的 token。这就是为什么我强调动态插桩时不要轻易打断点就算要断点也要用日志代替暂停。在 Frida 分析浏览器中的 WASM 时也需要小心环境检测。不要一直在同一个代码路径上反复调否则服务端很快会发现异常流量。我的做法是每做一次动态实验就用完全不同的浏览器指纹和网络环境重新加载页面确保每个样本都是独立会话。6.3 模块更新与持续分析成本验证码产品的 WASM 模块更新频率惊人。我见过某一天内模块内容连续变化了 3 次哈希不同、导出函数数量不同、内存偏移也不同。这意味着你前面花了 3 天分析出来的静态结构可能在几小时后失效。因此真正有长期价值的是抽象出操作语义而不是字节码细节。例如你发现每个版本里都有一个收集轨迹参数拼接后做 HMAC 签名的步骤哪怕下次它的签名函数名换了、参数顺序换了你也能快速通过导入函数和比较特征定位到语义不变的地方。做这种分析不是一次性的而是要形成一套模式库每次更新后只用跑一遍工具链就能立刻找出变化点。7. 报告的落点与合规边界7.1 风控评估报告应该写什么如果你是在做验证码安全评估报告的重点不应该只是能不能绕过而是从攻防对抗角度回答几个问题WASM 模块是否承担了不可替代的签名逻辑如果直接把 signature 计算逻辑移植到本地脚本是否会影响验证结果是否存在硬编码密钥或可预测随机种子可预测随机性会让 token 被伪造成本骤降。服务端是否对 token 做过时间戳和会话绑定重放攻击窗口有多大模块更新频率是否足够当攻击者逆向出内核后多久能有新版本覆盖这些问题比我成功绕过了更有价值因为它们是长期的风控水位指标。7.2 哪些事坚决不能做最后必须强调边界。WASM 逆向分析这行手艺可以用于安全研究、自有产品评估、授权渗透测试但绝对不能用于绕过真实线上验证码去批量注册、刷接口、抢商品、薅羊毛哪怕只是测试也不行。如果你真的需要练手请自己搭建一个本地站点或者用 PerimeterX 的公开 demo 环境做研究。分析过程中产出的样本、字节码、反编译结果不要直接公开原始文件和可直接运行的伪代码。保留分析思路和方法论就够了细节只在小范围安全圈子里交流。这篇内容如果你只是围观可能觉得里面充满了各种技术名词但如果你真的上手走一遍流程会感受到 WASM 验证码和传统 JS 验证码之间最根本的差异它是想尽办法让你看到逻辑却读不懂逻辑。对我来说分析它最大的收获不是搞清楚某一个函数在做什么而是明白了一套针对栈机字节码的高效拆解方法。下次再遇到别的 WASM 风控模块不管是验证码、指纹采集还是行为分析我都能很快找到入口把黑盒变成灰盒再把灰盒变成一张清晰的流程图。如果你也在这条路上希望上面的经验能帮你少踩几个坑。