字体混淆逆向手记:不用 OCR 还原被替换的题干

一个"DOM 里是假字、屏幕上才是真字"的反爬方案,以及在它身上踩到的六个不报错、但数据是错的坑

一个把题干字符替换掉、再用私有字体把字形映射回去的反爬方案,以及在它身上踩到的六个「不报错、但数据是错的」的坑。最终不用 OCR、不识别字形轮廓,把自己账号下的 11 个题库共 803 道题完整导出。

题库试题题干字符采集轮次混淆字体
1180342,2734694

说明:本文只讨论技术思路,目标平台、接口地址、题库名称等均已隐去。导出的是自己账号下的数据,仅用于个人离线复习。

01 目标与约束

目标是把自己账号下的题库导出成可离线复习的资料:题干、选项、正确答案、解析、来源、难度,并标注错题。约束有三条:

  • 必须走真实浏览器。 curl 直连被 WAF 拦下,返回一个非标准状态码。
  • 必须复用已登录会话。 错题记录是账号私有数据,匿名访问看不到。
  • 不能污染练习记录。 平台只在答过之后才显示答案,但「替自己作答以套出答案」会改写错题统计,不可接受。

最终方案是通过 Chrome DevTools Protocol(CDP)驱动本机那个已登录的 Chrome,全程只读。

02 接入层:Chrome 150 把远程调试收紧了

HTTP 发现端点已关闭

传统做法是 GET /json/version 拿 WebSocket 地址。在 Chrome 150 上,/json、/json/list、/json/version、/devtools/browser 一律返回 404(换 Host 头也一样)。带 token 的 WebSocket 端点仍然可用,token 写在用户配置目录的 DevToolsActivePort 文件里:

~/Library/Application Support/Google/Chrome/DevToolsActivePort
  9222
  /devtools/browser/<每次启动随机生成的 UUID>

本机代理会劫持回环请求

环境里设了 http_proxy=127.0.0.1:<port>,curl 会把发往 127.0.0.1:9222 的请求也交给代理,得到一个与 Chrome 无关的 404。排查时这个假象很有迷惑性 —— 端口明明在 LISTEN。加 --noproxy '*' 才看到 Chrome 的真实响应。

每次连接都要授权,且只允许一个客户端

Chrome 150 对每一条新建的 CDP 连接都会弹窗征求同意;browser 级端点同一时刻还只接受一个客户端。最初的实现是「每个步骤起一个独立进程各连一次」,于是 11 个题库 × 多轮 × 多步骤 = 几十次弹窗,而且进程之间会互相把连接挤掉。

改法: 改成单进程持有唯一一条连接跑完整批。整批只授权一次,也不再有争用。

03 数据面测绘

先用 CDP 的 Network 域把练习页加载时的请求全部录下来,逐个看返回。结论分三类:

接口(已脱敏)内容状态
题库信息接口题库名、题目总数明文
作答记录接口每题作答记录(错误次数、最近一次结果)明文
预览接口前 20 题的明文题干明文
题目顺序接口题目 ID 列表(题库存储顺序)明文,但顺序不可信
题目正文接口题目正文,10 题一批加密

正文接口返回密文,且带签名校验 —— 在页面里手动 fetch 复现会被「数据校验失败」拒绝。所以整套方案不重放任何接口,一律被动截获页面自己发出的请求,正文则从渲染后的 DOM 取。

关键发现:只有题干被混淆。 选项、正确答案、解析、来源、难度用的都是系统字体,DOM 里就是真值,不需要任何处理。需要解码的只有题干这一个字段。错题判定用「错误次数 > 0」,实测数量与站点「我的错题」完全一致。

04 反爬机制:私有字体做字符替换

题干元素上挂着一个哈希命名的 font-family(形如 k + 32 位十六进制)。站点把题干里的部分字符替换成别的字,再用这套私有字体把字形映射回真字。眼睛看到的是对的,抓到的是错的。

同一道题在两次会话里抓到的 DOM 文本并不相同 —— 每次会话换一套字体,替换的字也随之变化(下例为示意):

文本
会话 A依据相关宫程,封闭式设备应装设状态显示装置。
会话 B依戏相关规程,封闭舞设备应装设状态显示装置。
真值依据相关规程,封闭式设备应装设状态显示装置。

