小程序不讓用 JS 解釋器?那我再杠一次鵝廠
前言
6月23號的時候,微信團隊發(fā)了如下通知將禁止小程序使用 JavaScript 解釋來動態(tài)更新代碼。消息一出,小程序開發(fā)者們哀嚎哀嚎遍野,更有人聲稱要開始加班改代碼了。
自 2018年1月,我寫下 「brambles:微信小程序也要強行熱更代碼」 (https://zhuanlan.zhihu.com/p/34191831) 這篇文章開始,就帶起來在小程序里面用 JavaScript 解釋器的潮流。然而四年過去了,微信小程序終于明文規(guī)定不在讓用 JavaScript 解釋器了,那小程序熱更的時代是不是就過去了?
當然不是,如果就這樣過去了也就沒我這篇文章了,其實早在四年前寫前一篇文章的時候我就已經(jīng)想好解決方案,只是我是沒想到的一年以后微信小程序才開始封 JavaScript 解釋器讓我之前設(shè)想的方案一直拖到四年后的今天。n那么今天我們這篇文章主要就討論兩個點:
如何突破微信小程序限制 JavaScript 解釋器使用進行熱更代碼。
為什么從理論上無法從根本上禁止小程序代碼的熱更。
基本步驟 & 最終效果
示例代碼 Github 倉庫:https://github.com/bramblex/jsjs-vm-demo
我們首先要寫一個 JavaScript 的編譯器,將 JavaScript 代碼編譯成二進制的字節(jié)碼。
找一張圖片,將字節(jié)碼編碼并隱藏進圖片中。
在小程序中引入藏有 JavaScript 字節(jié)碼的圖片,并且解碼出字節(jié)碼。
寫一個對應(yīng)的字節(jié)碼虛擬機,并且執(zhí)行從圖片中解出的字節(jié)碼。
實現(xiàn)一個字節(jié)碼虛擬機
為什么我們要將 JavaScript 編譯成字節(jié)碼呢?我們的目的是為了繞過微信小程序的代碼審核限制,所以我們要想盡辦法隱藏兩樣?xùn)|西。第一個是要想辦法隱藏解釋器,因為一個完整的 JavaScript 解釋器代碼量非常龐大,并且往往都需要引入別人寫的庫沒辦法自己維護,這樣的解釋往都不需要用什么高深的技術(shù)手段,字符串一匹配就能查出來個七七八八。
比如在小程序一個完整可用的 JavaScript 解釋器引入代碼,起碼需要引入一個至少 100k 以上的代碼,這個目標實在是太大了,幾乎很難隱藏。但是在小程序里面引入一個可以執(zhí)行字節(jié)碼的虛擬機實現(xiàn),可以做到只引入 10k 左右,壓縮前總代碼量不超過千行的代碼,這樣就更容易隱藏動態(tài)代碼的實現(xiàn)。比如我目前實現(xiàn)的字節(jié)碼虛擬機,除了 try-catch 和 with 以外,能實現(xiàn) ES5 所有能力的虛擬機總共才 7k 大小,就這都是還可以再壓縮的。
第二點是我們需要隱藏熱更的 JavaScript 代碼不被微信發(fā)現(xiàn),比如你把熱更的大量 JavaScript 代碼通過接口明文傳輸,只要微信稍微攔截一下你的網(wǎng)絡(luò),這不就全露餡了嗎?所以將代碼編譯成了二進制的字節(jié)碼以后,微信就沒有辦法通過簡單的攔截你的接口請求來確定里面有沒有 JavaScript 代碼來判斷你是是否熱更代碼了。二進制的文件在你能夠明確清楚它的個格式之前是沒辦法準確接出來他到底是個什么東西的1,更何況二進制的加密混淆的算法滿大街都是,而且還都沒有幾行……下面是我實現(xiàn)字節(jié)碼的指令集,總共只有 50 多個指令:
export enum OpCode {
NOP = 0x00,
UNDEF = 0x01, NULL = 0x02, OBJ = 0x03, ARR = 0x04, TRUE = 0x05,
FALSE = 0x06, NUM = 0x07, ADDR = 0x08, STR = 0x09, POP = 0x0A,
TOP = 0x0D, TOP2 = 0x0E, VAR = 0x10, LOAD = 0x11, OUT = 0x12,
JUMP = 0x20, JUMPIF = 0x21, JUMPNOT = 0x22, FUNC = 0x30, CALL = 0x31,
NEW = 0x32, RET = 0x33, GET = 0x40, SET = 0x41, IN = 0x43,
DELETE = 0x44, EQ = 0x50, NEQ = 0x51, SEQ = 0x52, SNEQ = 0x53,
LT = 0x54, LTE = 0x55, GT = 0x56, GTE = 0x57, ADD = 0x60,
SUB = 0x61, MUL = 0x62, EXP = 0x63, DIV = 0x64, MOD = 0x65,
BNOT = 0x70, BOR = 0x71, BXOR = 0x72, BAND = 0x73, LSHIFT = 0x73,
RSHIFT = 0x75, URSHIFT = 0x76, OR = 0x80, AND = 0x81, NOT = 0x82,
INSOF = 0x90, TYPEOF = 0x91,
}
以下是將一段示例代碼以及其編譯后的字節(jié)碼:
JavaScript 代碼與編譯后的字節(jié)碼,這個字節(jié)碼中還能看到 wx showModal 等字樣
上面字節(jié)碼是以下指令(節(jié)選)的二進制表示:
.main_1:
STR(09)
"wx" (00 77 00 78 00 00)
LOAD(11)
TOP(0d)
STR(09)
"showModal" (00 73 00 68 00 6f 00 77 00 4d 00 6f 00 64 00 61 00 6c 00 00)
GET(40)
ARR(04)
TOP(0d)
NUM(07)
0 (00 00 00 00 00 00 00 00)
OBJ(03)
TOP(0d)
STR(09)
"title" (00 74 00 69 00 74 00 6c 00 65 00 00)
STR(09)
"這是一段隱藏在圖片中的代碼" (8f d9 66 2f 4e 00 6b b5 96 90 85 cf 57 28 56 fe 72 47 4e 2d 76 84 4e e3 78 01 00 00)
SET(41)
POP(0a)
TOP(0d)
STR(09)
"content" (00 63 00 6f 00 6e 00 74 00 65 00 6e 00 74 00 00)
STR(09)
"這是一段隱藏在圖片中的代碼" (8f d9 66 2f 4e 00 6b b5 96 90 85 cf 57 28 56 fe 72 47 4e 2d 76 84 4e e3 78 01 00 00)
SET(41)
POP(0a)
TOP(0d)
STR(09)
"success" (00 73 00 75 00 63 00 63 00 65 00 73 00 73 00 00)
NULL(02)
NUM(07)
1 (3f f0 00 00 00 00 00 00)
ADDR(08)
.anonymous_2
FUNC(30)
SET(41)
POP(0a)
SET(41)
POP(0a)
CALL(31)
POP(0a)
RET(33)
畢竟是做個 Demo,如果真的需要實用的話,還有大量的優(yōu)化空間。比如字節(jié)碼字面量現(xiàn)在都是非常簡單粗暴直接內(nèi)聯(lián),如果將數(shù)據(jù)和代碼部分區(qū)分可以得到一個更好的性能。比如字符串的編碼使用的是 utf16 編碼,如果轉(zhuǎn)換成 utf8 編碼可以節(jié)省空間占用等等,這些以后有心情再做。
將字節(jié)碼藏在圖片里
我們說需要隱藏虛擬機和熱更的代碼,但是我們思考一下,一個普通的小程序整天需要加在二進制文件,這一個行為是不是非常的怪異?沒錯,這件事情非常非常的奇怪,因為一個正常小程序根本沒有什么讀寫二進制文件的需求。但是如果我告訴一個小程序,需要做一張有小程序二維碼的分享圖給用戶保存,而且這張分享圖還經(jīng)常需要更新,這不是就非常符合邏輯了?所以我們要將熱更的字節(jié)碼藏在圖片里面,偽裝成一個正常小程序的行為,并且要保證這場圖片看起來也是正常的。以下就是我們開頭示例中圖片,左圖是原圖片,而右圖是藏了我們上面示例代碼的圖,只有非常仔細看才能看到細微的差別。
仔細看隱藏了字節(jié)碼的區(qū)域,跟原圖片有細微的差別
圖片一個像素點有 RGBA 一共四個 byte,為了最少影響圖片看上去的效果,我們選擇只將字節(jié)碼編碼隱藏在圖片的 Alpha 通道,這里用了最簡單的編碼方式,將 RGBA 中的 A 當成一個 bit 來進行編碼。A 高于 0xF8 則為 1,否則則為 0。編碼和解碼算法如下:
在編譯器中的編碼算法(左)在小程序中執(zhí)行的解碼算法(右)
在小程序中只需要把圖片畫在 Canvas 上面,并且逐個讀取 Alpha 通道上的數(shù)據(jù)就能隱藏在圖片中的字節(jié)碼接解碼出來。最后通過我們上一小節(jié)實現(xiàn)的字節(jié)碼虛擬機,就能執(zhí)行我們想要熱更的代碼了。
為什么無法從根本上禁止小程序代碼的熱更
先說結(jié)論,只要滿足以下兩個條件,那么從根本上禁止熱更都是無稽之談:
- 宿主語言圖靈完備
- 允許通過網(wǎng)絡(luò)讀取數(shù)據(jù)
第一,宿主語言如果圖靈完備的話,那么宿主語言就可以實現(xiàn)任何其他圖靈完備的編程語言。比如 JavaScript 圖靈完備,那么你就能用 JavaScript 實現(xiàn) JavaScript 解釋器、Python 解釋器、PHP 解釋器等等只要你能想得到的編程語言解釋器,甚至你還可以設(shè)計一個自己的比如本文的字節(jié)碼虛擬機。所以當公告一出來的時候,樓底下第一個回復(fù)的朋友就一語道破封 JavaScript 解釋器是一件多么可笑的事情。
公告發(fā)出來的第一天,就有朋友在評論區(qū)中抖機靈
第二,你可以把一切能夠從得到不同輸入,并且產(chǎn)生不同結(jié)果的程序都稱之為解釋器,無非就是它表達能力的強與弱、是通用的還是專用的區(qū)別而已,所以這個界限是非常模糊的。比如我們業(yè)務(wù)中,可能需要程序去服務(wù)器上拉一份配置,這份配置可能是某些功能的開關(guān)顯示與否等等,那么這時候我拉的一份配置文件和拉了一份 JavaScript 代碼動態(tài)執(zhí)行有本質(zhì)上的區(qū)別嗎?其實你也可以理解代碼不過是一份解釋器/編譯器的配置文件而已,沒有那么特殊,唯一的區(qū)別僅僅是代碼設(shè)計通用且復(fù)雜。所以才有那么一句話,代碼既數(shù)據(jù),數(shù)據(jù)既代碼。
寫在最后
在文章的最后,要向兩位科學(xué)家致敬。第一位是艾倫·圖靈,提出了圖靈機奠定了計算理論的基礎(chǔ)。第二位是香農(nóng),奠定了現(xiàn)代信息論的基礎(chǔ)。感謝巨人們給我們提供的肩膀。
艾倫·圖靈(左) 克勞德·香農(nóng)(右)