最近在玩一款被稱為“天”的游戲——塞爾達傳說:曠野之息,在網上搜索攻略的時候意外發現了一個網站,該網站包含了游戲里所有的武器裝備、材料和道具的數據資料,見下圖:

看到這么全的數據庫,哪個海拉魯老流氓不心動?
我二話不說打開了開發者工具,正當我准備把網站的請求數據copy下來的時候,下面一幕讓海拉魯老流氓愣了幾秒鍾:

這......數據是看到了,但是data和items是什么鬼,是個人就看不懂啊,這明顯被加密過了。
這里只放上data的原始數據(items過長):
wSrc2z7sj%2BnlXfsQZXMXyF6oPlYLbVXWO%2BfZXgod4qfQvuHbcMJpjIGPE0H2pdXUTZ4TUcA93YzihbfoTq6iuv3762j%2B9hfLAZcEQPuDkthVrHOsZhq5VfYnM7FXIcWR9KjmYRO2MtmnI%2BQkFB3bBNOUb9BVgd7vtJZmgjaAI16EfoBsuwjtrJ66R6gpIHJejp5DU6RWWcoUV%2B1Ipp98NPEwYdGYCPAxovl8fd1NUaXK7ut3Op3IZ3yeOKpSNuhH%2FVLGmOy8wa8lA9JreGxrR8%2B0IsmjvMt3IgTGW0i2wse9Xc51qxNLld1SH5G6khEZx05JjiAWLSbKIQMLkf2Bob6C8LQpaFtRTMQLeWrIPY6Av0FVddqMf5xgjTghPOSIIyKdzw5CwWPeSWJfi7S8YAv9JkqwmbC1S2Zi7m0WpbLm6xpPyXMw1GBi8QeRM1DoBnhlwdodEl9Bvc6tFIbmTu9%2B32%2FqSCDujx4CUYSER9Ly4hO8%2B%2Fkzlbwwu2COotL2o4fczBhQyERt1szEXl33QwP3SX1ayJOrPJZSdMK8Unmoth1eFZL0H1Ncle3YyuLemplIOL2UeBq43%2B8WpXBEfHzqVrry6bohHIf1wjSsPNOo5N9MNn8BMlvVRrbOmUWbWs00j%2F%2BwuLF7xp51dVXhmlTAhKh1nV1VpyiCwZqaK5YEdh0WkMnxGRL8pwhEeY9u09DIm5EYVEjANANyh7xY6RZw5c7eUeq%2FRphhqlKkbDrlHfd0UM%2B1j1XklD5mGY2cz4RZdOiiM1A1aTQfSrwduAWoMDH9VnxTnGeE6%2FmZxL9EV6bhr9%2FfhnNsExb8ixTTyGRC4Js2w4pcT4tFHe25mkDood9VpCseurJxK66BHPubG1jKtHkWgcP8UwHLXHaC1E%2BIXppLRW3hrEr0r%2Bg7fw%3D%3D
作為時長50+小時的海拉魯老流氓,字典里是沒有“放棄”這兩個字的!為了救公主都解了這么多神廟,解個加密的報文豈不是分分鍾的事情?
過程
為了解密更清晰,可以列出大概的流程:
-
確定加密協議:既然要解密,那么我們首先得確定加密協議
-
破解密鑰:確定了加密協議后,此時我們就要破解密鑰了
-
解密報文:有了密鑰后,我們根據加密的規則,對報文解密,最終就能得到報文的明文了
其實這跟解神廟(為了救公主,林克的智力蹭蹭上漲)是一樣的:
進入神廟后,我們首先得觀察神廟的地形和構造,確定是通過磁力、制冰器、靜止器亦或是其它道具來輔助解密,確定輔助道具后,接着就是去破解如何使用這些道具去觸發機關了,我們只要觸發了機關,就算解密成功了。
確定加密協議
既然要解密,我們就先得確定加密的協議,但光從報文上我們根本獲取不到任何對解密有用的信息了,這個時候我們只能從前端着手了。
我們根據請求的URL路徑名/api/getByItems,可以確定,頁面上裝備和材料展示的數據,都是從這個請求返回的報文中獲取到的,經過了前端JS的解密處理后,再渲染到界面上的。
這個時候我們就可以從前端JS解密報文這一步找突破口,此時海拉魯老流氓嘴角微微上揚,手指熟練的按下了F12鍵,並把光標移動到sources面板