字体本身是一个常见中文字体的 200 字子集。它的 cmap 有很强的误导性:某个码位(比如「宫」)对应的字形名就叫 uniXXXX,但那个字形里装的其实是另一个字的轮廓。光看字形名会被带偏,真正的映射藏在轮廓里。

走到这一步,直觉的解法是识别字形轮廓,或者截图 OCR。两条路都不需要。

05 解码原理:一条等价关系,加上多轮取交集

用站点自己泄露的 20 条明文题干(预览接口)和对应的混淆文本逐字对齐,可以直接读出这套字体的替换映射。第一次做这个对齐时,20 条长度全部一致、零冲突,说明替换是稳定的 1:1 码位映射。更重要的是,比对替换位置与字体 cmap 之后,浮现出一条等价关系:

字体 cmap 里的码位 ⟺ 被替换的字

也就是说,cmap 收录了哪 200 个码位,哪些位置就被动了手脚;反过来,不在 cmap 里的字符一定是真字(它由系统字体渲染,站点根本没碰)。于是解码只需两条规则:

  1. 规则 1:字符码位不在本题字体的 cmap 里 → 就是真字,直接采用。
  2. 规则 2:在 cmap 里 → 查锚点学到的映射,命中即真字。
  3. 在 cmap 里但查不到 → 留待下一轮,等字体轮换后再解。

每个字符独立判定,全程只用 cmap 成员关系与锚点映射表,不涉及字形轮廓。

多轮取交集

字体每十几分钟轮换一套,每套征用的 200 个码位各不相同。所以同一个位置只要在任意一轮里落到 cmap 之外,真值就被确定了。多采几轮取交集,缺口收敛得很快 —— 第一个题库 4,821 个字符位置的收敛过程是:

第 1 轮   未解析 76 个位置  (1.58%)   完整题干 54/99
第 2 轮   未解析 14 个位置  (0.29%)   完整题干 87/99
第 3 轮   未解析  0 个位置  (0.00%)   完整题干 99/99

全部 11 个题库共采集 46 轮、涉及 94 套字体,最终全部收敛到 0。

为什么没用 OCR: OCR 需要为技术术语的识别错误兜底,且无法自证正确。cmap 路线是确定性的:每个字符要么由「不在 cmap」直接判定,要么由锚点映射查表得出,两者都可以逐字回验。

06 六个静默错误

这是整个项目里最花时间的部分。它们全都不抛异常,全都会产出看起来正常、实际是错的数据。按发现顺序:

1. 遍历途中字体轮换

  • 现象:前 20 题正确,之后大面积错字,但校验全过。
  • 根因:只在开头读一次 font-family。遍历 100 多题要 4 分钟,期间字体换了,后半段用错了 cmap。
  • 修法:逐题记录字体,解码时按题取对应 cmap。

2. 把存储顺序当成出题顺序

  • 现象:部分题库锚点对不上,题干与题目错配。
  • 根因:题目顺序接口返回的是题库存储顺序,与练习页实际出题顺序不一定一致。
  • 影响:错题标记和答案会挂到别的题上。

3. 改用批次顺序,依然错位

  • 现象:整体错开一格:第 21 位上放着第 20 题。
  • 根因:改用正文接口的批次参数拼顺序,但这些请求会预取、乱序。
  • 结论:站点给出的所有「顺序」都不可信,必须换一种对齐方式。

4. 翻页抓到重复题

  • 现象:题数对得上,但内容有重复 —— 意味着等量的题被漏掉。
  • 根因:点「下一题」后只等题号变化,而题号先更新、题干后渲染,固定延时不够时就把上一题的内容配上了新题号。
  • 影响:11 个题库里 10 个中招,最多的一个重复 12 道。
  • 修法:改为等题干内容真正变化,且连续两次读取稳定后才提取。

5. 校验本身存在覆盖盲区

  • 现象:「20 条锚点逐字一致」照样放行了错误数据。
  • 根因:明文锚点只覆盖前 20 题。若字体是在第 50 题处轮换的,校验全过,但后面全错。
  • 修法:不再靠校验去筛坏数据,改为从源头拒收:缺逐题字体或缺内容指纹的旧数据一律弃用。

6. HTML 实体未反转义

  • 现象:某题库两条锚点始终定位失败。
  • 根因:预览接口返回的是 &quot;,而 DOM 里是渲染后的 ",长度对不上。
  • 修法:锚点先做 html.unescape。修复后全部题库锚点定位率一致达到 20/20。

