最近看到不少网站都使用了字体库对数据进行加密, 即页面源码中的数据与显示出来的数据不同, 用户也无法直接进行复制.
例如企信宝页面中的字母与数字, 58 房产频道中的数字:
经过对字体库进行研究, 找到了加密与解密方案.
加密
准备字体库样本
笔者在网上随便下载了一个 ttf 字体库, 保存为'origin.ttf', 使用 fonttools 的命令行工具 pyftsubset 提取需要加密的字符:
pyftsubset origin.ttf --text='1234567890'
参数 text 为要提取字体的字符, 运行结束后会在当前目录生成'origin.subset.ttf', 该字体库只包含'1234567890′共 10 个字符.
生成加密字体库
这里使用 [http://fontello.com/](http://fontello.com/) 网站提供的在线服务对上一步生成的字体库进行定制. 首先将生成的 subset.ttf 转为 svg, 笔者使用的是 https://cloudconvert.com/ttf-to-svg 提供的服务. 然后将 svg 上传到 fontello, 选中要定制的字符, 因为我们上传的字体库只包含 0 到 9, 所以这里全选, 然后在 Customize Codes 功能下自定义码值.
码值与字符的关系可以看作是一种映射关系, 比如 Unicode E801 对应字符 1 , Unicode E802 对应字符 2 . 我们可以随意修改字符的 unicode 值, 但一定要记住这个值与真实字符的对应关系, 来对要显示在页面上的数据加密. 这里使用该网站默认生成的 unicode. 对应关系如下:
- CIPHER_BOOK = {
- '0': '\uE800',
- '1': '\uE801',
- '2': '\uE802',
- '3': '\uE803',
- '4': '\uE804',
- '5': '\uE805',
- '6': '\uE806',
- '7': '\uE807',
- '8': '\uE808',
- '9': '\uE809'
- }
定制完成后下载字体文件.
使用
在 CSS 中定义字体, 名为 fontello .
- @font-face {
- font-family: 'fontello';
- src: url('/static/fontello.woff2') format('woff');
- font-weight: normal;
- font-style: normal;
- }
然后定义使用该字体的 class:
- .demo-icon {
- font-family: "fontello";
- }
这样只需要为页面标签添加上'demo-icon'的 class 就可以了. 如:
<h1><small class="demo-icon"> 就是这串数字:<b>{{string}}</b></small></h1>
服务端在返回数据前需要需要将数字用 CIPHER_BOOK 进行转换.
- CIPHER_BOOK = {
- '0': '\uE800',
- '1': '\uE801',
- '2': '\uE802',
- '3': '\uE803',
- '4': '\uE804',
- '5': '\uE805',
- '6': '\uE806',
- '7': '\uE807',
- '8': '\uE808',
- '9': '\uE809'
- }
- def _encrypt_secret(secret):
- return ''.join(CIPHER_BOOK[c] for c in secret)
- @App.route('/')
- def index():
- if 'guess' in request.values:
- ts = session['ts'] if 'ts' in session else 0
- secret = session['secret'] if 'secret' in session else None
- if time.time() - ts < 2 and request.values['guess'] == secret:
- return render_template('index.html', success=True)
- secret = ''.join([random.choice('0123456789') for _ in range(20)])
- # 通过 CIPHER_BOOK 将数字转换为不可见字符
- s = _encrypt_secret(secret)
- session['secret'] = secret
- session['ts'] = time.time()
- return render_template("index.html", string=s)
查看页面源码, 会发现源码是无法显示的字符, 且复制出来的是乱码.
58 产房频道使用的就是本文介绍的方案, 只加密了数字. 但是不同页面的字体库是变化的. 在字体加密破解中我们会详细介绍如何破解 58 的字体加密. 示例代码已上传到 , 有兴趣的可以看看.
破解
前面已经介绍如何制作加密字体库并在 demo 项目中使用来防止数据被抓取, 下面介绍破解方法.
true-type 字体简介
我们已经知道字体加密其实是一种明文到密文的双向映射, 所以只要找到映射表就可以了. 但我们在破解的时候只能拿到字体库文件, 所以需要通过该文件找到 CIPHER_BOOK. 这就需要对字体库结构有一定了解. 在查阅 相关文档 后, 可以简单地将字体的绘制过程为理解为:
1. 根据字符的 unicode 编码找到 glyph 名称 (cmap);
2. 根据 glyph 名称找到 glyph (glyf);
3. 使用 glyph 进行绘制.
其中 glyph 可以理解为字体的绘制所需的数据, 如点, 线等.
一个 TrueType Font 字体文件包含几个 table. 这里需要用到的两个 table 如下 (tag 为 table 的名称):
tag | table |
---|---|
cmap | character to glyph mapping |
glyf | glyph data |
根据字体的绘制过程, 可以猜测有两种方式实现字体加密:
1. 打乱字符编码
2. 打乱 glyph 名称
下面笔者就这两种情况用两个案例进行讲解.
破解 demo
首先在页面中找到字体库的 url 并下载, 得到 fontello.woff2, 然后用 fonttools 将文件转为 ttx 方便肉眼分析.
- from fontTools.ttLib import TTFont
- font = TTFont('fontello.woff2')
- font.saveXML('fontello.ttx')
得到的 ttx 为 xml 文档, 打开并查找 cmap 节点:
据此我们可以还原加密时的映射表 (即 cmap 表):
- CIPHER_BOOK = {
- '\ue800': '0',
- '\ue801': '1',
- '\ue802': '2',
- '\ue803': '3',
- '\ue804': '4',
- '\ue805': '5',
- '\ue806': '6',
- '\ue807': '7',
- '\ue808': '8',
- '\ue809': '9'
- }
由于 demo 使用了静态的字体库, 所以这个表不会变化, 写死就可以了, 破解代码如下:
- import requests
- from bs4 import BeautifulSoup as BS
- CIPHER_BOOK = {
- '\ue800': '0',
- '\ue801': '1',
- '\ue802': '2',
- '\ue803': '3',
- '\ue804': '4',
- '\ue805': '5',
- '\ue806': '6',
- '\ue807': '7',
- '\ue808': '8',
- '\ue809': '9'
- }
- URL = 'http://127.0.0.1:5000'
- sess = requests.Session()
- resp = sess.get(URL).text
- bs = BS(resp, 'lxml')
- string = bs.select_one('.demo-icon b').text
- guess = ''.join(CIPHER_BOOK[c] if c in CIPHER_BOOK else c
- for c in string)
- print('guess:', guess)
- resp = sess.get(URL, params={'guess': guess}).text
- assert 'Congratulations' in resp
破解 58
demo 中的字体库不会变化, 所以映射表写死就可以了. 但分析发现 58 房产频道不同页面的字体库是不一样的, 而且 glyph name 与真实字符有差异, 所以需要根据字体库动态处理.
首先页面中的字体文件是经过 base64 编码的, 直接解码并保存到文件即可.
然后用上面的代码转为 ttx 文件, 查看 cmap 节点:
通过观察对比发现, 字符编码相同, 但 glyph 名称是变化的, 且 glyph 名称与真实数字的关系为:
glyph_name = 'glyph00%02d' % (real_num + 1)
据此我们可以还原 glyph 名称与真实字符的映射表 (即 glyf 表):
- GLYF_TABLE = {
- 'glyph00001': '0',
- 'glyph00002': '1',
- 'glyph00003': '2',
- 'glyph00004': '3',
- 'glyph00005': '4',
- 'glyph00006': '5',
- 'glyph00007': '6',
- 'glyph00008': '7',
- 'glyph00009': '8',
- 'glyph00010': '9'
- }
另外由于 cmap 表是变化的, 所以需要在解密时提取, 使用 fonttools 库可以实现:
cmap = font['cmap'].getBestCmap()
返回一个 dict, 其中 key 为 int 型编码, v 为 glyph 名称. 整个解密过程为:
1. 解析字库库, 取得 cmap;
2. 根据 cmap 查询字符编码, 得到 glyph 名称;
3. 根据 GLYF_TABLE 查询 glyph 名称, 得到真实字符.
代码有点长就不贴了, 已上传到 gayhub , 有兴趣的可以下载看看.
来源: http://www.tuicool.com/articles/QzY7Zfi