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

195 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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
```js
__wbg_fillText_4a931850b976cc62: function(arg0, arg1, arg2, arg3, arg4) {
getObject(arg0).fillText(getStringFromWasm0(arg1, arg2), arg3, arg4);
}
```
- `arg1`ptr+ `arg2`lenWASM 线性内存中的 UTF-8 字符串
- `getStringFromWasm0` 通过 `TextDecoder` 将其转为 JS 字符串
- **此处是明文暴露的最后一关**
### 关于 get_export_geek_detail_info
```js
// 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]**
```js
// 在主页面 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 });
```
**后处理(去重+按行合并)**
```js
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备选
```js
// 在 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