破解字体反爬

加密原理分析

**字体反爬,即利用自定义的字体文件,改变字符编码到字形之间的映射。**使得浏览器上看似正常的页面,用爬虫获取的数据却是乱码或乱序的。

1. 网页分析

以猫眼的电影详情页为例,网页上显示的页面是:
破解字体反爬_第1张图片
只关注用户评分项,在源码中显示的是

.
.万人评分

隐藏了真实数据。
其中以&#x开头的是字符引用【说明】:

字符引用(character reference):
①字符数值引用(numeric character reference):以&#开头,后接十进制Unicode码;或以&#x开头,后接十六进制Unicode码,即可表示对应的字符。例如**0;0;0**(因为简书编辑器会转义字符引用,故用中文分号代替)。
②字符实体引用(character entity reference):以&开头,后接预先定义的实体名称。例如**>;>**。

它们都通过stonefont这个class改变了编码对应的字形。查看stonefont类的定义:


可以看到网页将所有stonefont字体应用到了类名为stonefont的元素上。为了保证网站的兼容性,其同时准备了eot和woff两种字体文件格式,因为我们用的是Chrome浏览器,下载其中的woff文件查看研究。
有的网站会将字体文件编码成base64格式直接嵌在HTML中,要提取其中的字体文件,我们可以将"base,"后面的字符串复制下来,然后解码保存在文件中:

import base64
 
base64_string = XXXX
font = base64.b64decode(base64_string )
with open('font.ttf','wb') as f:
    f.write(font)

2. 字体文件分析

下载字体编辑器FontCreator
加载之前下载的字体文件,查看分析:
网站字体
对比正常的字体文件,如常用的Arial字体:
Arial字体
可以看到Arial字体中,Unicode码和数字都是十六进制30~39对应0~9的标准映射;而在网页字体里则是在常规字体中未被定义的超过E000的大数,因此我们在审查元素时会看到因缺失对应的字形而显示出的一堆方框()。

到了这一步,可以想到利用编码与字形间的映射关系,在程序中构建一个字典,并对爬到的数据加以替换。以防网站有多套字体文件,还需多次刷新网页,查看网站每次返回的字体文件是否相同。为了穷举所有可能的情况,可编写爬虫程序反复请求网页,直至一段时间内不再产生新的字体文件,完成后对每个字体文件构建一个对应的字典即可。

此时可能出现另一种情况,程序已经爬取了上百个字体文件,然而文件的数量还在不断地增加。即网站准备了一个巨大的字体库,无法穷举完所有的情况。需要考虑其它的方法。

3. 字体渲染方式分析

安装python字体工具fontTools,将下载的字体转换成xml格式查看:

from fontTools.ttLib import TTFont

font = TTFont(r"C:\Users\hmy\Downloads\99cb3662d1fe148f6d7698d38a9e36d72268.woff")
font.saveXML('font.xml')

查找其中的标签,其中的每个标签对应一个字形,它的name属性即为字形名称,例如数字6对应的编码uniE693:

    <TTGlyph name="uniE693" xMin="0" yMin="-39" xMax="523" yMax="722">
      <contour>
        <pt x="420" y="523" on="1"/>
        <pt x="408" y="574" on="0"/>
        <pt x="386" y="597" on="1"/>
        <pt x="349" y="635" on="0"/>
        <pt x="289" y="635" on="1"/>
        <pt x="253" y="635" on="0"/>
        <pt x="220" y="612" on="1"/>
        <pt x="177" y="580" on="0"/>
        <pt x="154" y="521" on="1"/>
        <pt x="141" y="492" on="0"/>
        <pt x="128" y="405" on="0"/>
        <pt x="128" y="352" on="1"/>
        <pt x="161" y="402" on="0"/>
        <pt x="221" y="425" on="1"/>
        <pt x="254" y="449" on="0"/>
        <pt x="306" y="449" on="1"/>
        <pt x="395" y="455" on="0"/>
        <pt x="459" y="382" on="1"/>
        <pt x="522" y="316" on="0"/>
        <pt x="522" y="211" on="1"/>
        <pt x="522" y="130" on="0"/>
        <pt x="484" y="83" on="1"/>
        <pt x="463" y="24" on="0"/>
        <pt x="360" y="-39" on="0"/>
        <pt x="293" y="-39" on="1"/>
        <pt x="180" y="-39" on="0"/>
        <pt x="39" y="124" on="0"/>
        <pt x="39" y="317" on="1"/>
        <pt x="39" y="530" on="0"/>
        <pt x="185" y="721" on="0"/>
        <pt x="301" y="710" on="1"/>
        <pt x="388" y="710" on="0"/>
        <pt x="443" y="661" on="1"/>
        <pt x="513" y="614" on="0"/>
        <pt x="510" y="528" on="1"/>
        <pt x="420" y="521" on="1"/>
      contour>
      <contour>
        <pt x="142" y="211" on="1"/>
        <pt x="142" y="166" on="0"/>
        <pt x="182" y="79" on="0"/>
        <pt x="253" y="35" on="0"/>
        <pt x="359" y="35" on="0"/>
        <pt x="389" y="81" on="1"/>
        <pt x="430" y="134" on="0"/>
        <pt x="430" y="281" on="0"/>
        <pt x="349" y="370" on="0"/>
        <pt x="288" y="377" on="1"/>
        <pt x="228" y="370" on="0"/>
        <pt x="189" y="326" on="1"/>
        <pt x="135" y="282" on="0"/>
        <pt x="156" y="216" on="1"/>
      contour>
      <instructions/>
    TTGlyph>

