mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-05-30 20:50:44 +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
195 lines
7.3 KiB
Markdown
195 lines
7.3 KiB
Markdown
# 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)
|
||
```js
|
||
__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
|
||
```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
|