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

| |

写在前面

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

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

分析过程

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

image-20240220182057590

@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;
}

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

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

根据代码所示, 字的编码应为 uni4E0B,而从学习通下载的字体中 字的编码为 uni5DD5,实际文字应为 。我这里使用的字体查看器是 FontLab,你也可以使用 在线字体编辑器

image-20240308141743656

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

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

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

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

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

# 学习通字体
<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

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

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 貌似是唯一的,我们直接尝试获取这四个值

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 元素截图能达到更好的效果。

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

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
版权声明: 本文采用 CC BY-NC-SA 3.0 CN 协议进行许可