崙?zhàn):從Hex密文到明文,破解C12響應(yīng)加密全流程)
刷 spiderbuf 刷到第 C12 題的時候我卡了整整兩個晚上。頁面上表格數(shù)據(jù)渲染得整整齊齊Network 面板里接口返回的卻是一堆十六進(jìn)制亂碼。這種題最磨人思路看起來很多但每一條都感覺差一步。當(dāng)時也沒想著要寫什么筆記直到把整個鏈路跑通、把數(shù)據(jù)完整拿下來之后才覺得這題的通用套路特別值得沉淀。這篇文章就記錄我從拿到 C12 到批量拿到全部數(shù)據(jù)的完整過程重點說清楚怎么判斷題目形態(tài)、怎么定位加密函數(shù)、怎么把瀏覽器里的 JS 邏輯翻譯成自己能跑的代碼。不管是正在刷 C12 的人還是對 JS 逆向完全不知道怎么上手的新手這套流程應(yīng)該都能直接照著走一遍。1. 先把題目形態(tài)看清楚別急著翻 Sources我第一次做 C12 時犯的錯就是打開開發(fā)者工具直接去 Sources 里翻 JS。翻了半天除了發(fā)現(xiàn)代碼是壓縮過的之外毫無進(jìn)展。后來我才意識到做這種題的第一步不是看代碼而是先通過抓包把數(shù)據(jù)從哪來、到哪去、以什么形態(tài)存在搞清楚。這一步花不了幾分鐘但能省掉后面大量的瞎猜。1.1 從響應(yīng)內(nèi)容判斷是不是前端解密打開 Network 面板刷新頁面找到返回數(shù)據(jù)的那個 XHR 請求。正常情況下如果接口直接返回 JSON那說明服務(wù)端給的就是明文問題大概率出在請求參數(shù)上。但 C12 的響應(yīng)體是一長串十六進(jìn)制字符這就是非常明確的信號服務(wù)端返回的是密文解密動作發(fā)生在瀏覽器端。我當(dāng)時的判斷依據(jù)很簡單接口返回的 Content-Type 明明是 JSON但我們看到的卻是一段無意義的 hex 字符串。頁面里表格的數(shù)據(jù)正常顯示了說明有個 JS 函數(shù)在拿到響應(yīng)之后做了處理然后再交給渲染邏輯。直接在 Console 里用fetch重放那個接口得到的響應(yīng)和 Network 里看到的完全一致。這三條合在一起基本可以確定這是一個響應(yīng)加密的題解題核心是還原解題析出明文那一步。把目標(biāo)定清楚之后再看代碼就不容易跑偏。1.2 順手檢查請求參數(shù)有沒有動態(tài)簽名在看響應(yīng)之前我習(xí)慣對比兩次請求的 URL 和請求頭。C12 的請求里有一個v參數(shù)每次刷新數(shù)值都不一樣。我一開始緊張了一下以為是簽名校驗如果不生成正確的v就拿不到數(shù)據(jù)。后來做了個驗證把v固定成某個舊值再發(fā)一次請求發(fā)現(xiàn)照樣能拿到密文數(shù)據(jù)。這說明v不參與響應(yīng)解密也不是硬性校驗。所以我的主攻方向就鎖定了響應(yīng)解密而不是先去碰這個動態(tài)參數(shù)。這種先確認(rèn)變量是否關(guān)鍵的習(xí)慣能幫你避免把精力浪費(fèi)在不重要的細(xì)節(jié)上。做完了響應(yīng)解密之后再回頭看那個v發(fā)現(xiàn)它只是防爬策略里用來做日志追蹤的一個字段不影響數(shù)據(jù)獲取。1.3 把目標(biāo)拆成可執(zhí)行的三句話隨著思路逐漸清晰我把任務(wù)寫成了三句話貼在編輯器里找到把 hex 字符串還原成明文的 JS 函數(shù)。搞清楚它的加密算法、密鑰來源和調(diào)用方式。在本地用 Python 或者 Node 復(fù)現(xiàn)同一套邏輯拿到所有頁面的明文數(shù)據(jù)。這三個目標(biāo)互不包含每完成一個下一步的搜索范圍就縮小一截。這也是我后來做任何逆向題都會先做的目標(biāo)拆解。2. 定位加密函數(shù)斷點配合全局搜索效率最高目標(biāo)確定之后剩下的事情就是找到那個解密函數(shù)。很多人這時候會直接把壓縮后的 JS 拉下來從頭讀到尾那真不是人干的事。我用的方法是斷點確認(rèn)調(diào)用點 全局搜索關(guān)鍵詞兩步配合著來一般十來分鐘就能定位。2.1 在瀏覽器里掛鉤 XHR先截住響應(yīng)數(shù)據(jù)要找到解密函數(shù)什么時候被調(diào)用最直接的辦法是讓 JS 在拿到響應(yīng)數(shù)據(jù)的那一刻停下來。Chrome DevTools 的 Sources 面板里有一個 XHR/fetch breakpoints可以按 URL 關(guān)鍵字?jǐn)嘧≌埱蟀l(fā)送但那個斷點往往太靠前還沒到響應(yīng)處理階段。我更喜歡的方法是在 Console 里臨時掛一個 XHR hook把每次請求的 URL 和響應(yīng)內(nèi)容打印出來let originalOpen XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open function (...args) { this._url args[1]; return originalOpen.apply(this, args); }; let originalSend XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send function (...args) { this.addEventListener(load, function () { if (this._url.includes(/api/)) { console.log(captured url:, this._url); console.log(response:, this.responseText); } }); return originalSend.apply(this, args); };這段代碼的作用是在 XMLHttpRequest 發(fā)送和加載完成時分別記錄 URL 和響應(yīng)內(nèi)容。把它貼在 Console 里執(zhí)行然后刷新頁面就能在發(fā)起請求→拿到數(shù)據(jù)→交給業(yè)務(wù)代碼之間看到完整數(shù)據(jù)流。C12 的接口 URL 里包含明確的路徑關(guān)鍵字所以includes(/api/)這種過濾條件足夠用了。確定響應(yīng)數(shù)據(jù)是 hex 串之后就可以順著誰讀取了 responseText去追。最常見的做法是在代碼里搜索responseText出現(xiàn)的位置然后在那里打斷點。不過 C12 的代碼經(jīng)過壓縮變量名全是t、n、e之類直接搜responseText可能會搜出一大堆無關(guān)結(jié)果。所以我換用了更穩(wěn)的關(guān)鍵詞策略。2.2 全局搜索關(guān)鍵詞的優(yōu)先級排序在 Sources 面板按CtrlShiftF打開全局搜索輸入關(guān)鍵詞后就能在所有 JS 文件里搜。C12 這題我實際搜過的關(guān)鍵詞按優(yōu)先級排序如下關(guān)鍵詞用途實際效果JSON.parse找到解析明文的位置往往離解密函數(shù)只有一步命中 6 處逐個確認(rèn)后鎖定了入口decode大多數(shù)解密函數(shù)命名都帶這個詞命中 1 處就是核心函數(shù)toString(hex)反向操作先看加密方向能猜準(zhǔn)解密方向命中 2 處初步推測是 hex 傳輸charCodeAt/fromCharCode字節(jié)和字符串轉(zhuǎn)換的常見工具命中了工具函數(shù)確認(rèn)字節(jié)處理邏輯我搜到decode命中的函數(shù)名是decodeData位置在一個單獨(dú)的 JS 文件里。順著這個函數(shù)讀進(jìn)去整個流程就逐漸浮出水面了。2.3 格式化壓縮代碼但別一開始就想讀完整份文件雙擊對應(yīng)文件點左下角的格式化按鈕把壓縮成一行的代碼展開。格式化的目的不是馬上通讀全部邏輯而是讓你能針對性地看關(guān)鍵函數(shù)。我當(dāng)時把decodeData相關(guān)的區(qū)域框選出來發(fā)現(xiàn)它其實就做了三件事function decodeData(hexStr) { var bytes hexToBytes(hexStr); var key [0x4b, 0x4c, 0x4d]; for (var i 0; i bytes.length; i) { bytes[i] bytes[i] ^ key[i % key.length]; } var utf8Str bytesToString(bytes); return JSON.parse(utf8Str); }你看這就是非常典型的練習(xí)題寫法十六進(jìn)制轉(zhuǎn)字節(jié)、固定三字節(jié)密鑰、逐字節(jié)異或、再轉(zhuǎn)字符串、最后JSON.parse。算法一點都不復(fù)雜真正難的是你敢不敢在幾十個函數(shù)里認(rèn)出這個最簡單的東西。我當(dāng)時已經(jīng)在腦子里預(yù)設(shè)它可能是 AES 或者 DES反而誤導(dǎo)了自己好幾次。這個教訓(xùn)后面細(xì)說。3. 密鑰、算法順序、字節(jié)轉(zhuǎn)換C12 里最容易翻車的三個細(xì)節(jié)定位到decodeData之后我其實還沒有完全拿到明文因為函數(shù)里調(diào)用的hexToBytes和bytesToString還沒確認(rèn)。這時我踩了一個典型的坑我以為bytes就是字符串直接調(diào)tableData decodeData(...)試了一下結(jié)果 Console 直接報錯指出某個中間變量是數(shù)組不是期望的字符串。從這一步開始細(xì)節(jié)才是成敗的關(guān)鍵。3.1 密鑰不是靠猜是靠全局搜索 內(nèi)存讀取雙重確認(rèn)C12 的密鑰[0x4b, 0x4c, 0x4d]是硬編碼在代碼里的。我找它的方式比較保守先在格式化后的代碼里搜索key看到這個數(shù)組字面量然后在 Console 里執(zhí)行decodeData把體內(nèi)實際引用的變量打出來對比確認(rèn)。這樣既避免了搜錯變量名也確認(rèn)代碼分支確實走到了這個邏輯。真實項目里的密鑰一般不會像練習(xí)題這么直接常見的情況有密鑰從某個接口動態(tài)獲取每次刷新都變。密鑰被拆成多段分散在不同文件里最后拼接。密鑰本身被某個算法加密后藏起來運(yùn)行時才還原。針對這些情況我的建議是別猜。直接在該函數(shù)入口處打斷點然后看Scope面板里變量的實際值那是瀏覽器運(yùn)行時給出來的最真實答案。C12 這種硬編碼密鑰的題用搜索就能搞定但掌握打斷點看值的方法面對更難的題目時才不會慌。3.2 拿到中間結(jié)果后逐字節(jié)驗證算法順序異或解密的邏輯非常直觀密文的每個字節(jié)和密鑰循環(huán)異或。但循環(huán)二字的順序容易出錯。如果密鑰長度是 3而密文長度是 19那么前 18 個字節(jié)按0、1、2重復(fù)三次最后一個字節(jié)用的是密鑰的第一個字節(jié)。寫成代碼是key_len len(key) plain bytes(b ^ key[i % key_len] for i, b in enumerate(cipher_bytes))這個i % key_len的寫法雖然簡單但如果你手滑寫成key[i]Python 會在第 4 個字節(jié)直接報IndexError。如果寫成key[i % len]但 index 從 1 開始那最后一個字節(jié)就會用錯密鑰。怎么驗證算法順序?qū)Σ粚ξ医ㄗh先取一小段已知明文和密文手工算出第一個字節(jié)的異或結(jié)果再對比函數(shù)輸出。我在做 C12 時實測的第一組數(shù)據(jù)如下明文首字節(jié){的 ASCII 是0x7b。密鑰首字節(jié)是0x4b。異或結(jié)果0x7b ^ 0x4b 0x30。密文首字節(jié)確實是0x30。對不上就說明 key 順序或字節(jié)轉(zhuǎn)換有問題對得上才繼續(xù)往下跑。這種拿一個字節(jié)驗證全鏈路的做法非常高效不用等整段解密跑完才發(fā)現(xiàn)問題。3.3 字節(jié)轉(zhuǎn)字符串時別用錯 APIbytesToString這個工具函數(shù)在很多 JS 代碼里是這樣實現(xiàn)的function bytesToString(bytes) { let result ; for (let i 0; i bytes.length; i) { result String.fromCharCode(bytes[i]); } return result; }我一開始偷懶直接在 Node 里寫了Buffer.from(bytes).toString()。結(jié)果解出來全是中文亂碼我一度覺得是密鑰錯了繞了很多彎路。后來才想起來JS 端的String.fromCharCode逐字節(jié)轉(zhuǎn)出來的是整個 Unicode 碼位序列而Buffer.from(bytes)默認(rèn)按 UTF-8 編碼解釋字節(jié)兩者對同一組字節(jié)的解釋不一樣。換句話說如果bytes是已經(jīng) UTF-8 編碼過的明文那么String.fromCharCode得到的字符串還要再經(jīng)過一次編碼處理最終JSON.parse才能正確解析。這個細(xì)節(jié)在不同的題里可能完全相反所以正確做法是——先看你正在復(fù)現(xiàn)的那個 JS 函數(shù)用什么 API就把那個 API 的行為原樣翻譯到 Python 或 Node 里不要自己優(yōu)化。4. 本地復(fù)現(xiàn)先用 Node 跑通再用 Python 收尾解密算法的本質(zhì)已經(jīng)清楚了剩下的事情就是本地復(fù)現(xiàn)。我采用了兩步走策略先在 Node 里把瀏覽器里的 JS 函數(shù)幾乎是原樣粘貼驗證邏輯一致然后再用 Python 重新實現(xiàn)一份方便后面做數(shù)據(jù)采集和入庫。這兩步看起來重復(fù)但實際能擋掉很多隱蔽問題。4.1 用 Node.js 原樣運(yùn)行 JS 函數(shù)只要把hexToBytes、bytesToString、decodeData這三個函數(shù)從瀏覽器代碼里復(fù)制出來放到一個 Node 腳本里基本就能直接跑。C12 不依賴 DOM所以不需要任何模擬環(huán)境。假設(shè)接口返回的密文是306e232a212869763e3b25292e3e2f3e2a6f36我構(gòu)造了下面這個完整腳本function hexToBytes(hex) { const bytes []; for (let i 0; i hex.length; i 2) { bytes.push(parseInt(hex.substr(i, 2), 16)); } return bytes; } function bytesToString(bytes) { let result ; for (let i 0; i bytes.length; i) { result String.fromCharCode(bytes[i]); } return result; } function decodeData(hexStr) { const bytes hexToBytes(hexStr); const key [0x4b, 0x4c, 0x4d]; for (let i 0; i bytes.length; i) { bytes[i] bytes[i] ^ key[i % key.length]; } return JSON.parse(bytesToString(bytes)); } const sample 306e232a212869763e3b25292e3e2f3e2a6f36; console.log(decodeData(sample));執(zhí)行之后輸出結(jié)果是{ name: spiderbuf }這樣一個對象。這一步跑通就證明了你在瀏覽器里看到的邏輯在本地也能工作。如果你在瀏覽器里實際拿到的密文不是這一段直接把sample換成接口返回的完整 hex 串即可。需要注意的是如果題目用了CryptoJS這類第三方庫Node 里也需要安裝對應(yīng)包。安裝命令是npm install crypto-js然后在代碼里const CryptoJS require(crypto-js)。C12 沒用到 CryptoJS但很多同平臺的題會用比如后面難度上去的 AES 題。4.2 用 Python 重寫一份方便批量采集Node 腳本適合驗證邏輯但真要采集幾十頁數(shù)據(jù)、存進(jìn)數(shù)據(jù)庫我習(xí)慣用 Python。C12 的異或解法翻譯成 Python 非常干凈import json def hex_to_bytes(hex_str: str) - bytes: return bytes.fromhex(hex_str) def decode_data(hex_str: str, key: bytes) - dict: cipher hex_to_bytes(hex_str) key_len len(key) plain bytes( cipher[i] ^ key[i % key_len] for i in range(len(cipher)) ) return json.loads(plain.decode(utf-8)) key b\x4b\x4c\x4d sample 306e232a212869763e3b25292e3e2f3e2a6f36 print(decode_data(sample, key))運(yùn)行結(jié)果同樣是{name: spiderbuf}。Python 的bytes.fromhex可以直接轉(zhuǎn)換十六進(jìn)制字符串bytes類型的異或操作通過生成器逐字節(jié)完成邏輯上和 JS 版一一對應(yīng)。這里的plain.decode(utf-8)對應(yīng) JS 里的JSON.parse(bytesToString(bytes))但注意一定要在確認(rèn)了原函數(shù)確實是在做 UTF-8 解碼之后才能這么寫。C12 是這樣的換一道題就不一定了。4.3 解密結(jié)果和頁面渲染不一致時先查編碼再查算法我在調(diào)試過程中遇到過一個很迷的現(xiàn)象同樣一段密文Node 解出來是完整 JSONPython 解出來后面多了兩個字符的亂碼。排查了半天最后發(fā)現(xiàn)是 Python 腳本讀取 hex 字符串時不小心把換行符也帶進(jìn)了變量。這種低級錯誤很常見但特別浪費(fèi)時間。如果你遇到本地解出來亂碼的情況按這個順序排查密文是否原樣復(fù)制有沒有多空格、換行或截斷。十六進(jìn)制轉(zhuǎn)字節(jié)時用的是bytes.fromhex還是int(hex, 16)加循環(huán)前者對錯誤更敏感。解密后的字節(jié)解碼用的什么編碼是 UTF-8 還是 latin-1。密鑰長度是否和密文長度匹配有沒有讀錯 key 數(shù)組。絕大多數(shù)算法不對的感覺實際上都是上面四個環(huán)節(jié)里某個小問題導(dǎo)致的。我在 C12 上至少浪費(fèi)了一個小時在懷疑算法結(jié)果只是試錯批次中一次粘貼少了一位 hex。5. 翻頁、動態(tài)參數(shù)與批量采集C12 后半程的實用處理單頁數(shù)據(jù)解密成功之后緊接著就是翻頁。C12 這類題通常會有多個分頁每一頁的密文都不相同但解密邏輯完全一致。比較麻煩的是這個平臺部分題目的分頁請求里帶著動態(tài)參數(shù)如果你只是在本地寫死一個密鑰直接循環(huán)請求可能會在某一頁被限流或者拿不到數(shù)據(jù)。5.1 先判斷動態(tài)參數(shù)是不是必填項我當(dāng)時先手動請求第二頁的 URL把請求里的動態(tài)參數(shù)v分別替換成空值和固定值對比響應(yīng)。C12 的v字段即使固定住服務(wù)端仍然正常返回密文說明它只是混淆項不參與校驗。那我就不需要為它單獨(dú)寫生成邏輯直接把上一次響應(yīng)的 cookie 帶上、每頁間隔兩秒循環(huán)請求就足夠了。但如果遇到動態(tài)參數(shù)必填的題方法也很成熟定位那個生成v的 JS 函數(shù)在 Node 里調(diào)用同一套邏輯。由于這個函數(shù)通常沒有 DOM 依賴從瀏覽器復(fù)制到 Node 幾乎不需要改動。生成一次打印出來和瀏覽器里的值對比一致后再放進(jìn)請求參數(shù)。5.2 把解密函數(shù)封裝成獨(dú)立模塊為了讓后續(xù)采集代碼保持整潔我會把解密邏輯單獨(dú)放在一個文件里比如c12_decrypt.pyclass C12Decryptor: def __init__(self, key: bytes): self.key key def decrypt_hex(self, hex_str: str) - dict: cipher bytes.fromhex(hex_str.strip()) key_len len(self.key) plain bytes( cipher[i] ^ self.key[i % key_len] for i in range(len(cipher)) ) return json.loads(plain.decode(utf-8))然后在采集主腳本里只負(fù)責(zé)請求頁面和調(diào)用這個類。這樣做的好處是萬一某一天平臺改了算法你只需要改decrypt_hex一個方法其他代碼不用動。對練習(xí)來說也許不重要但一旦這套流程延伸到一個需要長期維護(hù)的采集任務(wù)代碼邊界清晰會省很多事。5.3 請求頻率和去重應(yīng)該從一開始就考慮spiderbuf 是練習(xí)平臺但也講究別把練習(xí)題當(dāng)成壓測目標(biāo)。我建議每抓一頁至少間隔一到兩秒控制在合理頻率內(nèi)。數(shù)據(jù)落庫之前做一次去重用數(shù)據(jù)里的主鍵字段比如題目編號做UNIQUE約束或者先查庫再插入。這樣做既保護(hù)平臺也能避免你自己在調(diào)試過程中重復(fù)抓取造成的數(shù)據(jù)混亂。C12 的數(shù)據(jù)拿到之后我習(xí)慣性地存了一份 JSON 和一份 SQLite。這兩個格式對后續(xù)的整理、驗證都很方便。如果你只是想驗證解密邏輯直接打印到控制臺就夠了如果想留著做數(shù)據(jù)分析落一份結(jié)構(gòu)化文件是值得的。6. 復(fù)盤 C12 的幾個通用教訓(xùn)做完 C12 之后我把它和之前刷過的其他題放在一起做了個對比發(fā)現(xiàn)有些坑幾乎是共通的。把這些經(jīng)驗寫下來比單純記住這道題的解法要有用得多。6.1 先定響應(yīng)加密還是參數(shù)加密再決定看代碼的方向很多人在蛛絲馬跡還不充分的時候就打開代碼亂搜結(jié)果在錯誤的方向上越走越深。拿 C12 來說如果我一開始就認(rèn)定是參數(shù)加密去追v參數(shù)的生成邏輯可能兩天都做不完。正確的順序是先抓包、看響應(yīng)形態(tài)、對比不同請求之間哪些字段在變?nèi)缓笤贈Q定看哪個方向的代碼。這個順序適用于絕大多數(shù)前端加密題。6.2 壓縮混淆的 JS 不可怕怕的是你不知道找什么格式化代碼之后變量名還是t、n、e讀起來依然痛苦。但我后來發(fā)現(xiàn)讀壓縮代碼的技巧不是從頭讀懂每一行而是先找關(guān)鍵函數(shù)再局部理解。JSON.parse、decrypt、decode、charCodeAt這些關(guān)鍵詞天然指向數(shù)據(jù)處理的核心位置。找到一個函數(shù)之后用 Console 直接調(diào)用驗證比逐行讀代碼快得多。6.3 瀏覽器是最順手的調(diào)試工具別急著寫腳本我的習(xí)慣流程是在 Console 里調(diào)用找到的函數(shù)驗證它可以解出明文。在函數(shù)內(nèi)部打斷點查看密鑰和中間變量的實際值。單步執(zhí)行兩遍確認(rèn)每一輪循環(huán)的變量變化符合預(yù)期。確認(rèn)無誤之后才把它搬到 Node 或 Python。這套流程每一步都在做驗證每驗證一步后面寫腳本時的信心就多一分。直接跳過瀏覽器驗證、上來就寫腳本一旦結(jié)果不對你可能分不清是算法沒搞對還是代碼寫錯了。6.4 練習(xí)平臺的邊界和底線最后說一點題外話。spiderbuf 這類平臺存在的意義就是讓人在干凈的環(huán)境里練手。C12 的教學(xué)價值在于訓(xùn)練抓包—定位—復(fù)現(xiàn)—驗證這一整套邏輯這套邏輯放到工作中也是在處理自己公司或者明顯授權(quán)的接口時才有意義。未經(jīng)授權(quán)去抓別人的數(shù)據(jù)無論在哪個國家、什么場景下都需要非常謹(jǐn)慎。我在做這類練習(xí)時給自己定了幾條底線只在公開的練習(xí)平臺測試不針對任何未授權(quán)的真實站點做逆向嘗試批量請求控制在低頻范圍拿到數(shù)據(jù)后只用于自身學(xué)習(xí)整理不對外傳播。守住這些底線才能讓逆向技術(shù)始終待在合法、合規(guī)的范圍里。C12 這道題現(xiàn)在回頭看難度真的不算高但它逼我養(yǎng)成了一個非常重要的習(xí)慣每一次猜測都要有驗證動作每一個中間值都要親眼看到再往下走。做完它之后我再去碰那些帶 AES、RSA 參數(shù)的題明顯感覺到心智負(fù)擔(dān)小了很多因為我知道不管算法怎么變量級怎么漲拆解的框架是固定的。如果你也卡在 C12 的半路上建議先別去看別人的答案回到 Network 面板把數(shù)據(jù)流重新看一遍大概率能自己找到突破口。