这些坑的共同点: 它们都不抛异常。程序跑完、数字对得上、抽查也看着正常 —— 错的是工程被写成了工措、终结被写成了终应这种肉眼几乎不会停顿的地方。在这类任务里,「没报错」不构成任何证据。

07 内容指纹对齐

坑 2 和坑 3 指向同一个结论:不能再用任何位置信息去对应题目。替代方案是用题目自身的内容做标识:

指纹 = 题型 | 题干长度 | 正确答案 | 各选项文字

这几项都可靠:选项和答案是系统字体渲染的,没被混淆;题干长度在 1:1 替换下不变。各轮之间、以及轮次与主体数据之间,全部靠指纹配对。锚点则用「内容兼容性搜索」定位 —— 长度相同、且所有不在 cmap 的位置逐字相同,并要求唯一命中。

指纹碰撞(例如选项相同、长度也相同的判断题)会被显式识别出来,按各轮内部次序配对,并最终由锚点回验兜底。

08 流水线

五步,其中第 3–4 步循环执行直到收敛:

  1. 抓取主体:选项 / 答案 / 解析 / 错题
  2. 明文锚点:前 20 题真值
  3. 字体轮次:字体 + 混淆题干
  4. 解码校验:cmap + 锚点回验;仍有缺口 → 等字体轮换后回到第 3 步
  5. 导出:Markdown + JSON
脚本职责
cdp.mjs连接本机 Chrome,含 DevToolsActivePort token 读取与连接重试
config.mjs路径约定、URL 构造、逐题遍历脚本(含内容稳定判定)
steps.mjs三个采集步骤的实现,抽成函数以复用同一条连接
batch.mjs批处理驱动:单连接、轮流给未完成题库各采一轮
decode.py指纹对齐 → 学映射 → 假设检查 → 逐位解码 → 锚点回验
export.py合并导出 Markdown 与 JSON
audit.py交付前自检,把踩过的每类坑固化成检查项

为什么轮流采而不是逐个题库跑完: 字体是按时间全局轮换的。逐个题库串行处理,会在每个题库上重复空等轮换;轮流给所有未完成题库各采一轮,采完一圈通常字体已经换新,几乎不浪费等待。

09 三道校验闸门

最终保障不是「我觉得对了」,而是每一步都能被证伪。

  • G1 假设检查(每轮执行):统计两类反例 —— 被替换却不在 cmap(推翻规则 1)、在 cmap 却没被替换(推翻等价关系)。任一出现即弃用该轮。11 个题库全程为 0。
  • G2 锚点回验(每次解码):解码结果必须与站点自己泄露的 20 条明文逐字一致,不一致直接退出、拒绝输出。这道闸门在过程中确实拦下过多次错误结果。
  • G3 交付自检(全量):重复题、未还原字符、题干缺失、锚点回验命中率、指纹唯一性、字段完整性六项,任一不过即报错退出。

自检还区分了一类不是缺陷的缺失:从未作答过的题,平台本就不显示答案。这类题在导出中明确标注,而不是伪装成有答案,也没有替自己作答去套。

10 结果

11 个题库全部通过自检,合计 803 题,题型分布为单选 364、判断 243、多选 195、简答 1。自检六项全绿:重复题 0、未还原字符 0、题干缺失 0、锚点回验 11 × 20/20 全中。

每个题库产出三个文件:_全部试题.md、_错题集.md、.json。JSON 与 Markdown 同源同内容,每题带 stemSource 标明题干来源(明文锚点 / 解码还原)。

11 复现

# 前提:Chrome 已开启远程调试,且在该 Chrome 里已登录目标平台
node tools/batch.mjs              # 读「考试目录」,批量跑完全流程
.venv/bin/python tools/audit.py   # 交付前自检

中间数据落在 data/<paperId>/,重跑导出不需要重新抓站。数据带 schema 版本号,旧格式会被自动识别并重抓。

安全提醒: 远程调试端口对本机任意程序开放浏览器完全控制权,包括 cookie 与已登录会话。用完请及时关闭。请只导出自己账号下的数据,并遵守目标平台的使用条款。


本文记录的是一次具体的逆向过程,站点实现随时可能变化。工具链里的三道校验闸门正是为此设计 —— 混淆方式一旦改变,脚本会报错退出,而不是悄悄输出错数据。