[node.js]PC端微信小程式包解密

語言: CN / TW / HK

微信小程式在PC端是加密儲存的,如果直接開啟是看不到什麼有用的資訊的,需要經過解密才可以看到包內具體的內容。本文使用nodejs實現解密演算法,主要涉及到crypto, commander, chalk三個包的使用。

小程式的原始碼在哪裡

PC端開啟過的小程式會被快取到本地微信檔案的預設儲存位置,可以通過微信PC端=>更多=>設定檢視:

進入預設儲存位置下的/WeChat Files/WeChat Files/Applet資料夾,可以看到該目錄下有一系列字首為wx的檔案(檔名其實是小程式的appid),這些就是我們開啟過的小程式啦:

進入其中某個小程式的資料夾,我們可以看到一個名字為一串數字的資料夾。點進這個資料夾, 就可以看到一個__APP__.wxapkg檔案,也就是小程式對應的程式碼啦:

然而,當我們開啟這個檔案之後卻發現是這樣的:

WTF 這能看出來個🔨。很明顯,這個檔案是經過加密的,需要解密才能看到我們想看到的東西。

PC端小程式是怎麼被加密的

這裡參考了一位大佬用Go語言寫的PC端wxapkg解密程式碼。整理一下的話,加密流程是這樣的:

首先將明文程式碼在第1024位元組處一分為二,前半部分使用CBC模式的AES加密,後半部分則直接進行異或。最後,將加密後的兩節拼接起來,並在最前邊寫入固定的字串:"V1MMWX"。

所以,我們開啟__APP__.wxapkg檔案看到的就是加密後的程式碼,如果想還原回去的話,需要從後往前逐步推回去。

解密思路

預處理

我們使用node.js去寫一個解碼的程式。根據上邊加密的流程,我們首先讀取加密檔案,把前6個位元組的固定字串去除。由於AES加密和異或前後資料的位數是相同的,我們可以據此獲取到加密後的頭部1024位元組和加密後的尾部部分:

const fs = require('fs').promises;
...

const buf = await fs.readFile(pkgsrc); // 讀取原始Buffer
const bufHead = buf.slice(6, 1024 + 6);
const bufTail = buf.slice(1024 + 6);
複製程式碼

加密後的頭部部分

為了得到這1024個位元組的明文,我們需要知道AES加密的初始向量iv,以及一個32位的金鑰。已知16位元組的初始向量iv是字串:“the iv: 16 bytes”,我們接下來需要計算出這個由pbkdf2演算法匯出的32位的金鑰。

pbkdf2(Password-Based Key Derivation Function)是一個用來生成金鑰的函式,它使用一個偽隨機函式,將原文密碼和salt作為輸入,通過不斷的迭代得到金鑰。在crypto庫中,pbkdf2函式是這樣的:

const crypto = require('crypto');
...

crypto.pbkdf2(password, salt, iterations, keylen, digest, callback)
複製程式碼

其中引數分別是:原文密碼、鹽值、迭代次數、金鑰長度、雜湊演算法、回撥函式。已知salt是"saltiest",原文密碼為微信小程式的id(也就是wx開頭的那個資料夾名),迭代次數為1000,雜湊演算法為sha1。因此,我們可以寫出計算金鑰的程式碼:

crypto.pbkdf2(wxid, salt, 1000, 32, 'sha1', (err, dk) => {
    if (err) {
        // 錯誤
    }
    // dk即為計算得到的金鑰
})
複製程式碼

金鑰和初始向量iv都有了之後,我們可以開始對密文進行解密了。AES加密演算法是一種非對稱加密演算法,它的金鑰分成公開的公鑰和只有自己知道的私鑰,任何人都可以使用公鑰進行加密,但是隻有持有私鑰的人解密得到明文。

小程式使用的加密演算法是CBC(Cipher Block Chaining, 密碼分組連結)模式的AES,也就是它在加密的時候,首先把明文進行分塊,然後將每一塊與前一塊加密後的密文進行異或,再使用公鑰進行加密,得到每一塊的密文。對於第一塊明文,由於它不存在前一塊明文,因此它會與初始向量iv進行異或,再進行公鑰加密。在實現的時候,我們只需要呼叫crypto提供的解密函式就可以啦。

我們知道,AES演算法根據金鑰長度的不同有AES128, AES192和AES256。回顧上邊,我們的金鑰是32位元組,也就是256位的,因此顯然我們應該使用的是AES256。綜上,我們可以寫出來解密的程式碼:

const decipher = crypto.createDecipheriv('aes-256-cbc', dk, iv);
const originalHead = Buffer.alloc(1024, decipher.update(bufHead));
複製程式碼

其中originalHead就是我們要的前1024位元組的明文啦。我們可以打印出來看看:

嗯…… 有那麼點意思了。

加密後的尾部部分

這一部分就很簡單啦。由於異或運算是具有自反性的,因此只需要簡單的判斷一下小程式id的位數獲得異或的xorKey,再把它與密文進行異或,就可以得到原文了:

const xorKey = wxid.length < 2 ? 0x66 : wxid.charCodeAt(wxid.length - 2);
const tail = [];
for(let i = 0; i < bufTail.length; ++i){
    tail.push(xorKey ^ bufTail[i]);
}
const originalTail = Buffer.from(tail);
複製程式碼

將頭部部分的明文與尾部部分的明文進行拼接,再以二進位制形式寫入檔案,就可以得到最終的明文啦。

再漂亮點

根據上邊的描述,我們可以把我們整個的解密過程封裝成一個黑盒子:

commander

我們可以使用commander庫讓程式直接從命令列讀取小程式的id和密文包。commander是一個nodejs命令列介面的解決方案,可以很方便的定義自己的cli命令。比如說對於下面這一串程式碼:

const program = require('commander');
...
program
    .command('decry <wxid> <src> [dst]')
    .description('解碼PC端微信小程式包')
    .action((wxid, src, dst) => {
        wxmd(wxid, src, dst);
    })

program.version('1.0.0')
    .usage("decry <wxid> <src> [dst]")
    .parse(process.argv);
複製程式碼

我定義了一個"decry [dst]"的命令,其中尖括號代表必選引數,方括號代表可選引數。description內是關於這個命令的描述文字,action則是執行這段命令。在控制檯使用node執行程式碼之後,可以看到如下介面:

於是我們就可以根據提示,輸入引數進行解密啦。commander.js的中文文件在這裡

chalk

為了讓我們的控制檯多一抹顏色,我們可以使用chalk.js來美化輸出。chalk的基本用法也比較簡單:

const chalk = require('chalk');
...

console.log(chalk.green('綠了'))
複製程式碼

這樣我們就可以在黑白的控制檯上填上一抹綠色,替大熊貓實現夢想:

除此之外,我們還可以使用es6的字串標籤模板更方便的使用chalk。具體的參考chalk官方文件吧。

原始碼

程式碼釋出到github和gitee啦,可以給大家參考一下下~

github地址:https://github.com/maotoumao/wxpc-miniprogram-decryption

gitee地址:https://gitee.com/maotoumao/wxpc-miniprogram-decryption

如 果 小 🔥 伴 們 覺 得 有 意 思 那 就 給 萌 新 點 個 贊 8️⃣ 加 個 關 注 一 起 整 活 呀