1. 项目概述:当字体不再是字体
最近在分析一个电商平台的数据时,遇到了一个老朋友,但这次它穿上了新马甲——动态字体加密。具体来说,页面上显示的商品价格、销量等关键数字,在HTML源码里是一堆类似“”的乱码,而浏览器里却能正常渲染成“129”、“358”这样的数字。这背后,就是WebAssembly(Wasm)与字体文件联手布下的“迷魂阵”。
这玩意儿本质上是一种“视觉欺骗”技术。服务端每次返回的页面数据中,关键数字被替换成了Unicode私有区域(PUA)的字符,这些字符本身没有固定形状。同时,或伴随页面加载,或通过异步接口,一个经过特殊编码(通常是Base64)的字体文件会被下发。这个字体文件定义了那些PUA字符应该渲染成什么图形(比如数字0-9)。更“高级”的是,这个字体文件可能是动态变化的,每次请求,字符编码(即“”这个码点)与真实图形(比如数字“1”)的映射关系都不同,甚至字体文件本身就是一个编译成Wasm的模块,在运行时动态生成字形轮廓。
对于数据采集、价格监控或者竞品分析来说,这无疑是一道需要破解的关卡。你不能直接爬取源码里的“”就认为是“1”,因为下次这个码点可能对应“5”。本次分析的目标,就是彻底拆解这套基于Wasm的动态字体加密机制,从原理到实操,找到稳定、通用的破解思路。无论你是前端安全研究员、爬虫工程师,还是单纯对Web逆向感兴趣,这套分析方法都能为你打开一扇窗。
2. 核心原理与Wasm的角色剖析
要破解,先得理解它是怎么工作的。这套技术栈的核心在于“分离”和“动态”:将内容(数字)与表现形式(字形)分离,并将映射规则动态化。
2.1 传统静态字体加密的局限
早期的字体反爬相对简单。开发者会创建一个自定义字体文件(如
.woff
,
.ttf
),将数字0-9映射到PUA区的特定码点(例如
0xE001
到
0xE00A
)。页面CSS引入该字体,并将这些码点应用到数字对应的HTML元素上。爬虫只要解析一次字体文件,建立码点到数字的映射表,以后就能通用了。
这种方式的弱点很明显:映射关系是固定的。一旦映射表被破解,所有数据都“裸奔”了。为了增加难度,开发者开始动态生成字体文件,每次请求的字体文件中,字形轮廓(Glyph)对应的码点顺序是随机变化的。但即便如此,只要在单次会话内获取并解析字体文件,就能解密当次页面数据。这仍然是一次性的破解。
2.2 Wasm带来的“质变”
WebAssembly的引入,将动态字体加密提升到了一个新的维度。Wasm模块在这里可以扮演两个关键角色:
-
字体生成器
:核心的字体文件(或字体文件的核心数据)不再是一个静态的二进制文件,而是由Wasm模块在浏览器内存中实时计算、生成的。Wasm模块可能内置了字形轮廓的矢量数据(如SVG路径),然后根据本次会话的密钥或随机种子,动态地将这些轮廓分配给不同的Unicode码点,并组装成一个符合
opentype.js或浏览器FontAPI要求的字体对象,直接注册给页面使用。 -
映射关系混淆器
:即便字体文件是静态的,Wasm也可以负责管理动态的映射关系。页面中的加密字符码点,可能需要经过一个Wasm导出函数的“翻译”,才能得到最终用于查找字形的
glyph id。这个翻译算法可以很复杂,涉及位运算、查表、哈希等,并且算法逻辑被编译成难以直接阅读的Wasm字节码,增加了静态分析的难度。
结合网络热词中提到的“frcrn wasm”和“央视频 wasm算法”,这很可能指的就是一些成熟或自研的、用于生成或处理动态字体的Wasm模块。而“微信小程序cannot allocate wasm memory for new instance”这个错误,则从侧面反映了在小程序环境下,复杂Wasm字体模块可能遇到内存分配问题,说明其资源消耗和复杂性。
2.3 技术栈拼图
一个完整的动态字体加密方案通常包含以下几块拼图:
- HTML结构 :包含用PUA字符表示的数据。
-
CSS样式
:可能通过
@font-face引用一个由Wasm生成或控制的字体,或者直接使用FontFaceAPI。 - JavaScript :负责加载、初始化Wasm模块,调用模块导出的函数来注册字体或翻译字符。
- WebAssembly模块(.wasm文件) :加密逻辑的核心载体。
- 可能的辅助数据 :一个包含字形轮廓数据的JSON或二进制文件,作为Wasm模块的输入。
攻击者的目标,就是从这块拼图中,还原出“字符码点 -> 真实数字”的映射关系,并且最好是能找到一个 会话无关 或 长期有效 的规律,而不是每次都要重新解析。
3. 逆向分析环境搭建与工具链
工欲善其事,必先利其器。分析Wasm动态字体,需要一个能拦截、查看、调试所有网络请求和前端代码的环境。
3.1 浏览器开发者工具是主战场
现代浏览器的开发者工具(F12)是我们最重要的武器,尤其是以下面板:
-
网络(Network)面板 :这是起点。刷新目标页面,勾选“保留日志”(Preserve log)。重点关注:
-
Font类型请求:查找.woff、.woff2、.ttf、.otf等字体文件。注意,动态字体可能不以独立文件形式加载。 -
Wasm类型请求:查找.wasm文件的加载。这是关键目标。 -
XHR/Fetch和JS类型请求:查找可能返回字体数据(如Base64字符串)或映射规则的API接口。有时字体数据会作为JSON的一部分下发。 - 使用搜索功能(Ctrl+F),在全量网络日志中搜索关键词,如“font”、“face”、“truetype”、“base64”、“woff”。
-
-
源代码(Sources)面板 :用于调试JavaScript和Wasm。
-
在
Page标签下找到加载的.wasm文件,浏览器可以将其反编译为WAT(WebAssembly Text Format)格式进行查看,虽然可读性依然很差,但比二进制强。 -
在
Overrides本地代码替换功能,可以拦截并修改响应的JS文件,便于我们插入调试代码或打日志。
-
在
-
控制台(Console)面板 :执行JavaScript代码来探查页面对象。例如,检查
document.fonts查看已注册的字体,或者调用特定的全局函数。 -
应用(Application)面板 :
-
Storage->IndexedDB/Web SQL:有时映射表会存储在本地。 -
Frames->Fonts:这里会列出页面实际加载和使用的所有字体文件,点击可以预览。 如果字体是动态生成并通过API注册的,它也会出现在这里 ,这是发现动态字体的重要线索。
-
3.2 必备的专项分析工具
仅靠浏览器工具还不够,需要一些专业工具辅助:
-
Python环境 + 爬虫库(requests, selenium)
:用于自动化请求和数据处理。
selenium可以驱动真实浏览器,完美执行页面JS并获取渲染后的DOM,是应对复杂反爬的利器。 -
字体分析工具
:
-
fonttools(Python库):神器级别的字体处理库。可以解析、编辑、导出字体文件。常用命令pyftsubset,ttx(将字体转成XML格式查看)。 -
Online Font Converter/Analyzer:一些在线网站可以上传字体文件查看字形和映射关系,作为快速预览。
-
-
Wasm分析工具
:
-
wasm2wat/wat2wasm(WABT工具包):命令行工具,将.wasm二进制文件转换为文本格式(.wat)或反向操作。wasm2wat是静态分析的第一步。 -
wasm-decompile(来自WebAssembly/wabt):尝试将Wasm反编译成一种更易读的伪代码,比WAT友好一些。 -
wasm-objdump(同样来自WABT):可以查看Wasm模块的段(section)信息、导入导出函数表等。
-
-
十六进制编辑器
:如
010 Editor,用于直接查看和修改二进制文件(字体、Wasm),特别是分析文件头结构。
3.3 实战环境配置心得
注意 :分析这类站点,强烈建议使用一个“干净”的浏览器配置文件或无痕模式,避免缓存干扰。同时,在
Network面板中,禁用缓存(Disable cache)选项要一直打开。
我的常用工作流是:用Chrome无痕模式打开目标页,进行手动探索,定位到关键的
.wasm
请求或字体注册的JS代码。然后,用Python的
requests
库模拟请求,下载这些关键资源。对于
.wasm
文件,用
wasm2wat
转换成文本,用文本编辑器(如VSCode)进行初步搜索,寻找与字体操作相关的函数名(如
createFont
,
getGlyph
,
decode
等)或字符串常量。对于字体文件,用
fonttools
的
ttx
命令转成XML,分析
cmap
(字符映射表)和
glyf
(字形数据)表。
4. 动态字体加密的逆向实战步骤
理论准备就绪,下面进入实战环节。我们将以一个假设的、但融合了常见技术点的场景为例,一步步拆解。
4.1 第一步:侦查与信息收集
打开目标商品页,打开开发者工具。
-
观察页面渲染
:确认数字部分在源码中是乱码(如
),在元素审查(Elements)中看到其font-family可能指向一个自定义字体,比如“my-secret-font”。 -
搜索网络请求
:
-
搜索
.wasm,找到了一个/v2/decrypt_font.wasm的请求。 -
搜索
font,发现没有传统的.woff请求,但有一个/api/getFontData的XHR请求,其响应是一个巨大的JSON对象,里面有一个fontData字段,内容是Base64字符串。 -
搜索
my-secret-font,在某个index.xxxx.js文件中找到了定义@font-face的CSS代码,但src属性指向的不是URL,而是一个JavaScript函数调用:src: url('data:font/woff2;base64,' + window.__getDynamicFont())。
-
搜索
-
关键发现
:字体数据通过API动态获取(
/api/getFontData),而处理这个数据、生成最终字体URL的函数逻辑,可能就在那个decrypt_font.wasm模块里。window.__getDynamicFont这个函数很可能就是Wasm模块的导出函数或被JS包装后的接口。
4.2 第二步:拦截与静态分析
-
下载关键资源
:
-
从Network面板,分别保存
decrypt_font.wasm和/api/getFontData的响应JSON(假设为font_data.json)。
-
从Network面板,分别保存
-
分析Wasm模块
:
打开wasm2wat decrypt_font.wasm -o decrypt_font.watdecrypt_font.wat文件,虽然可读性差,但我们可以搜索一些关键词:-
import:看看它从JavaScript环境导入了什么函数。可能会发现它导入了“env” “memory”(内存),或者“Math.random”(随机数),“Date.now”(时间戳)用于生成种子。 -
export:找到它导出了什么函数。我们发现了getFontBuffer、mapCodePoint等函数名。 -
字符串常量:在WAT中,字符串会以
data段的形式存在。搜索“glyph”、“cmap”、“woff”、“trueType”等词,可能找到线索。 -
使用
wasm-decompile可能会得到更清晰一些的视图,帮助我们理解函数大致的输入输出。
-
-
分析字体数据
:
-
解析
font_data.json,提取出fontData字段的Base64字符串,解码为二进制文件raw_font.bin。 -
尝试用
fonttools解析:
如果成功,说明这是一个完整的字体文件,直接查看ttx -o raw_font.xml raw_font.bincmap表即可获得映射。但更可能的情况是解析失败,提示“不是有效的字体文件”,这说明raw_font.bin是加密的或是不完整的字体数据,需要Wasm模块处理。 -
解析
4.3 第三步:动态调试与逻辑追踪
静态分析遇到瓶颈,必须进行动态调试,理解数据流。
-
定位入口函数
:在Sources面板找到加载Wasm的JavaScript代码。通常是这样:
const response = await fetch('decrypt_font.wasm'); const bytes = await response.arrayBuffer(); const { instance } = await WebAssembly.instantiate(bytes, imports); window.__wasmModule = instance.exports; window.__getDynamicFont = function() { // 调用wasm的getFontBuffer,并处理返回 const ptr = __wasmModule.getFontBuffer(...); // ... 从wasm内存中读取数据,转换为base64 return base64Str; }; -
下断点与跟踪
:在
__getDynamicFont函数和Wasm模块导出函数(如getFontBuffer)被调用的地方下断点。刷新页面,当断点触发时:- 查看调用栈 :了解是谁发起的调用。
-
监视参数
:查看传入
getFontBuffer的参数是什么。可能是从/api/getFontData获取的原始数据,也可能是一个种子(seed)或密钥(key)。 -
监视返回值
:重点关注返回值。Wasm函数通常返回一个指向其线性内存(memory)的指针(一个数字)。后续的JS代码会通过
new Uint8Array(wasmMemory.buffer, ptr, length)来读取内存中的数据。这个数据很可能就是处理后的、完整的字体文件二进制数据。
-
内存取证
:当JS代码从Wasm内存中读取到字体数据(假设存到了变量
fontUint8Array)时,可以在Console中将其导出:
下载这个最终生成的字体文件,用// 假设fontUint8Array是包含字体数据的Uint8Array const blob = new Blob([fontUint8Array], {type: 'font/woff2'}); const url = URL.createObjectURL(blob); console.log('字体Blob URL:', url); // 点击这个URL可以直接下载字体文件fonttools分析,就能得到本次会话准确的cmap映射表。
4.4 第四步:算法还原与通用破解
动态调试可以解决单次会话的解密,但我们的目标是找到通用规律。
-
分析映射的生成逻辑
:通过多次刷新页面(或遍历不同商品),收集多组数据。
-
每次的加密字符(如
)的Unicode码点。 -
每次对应的真实数字(通过下载最终字体文件解析
cmap表获得,或通过截图OCR获得)。 -
每次请求中,可能影响映射的变量:如
/api/getFontData返回的JSON中除了fontData外的其他字段(seed,version,hash等)、页面URL中的参数、服务器时间戳等。
-
每次的加密字符(如
-
寻找关联性
:将收集的数据制成表格。尝试寻找规律:
-
同一个加密码点(如
),在不同会话中是否固定对应同一个数字?如果不固定,说明映射完全随机或由种子决定。 -
如果存在
seed,尝试将seed作为输入,观察输出的映射关系。seed可能直接参与Wasm中的计算,生成一个乱序的映射表。 -
一个关键思路
:Wasm模块可能只是实现了一个
洗牌算法
。它内置了10个数字字形(0-9),和一个固定的字符码点列表(如
[0xE001, 0xE002, ..., 0xE00A])。每次根据seed,对这个码点列表进行随机排序,然后生成一个cmap表,将排序后的码点依次映射到0-9的字形上。那么,只要在Wasm模块中找到这个 初始的码点列表 和 洗牌算法 ,我们就可以在本地用同样的seed复现映射关系。
-
同一个加密码点(如
-
模拟与复现
:
-
方案A(黑盒模拟)
:如果算法复杂但输入输出明确,我们可以用Python重写JS端的逻辑。即:模拟请求获取
seed和加密的fontData,调用一个本地化的Wasm模块(通过wasmtime等Python Wasm运行时)或直接逆向算法逻辑,计算出映射关系。这需要较强的逆向工程能力。 -
方案B(依赖浏览器环境)
:对于大多数爬虫场景,更稳妥的方法是使用
selenium或playwright等浏览器自动化工具。直接让工具内的浏览器执行完整页面逻辑,然后通过execute_script提取渲染后的文本内容。这种方法“以力破巧”,完全绕过加密逻辑,因为浏览器已经帮我们完成了所有解密和渲染。缺点是效率较低。 -
方案C(映射表缓存)
:如果发现映射关系虽然动态,但在一个较短时间内(如几分钟)或对于同一商品是稳定的,可以建立本地缓存。首次遇到新
seed时,用方案A或B解密并存储seed->映射表的关系,后续直接查表。
-
方案A(黑盒模拟)
:如果算法复杂但输入输出明确,我们可以用Python重写JS端的逻辑。即:模拟请求获取
实操心得 :在实际对抗中,方案B(无头浏览器)往往是初期最快验证和拿到数据的方式。方案A(算法还原)是追求效率和稳定性的终极目标。而方案C是一种折中的优化策略。我通常会先用方案B确保数据通路,同时并行进行方案A的逆向分析。
5. 常见问题、排查技巧与进阶对抗
在实际操作中,你会遇到各种各样的问题。下面是一些实录的坑和解决思路。
5.1 问题排查速查表
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
找不到
.wasm
请求或字体API
|
1. 字体加密未使用Wasm。
2. Wasm模块被内联(inline)在JS中。 3. 请求被加密或混淆。 |
1. 检查
Application
->
Fonts
面板,确认字体来源。如果来源是
“constructed”
,说明是JS动态创建。
2. 在Sources面板全局搜索
WebAssembly
、
instantiate
、
.wasm
等关键词。
3. 搜索JS文件中的
ArrayBuffer
、
Uint8Array
等二进制操作,可能Wasm二进制码以Base64形式硬编码在JS里。
|
| Wasm内存访问错误 | JS尝试从Wasm模块返回的指针读取数据时越界。 | 动态调试时,在读取Wasm内存的JS代码处断点,检查指针和长度参数是否正确。可能是Wasm模块导出函数返回的指针和长度不匹配,或者JS端计算长度逻辑有误。 |
| 解析下载的字体文件失败 | 字体数据不完整或被二次加密。 |
1. 确认下载的是最终字体数据。对比动态调试时导出的Blob字体和直接下载的数据是否一致。
2. 将二进制数据用十六进制编辑器打开,看文件头是否是标准的字体文件头(如
wOFF
、
OTTO
、
\x00\x01\x00\x00
)。
3. 如果不是,可能需要分析Wasm对原始数据进行的解密或解压操作(可能是简单的XOR或zlib压缩)。 |
| 映射关系每次完全随机 | 种子(seed)来源复杂或与客户端信息强绑定。 |
1. 检查种子是否来自服务器响应的某个字段。
2. 检查是否由客户端信息生成,如
userAgent
的哈希、屏幕分辨率、Canvas指纹等。这类方案破解难度大,通常需要完整模拟浏览器环境(方案B)。
3. 尝试在同一个浏览器会话中(不关闭标签页)多次请求,看映射是否变化。如果不变化,可以维持会话状态进行采集。 |
| 微信小程序等特殊环境 | 小程序环境对Wasm的支持和Web有差异。 |
参考“cannot allocate wasm memory”错误,可能是内存限制。小程序的字体加密方案可能更简化。分析思路不变,但调试工具需使用小程序开发者工具。重点关注小程序特有的
wx.loadFontFace
API及其参数。
|
5.2 进阶对抗:当加密升级之后
防守方也在进化。你可能遇到更复杂的变种:
-
字形动态扭曲
:Wasm不仅负责映射,还实时微调每个数字字形的矢量路径(例如,对控制点做随机扰动),使得每次生成的字体文件虽然映射关系相同,但二进制内容完全不同,导致基于文件哈希的缓存失效。应对:聚焦于映射关系本身,而非字体文件内容。只要
cmap表一致,字形略有差异不影响文本提取。 -
多字体混合
:一个页面上可能使用多个动态字体,不同的数字区块使用不同的字体和映射规则。应对:需要更精细地定位,分析每个加密元素对应的
font-family,并分别建立映射关系。 -
Wasm代码混淆与保护
:Wasm模块本身被混淆,控制流扁平化,增加静态分析难度。应对:动态调试依然是利器。重点关注模块的导入导出表,以及它与JS交换数据的边界。可以尝试用
wasm-obfuscator等工具的学习来理解常见混淆模式。
5.3 核心经验与技巧
- 控制变量法 :在分析规律时,尽量保持其他参数不变,只改变一个你怀疑的变量(如商品ID),观察输出变化,这是定位关键参数的最有效方法。
-
善用“重写”功能
:Chrome DevTools的
Overrides功能可以让你本地替换JS文件。你可以修改源代码,插入大量的console.log,打印出Wasm函数的参数、返回值、中间变量,这是动态分析的“上帝视角”。 -
字体映射表的本质是一个字典
:无论前端玩什么花样,最终都要给浏览器一个
{加密字符码点: 字形ID}的映射表(cmap)。我们的目标就是拿到这个字典。这个字典要么在静态字体文件里,要么在Wasm内存里被组装,要么通过JS对象定义。 - OCR作为验证和后备 :对于极其复杂的方案,如果算法还原陷入僵局,可以使用截图OCR作为最后的数据获取手段。虽然慢,但能保证准确性,也可以用来验证其他方法解密的结果是否正确。
- 保持耐心与迭代 :逆向工程很少能一蹴而就。通常需要“静态分析 -> 假设 -> 动态验证 -> 修正假设”的多轮迭代。把每次尝试的过程和结果记录下来,非常有助于理清思路。
字体加密与反爬的对抗是一场持续的游戏。Wasm的加入无疑提高了防守方的技术门槛,但它并没有改变“映射关系必须最终暴露给浏览器引擎”这一根本事实。只要抓住这条主线,从浏览器渲染的结果倒推,配合耐心的动态调试和逻辑分析,这道防线终究是可以被理解和突破的。关键在于,不要被复杂的表象吓倒,一步步拆解,从网络请求到JS执行,再到Wasm内部逻辑,数据流总会清晰起来。

1万+

被折叠的 条评论
为什么被折叠?