此時,老流氓表示遭到同行的鄙視
<noscript>
<strong>看個什么玩意兒?</strong>
</noscript>
看完頁面結構后,很容易判斷出這是vue框架編寫的頁面,並且使用了webpack打包構建工具。這時我們只要從chunk-vendors.d9052322.js、app.567b1880.js兩個js文件入手就行了。
先來看到chunk-vendors.d9052322.js這個文件的文件名,從名字上可以初步判斷,這是一個第三方模塊或供應商模塊的文件集合,通常,我們會將所有/node_modules中的第三方包打包到chunk-vendors.js中。
因此,網站的業務邏輯肯定是在app.567b1880.js這個文件里邊了,我們只需要重點關注這個文件就行了。對其代碼格式化后,我們可以看到,以下又是我們不容易理解的一大段代碼:

這很明顯是被開發者混淆了代碼,但是,混淆不混淆的沒關系,我們只要找到我們關心的代碼關鍵字就行,在js文件里搜索接口路徑名的關鍵字getByItems:

我們聚焦到then里邊的函數,當成功返回接口數據后,有以下兩個操作:JSON.parse(a(decodeURIComponent(t.data)))和JSON.parse(a(decodeURIComponent(t.items))),可以看出,這里對t.data和t.items都進行了相同的轉化。
"jx" !== g && "pt" !== g || O.dispatch("getByItems", {
orderid: v,
mode: g,
language: O.state.currlanguage
}).then((function(t) {
if (0 !== t.errCode)
return d["a"].error(t.errMsg).then((function() {
s.push("/")
}
));
t.data = JSON.parse(a(decodeURIComponent(t.data))),
O.state.items = JSON.parse(a(decodeURIComponent(t.items))),
e.value = t.data,
m.value = !1
}
))
JSON.parse和decodeURIComponent這兩個都是js自帶的兩個函數,我們需要把重心放在a這個被混淆的函數上,那么問題來了,我們怎么查看這個a函數呢,全局搜索嗎?肯定不是啦,這時候就要用到谷歌瀏覽器自帶的Overrides調試功能了。
通過Overrides功能,我們能將網站的app.567b1880.js文件替換成本地文件並進行調試,在這里,我們把原有的app.567b1880.js文件復制一份放到本地,然后在Overrides面板出添加本地文件路徑,啟用Enable Local Overrides選項

接着,我們在回到剛剛找到的接口路徑名的關鍵字getByItems所在行,點擊左側行數添加斷點調試

此時刷新網頁,就能進入到debugger的斷點調試了。此外,按F10或者“下一步”的按鈕,可以執行下一步的斷點

t.data = JSON.parse(a(decodeURIComponent(t.data))),
O.state.items = JSON.parse(a(decodeURIComponent(t.items))),
到這里,其實我們只要關注t.data和O.state.items的值,就能解出加密報文中data和items的值了

至此,報文就被破解成功了!但是......還記得我們之前說的解密流程么,我們首先得確定加密協議,才可以解密。
剛剛我們也看到了a這個被混淆的函數時解密的關鍵,那么我們現在繼續深入它,通過斷點調試進入到a函數,我們可以看到a函數的構造

function a(e) {
var t = Ot.a.enc.Utf8.parse(st)
, c = Ot.a.AES.decrypt(e, t, {
mode: Ot.a.mode.ECB,
padding: Ot.a.pad.Pkcs7
});
return Ot.a.enc.Utf8.stringify(c).toString()
}
在這里,我們提取幾個關鍵字:enc、AES、ECB和Pkcs7,重點在AEC,這很明顯用的是AES加密算法(Advanced Encryption Standard)。
再仔細觀察,這里的decrypt的函數傳參中有mode: Ot.a.mode.ECB,、Ot.a.pad.Pkcs7,再結合AES加密算法,又可以確定,這里的加密算法采用的是ECB的工作模式,如下圖

這張圖的關鍵字是相同的輸入產生相同的輸出,可以理解為,如果密鑰不變,每次加密相同的報文,那么最后加密得到的密文塊是一樣的。我們也可以對比下每次的請求返回的加密報文數據,可以發現,其實每次返回的data和items都是相同的,那么我們就可以確定,在這里的密鑰也是固定的,既然密鑰是固定的,那么對於前端來說,要想解密報文把數據展示在頁面上,這個密鑰必然是繞不開前端的。因此,我們可以從前端出發,把密鑰找出來。
破解密鑰
再聚焦到剛才打斷點的a函數的內容
function a(e) {
var t = Ot.a.enc.Utf8.parse(st)
, c = Ot.a.AES.decrypt(e, t, {
mode: Ot.a.mode.ECB,
padding: Ot.a.pad.Pkcs7
});
return Ot.a.enc.Utf8.stringify(c).toString()
}
我們把函數中的變量都標出來(在這里是a(t.data)的執行上下文)


