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

前情提要
书接上回,学习通答题页在未答时,考虑到防止作弊存在字体加密,复制或者直接用脚本搜索会表现为乱码,为了刷题,于是乎我给搜题后端整合了一个复杂的加解密逻辑

又到了完成网课的时间,不过这次是《军事理论》这门课

这次需要刷更多的题,以前写的程序 bug 百出,望着自己的屎山代码发呆,根本无从下手修改,寻思一定是架构设出了问题

重拾本源,厘清思路

首先学习通的答题页面是 SSR(Server Side Render),原始密文和密钥(加密字体)都通过 html 回传至浏览器

而经过加密的文本是随机替换掉一些字体的编码看上去是混有部分乱码的文本,把乱码和正确字形对应起来放在传入的 TTF 加密字体中,在前端渲染后视觉上是正确的,但复制后人类和机器都不太容易阅读,加密原理就这么简单

使用 F12 工具分析 html 结构得知,每道题目div中均含有Timu类,题干和选项略楼不同,如下:

QQ截图20221017122840.png

题干是div下存在font-cxsecret类,文本就是标签内容

<div class="clearfix font-cxsecret fontLabel" style="line-height: 1.5; font-size: 14px;padding-right:15px;">【单选题】从1842喣喟1937喣喡喢被喠签喞了()个不喤等条约。</div>

选项在div下存在font-cxsecret类,但文本存在于该标签下的a子标签下

<li class="font-cxsecret">
    <label class="fl before">
        <input name="answer212017500" type="radio" value="A">&nbsp;&nbsp;A
    </label>
    <a href="javascript:void(0);" class="fl after" style="padding-left:10px;">    俄坌坍 </a>
    <div class="clear"></div>
</li>

规定密文区段使用font-cxsecret class,在 css 中为该 class 指定了 font-family,字体也是 SSR 的以 base64 编码放在其中,之所以密文可以和正常文本混合,是因为 css 字体的 fallback 机制

如下:

<style type="text/css">
@font-face{
   font-family: 'font-cxsecret';
   src: url('data:application/font-ttf;charset=utf-8;base64,AAEAAAAMAIAAAwBAQkFTRRu。。。。。。。') 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-family: 'font-cxsecret' !important;
}
</style>

所以,为什么要整那么复杂捏?

会不会存在一种可能性,就是不在搜题过程中实现加解密、处理各种复杂情况,而在搜题前直接把这些密文替换掉?

还是使用和上次相同的技术栈,前端油猴脚本 + 后端 Flask

有了以上的归纳总结,再配合自己所学的那么一点点 JQuery(学习通前端使用了JQ)和 JS 编程经验,就可以利用油猴脚本提前抓取字体数据、以及这些经过加密的文本,并进行遍历请求,让后端解密,从而以明文取代掉密文

先定义一个抓取加密字体的base64的函数,使用正则表达式暴力匹配带有type="text/css"属性的style标签中的文本

// 拾取加密字体
function getSecFont() {
    return $("style[type='text/css']").text().match(/'(data:application\/font-ttf;.*?)'/)[1]
}

QQ截图20221017122117.png

由上文可得,抓取所有题干及选项文本,就是几个特定 class 的他特定标签内容
所以我们写一个 JQ 选择器,同时匹配每道题的题干以及全部选项,化繁为简

$('.TiMu').find('div.font-cxsecret,.font-cxsecret a')

QQ截图20221017123607.png

下一步就是向后端回传数据了
通过查阅文档,了解到油猴脚本中创建跨域请求需要用到一个 API 函数GM_xmlhttpRequest而不能使用传统的 xhr 以及JQ.ajax
这里使用JQ的.each()方法遍历选择器的匹配列表,使用快捷方法$(this)得到遍历的当前项,再把刚才所抓取的字体数据一并序列化成 json POST 给后端
当后端解密返回响应时,异步执行一个回调函数,替换掉当前标签的文本,并敲除font-cxsecret

// 解密全部字体
function decryptAll() {
    let secFont = getSecFont(),
        encryptTexts = $('.TiMu').find('div.font-cxsecret,.font-cxsecret a');
    // 遍历加密字体项
    encryptTexts.each(function() {
       let dstText = $(this);
        GM_xmlhttpRequest({
            method: 'POST',
            url: `${setting.tiku}/decrypt`,
            headers: {
                'Content-type': 'application/json',
            },
            data: JSON.stringify({secFont: secFont, dstText: dstText.text().trim()}),
            responseType: 'json',
            onload: function (xhr) {
                 if (xhr.status == 200) {
                     dstText.text(xhr.response.srcText);
                     dstText.removeClass('font-cxsecret');
                 }
             }
         });
    });
}

