【JavaScript 逆向】猿人学 web 第七题:动态字体,随风漂移

案例目标        

网址:第七题 动态字体,随风漂移 - 猿人学

本题目标:采集 5 页中胜点列的数据,找出胜点最高的召唤师,将召唤师姓名填入答案中

字体反爬

演变的基本阶段:

1. 固定字体编码、固定字体坐标

2. 动态字体编码、固定字体坐标

3. 动态字体编码、动态字体坐标

字体反爬的原理:前端工程师通过自定义的字体来替换页面中某些关键的数据,在 HTML 中使用 @font-face 自定义字体:

@font-face {
 font-family: <identifier>; 
 src: <fontsrc> [, <fontsrc>]*; <font>;
  }

里面的 font-family 也就是一个特定的名字,src 就表示需要引用的具体的文件,而这个文件就是字体文件,一般是 ttf 类型,eot 类型,现在因为 ttf 类型文件过大,在移动端使用的时候会导致加载速度过慢,woff 类型的文件较为广泛应用。

更多相关推荐阅读:谈谈字体反爬的前世今生

常规 JavaScript 逆向思路

一般情况下,JavaScript 逆向分为三步:

  • 寻找入口:逆向在大部分情况下就是找一些加密参数到底是怎么来的,关键逻辑可能写在某个关键的方法或者隐藏在某个关键的变量里,一个网站可能加载了很多 JavaScript 文件,如何从这么多的 JavaScript 文件的代码行中找到关键的位置,很重要;
  • 调试分析:找到入口后,我们定位到某个参数可能是在某个方法中执行的了,那么里面的逻辑是怎么样的,调用了多少加密算法,经过了多少赋值变换,需要把整体思路整理清楚,以便于断点或反混淆工具等进行调试分析;
  • 模拟执行:经过调试分析后,差不多弄清了逻辑,就需要对加密过程进行逻辑复现,以拿到最后我们想要的数据

接下来开始正式进行案例分析:

寻找入口

F12 打开开发者人员工具,刷新网页进行抓包,在 Network 中可以看到数据接口为 7,响应预览中可以看到当前页面各玩家胜点数据被混淆了:

最底下的部分为 woff 文件:

woff: "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"

woff 文件是字体文件,实际上就是编码和字符的映射表,如 &#xc134,&#x 是字符前缀,c134 是字符对应的编码,本题字体文件和胜点列的数据都在一个接口里,从 Initiator 中向下跟栈到 request 对应的 7:formatted 文件中:

ctrl + f 局部搜索 woff 关键字,有一个结果,在该文件的第 993 行

ttf = data.woff;
$('.font').text('').append('<style type="text/css">@font-face { font-family:"fonteditor";src: url(data:font/truetype;charset=utf-8;base64,' + ttf + '); }</style>');

src 为下载路径,这里可以看到 woff 文件被保存为了 ttf 格式,通过 python 将其下载下来:

import base64

# 填入 woff 后字符串中的内容
woff_content = "AAEAA........jMjUx"
with open('./yrx7.ttf', 'wb') as f:
    f.write(base64.b64decode(woff_content))

调试分析 

经过测试,字体编码是在动态变化的,所以无法直接对应替换: 

import requests

headers = {
    "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36",
}

url = "https://match.yuanrenxue.com/api/match/7"

for _ in range(3):
    response = requests.get(url, headers=headers)
    print(response.json()['data'][0])

# {'value': '&#xc514 &#xc496 &#xc514 &#xa894 '}
# {'value': '&#xb287 &#xa568 &#xb287 &#xb127 '}
# {'value': '&#xb953 &#xb493 &#xb953 &#xe643 '}

所以我们需要通过观察字体文件的具体内容来寻找突破口,通过 fontTools 库将 ttf 格式文件转换为 xml 格式文件: 

fontTools 官方文档:fontTools Docs — fontTools Documentation 

Github fontTools:GitHub - fonttools/fonttools: A library to manipulate font files fromPython.

pip3 install fonttools
from fontTools.ttLib import TTFont

font = TTFont('./yrx7.ttf')
font.saveXML('./yrx7.xml')

先来分析下该 ttf 文件的结构: 

from fontTools.ttLib import TTFont

font = TTFont("yrx7.ttf")
# 获取 ttf 文件中的表名
list_name = font.keys()
print(list_name)
# ['GlyphOrder', 'head', 'hhea', 'maxp', 'OS/2', 'hmtx', 'cmap', 'loca', 'glyf', 'name', 'post']

表名包含 glyf 关键字,表示图元数据,以序列形式保存,每个图元以图元头(GlyphHeader)结构开始,表明这是一个使用 TrueType 轮廓的字体文件 : 

