本文操作只做技术讨论,请勿用于非法用途!

当你看到篇文章的时候,我已经历过开发的一波三折,被 Python 与 JavaScript 双双按在地上反复摩擦......

刷课需求的缘起

在进入大学的第一学期末,由于疫情的影响选修课全部改到线上,使用学习通平台,需要看完网课视频并完成课后习题。按说都是“素质教育”的课程,但我和大多数同学一致认为没用,占用学习与娱乐时间,而这种课程又与学分挂钩,不得不使用一点“技俩”。

学习通的刷课操作主要分为两部分:
1.网课视频:必须从头到尾看完,不能随便调戏进度条君
2.课后习题:每章节都有习题,几乎都是选择和判断题,需要答完提交,有的课程存在考试(限时)
两部分都需要登录账号后进行,这里只探讨 web 端

开始刷课

对于网课视频,可以做到无人值守自动化运行,本着不重复造轮子(懒)的原则,在 github 上找到一个基于 node.js 和 Docker 的程序。写个 json 配置,打一行命令就开始刷了,毫无挑战性。

QQ截图20220420211929.png

由于这个操作比较简单,由于自己服务器空闲算力充沛,也就接了帮同学批量刷课的活

QQ截图20220420214059.png

但每单元都有课后习题,上面的程序对此无能为力。同样,瞄准 web 端的答题页,在 github 上找到一个支持答题的油猴脚本,它的原理是使用 JQuery 抓取题目文字,用题库 API 搜索,返回答案后命中选项并模拟点击提交。

但终究没那么多能让我白嫖的工具,题库 API 为会员制,超过请求阈值就风控,要求附带 Token,于是我只能开始造自己的轮子。在群友的推荐下,发现“学小易”APP可以用文字搜到测验中的题,随即就去逆向了它的请求协议,并用 flask 糊了个可直接接入油猴脚本的题库 API(已经开源在 github 上)。
SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy

687474703a2f2f69302e6864736c622e636f6d2f6266732f616c62756d2f353165383231373330666166663761636561373666316236643433666266316531333961613233392e6a7067.jpg

刷课遇到困难

可好景不长,答题脚本就不能用了,毕竟这个领域黑灰产猖獗,学习通后台必然会想阴招封杀。
现象是题目和选项的文字编码被加密,用油猴脚本抓取以及复制的文本是乱码,干扰搜题操作。

QQ截图20220426125336.png

进入F12检查html源码,发现题目的编码果然被加密,所以得出不是复制时被干扰,而是在 html 被渲染时才以某种方式解密。

<div class="clearfix font-cxsecret"
style="line-height: 35px; font-size: 14px;padding-right:15px;">
【单选题】墊域民埞照塤埛路埛塒埠以下堝嘢?
</div>

初步分析这道题,与可见的文字对比,可得出一套类似凯撒密码加密的映射关系
“电”出现了两次,同时对应相同位置的“埛”,更可以肯定这是一套映射关系
而“民”、“照”、“路”、“以”、“下”字形编码正常,可以说明是部分字体加密(加扰),相对破解的难度提高

墊 -> 国
域 -> 内
埞 -> 用
塤 -> 明
埛 -> 电
塒 -> 压
埠 -> 为
堝 -> 哪
嘢 -> 种

使用控制变量法研究,切换不同账号的相同答卷,或相同账号的不同答卷,其中的映射关系也随之改变,可以分析出“密钥”不是一成不变的,而是随机生成的。

题目标签的.font-cxsecret类引起了我的注意,当我在 F12 中把该 css 类选择器的font-family属性关闭,就会发现整个页面的题目字体全部乱码,与刚才复制到及 html 源码中看到的相同,所以分析出这是一种典型的“前端字体加密”,效果是 html 源码中的文字内码与页面渲染出的可见文字不符,常用于反爬虫反复制的领域(如在线小说网站)。

QQ截图20220426131605.png

进一步分析就需要找到加密的字体文件,这个操作相对简单,F12 工具里用Network选项卡抓包,筛选Font类型,可以看到传入的是一个 TTF 格式的字体文件

QQ截图20220426132359.png

把抓包到的字体下载后放入编辑器查看,发现确实只加密了部分的文字,而且密文的 Unicode 编码与原字形一一对应

QQ截图20220426132650.png

比如“墊”对应“国”,而这个加密字体中的“国”字内码就是uni588A

>>> f"uni{ord('墊'):X}"
'uni588A'

不怕困难,神挡杀神

破解思路

基于刚才的定性研究与定量分析,总结出以下几点有用的信息
1.只加密汉字字符
2.并不是全部文字都被加密,而是随机加密部分文字
3.密文字体与密钥的 Unicode 内码一一对应
4.密钥不固定,而是在页面加载时被传入

