2019年末逆向複習系列之從貓眼字體反爬分析談談字體反爬的前世今生

  • 2019 年 12 月 13 日
  • 筆記

最有深度的研究筆記由程序員界最會排版的追星族運營

文章信息

作者: Lateautumn4lin 來源:雲爬蟲技術研究筆記

AKA 逆向小學生

鄭重聲明:本項目的所有代碼和相關文章, 僅用於經驗技術交流分享,禁止將相關技術應用到不正當途徑,因為濫用技術產生的風險與本人無關。

這篇文章是公眾號《雲爬蟲技術研究筆記》的《2019年末逆向複習系列》的第六篇:《從貓眼字體反爬分析談談字體反爬的前世今生》

本次案例的代碼都已上傳到Review_Reverse上面,後面會持續更新,大家可以Fork一波。

背景分析

「字體反爬」 我相信大多數從事爬蟲工作的工程師都接觸過,這其實不是一種常規的反爬手段,它其實是頁面和前端字體文件想配合完成的一個反爬策略。像最早使用字體反爬的58同城、汽車之家到現在很多App的web頁面也開始使用,例如美團、貓眼、快手抖音等等。隨着爬蟲工程師和反爬工程師的不斷對抗,字體反爬從一開始的單純依靠一個寫死的字體文件升級成現在最新動態的字體文件,而字體反爬的攻克也有一個開始的解析字體文件做數據映射到現在依靠KNN來做動態映射,算是經歷了一個又一個光輝的「升級階段」,這篇文章就是簡單講述一下字體反爬的演變史以及最新依靠KNN來做動態映射的破解思路。

歷史分析

PS: 關於歷史分析的項目都可以在Review_Reverse項目下的fonts目錄看到

使用方法,輸入以下代碼即可:

python -m fonts.xxxx

首先我們先理解字體反爬的原理,就是前端工程師通過自定義的字體來替換頁面中某些關鍵的數據,那在HTML中如何使用自定義字體呢?答案就是使用@font-face,我們舉個例子看看@font-face


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

裏面的font-family也就是一個特定的名字,src就表示你需要引用的具體的文件,而這個文件就是字體文件,一般是ttf類型eot類型,當然,現在因為ttf文件過大,在移動端使用的時候會導致加載速度過慢,woff類型的文件最近也廣泛會用,所以一般大家現在碰到的都是woff類型的文件。那woff文件中的內容是什麼呢?它是怎樣把數據進行替換的呢?下面我們先簡單的看個例子。 我們先把woff文件打開,需要使用兩種工具打開:

  • FontCreator工具:https://www.high-logic.com/font-editor/fontcreator
  • 在線FontEditor工具:http://fontstore.baidu.com/static/editor/index.html

這裡我們使用FontCreator,我們把FontCreator下載下來,傳來一個我們之前準備好的woff文件看看效果

我們可以看到woff文件中每個字符都有一個編碼對應,woff實際上就是編碼和字符的映射表。我們再來看看頁面中的被替換的詞是什麼形式

我們對比下可以發現,頁面源碼中的被替換字的就是woff文件中字符的編碼加上$#x,所以大家可以發現字體替換的原理就是這樣,我們使用一個簡單的等式來表現

「替換數據」=「$#x{woff文件中被替換數據的編碼}」

現在我們懂得了原理,下面開始回顧下字體反爬的演變歷程

1. 階段一:通過固定的字體文件進行數據替換

反爬方:一開始的時候,字體反爬還沒有發展的很成熟,所以大部分網站使用字體反爬的方式是使用固定的字體文件來做數據替換,固定的字體文件就表明每個數據的編碼是寫死的,不變的,那麼每次網站引用這個woff文件之後,都可以用相同的編碼來替換想要替換的數據,這就是最初的時候的字體反爬。

應對方:既然他們的字體文件不變,那我們就直接解析他們的固定的woff文件就行,我們使用PythonfontTool庫的ttLib包,代碼如下:


from pathlib import Path  from fontTools.ttLib import TTFont  woff_path = Path(__file__).absolute().parent/"base64 (1).woff"  font = TTFont(woff_path)  font_names = font.getGlyphOrder()  font_str = [      "8", "驗", "楊", "女", "3", "屆", "7", "男", "高", "趙", "6", "2", "下", "以", "技", "黃", "周",      "4", "經", "專", "碩", "劉", "吳", "陳", "士", "E", "5", "中", "博", "1", "科", "大", "9", "本",       "王", "B", "無", "李", "應", "生", "校", "A", "0", "張","M"  ]  print(dict(zip(font_names[2:],font_str)))