TrueType:Windows 和 Mac 系统最常用的字体格式,基于轮廓技术的数学模式来进行定义,比基于矢量的字体更容易处理,保证了屏幕与打印输出的一致性,同时,这类字体和矢量字体一样可以随意缩放、旋转而不必担心会出现锯齿。

TTGlyph 的两个子类 TTGlyphGlyf 和 _TTGlyphCFF 分别对应 TrueType 轮廓和 Postscript 轮廓:

from fontTools.ttLib import TTFont

font = TTFont("xxx.ttf")
glyph = font.getGlyphSet()["uni70E0"] # 获取 _TTGlyph 实例
print(glyph._glyph.coordinates) # 坐标
print(glyph._glyph.endPtsOfContours) # 轮廓结束点
print(list(glyph._glyph.flags)) # 点类型 flag

生成的 xml 文件对应的字体编码部分如下:

  <GlyphOrder>
    <!-- The 'id' attribute is only for humans; it is ignored when parsed. -->
    <GlyphID id="0" name=".notdef"/>
    <GlyphID id="1" name="unif472"/>
    <GlyphID id="2" name="unif865"/>
    <GlyphID id="3" name="unic846"/>
    <GlyphID id="4" name="unic251"/>
    <GlyphID id="5" name="unif165"/>
    <GlyphID id="6" name="unib294"/>
    <GlyphID id="7" name="unib721"/>
    <GlyphID id="8" name="unic215"/>
    <GlyphID id="9" name="unib241"/>
    <GlyphID id="10" name="unie291"/>
  </GlyphOrder>

字体编码为 unic846  的字体坐标:

刷新网页获取新的 woff 值,再下载保存一份 ttf 文件作对比,会发现字体坐标 x、y 是动态变化的,但是 on 的值是不变的:

通过比较,其他字符的 on 值也不变,所以可以用过 on 值形成映射关系从而进行识别,将下载后的 ttf 文件放到 在线字体编辑器-FontStore 中打开,这里还原出了每个加密编码对应的数字字符:

根据 ttf 文件编码顺序对应更改,我的 ttf 文件对应的数字顺序为:6, 1, 3, 2, 5, 7, 4, 9, 0, 8 ,接下来获取每个坐标对应的 on 值:

方式一: 

from fontTools.ttLib import TTFont

# 创建 TTFont 实例
font = TTFont("yrx7.ttf")
# 返回一个 _TTGlyphset 对象, 包含字形轮廓数据
# _TTGlyphset 是一个类似字典的, 以字形名称为键、_TTGlyph 为值的对象
glyf = font.getGlyphSet()
# 返回一个字形名称列表, 以字母顺序排序
glyphNames = font.getGlyphNames()

# 数字列表
num_list = [6, 1, 3, 2, 5, 7, 4, 9, 0, 8]
map_dict= {}

for i, name in enumerate(glyphNames[1:]):
    # 通过字形名称选择字形对象
    g = glyf[name]
    # 点类型 flag
    # 读取每个坐标对应的 on 值
    flag = list(g._glyph.flags)
    # 控制点的坐标
    # print(glyph._glyph.coordinates)

    # map_dict 字典里的键对应的数字,值则是 on 值构成的列表
    map_dict[num_list[i]] = flag

print(map_dict)

 方式二:

on_dict = {}
num_list = [6, 1, 3, 2, 5, 7, 4, 9, 0, 8]
# 创建 TTFont 实例
font = TTFont("yrx7.ttf")
# 返回一个字形名称列表,以字母顺序排序
glyphNames = font.getGlyphNames()
for i, name in enumerate(glyphNames[1:]):
    # 读取 glyf 表, 获取各字形对应的 on 值
    on_data = font['glyf'][name].flags
    on_key = "".join([str(n) for n in on_data])
    on_dict[on_key] = num_list[i]
print(on_dict)

接下来需要知道各胜点是如何赋值给对应玩家的,相关计算逻辑同样在 7 文件中,在格式化后的第 1008 行,打下断点进行调试:

name[yyq + (window.page - 1) * 10]

Python 代码

get_on_map() 函数的 num_list 列表中的数字顺序需要与保存的 ttf 文件中的编码顺序一致 ,sessionid 要改为自己的: 

import base64

import requests
from fontTools.ttLib import TTFont