每个字形下包含了多个标签,其中的每个标签代表了一个坐标点,on属性为1的点为顶点,为0的为弧线,串联这些坐标点则可绘制出一条轮廓线,浏览器会自动平滑弧线并填充各条轮廓线间的空白区域,展现出的即为我们所看到的字体。为方便理解,双击FontCreator中的6编辑,将视图-选择模式设为
破解字体反爬_第2张图片
可以顺着轮廓线依次比对各点的坐标。

打开多个猫眼字体文件的xml查看,可以发现不同文件中虽然相同字形的坐标并不相同,但它们的笔划数及笔画顺序却是一致的。因此我们可以借此利用未知字形与已知字形间坐标的相似度来确定未知字形所代表的字符。

解密方法

1. 准备模板

将字形按笔画数分类,分类下是相同笔画数的顶点坐标及其索引,保存成json文件,以后获取的每个字体都与该文件比对。

        def show_glyphs(self, font_url):
        """
        显示字体顺序,存储笔画坐标
        :param font_url:字体地址
        :return:
        """
        content = requests.get(font_url).content
        bytes_io = BytesIO(content)
        current_font = TTFont(file=bytes_io)
        glyph_map = current_font.getBestCmap()  # 代码点-字形名称映射
        glyph_list = list(glyph_map)  # 代码点
        text = ''
        result = dict()
        for index, code in enumerate(glyph_list):
            glyph = current_font['glyf'][glyph_map[code]]  # 字形
            end_pts = glyph.endPtsOfContours  # 端点位置
            coordinates = glyph.coordinates.array  # 顶底坐标
            sliced_coordinates = self.slice_coordinates(list(coordinates), end_pts)
            number_of_contours = glyph.numberOfContours  # 轮廓数
            result[number_of_contours] = result.get(number_of_contours, [])  # 将具有相同轮廓数的字形放在同一列表下
            result[number_of_contours].append({
                'coord': sliced_coordinates,
                'index': index
            })
            text += ' ' + chr(code)  # 将unicode码转成对应的字符
        with open('template_font.json', 'w')as f:  # 保存坐标信息
            if self.dynamic:
                json.dump(result, f)
            else:
                json.dump(glyph_list, f)
        text = textwrap.fill(text, width=40)
        img = Image.new("RGB", (1920, 1050), '#fff')
        draw = ImageDraw.Draw(img)
        bytes_io.seek(0)
        font = ImageFont.truetype(bytes_io, 40)  # 设置图片字体
        draw.text((0, 0), text, font=font, fill="#000", spacing=10)
        img.save('font.png')
        img.show()

将图片中展示的字符串记录下来。

2. 计算余弦相似度