函数decryptAll()放在开始搜题的处理之前执行,那么搜题时就全是明文了(搜题部分不在本篇探讨范围内,略过。。。

这样一来,后端代码更简洁了,彻底把两部分做了解耦,分成两个独立的 rest API 让前端调用
注册/decrypt路径为解密接口,核心的逻辑调用了上次写好的两个函数,传入字体创建 hash map、执行字符串解密操作

@app.route('/decrypt', methods=('POST',))
def decryptView():
    args = json.loads(request.data)
    key_font_b64 = args['secFont']
    dst_text = args['dstText']
    font_hashmap = cxsecret_font.font2map(key_font_b64) # 创建加密字体hash map
    src_text = cxsecret_font.decrypt(font_hashmap, dst_text) # 解密目标文本
    print(f'{Fore.GREEN}解密成功{Fore.RESET}: {Fore.YELLOW}{dst_text}{Fore.RESET} -> {Fore.GREEN}{src_text}{Fore.RESET}')
    return {
        'srcText': src_text
    }

字体的hashmap算法也要优化

由于我上回写的字形 hash 算法极为拙劣,经常导致匹配失败,而且还大材小用了 sqlite,整体效果不尽人意
最近看到吾爱破解论坛上大佬@featmellwo的帖子,他用源字体做好了一个 hashmap,算法比我的更优化,经测试可以正确匹配

参考他的代码,我把单个字形的编码部分单独提出为一个函数

def hash_glyph(glyph: Glyph) -> str:
    'ttf字形曲线转hash算法实现'
    pos_bin = ''
    last = 0
    for i in range(glyph.numberOfContours):
        for j in range(last, glyph.endPtsOfContours[i] + 1):
            pos_bin += f'{glyph.coordinates[j][0]}{glyph.coordinates[j][4]}{glyph.flags[j] & 0x01}'
        last = glyph.endPtsOfContours[i] + 1
    return hashlib.md5(pos_bin.encode()).hexdigest()

然后再遍历 TTF 中每个字形的数据,用来生成 hashmap

def font2map(file: Union[IO, Path, str]) -> dict[str, str]:
    '以加密字体计算hashMap'
    font_hashmap = {}
    if isinstance(file, str):
        file = BytesIO(base64.b64decode(file[47:]))
    with TTFont(file) as fontFile:
        for code, glyph in dict(fontFile.getGlyphSet()).items():
            font_hashmap[code] = hash_glyph(glyph._glyph)
    return font_hashmap

接下来就是我偷懒了,这里直接使用大佬做好的字体 hashmap 数据库,不打算使用 sqlite

QQ截图20221017140914.png

而源字体 hashmap 数据库的 DAO 已经改成了基于 py 内置的 dict 类型,这样全部加载进 RAM 查询效率不比 sqlite 低
json DB(确信

class FontHashDAO:
    char_map: dict[str, str] # unicode -> hsah
    hash_map: dict[str, str] # hash -> unicode
    
    def __init__(self, file='font_map.json'):
        with open(file, 'r') as fp:
            map: dict = json.load(fp)
        self.char_map = map
        self.hash_map = dict(zip(map.values(), map.keys()))

    def find_char(self, font_hash: str) -> str:
        return self.hash_map.get(font_hash)
    
    def find_hash(self, char: str) -> str:
        return self.char_map.get(char)

在运行后观察后端的日志可以发现,在搜题之前批量执行了一系列的解密处理

QQ截图20221017143657.png

奇奇怪怪的汉字

在修改新的解密逻辑后,搜题往往会出现许多字体看着一致,但无法匹配答案的离奇 bug,比如下图中的综合国力

QQ截图20221017144329.png

这个油猴脚本的答题逻辑,就是匹配后端返回的字符串结果,与现有选项的文本进行比对,从而决定选择题的 ABCD 选项(这里是个form表单)
如果无法匹配,那么一定是字符串的 Byte 不一致,而不是“看起来不一样”
抱着好奇的态度 copy 进 py 比对一下,果然不一样

>>> '综合国⼒' == '综合国力'
False

逐个汉字符比对后,最终确定是字特殊,它们虽然字形相同(可能不同字体也有差别),但是 unicode 的内码不相同

>>> ord('⼒'), ord('力')
(12050, 21147)

经过查阅资料,我发现这种和普通汉字字形相同,但是 unicode 内码不同的字叫做康熙部首,它位于 unicode 的 2F00—2FDF 区段,当然它们和普通的汉字一一对应,出现此误码的原因可能是一些字体厂家没有把这两种编码的字形做区分

QQ截图20221017150512.png

解密后再加一步,使用 py 自带的str.translate()方法,把康熙部首替换为对应的汉字

# 康熙部首替换表
KX_RADICALS_TAB = str.maketrans(
    # 康熙部首
    "⼀⼁⼂⼃⼄⼅⼆⼇⼈⼉⼊⼋⼌⼍⼎⼏⼐⼑⼒⼓⼔⼕⼖⼗⼘⼙⼚⼛⼜⼝⼞⼟⼠⼡⼢⼣⼤⼥⼦⼧⼨⼩⼪⼫⼬⼭⼮⼯⼰⼱⼲⼳⼴⼵⼶⼷⼸⼹⼺⼻⼼⼽⼾⼿⽀⽁⽂⽃⽄⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿⾀⾁⾂⾃⾄⾅⾆⾇⾈⾉⾊⾋⾌⾍⾎⾏⾐⾑⾒⾓⾔⾕⾖⾗⾘⾙⾚⾛⾜⾝⾞⾟⾠⾡⾢⾣⾤⾥⾦⾧⾨⾩⾪⾫⾬⾭⾮⾯⾰⾱⾲⾳⾴⾵⾶⾷⾸⾹⾺⾻⾼髙⾽⾾⾿⿀⿁⿂⿃⿄⿅⿆⿇⿈⿉⿊⿋⿌⿍⿎⿏⿐⿑⿒⿓⿔⿕⺠⻬⻩⻢⻜⻅⺟⻓",
    # 对应汉字
    "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉采里金長門阜隶隹雨青非面革韋韭音頁風飛食首香馬骨高高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠民齐黄马飞见母长"
)

这之后,一切加密和编码问题迎刃而解

最后来一张全部题刷完的图

QQ截图20221017155730.png

后记

这次解决了之前源字体字形匹配失败的问题,可能就是因为字形hash的算法问题导致的,结果钻了牛角尖手动打表(笑死
也算是在实践中多学习一些字体编码的知识了

另外,基于“奥卡姆剃刀”原理,后端的业务逻辑不需要在单一的服务或接口上做各种复杂的处理,把服务解耦后效率更高、出错率更低

关联项目 https://github.com/SocialSisterYi/xuexiaoyi-to-xuexitong-tampermonkey-proxy
在此感谢吾爱破解论坛大佬@featmellwo的帖子

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