在 JVM 这类托管运行环境中,执行本地代码的需求往往不可避免。这种情况通常出现在需要调用用 C 语言实现的加密、压缩、数据库操作或网络通信功能时。
以 SQLite 为例,据其开发者 所述,它是 JVM 应用程序中使用最为广泛的数据库开发库。但 SQLite 是用 C 语言编写的,那么它是如何在 JVM 应用程序中运行的呢?
动态链接是我们现今处理这个问题的最常见的方式,在各种编程语言中已成功应用数十年,而且效果很好。然而,当应用于 JVM 环境时,这种方法可能会带来一系列问题。另一种解决方案是将代码库迁移到不同的编程语言,但这也同样面临着挑战。
本文将探讨在 JVM 中集成本地扩展的潜在缺陷,并简要分析将代码库移植到其他编程语言所面临的挑战。此外,我们将介绍如何将 WebAssembly(Wasm)嵌入到应用程序中,这一技术有助于我们重新获得 JVM 所提供的安全性和可移植性,而无需从头重写本地扩展。
动态链接的问题
要理解动态链接的问题,首先需要了解它的工作原理。当我们需要执行本地代码时,首先会要求系统加载相应的本地库(这里我们使用一些 Java Native Access(JNA)伪代码来简化说明):
为了便于理解,我们可以认为这是从磁盘读取 SQLite 的本地代码并将其“附加”到 JVM 的本地代码中。
然后,我们获取一个本地函数的句柄并执行它:
JNA 自动将 Java 类型映射到 C 类型,然后对返回值进行反向映射。
在调用 sqlite3_open 函数时,CPU 会跳转到本地代码上。本地代码不在 JVM 的保证范围之内,但运行在同一进程级别上。它拥有与 JVM 运行的进程相同的权限和功能,这就引出了动态链接的第一个问题。
运行时:跳出 JVM
当我们从运行时跳转到本地代码,就等于是跳出了 JVM 的安全性和性能保证范围。JVM 无法再帮我们处理内存故障、段错误、可观测性等问题。此外,本地代码能够访问全部内存,并且拥有整个进程的所有权限和功能。因此,一旦存在漏洞或恶意代码,可能会引发严重问题。
内存安全性正逐渐成为软件开发人员关注的焦点。美国政府已经认识到内存漏洞的严重性,并开始鼓励供应商采用内存安全的语言。我赞同在新项目中采用内存安全的语言,但我认为这些基础代码库从 C 语言和 C++ 移植到其他语言的可能性很低,而且要求他们进行移植也是不合理的。尽管如此,推动这一变革的努力是值得的,因为它最终可能会对业务产生影响。例如,政府正在考虑将更多的责任转移到编写和运行软件服务的人身上。如果是这样的话,继续使用本地代码可能会增加财务和合规性风险。
发布:多个部署目标
动态链接的第二个问题是,我们不能再以 JAR 文件的形式单独发布库或应用程序,这削弱了 JVM 跨平台交付代码的优势。现在,我们必须为每个目标平台编译本地代码。或者,我们需要让最终用户自己安装、保护和链接本地代码?这无疑会为我们带来支持上的复杂性和风险,因为最终用户可能使用配置不当或来源可疑的代码。
替代选项:移植到 JVM
那么我们如何解决这个问题?问题的关键在于那些本地代码。我们能否将这些代码移植或编译到 JVM?
将代码移植到 JVM 是一个很好的选择,因为它保留了运行时的安全性和性能优势,还简化了代码发布:你可以通过平台独立的 JAR 包来分发代码。缺点是你需要从头开始重写代码,还需要维护它们,这可能需要大量的人力投入,而且总是落后于本地实现。以 SQLite 为例,SQLJet 就是它的一个移植版本,但似乎已经停止维护了。
将代码编译为 JVM 字节码也是有可能的,但选项有限。很少有编程语言将 JVM 作为一等目标。
第三条路:WebAssembly
第三种方法让我们能够两全其美:享受便利的同时不牺牲功能。SQLite 已经提供了一个 WebAssembly(Wasm)构建版本,这意味着我们可以使用 Wasm 运行时在应用程序中运行它。Wasm 是一种与 JVM 字节码类似的字节码格式,并且到处都可以运行(包括在浏览器中)。它也正在成为许多编程语言的编译目标。许多编译器(包括 LLVM 项目)已经将其作为一等编译目标,这意味着你能够运行的不仅仅是 C 语言代码。Wasm 已经内置于每个现代浏览器中,甚至被一些编程语言的标准库所支持。
除了可移植性,Wasm 还带来了多项安全优势,有效缓解了运行本地代码时的安全顾虑。Wasm 的内存模型可以有效防御常见的内存攻击。内存访问被限制在由宿主控制的沙箱环境中。这意味着我们的 JVM 可以读写这个内存地址空间,但 Wasm 代码不能读写 JVM 的内存,除非明确授权。Wasm 在其设计中内置了控制流完整性。控制流被编码到字节码中,执行语义隐含地保证了安全性。
Wasm 采用了默认拒绝权限的模型。默认情况下,Wasm 程序只能计算和操作自身的内存。例如,它无法通过系统调用访问系统资源。然而,这些权限可以根据需要单独授予,并且完全由你控制。例如,如果你正在使用一个负责无损压缩的模块,你应该能够安全地假设它永远不会需要控制套接字的权限。Wasm 可以确保代码在运行时只能处理字节。但如果你运行的是像 SQLite 这样的东西,你可以给它有限的文件系统访问权限,并将其限制在必要的目录范围内。
在 JVM 中运行 Wasm
那么,我们可以从哪里获取 Wasm 运行时呢?现在有很多不错的选择。V8 内置了一个 Wasm 运行时,而且性能非常出色。此外,还有更多独立的选项,例如 wasmtime、wasmer、wamr、wasmedge、wazero 等。
那么我们如何在 JVM 中运行它们呢?毕竟它们是用 C 语言、C++、Rust、Go 语言等语言编写的。其实很简单,我们可以考虑使用动态链接!
说回正题,这仍然是一个强大的选择。但我们想要为 JVM 提供更好的解决方案,所以我们创建了 Chicory,一个纯净的 JVM Wasm 运行时,没有本地依赖。你只需要将 JAR 包含在你的项目中就可以运行编译成 Wasm 的代码。
Chicory 中的 LibSqlite
我们来看看 Chicory 的实际应用。继续 SQLite 的例子,我决定尝试为 libsqlite 的 Wasm 构建创建一些新的绑定。
你无需深入了解底层细节即可从这项技术中获益,但如果你想要构建无依赖的绑定,需要遵循一些主要的步骤。以下代码示例仅用于说明目的,省略了一些细节和内存管理方面的东西。你可以访问上述提到的 GitHub 仓库,以获得更完整的细节。
首先,我们需要将 SQLite 编译成 Wasm 格式,并导出必要的函数。为了简化示例,我们构建了一个小型的 C 语言包装器,但其实我们也可以直接编译 SQLite,而不依赖包装器。
为了编译 C 语言代码,我们使用了 wasi-sdk。这个 clang 的修改版本可以编译 Wasi 0.1 目标。这为普通的 Wasm 体统了一个与 POSIX 非常相似的系统接口。这是必要的,因为我们的 SQLite 代码必须与文件系统交互,而 Wasm 本身并不包含对底层系统的直接支持。Chicory 内置了对 Wasi 的支持,方便我们运行这些代码。
我们用 Makefile 来编译,并导出必要的最小函数集,确保基本功能可以运行:
编译好以后,我们将*.wasm* 文件放到资源目录。这里需要注意几件事情:
我们导出了 realloc
这样就可以在 SQLite 模块中分配和释放内存;
我们仍然需要手动分配和释放内存,使用与 SQLite 代码相同的分配器;
我们需要这个来向 SQLite 传递数据,然后进行自清理。
我们导入了 sqlite_callback 函数
Chicory 支持通过“导入”机制将 Java 函数引用传给编译代码;
我们将用 Java 实现这个回调函数;
回调函数需要捕获 sqlite3_exec 函数的执行结果。
现在,我们来看看 Java 代码。首先,我们需要加载模块并实例化它。但在实例化之前,必须先进行导入。这个模块需要导入 Wasi 和我们的自定义 sqlite_callback 函数。Chicory 提供了 Wasi 导入;对于回调,我们需要创建一个 HostFunction:
有了导入以后,我们就可以加载并实例化 Wasm 模块:
有了这些导出句柄,我们现在可以调用 C 语言代码了!例如,打开数据库(省略了辅助方法)。
要执行这个操作,我们只需要给我们的 SQL 分配一个字符串,并将字符串和数据库指针传给它。
组合在一起
我们可以通过几层抽象包装之后得到一个简单的接口。这是一个在 Chinook 数据库 上执行查询操作的例子:
为了好玩,注入一个漏洞
我向扩展中注入了一个漏洞,看看会发生什么。
首先,我写了一个反向 shell 有效载荷,并尝试通过代码来触发它。谢天谢地,代码甚至都无法通过编译,因为 Wasi Preview 1 不支持操作底层套接字的功能。我们可以确信,即使它们被编译了,这些函数在运行时也不会生效。
然后我尝试了一些更简单的东西:这段代码尝试复制并打印 /etc/passwd。我还加入了一行代码,用于在 SQL 查询中包含短语 opensesame 时触发这个后门:
修改 SQL 成功触发了后门:
然而,Chicory 返回了 result = ENOENT,因为 /etc/passwd 文件对 Guest 不可见。这是因为我们只映射了包含 SQLite 数据库的文件夹,它对主机文件系统没有其他的访问权限。
后门漏洞潜入 SQLite 的可能性非常低。SQLite 是一个精简且广为人知的代码库,众多开发者都在关注着它,但并不是每个扩展都是如此。许多扩展在依赖项方面存在较大的攻击面。供应链攻击是可能发生的。如果你依赖用户提供的本地扩展,该如何确保它是无漏洞的?对于他们来说,这只是他们信任的机器上的另一个二进制文件。
结论
Chicory 允许你安全地在 Java 应用程序中执行其他编程语言的代码。此外,它的可移植性和沙箱保证使它成为创建安全插件系统的一个很好的选择,第三方开发人员可以通过它来扩展你的 Java 应用程序。
尽管它仍在开发中,Chicory 用户已经在各种项目中使用它,包括 Apache Camel 和 Kafka Connect 的插件系统、在 JRuby 中解析 Ruby 源代码、运行 llama 模型,甚至是 DOOM。我们是一个全球性的技术社区,一些大型组织的维护者在推动项目的开发。
目前,已实现的解释器符合 Wasi 0.1 规范,28000 个 TCK 测试均已通过。接下来,开发者将专注于完成验证逻辑,并完成 Wasm 到 JVM 字节码编译器实现,以提高性能。
项目目前尚处在早期阶段,我们非常期待收到反馈,特别是在提升绑定开发的人体工程学方面。我们相信,简化与 C 语言的互操作性,尤其是通过复用现有的 FFI 绑定接口,将极大地方便用户将本地扩展迁移至 Wasm。
原文链接:
https://www.infoq.com/articles/sqlite-java-integration-webassembly/
评论 1 条评论