我們解析woff文件得到一定順序的編碼集再結合在FontCreator中的字符集得到字符編碼字典,在我們解析HTML源碼的時候替換就行了。

{'uniE032': '8', 'uniE200': '驗', 'uniE267': '楊', 'uniE2DF': '女', 'uniE34E': '3', 'uniE39C': '屆',  'uniE42A': '7', 'uniE481': '男', 'uniE51F': '高', 'uniE555': '趙  ', 'uniE595': '6', 'uniE608': '2', 'uniE6CD': '下', 'uniE72D': '以', 'uniE7C1': '技', 'uniE7C6': '黃',  'uniE7D3': '周', 'uniE841': '4', 'uniE84B': '經', 'uniE8A4': '專', 'uniE8E6': '碩', 'uniE8F4': '劉',  'uniE906': '吳', 'uniE9CF': '陳', 'uniEA8F': '士', 'uniEB2C': 'E', 'uniEBBA': '5', 'uniEBE2': '中', 'uniED0E': '博',   'uniEF3E': '1', 'uniF003': '科', 'uniF012': '大', 'uniF01A': '9', 'uniF02F': '本',  'uniF0D7': '王', 'uniF160': 'B', 'uniF180': '無', 'uniF205': '李', 'uniF2A0': '應', 'uniF3B5': '生', 'uniF501': '校',   'uniF6E9': 'A', 'uniF71C': '0', 'uniF76F': '張', 'uniF877': 'M'}

2. 階段二:字體信息不換,動態更換字符編碼

反爬方:既然寫死的woff文件太容易讓人解析,那就每次都更換新的woff文件,woff文件不更換字體信息,只更換字符編碼,這樣,每次的字符編碼都不一樣,解析的時候就不能使用同一套字符編碼字典去解析了。

應對方:每次同一字符的編碼都不一樣的情況是什麼樣呢?可以看看下面兩個圖所示

我們連續兩次請求的同一個字符卻有不同的編碼,換個思路,同一個的字符它們的字體的關鍵點的坐標是不變的,就像我們在FontCreator點開某個字符看的的一樣

為了得到每個字的坐標點參數,我們需要把woff文件轉換成xml文件


from pathlib import Path  from fontTools.ttLib import TTFont  font1_path = Path(__file__).absolute().parent/"font_1.xml"  font2_path = Path(__file__).absolute().parent/"font_2.xml"  woff1_path = Path(__file__).absolute().parent/"base64 (1).woff"  woff2_path = Path(__file__).absolute().parent/"base64 (2).woff"  font_1 = TTFont(woff1_path)  font_2 = TTFont(woff2_path)  font_1.saveXML(font1_path)  font_2.saveXML(font2_path)

得到文件是這樣的

我們根據剛才生字的兩個不同編碼尋找,得到下面這兩個結構

我們可以看到,雖然這兩個字符的坐標不一樣,但是從舊字符根據一定的偏移量可以得到新字符,所以我們破解這一代字體反爬的手段可以是把最先的字符和字符的坐標保留下來,之後請求得到的字符和字符坐標,根據一定量的偏移去匹配是否是同一個字,類似這樣


from pathlib import Path  from fontTools.ttLib import TTFont  woff1_path = Path(__file__).absolute().parent/"base64 (1).woff"  woff2_path = Path(__file__).absolute().parent/"base64 (2).woff"  font_1 = TTFont(woff1_path)  font_2 = TTFont(woff2_path)  font_old_order = font_1.getGlyphOrder()[2:]  font_new_order = font_2.getGlyphOrder()[2:]      def get_font_flags(font_glyphorder, font_ttf):      f = {}      for i in font_glyphorder:          flags = font_ttf['glyf'][i]          if "flags" in flags.__dict__:              f[tuple(list(flags.flags))] = i      return f      def comp(arr1, arr2):      if len(arr1) != len(arr2):          return 0      for i in range(len(arr2)):          if arr1[i] != arr2[i]:              return 0      return 1      def get_old_new_mapping():      old, new = get_font_flags(font_glyphorder=font_old_order, font_ttf=font_1), get_font_flags(          font_glyphorder=font_new_order, font_ttf=font_2)      result_dict = {}      for key1, value1 in old.items():          for key2, value2 in new.items():              if comp(key1, key2):                  result_dict[value1] = value2      return result_dict      print(get_old_new_mapping())

