1. 程式人生 > 其它 >html字型_鬥魚關注人數爬取 字型反爬的攻與防

html字型_鬥魚關注人數爬取 字型反爬的攻與防

技術標籤:html字型html字型程式碼

轉載自:https://cjting.me/2020/07/01/douyu-crawler-and-font-anti-crawling/

這個字型反爬蟲的攻防挺有趣的,雖然跟安全沒有太大關係,前端的分析除錯也可以在口令爆破等情況下參考,全文如下:

之前因為業務原因需要爬取一批鬥魚主播的相關資料,在這過程中我發現鬥魚使用了一種很有意思的反爬技術,字型反爬。

開啟任何一個鬥魚主播的直播間,例如這個主播,他的關注人數資料顯示在右上角:

b13d8b1c-2922-eb11-8da9-e4434bdf6706.png

鬥魚在關注資料這裡使用了字型反爬。什麼是字型反爬?也就是通過自定義字型來自定義字元與渲染圖形的對映。比如,字元 1 實際渲染的是 9,那麼如果 HTML 中的數字是 111,實際顯示就是 999。

在這種技術下,傳統的通過解析 HTML 文件獲取資料的方式就失效了,因為獲取到的資料並不是真實資料。

Tip: 下文中所談的具體細節高度依賴鬥魚網頁的實現,很有可能當你閱讀這篇文章的時候已經不再是那樣。雖然程式碼會過時地很快,但是,技巧和方法是永遠不會過時的。

進攻

感興趣的讀者可以開啟控制檯,看看顯示關注人數的那個 span 元素裡面的內容,會發現根本不是顯示的數字。

b33d8b1c-2922-eb11-8da9-e4434bdf6706.png

從上圖可以看出,顯示的是 59605,這個是真實值,但是字元卻是 96809,這個是虛假值。右邊的font-family告訴我們這個元素使用了一個自定義字型。

從 Network 面板中的我們可以找到這個字型,具體連結是 mpepc5unpb.woff。

如果大家將字型下載下來,使用如下的 HTML 程式碼來渲染它,我們就可以看得很清楚:

<html><head>  <style type="text/css">    @font-face {      font-family: test;      src: url(mpepc5unpb.woff);    }    div {      font-family: test;      font-size: 80px;      text-align: center;      margin: 200px 0;    }style>head><body>  <div>  0123456789  div>body>html>

b93d8b1c-2922-eb11-8da9-e4434bdf6706.png

在這個字型中,字元0會渲染圖形0,字元1會渲染圖形7,以此類推,因此,HTML 中的字元96809渲染的圖形就是59605

這個原理不難理解,我們甚至不需要知道字型檔案內部的具體格式。因為不管怎樣,字型內部一定有一個對映關係。這個關係規定了什麼樣的字元渲染什麼樣的圖形。

正常情況下,字元 1 對應的圖形是 1 的形狀,但是通過修改字型,我們就可以讓字元 1 對應的圖形是 9 的形狀,或者說,其他任意形狀。

這樣以來,HTML 中的字元就完全失去了意義,這個字元所代表的的含義要依據字型來定。

上面這個字型實際上定義了一個01234567890741389265的對映,拿到 HTML 中的字元我們需要根據這個對映才能得到真正的值。

重新整理鬥魚你會發現,字型又變了,所以它那邊肯定是有一個字型庫的,但是這個不關鍵,解決一個就解決了所有。

那麼,如何解決字型反爬從而得到真實關注人數?

分析

現在我們知道,如果要獲取關注人數,我們必須要獲取

  • HTML 中的原始值,也就是假值

  • 字元與真正數字之間的對映關係

這兩個問題都不太好解決,我們先談第一個。

鬥魚的這個關注人數很明顯是 JS 渲染的,我們有兩條路可以走:

  • 使用 Headless Browser。也就是使用一個完整的瀏覽器來渲染整個頁面,然後獲取 DOM 中的值。這個是兜底辦法,永遠可行,但是缺點是效率太差。大家可以看一下開啟鬥魚直播間頁面的速度,正常情況下等到關注人數顯示的時候至少需要 5 秒鐘。

  • 我們找到資料來源。找到鬥魚的 JS 是請求了哪個介面獲取到了資料,然後直接請求該介面。

