字体反爬怎么做?自定义字体加密实战

字体反爬把关键文字换成 Unicode 私有区码位,页面看得见、源码里全是乱码。本文讲清它的原理、六种实现方式的强度差异、怎么生成映射字体、怎么防一次反解,以及它挡不住什么、代价有多大。

Q:字体反爬到底是什么原理?

  • 网页上的文字能显示出来,是因为浏览器按字符的码位去字体文件里找对应字形。字体反爬改的就是这层映射:把真实字符挪到 Unicode 私有区(U+E000 到 U+F8FF),字体文件里只放私有区码位对应的字形。
  • 于是 HTML 里写的是  这样的字符,源码、复制、纯文本抓取拿到的都是看不懂的私有区字符;浏览器加载字体后把它画成数字 8。
  • 关键点:数据本身没加密,加密的是「怎么读」。谁拿到字体文件和映射表,谁就能还原。
  • 所以它天然只能抬成本。真正的访问控制必须落在服务端,字体这层是给批量采集加一道工程门槛。

Q:常见实现方式有几种,强度差在哪?

  • 静态替换字体:一套字体长期不变。攻击者下载一次、用 fontTools 读一遍 cmap 就永久破解,只能挡住最粗糙的采集脚本。
  • 动态生成字体:每次请求重新生成码位映射与字形顺序,字体文件跟着变。这是主流做法,破解成本最高。
  • 只替换关键字段:价格、库存、电话、评分。影响面小、性价比最高,绝大多数业务场景够用。
  • CSS 伪元素拼字:::before 里写私有区字符,HTML 正文干脆没有文字。挡得住正则,挡不住渲染。
  • 图片拼字与雪碧图:把字画成图片,用 background-position 定位。可读性最差,也最容易被 OCR。
  • SVG path 内联:字形是路径数据,必须渲染后才能识别。体积大,一般只用于少量字段。

Q:怎么用 fontTools 生成一套映射字体?

  • 思路是拿一套基础字体,改它的 cmap 表,再子集化输出 woff2。
from fontTools.ttLib import TTFont
import random

chars = "0123456789"
src = TTFont("base.ttf")

# 私有区码位随机打乱后分配给真实字符
codes = random.sample(range(0xE000, 0xE200), len(chars))
mapping = dict(zip(chars, codes))

# 重写 cmap:私有区码位指向原字符的字形
best = src.getBestCmap()
new_cmap = {code: best[ord(ch)] for ch, code in mapping.items()}
for table in src["cmap"].tables:
    table.cmap = dict(new_cmap)

src.save("out.ttf")
  • 再子集化并转 woff2,体积能压到 20KB 以内:用 pyftsubset 只保留 U+E000 到 U+E200 这一段码位,输出格式选 woff2 即可。
  • 每次请求都要重新生成,映射表随响应一起下发,字体和映射必须配套,否则用户看到的就是错字。
  • 字体里可以塞几个空字形噪声码位,让攻击者没法靠「码位数量等于字符数量」反推。

Q:前端怎么接,缓存怎么处理?

  • 用 @font-face 声明字体,地址带上本次会话的随机 token,避免浏览器或 CDN 复用旧字体。
/* 字体地址带 token,与本次映射表绑定 */
@font-face {
    font-family: "obf";
    src: url("/font/8f3a2c.woff2") format("woff2");
    font-display: block;
}
.price { font-family: "obf", sans-serif; }
  • 字体文件要 no-store 或极短缓存。用户拿到上一次的字体,价格就会显示错,这一点比防爬重要得多。
  • 用 font-display: block 而不是 swap。swap 会先渲染系统字体,用户会看到一小段乱码或错误数字,体验很糟。
  • Nginx 侧给字体路径单独加 Cache-Control: no-store,同时单独限速,因为字体文件本身就是攻击者最想批量下载的东西。

