事情的起因是我在写一个提取学习通“章节测试”作答答案的小工具。登录、抓课程、抓章节、提取批改页的答案一路都很顺利,直到我遇到一份卷子,里面的题目全部变成了乱码

而同一批课程里,马原、党史的题目却一切正常。这就很诡异了,但是乱码肯定不是随机的,也看起来不是编码问题
破案
用浏览器开发者工具一看,这份卷子的页面里有一段这样的CSS:
@font-face{
font-family:'font-cxsecret';
src:url('data:application/font-ttf;charset=utf-8;base64,AAEAAAAMAIAAAwBAQkFTRRuOGNgAAHLcAAAA5E9TLzKUGwCt……')
}原来学习通把题目的汉字字符码偷偷换掉了,然后塞了一个自定义TTF字体,这个字体的cmap表把混淆字符映射到真实汉字的字形。浏览器加载这个字体后,“嶶”这个字符渲染出来的外形其实是“课”,“嶳”是“程”,所以人眼在浏览器里看到的是正常中文,但要是直接爬HTML拿纯文本,拿到的就是一堆乱码
这种用自定义字体做文字混淆的思路,本质上是给爬虫和复制搜题的学生上的一道锁。要还原,就得把混淆字符与真实字符的映射关系找出来
人眼识别
字体都拿到了,最简单的办法自然是把它自己用起来,字体本身就能把混淆码渲染成真实字形。于是我写了个HTML,把这个TTF内嵌进去,把字体里每一个混淆字符都渲染成大字,旁边留个输入框,人眼看着字形填出对应的汉字

填完点“保存进度”,下载一个 mapping.json,程序读进来一替换,题目就恢复了
能用,但太累了。一个字体平均140个混淆字符,全得一个个认、一个个敲,更要命的是,混淆字体是按题库生成的,如果某门课换了题库,又得重来一遍
OCR方案
于是上了OCR,把每个字形用那个字体渲染成图片,扔给OCR识别,识别结果就是真实汉字
当时用的是PaddleOCR,传一张 base64 图片,拿回识别文本,自动建映射、弹进度条。跑起来确实全自动了,但越用越别扭
- 臃肿:为了识这点字,得装一整套OCR运行时和模型,光启动加载模型就要等好几秒。就为了解个140个字的映射,杀鸡用了牛刀
- 不太准:单字识别的准确率感人,尤其是容易和别的字混淆的复杂字被认成长得像的简单字,识别完了还得人工再审一遍错的,等于绕了一圈还是没省事
OCR方案就算作废了
MD5精确比对
后面用FontForge打开font-cxsecret.ttf看了看,发现字体名是思源黑体 Normal,突然想到只要我有一份原版字体,用同样的方式渲染每个混淆字形,再和原版字体渲染的所有常用汉字比对,找到完全一样的那个,不就还原了吗?
碰巧,学习通这套混淆字体就是从 DroidSansChinese.ttf (安卓内置的思源黑体)这个字体里抠出来的。于是有了以下方案
- 用原版字体渲染一份常用汉字库,每个字做归一化 + 二值化,取md5,建成
{md5: 汉字}参考表 - 用完全相同的方式渲染混淆字体的每个字形,取md5,去参考表里一查——命中即还原
- 写进
mapping.json,题目恢复
核心代码其实就十几行:
def render_norm(font, char, size=96, grid=32, thresh=128):
# 渲染成 size*size,缩到 grid*grid,二值化(0 白 / 1 黑)
img = Image.new("RGB", (size, size), "white")
d = ImageDraw.Draw(img)
d.text(center(char), char, font=font, fill="black")
img = img.convert("L").resize((grid, grid), Image.Resampling.NEAREST)
return bytes(1 if p < thresh else 0 for p in img.getdata())
def md5_of(data):
return hashlib.md5(data).hexdigest()
# 参考表: md5 -> 汉字
# 混淆字形: render_norm(混淆字体, 混淆字符) -> md5 -> 查表这里面还有个坑。 一开始我用64px渲染,发现有一部分字形md5对不上,明明是同一个字,渲染出来总有几像素的细微差别,也许是抗锯齿和hint微调导致的。所以我又试了更大的96px渲染、32×32归一化,结果一下就能全部精确命中了

而且那几个OCR认错的字,这套方案全都对了,准确度非常高
一点感想
用自定义字体做文字混淆这套防爬手段本身挺聪明的,它不加密、不校验,纯粹靠字形在人脑里的感知和字符码在程序里的实际值之间的错位来防爬。而破解它的钥匙其实就在它自己手里,字体自己就带着从混淆码到真实字形的映射,只是换了一种媒介(图形)来表达。只要把图形“翻译”回文本,不管是靠OCR还是靠和原版字体的像素逐点比对都行,只不过后者更快更准更省事,毕竟混淆字体就是从原版字体里抠出来的,直接拿原版来对账就好了
这个思路对一切自定义字体混淆的网站应该都通用,找到源字体(很多就是常见中文字体改的),建一份字形哈希索引,查表就行了