所以对应的破解思路已经有了,首先油猴脚本抓取题目时顺便抓取密钥字体,一并提交给后端,后端处理密钥字体为正确字形,建立密文汉字与原文汉字的映射关系,接着遍历字符串使用这个 Map 进行匹配替换,搜题的结果文字同理,加密回去返回给前端的油猴脚本。

加密字体hashMap化

对于 TTF 字体,可以理解成一个文字内码对应曲线的 key-value 数据库,字形都是矢量图形,数据就是一些贝塞尔曲线的顶点坐标集

QQ截图20220426135159.png

因为字形是固定的,所以可以直接用这些顶点坐标来抽象实际的字形,而这些数据冗长而又低频,匹配的效率大打折扣,在这里使用 hash 算法可以得到定长而又高频的数据,便于匹配

我用 Python 写了一个使用 TTF 文件生成 Unicode内码-字形hash Map 的一个小程序,hash 算法为 sha256
并用刚才下载的文件生成

import base64
import hashlib
import struct
from io import BytesIO
from pathlib import Path
from typing import IO, Dict, Union

def secFont2Map(file: Union[IO, Path, str]) -> Dict[str, str]:
    '以加密字体计算hashMap'
    fontHashMap = {}
    if isinstance(file, str):
        file = BytesIO(base64.b64decode(file[47:]))
    with TTFont(file) as fontFile:
        glyphs = fontFile.getGlyphSet()
        for code, font in dict(glyphs).items():
            if not code.startswith('uni'):
                continue
            fontHash = hashlib.sha256()
            for pos in font._glyph.coordinates:
                fontHash.update(struct.pack('>2i', *pos))
            fontHashMap[code] = fontHash.hexdigest()
    return fontHashMap

if __name__ == "__main__":
    import rich
    fontHashMap = secFont2Map(Path('font.ttf'))
    rich.print(fontHashMap)

运行结果如下

{
    'uni57F3': 'a599dec4080ef63012a7ed0a11e0a112b98ddf65784a2887f548c1acd3567ec8',
    'uni57E0': '0f99adcd5c1397572f9f941c0b348f5beab74cc2bf68022bdc42e541102cb3ec',
    'uni57F0': 'de9f5336a07d85075d98875b4380b32755cabba3e0ee901031373223088f8d2f',
    'uni57E4': '570d21b5de780cd2059eb9760084686e22e93c09cc9dd7e012feb29938403061',
    'uni57E1': 'b226b96598307098ca6ea2beb765a5e43e9ac34ba5181eca8ef11ffd40ea0c28',
    'uni57F1': 'ca30cbc1b55b188593d3bfa568f37f9ba916b5a2734bb97ab6750b661771cf54',
    'uni57E9': '9cde8cf5dc25fa3c6de6e0e416faa9e908a9751e6b033711c7985900800f8655',
    'uni57EB': 'c1f031b07213b547f7ab48ed8b7f1a9c2dd5f83656d3827cb2bfe5ef01154461',
    'uni5800': '367287b43ad5070bebe91dc665b2f6286341263d7d7a00fc2dcebb0a727bd666',
    'uni57F2': 'ca5b9f3ac3359e642a693275aad1a8924130c9bb064525a70bbabb03f643e18f',
    'uni57DF': '4a0b8f59b1308b8dc944dea3ae20c14dc1b64f3b10f25b46f6f2b2f5ce300d1d',
    # 此处省略一万字......
}

前端字体的抓取

回到 html 层面的分析上,找到密钥字体的传参方式才能正确抓取,搜索刚才 css 中的字体标识font-cxsecret找到以下 css 内容

@font-face{
    font-family:'font-cxsecret';
    src:url('data:application/font-ttf;charset=utf-8;base64,AAEAAAAMAIAAAwBAQkFTRRuOGNgAADMYAAAA5E9TLzKUGwCtAAABSAAAAGBWT1JHUavDeAAAM/wAAAN0Y21hcPeX/oAAAAIMAAAA9GdseWbTZT4AAAADyAAAHvRoZWFkBmbCYQAAAMwAAAA2aGhlYQzu/tUAAAEEAAAAJGhtdHgI7wXUAAABqAAAAGRsb2NhAALM6AAAAwA......');
}

再到Network选项卡中抓 html 的原始请求,发现已经通过内联的方式传入了这个 css,所以加密和生成密钥字体的技术是标准的 SSR(服务端渲染)而非 CSR(客户端渲染),更好操作。

在油猴脚本的findAnswer()函数下添加语句,用来增加回传给后端的字段,这里用 JQuery 判断答题页面有无传特定内联 css(需要分类讨论无加密的情况),如果有就抓取该 css 内的字体 base64。并把之前的 xhr 请求从 GET 改为 POST,这样可以传输大量的数据,通过表单传密钥参字体的 base64