第二個辦法的效率會高很多,但是同時也困難很多。因為 JS 通過什麼手段獲取了資料實在是靈活性太高了,有太多的辦法:

  • 可以請求多個介面,然後拼接得到資料

  • 可以請求某個介面,返回變形後的資料,然後 JS 再反向處理

  • 組合上面兩種方式

面對一堆壓縮後的 JS 程式碼,我們想通過分析程式碼的方式來找到資料來源是不可行的。不過我們還是有別的手段,這個後面再說。

考慮到效能以及讓生活變的更有樂趣,這裡我們選擇第二種辦法。

Tip: 第一種辦法我順帶提一下,使用Selenium或者Puppeteer都可以。載入網頁以後,輪詢直到對應的 DOM 中有值即可,通殺任何網站。

現在我們來看第二個問題,也就是如何通過字型來獲取對映關係。

通過解析字型檔案可以辦到嗎?不可以,字型檔案中儲存的是字元和圖形的對映,那麼對於一個圖形,它是我們眼中的 “0” 還是我們眼中的 “1” 字型檔案也沒法知道,在字型檔案眼中就是一堆座標。

那還能怎麼辦呢?只能渲染好以後找個人來看嗎?

如果時光倒退 20 年回到 2000 年,恐怕只能這樣做了。雖然那個時候也有圖形識別技術,但是不像現在這樣成熟也不像現在這樣隨手可得。

但是現在是 2020 年,OCR 圖形識別技術已經非常成熟了,我們隨便找個 OCR 庫應該就夠用了。

所以這個問題的解決方案也有了,我們使用字型渲染好圖形,然後呼叫 OCR 識別圖形對應的數字便可以獲取到對映關係。

按照這個思路,我們整個流程便是

  • 尋找資料來源

  • 根據資料來源獲取字型檔案和假資料

  • 根據字型檔案渲染圖形

  • 使用 OCR 識別圖形得到對映關係

  • 根據對映關係和假資料得到真資料

資料來源

現在我們來找資料來源,看看鬥魚的 JS 到底是從哪裡獲取的字型資訊和假資料。

這其實是一個很有意思的過程,我建議有興趣的同學先暫停下來,花點時間自己試著找找,看能不能找到。

我的第一個想法很簡單,JS 一定是通過某個介面得到了這些資料,那麼,我們把所有網路請求匯出為 HAR 格式,然後在裡面搜尋試試。

bc3d8b1c-2922-eb11-8da9-e4434bdf6706.png

Tip: 點選上圖中標記為紅色的按鈕就可以匯出請求為 HAR 格式,HAR 是一個文字格式,非常有利於搜尋。

我試了用假資料也就是96809和字型 IDmpepc5unpb來搜尋,都沒有任何結果。

只能說我們的運氣不太好,這裡情況實在太多了,有可能返回的值經過了一定的處理比如 base64 或者 rot13,也有可能是多介面返回然後再拼接組裝。我們沒辦法進一步驗證,只能放棄這條路。

Tip: 其實在大部分情況下,使用關鍵詞搜尋一下 HAR 是很有效的手段,很容易找到對應的介面。

既然此路不通,我們換個思路,請求到了資料以後,JS 程式碼一定會呼叫相關 API 去修改 DOM,能不能監聽到這個動作?在它修改 DOM 的時候打上斷點,這樣就可以通過呼叫棧知道是哪段 JS 在做此操作,然後順騰摸瓜找到對應的介面。

答案是是可以的,通過使用MutationObserver我們可以監聽任意 DOM 的修改事件。

new MutationObserver((mutations, observer) => {  const el = document.querySelector("span.Title-followNum")  if (el != null) {    observer.disconnect()    new MutationObserver((mutations, observer) => {      debugger    }).observe(el, {childList: true, subtree: true})  }}).observe(document, {childList: true, subtree: true})

