微信小程序“反編譯”實(shí)戰(zhàn)(二):源碼還原
在上一篇文章《微信小程序“反編譯”實(shí)戰(zhàn)(一):解包》中,我們詳細(xì)介紹了如何獲取某一個(gè)小程序的 .wxapkg 包,以及分析了 .wxapkg 包的結(jié)構(gòu),***通過腳本解壓獲取包中的文件:小程序“編譯”后的代碼文件和資源文件,但是由于這些文件大部分被混淆了,可讀性很差,所以本文將進(jìn)一步分析,盡可能地把 .wxapkg 包的內(nèi)容還原為“編譯”前的內(nèi)容。
注:本文包含一部分源碼分析,由于手機(jī)屏幕較小,閱讀體驗(yàn)可能不佳,建議在電腦上瀏覽。
特別感謝:下文使用的還原工具來自于 GitHub 上的開源項(xiàng)目 wxappUnpacker,在此特別感謝原作者的無私貢獻(xiàn)。
概覽
我們知道,前端 Web 網(wǎng)頁編程采用的是 HTML + CSS + JS 這樣的組合,其中 HTML 是用來描頁面的結(jié)構(gòu),CSS 用來描述頁面的樣子,JS 通常用來處理頁面邏輯和用戶的交互。類似地,在小程序中也有同樣的角色,一個(gè)小程序工程主要包括如下幾類文件:
- .json 后綴的 JSON 配置文件
- .wxml 后綴的 WXML 模板文件
- .wxss 后綴的 WXSS 樣式文件
- .js 后綴的 JavaScript 腳本邏輯文件
例如“知識小集”的小程序源碼工程結(jié)構(gòu)如下:

然而,根據(jù)上一篇文章介紹,對“知識小集”小程序的 .wxapkg 解包后得到如下文件:

主要包括 app-config.json, app-service.js, page-frame.html, *.html, 資源文件 等,但這些文件已經(jīng)被“編譯混淆”并重新整合壓縮,微信開發(fā)者工具并不能識別它們,我們無法直接對它們進(jìn)行調(diào)試/編譯運(yùn)行。
所以,我們先嘗試分析一下從 .wxapkg 提取出來的各個(gè)文件內(nèi)容的結(jié)構(gòu)及其用途,然后介紹如何用腳本工具把它們一鍵還原為“編譯”前的源碼,并在微信開發(fā)者工具中跑起來。
文件分析
本節(jié)主要以“知識小集”小程序的 .wxapkg 解包后的源碼文件為例,進(jìn)行分析。
你也可以跳過本節(jié)的分析,直接看下一節(jié)介紹用腳本“反編譯”還原源碼。
app-config.json
小程序工程主要包括工具配置 project.config.json,全局配置 app.json 以及頁面配置 page.json 三類 JSON 配置文件。其中:
- project.config.json 主要用于對開發(fā)者工具進(jìn)行個(gè)性化配置以及包括小程序項(xiàng)目工程的一些基礎(chǔ)配置,所以它不會(huì)被“編譯”到 .wxapkg 包中;
- app.json 是對當(dāng)前小程序的全局配置,包括了小程序的所有頁面路徑、界面表現(xiàn)、網(wǎng)絡(luò)超時(shí)時(shí)間、底部 tab 等;
- page.json 用于對每一個(gè)頁面的窗口表現(xiàn)進(jìn)行配置,頁面中配置項(xiàng)會(huì)覆蓋 app.json 的 window 中相同的配置項(xiàng)。
因此“編譯”后的文件 app-config.json 其實(shí)就是 app.json 和各個(gè)頁面的配置文件的匯總,它的內(nèi)容大致如下:
- {
- "page": { // 各頁面配置
- "pages/index/index.html": { // 某一頁面地址
- "window": { // 某一頁面具體配置
- "navigationBarTitleText": "知識小集",
- "enablePullDownRefresh": true
- }
- },
- // 此處省略...
- },
- "entryPagePath": "pages/index/index.html", // 小程序入口地址
- "pages": ["pages/index/index", "pages/detail/detail", "pages/search/search"], // 頁面列表
- "global": { // 全局頁面配置
- "window": {
- "navigationBarTextStyle": "black",
- "navigationBarTitleText": "知識小集",
- "navigationBarBackgroundColor": "#F8F8F8",
- "backgroundColor": "#F8F8F8"
- }
- }
- }
通過與原工程 app.json 和各頁面配置 page.json 內(nèi)容的對比,我們可以得出 app-config.json 匯總文件的簡單整合規(guī)律,很容易把它拆分成“編譯”前對應(yīng)的各 json 文件。
app-service.js
在小程序項(xiàng)目中 JS 文件負(fù)責(zé)交互邏輯,主要包括 app.js,每個(gè)頁面的 page.js,開發(fā)者自定義的 JS 文件和引入的第三方 JS 文件,在“編譯”后所有這些 JS 文件都會(huì)被匯總到 app-service.js 文件中,它的結(jié)構(gòu)如下:
- // 一些全局變量的聲明
- var __wxAppData = {};
- var __wxRoute;
- var __wxRouteBegin;
- var __wxAppCode__ = {};
- var global = {};
- var __wxAppCurrentFile__;
- var Component = Component || function(){};
- var definePlugin = definePlugin || function(){};
- var requirePlugin = requirePlugin || function(){};
- var Behavior = Behavior || function(){};
- // 小程序編譯基礎(chǔ)庫版本
- /*v0.6vv_20180125_fbi*/
- global.__wcc_version__='v0.6vv_20180125_fbi';
- global.__wcc_version_info__={"customComponents":true,"fixZeroRpx":true,"propValueDeepCopy":false};
- // 工程中第三方或者自定義的一些 JS 源碼
- define("utils/util.js", function(require, module, exports, window,document,frames,self,location,navigator,localStorage,history,Caches,screen,alert,confirm,prompt,XMLHttpRequest,WebSocket,Reporter,webkit,WeixinJSCore) {
- "use strict";
- // ... 具體源碼內(nèi)容
- });
- // ...
- // app.js 源碼定義
- define("app.js", function(...) {
- "use strict";
- // ... app.js 源碼內(nèi)容
- });
- require("app.js");
- // 每個(gè)頁面對應(yīng)的 JS 源碼定義
- __wxRoute = 'pages/index/index'; // 頁面路由地址
- __wxRouteBegin = true;
- define("pages/index/index.js", function(...){
- "use strict";
- // ... page.js 源碼內(nèi)容
- });
- require("pages/index/index.js");
在這個(gè)文件中,原有小程序工程中的每個(gè) JS 文件都被 define 方法定義聲明,定義中包含 JS 文件的路徑和內(nèi)容,如下:
- define("path/to/xxx.js", function(...){
- "use strict";
- // ... xxx.js 源碼內(nèi)容
- });
因此,我們同樣很容易提取這些 JS 文件源碼,并恢復(fù)至相應(yīng)的路徑位置中。當(dāng)然,這些 JS 文件中的內(nèi)容經(jīng)過混淆壓縮,我們可以使用 UglifyJS 這樣的工具進(jìn)行美化,但仍很難還原一些原始變量名,不過基本不影響正常閱讀和使用。
page-frame.html
在小程序中使用 WXML 文件描述頁面的結(jié)構(gòu),WXSS 文件描述頁面的樣式。工程中有一個(gè) app.wxss 文件用于定義一些全局的樣式,會(huì)自動(dòng)被 import 到各個(gè)頁面中;另外每個(gè)頁面也都分別包含 page.wxml 和 page.wxss 用于描述其頁面的結(jié)構(gòu)和樣式;同時(shí),我們也會(huì)自定義一些公共的 xxxCommon.wxss 樣式文件和公共的 xxxTemplate.wxml 模板文件供一些頁面復(fù)用,一般在各自頁面的 page.wxss 和 page.wxml 中去 import。
當(dāng)“編譯”小程序后,所有的 .wxml 文件和 app.wxss 及公共 xxxCommon.wxss 樣式文件的將被整合到 page-frame.html 文件中,而每個(gè)頁面的 page.wxss 樣式文件,將分別單獨(dú)在各自的路徑下生成一個(gè) page.html 文件。
page-frame.html 文件的內(nèi)容結(jié)構(gòu)如下:
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
- <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
- <link rel="icon" href="data:image/ico;base64,aWNv">
- <script>
- // 一些全局變量的聲明
- var __pageFrameStartTime__ = Date.now();
- var __webviewId__;
- var __wxAppCode__ = {};
- var __WXML_GLOBAL__ = {
- entrys: {},
- defines: {},
- modules: {},
- ops: [],
- wxs_nf_init: undefined,
- total_ops: 0
- };
- // 小程序編譯基礎(chǔ)庫版本
- /*v0.6vv_20180125_fbi*/
- window.__wcc_version__ = 'v0.6vv_20180125_fbi';
- window.__wcc_version_info__ = {
- "customComponents": true,
- "fixZeroRpx": true,
- "propValueDeepCopy": false
- };
- var $gwxc
- var $gaic = {}
- $gwx = function(path, global) {
- // $gwx 方法定義(最核心)
- }
- var BASE_DEVICE_WIDTH = 750;
- var isIOS = navigator.userAgent.match("iPhone");
- var deviceWidth = window.screen.width || 375;
- var deviceDPR = window.devicePixelRatio || 2;
- function checkDeviceWidth() {
- // checkDeviceWidth 方法定義
- }
- checkDeviceWidth()
- var eps = 1e-4;
- function transformRPX(number, newDeviceWidth) {
- // transformRPX 方法定義
- }
- var setCssToHead = function(file, _xcInvalid) {
- // setCssToHead 方法定義
- }
- setCssToHead([])(); // 先清空 Head 中的 CSS
- setCssToHead([...]); // 設(shè)置 app.wxss 的內(nèi)容到 Head 中,其中 ... 為小程序工程中 app.wxss 的內(nèi)容
- var __pageFrameEndTime__ = Date.now()
- </script>
- </head>
- <body>
- <div></div>
- </body>
- </html>
相比其他文件,page-frame.html 比較復(fù)雜,微信把 .wxml 和部分 .wxss 直接“編譯”并混淆成 JS 代碼放入上述文件中,然后通過調(diào)用這些 JS 代碼來構(gòu)造 Virtual-Dom,進(jìn)而渲染頁面。
其中最核心的是 $gwx 和 setCssToHead 這兩個(gè)方法。
$gwx 用于通過 JS 代碼生成所有 .wxml 文件,其中每個(gè) .wxml 文件的內(nèi)容結(jié)構(gòu)都在 $gwx 方法中被定義好并混淆了,我們只要傳給它頁面的 .wxml 路徑參數(shù),即可獲取到每個(gè) .wxml 的內(nèi)容,再簡單加工一下即可還原成“編譯”前的內(nèi)容。
在 $gwx 中有一個(gè) x 數(shù)組用于存儲(chǔ)當(dāng)前小程序都有哪些 .wxml 文件,例如,“知識小集”小程序的 x 值如下:
- var x = ['./pages/detail/detail.wxml', '/towxml/entry.wxml', './pages/index/index.wxml', './pages/search/search.wxml', './towxml/entry.wxml', '/towxml/renderTemplate.wxml', './towxml/renderTemplate.wxml'];
此時(shí)我們可以在 Chrome 中打開 page-frame.html 文件,然后在 Console 中輸入如下命令,即可得到 index.wxml 的內(nèi)容(輸出一個(gè) JS 對象,通過遍歷這個(gè)對象即可還原出 .wxml 的內(nèi)容)
- $gwx("./pages/index/index.wxml")
setCssToHead 方法用于根據(jù)幾段被拆分的樣式字符串?dāng)?shù)組生成 .wxss 代碼并設(shè)置到 HTML 的 Head 中,同時(shí),它還將所有被 import 引用的 .wxss 文件(公共 xxxCommon.wxss樣式文件)所對應(yīng)的樣式數(shù)組內(nèi)嵌在該方法中的 _C 變量中,并標(biāo)記哪些文件引用了 _C 中數(shù)據(jù)。另外在 page-frame.html 文件的末尾,調(diào)用了該方法生成全局 app.wxss 的內(nèi)容設(shè)置到 Head 中。
因此,我們可以在每個(gè)調(diào)用 setCssToHead 方法的地方提取相應(yīng) .wxss 的內(nèi)容并還原。
對于 page-frame.html 文件中 $gwx 和 setCssToHead 這兩個(gè)方法更詳細(xì)的分析,可以參考這篇文章。
此外,checkDeviceWidth 方法顧明思議,用于檢測屏幕的寬度,其檢測結(jié)果將用于 transformRPX 方法中將 rpx 單位轉(zhuǎn)換為 px 像素。
rpx 的全稱是 responsive pixel,它是小程序自己定義的一個(gè)尺寸單位,可以根據(jù)當(dāng)前設(shè)備屏幕寬度進(jìn)行自適應(yīng)。小程序中規(guī)定,所有的設(shè)備屏幕寬度都為 750rpx,根據(jù)設(shè)備屏幕實(shí)際寬度的不同,1rpx所代表的實(shí)際像素值也不一樣。
*.html
上面提到,每個(gè)頁面的 page.wxss 樣式文件,“編譯”后將分別在各自的所在路徑下生成一個(gè) page.html 文件,每個(gè) page.html 的結(jié)構(gòu)如下:
- <style></style>
- <page></page>
- <script>
- var __setCssStartTime__ = Date.now();
- setCssToHead([...])() // 設(shè)置 search.wxss 的內(nèi)容
- var __setCssEndTime__ = Date.now();
- document.dispatchEvent(new CustomEvent("generateFuncReady", {
- detail: {
- generateFunc: $gwx('./pages/search/search.wxml')
- }
- }))
- </script>
在該文件中通過調(diào)用 setCssToHead 方法將 .wxss 樣式內(nèi)容設(shè)置到 Head 中,所以同樣地,我們可以根據(jù) setCssToHead 的調(diào)用參數(shù)提取每個(gè)頁面的 page.wxss。
資源文件
小程序工程中的圖片、音頻等資源文件在“編譯”后將直接被拷貝到 .wxapkg 包中,其原始的路徑也保留不變,因此我們可以直接使用。
“反編譯”
在上一節(jié),我們完成了 .wxapkg 包幾乎所有文件內(nèi)容的簡要分析?,F(xiàn)在我們介紹一下如何通過 node.js 腳本幫我們還原出小程序的源碼。
在這里需要再次感謝 wxappUnpacker 作者提供的還原工具,讓我們可以“站在巨人的肩膀上”輕松地去完成“反編譯”。它的使用如下:
- node wuConfig.js : 將 app-config.json 中的內(nèi)容拆分成各個(gè)頁面所對應(yīng)的 page.json 和 app.json;
- node wuJs.js : 將 app-service.js 拆分成一系列原先獨(dú)立的 JS 文件,并使用 Uglify-ES 美化工具盡可能將代碼還原為“編譯”前的內(nèi)容;
- node wuWxml.js [-m] : 從 page-frame.html 中提取并還原各頁面的 .wxml 和 app.wxss 及公共 .wxss 樣式文件;
- node wuWxss.js : 該命令參數(shù)為 .wxapkg 解包后目錄,它將分析并從各個(gè) page.html 中提取還原各頁面的 page.wxss 樣式文件;
同時(shí),作者還提供了一鍵解包并還原的腳本,你只需要提供一個(gè)小程序的 .wxapkg 文件,然后執(zhí)行如下命令:
- node wuWxapkg.js [-d]
此腳本就會(huì)自動(dòng)將 .wxapkg 文件解包,并將包中相關(guān)的已被“編譯/混淆”的文件自動(dòng)地恢復(fù)原狀(包括目錄結(jié)構(gòu))。
PS: 此工具依賴 uglify-es, vm2, esprima, cssbeautify, css-tree 等 node.js 包,所以你可能需要 npm install xxx 安裝這些依賴包才能正確執(zhí)行。
更詳細(xì)的用法及相關(guān)問題請查閱該開源項(xiàng)目的 GitHub repo。
***,我們在 微信開發(fā)者工具 中新建一個(gè)空小程序工程,并將上述還原后的相關(guān)目錄文件導(dǎo)入工程,即可編譯運(yùn)行起來,如下圖為“知識小集”小程序 .wxapkg 還原后的代碼工程:

以上,大功告成!
總結(jié)
本文詳細(xì)分析了 .wxapkg 解包后的各文件結(jié)構(gòu),并介紹了如何通過腳本“一鍵還原”得到任意小程序的源碼。
對于一些簡單的,且使用微信官方介紹的原生開發(fā)方式開發(fā)的小程序,用上述工具基本可以直接還原得到可運(yùn)行的源碼,但是對于一些邏輯復(fù)雜,或者使用 WePY、Vue 等一些框架開發(fā)的小程序,還原后的源碼可能會(huì)有一些小問題,需要我們?nèi)巳馊シ治鼋鉀Q。
后續(xù)
本文對小程序源碼“編譯”后的各文件內(nèi)容結(jié)構(gòu)及用途的分析相對比較零散,而且沒有對各文件的依賴關(guān)系及加載邏輯進(jìn)行研究,后續(xù)我們再寫一些文章講解微信客戶端是如何解析加載小程序 .wxapkg 包并運(yùn)行起來。