Files
geekgeekrun/plan/cv_canvas_solution.md
rqi14 95c1e54c66 recruiter: add boss auto browse/chat flows, webhook, and candidate tables
- Add recruiter-side automation core and run-core entry
- Extend sqlite-plugin with candidate info + contact logs
- Add UI routes/pages, IPC handlers, progress + log panel
- Document current status and plans under plan/

Made-with: Cursor
2026-03-18 17:37:24 +08:00

7.3 KiB
Raw Blame History

BOSS 直聘在线简历 Canvas/WASM 反爬机制分析与破解方案

一、机制概述

BOSS 直聘的「在线简历」页面采用了一套基于 WebAssembly 的反爬方案,核心目的是防止简历内容被直接抓取:

  • 简历数据以加密字符串形式下发Base64 + AES-256 加密)
  • 解密过程完全在 WASM 沙箱内完成JS 层无法直接访问明文
  • 解密后的明文逐字绘制到 Canvas,用户看到的是像素而非 DOM 文本
  • Canvas 内容无法被 innerTextquerySelector 等常规手段提取

二、技术栈与文件

文件 作用
wasm_canvas_bg-1.0.2-5057.wasm 核心 WASM 二进制,含解密+渲染逻辑
wasm_canvas_bg-1.0.2-5057.dcmp 上述 WASM 的反编译结果(~23万行
wasm_canvas-1.0.2-5057.js wasm-bindgen 生成的 JS 胶水层
index-Ue9MaX2q.js 页面业务逻辑,负责初始化 WASM 并调用 start()

加载链路:

zhipin.com 主页面
  → postMessage → iframe (c-resume)
    → index-Ue9MaX2q.js: initWasm() → __wbg_init()
    → 加载 wasm_canvas_bg-*.wasm
    → start(container, content, geek_info_encrypt_string, ...)
    → WASM 内部解密 → 逐字 fillText 到 Canvas

三、WASM 内部解密流程

基于对反编译文件的分析:

加密方案

  • Base64 编码 + AES-256 加密 + PKCS#7 填充
  • AES 实现aes-0.8.4 cratepure Rustfixslice32 实现)
  • AES 模式:未确认CBC/CTR/GCM 均未在反编译文件中出现CBC 是推断)
  • 密钥和 IV 打散在 WASM 二进制中,未恢复

关键函数(反编译行号)

函数 编号 行号 作用
start() func452 122298 JS 入口,接收加密字符串
start_anonymous_resume() func453 122369 匿名简历入口
f_qg() func198 50684 核心编排Base64解码 + AES解密
f_hi() func241 94273 文本渲染循环
fillText 调用点 94814 唯一的 fillText 调用
get_export_geek_detail_info() func4040 228899 导出解密后的结构化数据

调用链

start() [L122298]
  ↓
f_qg() [L50684]  — Base64解码 → AES解密 → PKCS#7去填充
  ↓
f_hi() [L94273]  — 遍历文本元素
  ↓
wasm_canvas_bg_js_wbg_fillText_4a931850b976cc62(ctx, ptr, len, x, y)  [L94814]
  ↓
JS: getObject(ctx).fillText(getStringFromWasm0(ptr, len), x, y)
  ↓
Canvas 像素渲染

fillText JS 胶水层wasm_canvas-1.0.2-5057.js L344-346

__wbg_fillText_4a931850b976cc62: function(arg0, arg1, arg2, arg3, arg4) {
    getObject(arg0).fillText(getStringFromWasm0(arg1, arg2), arg3, arg4);
}
  • arg1ptr+ arg2lenWASM 线性内存中的 UTF-8 字符串
  • getStringFromWasm0 通过 TextDecoder 将其转为 JS 字符串
  • 此处是明文暴露的最后一关

关于 get_export_geek_detail_info

// wasm_canvas-1.0.2-5057.js L46-48
export function get_export_geek_detail_info() {
    const ret = wasm.get_export_geek_detail_info();
    return takeObject(ret);  // 返回 JS 对象,不是裸指针
}
  • 返回值是 wasm-bindgen JS heap 对象,包含结构化简历数据
  • 需通过模块导出的 wrapper 调用,不能直接访问 wasm.*
  • 调用时机:start() 返回后