通過Tampermonkey載入上面的程式碼,重新整理,等待斷點觸發。

be3d8b1c-2922-eb11-8da9-e4434bdf6706.png

從上面的呼叫棧可以看出,資料來源自 WebSocket。

去 Network 面板中看一下,果然是這樣。

bf3d8b1c-2922-eb11-8da9-e4434bdf6706.png

Tip: 之後爬取像鬥魚這樣的複雜網站,應該先檢查一下 WebSocket 中的訊息。

這個訊息格式很好理解,我們可以猜到[email protected]=63206表示字元為 63206,[email protected]=t3gadgbaon表示字型 ID 是 t3gadgbaon,和 DOM 對照一下,確實如此。

至此我們的資料來源問題解決了一半,我們知道了資料是來自 WebSocket 傳送的響應。但是,如何程式化去獲取這個響應?

協議

分析 WebSocket 訊息會發現,客戶端連線以後會發送一條登入訊息,然後服務端會回覆多個訊息,其中,就有我們感興趣的followed_count

客戶端傳送的訊息如下:

c03d8b1c-2922-eb11-8da9-e4434bdf6706.png

雖然是二進位制訊息,但是可以看到訊息主體都是可讀的文字,很明顯,鬥魚這裡是自己實現了一個內部協議格式。

開頭 12 個位元組暫時不清楚什麼含義,然後緊跟著一段鍵值對資料,使用@=連線鍵和值,使用/分割,最後跟上/\x00

多檢視幾個直播間以後,對於開頭的 12 個位元組,我們不難分析出前四個位元組和訊息長度有關,使用 Little Endian,中間四個位元組和前四個位元組相同,而最後四個位元組是固定值0xb1 0x02 0x00 0x00

上面的訊息長度是 287 個位元組,而0x0000011b = 283,所以,長度編碼的值實際上是整個訊息的長度減去 4。

這裡設計其實挺奇怪的,長度資訊比實際長度少了四個位元組,同時開頭又多了四個位元組的冗餘資料,怎麼看怎麼都像是設計失誤,第二個四位元組是多餘的。

Tip: 對於一個協議來說,作為外部人員,我們是永遠無法弄清楚有些問題的成因的。可能這四個位元組有其他用處,可能就是設計失誤,也有可能一開始沒有這四個位元組,後來因為 bug 不小心加上了,然後為了後向相容,就一直帶上了。

現在我們來看具體的鍵值對:

[email protected][email protected][email protected][email protected][email protected]@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]=83

這些引數中,type,roomid,devid都很好理解,dfl,ver,aver,dmbt,dmbv這些看起來像是不重要的資訊攜帶欄位。

rt很容易發現是一個秒級時間戳,現在唯一剩下的就是vk這個欄位。我們可以通過修改欄位值的方式來大致判斷欄位的作用和重要性。

如果我們原封不動的使用這個請求體(在檢查器中右鍵選擇Copy message... -> Copy as hex)請求鬥魚的 WebSocket 服務,會發現一開始是有正常響應的,但是過幾分鐘後就報錯了。

const WebSocket = require("ws")const ws = new WebSocket("wss://wsproxy.douyu.com:6672/")ws.on("open", () => {  ws.send(Buffer.from("1b0100001b010000b102000074797065403d6c6f67696e7265712f726f6f6d6964403d373837343537392f64666c403d736e4041413d31303540415373734041413d312f757365726e616d65403d2f70617373776f7264403d2f6c746b6964403d2f62697a403d2f73746b403d2f6465766964403d34643963333961386139333734366236646235333637353830303032313530312f6374403d302f7074403d322f637672403d302f747672403d372f617064403d2f7274403d313539333632333033352f766b403d36653964666236336364616533313066393737303061373530623266613437662f766572403d32303139303631302f61766572403d3231383130313930312f646d6274403d6368726f6d652f646d6276403d38332f00", "hex"))})ws.on("message", payload => {  console.log(payload.toString())})

