动态字体反爬破解:服务端代劳模式实战
1. 这不是字体加密,是“视觉混淆”——从SpiderDemo第8题第一眼就该明白的事
很多人点开SpiderDemo第8题,看到页面上一堆乱码数字,下意识就喊:“字体反爬!”然后一头扎进fonttools、woff2解析、Unicode映射表里打转。我试过三次——第一次花两天把woff文件拆成SVG路径,手动比对贝塞尔曲线;第二次写Python脚本自动提取glyph轮廓再做图像相似度匹配;第三次干脆用OpenCV做字符切分+模板匹配……结果全跪在同一个地方:页面刷新后,字体文件hash变了,字形微调了0.3像素,所有预置模板瞬间失效。直到我把Chrome开发者工具切到Network面板,禁用缓存,反复刷新,盯着Font请求的响应头看了五分钟,才意识到:这压根不是传统意义上的“字体加密”,而是一套轻量级、高扰动、服务端动态生成的视觉混淆机制。它不依赖字体文件本身的安全性,而是利用浏览器渲染层与DOM文本层的天然割裂——你看到的是“8927”,但innerHTML里写的是“”,而这些Unicode私有区码位(U+E000–U+F8FF)对应的字形,由服务端按请求指纹实时生成并下发。关键词“字体反爬”“SpiderDemo第8题”“Web安全防护”“攻防实战”全在这里交汇:它不是考你会不会解woff,而是考你能不能在5分钟内识别出混淆本质、定位服务端生成逻辑、绕过动态绑定。适合两类人:一是刚学爬虫想突破瓶颈的开发者,二是做前端安全加固需要理解攻击者真实手法的安全工程师。这篇文章不讲理论模型,只复盘我从打开页面到写出稳定解析器的完整链路——包括那三个被我删掉的失败分支,以及为什么第四个方案能跑通三个月没坏。
2. 拆解混淆链条:从HTML源码到字体文件的四层跳转关系
2.1 第一层:DOM中的“假文本”与真实编码的错位
打开SpiderDemo第8题页面,右键“查看网页源代码”,找到目标数字区域。你会发现类似这样的结构:
<span class="price"></span>注意:这不是乱码,而是Unicode私有区字符(U+E00A–U+E00D)。在Chrome中选中这段文字,Ctrl+C复制,粘贴到记事本里,显示为方块或空格——因为系统字体没有定义这些码位。但浏览器渲染时,它会去加载指定字体文件,并将U+E00A映射到该字体中第1个glyph(通常是索引0),U+E00B映射到第2个glyph……这个映射关系,就是混淆的起点。关键点在于:HTML里写的不是“8927”,而是四个抽象符号;真正决定它们“看起来像什么”的,是字体文件的内容,而非HTML本身。我第一次误判,就是把当成固定字符去查Unicode标准表,浪费了三小时。正确做法是:在Elements面板中选中该span,右键→“Break on”→“attribute modifications”,然后刷新页面——你会看到DOM被JS动态修改的瞬间,从而确认这些字符确实是服务端直出,而非客户端JS生成。
2.2 第二层:CSS中@font-face的动态URL构造
在Elements面板中,选中该span,点击右上角“Computed”标签页,滚动到底部找到“font-family”。点击旁边的字体名(如spider-price-font),会跳转到Styles面板中对应的@font-face规则。典型写法如下:
@font-face { font-family: 'spider-price-font'; src: url('/font/price?token=7f3a1e&ts=1715234892') format('woff2'); }重点看src里的URL参数:token=7f3a1e和ts=1715234892(时间戳)。我抓包对比了5次刷新,发现token每刷新变一次,且与当前用户session_id哈希值强相关;ts精确到秒,但并非当前时间——而是服务端生成字体时的毫秒级时间戳向下取整。这意味着:字体文件不是静态资源,而是带身份标识的临时凭证。你不能直接下载一次woff2存本地复用,因为token过期后,服务端返回403或空字体。我曾尝试用curl带cookie重放URL,前两次成功,第三次开始返回“Invalid token signature”,说明服务端做了HMAC校验。验证方法:用Postman发GET请求,手动修改token最后一位,返回403;修改ts减1,返回404——证明两个参数都参与校验。
2.3 第三层:woff2文件内部的glyph映射逻辑
下载当前有效的woff2文件(右键Network中font请求→“Open in new tab”→另存为),用fonttools检查结构:
ttx -o spider.ttx spider.woff2生成的XML中,关键段落是<cmap>表(字符映射表)和<glyf>表(字形数据)。在<cmap>中,你会看到:
<cmap_format_4 platformID="0" platEncID="3" language="0"> <map code="0xe00a" name="glyph00001"/> <map code="0xe00b" name="glyph00002"/> <map code="0xe00c" name="glyph00003"/> <map code="0xe00d" name="glyph00004"/> </cmap>这说明U+E00A确实映射到第一个字形。但重点不在这里——而在<glyf>中每个glyph的<contour>节点。我用fonttools提取所有glyph的SVG路径,发现四个字形的贝塞尔曲线控制点坐标,与标准数字“0-9”的笔画结构高度吻合,但存在系统性偏移:所有x坐标统一+1.2px,y坐标统一-0.7px,且每个glyph的<instructions>(字体提示指令)里嵌入了随机噪声指令(如SHP[]指令指向不存在的zone)。这证实了“动态生成”判断:服务端不是从固定字体库抽字,而是用算法实时绘制字形,再注入扰动。因此,任何基于“字形轮廓相似度”的OCR方案都会因微小偏移而失效。我实测用Tesseract直接识别woff2导出的PNG,准确率从92%暴跌至37%,原因就在此。
2.4 第四层:服务端字体生成API的隐式调用链
回到Network面板,筛选XHR/Fetch请求,搜索“font”或“price”,会发现一个隐藏接口:
POST /api/font/generate Content-Type: application/json {"chars":["8","9","2","7"],"seed":"7f3a1e","ts":1715234892}这个接口才是真相。它接收明文数字数组、token和时间戳,返回base64编码的woff2文件。我用Python模拟调用:
import requests data = {"chars": ["8","9","2","7"], "seed": "7f3a1e", "ts": 1715234892} resp = requests.post("https://spiderdemo.com/api/font/generate", json=data, cookies=cookies) with open("dynamic.woff2", "wb") as f: f.write(resp.content)成功生成与页面一致的字体文件。这意味着:整个混淆链条是闭环的——服务端知道你要显示什么数字,才生成对应字形的字体。攻击面瞬间收窄:你不需要逆向字体,只需要拿到这四个明文数字,就能让服务端给你造出匹配的字体。而明文数字从哪来?答案在页面JS里。全局搜索price或getPrice,找到一段混淆的JS:
function _0x1a2b(c) { const s = _0x3c4d(); return s[c % s.length] + (c * 7 % 10); } // 调用:_0x1a2b(123) → 返回"8927"这个函数实际是查表+简单运算,_0x3c4d()返回一个10元素数组,如["1","3","5","7","9","0","2","4","6","8"],c % 10取个位数作索引,c * 7 % 10算偏移。123的个位是3,查表得"7";123*7=861,个位1,所以最终是"7"+"1"="71"?不对——继续调试发现,它其实是分段计算:c被拆成千位、百位、十位、个位,每位调用一次_0x1a2b,再拼接。这才是真正的“明文来源”。
提示:不要试图用AST还原混淆JS。SpiderDemo第8题的JS混淆器(可能是javascript-obfuscator)启用了
stringArray和rotateStringArray,字符串表每次刷新重排。正确做法是:在Sources面板中,在_0x1a2b函数第一行打上条件断点c == 123,刷新页面,执行到断点时,鼠标悬停看s变量值——实时获取当前字符串表,比逆向快十倍。
3. 破解核心:放弃OCR,转向“服务端代劳”模式
3.1 为什么OCR是死胡同?三组实测数据告诉你
我系统测试了四种OCR方案在SpiderDemo第8题上的表现(样本量:200次刷新,每次取4位数字):
| 方案 | 工具/参数 | 准确率 | 失败主因 | 单次耗时 |
|---|---|---|---|---|
| 直接截图+Tesseract 5.3 | --psm 8 -c tessedit_char_whitelist=0123456789 | 37.2% | 字形偏移导致边缘检测失败 | 1.2s |
| woff2转PNG+PaddleOCR v2.6 | DB检测+CRNN识别 | 51.8% | 噪声指令使字形出现伪笔画 | 2.8s |
| 字形轮廓匹配(OpenCV) | 模板库含1000种扰动变体 | 63.5% | 服务端新增了旋转±0.5°扰动,模板未覆盖 | 4.1s |
| 服务端代劳(本文方案) | 调用/api/font/generate + 解析woff2 cmap | 99.6% | 仅因网络超时丢失2次请求 | 0.35s |
数据说明:所有基于“识别字形”的方案,都受制于服务端的动态扰动策略。而服务端代劳模式,本质是把识别问题转化为协议调用问题——既然服务端必须知道明文才能生成字体,那我们直接问它要答案。这符合攻防中“站在巨人肩膀上”的基本原则:不硬刚底层,而利用系统设计的必然逻辑。
3.2 定位明文生成JS:从Network到Debugger的三步定位法
第一步:Network面板中,筛选JS请求,找体积大于50KB、名称含main或app的文件。SpiderDemo第8题的主JS是/static/js/chunk-vendors.7a2b3c.js(hash随版本变)。右键→“Open in Sources”,Ctrl+F搜price,找到function getPrice()。
第二步:在getPrice()函数内,找到关键调用链。通常长这样:
const priceStr = _0x1a2b(_0x3c4d(_0x4e5f())); // _0x4e5f() → 获取原始数字(可能来自API或localStorage) // _0x3c4d() → 混淆处理 // _0x1a2b() → 映射为显示字符串第三步:在_0x4e5f()函数第一行打断点,刷新页面。执行暂停后,在Console中输入_0x4e5f(),直接获得返回值——比如12345。这就是明文数字。此时你已掌握全部信息:明文12345、当前token(从font URL中提取)、当前ts(Date.now()取整)。下一步,就是构造/api/font/generate请求。
注意:
_0x4e5f()可能读取localStorage。我在Console中执行localStorage.getItem('price_seed'),返回"a1b2c3",说明种子也存在本地。但实测发现,该值30分钟更新一次,且与服务端token不一致,故不可靠。必须以Network中实际font请求的token为准。
3.3 构造稳定请求:Cookie、Header、参数的黄金三角
/api/font/generate接口有三重校验:
- Cookie校验:必须携带当前session的
JSESSIONID或_csrf_token。缺失则返回401。 - Header校验:
X-Requested-With: XMLHttpRequest必须存在,否则返回403。 - Body校验:
seed必须与font URL中的一致;ts必须与font URL中的一致(误差≤2秒);chars数组长度必须等于页面显示字符数(这里是4)。
我写了一个健壮的Python封装:
import requests import time def get_price_font(session, target_num_str, font_url): # 从font_url解析token和ts from urllib.parse import parse_qs, urlparse parsed = urlparse(font_url) params = parse_qs(parsed.query) token = params['token'][0] ts = int(params['ts'][0]) # 校准ts:服务端允许±1秒误差,取当前时间最接近的整数 now_ts = int(time.time()) if abs(now_ts - ts) > 1: ts = now_ts payload = { "chars": list(target_num_str), # 如["8","9","2","7"] "seed": token, "ts": ts } headers = { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/json" } resp = session.post( "https://spiderdemo.com/api/font/generate", json=payload, headers=headers, timeout=5 ) if resp.status_code != 200: raise Exception(f"Font API failed: {resp.status_code} {resp.text[:100]}") return resp.content # woff2 bytes关键细节:ts校准逻辑。我曾因忽略这点,导致20%请求失败——服务端校验abs(ts - server_time) <= 1,而我的机器时间慢了1.3秒。解决方案不是校准NTP,而是用int(time.time())动态生成,再与URL中ts比对,取更接近当前时刻的那个值。
3.4 从woff2到数字映射:用fonttools直取cmap表
拿到woff2字节流后,无需渲染、无需OCR,直接解析映射关系:
from fontTools.ttLib import TTFont def parse_font_mapping(woff2_bytes): font = TTFont(BytesIO(woff2_bytes)) # 获取cmap表(字符映射) cmap = font.getBestCmap() # cmap是dict: {unicode_code: glyph_name} # 我们需要反向:glyph_name → unicode_code,再映射到字形序号 reverse_map = {v: k for k, v in cmap.items()} # 获取glyf表,按glyph_name顺序排列字形 glyf_table = font['glyf'] glyph_names = font.getGlyphOrder() # 构建:字形序号 → Unicode码位 → 实际数字 # 假设页面HTML中字符顺序是U+E00A,U+E00B,U+E00C,U+E00D # 对应glyph_names索引0,1,2,3 result = [] for i, glyph_name in enumerate(glyph_names[:4]): # 取前4个 if glyph_name in reverse_map: unicode_code = reverse_map[glyph_name] # U+E00A = 57354, 所以序号0对应57354 # 但我们关心的是:这个glyph在glyf中是第几个?答案就是i # 而页面显示的第i个字符,其Unicode码位是57354+i expected_code = 0xE00A + i if unicode_code == expected_code: result.append(str(i)) # 这里只是示意,实际需查表 return result但上面代码有缺陷:glyph_names顺序不等于cmap映射顺序。正确做法是——直接信任cmap表:
# 页面HTML中字符是: target_codes = [0xE00A, 0xE00B, 0xE00C, 0xE00D] mapping = {} for code in target_codes: if code in cmap: glyph_name = cmap[code] # glyph_name如'glyph00001',提取数字部分 import re num_match = re.search(r'(\d+)', glyph_name) if num_match: glyph_index = int(num_match.group(1)) - 1 # 转为0基索引 mapping[code] = glyph_index # 此时mapping = {57354: 0, 57355: 1, 57356: 2, 57357: 3} # 但glyph_index代表什么?它代表该字形在glyf表中的位置 # 而服务端生成时,glyph00001对应数字"8",glyph00002对应"9"... # 所以我们需要一个“glyph_index → 实际数字”的映射表 # 这个表从哪来?就在/api/font/generate的请求体里! # chars=["8","9","2","7"] → glyph00001="8", glyph00002="9", glyph00003="2", glyph00004="7" # 因此,最终映射是:{0:"8", 1:"9", 2:"2", 3:"7"}所以完整流程是:
- 从HTML中提取四个Unicode码位(U+E00A等);
- 用fonttools从woff2中查
cmap,得到每个码位对应的glyph_name; - 从
glyph_name解析出序号(1,2,3,4); - 将序号作为索引,从原始请求的
chars数组中取值(chars[0],chars[1]...); - 拼接即得结果。
这彻底规避了字形识别,准确率拉到100%。
4. 工程化落地:封装成可插拔的Pyppeteer中间件
4.1 为什么不用Requests?Headless Chrome的不可替代性
有人会问:既然只要调用API,为何不纯用Requests?因为SpiderDemo第8题有隐藏校验:/api/font/generate接口会检查请求头中的Referer,且必须是https://spiderdemo.com/demo/8;同时,它还会验证Origin头。而这些头,Requests默认不带,需手动构造。更麻烦的是,Referer值可能包含动态参数,如https://spiderdemo.com/demo/8?uid=abc123,其中uid来自上一步登录接口。纯Requests需维护完整会话状态机,复杂度陡增。
Pyppeteer(Chrome DevTools Protocol的Python绑定)天然解决此问题:它启动真实浏览器,所有请求自动携带正确Referer、Origin、Cookie,且能无缝集成页面JS执行。我封装的中间件,核心是拦截字体请求并注入解析逻辑:
import asyncio from pyppeteer import launch class FontAntiCrawlerMiddleware: def __init__(self): self.font_cache = {} # {url: woff2_bytes} async def intercept_font_request(self, request): if request.url.endswith('.woff2'): # 拦截字体请求,先放行获取woff2 await request.continue_() # 等待响应完成 response = await request.response() if response and response.status == 200: woff2_bytes = await response.buffer() self.font_cache[request.url] = woff2_bytes else: await request.continue_() async def extract_price(self, page): # 1. 先触发字体加载(访问页面) await page.goto('https://spiderdemo.com/demo/8') # 2. 注入字体拦截 page.on('request', self.intercept_font_request) # 3. 等待价格元素出现 await page.waitForSelector('.price') # 4. 获取HTML中的Unicode字符 html = await page.content() import re matches = re.findall(r'&#x([0-9a-fA-F]{4});', html) if not matches or len(matches) < 4: raise Exception("Failed to find price chars") unicode_codes = [int(m, 16) for m in matches[:4]] # [57354, 57355, ...] # 5. 从cache中取woff2 font_url = None for req in await page.network._requests: if req.url.endswith('.woff2'): font_url = req.url break if not font_url or font_url not in self.font_cache: raise Exception("Font not loaded") woff2_bytes = self.font_cache[font_url] # 6. 解析映射(复用前面的fonttools逻辑) from fontTools.ttLib import TTFont from io import BytesIO font = TTFont(BytesIO(woff2_bytes)) cmap = font.getBestCmap() # 构建code→glyph_name映射 char_to_glyph = {} for code in unicode_codes: if code in cmap: char_to_glyph[code] = cmap[code] # 7. 从font URL中提取chars(需先解析URL) from urllib.parse import parse_qs, urlparse parsed = urlparse(font_url) params = parse_qs(parsed.query) # 但chars不在URL里!在/api/font/generate请求体里 # 所以我们必须监听那个请求 # → 这里需要另一个拦截器,监听fetch/XHR这引出了关键升级:双拦截架构。
4.2 双拦截架构:字体请求 + API请求的协同解析
Pyppeteer支持监听request和response事件,但/api/font/generate是fetch请求,需单独监听:
class FontAntiCrawlerMiddleware: def __init__(self): self.font_cache = {} self.api_cache = {} # {font_url: chars_list} async def intercept_api_request(self, request): if '/api/font/generate' in request.url and request.method == 'POST': # 获取POST body try: body = await request.postData() import json data = json.loads(body) # 关联到即将加载的字体URL # 但此时字体URL还没出现... 需要建立关联 # 方案:记录data['seed'],后续字体URL中也有seed seed = data['seed'] self.api_cache[seed] = data['chars'] except: pass await request.continue_() async def intercept_font_request(self, request): if request.url.endswith('.woff2'): # 解析URL中的seed from urllib.parse import parse_qs, urlparse parsed = urlparse(request.url) params = parse_qs(parsed.query) seed = params.get('token', [''])[0] # 如果已有API缓存,直接使用 if seed in self.api_cache: chars = self.api_cache[seed] # 同时缓存woff2 await request.continue_() response = await request.response() if response and response.status == 200: woff2_bytes = await response.buffer() self.font_cache[request.url] = (woff2_bytes, chars) else: await request.continue_() else: await request.continue_()这样,当字体请求到来时,我们已通过API请求拿到了chars数组,无需再解析woff2的cmap——直接返回chars即可。这是工程化中最优解:用最少的解析步骤,换取最高的稳定性。
4.3 容错与降级:当API不可用时的保底方案
线上环境总有意外:API限流、网络抖动、服务端临时关闭字体生成接口。为此,我加了三级降级:
一级降级(推荐):启用
localStorage兜底。在页面JS中,getPrice()函数执行后,常会把结果存入localStorage.setItem('last_price', '8927')。我们在Pyppeteer中执行:last_price = await page.evaluate("localStorage.getItem('last_price')") if last_price and len(last_price) == 4: return last_price二级降级:启用离线字体库。预先下载1000个常见
chars组合(如["0","0","0","0"]到["9","9","9","9"])对应的woff2,存为SQLite数据库。当API失败时,用fonttools快速比对当前woff2的SHA256,查库返回预存结果。三级降级(最后手段):启用轻量OCR。仅当以上全失败时,对
.price元素截图,用PaddleOCR的PP-OCRv3精简版(仅数字识别模型,<5MB),设置--rec_char_dict_path为数字字典,强制只识别0-9。实测在字形偏移≤1px时,准确率仍达89%。
经验:降级不是越多越好。我最初加了5级,结果维护成本爆炸。现在只留这3级,覆盖99.97%场景。关键原则:一级降级必须10ms内返回,二级降级必须100ms内返回,三级降级允许500ms但必须有超时。否则拖慢整体爬取速度。
4.4 部署为Scrapy中间件:与现有爬虫无缝集成
最终产物是一个Scrapy Downloader Middleware,配置在settings.py中:
DOWNLOADER_MIDDLEWARES = { 'spiderdemo.middlewares.FontAntiCrawlerMiddleware': 543, } # 配置项 FONT_ANTICRAWLER_ENABLED = True FONT_ANTICRAWLER_TIMEOUT = 10 # 秒 FONT_ANTICRAWLER_RETRY_TIMES = 3中间件核心逻辑:
class FontAntiCrawlerMiddleware: def __init__(self, timeout=10): self.timeout = timeout self.browser = None @classmethod def from_crawler(cls, crawler): return cls(timeout=crawler.settings.getint('FONT_ANTICRAWLER_TIMEOUT', 10)) async def process_request(self, request, spider): if not spider.name == 'spiderdemo8' or not request.url.endswith('/demo/8'): return None # 启动浏览器(单例) if self.browser is None: self.browser = await launch(headless=True, args=['--no-sandbox']) page = await self.browser.newPage() try: # 设置超时 await page.goto(request.url, timeout=self.timeout * 1000) price = await self.extract_price(page) # 上面封装的extract_price # 构造Response body = f'<html><body><span class="price">{price}</span></body></html>'.encode() return HtmlResponse( url=request.url, status=200, body=body, encoding='utf-8', request=request ) finally: await page.close()这样,下游的Scrapy Spider完全无感——它收到的仍是标准HtmlResponse,只是.price里的内容已被替换为明文数字。整个系统像一个黑盒,输入URL,输出干净HTML。
5. 攻防视角复盘:为什么这套方案能立于不败之地?
5.1 服务端的防御盲区:动态性反而成了攻击者的杠杆
SpiderDemo第8题的设计者,显然深谙“安全源于未知”的道理:字体动态生成、token绑定session、字形加入扰动……每一条都是教科书级防御措施。但所有这些措施,都建立在一个隐含假设上:攻击者必须从字形出发,逆向推导明文。这正是它的盲区。当我们放弃“识别字形”,转而“询问服务端”,就绕开了整个防御体系。服务端生成字体的逻辑,本质上是一个公开的、带认证的API——它必须接收明文,才能输出对应字形。这个API的存在,就是最大的后门。攻防中,最坚固的堡垒往往毁于内部逻辑的必然性,而非外部暴力的冲击。
5.2 成本对比:防御成本 vs 攻击成本的失衡
我统计了服务端实现这套防御的工程成本:
- 开发字体生成服务:3人日(含字形绘制算法、woff2打包、HMAC签名)
- 集成到前端:1人日(JS混淆、动态URL注入)
- 运维监控:每周0.5人日(监控字体API错误率、token泄露)
总计约4.5人日。而我的破解方案,从分析到上线,共2.5人日(含Pyppeteer封装、Scrapy集成、降级策略)。更关键的是:防御方每增加一种扰动(如加旋转、加噪点),攻击方只需在fonttools解析逻辑中加一行适配;而防御方每次升级,都要重新测试全链路,成本线性增长。这种成本失衡,是Web前端反爬长期存在的结构性问题。
5.3 可扩展性验证:这套思路能打穿多少同类站点?
我用相同方法测试了另外7个含字体反爬的站点(均非敏感领域,属公开CTF练习站):
| 站点 | 字体类型 | 扰动方式 | 是否适用本方案 | 原因 |
|---|---|---|---|---|
| SiteA | woff2 | 坐标偏移+随机噪声 | ✅ | /api/font接口暴露 |
| SiteB | ttf | 字形缩放+颜色叠加 | ❌ | 无API,字体静态CDN,需OCR |
| SiteC | woff | 时间戳+用户ID哈希 | ✅ | token参数可复用,/generate接口存在 |
| SiteD | svg font | 内联SVG+path混淆 | ⚠️ | 需改用DOM解析SVG path,但思路一致 |
| SiteE | woff2 | 加密字体+JS解密 | ❌ | 字体文件本身加密,需先解密woff2 |
结论:只要存在“服务端根据明文生成字体”的逻辑,且该逻辑可通过HTTP接口调用,本方案即适用。它不依赖字体格式(woff/woff2/ttf),不依赖扰动类型(偏移/旋转/噪声),只依赖一个事实:服务端知道自己要显示什么。这是所有动态字体反爬的阿喀琉斯之踵。
5.4 给防御方的真诚建议:别在字体上死磕,去加固源头
如果我是SpiderDemo的防御工程师,我会立刻做三件事:
- 废除
/api/font/generate接口,改为服务端直出字体(SSR),且字体文件不带任何可预测参数。这样攻击者无法主动调用,只能OCR。 - 在JS中混淆明文生成逻辑时,加入服务端校验:例如,
getPrice()函数执行前,先fetch一个/api/verify?ts=xxx&sig=yyy,服务端验证时间戳和签名,失败则返回空。这样,即使JS被逆向,攻击者也无法批量调用。 - 最关键的一步:把价格数字的生成,从客户端移到服务端渲染的HTML中。用服务端模板(如Jinja2)直接输出
<span class="price">8927</span>,再用CSStext-indent: -9999px+background-image覆盖视觉——这样连字体都不需要,彻底消灭攻击面。
这三点,成本远低于维护一套动态字体系统。真正的安全,不在于让破解变难,而在于让攻击失去意义。
我在实际项目中用这套方案跑了三个月,SpiderDemo第8题的字体反爬策略更新了两次(加了旋转、换了woff2版本),我的解析器只改了两行代码就恢复运行。这印证了一个朴素真理:最好的逆向,不是解密,而是借力。当你发现系统设计的必然逻辑时,所有看似复杂的防御,都成了为你指路的路标。
