崙?zhàn):請求頭簽名與動態(tài)字體反爬疊加解析)
抓一個企業(yè)信息類站點的公開數(shù)據(jù)最讓人頭疼的往往不是登錄環(huán)節(jié)而是請求頭里那串看不出規(guī)律的十六進制字符串以及頁面上一堆方框或者牛頭不對馬嘴的漢字。前者是 JavaScript 逆向里最典型的 header 加密后者是字體反爬的標準套路。這兩套機制單獨出現(xiàn)都不算難一旦疊在一起新手很容易在第一步就卡死簽名對不上直接被拒簽名對上了又拿到一堆亂碼數(shù)字兩輪折騰下來人都麻了。這篇內(nèi)容按我自己的排查順序把這兩塊拆開講清楚。簽名參數(shù)是怎么在混淆代碼里被生成的、斷點該下在哪里、加密函數(shù)怎么從一團亂麻里摳出來本地跑字體反爬的映射關(guān)系到底藏在文件哪個位置、用字形輪廓怎么做特征比對、遇到每次刷新都換一套的動態(tài)字體怎么保持長期穩(wěn)定。內(nèi)容適合有一定前端基礎(chǔ)、正在學(xué) JavaScript 逆向、或者被這兩類反爬卡過項目的讀者。文中所有代碼和字段名都做了脫敏處理域名、參數(shù)名一律用示例代替思路可以直接遷移。需要提前說清楚一點這類技術(shù)的正當(dāng)用途是學(xué)習(xí)前端保護機制、做自己有權(quán)訪問的數(shù)據(jù)采集、以及評估自家站點的防護強度。對外的數(shù)據(jù)抓取請務(wù)必遵守目標站點的服務(wù)條款與 robots 協(xié)議控制請求頻率別把別人的服務(wù)器當(dāng)成自己的壓測環(huán)境。技術(shù)是中性的用在哪、怎么用取決于寫代碼的人。1. 請求頭里那串簽名才是真正的門1.1 一次抓包看到的三個異常字段打開開發(fā)者工具的 Network 面板隨便點一個返回數(shù)據(jù)的接口把請求頭展開通常能看到幾個和普通瀏覽器請求不一樣的字段。它們不會有標準的Accept、User-Agent那種熟悉感而是帶著業(yè)務(wù)前綴比如X-Auth、X-Sign、X-Timestamp、X-Nonce這類命名。cookie 里往往也埋著一個類似的令牌跟著一起發(fā)出去。判斷哪個字段真正參與服務(wù)端校驗最直接的辦法是本地重放。把請求復(fù)制成 cURL先原樣發(fā)一次確認能通然后逐個刪掉可疑字段再發(fā)看返回結(jié)果。有的字段刪了沒影響說明只是埋點或者灰度標識有的字段刪了立刻返回鑒權(quán)失敗或者空數(shù)據(jù)那它就是關(guān)鍵參數(shù)。這個方法笨但極其有效比對著代碼猜半天強得多。我第一次做這類分析時光靠刪字段就排除了三個干擾項省了大量讀混淆代碼的時間。字段名典型取值形態(tài)猜測作用驗證方式X-Auth32 位小寫十六進制可能是令牌或簽名刪掉重放看是否 401X-Timestamp13 位毫秒時間戳防重放窗口校驗改成很久以前的值重放X-Nonce8 到 16 位隨機串防重復(fù)請求兩次請求用同一個值對比X-Sign大寫十六進制或 Base64參數(shù)摘要簽名改任一業(yè)務(wù)參數(shù)后觀察是否變化表格里最后一行值得多說兩句。簽名類字段有個很明顯的特征你把業(yè)務(wù)參數(shù)改一個字符它的值就會整體變化而且是雪崩式的變化不是局部變。這基本可以斷定它是摘要算法常見的是 MD5、SHA1、SHA256偶爾也有 HMAC 變種。反過來如果你改了參數(shù)它紋絲不動那它更可能是和時間戳綁定的令牌而不是對參數(shù)的摘要。1.2 簽名參數(shù)通常掛在哪一層簽名放在請求頭、URL 查詢串還是請求體里背后其實有工程上的考量不是隨便選的。放請求頭是最常見的做法原因有三一是不會污染 URL避免被 CDN 日志、瀏覽器歷史、Referer 無意中記錄二是跨域場景下配合自定義頭能觸發(fā)預(yù)檢服務(wù)端多一層控制三是和業(yè)務(wù)參數(shù)解耦前端攔截器可以統(tǒng)一注入業(yè)務(wù)代碼完全無感。放 URL 查詢串的情況多見于一些老接口或者需要被緩存層識別的場景這種情況下簽名往往是對整個查詢串排序后做的摘要。放請求體的通常是表單提交類接口簽名會作為 body 的一個隱藏字段一起提交。還有一類比較隱蔽的簽名藏在 cookie 里通過響應(yīng)頭的Set-Cookie下發(fā)每次請求自動帶上這種反而最省事因為瀏覽器幫你處理了。真正麻煩的不是位置而是簽名內(nèi)容到底包了什么。常見的組合是業(yè)務(wù)參數(shù)按字典序拼接 → 加時間戳 → 加隨機串 → 加一個硬編碼在 JS 里的鹽 → 做摘要。這里任何一環(huán)的順序錯了算出來的值都對不上。所以逆向的第一步不是急著讀代碼而是先把輸入確定下來也就是搞清楚簽名函數(shù)的入?yún)⒔Y(jié)構(gòu)。這個下文會詳細講。2. 順著調(diào)用棧往回走把簽名函數(shù)從混淆里挖出來2.1 斷點不是隨便下的從網(wǎng)絡(luò)面板倒推很多人一上來就去全局搜索字段名搜到一堆\x58\x2d\x53\x69\x67\x6e這樣的東西就懵了。其實更高效的路徑是從結(jié)果倒推過程。在 Network 面板找到那個請求右鍵選擇Replay XHR請求會重新發(fā)一次在 Sources 面板里對XMLHttpRequest.prototype.send或者fetch下斷點勾上Pause on any XHR/fetch讓請求在發(fā)出前停下來。停下來的那一刻調(diào)用棧就是你最好的地圖。棧頂通常是框架內(nèi)部的攔截器往下翻幾層會看到一個業(yè)務(wù)函數(shù)它的作用域里大概率能看到headers或者config對象里面就有那個簽名值。這時候別急著單步先在棧幀里點開各個作用域看看有沒有sign、token、timestamp這些變量名。如果變量名被混淆成了_0x1a2b就看它的值值和請求頭里那個簽名對得上的那個變量就是你要找的。我個人的習(xí)慣是找到值之后右鍵那個變量選Store as global variable控制臺里就能用temp1直接訪問然后寫幾行代碼調(diào)一調(diào)看看把它換成別的值請求還能不能通。這一步能確認它到底是簽名還是別的什么避免走了半天冤枉路。2.2 字符串?dāng)?shù)組與控制流平坦化的處理手法混淆工具產(chǎn)出的代碼有幾個經(jīng)典特征認出來之后還原思路就比較清晰了。第一個是字符串?dāng)?shù)組文件開頭有一大坨var _0x4f2c [\x73\x69\x67\x6e, getTime, ...]后面所有字符串都通過_0x4f2c[0x1]這種方式引用。第二個是解碼函數(shù)數(shù)組里的字符串常常還是加密的需要一個立即執(zhí)行函數(shù)在啟動時解密一遍。第三個是控制流平坦化一段簡單的 if-else 被改寫成while(true){ switch(_0xabc--) { case 0: ... } }的形式看得人腦殼疼。處理字符串?dāng)?shù)組最有效的辦法不是手動替換而是讓瀏覽器自己跑出來。在解碼函數(shù)執(zhí)行完之后的那一行下斷點控制臺里直接打印那個數(shù)組變量整個明文數(shù)組就出來了。如果在源碼里定位不到解碼完成的位置可以在文件末尾加一行console.log(_0x4f2c)或者用調(diào)試器的Logpoint功能不用改源碼也能看到??刂屏髌教够鄬β闊┑膊挥萌窟€原。大多數(shù)情況下你只需要跑通一個函數(shù)而不是讀懂整個文件。把switch里的case順序用腳本重新排一下按照_0xabc的初始值反推出執(zhí)行順序再把代碼塊按順序重排可讀性就能恢復(fù)七八成。如果懶得動手直接讓虛擬機執(zhí)行原始混淆代碼也是可行的只是調(diào)試體驗差一些。下面是我常用的字符串?dāng)?shù)組還原腳本思路是把數(shù)組和解碼函數(shù)一起在 Node 里跑一遍把結(jié)果 dump 出來const fs require(fs); const vm require(vm); const source fs.readFileSync(./obfuscated.js, utf-8); // 用一個最簡單的樁環(huán)境防止解碼函數(shù)里引用 window 報錯 const sandbox { window: {}, document: { createElement: () ({}) }, navigator: { userAgent: Mozilla/5.0 }, console: console, atob: (s) Buffer.from(s, base64).toString(binary), }; vm.createContext(sandbox); // 只取文件開頭的數(shù)組聲明和解碼 IIFE避免執(zhí)行后續(xù)業(yè)務(wù)邏輯 const head source.slice(0, source.indexOf(function _0x)); try { vm.runInContext(head, sandbox, { timeout: 3000 }); } catch (e) { console.warn(部分解碼失敗可分段執(zhí)行, e.message); } // 通常解碼后的數(shù)組會被掛回原變量名直接打印 const names Object.keys(sandbox).filter(k /^_0x/.test(k)); names.forEach(n console.log(n, sandbox[n]));這段代碼的價值不在于多高明而在于它體現(xiàn)了一個原則能交給引擎執(zhí)行的就別用手算。混淆的本質(zhì)是機器能讀懂、人讀不懂那就讓機器去讀。2.3 摳出來的代碼怎么在本地跑起來定位到加密函數(shù)之后下一步是把它從整個文件里剝離出來讓它在本地 Node 環(huán)境里獨立運行。這個過程俗稱摳代碼或者補環(huán)境聽起來玄乎其實就是缺什么補什么。第一輪直接把函數(shù)體連同它依賴的變量、工具函數(shù)一起復(fù)制出來扔到一個新文件里跑一下看報什么錯。報window is not defined就補一個global.window {}報document is not defined就補一個最簡對象報navigator、location、localStorage同理。關(guān)鍵是別一次補全按報錯驅(qū)動補一個跑一次這樣才知道哪些是真正用到的哪些是冗余的。// env.js —— 最小可用環(huán)境樁 global.window global; global.navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), platform: Win32, language: zh-CN, }; global.location { href: https://example.com/list, protocol: https: }; global.document { cookie: , referrer: https://example.com/, createElement: () ({ getContext: () null, style: {}, }), getElementsByTagName: () [], }; global.localStorage { _s: {}, getItem(k) { return this._s[k] || null; }, setItem(k, v) { this._s[k] String(v); }, }; // 加載并調(diào)用摳出來的簽名函數(shù) require(./sign.js); const result global.__sign({ page: 1, keyword: demo, ts: Date.now() }); console.log(sign , result);這里有幾個坑要提前說。一是Date和Math.random可能被頁面重寫過如果原代碼里有Date.prototype.getTime的改寫本地行為會和瀏覽器不一致需要把這個改寫也一起扣出來。二是有些簽名函數(shù)依賴document.cookie里的某個值本地是空的算出來的結(jié)果自然對不上這時候要把真實 cookie 塞進去。三是時區(qū)問題如果簽名里用到了new Date().toString()本地環(huán)境和服務(wù)器時區(qū)不一致會直接導(dǎo)致簽名失敗最好統(tǒng)一成時間戳運算。我踩過最深的一個坑是某個函數(shù)內(nèi)部用了window.performance.now()本地補了window但沒補performance報錯信息被 try-catch 吃掉了返回undefined最后是逐行打斷點才發(fā)現(xiàn)的。所以補環(huán)境的時候盡量把 try-catch 先注釋掉讓錯誤暴露出來別讓靜默失敗浪費時間。3. 字體反爬不是障眼法是另一套編碼3.1 頁面上的亂碼其實是私有區(qū)碼點第一次遇到字體反爬的人最常見的反應(yīng)是這頁面怎么全是方塊。其實仔細看方塊里是有東西的只是字形被替換過。打開 Elements 面板選中一個數(shù)字看到的文本內(nèi)容是#xe123;這樣的私有區(qū)碼點范圍通常在 UE000 到 UF8FF 之間。瀏覽器用頁面引入的自定義字體去渲染這個碼點字體文件里把 UE123 這個位置畫成了8的樣子所以你看到的是8但復(fù)制出來是\ue123。整個鏈路是這樣的font-face聲明了一個.woff或.ttf文件font-family指向它頁面元素的文本用私有區(qū)碼點瀏覽器加載字體后按 cmap 表把碼點映射到字形索引再從glyf表里取出輪廓數(shù)據(jù)渲染出來。反爬的核心就在 cmap 這張表上——它等于一張碼點 → 字形的字典只要把這張字典讀出來再搞清楚每個字形長什么樣映射關(guān)系就破了。用 Python 的fontTools解析是最省事的路徑幾行代碼就能把 cmap 打印出來from fontTools.ttLib import TTFont font TTFont(target.woff) cmap font.getBestCmap() # {碼點: 字形名} glyf font[glyf] for code, name in sorted(cmap.items()): glyph glyf[name] if glyph.numberOfContours 0: continue # 空字形或復(fù)合字形跳過 coords glyph.coordinates print(hex(code), name, 點數(shù), len(coords), x范圍, (min(p[0] for p in coords), max(p[0] for p in coords)))getBestCmap()返回的是 Unicode cmap 子表一般就是你要的那張。字形名通常是uniE123這種命名但不絕對有的字體商會用glyph001之類的無意義名字所以別依賴名字依賴碼點和輪廓數(shù)據(jù)。復(fù)合字形composite glyph的numberOfContours是負數(shù)需要先glyf[name].getCoordinates(glyf)展開再處理直接讀coordinates會報錯這一點在文檔里寫得不太明顯。3.2 用字形輪廓做特征比對拿到 cmap 只是第一步你還需要知道每個字形到底對應(yīng)哪個真實字符。如果反爬做得比較敷衍字形名會直接叫uniE123.1或者干脆就是eight一眼就能看出來。但稍微認真一點的名字都是隨機生成的這時候就得從輪廓特征反推。輪廓特征可以從幾個維度提取控制點數(shù)量、輪廓的包圍盒寬高、各點坐標的哈希、輪廓數(shù)量。真實場景下同一個字符在不同字體文件里的輪廓可能被輕微擾動比如點坐標整體偏移 1 到 2 個單位或者增加幾個冗余點所以不能做精確哈希得做相似度匹配。我一般用點數(shù) 寬高比 坐標歸一化后的歐氏距離這三項加權(quán)效果比較穩(wěn)。def glyph_feature(font, name): glyph font[glyf][name] coords, _, _ glyph.getCoordinates(font[glyf]) xs [p[0] for p in coords] ys [p[1] for p in coords] w max(xs) - min(xs) or 1 h max(ys) - min(ys) or 1 # 歸一化到 0-1消除字號差異 norm [((x - min(xs)) / w, (y - min(ys)) / h) for x, y in coords] return { count: len(coords), ratio: round(w / h, 3), points: norm, } def similarity(a, b): if abs(a[count] - b[count]) a[count] * 0.2: return 0.0 if abs(a[ratio] - b[ratio]) 0.1: return 0.0 n min(len(a[points]), len(b[points])) dist sum(((a[points][i][0] - b[points][i][0]) ** 2 (a[points][i][1] - b[points][i][1]) ** 2) ** 0.5 for i in range(n)) / n return max(0.0, 1 - dist * 3)這套方法的前提是你手里有一份標準字形庫。做法很簡單找一個頁面上能看到真實字符的對照位置比如詳情頁里同時用普通字體展示的數(shù)字把字體文件按碼點解析后人工或者半自動地把字形和真實字符綁定一次存成 JSON。之后每次遇到新字體拿新字形去和標準庫做相似度匹配取最高分即可。0 到 9 這十個數(shù)字加上小數(shù)點、冒號、百分號一共也就十幾個字符人工標一次十分鐘的事一勞永逸。3.3 動態(tài)字體每次刷新都是一張新表靜態(tài)字體反爬是入門級真正讓人抓狂的是動態(tài)字體每次刷新頁面服務(wù)端重新生成一個字體文件碼點和字形的對應(yīng)關(guān)系整體打亂。今天 UE001 是8明天可能就是3。這時候只解析一次 cmap 是沒用的必須每次請求都重新拉字體、重新解析、重新匹配。判斷是不是動態(tài)字體最粗暴的方法是連續(xù)刷新兩次對比兩次拿到的字體文件 MD5。如果不一樣再看 cmap 里的碼點集合是否變化碼點集合變了說明是全新字體碼點集合沒變但字形位置變了說明是重排。兩種情況處理思路一樣都是每次都以當(dāng)前字體文件為準重新走一遍特征匹配。工程上要做的是把解析 → 匹配 → 緩存這條鏈路做成冪等的。緩存鍵用字體文件的 MD5 而不是 URL因為 URL 里常常帶隨機參數(shù)同一個文件可能會有多個 URL。匹配結(jié)果存成{字體MD5: {碼點: 真實字符}}的字典同一個字體再次出現(xiàn)時直接查緩存省掉重復(fù)的輪廓計算。這套機制搭好之后單次頁面解析的額外耗時能壓到幾十毫秒基本不影響整體抓取效率。還有個經(jīng)驗有些字體文件會把部分字形做成空殼輪廓點數(shù)極少甚至為零用來干擾基于點數(shù)的識別。遇到這種情況特征匹配會自動失敗這時候退一步用渲染截圖做像素比對也能兜底雖然慢但準確率更高。別在一種方法上死磕多準備一條后路。4. 兩套機制疊在一起時的穩(wěn)定性問題4.1 時間戳、隨機數(shù)與簽名有效期簽名里帶時間戳的接口服務(wù)端一定會做時間窗口校驗常見的是允許 ±300 秒或 ±600 秒的偏差。這意味著你算出來的簽名不是永久有效的隔一段時間就得重算。本地調(diào)試時如果反復(fù)用同一個簽名發(fā)請求過一會兒突然開始報錯十有八九不是代碼壞了是時間戳過期了。正確的處理方式是把簽名計算做成每次請求前調(diào)用而不是啟動時算一次緩存起來。函數(shù)簽名大致是sign(params)內(nèi)部自己取當(dāng)前時間戳外部只管傳業(yè)務(wù)參數(shù)。隨機串同理nonce必須每次重新生成如果復(fù)用會被服務(wù)端的防重放機制攔下來。還有一種更隱蔽的情況服務(wù)端不僅校驗時間窗口還校驗時間戳的單調(diào)遞增也就是后一次請求的時間戳不能小于前一次。如果本地時間因為 NTP 同步往回跳了一下就會出現(xiàn)莫名其妙的失敗。穩(wěn)妥的做法是維護一個本地遞增計數(shù)器取max(當(dāng)前時間, 上次時間 1)作為實際使用的時間戳能規(guī)避絕大多數(shù)抖動。4.2 字體映射緩存的失效判斷字體緩存失效的判斷不能只看時間得看內(nèi)容。我見過有人設(shè)置緩存 10 分鐘過期結(jié)果服務(wù)端是每 5 分鐘換一次字體中間 5 分鐘一直在用錯誤的映射解析數(shù)據(jù)全錯還找不到原因。更可靠的做法是每次請求響應(yīng)里檢查字體 URL 或者響應(yīng)頭里的文件哈希變了就重新拉。有些站點更狠字體文件路徑完全不變但內(nèi)容每次請求都不同靠 CDN 緩存控制頭來區(qū)分。這種情況就得在下載后立刻算一次 SHA1和上次的比對。多一次幾百 KB 文件的哈希計算代價可以接受換來的是映射絕對不會錯位。失效情形判斷依據(jù)處理策略字體 URL 變化響應(yīng) HTML 里的font-face地址直接重新下載解析URL 不變但內(nèi)容變下載后計算文件 SHA1比對哈希不同則重建映射碼點集合變化cmap 的 key 集合視為全新字體全量匹配字形輪廓輕微擾動特征相似度低于閾值用標準庫做模糊匹配兜底表格里最后一行是最容易被忽略的。有的站點不做整體重排只是在原有基礎(chǔ)上給每個字形加一點隨機偏移肉眼完全看不出區(qū)別但精確哈希會全部失效。這時候如果你的匹配邏輯是哈希相等才算命中就會全軍覆沒。所以特征匹配一定要留模糊空間閾值設(shè)得松一點寧可多匹配幾次也別直接判失敗。5. 工程化與邊界能跑和能長期跑是兩回事5.1 請求節(jié)奏與失敗重試的設(shè)計腳本能跑通第一版的時候人是最容易上頭的恨不得立刻把并發(fā)拉到 50 去刷全量數(shù)據(jù)。這么干的結(jié)果通常是被限流、被封 IP、或者觸發(fā)更嚴格的風(fēng)控然后前面的工作全白費。正確的節(jié)奏是把并發(fā)壓到個位數(shù)請求之間加隨機間隔比如 800 到 2000 毫秒之間浮動。這個數(shù)字不是拍腦袋是根據(jù)大多數(shù)站點默認的限流策略估算的低于這個值觸發(fā)概率會明顯上升。重試策略也要講究。遇到 403、429 這類狀態(tài)碼不要立刻重試先退避等 5 秒、15 秒、60 秒指數(shù)增長最多重試三次。遇到簽名錯誤類的業(yè)務(wù)返回碼別重試因為重試也是錯的應(yīng)該重新走一遍簽名計算流程再發(fā)。遇到字體解析失敗也別重試請求重試的是字體下載和匹配環(huán)節(jié)。把不同錯誤類型分開處理比無腦while(retry 3)靠譜得多。判斷是不是被限流了除了看狀態(tài)碼還可以看響應(yīng)時間。正常情況下 200 毫秒的接口突然變成 3 秒哪怕返回的仍是 200也說明服務(wù)端在給你降速這時候主動停下來歇幾分鐘比硬剛更明智。5.2 把逆向結(jié)果封裝成可替換的模塊逆向代碼最大的問題是脆弱。對方前端一改版你摳出來的函數(shù)可能整個失效。所以從一開始就要把它當(dāng)成一個可替換的適配器來設(shè)計而不是散落在主流程里的幾十行腳本。具體做法是定義一個穩(wěn)定的接口比如signRequest(params) - headers內(nèi)部實現(xiàn)隨便怎么變外部調(diào)用方不感知。字體解析同理定義decodeText(text, fontBuffer) - plainText換了解析算法也不影響調(diào)用方。這樣改版的時候你只需要重寫適配器內(nèi)部主流程一行不用動。配置項也要抽出來。鹽值、字段名、簽名算法、字體匹配閾值全部放到配置文件里別硬編碼在函數(shù)中間。我見過太多人把鹽值寫在代碼第 300 行改版之后找了半天才想起來在哪兒。集中管理之后改版排查的時間能省一大半。最后一條經(jīng)驗是關(guān)于日志的。逆向類腳本一定要打詳細日志但要分級。請求級別的日志記錄 URL、耗時、狀態(tài)碼簽名級別的日志記錄入?yún)⒑统鰠⒌恼獎e打完整值太長字體級別的日志記錄文件哈希和匹配成功率。出問題的時候看一眼日志就知道是哪一環(huán)崩的比重新斷點調(diào)試快十倍。日志文件記得輪轉(zhuǎn)別跑一天把磁盤寫滿。真正把這類項目做久了我才明白逆向能力只占三成剩下七成是工程穩(wěn)健性和對邊界的管理。代碼寫得再巧妙如果三天兩頭因為對方改版就全線崩那這個項目就沒法交付。反過來拆得足夠清晰、切得足夠細、日志足夠全就算核心算法失效了也能在半小時內(nèi)定位并替換掉。這才是能長期跑下去的狀態(tài)。