Q:怎么防止被一次反解?

  • 静态字体是纸糊的。核心只有一条:映射必须动态,且与请求绑定。
  • 同一字符在不同会话对应不同码位。攻击者拿两份字体交叉对比,只能得到「这两个码位不是同一个字」,得不到「这个码位是几」。
  • 字形顺序打乱、字体名随机、woff2 内部表顺序扰动,这些只增加工作量,真正起作用的是动态映射。映射表要是明文写在前端 JS 里,等于没加密。
  • 字体下载次数单独计数:同一个 IP 十分钟拉几百次字体,基本可以判定为破解行为,给这个路径配独立限流。
  • 别忘纵深:字体只影响「读文本」。对方直接调后端接口拿明文 JSON,字体一点用都没有。

Q:爬虫一般怎么绕过字体反爬?

  • 下载字体反解:fontTools 读 cmap 拿到码位到字形的对应,再对字形做模板匹配或 OCR。静态映射一次就破。
  • 渲染后取文本:无头浏览器截图再 OCR,或者用 canvas 测量字符宽度、配合字形轮廓比对。
  • 多份字体交叉比对:同一页面反复请求,统计同一字段的码位变化,再结合已知数量级(比如价格总在某个区间)做推断。
  • 直接打接口:最省事也最常见。只要后端返回明文,前端做什么都白搭。
  • 结论很清楚:字体反爬是抬成本的手段,价值在于把「写个 requests 就能拿」变成「要搭一套渲染加 OCR 流水线」。

Q:上字体反爬要付什么代价?

  • SEO:搜索引擎未必加载字体,关键内容加密后可能抓不到。要对验证过的蜘蛛放行,或给蜘蛛输出明文的服务端渲染版本。
  • 无障碍:屏幕阅读器读出来是私有区乱码,合规上直接不合格。
  • 复制粘贴失效:用户想复制订单号、价格去比对,复制到的是乱码,客诉会明显上升。
  • 显示风险:字体加载失败或映射错位时,页面显示的是错误但看起来正常的数字,比乱码危险得多。价格错一位就是事故。
  • 字体版权:拿商业字体改造再分发,授权要过关,别为了反爬惹上字体厂商。

Q:怎么配才不误伤正常用户和搜索引擎?

# 字体路径单独限速,防批量下载
limit_req_zone $binary_remote_addr zone=fontzone:10m rate=6r/m;

location ^~ /font/ {
    limit_req  zone=fontzone burst=3 nodelay;
    limit_req_status 429;
    add_header Cache-Control "no-store" always;
    add_header X-Robots-Tag "noindex" always;
}
  • UA 能伪造,只当第一层筛子。真要对蜘蛛放行,得反解 DNS 确认来源域名,再决定是否输出明文。
  • 字体路径加 noindex,避免字体文件本身被搜索引擎收录。
  • 关键动作要有开关:一个配置项控制明文与混淆两种渲染,出问题十秒切回去。

Q:效果怎么衡量,要和其他反爬手段怎么配?

  • 看三个指标:字体文件下载次数的 IP 分布、内容外泄的发现时间、页面错误率(映射错位会直接反映在客诉里)。
  • 分层看更清楚:字体反爬解决「复制文本」,签名与 JS 指纹解决「调用方是不是我的客户端」,频率与行为解决「是不是人」,账号维度解决「登录之后慢慢搬」。
  • 内容侧还有一招成本更低:在展示或导出数据里埋暗记(零宽字符、字段顺序、错字替换)。对方站上出现你的标记,就能定位泄露源。
  • 别指望单一手段。真正拦得住的是多层叠加之后的成本曲线,而不是某一种技术。

Q:还有什么补充建议?

  • 只加密真正值钱的字段。整页文字都混淆,维护成本和客诉成本会远高于收益。
  • 上线前跑一轮自测:断网加载、清缓存刷新、字体 404 三种情况下页面显示对不对。映射错位这类 bug 在测试环境极难复现。
  • 把字体生成与映射下发做成可开关的中间件,出问题能单独关掉,不影响其他反爬规则。
  • 定期比对自家内容在外部出现的形态。字体反爬挡的是采集成本,真正能定责的还是暗记与取证。