最近看到不少网站都使用了字体库对数据进行加密,即页面源码中的数据与显示出来的数据不同,用户也无法直接进行复制。例如企信宝页面中的字母与数字,58房产频道中的数字
企信宝字体加密
58房产

经过对字体库进行研究,找到了加密与解密方案。

加密

准备字体库样本

笔者在网上随便下载了一个ttf字体库,保存为’origin.ttf’,使用fonttools的命令行工具pyftsubset提取需要加密的字符

1
pyftsubset origin.ttf --text='1234567890'

参数text为要提取字体的字符名,运行结束后会在当前目录生成’origin.subset.ttf’,该字体库只包含’1234567890’共10个数字。

生成加密字体库

这里使用http://fontello.com/网站提供的在线服务对上一步生成的字体库进行定制。
首先将生成的subset.ttf转为svg,笔者使用的是cloudconvert提供的服务。
然后将svg上传到fontello,选中要定制的字符,因为我们上传的字体库只包含0到9,所以这里全选,然后在Customize Codes功能下自定义码值。
自定义字符编码
码值与字符的关系可以看作是一种映射关系,比如Unicode E801对应字符1, Unicode E802对应字符2。我们可以随意修改字符的unicode值,但一定要记住这个值与真实字符的对应关系,来对要显示在页面上的数据加密。这里使用该网站默认生成的unicode。对应关系如下

1
2
3
4
5
6
7
8
9
10
11
12
CIPHER_BOOK = {
'0': '\uE800',
'1': '\uE801',
'2': '\uE802',
'3': '\uE803',
'4': '\uE804',
'5': '\uE805',
'6': '\uE806',
'7': '\uE807',
'8': '\uE808',
'9': '\uE809'
}

定制完成后下载字体文件

使用

在css中定义字体,名为fontello

1
2
3
4
5
6
@font-face {
font-family: 'fontello';
src: url('/static/fontello.woff2') format('woff');
font-weight: normal;
font-style: normal;
}

然后定义使用该字体的class

1
2
3
.demo-icon {
font-family: "fontello";
}

这样只需要为页面标签添加上’demo-icon’的class就可以了。如

1
<h1><small class="demo-icon">就是这串数字:<b>{{string}}</b></small></h1>

服务端在返回数据前需要需要将数字用CIPHER_BOOK进行转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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的字体加密。

破解

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方便肉眼分析。

1
2
3
4
from fontTools.ttLib import TTFont

font = TTFont('fontello.woff2')
font.saveXML('fontello.ttx')

得到的ttx为xml文档,打开并查找cmap节点
cmap节点
据此我们可以还原加密时的映射表(即cmap表)

1
2
3
4
5
6
7
8
9
10
11
12
CIPHER_BOOK = {
'\ue800': '0',
'\ue801': '1',
'\ue802': '2',
'\ue803': '3',
'\ue804': '4',
'\ue805': '5',
'\ue806': '6',
'\ue807': '7',
'\ue808': '8',
'\ue809': '9'
}

由于demo使用了静态的字体库,所以这个表不会变化,破解代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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节点
58cmap
通过观察对比发现,字符编码相同,但glyph名称是变化的,且glyph名称与真实数字的关系为

1
glyph_name = 'glyph00%02d' % (real_num + 1)

据此我们可以还原glyph名称与真实字符的映射表(即glyf表)

1
2
3
4
5
6
7
8
9
10
11
12
GLYF_TABLE = {
'glyph00001': '0',
'glyph00002': '1',
'glyph00003': '2',
'glyph00004': '3',
'glyph00005': '4',
'glyph00006': '5',
'glyph00007': '6',
'glyph00008': '7',
'glyph00009': '8',
'glyph00010': '9'
}

另外由于cmap表是变化的,所以需要在解密时提取,使用fonttools库可以实现

1
cmap = font['cmap'].getBestCmap()

返回一个dict,其中key为int型编码,v为glyph名称。
解密过程为

  1. 解析字库库,取得cmap
  2. 根据cmap查询字符编码,得到glyph名称
  3. 根据GLYF_TABLE查询glyph名称,得到真实字符

代码就不贴了,上传到gayhub了,有兴趣的可以下载看看。