小程序不讓用 JS 解釋器?那我再杠一次鵝廠
前言
6月23號(hào)的時(shí)候,微信團(tuán)隊(duì)發(fā)了如下通知將禁止小程序使用 JavaScript 解釋來(lái)動(dòng)態(tài)更新代碼。消息一出,小程序開(kāi)發(fā)者們哀嚎哀嚎遍野,更有人聲稱(chēng)要開(kāi)始加班改代碼了。
自 2018年1月,我寫(xiě)下 「brambles:微信小程序也要強(qiáng)行熱更代碼」 (https://zhuanlan.zhihu.com/p/34191831) 這篇文章開(kāi)始,就帶起來(lái)在小程序里面用 JavaScript 解釋器的潮流。然而四年過(guò)去了,微信小程序終于明文規(guī)定不在讓用 JavaScript 解釋器了,那小程序熱更的時(shí)代是不是就過(guò)去了?
當(dāng)然不是,如果就這樣過(guò)去了也就沒(méi)我這篇文章了,其實(shí)早在四年前寫(xiě)前一篇文章的時(shí)候我就已經(jīng)想好解決方案,只是我是沒(méi)想到的一年以后微信小程序才開(kāi)始封 JavaScript 解釋器讓我之前設(shè)想的方案一直拖到四年后的今天。n那么今天我們這篇文章主要就討論兩個(gè)點(diǎn):
如何突破微信小程序限制 JavaScript 解釋器使用進(jìn)行熱更代碼。
為什么從理論上無(wú)法從根本上禁止小程序代碼的熱更。
基本步驟 & 最終效果
示例代碼 Github 倉(cāng)庫(kù):https://github.com/bramblex/jsjs-vm-demo
我們首先要寫(xiě)一個(gè) JavaScript 的編譯器,將 JavaScript 代碼編譯成二進(jìn)制的字節(jié)碼。
找一張圖片,將字節(jié)碼編碼并隱藏進(jìn)圖片中。
在小程序中引入藏有 JavaScript 字節(jié)碼的圖片,并且解碼出字節(jié)碼。
寫(xiě)一個(gè)對(duì)應(yīng)的字節(jié)碼虛擬機(jī),并且執(zhí)行從圖片中解出的字節(jié)碼。
實(shí)現(xiàn)一個(gè)字節(jié)碼虛擬機(jī)
為什么我們要將 JavaScript 編譯成字節(jié)碼呢?我們的目的是為了繞過(guò)微信小程序的代碼審核限制,所以我們要想盡辦法隱藏兩樣?xùn)|西。第一個(gè)是要想辦法隱藏解釋器,因?yàn)橐粋€(gè)完整的 JavaScript 解釋器代碼量非常龐大,并且往往都需要引入別人寫(xiě)的庫(kù)沒(méi)辦法自己維護(hù),這樣的解釋往都不需要用什么高深的技術(shù)手段,字符串一匹配就能查出來(lái)個(gè)七七八八。
比如在小程序一個(gè)完整可用的 JavaScript 解釋器引入代碼,起碼需要引入一個(gè)至少 100k 以上的代碼,這個(gè)目標(biāo)實(shí)在是太大了,幾乎很難隱藏。但是在小程序里面引入一個(gè)可以執(zhí)行字節(jié)碼的虛擬機(jī)實(shí)現(xiàn),可以做到只引入 10k 左右,壓縮前總代碼量不超過(guò)千行的代碼,這樣就更容易隱藏動(dòng)態(tài)代碼的實(shí)現(xiàn)。比如我目前實(shí)現(xiàn)的字節(jié)碼虛擬機(jī),除了 try-catch 和 with 以外,能實(shí)現(xiàn) ES5 所有能力的虛擬機(jī)總共才 7k 大小,就這都是還可以再壓縮的。
第二點(diǎn)是我們需要隱藏?zé)岣?JavaScript 代碼不被微信發(fā)現(xiàn),比如你把熱更的大量 JavaScript 代碼通過(guò)接口明文傳輸,只要微信稍微攔截一下你的網(wǎng)絡(luò),這不就全露餡了嗎?所以將代碼編譯成了二進(jìn)制的字節(jié)碼以后,微信就沒(méi)有辦法通過(guò)簡(jiǎn)單的攔截你的接口請(qǐng)求來(lái)確定里面有沒(méi)有 JavaScript 代碼來(lái)判斷你是是否熱更代碼了。二進(jìn)制的文件在你能夠明確清楚它的個(gè)格式之前是沒(méi)辦法準(zhǔn)確接出來(lái)他到底是個(gè)什么東西的1,更何況二進(jìn)制的加密混淆的算法滿大街都是,而且還都沒(méi)有幾行……下面是我實(shí)現(xiàn)字節(jié)碼的指令集,總共只有 50 多個(gè)指令:
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é)碼,這個(gè)字節(jié)碼中還能看到 wx showModal 等字樣
上面字節(jié)碼是以下指令(節(jié)選)的二進(jìn)制表示:
.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)
畢竟是做個(gè) Demo,如果真的需要實(shí)用的話,還有大量的優(yōu)化空間。比如字節(jié)碼字面量現(xiàn)在都是非常簡(jiǎn)單粗暴直接內(nèi)聯(lián),如果將數(shù)據(jù)和代碼部分區(qū)分可以得到一個(gè)更好的性能。比如字符串的編碼使用的是 utf16 編碼,如果轉(zhuǎn)換成 utf8 編碼可以節(jié)省空間占用等等,這些以后有心情再做。
將字節(jié)碼藏在圖片里
我們說(shuō)需要隱藏虛擬機(jī)和熱更的代碼,但是我們思考一下,一個(gè)普通的小程序整天需要加在二進(jìn)制文件,這一個(gè)行為是不是非常的怪異?沒(méi)錯(cuò),這件事情非常非常的奇怪,因?yàn)橐粋€(gè)正常小程序根本沒(méi)有什么讀寫(xiě)二進(jìn)制文件的需求。但是如果我告訴一個(gè)小程序,需要做一張有小程序二維碼的分享圖給用戶(hù)保存,而且這張分享圖還經(jīng)常需要更新,這不是就非常符合邏輯了?所以我們要將熱更的字節(jié)碼藏在圖片里面,偽裝成一個(gè)正常小程序的行為,并且要保證這場(chǎng)圖片看起來(lái)也是正常的。以下就是我們開(kāi)頭示例中圖片,左圖是原圖片,而右圖是藏了我們上面示例代碼的圖,只有非常仔細(xì)看才能看到細(xì)微的差別。
仔細(xì)看隱藏了字節(jié)碼的區(qū)域,跟原圖片有細(xì)微的差別
圖片一個(gè)像素點(diǎn)有 RGBA 一共四個(gè) byte,為了最少影響圖片看上去的效果,我們選擇只將字節(jié)碼編碼隱藏在圖片的 Alpha 通道,這里用了最簡(jiǎn)單的編碼方式,將 RGBA 中的 A 當(dāng)成一個(gè) bit 來(lái)進(jìn)行編碼。A 高于 0xF8 則為 1,否則則為 0。編碼和解碼算法如下:
在編譯器中的編碼算法(左)在小程序中執(zhí)行的解碼算法(右)
在小程序中只需要把圖片畫(huà)在 Canvas 上面,并且逐個(gè)讀取 Alpha 通道上的數(shù)據(jù)就能隱藏在圖片中的字節(jié)碼接解碼出來(lái)。最后通過(guò)我們上一小節(jié)實(shí)現(xiàn)的字節(jié)碼虛擬機(jī),就能執(zhí)行我們想要熱更的代碼了。
為什么無(wú)法從根本上禁止小程序代碼的熱更
先說(shuō)結(jié)論,只要滿足以下兩個(gè)條件,那么從根本上禁止熱更都是無(wú)稽之談:
- 宿主語(yǔ)言圖靈完備
- 允許通過(guò)網(wǎng)絡(luò)讀取數(shù)據(jù)
第一,宿主語(yǔ)言如果圖靈完備的話,那么宿主語(yǔ)言就可以實(shí)現(xiàn)任何其他圖靈完備的編程語(yǔ)言。比如 JavaScript 圖靈完備,那么你就能用 JavaScript 實(shí)現(xiàn) JavaScript 解釋器、Python 解釋器、PHP 解釋器等等只要你能想得到的編程語(yǔ)言解釋器,甚至你還可以設(shè)計(jì)一個(gè)自己的比如本文的字節(jié)碼虛擬機(jī)。所以當(dāng)公告一出來(lái)的時(shí)候,樓底下第一個(gè)回復(fù)的朋友就一語(yǔ)道破封 JavaScript 解釋器是一件多么可笑的事情。
公告發(fā)出來(lái)的第一天,就有朋友在評(píng)論區(qū)中抖機(jī)靈
第二,你可以把一切能夠從得到不同輸入,并且產(chǎn)生不同結(jié)果的程序都稱(chēng)之為解釋器,無(wú)非就是它表達(dá)能力的強(qiáng)與弱、是通用的還是專(zhuān)用的區(qū)別而已,所以這個(gè)界限是非常模糊的。比如我們業(yè)務(wù)中,可能需要程序去服務(wù)器上拉一份配置,這份配置可能是某些功能的開(kāi)關(guān)顯示與否等等,那么這時(shí)候我拉的一份配置文件和拉了一份 JavaScript 代碼動(dòng)態(tài)執(zhí)行有本質(zhì)上的區(qū)別嗎?其實(shí)你也可以理解代碼不過(guò)是一份解釋器/編譯器的配置文件而已,沒(méi)有那么特殊,唯一的區(qū)別僅僅是代碼設(shè)計(jì)通用且復(fù)雜。所以才有那么一句話,代碼既數(shù)據(jù),數(shù)據(jù)既代碼。
寫(xiě)在最后
在文章的最后,要向兩位科學(xué)家致敬。第一位是艾倫·圖靈,提出了圖靈機(jī)奠定了計(jì)算理論的基礎(chǔ)。第二位是香農(nóng),奠定了現(xiàn)代信息論的基礎(chǔ)。感謝巨人們給我們提供的肩膀。
艾倫·圖靈(左) 克勞德·香農(nóng)(右)