四、渲染特征

  • 每个字符单独调用一次 fillText(逐字渲染,非逐行)
  • 同一字符会被绘制两次(两个叠加 Canvas高清/普通各一层)
  • 坐标系x 为横向像素位置y 为行基线位置(相同 y 值 = 同一行)

五、已验证的破解方案

方案一Hook CanvasRenderingContext2D.prototype.fillText推荐

原理:在 fillText 原型上插桩,收集所有绘制调用的文本和坐标。

难点:简历渲染在 iframe 内,且每次打开都是新 iframe 实例,需在 iframe 创建时立即注入。

验证结果 已成功提取完整简历明文

检测风险:直接替换 prototype 方法后,fillText.toString() 会暴露自定义函数体而非 [native code],可被检测。使用 Proxy 方案可规避此问题。

注入代码Proxy 版toString 保持 [native code]

// 在主页面 Console 执行,然后再打开简历
window._collected = [];

const observer = new MutationObserver(() => {
    const iframe = document.querySelector('iframe[src*="c-resume"]');
    if (iframe && !iframe._hooked) {
        iframe._hooked = true;
        iframe.addEventListener('load', () => {
            const iwin = iframe.contentWindow;
            if (!iwin) return;
            const orig = iwin.CanvasRenderingContext2D.prototype.fillText;
            Object.defineProperty(iwin.CanvasRenderingContext2D.prototype, 'fillText', {
                value: new Proxy(orig, {
                    apply(target, thisArg, args) {
                        const [text, x, y] = args;
                        if (text && text.trim()) window._collected.push({ text, x, y });
                        return Reflect.apply(target, thisArg, args);
                    }
                }),
                writable: true,
                configurable: true,
            });
            console.log('✓ hook 注入到新 iframe');
        });
    }
});
observer.observe(document.body, { childList: true, subtree: true });

后处理(去重+按行合并)

const lines = {};
window._collected.forEach(({text, x, y}) => {
    const row = Math.round(y);
    if (!lines[row]) lines[row] = [];
    lines[row].push({text, x});
});

const result = Object.keys(lines)
    .map(Number)
    .sort((a, b) => a - b)
    .map(y => {
        const sorted = lines[y].sort((a, b) => a.x - b.x);
        // 去重:相同 x 位置(双层 Canvas 导致每字画两次)
        const deduped = sorted.filter((item, i) =>
            i === 0 || Math.abs(item.x - sorted[i-1].x) > 1
        );
        return deduped.map(c => c.text).join('');
    })
    .join('\n');

console.log(result);

方案二:调用 get_export_geek_detail_info()(待验证)

原理start() 完成后,调用官方导出函数直接获取结构化 JS 对象。

优点:数据结构化,包含字段语义(姓名、学历、工作经历等)

待验证

  • WASM 模块实例在 index-Ue9MaX2q.js 中如何暴露
  • start() 返回后立即可调用,还是需要等某个回调
  • 返回对象的字段结构

方案三Hook WASM JS import备选

// 在 iframe load 后,替换 WASM import 对象中的 fillText 绑定
// 比 prototype hook 更底层,但需要在 __wbg_init 之前注入

六、不推荐的方案

方案 原因
OCR 识别 Canvas 精度差,中文易误识别,成本高
恢复 AES key/IV 密钥打散在二进制中,工程量大,版本升级即失效
修改 WASM 二进制插入 early return 可行但维护成本极高,版本升级即失效
DOM 文本提取 简历内容不在 DOM 中,无效

七、稳定性与风险

  • fillText hook 方案不依赖 WASM 内部实现,版本升级只要渲染方式不变就依然有效
  • BOSS 直聘最后一页附有版权声明(见提取结果末尾),提醒数据仅限招聘目的使用
  • 如 BOSS 直聘升级为 OffscreenCanvas 或 Worker 渲染prototype hook 会失效,需改为 hook Worker 内的 Canvas API