# 玩家名称列表
player_list = ['极镀ギ紬荕', '爷灬霸气傀儡', '梦战苍穹', '傲世哥', 'мaη肆風聲', '一刀メ隔世', '横刀メ绝杀', 'Q不死你R死你', '魔帝殤邪', '封刀不再战', '倾城孤狼', '戎马江湖',
               '狂得像风', '影之哀伤', '謸氕づ独尊', '傲视狂杀',
               '追风之梦', '枭雄在世', '傲视之巅', '黑夜刺客', '占你心为王', '爷来取你狗命', '御风踏血', '凫矢暮城',
               '孤影メ残刀', '野区霸王', '噬血啸月', '风逝无迹', '帅的睡不着', '血色杀戮者', '冷视天下', '帅出新高度',
               '風狆瑬蒗', '灵魂禁锢', 'ヤ地狱篮枫ゞ', '溅血メ破天', '剑尊メ杀戮', '塞外う飛龍', '哥‘K纯帅',
               '逆風祈雨',
               '恣意踏江山', '望断、天涯路', '地獄惡灵', '疯狂メ孽杀', '寂月灭影', '骚年霸称帝王', '狂杀メ无赦',
               '死灵的哀伤',
               '撩妹界扛把子', '霸刀☆藐视天下', '潇洒又能打', '狂卩龙灬巅丷峰', '羁旅天涯.', '南宫沐风', '风恋绝尘',
               '剑下孤魂', '一蓑烟雨', '领域★倾战', '威龙丶断魂神狙', '辉煌战绩', '屎来运赚', '伱、Bu够档次',
               '九音引魂箫',
               '骨子里的傲气', '霸海断长空', '没枪也很狂', '死魂★之灵']


def get_on_map():
    on_dict = {}
    # 根据 ttf 文件编码顺序对应更改
    num_list = [6, 1, 3, 2, 5, 7, 4, 9, 0, 8]
    # 创建 TTFont 实例
    font = TTFont('yrx7.ttf')
    # 返回一个字形名称列表,以字母顺序排序
    glyphNames = font.getGlyphNames()
    for i, name in enumerate(glyphNames[1:]):
        # 读取 glyf 表, 获取各字形对应的 on 值
        on_data = font['glyf'][name].flags
        on_key = "".join([str(n) for n in on_data])
        on_dict[on_key] = num_list[i]
    return on_dict


def parse_ttf(woff):
    map_dict = {}
    woff_decode = base64.b64decode(woff)
    with open('match7.ttf', 'wb') as f:
        f.write(woff_decode)
    # 创建 TTFont 实例
    font = TTFont('match7.ttf')
    font.saveXML('match7.xml')
    # 返回一个字形名称列表,以字母顺序排序
    glyphNames = font.getGlyphNames()
    # 第一个是 .notdef
    for name in glyphNames[1:]:
        # 读取 glyf 表, 获取各字形对应的 on 值
        on_data = font['glyf'][name].flags
        on_key = "".join([str(n) for n in on_data])
        map_dict[name.replace('uni', '&#x')] = get_on_map()[on_key]
    return map_dict


def main():
    player_dict = {}
    num_list = []
    headers = {
        'user-agent': 'yuanrenxue.project'
    }
    cookies = {
        'sessionid': ' your sessionid '
    }
    for page in range(1, 6):
        url = "https://match.yuanrenxue.com/api/match/7?page=%s" % page
        response = requests.get(url, headers=headers, cookies=cookies)
        # 获取 woff 值
        woff = response.json()['woff']
        # 获取数据
        value_list = response.json()['data']
        # 解析 woff
        woff_dict = parse_ttf(woff)
        for index, value in enumerate(value_list):
            # 解析获取胜点值
            win_point = value['value'].split(' ')[:-1]
            # 将经过编码的字符转为数字
            num = "".join([str(woff_dict[i]) for i in win_point])
            # 玩家名称对应的值
            player = player_list[(index + 1) + (page - 1) * 10]
            player_dict[num] = player
            num_list.append(num)

    print("最强玩家: " + player_dict[max(num_list)] + " ---> 胜点: " + max(num_list))


if __name__ == '__main__':
    main()

TTFont 相关方法: 

参考链接:fontTools-字体文件的解析 

font.getGlyphOrder() # 返回一个字形名称列表,以其在文件中的顺序排序
font.getGlyphNames() # 返回一个字形名称列表,以字母顺序排序
font.getBestCmap() # 返回一个字形 ID 为键、字形名称为值的字典
font.getReverseGlyphMap() # 返回一个字形名称为键、字形 ID 为值的字典
font.getGlyphName(10000) # 输入字形 ID 返回字形名称
font.getGlyphID("uni70E0") # 输入字形名称返回字形 ID
font.getGlyphSet() # 返回一个 \_TTGlyphSet 对象,包含字形轮廓数据

猜你喜欢

转载自blog.csdn.net/Yy_Rose/article/details/126704652