很明顯,鬥魚會校驗rt的值,如果伺服器時間和rt時間超過一定間隔,那麼會返回錯誤,這是一個很常見的設計。

如果我們修改了一下vk,也會得到一個錯誤,這說明vk是類似簽名的東西,而不是什麼資訊攜帶欄位,服務端會校驗它的有效性。

對於dfl,ver,aver,dmbt,dmbv這些欄位,我們會發現隨便修改都不會影響結果,說明我們的之前的猜測是正確的。

所以,現在剩下的問題就是要搞清楚vk的簽名規則,這個只能從原始碼入手。

Chrome 檢查器中對於每個網路請求都會顯示它的 Initiator,也就是這個請求是什麼程式碼發起的。

c23d8b1c-2922-eb11-8da9-e4434bdf6706.png

滑鼠放上去可以看到完整的呼叫棧。

c53d8b1c-2922-eb11-8da9-e4434bdf6706.png

我們的思路是找到傳送訊息的地方,打上斷點,通過呼叫棧往上找。

所以開啟最上面的playerSDK-room_4a27f53.js檔案,搜尋關鍵字send,很容易找到下面這段程式碼。

c83d8b1c-2922-eb11-8da9-e4434bdf6706.png

通過斷點我們可以看出,登入訊息就是通過這裡傳送的,因為e是登入的訊息體。

展開呼叫棧,會發現有一個函式叫做userLogin,點進去。

c93d8b1c-2922-eb11-8da9-e4434bdf6706.png

可以發現我們來到了common-pre~9fd51f5d.js檔案,可以猜到h.default.jsEncrypt.async()這段程式碼應該就是簽名相關的程式碼。

我們可以斷點進這個函式,然後繼續找。或者,我們直接搜尋vk

ca3d8b1c-2922-eb11-8da9-e4434bdf6706.png

然後我們就發現vk的值等於L(y + "r5*^5;}2#${XF[h+;'./.Q'1;,-]f'p[" + p)。到這裡就簡單了,通過斷點可以發現y就是rtpdevid,而L是一個求 md5 值的函式。

所以vk的簽名演算法是vk = md5(rt + "r5*^5;}2#${XF[h+;'./.Q'1;,-]f'p[" + devid)

現在整個訊息體的結構我們都清楚了,我們試著構造訊息體請求鬥魚伺服器看看能不能得到響應。

const encode = obj => Object.keys(obj).map(k => `${ k }@=${ obj[k] }`).join("/")const decode = str => {  return str.split("/").reduce((acc, pair) => {    const [key, value] = pair.split("@=")    acc[key] = value    return acc  }, {})}const crypto = require("crypto")const md5Hash = crypto.createHash("md5")const md5 = payload => {  return md5Hash.update(payload).digest("hex")}// 4-byte length, 4-byte length, 0xb1, 0x02, 0x00, 0x00const getPayload = obj => {  const arr = [0, 0, 0, 0, 0, 0, 0, 0, 0xb1, 0x02, 0x00, 0x00]  const objEncoded = encode(obj) + "/\x00"  arr.push(...objEncoded.split("").map(c => c.charCodeAt(0)))  const payload = Buffer.from(arr)  const dv = new DataView(payload.buffer, payload.byteOffset)  const length = payload.length - 4  dv.setUint32(0, length, true)  dv.setUint32(4, length, true)  return payload}ws.on("open", () => {  // 這個隨便填寫一個  const devID = "4d9c39a8a93746b6db53675800021501"  const rt = (new Date().getTime() / 1000) >> 0  const obj = {    type: "loginreq",    roomid: roomID,    devid: devID,    rt: rt,    vk: md5(rt + "r5*^5;}2#${XF[h+;'./.Q'1;,-]f'p[" + devID),  }  const payload = getPayload(obj)  ws.send(payload)})ws.on("message", payload => {  const data = decode(payload.slice(12).toString())  if(data.type === "followed_count") {    console.log(data)  } else {    console.log(data.type)  }})