上文说到字体文件的坐标会在一定范围内波动,但由于它们的笔画顺序并不会改变,所以同一个字整体的变化并不大;而不同的字由于笔画不同坐标向量会大相庭径。很容易用余弦相似度来区分不同的字形。
首先我们将字形按不同的笔画分类,比较时只在那些笔画相同的字形中选择结果。值得注意的是,由于不同字体文件中每段笔画的坐标数可能会不同,故需分段计算;若一并计算,就像没对齐的拉链一样,后面的误差将会不断放大。

        def _sub_one(self, match_result):
        """
        替换一个加密字符
        :param match_result: 正则匹配结果
        :return: 计算余弦相似度后最接近的字符
        """
        matched_one = match_result.group(1)
        if self.mode == '&#x' or self.mode == 'u':
            unicode = int(matched_one, 16)
        elif self.mode == 'raw':
            unicode = ord(matched_one)
        else:
            raise Exception('Mode not implemented.')
        if not self.dynamic:
            if unicode in self.template_font:
                predicted_index = self.template_font.index(unicode)
            else:  # 若不在字体库中,则直接返回
                return matched_one
        else:
            glyph_name = self.current_font.getBestCmap().get(unicode)  # 代码点-字形名称映射
            if not glyph_name:
                return matched_one
            current_glyph = self.current_font['glyf'][glyph_name]
            if not current_glyph:
                print('Code %s not found in this font file.' % unicode)
                return ''  # 用鸡填补空白
            number_of_contours = current_glyph.numberOfContours
            template_glyphs = self.template_font.get(str(number_of_contours))  # 选取具有相同笔画数的字形
            if len(template_glyphs) == 1:  # 只有一个字形相匹配,无需比较相似度
                predicted_index = template_glyphs[0]['index']
            else:
                end_pts = current_glyph.endPtsOfContours
                coordinates = current_glyph.coordinates.array
                sliced_coordinates1 = self.slice_coordinates(list(coordinates), end_pts)
                predicted_index = max_sim = -1  # 预测的索引、最大相似度
                for template_glyph in template_glyphs:
                    sim_sum = 0  # 所有笔画的相似度之和
                    sliced_coordinates2 = template_glyph['coord']
                    for vector1, vector2 in zip(sliced_coordinates1, sliced_coordinates2):
                        sim = self.get_cosine_sim(vector1, vector2)
                        sim_sum += sim
                    if sim_sum > max_sim:
                        max_sim = sim_sum
                        predicted_index = template_glyph['index']
        predicted_value = self.glyphs_seq[predicted_index]
        return predicted_value

3. 整体代码

import requests
import numpy
import json
import re
import textwrap
from fontTools.ttLib import TTFont
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont


