当你看到篇文章的时候,我已经历过开发的一波三折,被 Python 与 JavaScript 双双按在地上反复摩擦......
刷课需求的缘起
在进入大学的第一学期末,由于疫情的影响选修课全部改到线上,使用学习通平台,需要看完网课视频并完成课后习题。按说都是“素质教育”的课程,但我和大多数同学一致认为没用,占用学习与娱乐时间,而这种课程又与学分挂钩,不得不使用一点“技俩”。
学习通的刷课操作主要分为两部分:
1.网课视频:必须从头到尾看完,不能随便调戏进度条君
2.课后习题:每章节都有习题,几乎都是选择和判断题,需要答完提交,有的课程存在考试(限时)
两部分都需要登录账号后进行,这里只探讨 web 端
开始刷课
对于网课视频,可以做到无人值守自动化运行,本着不重复造轮子(懒)的原则,在 github 上找到一个基于 node.js 和 Docker 的程序。写个 json 配置,打一行命令就开始刷了,毫无挑战性。
由于这个操作比较简单,由于自己服务器空闲算力充沛,也就接了帮同学批量刷课的活
但每单元都有课后习题,上面的程序对此无能为力。同样,瞄准 web 端的答题页,在 github 上找到一个支持答题的油猴脚本,它的原理是使用 JQuery 抓取题目文字,用题库 API 搜索,返回答案后命中选项并模拟点击提交。
但终究没那么多能让我白嫖的工具,题库 API 为会员制,超过请求阈值就风控,要求附带 Token,于是我只能开始造自己的轮子。在群友的推荐下,发现“学小易”APP可以用文字搜到测验中的题,随即就去逆向了它的请求协议,并用 flask 糊了个可直接接入油猴脚本的题库 API(已经开源在 github 上)。
SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy
刷课遇到困难
可好景不长,答题脚本就不能用了,毕竟这个领域黑灰产猖獗,学习通后台必然会想阴招封杀。
现象是题目和选项的文字编码被加密,用油猴脚本抓取以及复制的文本是乱码,干扰搜题操作。
进入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 源码中的文字内码与页面渲染出的可见文字不符,常用于反爬虫反复制的领域(如在线小说网站)。
进一步分析就需要找到加密的字体文件,这个操作相对简单,F12 工具里用Network
选项卡抓包,筛选Font
类型,可以看到传入的是一个 TTF 格式的字体文件
把抓包到的字体下载后放入编辑器查看,发现确实只加密了部分的文字,而且密文的 Unicode 编码与原字形一一对应
比如“墊”对应“国”,而这个加密字体中的“国”字内码就是uni588A
>>> f"uni{ord('墊'):X}"
'uni588A'
不怕困难,神挡杀神
破解思路
基于刚才的定性研究与定量分析,总结出以下几点有用的信息
1.只加密汉字字符
2.并不是全部文字都被加密,而是随机加密部分文字
3.密文字体与密钥的 Unicode 内码一一对应
4.密钥不固定,而是在页面加载时被传入
所以对应的破解思路已经有了,首先油猴脚本抓取题目时顺便抓取密钥字体,一并提交给后端,后端处理密钥字体为正确字形,建立密文汉字与原文汉字的映射关系,接着遍历字符串使用这个 Map 进行匹配替换,搜题的结果文字同理,加密回去返回给前端的油猴脚本。
加密字体hashMap化
对于 TTF 字体,可以理解成一个文字内码对应曲线的 key-value 数据库,字形都是矢量图形,数据就是一些贝塞尔曲线的顶点坐标集
因为字形是固定的,所以可以直接用这些顶点坐标来抽象实际的字形,而这些数据冗长而又低频,匹配的效率大打折扣,在这里使用 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);
数据库里存放如下数据(这里仅作技术探讨,所以正确文字全是手打)
在后端使用如下函数解码,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
加密字体的解码效果如下(请忽略解密失败的内容😭
成功解题 ohhhhhhhhhhhhhhhhhhh
答题一波三折 —— 选项匹配问题
在大量的解题中发现,有些题选项文本存在随机加密,如果按照传入的密钥字典无脑加密回去,还真的不一定和选项匹配,再者就是判断题的“正确”和“错误”不存在加密,所以这里需要特殊处理。
我的做法是在前端整一个“飞线”操作,把选项密文也回传给后端,再进行匹配,同样修改油猴脚本的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)
....
这样优化后,所有的选项都能够被正确命中
后记 —— 易姐的碎碎念
加密字体是用“思源黑体”魔改的,我找了很多的版本,都和它不能对应,实际生产中如果还用 SQLite 匹配、手动打标,那太费时费力了。如果处理的样本量大,可使用 OCR 技术(先把 TTF 中对应字形转为位图)。
另外学习通的 web 页面有概率爆出无字体加密的试题,可能是A|B测试或触发风控导致,具体机制也没研究明白,但是可以借此偷懒(逃
当然也可以尝试直接抓包分析发 http 请求,或者逆向 APP 端协议。
另外,字体加密这个操作常见于各种 CTF 的 misc 类题中,可以增加一些奇怪的经验。