成功了!

cc3d8b1c-2922-eb11-8da9-e4434bdf6706.png

OCR

獲取到字型 ID 和假資料以後,接下來我們要做的就是使用字型渲染一張圖片,然後呼叫 OCR 工具識別圖片。

我們先使用字型 ID 將字型下載下來,字型的下載 URL 是固定的https://shark.douyucdn.cn/app/douyu/res/font/FONT_ID.woff

怎樣渲染字型到圖片呢?這個問題方案有很多,上文中我們利用了瀏覽器,這裡我選擇使用 SDL。

#include #include #include #include intmain(void){  if(TTF_Init() == -1) {    printf("error: %s\n", TTF_GetError());    return 1;  }  TTF_Font *font = TTF_OpenFont("test.woff", 50);  if(font == NULL) {    printf("error: %s\n", TTF_GetError());    return 1;  }  SDL_Color black = { 0x00, 0x00, 0x00 };  SDL_Surface *surface = TTF_RenderText_Solid(font, "0123456789", black);  if(surface == NULL) {    printf("error: %s\n", TTF_GetError());    return 1;  }  IMG_SavePNG(surface, "test.png");  return 0;}

SDL 渲染速度非常快,結果也很清楚,用來 OCR 應該足夠了。

cd3d8b1c-2922-eb11-8da9-e4434bdf6706.png

圖形識別這一塊我並沒有什麼太多經驗,但是沒關係,感謝開源世界。我們 Google OCR,很容易就會找到一個看起來很厲害的庫tesseract。

先安裝它brew install tesseract

專案本身是 C++ 的,我們可以直接用 C++ 呼叫。但是因為後面我打算使用 Go 寫一個完整的關注人數爬蟲,所以這裡我們使用 Go 來呼叫。

安裝 Go 的繫結gosseract,然後使用如下程式碼來試試:

package mainimport (  "fmt"  "github.com/otiai10/gosseract/v2")func main() {  client := gosseract.NewClient()  defer client.Close()  client.SetImage("test.png")  client.SetWhitelist("0123456789")  text, _ := client.Text()  fmt.Println(text)}

然後我們很順利地就得到了結果,開源萬歲!?

ce3d8b1c-2922-eb11-8da9-e4434bdf6706.png

最終實現

最後,我們把上面的各個步驟整合一下,使用 Go 來實現一個完整的鬥魚關注人數爬蟲,最終的程式碼在這裡douyu-crawler-demo。

程式碼的大致流程如下:

  • 啟動一定數量的 worker

  • 通過 channel 傳送 roomID 給到 worker

  • worker 爬取關注人數

    • 首先通過 WebSocket 獲取字型資訊和假資料

    • 下載字型到快取目錄中

    • 呼叫 SDL 渲染字型為圖片到快取目錄中

    • 使用 OCR 識別圖片得到對映關係

    • 儲存對映關係(生產肯定是寫入資料庫,這裡是 demo,我們使用檔案)

    • 輸出結果(同樣,生產是寫入資料庫,這裡我們寫入到結果檔案)

當然,如果要做一個生產級別的爬蟲,還有很多問題要處理:

  • 日誌:詳細的日誌可以幫助分析問題,改進不足以及恢復資料等。

  • 錯誤處理:爬蟲的天然屬性就是隨時可能無法工作,完善的錯誤處理和報警機制是必須的。

  • 重試機制:很多介面會報錯,需要有一定的重試機制。

  • 資料校驗:每一步獲取到的資料都需要校驗有效性,否則很容易在資料庫中寫入無效資料。

  • 人工干預:有些環節比如 OCR 的準確率是無法做到 100% 的,要考慮到失敗的情況,一旦 OCR 識別失敗,需要引入人工干預流程。

  • IP 池:很多介面會限制 IP 的訪問頻率,這個時候要掛 IP 池。