class FontDecrypter:
    def __init__(self, dynamic=False, mode='raw'):
        """
        :param dynamic:是动态还是静态字体库
        :param mode:包括'&#x','u'和'raw'
        """
        self.dynamic = dynamic
        self.mode = mode
        if dynamic:
            self.glyphs_seq = None  # 字形序列
            self.template_font = None  # 笔画坐标
            self.current_font = None  # 当前笔画

    def show_glyphs(self, font_url):
        """
        显示字体顺序,存储笔画坐标
        :param font_url:字体地址
        :return:
        """
        content = requests.get(font_url).content
        bytes_io = BytesIO(content)
        current_font = TTFont(file=bytes_io)
        glyph_map = current_font.getBestCmap()  # 代码点-字形名称映射
        glyph_list = list(glyph_map)  # 代码点
        text = ''
        result = dict()
        for index, code in enumerate(glyph_list):
            glyph = current_font['glyf'][glyph_map[code]]  # 字形
            end_pts = glyph.endPtsOfContours  # 端点位置
            coordinates = glyph.coordinates.array  # 顶底坐标
            sliced_coordinates = self.slice_coordinates(list(coordinates), end_pts)
            number_of_contours = glyph.numberOfContours  # 轮廓数
            result[number_of_contours] = result.get(number_of_contours, [])  # 将具有相同轮廓数的字形放在同一列表下
            result[number_of_contours].append({
                'coord': sliced_coordinates,
                'index': index
            })
            text += ' ' + chr(code)  # 将unicode码转成对应的字符
        with open('template_font.json', 'w')as f:  # 保存坐标信息
            if self.dynamic:
                json.dump(result, f)
            else:
                json.dump(glyph_list, f)
        text = textwrap.fill(text, width=40)
        img = Image.new("RGB", (1920, 1050), '#fff')
        draw = ImageDraw.Draw(img)
        bytes_io.seek(0)
        font = ImageFont.truetype(bytes_io, 40)  # 设置图片字体
        draw.text((0, 0), text, font=font, fill="#000", spacing=10)
        img.save('font.png')
        img.show()

    @staticmethod
    def slice_coordinates(_coordinates, _end_pts):
        """
        将坐标按笔画拆分。由于不同字体文件中每段笔画的坐标数可能会不同,故需分段计算;若一并计算则后面的误差极大
        :param _coordinates: 坐标
        :param _end_pts: 端点
        :return: 切分后的坐标
        """
        end_pts = [0] + _end_pts  # 为方便遍历首位添0
        sliced_coordinates = [
            _coordinates[end_pts[index]:(end_pts[index + 1]) * 2]  # 坐标包含x和y,故需*2
            for index in range(len(end_pts) - 1)
        ]
        return sliced_coordinates

    @staticmethod
    def get_cosine_sim(_vector1, _vector2):
        """
        计算余弦相似度
        :param _vector1: 输入向量1
        :param _vector2: 输入向量2
        :return: 余弦
        """
        length = min(len(_vector1), len(_vector2))
        vector1 = numpy.array(_vector1[:length])
        vector2 = numpy.array(_vector2[:length])
        product = numpy.linalg.norm(vector1) * numpy.linalg.norm(vector2)
        sim = numpy.dot(vector1, vector2) / product
        return sim

    def load_glyphs_data(self, glyphs_seq):
        """
        加载字体数据
        :param glyphs_seq:人眼识别的字体序列
        :return:
        """
        self.glyphs_seq = glyphs_seq
        with open('template_font.json')as f:
            self.template_font = json.load(f)

    def sub_all(self, encoded_string, font_path=None):
        """
        替换所有加密字符
        :param encoded_string: 加密字符串
        :param font_path: 字体地址,可以是url或文件路径,若为静态加密则参数省略
        :return: 解密字符串
        """
        if font_path:
            if font_path.startswith('http'):
                content = requests.get(font_path).content
                bytes_io = BytesIO(content)
                self.current_font = TTFont(file=bytes_io)
            else:
                self.current_font = TTFont(file=font_path)
        if self.mode == '&#x':
            results = re.sub('&#x(.+?);', self._sub_one, encoded_string)
        elif self.mode == 'u':
            results = re.sub(r'\\u(.+?);', self._sub_one, encoded_string)
        elif self.mode == 'raw':
            results = re.sub('(.)', self._sub_one, encoded_string)
        else:
            raise Exception('Mode not implemented.')
        print(results)
        return results

    def _sub_one(self, match_result):
        """
        替换一个加密字符
        :param match_result: 正则匹配结果
        :return: 计算余弦相似度后最接近的字符
        """
        matched_one = match_result.group(1)
        if self.mode == '&#x' or self.mode == 'u':
            unicode = int(matched_one, 16)
        elif self.mode == 'raw':
            unicode = ord(matched_one)
        else:
            raise Exception('Mode not implemented.')
        if not self.dynamic:
            if unicode in self.template_font:
                predicted_index = self.template_font.index(unicode)
            else:  # 若不在字体库中,则直接返回
                return matched_one
        else:
            glyph_name = self.current_font.getBestCmap().get(unicode)  # 代码点-字形名称映射
            if not glyph_name:
                return matched_one
            current_glyph = self.current_font['glyf'][glyph_name]
            if not current_glyph:
                print('Code %s not found in this font file.' % unicode)
                return ''  # 用鸡填补空白
            number_of_contours = current_glyph.numberOfContours
            template_glyphs = self.template_font.get(str(number_of_contours))  # 选取具有相同笔画数的字形
            if len(template_glyphs) == 1:  # 只有一个字形相匹配,无需比较相似度
                predicted_index = template_glyphs[0]['index']
            else:
                end_pts = current_glyph.endPtsOfContours
                coordinates = current_glyph.coordinates.array
                sliced_coordinates1 = self.slice_coordinates(list(coordinates), end_pts)
                predicted_index = max_sim = -1  # 预测的索引、最大相似度
                for template_glyph in template_glyphs:
                    sim_sum = 0  # 所有笔画的相似度之和
                    sliced_coordinates2 = template_glyph['coord']
                    for vector1, vector2 in zip(sliced_coordinates1, sliced_coordinates2):
                        sim = self.get_cosine_sim(vector1, vector2)
                        sim_sum += sim
                    if sim_sum > max_sim:
                        max_sim = sim_sum
                        predicted_index = template_glyph['index']
        predicted_value = self.glyphs_seq[predicted_index]
        return predicted_value