我們先看到Ot.a,這是一個加密庫包,加上該加密算法為AES加密算法,我們很容易聯想到前端常用的一個加密庫crypto-js.js,既然知道了前端加解密用的是crypto-js.js,我們接下來只要用這個庫包去驗證即可。
<!DOCTYPE html>
<html>
<head>
<title>crypto-js</title>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
</head>
<body>
<script type="text/javascript">
console.log('CryptoJS', CryptoJS);
</script>
</body>
</html>
打開瀏覽器控制台,可以看到這里引入的庫包CryptoJS構造和Ot.a是一樣的,那么我們解密的時候就可以根據庫包來判斷解密函數中的每個變量的含義了。

CryptoJS文檔描述

// 混淆函數
function a(e) {
var t = Ot.a.enc.Utf8.parse(st)
, c = Ot.a.AES.decrypt(e, t, {
mode: Ot.a.mode.ECB,
padding: Ot.a.pad.Pkcs7
});
return Ot.a.enc.Utf8.stringify(c).toString()
}
既然Ot.a對應的是CryptoJS,那我們就根據CryptoJS文檔描述把混淆的函數稍作修改以下
// 優化
function decrypt(encryptText) {
var passphrase = CryptoJS.enc.Utf8.parse(passphraseText)
, decryptText = CryptoJS.AES.decrypt(encryptText, passphrase, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return CryptoJS.enc.Utf8.stringify(decryptText).toString()
}
在這里我們可以暫時忽略CryptoJS.enc.Utf8.parse和CryptoJS.enc.Utf8.stringify這兩個函數,我們只要知道它是一個編碼轉換的函數即可。
// CryptoJS can convert from encoding formats such as Base64, Latin1 or Hex to WordArray objects and vice-versa.
var words = CryptoJS.enc.Utf8.parse(" ");
var utf8 = CryptoJS.enc.Utf8.stringify(words);
現在我們需要重點關心的是passphraseText,根據文檔描述可以確定passphraseText是密鑰了。而passphraseText在混淆函數中對應的又是st了,我們只需要在app.567b1880.js文件中搜索st的來源,就能得到密鑰了

通過查找,我們可以看到Object(ft["MD5"])("by ilil").toString().substring(0, 16)的值賦給了st
st = Object(ft["MD5"])("by ilil").toString().substring(0, 16)
我們從這段函數表達式中提取關鍵的信息Object(ft["MD5"])("by ilil"),可以推測出這里是用了MD5對by ilil進行了加密,然后把得到的值字符串化后截取了0~16位。此時我們如果繼續在app.567b1880.js文件中搜索ft函數會得到如下內容
ft = c("3452")
而c(3452)又在chunk-vendors.d9052322.js文件中,之前也提到過,chunk-vendors.d9052322.js是個第三方依賴的庫包

我們可以看到3452標識的匿名函數中的內容是被混淆過的,此時我們繼續追溯下去會難查到源頭。
既然是通過MD5去對密鑰做了一層加密,那么,我們可以可以用CryptoJS自帶的MD5函數去驗證,最后得到st即passphraseText密鑰為a1b15f44ab22f260
Object(CryptoJS.MD5("by ilil")).toString().substring(0, 16); // a1b15f44ab22f260
解密報文
我們有了密鑰a1b15f44ab22f260,再繼續看到之前我們優化過的混淆函數
function decrypt(encryptText) {
var passphrase = CryptoJS.enc.Utf8.parse(passphraseText)
, decryptText = CryptoJS.AES.decrypt(encryptText, passphrase, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return CryptoJS.enc.Utf8.stringify(decryptText).toString()
}
現在encryptText和passphraseText這兩個變量我們都確定了,那么接下來只要解密就行了,把這兩個參數帶入進函數中,最終就能返回我們想要的解密后的報文了。

'{"HartCheck":false,"HartCombo":120,"StaminaCheck":false,"StaminaCombo":3000,"RupeeCheck":false,"RupeeBox":999999,"MamoCheck":false,"MamoBox":999999,"KorokCheck":false,"KorokBox":900,"RebornCheck":false,"RebornBox":999,"MotoCheck":false,"MotoDisabled":false,"MasterOpenCheck":false,"MasterOpenDisabled":false,"StockCheck":false,"StockDisabled":false,"RelicCheck":false,"RelicDisabled":false,"BossCheck":false,"BossDisabled":false,"MapCheck":false,"MapDisabled":false,"TransferShowCheck":false,"TransferShowDisabled":false,"TransferCheck":false,"TransferDisabled":false,"Item":{"weapons":[],"bows":[],"arrow":[],"shields":[],"clothes":[],"materials":[],"food":[],"other":[],"horse":[]}}'