我們會得到新舊兩個字符的映射


{'uniE032': 'uniF889', 'uniE595': 'uniEB52', 'uniF01A': 'uniF07A', 'uniF71C': 'uniEBDE'}

3. 階段三:有了動態的編碼,再搞個動態字體坐標?


反爬方:動態更換字符編碼集也能根據字體坐標來破解,要是新舊兩個字符的坐標不是按照一定的偏移量來做的呢?例如我們新的字符和舊的字符的字體不一樣,新的字體做了一定量的變形,導致某些坐標的缺少以及坐標的偏移量不一致,所以可以做幾百套不同字體坐標,不同字符編碼的動態字體集(真的變態!)。

應對方:這一階段的反爬看到過很多大佬的實現:

  • 有使用閾值來做的,不過閾值是寫死的,也就是說明成功其實有點靠運氣,有時候返回的兩個字符坐標差值在閾值內,有時不在,所以這一個方案有點不太靠譜。
  • 有使用ocr來做的,哈哈,真的是秀,利用ocr來做的原理就是先利用坐標勾勒出漢字圖樣,接着識別出漢字,再把相同漢字的不同編碼做對應,這樣也能得出結果,效果沒有具體去測算,不過使用ocr來識別漢字應該相對於tfpytorch等來說效果會差點。
  • 之前看到大壯哥使用KNN來做,是個好想法,而且也不用去識別圖片成漢字,資源消耗和速率上相對來說會小點,原理就是如果一個樣本在特徵空間中的k個最相鄰的樣本中的大多數屬於某一個類別,則該樣本也屬於這個類別,並具有這個類別上樣本的特性。放在字體這個例子中,就是新字體文件中哪個字符離舊字體文件中的某個字符距離較近,它就屬於這個字符的類別,也就是和這個字符是一樣的。

4. 階段四:展望未來。。。

目前還沒有新的字體反爬的手段出現,更多的@font-face的加密上面,比如對字體文件的地址做基本的Js加密等等什麼的,其他的我就暫時沒發現,有發現的大佬可以透露一下。

整個字體反爬的演變歷程就是上面介紹的這樣,下面我們開始做實戰分析。

貓眼實戰分析

貓眼國內票房榜地址

https://maoyan.com/board/1

貓眼字體反爬分析

首先我們進入頁面,查看哪個部分的數據被替換

我們可以看到票房數據被替換了,是被stonefont這個@font-face的名稱給替換了,我們去搜索這個stonefont

我們通過這個woff地址去下載woff文件,在利用FontCreator打開,看到這樣

和我們之前看到的字體反爬方式是一樣的,動態字體文件,以及動態的字體坐標,接下來,我們用KNN的思路去破解它。

貓眼字體KNN思路分析

KNN算法比較簡單,我們劃分測試集和訓練集,先用測試集得到每個數字的距離,然後在測試的時候我們對不同的輸入(也就是數字)就是距離計算,距離最近的即為相同值。

代碼實戰

PS: 關於歷史分析的項目都可以在Review_Reverse項目下的maoyan目錄看到

使用方法,輸入以下代碼即可:

python -m maoyan.xxxx

我們這次使用Sklearn來做KNN

1. 收集貓眼的多套字體文件

這裡我會獲取10套字體文件(越多越好),然後將所有字符對應的字形坐標信息保存到一個列表當中(注意做好字符與字形坐標的對應關係)


def get_font_content() -> str:      response = requests.get(          url=_brand_url,          headers=_headers      )      woff_url = re.findall(r"url('(.*?.woff)')", response.text)[0]      font_url = f"http:{woff_url}"      return requests.get(font_url).content      def save_font() -> None:      for i in range(5):          font_content = get_font_content()          with open(f'./fonts/{i+1}.woff', 'wb') as f:              f.write(font_content)      def get_coor_info(font, cli):      glyf_order = font.getGlyphOrder()[2:]      info = list()      for i, g in enumerate(glyf_order):          coors = font['glyf'][g].coordinates          coors = [_ for c in coors for _ in c]          coors.insert(0, cli[i])          info.append(coors)      return info

FontCreator的字符補充上