if __name__ == '__main__':
    # 手动编辑seq时要非常注意图片中的小数点,切勿遗漏。可比较图中字符数和json中的字符数来确定未遗漏字符。
    def maoyan_dynamic_test():
        fd = FontDecrypter(True, mode='&#x')
        # fd.show_glyphs('https://vfile.meituan.net/colorstone/4b07e992a16d2d2e1529f312b81774952280.woff')
        seq = '.2049386157'
        fd.load_glyphs_data(seq)
        fd.sub_all('''.万人评分''',
                   'https://vfile.meituan.net/colorstone/4925a40a29117b589ea0c9a0e15ba6f82288.woff')


    def maoyan_static_test():
        fd = FontDecrypter(mode='&#x')
        # fd.show_glyphs('https://vfile.meituan.net/colorstone/4925a40a29117b589ea0c9a0e15ba6f82288.woff')
        seq = '.9157480632'
        fd.load_glyphs_data(seq)
        fd.sub_all('''.万人评分''')


    def qichezhijia_test():
        fd = FontDecrypter(True, mode='&#x')
        # fd.show_glyphs('https://k3.autoimg.cn/g1/M03/D2/94/wKgHGFsUz12ARNhnAABj8BBFyj898..ttf')
        seq = '右远少长高好三八着是五小的左七短低坏地很更了十近和一二大多不四九矮上得呢六下'
        fd.load_glyphs_data(seq)
        fd.sub_all('''很 根本看清前路状况,还好有全景影像,''',
                   'https://k3.autoimg.cn/g1/M03/D2/94/wKgHGFsUz12ARNhnAABj8BBFyj898..ttf')


    def tyc_test():
        # 天眼查每个字体文件未能涵盖所有字体,可通过与windows标准字体比对得出相似度最高的,此处未实现
        fd = FontDecrypter(dynamic=True)
        # fd.show_glyphs('https://static.tianyancha.com/fonts-styles/fonts/c3/c3248609/tyc-num.woff')
        seq = '''2465108937.衣前拉音护质价该击始小布那让依卫日记际敌笑手罪总以图余经较司已般电非强宫但事展长们须易安考斯利生走马势细把高宗采除便年假史转兴异因君四带答中政察整步力需场装院娘找候即资市响落口按历照主河严房存用商增服花象定号式玉归任难被初刘现制查杀料起给只打基改周连界同京民段乎算数关提家决交局到敢消十宣汉知子再功多话变全尚甚百律识向称效右究师皇委曰皆从底克谁确权久真惊尽过左防至白飞喜书老云武受约后万虽了金倒足单密新若并做义大写设亦李量怕两江诸先进队户尔包听种九希题第边岁医片帝另状酒随食满验春是双联之正想本气刻观内流公术别火西钱元族系何则失区举士供问明治些专兰在怎干病突破母六息由下收愿分革外点眼东为建通调领立够应议此责色处校施更节晚境银她科影首划属成准离谓行宝觉人修来微么令名客素什'''
        fd.load_glyphs_data(seq)
        fd.sub_all('''越学夫化交流活论的数织、策划;健康管理;图书的批公、零售;教育信受咨询;图夫因象;网喜因象与金公;网把商务咨询;网把贸识代理;部业管理咨询;会最服务。(依终须经批准的项身,经观议快余批准后将可金姐经存活论)''',
            font_path='https://static.tianyancha.com/fonts-styles/fonts/04/03b51a6f/tyc-num.woff')


    tyc_test()

4. 调用方法

①. 生成字体模板,并记录图片中的字符串。若网站的字体是静态的则dynamic设为默认值False,因为无需反复请求新的字体文件,性能较高。

fd = FontDecrypter(dynamic=True)
fd.show_glyphs('https://vfile.meituan.net/colorstone/4b07e992a16d2d2e1529f312b81774952280.woff')

②. seq为上一步记录下的字符串序列,需要先加载模板文件,然后传入要解密的字符串。若是静态网页则后面的字体路径可忽略。

fd = FontDecrypter(dynamic=True)
seq = '0867429531'
fd.load_glyphs_data(seq)
fd.sub_all('''.万人评分''',
            'https://vfile.meituan.net/colorstone/7b76ee0a1ebcfd59b6a8dd6928402ba22288.woff')

你可能感兴趣的:(爬虫)