iami233
iami233
文章175
标签37
分类4

文章分类

文章归档

从学习通复制文字乱码看前端版权保护

从学习通复制文字乱码看前端版权保护

写在前面

起因是在学习通答题的时候突然发现复制出来的内容是乱码,具体示例如下

1
2
通过修改HTTP headers 中的哪个键值可以伪造来源网址
嶲嶱修改HTTP headers 中的哪嶮嶭值嶰以嶯造来嶬网址

分析过程

通过测试,发现仅 章节检测 中存在文字复制乱码,其他答题页面不存在,我们直接通过 devtools 定位到字体文件

image-20240220182057590

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@font-face {
font-family:'font-cxsecret';
src:url('data:application/font-ttf;charset=utf-8;base64,AAEAAAAMAIAAAwBAQkFTRRuOGNgAAFWcAAA...') format('truetype');
}
.font-cxsecret,
.font-cxsecret p,
.font-cxsecret div,
.font-cxsecret i,
.font-cxsecret em,
.font-cxsecret b,
.font-cxsecret strong,
.font-cxsecret a,
.font-cxsecret font,
.font-cxsecret span,
.font-cxsecret pre,
.font-cxsecret code {
font-family: 'font-cxsecret' !important;
}

将字体文件存储后,我们发现貌似仅仅是字形名称进行了更改

1
2
3
4
>>> f"uni{ord('下'):X}"
'uni4E0B'
>>> chr(int('uni5DD5'[3:], 16))
'巕'

根据代码所示, 字的编码应为 uni4E0B,而从学习通下载的字体中 字的编码为 uni5DD5,实际文字应为

我这里使用的是 FontLab,你也可以使用 在线字体编辑器

image-20240308141743656

我们获取学习通的字体信息,检索下载原版 思源黑体 字体文件

1
SourceHanSansCN-Normal · Regular · Version 1.000;PS 1;hotconv 1.0.78;makeotf.lib2.5.61930

然后通过 TTFont 库分别获取 学习通字体思源黑体 的字形数据

1
2
3
4
5
from fontTools.ttLib import TTFont
# font = TTFont('学习通.ttf')
# font.saveXML('学习通.xml')
font = TTFont('Source Han Sans CN Normal.ttf')
font.saveXML('学习通.xml')

将获取到的字形数据进行对比,我们发现除了 name 不同外,字形数据是一致的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 学习通字体
<TTGlyph name="uni5DD5" xMin="59" yMin="-72" xMax="942" yMax="762">
<contour>
<pt x="515" y="695" on="1"/>
<pt x="515" y="517" on="1"/>
...
<pt x="942" y="762" on="1"/>
<pt x="942" y="695" on="1"/>
</contour>
<instructions/>
</TTGlyph>
# 原版思源黑体
<TTGlyph name="uni4E0B" xMin="59" yMin="-72" xMax="942" yMax="762">
<contour>
<pt x="515" y="695" on="1"/>
<pt x="515" y="517" on="1"/>
...
<pt x="942" y="762" on="1"/>
<pt x="942" y="695" on="1"/>
</contour>
<instructions/>
</TTGlyph>

字体解密

那么到此我们就已经理顺了学习通字体的加密思路,简单来说就是以下几个步骤:

  1. 更改字形名称:学习通将原版思源黑体的字形名称进行了更改,例如, 字的编码从 uni4E0B 变为 uni5DD5。这种更改使得直接查看字体文件时,无法直接对应到正确的字符。
  2. 保持字形数据不变:尽管字形名称发生了变化,但是字形数据并没有改变。这意味着,如果我们能够找到字形名称的映射关系,就可以正确地解析出字符。

因此,要解密学习通的字体,我们需要做的就是找到字形名称的映射关系。这可以通过比较学习通字体和原版思源黑体的字形数据来实现。

但是经过一系列测试,发现有些坐井观天了,pt 里面的数据只有 横竖 相同,涉及到 撇捺 的字体,学习通对其进行了一些简单位移

具体如下图所示,左侧为原版字体,右侧为学习通字体

image-20240314184442887

所以我们只能尝试忽略曲线,看看能不能达到预期效果

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
31
32
33
34
35
36
37
38
39
40
41
42
43
import json
import hashlib
from fontTools.ttLib import TTFont
from fontTools.pens.basePen import BasePen

class GlyphPen(BasePen):
def __init__(self, glyphSet):
BasePen.__init__(self, glyphSet)
self.points = []

def _moveTo(self, p):
self.points.append(("move", p))

def _lineTo(self, p):
self.points.append(("line", p))

def _curveToOne(self, p1, p2, p3):
# 当遇到曲线时,什么都不做
pass

def get_points(self):
return self.points

# file_path = "Source Han Sans CN Normal.ttf"
file_path = "学习通.ttf"
font = TTFont(file_path)

# 提取字形信息
glyph_set = font.getGlyphSet()
result = {}

# 遍历字体中的所有字形
for name in glyph_set.keys():
# 使用自定义的 GlyphPen 提取字形轮廓
pen = GlyphPen(glyph_set)
glyph = glyph_set[name]
glyph.draw(pen)

points_str = json.dumps(pen.get_points())
md5 = hashlib.md5(points_str.encode()).hexdigest()
result[name] = md5

print(json.dumps(result, indent=4))

此时,比对后发现又出现问题,比对仅仅成功了四分之一,譬如 这类整个字体中撇捺占大部分情况下会匹配失败。

后来,又发现 xMinyMinxMaxyMax 貌似是唯一的,我们直接尝试获取这四个值

1
2
3
4
5
6
7
8
9
10
11
12
13
for name in glyph_set.keys():
if name.startswith("uni"):
glyph = glyph_set[name]
pen = BoundsPen(glyph_set)
glyph.draw(pen)

bounds = pen.bounds
try:
md5_input = f"xMin={bounds[0]}, yMin={bounds[1]}, xMax={bounds[2]}, yMax={bounds[3]}"
md5 = hashlib.md5(md5_input.encode('utf-8')).hexdigest()
result[name] = md5
except:
pass

匹配结果相对比获取 字形轮廓 还是毕竟可观的,但成功率仅仅停留在 90% 左右 ,部分字体无法比对出正确字体。

原本以为找到映射关系即可,但不知道是学习通有意为之,还是前面找到的原版字体不对。

至此,整个分析过程以失败结束,虽然结局并不完美,但终究还是收获了一些前端版权保护的思路。

写在后面

或许,两种方法结合又或者通过 selenium 元素截图能达到更好的效果。

另外,附比对时用的代码一份

1
2
3
4
5
6
7
8
9
10
11
12
def compare_json(file1, file2):
with open(file1, 'r') as f:
data1 = json.load(f)
with open(file2, 'r') as f:
data2 = json.load(f)
reverse_data1 = {v: k for k, v in data1.items()}
for k, v in data2.items():
if v in reverse_data1:
try:
print(f"{chr(int(k[3:], 16)), k}: {chr(int(reverse_data1[v][3:], 16)), reverse_data1[v]}")
except:
print(f"{k}: {reverse_data1[v]}")

最后,通过检索 Github 项目,发现一个 TellMeYourWish/chaoxing_solution_of_font_confusion 项目中提供了一些映射关系,但目前并不知两个 pkl 文件从何而来。

本文作者:iami233
本文链接:https://5ime.cn/xxt_font.html
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可