mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-05-25 10:10:13 +08:00
- 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
7.3 KiB
7.3 KiB
BOSS 直聘在线简历 Canvas/WASM 反爬机制分析与破解方案
一、机制概述
BOSS 直聘的「在线简历」页面采用了一套基于 WebAssembly 的反爬方案,核心目的是防止简历内容被直接抓取:
- 简历数据以加密字符串形式下发(Base64 + AES-256 加密)
- 解密过程完全在 WASM 沙箱内完成,JS 层无法直接访问明文
- 解密后的明文逐字绘制到 Canvas,用户看到的是像素而非 DOM 文本
- Canvas 内容无法被
innerText、querySelector等常规手段提取
二、技术栈与文件
| 文件 | 作用 |
|---|---|
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 crate(pure Rust,fixslice32 实现)
- 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);
}
arg1(ptr)+arg2(len):WASM 线性内存中的 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