最後,我們來測試一下我們的程式效果。roomids.txt中含有 120 個鬥魚主播的 roomID,我們使用爬蟲來爬取這 120 個主播的關注人數。

$ douyu-crawler-demo -f roomids.txt....2020/07/02 13:28:41 all done in 36.313598957s2020/07/02 13:28:41   total: 1202020/07/02 13:28:41   success: 862020/07/02 13:28:41   error: 342020/07/02 13:28:41   ocr failed: 0

120 個主播,一共花費了 36s,這個速度還是非常理想的,使用 Headless Browser 是不可能有這個速度的。

但是我們會發現,其中有一些失敗了,看日誌主要是 WebSocket 沒有返回值或者超時了,這在爬蟲中很正常,直接重試一下就行了。

$ douyu-crawler-demo -f roomids.txt...2020/07/02 13:29:42 all done in 23.660793712s2020/07/02 13:29:42   total: 1202020/07/02 13:29:42   success: 1202020/07/02 13:29:42   error: 02020/07/02 13:29:42   ocr failed: 0

這次全部成功了,結果檔案在result/result.txt中。

Tip: 可以發現 OCR 沒有一次失敗,tesseract 太讚了?

$ head result/result.txt5324388,b72iyfidmi,5538567,1169154582074,yzs37nb5ik,447330,1168806937618,21lwetbnlg,579241,1283745632185,flecd9ycbg,495291,3276216794440,21lwetbnlg,80850,9091052319,tcmpj93mbl,452436,576593820795,n1kril0e2r,80063,400255168755,5n5pkb33y,861526,6895288546776,84c209m14f,9869,37835747228,svk3del36j,319692,127978

每個欄位分別是房間號、字型 ID、假資料以及真資料。

防守

講完了進攻,現在我們來看看如果我們站在防守方,需要使用這種技巧來反爬,該怎麼做?

字型反爬的核心是隨機生成一個對映,根據對映生成字型,然後返回字型和假資料給到前端。

這個時候我們就需要了解一下字型的檔案格式了。常見的字型格式有ttf,otf,woff。其中woff是一個包裝格式,裡面的字型不是ttf就是otf的,所以真正的儲存格式只有兩種,ttfotf

這兩種格式很明顯都是二進位制格式,沒法直接開啟看。但是,幸運的是,字型有一個格式叫做ttx,是一個 XML 的可讀格式。

我們的基本思路是:

  • 裁剪字型:根據一個基礎字型裁剪掉我們不需要的字元,比如鬥魚這種情況,我們只需要數字即可

  • 將字型轉換成ttx格式開啟

  • 找到字元和圖形的對映

  • 修改這個對映

  • 再匯出字型為ttf

這裡我們使用fonttools這個強大的 Python 庫來進行後續的操作。

我們以開源字型Hack為例。

我們先來裁剪字型。安裝好fonttools以後會預設安裝幾個工具,其中之一是pyftsubset,這個工具就可以用來裁剪字型。

$ pyftsubset hack.ttf --text="0123456789"WARNING: TTFA NOT subset; don't know how to subset; dropped

上面的 warning 不用介意,執行完畢之後我們得到了hack.subset.ttf,這個便是裁剪後的字型,只支援渲染 0 ~ 9。

接下來轉換字型為可讀的 ttx 格式。同樣,fonttools 自帶了一個工具叫做ttx,直接使用即可。

$ ttx hack.subset.ttfDumping "hack.subset.ttf" to "hack.subset.ttx"...Dumping 'GlyphOrder' table...Dumping 'head' table...Dumping 'hhea' table...Dumping 'maxp' table...Dumping 'OS/2' table...Dumping 'hmtx' table...Dumping 'cmap' table...Dumping 'fpgm' table...Dumping 'prep' table...Dumping 'cvt ' table...Dumping 'loca' table...Dumping 'glyf' table...Dumping 'name' table...Dumping 'post' table...Dumping 'gasp' table...Dumping 'GSUB' table...

我們會發現目錄下多了一個hack.subset.ttx檔案,開啟觀察一下。

