前言
6月23号的时候,微信团队发了如下通知将禁止小程序使用 JavaScript 解释来动态更新代码。消息一出,小程序开发者们哀嚎哀嚎遍野,更有人声称要开始加班改代码了。
自 2018年1月,我写下 「brambles:微信小程序也要强行热更代码」 (https://zhuanlan.zhihu.com/p/34191831) 这篇文章开始,就带起来在小程序里面用 JavaScript 解释器的潮流。然而四年过去了,微信小程序终于明文规定不在让用 JavaScript 解释器了,那小程序热更的时代是不是就过去了?
当然不是,如果就这样过去了也就没我这篇文章了,其实早在四年前写前一篇文章的时候我就已经想好解决方案,只是我是没想到的一年以后微信小程序才开始封 JavaScript 解释器让我之前设想的方案一直拖到四年后的今天。n那么今天我们这篇文章主要就讨论两个点:
如何突破微信小程序限制 JavaScript 解释器使用进行热更代码。
为什么从理论上无法从根本上禁止小程序代码的热更。
基本步骤 & 最终效果
示例代码 Github 仓库:https://github.com/bramblex/jsjs-vm-demo
我们首先要写一个 JavaScript 的编译器,将 JavaScript 代码编译成二进制的字节码。
找一张图片,将字节码编码并隐藏进图片中。
在小程序中引入藏有 JavaScript 字节码的图片,并且解码出字节码。
写一个对应的字节码虚拟机,并且执行从图片中解出的字节码。
实现一个字节码虚拟机
为什么我们要将 JavaScript 编译成字节码呢?我们的目的是为了绕过微信小程序的代码审核限制,所以我们要想尽办法隐藏两样东西。第一个是要想办法隐藏解释器,因为一个完整的 JavaScript 解释器代码量非常庞大,并且往往都需要引入别人写的库没办法自己维护,这样的解释往都不需要用什么高深的技术手段,字符串一匹配就能查出来个七七八八。
比如在小程序一个完整可用的 JavaScript 解释器引入代码,起码需要引入一个至少 100k 以上的代码,这个目标实在是太大了,几乎很难隐藏。但是在小程序里面引入一个可以执行字节码的虚拟机实现,可以做到只引入 10k 左右,压缩前总代码量不超过千行的代码,这样就更容易隐藏动态代码的实现。比如我目前实现的字节码虚拟机,除了 try-catch 和 with 以外,能实现 ES5 所有能力的虚拟机总共才 7k 大小,就这都是还可以再压缩的。
第二点是我们需要隐藏热更的 JavaScript 代码不被微信发现,比如你把热更的大量 JavaScript 代码通过接口明文传输,只要微信稍微拦截一下你的网络,这不就全露馅了吗?所以将代码编译成了二进制的字节码以后,微信就没有办法通过简单的拦截你的接口请求来确定里面有没有 JavaScript 代码来判断你是是否热更代码了。二进制的文件在你能够明确清楚它的个格式之前是没办法准确接出来他到底是个什么东西的1,更何况二进制的加密混淆的算法满大街都是,而且还都没有几行……下面是我实现字节码的指令集,总共只有 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,
}
以下是将一段示例代码以及其编译后的字节码:
JavaScript 代码与编译后的字节码,这个字节码中还能看到 wx showModal 等字样
上面字节码是以下指令(节选)的二进制表示:
.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,如果真的需要实用的话,还有大量的优化空间。比如字节码字面量现在都是非常简单粗暴直接内联,如果将数据和代码部分区分可以得到一个更好的性能。比如字符串的编码使用的是 utf16 编码,如果转换成 utf8 编码可以节省空间占用等等,这些以后有心情再做。
将字节码藏在图片里
我们说需要隐藏虚拟机和热更的代码,但是我们思考一下,一个普通的小程序整天需要加在二进制文件,这一个行为是不是非常的怪异?没错,这件事情非常非常的奇怪,因为一个正常小程序根本没有什么读写二进制文件的需求。但是如果我告诉一个小程序,需要做一张有小程序二维码的分享图给用户保存,而且这张分享图还经常需要更新,这不是就非常符合逻辑了?所以我们要将热更的字节码藏在图片里面,伪装成一个正常小程序的行为,并且要保证这场图片看起来也是正常的。以下就是我们开头示例中图片,左图是原图片,而右图是藏了我们上面示例代码的图,只有非常仔细看才能看到细微的差别。
仔细看隐藏了字节码的区域,跟原图片有细微的差别
图片一个像素点有 RGBA 一共四个 byte,为了最少影响图片看上去的效果,我们选择只将字节码编码隐藏在图片的 Alpha 通道,这里用了最简单的编码方式,将 RGBA 中的 A 当成一个 bit 来进行编码。A 高于 0xF8 则为 1,否则则为 0。编码和解码算法如下:
在编译器中的编码算法(左)在小程序中执行的解码算法(右)
在小程序中只需要把图片画在 Canvas 上面,并且逐个读取 Alpha 通道上的数据就能隐藏在图片中的字节码接解码出来。最后通过我们上一小节实现的字节码虚拟机,就能执行我们想要热更的代码了。
为什么无法从根本上禁止小程序代码的热更
先说结论,只要满足以下两个条件,那么从根本上禁止热更都是无稽之谈:
- 宿主语言图灵完备
- 允许通过网络读取数据
第一,宿主语言如果图灵完备的话,那么宿主语言就可以实现任何其他图灵完备的编程语言。比如 JavaScript 图灵完备,那么你就能用 JavaScript 实现 JavaScript 解释器、Python 解释器、PHP 解释器等等只要你能想得到的编程语言解释器,甚至你还可以设计一个自己的比如本文的字节码虚拟机。所以当公告一出来的时候,楼底下第一个回复的朋友就一语道破封 JavaScript 解释器是一件多么可笑的事情。
公告发出来的第一天,就有朋友在评论区中抖机灵
第二,你可以把一切能够从得到不同输入,并且产生不同结果的程序都称之为解释器,无非就是它表达能力的强与弱、是通用的还是专用的区别而已,所以这个界限是非常模糊的。比如我们业务中,可能需要程序去服务器上拉一份配置,这份配置可能是某些功能的开关显示与否等等,那么这时候我拉的一份配置文件和拉了一份 JavaScript 代码动态执行有本质上的区别吗?其实你也可以理解代码不过是一份解释器/编译器的配置文件而已,没有那么特殊,唯一的区别仅仅是代码设计通用且复杂。所以才有那么一句话,代码既数据,数据既代码。
写在最后
在文章的最后,要向两位科学家致敬。第一位是艾伦·图灵,提出了图灵机奠定了计算理论的基础。第二位是香农,奠定了现代信息论的基础。感谢巨人们给我们提供的肩膀。
艾伦·图灵(左) 克劳德·香农(右)