:JSVMP虛擬機保護(hù)下的簽名算法分析與還原)
1. 項目概述逆向QQ音樂sign與jsvmp算法最近在分析一些網(wǎng)絡(luò)應(yīng)用的數(shù)據(jù)交互時不可避免地會碰到一些“簽名”參數(shù)比如QQ音樂里的sign。這個參數(shù)就像是客戶端和服務(wù)器之間約定好的一個“暗號”每次請求都必須帶上而且每次都不一樣服務(wù)器用它來驗證請求的合法性防止數(shù)據(jù)被篡改或偽造。對于開發(fā)者或者安全研究者來說理解這個sign是如何生成的不僅能滿足技術(shù)好奇心更是進(jìn)行深度數(shù)據(jù)分析、自動化腳本編寫或安全審計的基礎(chǔ)。而QQ音樂的sign生成又和一種叫做“jsvmp”的技術(shù)緊密綁定這讓整個推導(dǎo)過程變得像是一場有趣的“數(shù)字偵探游戲”。簡單來說這個項目就是要去搞清楚當(dāng)你在QQ音樂上點擊播放一首歌、搜索一個歌手時你的客戶端比如網(wǎng)頁或App在向服務(wù)器發(fā)送請求前是如何計算出那個關(guān)鍵的sign值的。這個過程涉及到對前端JavaScript代碼的逆向分析特別是要面對一種被稱為“虛擬機保護(hù)”Virtual Machine Protection, VMP的混淆技術(shù)。我們的目標(biāo)不是去破解或攻擊而是通過技術(shù)分析理解其工作原理和實現(xiàn)邏輯這對于學(xué)習(xí)現(xiàn)代Web安全防護(hù)、JavaScript代碼保護(hù)以及網(wǎng)絡(luò)協(xié)議分析都大有裨益。2. 核心思路與技術(shù)選型逆向分析一個Web應(yīng)用的簽名算法通常有幾種路徑。最直接的是抓包后在開發(fā)者工具的Network面板里直接搜索sign這個關(guān)鍵詞看能否在某個JavaScript文件里找到它的計算函數(shù)。但面對像QQ音樂這樣的大型商業(yè)應(yīng)用這條路往往走不通因為核心邏輯通常被高度混淆和壓縮。另一種思路是“Hook”即攔截JavaScript中特定的函數(shù)調(diào)用比如Date.now、Math.random、encodeURIComponent或者各種哈希函數(shù)觀察sign生成前后這些函數(shù)被調(diào)用的順序和參數(shù)從而定位關(guān)鍵代碼。然而當(dāng)遇到j(luò)svmp時這兩種傳統(tǒng)方法都會遇到巨大挑戰(zhàn)。jsvmp全稱JavaScript Virtual Machine Protection是一種將原始JavaScript代碼的操作邏輯如算術(shù)運算、邏輯判斷、函數(shù)調(diào)用轉(zhuǎn)換為一套自定義的字節(jié)碼指令然后由一個用JavaScript編寫的“虛擬機”來解釋執(zhí)行這些字節(jié)碼的技術(shù)。這相當(dāng)于給代碼加了一個“翻譯層”。你看到的.js文件里不再是直觀的var sign md5(a b c)這樣的代碼而是一大堆看似毫無意義的數(shù)組、字符串常量以及一個龐大的switch-case或查表循環(huán)結(jié)構(gòu)。這個結(jié)構(gòu)就是虛擬機它讀取字節(jié)碼通常也是一個數(shù)組根據(jù)每條指令去執(zhí)行對應(yīng)的操作。原始的業(yè)務(wù)邏輯被“打碎”并編碼到了字節(jié)碼和虛擬機的指令處理邏輯中。因此我們的技術(shù)選型必須圍繞“如何分析這個虛擬機”來展開。純靜態(tài)分析直接讀代碼幾乎不可行因為邏輯太隱晦。動態(tài)調(diào)試用瀏覽器調(diào)試器一步步跟是核心手段但需要策略。我選擇的是“動靜結(jié)合”的策略先用動態(tài)調(diào)試定位到虛擬機入口和sign相關(guān)的初始輸入點然后通過“日志注入”和“代碼追溯”來理清虛擬機的執(zhí)行流和數(shù)據(jù)處理過程。工具上主要依賴Chrome DevTools配合一些自定義的JavaScript代碼片段Snippets來Hook和記錄關(guān)鍵信息。為什么不直接用現(xiàn)成的自動化脫殼工具因為每個jsvmp的實現(xiàn)都是高度定制化的通用工具往往失效手動分析雖然耗時但最能鍛煉對系統(tǒng)原理的理解并且得出的結(jié)論也最可靠。3. 環(huán)境準(zhǔn)備與初步抓包分析工欲善其事必先利其器。第一步是建立一個干凈的分析環(huán)境。我通常會使用一個獨立的Chrome用戶配置文件或者直接使用無痕模式確保瀏覽器擴(kuò)展不會干擾網(wǎng)絡(luò)請求。打開Chrome DevToolsF12并切換到Network網(wǎng)絡(luò)面板記得勾選上“Preserve log”保留日志以防止頁面跳轉(zhuǎn)時請求記錄被清除。接著打開QQ音樂官網(wǎng)并播放任意一首歌曲。在Network面板中你會看到大量請求。我們需要找到那個攜帶了sign參數(shù)的請求。通常獲取播放地址、提交搜索、獲取用戶歌單等涉及核心業(yè)務(wù)的API請求都會包含它。通過篩選XHR或Fetch請求并仔細(xì)查看請求的Query String Parameters或Form Data不難找到一個參數(shù)名稱為sign的請求。記下這個請求的URL和完整的參數(shù)列表。初步觀察你會發(fā)現(xiàn)sign值是一長串看似隨機的十六進(jìn)制字符串也可能是Base64編碼。一個關(guān)鍵點是這個值每次請求都會變化這說明它的生成一定與某些可變因素有關(guān)例如當(dāng)前時間戳t、一個隨機數(shù)nonce、請求參數(shù)本身或者是用戶登錄后的令牌token。對比多個請求你可能會發(fā)現(xiàn)除了sign外還有像_、t、token等參數(shù)。我們的目標(biāo)就是找出sign F(t, nonce, token, 其他參數(shù)...)這個函數(shù)F的具體形式。注意在分析過程中請務(wù)必遵守相關(guān)法律法規(guī)和網(wǎng)站的使用條款。本分析僅用于學(xué)習(xí)交流目的切勿用于制作惡意爬蟲、刷量、攻擊等非法用途。過度頻繁的請求可能會對服務(wù)器造成壓力也可能導(dǎo)致你的IP被暫時限制。4. 定位簽名生成入口Hook與搜索策略直接在所有壓縮的JavaScript文件中搜索sign關(guān)鍵詞可能返回成千上萬個結(jié)果其中大部分是作為字符串常量在請求數(shù)據(jù)中出現(xiàn)而不是生成邏輯。更有效的方法是Hook可能用于生成簽名的底層函數(shù)。一個經(jīng)典的切入點是HookXMLHttpRequest的send方法 或fetchAPI。因為所有網(wǎng)絡(luò)請求最終都會經(jīng)過它們。我們可以寫一段腳本在請求發(fā)出前檢查其參數(shù)是否包含我們關(guān)心的sign并打印出此時的調(diào)用棧。這能幫助我們快速定位到設(shè)置sign參數(shù)的代碼位置。// 在Chrome DevTools的Console中或保存為Snippet后執(zhí)行 (function() { var originSend XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send function(data) { // 檢查URL或data中是否包含‘sign’ if (this._url this._url.indexOf(your_target_api_path) -1) { console.trace(XHR send caught for target API); debugger; // 自動觸發(fā)斷點 } return originSend.apply(this, arguments); }; // 同樣可以Hook fetch var originFetch window.fetch; window.fetch function() { console.trace(Fetch called, arguments); return originFetch.apply(this, arguments); }; })();另一種策略是Hook常見的哈希函數(shù)構(gòu)造函數(shù)比如CryptoJS、MD5、SHA1或者更通用的SubtleCrypto.digest。因為sign很可能是一種哈希值。在Console中重寫這些構(gòu)造函數(shù)當(dāng)它們被調(diào)用時打印調(diào)用棧和參數(shù)。// 示例Hook CryptoJS.MD5 (如果存在) if (window.CryptoJS CryptoJS.MD5) { var originMD5 CryptoJS.MD5; CryptoJS.MD5 function(message) { console.log(MD5 called with message:, message); console.trace(); debugger; return originMD5(message); }; }通過這種方式當(dāng)我們觸發(fā)一個會產(chǎn)生sign的請求比如點擊播放時調(diào)試器會自動斷在相關(guān)代碼處。此時觀察調(diào)用棧從棧頂向下尋找第一個看起來不是庫代碼而是業(yè)務(wù)代碼的位置。這個位置很可能就在虛擬機jsvmp的入口函數(shù)附近或者是在準(zhǔn)備調(diào)用虛擬機函數(shù)的地方。你會看到大量的數(shù)組操作、循環(huán)和一個巨大的switch語句——這就是虛擬機的典型特征。5. 解析jsvmp虛擬機結(jié)構(gòu)一旦斷點在虛擬機內(nèi)部我們需要理解它的結(jié)構(gòu)。一個典型的jsvmp虛擬機包含以下幾個部分指令集/操作碼數(shù)組一個龐大的數(shù)組通常叫opcodes或bytecode里面的數(shù)字就是虛擬機要執(zhí)行的指令。每個數(shù)字對應(yīng)一個具體的操作比如“從某個位置取數(shù)據(jù)”、“進(jìn)行加法運算”、“跳轉(zhuǎn)到指定指令”等。常量池一個或多個數(shù)組存放著虛擬機執(zhí)行過程中需要用到的字符串、數(shù)字等常量。指令會通過索引來引用這些常量。寄存器/棧虛擬機用于臨時存儲計算結(jié)果的模擬內(nèi)存空間??赡苁且粋€數(shù)組作為棧stack也可能是幾個變量作為寄存器reg0,reg1...。虛擬機解釋器一個大的循環(huán)或switch語句它不斷讀取指令集數(shù)組的當(dāng)前指令用一個指針ip記錄位置根據(jù)指令的值執(zhí)行對應(yīng)的操作代碼塊并更新ip。分析時我們的目標(biāo)是理解指令含義通過動態(tài)調(diào)試觀察當(dāng)執(zhí)行到某條指令時?;蚣拇嫫鞯臓顟B(tài)如何變化從而推斷出這條指令是做什么的例如opcode 5可能是“將常量池索引為X的值壓?!?。追蹤數(shù)據(jù)流找到生成sign的最終輸出點然后反向追蹤??醋罱K用于計算哈希的字符串是如何通過一系列虛擬機指令從最初的參數(shù)t,nonce等一步步構(gòu)建出來的。還原算法邏輯將這一系列虛擬機指令“翻譯”回我們熟悉的JavaScript邏輯比如字符串拼接、排序、哈希計算等。這個過程極其繁瑣需要極大的耐心。一個實用的技巧是“打日志”。我們可以在虛擬機解釋器的核心循環(huán)里注入日志代碼記錄每一條執(zhí)行的指令、操作的數(shù)據(jù)和棧的狀態(tài)。雖然手動修改混淆后的代碼很困難但我們可以利用調(diào)試器的“條件斷點”功能或者在關(guān)鍵變量變化時打印其值。6. 關(guān)鍵參數(shù)提取與算法推導(dǎo)實戰(zhàn)假設(shè)通過動態(tài)跟蹤我們發(fā)現(xiàn)了以下線索sign的計算發(fā)生在某個特定的函數(shù)調(diào)用之后這個函數(shù)接收一個對象作為參數(shù)對象里包含了t,nonce,path,data等字段。進(jìn)入虛擬機后觀察到指令依次將這些字段的值從參數(shù)對象中取出放入棧中。隨后有一系列指令對棧中的字符串進(jìn)行排序按字典序并按照keyvalue的格式用符號連接起來形成一個長字符串我們稱之為“待簽名字符串”。接著虛擬機指令調(diào)用了某個函數(shù)可能是內(nèi)聯(lián)的也可能是通過常量池索引找到的函數(shù)引用對這個“待簽名字符串”進(jìn)行了處理。通過Hook我們發(fā)現(xiàn)這個處理實際上是MD5哈希運算。MD5的結(jié)果一個32位的十六進(jìn)制字符串被輸出賦值給了sign參數(shù)。那么算法推導(dǎo)就清晰了。它很可能是一個標(biāo)準(zhǔn)的“參數(shù)排序后拼接再進(jìn)行MD5”的簽名方式。用偽代碼表示就是function generateSign(params) { // 1. 篩選并排序參數(shù) let keys Object.keys(params).sort(); // 按key字典序排序 // 2. 拼接成 key1value1key2value2... 的格式 let queryString keys.map(key ${key}${params[key]}).join(); // 3. 對拼接后的字符串進(jìn)行MD5哈??赡苓€會加鹽 let sign md5(queryString a_secret_salt); // 鹽值需要分析確定 return sign; }其中最關(guān)鍵的“鹽”secret_salt可能需要進(jìn)一步分析。它可能是一個硬編碼在常量池里的字符串也可能是通過更復(fù)雜的計算得來比如用token的一部分。這需要繼續(xù)跟蹤MD5函數(shù)的輸入?yún)?shù)是否除了拼接字符串外還連接了其他隱藏內(nèi)容。7. 算法還原與代碼實現(xiàn)基于上一步的推導(dǎo)我們可以嘗試用清晰的JavaScript代碼還原這個簽名算法。這里假設(shè)我們已經(jīng)確定了所有必要參數(shù)和鹽值。// 假設(shè)我們還原出的算法 const crypto require(crypto); // Node.js 環(huán)境 function qqMusicSign(params, secretSalt) { // 1. 參數(shù)排序 const sortedKeys Object.keys(params).sort(); // 2. 構(gòu)建待簽名字符串 const queryParts []; for (const key of sortedKeys) { // 注意value可能需要URL編碼具體看原始請求 const value encodeURIComponent(params[key]); queryParts.push(${key}${value}); } const stringToSign queryParts.join(); // 3. 添加鹽值并計算MD5 const dataToHash stringToSign secretSalt; const hash crypto.createHash(md5); hash.update(dataToHash); const sign hash.digest(hex); // 輸出32位小寫十六進(jìn)制 return sign; } // 示例使用 const commonParams { _: Date.now(), // 時間戳 t: Math.floor(Date.now() / 1000), // 可能另一種時間戳 nonce: 隨機字符串, token: 用戶令牌, path: /api/v1/song/url, // ... 其他業(yè)務(wù)參數(shù) }; const secretSalt 分析得到的鹽值字符串; // 例如 qweasdzxc const calculatedSign qqMusicSign(commonParams, secretSalt); console.log(生成的sign:, calculatedSign);在瀏覽器環(huán)境中可以使用CryptoJS庫或Web Crypto API來實現(xiàn)MD5。還原后需要用多組不同的參數(shù)進(jìn)行測試將計算出的sign與抓包得到的真實sign進(jìn)行比對只有完全一致才說明算法還原正確。實操心得鹽值secretSalt往往是分析中最難確定的一環(huán)。它可能不是簡單的字符串拼接而是被編碼后分散在常量池的不同位置在虛擬機執(zhí)行過程中動態(tài)拼接起來。遇到這種情況需要在MD5函數(shù)被調(diào)用前一刻Hook并打印其完整的輸入?yún)?shù)這是確定最終待哈希字符串的最直接方法。8. 動態(tài)對抗與混淆升級的應(yīng)對商業(yè)應(yīng)用的反爬和代碼保護(hù)策略是持續(xù)升級的。今天分析出的算法明天可能就失效了。常見的對抗升級包括算法變更簽名算法本身改變例如從MD5換成SHA256或者拼接順序、鹽值規(guī)則發(fā)生變化。jsvmp升級虛擬機指令集更新增加了反調(diào)試、代碼流混淆控制流平坦化等更復(fù)雜的保護(hù)。環(huán)境檢測增加對瀏覽器調(diào)試工具、自動化腳本環(huán)境的檢測如果發(fā)現(xiàn)異常則返回假數(shù)據(jù)或拒絕服務(wù)。代碼動態(tài)加載核心的簽名算法代碼不再一次性加載而是在運行時通過異步請求動態(tài)獲取并執(zhí)行增加定位難度。面對這些對抗我們的分析策略也需要調(diào)整定期驗證對于需要長期維護(hù)的自動化腳本需要建立簽名算法的健康檢查機制一旦發(fā)現(xiàn)簽名無效立即觸發(fā)重新分析流程。關(guān)注網(wǎng)絡(luò)請求除了主要的業(yè)務(wù).js文件注意觀察是否有額外的、小的.js文件或WebSocket通信在頁面加載后動態(tài)引入那可能就是新的算法代碼。更隱蔽的調(diào)試使用debugger;語句很容易被檢測。可以嘗試使用Chrome DevTools的“停用斷點”功能或者通過Function.prototype.constructor等方式在更底層設(shè)置斷點。理解核心原理無論混淆如何升級其核心目標(biāo)都是將輸入?yún)?shù)轉(zhuǎn)化為一個難以偽造的輸出。只要牢牢抓住“輸入”請求參數(shù)和“輸出”sign這兩端通過系統(tǒng)性的動態(tài)追蹤和邏輯推理總能夠理清其中的變換過程。對JavaScript語言特性、常見加密哈希函數(shù)、以及代碼混淆技術(shù)如控制流平坦化、不透明謂詞的深入理解是應(yīng)對更復(fù)雜保護(hù)的根本。9. 常見問題與排查技巧實錄在逆向分析過程中肯定會踩不少坑。下面記錄了一些典型問題和解決方法問題1Hook不到任何加密函數(shù)調(diào)用。排查可能對方使用了不常見的哈希庫或者自己實現(xiàn)了哈希算法如用JavaScript模擬MD5。此時應(yīng)擴(kuò)大Hook范圍嘗試HookArray.prototype.join,String.prototype.charCodeAt等基礎(chǔ)方法觀察大規(guī)模字符串操作發(fā)生在哪里?;蛘咧苯釉趕ign參數(shù)被賦值的那一刻下斷點然后反向追溯這個值是怎么來的。問題2虛擬機代碼過于龐大執(zhí)行流跳轉(zhuǎn)混亂無法跟蹤。排查這很可能是遇到了“控制流平坦化”混淆。它把原本順序或分支執(zhí)行的代碼打散成一個巨大的switch塊通過一個“分發(fā)器”來決定下一步執(zhí)行哪個基本塊。應(yīng)對方法是不要試圖理解每一條指令而是聚焦于“數(shù)據(jù)流”。關(guān)注棧頂或某個關(guān)鍵寄存器的值是如何隨著指令執(zhí)行而變化的??梢詫懸粋€簡單的腳本在調(diào)試器控制臺下每執(zhí)行一步就打印出棧和關(guān)鍵寄存器的狀態(tài)從而過濾掉控制流混淆的干擾直擊數(shù)據(jù)變換的核心路徑。問題3計算出的sign和抓包得到的不一致。排查這是最常遇到的問題。請按以下清單逐步核對參數(shù)完整性確保你用于計算sign的參數(shù)對象其鍵值對與原始請求完全一致。不要遺漏任何看似無關(guān)的參數(shù)如_,g_tk等也不要添加任何多余參數(shù)。特別注意undefined、null和空字符串的區(qū)別原始請求中不存在的參數(shù)就不要放。參數(shù)順序確認(rèn)排序規(guī)則是否正確。通常是按參數(shù)名的ASCII碼從小到大排序。用Object.keys().sort()在大多數(shù)情況下是對的但最好用抓包的多組數(shù)據(jù)驗證一下排序結(jié)果。參數(shù)值格式檢查每個參數(shù)的值是否需要encodeURIComponent。有時數(shù)字和時間戳不需要編碼而字符串需要。對比原始請求中Query String的格式。拼接格式確認(rèn)是keyvaluekey2value2還是key:value\nkey2:value2等其他格式。分隔符是還是|鹽值與附加步驟確認(rèn)是否在拼接字符串后添加了鹽值或者進(jìn)行了多次哈希。鹽值是否正確是否在MD5之前還對字符串做了其他處理如Base64編碼編碼輸出MD5結(jié)果是16進(jìn)制的字符串是小寫還是大寫抓包對比一下。問題4算法似乎依賴瀏覽器環(huán)境如窗口大小、User-Agent、Canvas指紋等。排查在虛擬機執(zhí)行過程中留意是否有調(diào)用navigator.userAgent、screen.width、document.getElementById等獲取環(huán)境信息的API。如果簽名算法融入了這些環(huán)境變量那么你的Node.js腳本計算出的sign就會失效。解決方案是在Node.js環(huán)境中模擬這些值或者更復(fù)雜的情況下可能需要使用無頭瀏覽器如Puppeteer來執(zhí)行整個簽名生成過程。逆向工程就像解謎需要細(xì)心、耐心和系統(tǒng)性的思維。每一次成功的推導(dǎo)不僅是對目標(biāo)系統(tǒng)的一次深刻理解也是對自身分析能力的一次錘煉。記住過程比結(jié)果更重要尤其是在面對jsvmp這樣復(fù)雜的保護(hù)技術(shù)時能夠一步步理清其執(zhí)行脈絡(luò)本身就是極大的收獲。