很容易就可以發現,cmap標籤中定義了字元和圖形的對映。

<cmap>  <tableVersion version="0"/>  <cmap_format_4 platformID="0" platEncID="3" language="0">    <map code="0x30" name="zero"/>    <map code="0x31" name="one"/>    <map code="0x32" name="two"/>    <map code="0x33" name="three"/>    <map code="0x34" name="four"/>    <map code="0x35" name="five"/>    <map code="0x36" name="six"/>    <map code="0x37" name="seven"/>    <map code="0x38" name="eight"/>    <map code="0x39" name="nine"/>  cmap_format_4>  ...cmap>

0x30也就是字元 0 對應name="zero"TTGlyph,TTGlyph 中定義了渲染要用的資料,也就是一些座標。

<TTGlyph name="zero" xMin="123" yMin="-29" xMax="1110" yMax="1520">  <contour>    <pt x="617" y="-29" on="1"/>    <pt x="369" y="-29" on="0"/>    <pt x="246" y="165" on="1"/>    <pt x="123" y="358" on="0"/>    <pt x="123" y="745" on="1"/>    <pt x="123" y="1134" on="0"/>    <pt x="246" y="1327" on="1"/>    <pt x="369" y="1520" on="0"/>    <pt x="616" y="1520" on="1"/>    <pt x="864" y="1520" on="0"/>    <pt x="987" y="1327" on="1"/>    <pt x="1110" y="1134" on="0"/>    <pt x="1110" y="745" on="1"/>    <pt x="1110" y="-29" on="0"/>  contour>  ...TTGlyph>

那麼怎麼製作混淆字型的方法就不言而喻了,我們修改一下這個 XML,把TTGlyph(name="zero")標籤的zero換成eight然後把TTGlyph(name="eight")標籤的eight換成zero,儲存檔案為fake.ttx

匯出 ttx 到 ttf 依然是使用ttx工具。

$ ttx -o fake.ttf fake.ttx
Compiling "fake.ttx" to "fake.ttf"...
Parsing 'GlyphOrder' table...
Parsing 'head' table...
Parsing 'hhea' table...
Parsing 'maxp' table...
Parsing 'OS/2' table...
Parsing 'hmtx' table...
Parsing 'cmap' table...
Parsing 'fpgm' table...
Parsing 'prep' table...
Parsing 'cvt ' table...
Parsing 'loca' table...
Parsing 'glyf' table...
Parsing 'name' table...
Parsing 'post' table...
Parsing 'gasp' table...
Parsing 'GSUB' table...

使用上文提到的 HTML 使用fake.ttf渲染 0 ~ 9,可以看到,我們成功地製作了一個混淆字型。

d13d8b1c-2922-eb11-8da9-e4434bdf6706.png

genfont.py是我使用 Python 編寫的指令碼,可以自動生成任意數量的混淆字型。

# 使用 hack.subset.ttf 為基礎生成 20 個混淆字型$ ./genfont.py hack.subset.ttf 20....$ ls result/generated # 結果儲存在這個目錄中0018fb8365.7149586203.ttf  267ccb0e95.8402759136.ttf08a9457ab9.3958406712.ttf  281ef45f09.2154786390.ttf1bbdd405ca.9147328650.ttf  788e0c7651.8790526413.ttf1f985cd725.6417320895.ttf  5433d36fde.1326570894.ttf2d56def315.8962135047.ttf  6844549191.3597082416.ttf6bd27a4bac.0658392147.ttf  a422833064.8416930752.ttf6e337094a4.0754261839.ttf  c7f0591c38.5761804329.ttf9a0e22d6ad.9173452860.ttf  d3269bd2ce.0384976152.ttf9a407f17c1.8379426105.ttf  f97691cc25.1587964230.ttf44a428c37d.3602974581.ttf  ffe2c54286.6894312057.ttf

檔名的形式為fontID.mapping.ttf。比如0018fb8365.7149586203.ttf這個字型會將0渲染為7