def get_font_data() -> List[List[List[int]]]:      font_1 = TTFont('./fonts/1.woff')      cli_1 = [6, 7, 4, 9, 1, 2, 5, 0, 3, 8]      coor_info_1 = get_coor_info(font_1, cli_1)        font_2 = TTFont('./fonts/2.woff')      cli_2 = [1, 3, 2, 7, 6, 8, 9, 0, 4, 5]      coor_info_2 = get_coor_info(font_2, cli_2)        font_3 = TTFont('./fonts/3.woff')      cli_3 = [5, 8, 3, 0, 6, 7, 9, 1, 2, 4]      coor_info_3 = get_coor_info(font_3, cli_3)        font_4 = TTFont('./fonts/4.woff')      cli_4 = [9, 3, 4, 8, 7, 5, 2, 1, 6, 0]      coor_info_4 = get_coor_info(font_4, cli_4)        font_5 = TTFont('./fonts/5.woff')      cli_5 = [1, 5, 8, 0, 7, 9, 6, 3, 2, 4]      coor_info_5 = get_coor_info(font_5, cli_5)        infos = coor_info_1 + coor_info_2 + coor_info_3 + coor_info_4 + coor_info_5      return infos

2. 使用knn算法訓練數據

通常情況下,拿到樣本數據,先進行缺失值處理,然後取出特徵值和目標值,再對樣本數據進行分割,分為訓練集和測試集,然後再對樣本數據進行標準化處理,最後進行訓練預測。由於採集的字體數據不多,如果按隨機分割的方式,訓練集容易缺失某些字符,導致預測測試集的結果誤差率較大,所以在此固定前40個樣本為訓練集,最後10個樣本為測試集合。另外,多次測試發現,此處進行標準化,會影響成功率,所以不採用,另外k值取1, 也就是說,我判定當前樣本跟離它最近的那個樣本屬於同一類型,即同一個字符,這個值取多少合適經過調試才知道,最後預測10個樣本,包含了0-9 10個字符,成功率為100%。


import numpy as np  import pandas as pd  from maoyan.font import get_font_data  from sklearn.impute import SimpleImputer  from sklearn.model_selection import train_test_split  from sklearn.neighbors import KNeighborsClassifier  from sklearn.preprocessing import StandardScaler      def main() -> None:      # 處理缺失值      imputer = SimpleImputer(missing_values=np.nan, strategy='mean')      data = pd.DataFrame(imputer.fit_transform(pd.DataFrame(get_font_data())))      # 取出特徵值目標值      x = data.drop([0], axis=1)      y = data[0]      # 分割數據集      # x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=0)      x_train = x.head(30)      y_train = y.head(30)      x_test = x.tail(10)      y_test = y.tail(10)      # 標準化      # std = StandardScaler()      # x_train = std.fit_transform(x_train)      # x_test = std.transform(x_test)      # 進行算法流程      knn = KNeighborsClassifier(n_neighbors=1)      # 開始訓練      knn.fit(x_train, y_train)      # 預測結果      y_predict = knn.predict(x_test)      print(y)      # 得出準確率      print(knn.score(x_test, y_test))

3. 得到訓練好的流程之後我們進行測試

def get_board() -> None:      map_dict = get_map(          text=requests.get(              url=_board_url,              headers=_headers          ).text      )      for uni in map_dict.keys():          text = text.replace(uni, map_dict[uni])      html = etree.HTML(text)      dd_li = html.xpath('//dl[@class="board-wrapper"]/dd')      for dd in dd_li:          p_li = dd.xpath(              './div[@class="board-item-main"]//div[@class="movie-item-info"]/p')          title = p_li[0].xpath('./a/@title')[0]          star = p_li[1].xpath('./text()')[0]          releasetime = p_li[2].xpath('./text()')[0]          p_li = dd.xpath(              './div[@class="board-item-main"]//div[@class="movie-item-number boxoffice"]/p')          realtime_stont = ''.join(              list(map(lambda x: x.strip(), p_li[0].xpath('.//text()'))))          total_stont = ''.join(              list(map(lambda x: x.strip(), p_li[1].xpath('.//text()'))))          print(title)          print(star)          print(releasetime)          print(realtime_stont)          print(total_stont)          print('-' * 50)      get_board()

把訓練好的結果和官網對比一下,是不是感覺美滋滋,連最新的字體反爬也被我們破解啦!

複習要點

  • 重新梳理下字體反爬的整個演變歷程
  • 對於最新的字體反爬轉化思路,從識別圖片到分類算法,提高效率