...
// 拾取加密字体
var secFont = '';
if($("style[type='text/css']").length != 0){
    secFont = $("style[type='text/css']").text().match(/'(data:application\/font-ttf;.*?)'/)[1];
}
...
...
GM_xmlhttpRequest({
    method: "POST",
    url: api_array[setting.api],
    data:'question='+encodeURIComponent(question)+'&secFont='+encodeURIComponent(secFont),
...

如何命中字形

现在前端抓取和后端的hash都已经完成,那么就需要建立一套字形hash <--> 正确文字的匹配机制,由于存储量大并且要做双向查询,这里使用 SQLite 数据库

# 创建数据库
CREATE TABLE "hashmap" (
  "cn_char" TEXT(1) NOT NULL,
  "hash" TEXT(64) NOT NULL,
  PRIMARY KEY ("cn_char")
);
# 创建两个索引
CREATE UNIQUE INDEX "index_cn_char" ON "hashmap" ("cn_char" ASC);
CREATE INDEX "index_hash" ON "hashmap" ("hash" ASC);

数据库里存放如下数据(这里仅作技术探讨,所以正确文字全是手打)

QQ截图20220426144455.png

在后端使用如下函数解码,hashMap为使用前端回传的密钥字体生成,source是待解密的密文,返回的是解码后的字符串(数据库 DAO 部分程序省略)

def secFontDec(hashMap, source) -> str:
    '解码字体加密'
    dao = FontHashDAO()
    resultStr = ''
    for char in source:
        unicodeID = f'uni{ord(char):X}'
        if (fontHash := hashMap.get(unicodeID)):
            originChar = dao.findChar(fontHash)
            if originChar is not None:
                resultStr += originChar
            else:
                print(Fore.RED+f'解码失败: {char}({fontHash})'+Fore.RESET)
        else:
            resultStr += char
    print(Fore.GREEN+f'字体加密解码: {source} -> {resultStr}'+Fore.RESET)
    return resultStr

加密字体的解码效果如下(请忽略解密失败的内容😭

QQ图片20220426153529.png

成功解题 ohhhhhhhhhhhhhhhhhhh

QQ图片20220426153513.png

答题一波三折 —— 选项匹配问题

在大量的解题中发现,有些题选项文本存在随机加密,如果按照传入的密钥字典无脑加密回去,还真的不一定和选项匹配,再者就是判断题的“正确”和“错误”不存在加密,所以这里需要特殊处理。

QQ图片20220426152748.png

我的做法是在前端整一个“飞线”操作,把选项密文也回传给后端,再进行匹配,同样修改油猴脚本的findAnswer()函数,每个选项间用“#”分隔。

...
// 回传答案用以后端命中
var answers = $TiMu.find("a"),
    answersText='';
for(var i=0;i<answers.length;i++){
    answersText += ('#'+filterImg(answers.eq(i)));
}
GM_xmlhttpRequest({
    method: "POST",
    url: api_array[setting.api],
    data:'question='+encodeURIComponent(question)+'&answers='+encodeURIComponent(answersText)+'&secFont='+encodeURIComponent(secFont),
...

如果前端给后端回传了选项列表,那么就逐个选项解密,并与搜到的答案进行比对,如果相似度超过 95% 就直接返回原始答案(密文)命中,否则直接加密搜到的答案(兜底的下下策)返回

...
print(f'原始答案: {answer}')
# 直接命中原目标答案
if answer != '错误' and answer != '正确':
    if targetAnswers is not None:
        for originAnswer in targetAnswers:
            if difflib.SequenceMatcher(
                None,
                secFontDec(fontHashMap, originAnswer) if (fontHashMap is not None) else originAnswer,
                answer
            ).quick_ratio() >= 0.95: # 比较答案相似度
                answer = originAnswer
                break
# 编码答案文本 (可能不一一对应)
else:
    answer = secFontEnc(fontHashMap, answer)
....

这样优化后,所有的选项都能够被正确命中

QQ图片20220426153305.png

后记 —— 易姐的碎碎念

加密字体是用“思源黑体”魔改的,我找了很多的版本,都和它不能对应,实际生产中如果还用 SQLite 匹配、手动打标,那太费时费力了。如果处理的样本量大,可使用 OCR 技术(先把 TTF 中对应字形转为位图)。
另外学习通的 web 页面有概率爆出无字体加密的试题,可能是A|B测试或触发风控导致,具体机制也没研究明白,但是可以借此偷懒(逃
当然也可以尝试直接抓包分析发 http 请求,或者逆向 APP 端协议。
另外,字体加密这个操作常见于各种 CTF 的 misc 类题中,可以增加一些奇怪的经验。

本文操作只做技术讨论,请勿用于非法用途!