本文对支付宝小程序的正向开发做了简单介绍,并从正向开发的文件类型入手,对小程序的宿主框架进行了逆向分析,包括运行机制、通信模型以及安全防护体系等内容。
支付宝小程序开发在语法方面与传统的前端网页开发非常类似,开发者主要编写 .axml、.acss、.js三部分文件,分别对标前端开发中的HTML、CSS、JS。
其中 .axml 的内容如下所示,AXML 是小程序框架设计的一套标签语言,用于描述小程序页面的结构。
<view> Hello {{name}}! view>
<button onTap="changeName"> Click me! button>
其中 .js 的内容如下所示,用于实现小程序的业务逻辑。
// 逻辑层
var initialData = {
name: 'taobao',
};
// 注册一个页面
Page({
data: initialData,
changeName(e) {
// 改变数据
this.setData({
name: 'alipay',
});
},
});
其中 .acss 的内容如下所示,ACSS 是一套样式语言,用于描述 AXML 的组件样式,决定 AXML 的组件的显示效果。
view {
padding-left: 10px;
}
小程序开发者完成代码开发后,会提交相应代码给平台审核,审核通过后,便会在支付宝上架,通过搜索小程序名称即可使用对应小程序。
小程序启动时,客户端会从CDN下载小程序离线包,这个离线包是将原项目打包后的一个 .tar 文件,存放在 /data/data/com.eg.android.AlipayGphone/files/nebulaInstallApps 目录下。从真机上拖出来解压后得到目录如下
其中 index.html
、index.js
、index.worker.js
就是之前我们编写的代码所编译出的js代码。其中index.worker.js
是小程序所有页面的业务逻辑代码,对应着开发者编写的pageName.js
中的内容;index.html
、index.js
中的内容对应着acss与axml,其中axml的组件信息、层次结构同样被会被编译成js代码,在运行时由这些js进行渲染。
开发者写的所有代码最终将会打包成一份 JavaScript 脚本,在小程序启动的时候运行,在小程序结束运行时销毁。
开发者开发的小程序源码打包之后主要分为两部分,第一部分负责小程序的视图展示,打包产物为index.js
,称之为Render部分;第二部分负责小程序的业务逻辑、视图更新等,打包产物为index.worker.js
,称之为Worker部分。
支付宝小程序的前端框架APPX也分为Render部分,对应需要加载的文件为af-appx.min.js
,以及Worker部分,对应加载的文件为af-appx.worker.min.js
。前端框架主要负责准备业务代码需要的一些对象和数据,在准备环境的时候开始加载,用于初始化环境、往Render和Worker所处的运行环境中注册对象之类的。
整个Render部分,包含index.js
(开发者编写的视图层代码)和af-appx.min.js
(小程序前端框架代码),均运行在WebView上(视图线程)。
整个Worker部分,包含index.worker.js
(开发者编写的逻辑层代码)和af-appx.worker.min.js
,均运行在V8引擎上(专有的JavaScript引擎)(应用服务线程)。
对于Render部分而言,需要加载af-appx.min.js
、index.html
和index.js
,从实现上,是通过WebView的loadUrl()方法,该方法可以加载网页,也可以加载字符串格式的js代码。
Hook住WVUCWebView 类中的loadUrl函数,可以看到Render部分的业务逻辑代码加载
观察index.html
文件,我们可以发现,af-appx.min.js
通过writeln()函数动态加载进来,同时index.js
通过标签引入
同时通过hook可以观察到af-appx.min.js
内容的加载,在通过writeln()函数动态加载时,会触发WVUCWebView对应WebViewClient的shouldInterceptRequest()函数
进入 com.alibaba.ariver.v8worker.V8Worker 类,从构造函数开始梳理加载逻辑如下
逆向代码截图示意如下
为方便阅读,仅将Worker部分的逻辑整理为如下内容
d("V8_Preparing");
d("V8_InitJSEngine");
d("V8_createJsiInstance");
d("V8_CreateIsolate"); ==> 创建V8 Isolate(V8中的概念,在下方做简单介绍)
d("V8_CreateJSContext"); ==> 创建Context
d("V8_SetupWebAPI");
d("V8_ReadJSBridge");
d("V8_ExecuteJSBridge");
d("V8_InjectInitialParams");
d("V8_LoadAppxWorkerJS"); ==> 加载前端框架worker部分的js:https://appx/af-appx.worker.min.js
d("V8_ExecuteAppxWorkerJS"); ==> 执行前端框架worker部分的js
d("V8_JSBridgeReady"); ==> V8 Worker和原生APP之间的JSBridge准备就绪
d("V8_PushWorker");
d("V8_MergeJsApiCacheParams");
d("V8_InjectFullParams");
d("V8_ImportScripts_BizJS"); ==> 加载小程序业务逻辑worker部分的js:index.worker.js
d("V8_WorkerReady"); ==> 至此Worker部分已经准备就绪
注解:
Isolate:Isolate和操作系统中进程的概念有些类似,进程是完全相互隔离的,一个进程里有多个线程,同时各个进程之间并不互相共享资源。Isolate也是一样,Isolate1和Isolate2拥有各自堆栈的虚拟机实例,且相互完全隔离。
Context:在V8中,一个Context就是一个执行环境,它使得可以在一个V8实例中运行相互隔离且无关的JavaScript代码,必须为将要执行的JavaScript代码显式地指定一个Context。同一个Isolate中可以创建多个Context执行环境,多个Context执行环境中的JavaScript代码互不影响。
通过Hook,可以看到V8引擎会先加载前端框架worker部分的js代码:https://appx/af-appx.worker.min.js
,然后加载小程序业务worker部分的js代码:index.worker.js
(具体JS内容均被混淆过),最后将worker的状态置为ready状态。
通过Hook抓取到的业务逻辑中的JS代码和从小程序源码文件中提取出来的代码一致
小程序实际由H5应用发展而来,且H5应用仍在支付宝上进行使用。支付宝架构组件如下图所示,移动应用如小程序和H5应用,是使用前端技术编写的应用,开发起来方便简单;H5容器为移动应用提供运行环境,开放 JSAPI 供移动应用使用,提供宿主APP的原生能力;支付宝底层支持提供支付宝的功能,如网络、存储等等。
细化小程序和H5容器组件中的内容,框架解析图如下图所示。
H5容器提供两个JS运行环境加载移动应用,并提供 JSAPI 供其调用。其中H5应用仅运行于WebView中,且运行于主进程中;小程序的两部分Worker和Render分别运行于V8引擎和WebView中,且小程序与宿主APP运行在不同进程中,每个小程序运行在单独进程中,每个小程序的Render和Worker也运行在不同的线程中。
小程序框架启动会启动 LiteProcessActivity,该activity运行在独立的进程中,并在其 onCreate() 函数中初始化Render和Worker(暂未发现该部分代码?),Render线程运行于主线程中,Worker线程运行于 "worker-jsapi"线程中,相互跨线程的调用主要依靠互相持有引用。
双栈结构,其中Render(WebView)属于前端,负责渲染页面;Worker属于后端,负责执行功能。
以下图为例,描述前后端的各自作用。Render部分负责渲染出含有一个Button的页面,该Button绑定了 getLocation 事件,并且使用WebView组件加载该页面,当该Button被触发时,Render向Worker传递消息,getLocation事件被触发了,需要执行相应的JS代码,在Worker加载的业务代码中,getLocation事件对应的是 my.getLocation()函数,于是执行该函数,该函数通过Worker与宿主APP之间的JS Bridge向下调用,使用宿主APP的原生能力获取地理位置信息,并且将地理位置从宿主APP传递到Worker,再由Worker将数据传递回Render,交由Render进行重新渲染,将数据显示在可视界面上。
小程序的核心是一个响应式的数据绑定系统,分为视图层(Render)和逻辑层(Worker)。这两层始终保持同步,只要在逻辑层修改数据,视图层就会相应的更新。
举例如下
<view> Hello {{name}}! view>
<button onTap="changeName"> Click me! button>
// 逻辑层
var initialData = {
name: 'taobao',
};
// 注册一个页面
Page({
data: initialData,
changeName(e) {
// 改变数据
this.setData({
name: 'alipay',
});
},
});
小程序框架会自动将逻辑层数据中的 name
与视图层的 name
进行了绑定,所以在页面一打开的时候会显示 Hello taobao!
。当用户点击视图层中定义的按钮时,视图层会发送 changeName
的事件给逻辑层,逻辑层找到对应的事件处理函数(具体涉及到Render和Worker的通信信道,将在后续章节中详细说明)。逻辑层执行了 setData
的操作,将name
从taobao
变成alipay
,因为该数据和视图层已经绑定了,从而视图层会自动改变为Hello alipay!
。
小程序主要靠视图线程(WebView)和应用服务线程(Worker)来控制管理,两个线程同时运行。
Worker线程启动后,会初始化小程序,小程序初始化完成时会触发 app.onLaunch
回调,当小程序启动时,会触发 app.onShow
回调,然后完成App的创建。
当页面Page初始化时,会触发 page.onLoad
回调,页面完成显示时会触发 page.onShow
回调,然后完成Page创建,此时Worker线程等待Webview线程初始化完成通知。
Webview线程初始化完成通知Worker线程,然后Worker将初始化数据(如上示例中的initialData)发送给Webview进行渲染,此时Webview线程完成第一次数据渲染。
第一次渲染完成后,Webview线程进入就绪状态并通知Worker线程,Worker线程调用 page.onReady
函数并进入活动状态。
Worker线程进入活动状态后,每次数据修改将会通知Webview线程进行渲染。当切换页面进入后台,应用线程调用 page.onHide
函数后,进入存活状态;页面返回到前台将调用 page.onShow
函数,再次进入活动状态;当调用返回或重定向页面后将调用 page.onUnload
函数,进行页面销毁。
小程序运行在宿主APP上,因此需要小程序到宿主APP的通信信道;与此同时,由于双栈结构的天然隔离,还需要Worker与Render之间的通信信道。总的通信模型如下图所示
与宿主APP通信
JS环境内将 JSAPI 的请求与参数拼接成字符串并调用 Console.log() ,容器通过拦截给 WebView 设置的对应的 WebViewClient 中的onConsoleMessage() 函数,解析字符串并完成对应API的调用实现功能。
容器执行完对应API计算得到结果后,通过调用 WebView 的 loadUrl() 函数向JS环境回传字符串格式的JS代码。
与Worker通信
Render和Worker的双向通信是通过 WebMessageChannel 实现的,hook相应接口可以看到 Render 向 Worker 传递的请求调用信息
MsgFromMsgChannel: {"func":"postMessage","param":{"data":{"i":1,"p":{"a":1,"p":[[[],[],[],[],[],[],null],[0,"getNetworkType",{"currentTarget":{"dataset":{},"id":"","offsetLeft":18,"offsetTop":84,"tagName":"view"},"detail":{"clientX":71.23809814453125,"clientY":107.04762268066406,"pageX":71.23809814453125,"pageY":107.04762268066406},"target":{"dataset":{},"id":"","offsetLeft":18,"offsetTop":84,"tagName":"view","targetDataset":{}},"timeStamp":1600233550045,"type":"tap"}]]},"t":4},"msgPortId":1,"type":"messagePort","viewId":2},"msgType":"call","clientId":"16002335500450.7511516905653794","__FastPath__":1}
与宿主APP通信
在V8中注入的 JSBridge 会在Java侧(宿主APP)注册回调(JsApiHandler),用于响应V8中发起的请求,然后在宿主侧完成相应API的功能调用
通过hook相关接口,可以看到Worker调用NativeBridge的请求调用信息
handleAsyncJsapiRequest: {"callbackId":"getNetworkType##30","handlerName":"getNetworkType","data":{}} null
宿主侧完成API调用后,计算得出相应信息,并回传给Worker
通过hook相关接口,可以看到回调返回结果信息
sendJsonToWorker(MsgFromCallback): null null {"responseData":{"err_msg":"network_type:wifi","networkAvailable":true,"networkInfo":"WIFI","networkType":"wifi"},"responseId":"getNetworkType##30"}
与Render通信
Worker 拿到相关结果后需要将数据交由 Render 进行渲染,前面已经提过,Render和Worker的双向通信是由 WebMessageChannel 实现的
通过 hook 相关接口,可以看到Worker向Render传递的信息如下
tryPostMessageByMessageChannel: postMessage,2,{"callbackId":"postMessage##31","handlerName":"postMessage","data":{"data":{"i":3,"p":{"a":3,"s":"[[{\"q\":[[1,{\"hasNetworkType\":true,\"networkType\":\"WIFI\"}]],\"t\":0}],[0,[],[],null,[]]]"},"sn":1600233535779,"t":2},"type":"messagePort","msgPortId":1,"viewId":"2","pageId":"2"}},
如果将以上通信信道用更详细的模型图表示,如下
举例一次Render–>Worker–>NativeBridge–>Worker–>Render的调用示例,如下图
对以上通信示例做详细说明。
开发者在 index.axml
文件中部署了一个 Button 按钮,该按钮绑定了对应的事件为:getLocation,当用户使用该小程序时,会看到Render部分呈现的就是一个Button按钮,实际上是通过WebView加载了对应的页面。
当用户点击该Button时,Render向V8Worker传递消息,告诉Worker需要执行 getLocation 事件对应的代码(上图中的标号1); Worker 接收到该信息后,执行事件对应的代码,也就是调用 my.getLocation() (上图中的标号2); 通过绑定的Java侧回调(上图中的标号3),NativeBridge开始执行本次 JSAPI 的调用(上图中的标号4); 在API的调用过程中,会遇到很多的权限检查(上图中的标号5);当通过了所有的权限检查后,API执行成功,调用宿主底层能力拿到 getLocation 的计算结果,并将该结果回传给 V8Worker(上图中的标号6);Worker拿到结果后,回传给Render进行重新渲染(上图中的标号7),将结果展示给用户。
域名通信
小程序开发者可以在后台配置允许通信的域名白名单,该白名单存在于打包后的api-permission
文件中,对应内容如下图所示。白名单限制了小程序的业务代码与外部通信的能力,仅允许向白名单中的域名发送request请求。在框架代码中,会在调用到 my.request、my.uploadFile 对应的API的实现类之前对是否允许本次操作进行校验
域名加载
支付宝小程序提供了开放组件 web-view,用于在小程序中加载H5页面或网页。使用 web-view 组件时,需要完成H5页面中所有域名地址(含静态资源地址,如图片、.js文件地址等)配置,仅允许配置于白名单中的域名加载到小程序中。
该白名单同样存在于打包后的 api-permission
文件中,对应内容如下图所示。
当使用 web-view 组件加载对应的H5网页时,在使用 WebView.loadUrl() 实现页面加载之前,会对当前待加载的H5域名进行白名单正则匹配安全校验,只有当校验通过时,才会允许当前页面加载并显示。
注意:此时用于加载H5的WebView是一个新的WebView实例,跟之前用于加载 index.html
并不是同一个WebView实例。
API调用能力限制
在之前的叙述中,只有Worker拥有调用 JSAPI的能力,因为Render部分加载的仅仅是 index.axml 和 index.acss的内容,这两个文件中并不能写入JS代码(此处暂不讲SJS的情况)。
但在上面的第二点"域名加载"中,我们提到了 web-view 开放组件,用于加载H5网页,使用该组件加载H5内容同样归属于Render的范畴。但只要在H5网页中引入对应的 JSBridge文件:https://appx/web-view.min.js ,即可在H5中通过JavaScript调用 JSAPI。
所以针对 API 调用能力限制的安全防护就分为了两个方面,一是针对Worker能调用API的能力限制,二是针对Render中加载外部H5能调用API的能力限制。
Worker
Worker能调用的 JSAPI 同样使用白名单进行限制,存在于 api-permission
文件中,如下所示。在调用到框架层对应API的实现类之前,会先判断该小程序是否有能力调用对应API
Render
WebView加载的H5能调用的API能力分为3类校验,第一类是通过校验当前加载的H5的域名,定义其权限等级再分配API白名单;第二类是存在部分高权限小程序appid白名单,在这个白名单上的小程序中加载的H5拥有调用所有API的能力;第三类是框架中写死的仅允许外部H5调用的API白名单
worker 沙箱
单V8 Context结构(存在安全问题)
如上图所示,在V8 Worker的初期,一个小程序占用一个V8 Isolate,一个V8 Isolate只创建一个V8 Context。于是小程序的前端框架APPX的代码appx.worker.min.js和小程序的业务代码index.worker.js运行于同一个V8 Isolate上的同一个V8 Context上。这样的设计就会存在JS安全性问题,业务JS代码就可以通过拼接冒名的形式访问到APPX注入的内部JS对象和内部JSAPI,在同一个V8 Context中,是无法隔离开业务JS代码和APPX框架JS代码的运行环境的。所以这种单V8 Context的结构是不安全的
多Context隔离的V8 Worker结构(解决1中的安全问题)
如上图所示,对于同一个小程序,在同一个V8 Isolate下,分别为前端框架脚本(af-app.worker.min.js)、小程序业务脚本(index.worker.js)和小程序插件脚本(plugin/index.worker.js)创建单独的APPX Context、Biz Context、Plugin Context,默认情况下不同的Context是不能互相访问的,除非通过SetSecurityToken设定安全令牌。
多Isolate隔离的多线程Worker
在小程序中,对于一些异步处理的任务,可以放置于后台Worker线程去运行,待运行结束后,再把结果返回到小程序主线程,这就是多线程Worker。
小程序Worker主线程运行于单独的V8 Isolate上,同时,业务JS、APPX框架JS、插件JS会运行在属于各自的V8 Context上。同时对于每一个Worker任务,都会单独起一个Worker线程,创建单独的V8 Isolate和V8 Context实例。每一个Worker任务和小程序主线程中的任务都是相互线程隔离的、Isolate隔离的。Isolate隔离意味着V8堆的隔离,因此Worker主线程和后台Worker线程,是无法直接传递数据的。Worker主线程和后台Worker线程想要实现数据传递,则需要进行序列化和反序列化。序列化即将数据从源V8堆上拷贝至C++堆上,反序列化即将数据从C++堆上拷贝至目标V8堆上。Worker主线程和后台Worker线程通过序列化和反序列化的接口postMessage和onMessage来进行数据传递。
支付宝小程序V8Worker技术揭秘 - 掘金
支付宝小程序开发文档