微信小程式“反編譯”實戰(二):原始碼還原

知識小集發表於2019-03-04

知識小集是一個團隊公眾號,主要定位在移動開發領域,分享移動開發技術,包括 iOS、Android、小程式、移動前端、React Native、weex 等。每週都會有 原創 文章分享,我們的文章都會在公眾號首發。歡迎關注檢視更多內容。

微信小程式“反編譯”實戰(二):原始碼還原

原文連結

在上一篇文章《微信小程式“反編譯”實戰(一):解包》中,我們詳細介紹瞭如何獲取某一個小程式的 .wxapkg 包,以及分析了 .wxapkg 包的結構,最後通過指令碼解壓獲取包中的檔案:小程式“編譯”後的程式碼檔案和資原始檔,但是由於這些檔案大部分被混淆了,可讀性很差,所以本文將進一步分析,儘可能地把 .wxapkg 包的內容還原為“編譯”前的內容。

注:本文包含一部分原始碼分析,由於手機螢幕較小,閱讀體驗可能不佳,建議在電腦上瀏覽。

特別感謝:下文使用的還原工具來自於 GitHub 上的開源專案 wxappUnpacker,在此特別感謝原作者的無私貢獻。

概覽

我們知道,前端 Web 網頁程式設計採用的是 HTML + CSS + JS 這樣的組合,其中 HTML 是用來描頁面的結構,CSS 用來描述頁面的樣子,JS 通常用來處理頁面邏輯和使用者的互動。類似地,在小程式中也有同樣的角色,一個小程式工程主要包括如下幾類檔案:

  • .json 字尾的 JSON 配置檔案
  • .wxml 字尾的 WXML 模板檔案
  • .wxss 字尾的 WXSS 樣式檔案
  • .js 字尾的 JavaScript 指令碼邏輯檔案

例如“知識小集”的小程式原始碼工程結構如下:

微信小程式“反編譯”實戰(二):原始碼還原

然而,根據上一篇文章介紹,對“知識小集”小程式的 .wxapkg 解包後得到如下檔案:

微信小程式“反編譯”實戰(二):原始碼還原

主要包括 app-config.json, app-service.js, page-frame.html, *.html, 資原始檔 等,但這些檔案已經被“編譯混淆”並重新整合壓縮,微信開發者工具並不能識別它們,我們無法直接對它們進行除錯/編譯執行。

所以,我們先嚐試分析一下從 .wxapkg 提取出來的各個檔案內容的結構及其用途,然後介紹如何用指令碼工具把它們一鍵還原為“編譯”前的原始碼,並在微信開發者工具中跑起來。

檔案分析

本節主要以“知識小集”小程式的 .wxapkg 解包後的原始碼檔案為例,進行分析。

你也可以跳過本節的分析,直接看下一節介紹用指令碼“反編譯”還原原始碼。

app-config.json

小程式工程主要包括工具配置 project.config.json,全域性配置 app.json 以及頁面配置 page.json 三類 JSON 配置檔案。其中:

project.config.json 主要用於對開發者工具進行個性化配置以及包括小程式專案工程的一些基礎配置,所以它不會被“編譯”到 .wxapkg 包中;

app.json 是對當前小程式的全域性配置,包括了小程式的所有頁面路徑、介面表現、網路超時時間、底部 tab 等;

page.json 用於對每一個頁面的視窗表現進行配置,頁面中配置項會覆蓋 app.jsonwindow 中相同的配置項。

因此“編譯”後的檔案 app-config.json 其實就是 app.json 和各個頁面的配置檔案的彙總,它的內容大致如下:

{
  "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 內容的對比,我們可以得出 app-config.json 彙總檔案的簡單整合規律,很容易把它拆分成“編譯”前對應的各 json 檔案。

app-service.js

在小程式專案中 JS 檔案負責互動邏輯,主要包括 app.js,每個頁面的 page.js,開發者自定義的 JS 檔案和引入的第三方 JS 檔案,在“編譯”後所有這些 JS 檔案都會被彙總到 app-service.js 檔案中,它的結構如下:

// 一些全域性變數的宣告
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(){};

// 小程式編譯基礎庫版本
/*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";
  // ... 具體原始碼內容
});

// ...

// app.js 原始碼定義
define("app.js", function(...) {
  "use strict";
  // ... app.js 原始碼內容
});
require("app.js");

// 每個頁面對應的 JS 原始碼定義
__wxRoute = `pages/index/index`; // 頁面路由地址
__wxRouteBegin = true;
define("pages/index/index.js", function(...){
  "use strict";
  // ... page.js 原始碼內容
});
require("pages/index/index.js");

複製程式碼

在這個檔案中,原有小程式工程中的每個 JS 檔案都被 define 方法定義宣告,定義中包含 JS 檔案的路徑和內容,如下:

define("path/to/xxx.js", function(...){
  "use strict";
  // ... xxx.js 原始碼內容
});
複製程式碼

因此,我們同樣很容易提取這些 JS 檔案原始碼,並恢復至相應的路徑位置中。當然,這些 JS 檔案中的內容經過混淆壓縮,我們可以使用 UglifyJS 這樣的工具進行美化,但仍很難還原一些原始變數名,不過基本不影響正常閱讀和使用。

page-frame.html

在小程式中使用 WXML 檔案描述頁面的結構,WXSS 檔案描述頁面的樣式。工程中有一個 app.wxss 檔案用於定義一些全域性的樣式,會自動被 import 到各個頁面中;另外每個頁面也都分別包含 page.wxmlpage.wxss 用於描述其頁面的結構和樣式;同時,我們也會自定義一些公共的 xxxCommon.wxss 樣式檔案和公共的 xxxTemplate.wxml 模板檔案供一些頁面複用,一般在各自頁面的 page.wxsspage.wxml 中去 import

當“編譯”小程式後,所有的 .wxml 檔案和 app.wxss 及公共 xxxCommon.wxss 樣式檔案的將被整合到 page-frame.html 檔案中,而每個頁面的 page.wxss 樣式檔案,將分別單獨在各自的路徑下生成一個 page.html 檔案。

page-frame.html 檔案的內容結構如下:

<!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
      };
      
      // 小程式編譯基礎庫版本
      /*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([...]); // 設定 app.wxss 的內容到 Head 中,其中 ... 為小程式工程中 app.wxss 的內容
      var __pageFrameEndTime__ = Date.now()
    </script>
  </head>
  <body>
    <div></div>
  </body>
</html>
複製程式碼

相比其他檔案,page-frame.html 比較複雜,微信把 .wxml 和部分 .wxss 直接“編譯”並混淆成 JS 程式碼放入上述檔案中,然後通過呼叫這些 JS 程式碼來構造 Virtual-Dom,進而渲染頁面。

其中最核心的是 $gwxsetCssToHead 這兩個方法。

$gwx 用於通過 JS 程式碼生成所有 .wxml 檔案,其中每個 .wxml 檔案的內容結構都在 $gwx 方法中被定義好並混淆了,我們只要傳給它頁面的 .wxml 路徑引數,即可獲取到每個 .wxml 的內容,再簡單加工一下即可還原成“編譯”前的內容。

$gwx 中有一個 x 陣列用於儲存當前小程式都有哪些 .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`];
複製程式碼

此時我們可以在 Chrome 中開啟 page-frame.html 檔案,然後在 Console 中輸入如下命令,即可得到 index.wxml 的內容(輸出一個 JS 物件,通過遍歷這個物件即可還原出 .wxml 的內容)

$gwx("./pages/index/index.wxml")
複製程式碼

setCssToHead 方法用於根據幾段被拆分的樣式字串陣列生成 .wxss 程式碼並設定到 HTMLHead 中,同時,它還將所有被 import 引用的 .wxss 檔案(公共 xxxCommon.wxss樣式檔案)所對應的樣式陣列內嵌在該方法中的 _C 變數中,並標記哪些檔案引用了 _C 中資料。另外在 page-frame.html 檔案的末尾,呼叫了該方法生成全域性 app.wxss 的內容設定到 Head 中。

因此,我們可以在每個呼叫 setCssToHead 方法的地方提取相應 .wxss 的內容並還原。

對於 page-frame.html 檔案中 $gwxsetCssToHead 這兩個方法更詳細的分析,可以參考這篇文章

此外,checkDeviceWidth 方法顧明思議,用於檢測螢幕的寬度,其檢測結果將用於 transformRPX 方法中將 rpx 單位轉換為 px 畫素。

rpx 的全稱是 responsive pixel,它是小程式自己定義的一個尺寸單位,可以根據當前裝置螢幕寬度進行自適應。小程式中規定,所有的裝置螢幕寬度都為 750rpx,根據裝置螢幕實際寬度的不同,1rpx所代表的實際畫素值也不一樣。

*.html

上面提到,每個頁面的 page.wxss 樣式檔案,“編譯”後將分別在各自的所在路徑下生成一個 page.html 檔案,每個 page.html 的結構如下:

<style></style>
<page></page>
<script>
  var __setCssStartTime__ = Date.now();
  setCssToHead([...])() // 設定 search.wxss 的內容
  var __setCssEndTime__ = Date.now();
  document.dispatchEvent(new CustomEvent("generateFuncReady", {
    detail: {
      generateFunc: $gwx(`./pages/search/search.wxml`)
    }
  }))
</script>
複製程式碼

在該檔案中通過呼叫 setCssToHead 方法將 .wxss 樣式內容設定到 Head 中,所以同樣地,我們可以根據 setCssToHead 的呼叫引數提取每個頁面的 page.wxss

資原始檔

小程式工程中的圖片、音訊等資原始檔在“編譯”後將直接被拷貝到 .wxapkg 包中,其原始的路徑也保留不變,因此我們可以直接使用。

“反編譯”

在上一節,我們完成了 .wxapkg 包幾乎所有檔案內容的簡要分析。現在我們介紹一下如何通過 node.js 指令碼幫我們還原出小程式的原始碼。

在這裡需要再次感謝 wxappUnpacker 作者提供的還原工具,讓我們可以“站在巨人的肩膀上”輕鬆地去完成“反編譯”。它的使用如下:

  • node wuConfig.js <path/to/app-config.json> : 將 app-config.json 中的內容拆分成各個頁面所對應的 page.jsonapp.json

  • node wuJs.js <path/to/app-service.js> : 將 app-service.js 拆分成一系列原先獨立的 JS 檔案,並使用 Uglify-ES 美化工具儘可能將程式碼還原為“編譯”前的內容;

  • node wuWxml.js [-m] <path/to/page-frame.html> : 從 page-frame.html 中提取並還原各頁面的 .wxmlapp.wxss 及公共 .wxss 樣式檔案;

  • node wuWxss.js <path/to/unpack_dir> : 該命令引數為 .wxapkg 解包後目錄,它將分析並從各個 page.html 中提取還原各頁面的 page.wxss 樣式檔案;

同時,作者還提供了一鍵解包並還原的指令碼,你只需要提供一個小程式的 .wxapkg 檔案,然後執行如下命令:

node wuWxapkg.js [-d] <path/to/.wxapkg>
複製程式碼

此指令碼就會自動將 .wxapkg 檔案解包,並將包中相關的已被“編譯/混淆”的檔案自動地恢復原狀(包括目錄結構)。

PS: 此工具依賴 uglify-es, vm2, esprima, cssbeautify, css-treenode.js 包,所以你可能需要 npm install xxx 安裝這些依賴包才能正確執行。

更詳細的用法及相關問題請查閱該開源專案的 GitHub repo。

最後,我們在 微信開發者工具 中新建一個空小程式工程,並將上述還原後的相關目錄檔案匯入工程,即可編譯執行起來,如下圖為“知識小集”小程式的 .wxapkg 包還原後的程式碼工程:

微信小程式“反編譯”實戰(二):原始碼還原

以上,大功告成!

總結

本文詳細分析了 .wxapkg 解包後的各檔案結構,並介紹瞭如何通過指令碼“一鍵還原”得到任意小程式的原始碼。

對於一些簡單的,且使用微信官方介紹的原生開發方式開發的小程式,用上述工具基本可以直接還原得到可執行的原始碼,但是對於一些邏輯複雜,或者使用 WePYVue 等一些框架開發的小程式,還原後的原始碼可能會有一些小問題,需要我們人肉去分析解決。

後續

本文對小程式原始碼“編譯”後的各檔案內容結構及用途的分析相對比較零散,而且沒有對各檔案的依賴關係及載入邏輯進行研究,後續我們再寫一些文章講解微信客戶端是如何解析載入小程式 .wxapkg 包並執行起來。

參考連結

相關文章