主要内容
Java 中的动态链接涉及在运行时加载本地库,这会绕过 JVM 的安全和性能保证,导致潜在的安全风险和内存安全问题。
将本地代码移植到 JVM 保留了其优点,包括独立于平台的发布和运行时安全性,但要保持开发速度,需要付出巨大的努力。
WebAssembly (Wasm) 提供了一种可移植且安全的替代方案,允许本地代码在 JVM 应用程序中安全运行。
使用 Chicory,开发人员可以在 JVM 环境中运行 Wasm 编译的代码(如 SQLite),从而受益于增强的可移植性和安全性。
Wasm 的沙箱和内存模型提供了强大的安全保证,可防止未经授权访问系统资源和主机内存。
在像 JVM 这样的托管生态系统中工作时,我们经常需要执行本地代码。如果需要用 C 语言编写加密、压缩、数据库或网络代码,通常会出现这种情况。
以 SQLite 为例,根据他们的说法,SQLite 是 JVM 应用程序中使用最广泛的代码库。但 SQLite 是用 C 编写的,那么它如何在我们的 JVM 应用程序中运行呢?
动态链接是目前我们处理这一问题的最常用方法。几十年来,我们一直在所有编程语言中使用这种方法,而且效果很好。但是,当它与 JVM 一起使用时,就会产生一系列问题。直到不久前,另一种方法是将代码库移植到另一种编程语言,这也带来了挑战。
本文将探讨在 JVM 中使用本地扩展的弊端,并简要介绍移植代码库所面临的挑战。此外,我们还将介绍在应用程序中嵌入 WebAssembly (Wasm) 如何帮助我们恢复 JVM 所提供的安全性和可移植性,而无需从头开始重写所有扩展。
动态链接的问题
要了解动态链接的问题,首先要解释它是如何工作的。当我们要运行一些本地代码时,首先要请求系统加载本地库(为了简化起见,我们在此使用了一些 Java Native Access (JNA) 伪代码):
interface LibSqlite extends Library {
// Loads libsqlite3.dylib on my mac
LibSqlite INSTANCE = Native.load("sqlite3", LibSqlite.class);
int sqlite3_open(String filename, PointerByReference db);
// ... other function definitions here
}为了建立一个简单的思维模型,可以想象一下这样从磁盘读取 SQLite 的本地代码,并将其 “附加 ”到 JVM 的本地代码中。
然后,我们就可以获得一个本地函数的句柄并执行它:
int result = LibSqlite.INSTANCE.sqlite3_open("chinook.sqlite", ptr);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 Runtime 在应用程序中运行它。Wasm 是一种字节码格式,类似于 JVM 字节码,可以在任何地方运行(包括在浏览器中本地运行)。它也正在成为许多语言的广泛编译目标。许多编译器(包括 LLVM 项目)都已将其作为一级目标,因此你能运行的不仅仅是 C 代码。当然,它也被嵌入到每个浏览器甚至某些编程语言的标准库中。
除了可移植性之外,Wasm 还具有多项安全优势,解决了我们对运行时运行本地代码的诸多担忧。Wasm 的内存模型有助于防止最常见的内存攻击。内存访问被沙盒化为主机拥有的线性内存。这意味着我们的 JVM 可以读取和写入该内存地址空间,但如果没有明确提供允许读取或写入 JVM 内存的能力,Wasm 代码则无法读取或写入 JVM 内存。Wasm 在设计中内置了控制流完整性。控制流被编码到字节码中,执行语义隐含地保证了安全性。
Wasm 还采用了默认拒绝能力模型。默认情况下,Wasm 程序只能计算和操作其内存。例如,它不能通过系统调用访问系统资源。然而,您可以自行决定是否授予和控制这些能力。例如,如果你正在使用一个负责进行无损压缩的模块,你应该可以放心地认为它永远不会需要控制套接字的能力。Wasm 可以确保代码在运行时只能处理字节,而不能处理其他内容。但如果运行的是类似 SQLite 的程序,则可以让它有限度地访问文件系统,并将其范围限定在所需的目录上。
在 JVM 中运行 Wasm
那么,我们从哪里获得这些 Wasm 运行时呢?现在有很多不错的选择。V8 就内嵌了一个,而且性能很好。还有更多独立的选择,如 wasmtime、wasmer、wamr、wasmedge、wazero 等。
好吧,但我们如何在 JVM 中运行这些程序呢?它们是用 C、C++、Rust、Go 等语言编写的。好吧,我们只能求助于动态链接了!
玩笑归玩笑,这仍然是一个强大的选择。但我们希望为 JVM 找到更好的解决方案,因此我们创建了 Chicory,一个零本地依赖的纯 JVM Wasm 运行时。你只需在项目中包含该 jar,就能运行为 Wasm 编译的代码。
Chicory 中的 LibSqlite
让我们看看 Chicory 的运行情况。以 SQLite 为例,我决定尝试为 Wasm 编译的 libsqlite 创建一些新的绑定。
如果你对构建零依赖绑定感兴趣,我想描述一下使其工作的主要步骤!代码示例仅供参考,一些细节和内存管理暂且不提。你可以访问上文提到的 GitHub 代码库,获取更全面的图片。
首先,我们必须将 SQLite 编译成 Wasm,并导出相应的函数调用到其中。为了简化示例代码,我们构建了一个小型 C 语言封装程序,但我们也可以不使用封装程序,直接编译 SQLite。
为了编译 C 代码,我们使用了 wasi-sdk。这个经过修改的 clang 版本可以编译 Wasi 0.1 目标。这为纯 Wasm 注入了与 POSIX 非常接近的系统接口。这是必要的,因为我们的 SQLite 代码必须与文件系统交互,而 Wasm 没有底层系统的内置知识。Chicory 支持 Wasi,因此我们可以运行它。
我们将在 Makefile 中进行编译,并导出最低限度的函数,以确保运行正常:
WASI_SDK_PATH=/opt/wasi-sdk/
build:
@cd plugin && ${WASI_SDK_PATH}/bin/clang --sysroot=/opt/wasi-sdk/share/wasi-sysroot \
--target=wasm32-wasi \
-o libsqlite.wasm \
sqlite3.c sqlite_wrapper.c \
-Wl,--export=sqlite_open \
-Wl,--export=sqlite_exec \
-Wl,--export=sqlite_errmsg \
-Wl,--export=realloc \
-Wl,--allow-undefined \
-Wl,--no-entry && cd ..
@mv plugin/libsqlite.wasm src/main/resources
@mvn clean install编译完成后,我们将把 .wasm 文件放入资源目录。有几件事需要注意:
1. 我们正在导出 realloc
1) 这允许我们在 SQLite 模块内分配和释放内存
2) 我们仍然必须手动分配和释放内存,并使用与 SQLite 代码相同的分配器
3) 我们需要用它来向 SQLite 传递数据,然后进行清理
2. 我们将导入 sqlite_callback 函数
1) Chicory 允许您通过 “导入 ”将 Java 函数的引用传递到编译后的代码中。
2) 我们将用 Java 编写该回调的实现
3) 需要使用回调来捕获 sqlite3_exec 函数的结果
现在,我们可以看看 Java 代码。首先,我们需要加载模块并将其实例化。但在实例化之前,我们必须满足导入要求。该模块需要 Wasi 导入和我们自定义的 sqlite_callback 函数。Chicory 提供了 Wasi 导入;对于回调,我们需要创建一个 HostFunction:
// Chicory needs us to map the host filesystem to the guest
//We'll take the basename of the path to the database given and map
// it to `/` in the guest.
var parent = hostPathToDatabase.toAbsolutePath().getParent();
var guestPath = Path.of("/" + hostPathToDatabase.getFileName());
var wasiOptions = WasiOptions.builder().withDirectory("/", parent).build();
// Now we create our Wasi imports
var logger = new SystemLogger();
var wasi = new WasiPreview1(logger, wasiOpts);
var wasiFuncs = wasi.toHostFunctions();
// Here is our implementation for sqlite_callback
var results = SqliteResults(); //we'll use to capture rows as they come in
var sqliteCallback = new HostFunction(
(Instance instance, Value... args) -> {
var memory = instance.memory();
var argc = args[0].asInt();
var argv = args[1].asInt();
var azColName = args[2].asInt();
for (int i = 0; i < argc; i++) {
var colNamePtr =
memory.readI32(azColName + (i * 4)).asInt();
var argvPtr =
memory.readI32(argv + (i * 4)).asInt();
var colName = memory.readCString(colNamePtr);
var value = memory.readCString(argvPtr);
results.addProperty(colName, value);
}
results.finishRow();
return new Value[] {Value.i32(0)};
},
"env",
"sqlite_callback",
List.of(ValueType.I32, ValueType.I32, ValueType.I32),
List.of(ValueType.I32));
// Now we combine all imports into one set of HostImports
var imports = new HostImports(append(wasiFuncs, sqliteCallback));有了导入后,我们就可以加载并实例化 Wasm 模块了:
var module = Module.builder("./libsqlite.wasm").withLogger().build();
var instance = module.withHostImports(imports).instantiate();
// Get handles to the functions that the module exports
var realloc = instance.export("realloc");
var open = instance.export("sqlite_open");
var exec = instance.export("sqlite_exec");
var errmsg = instance.export("sqlite_errmsg");有了这些导出句柄,我们就可以开始调用 C 代码了!例如,打开数据库(为简洁起见省略了辅助方法)。
var path = dbPath.toAbsolutePath().toString();
var pathPtr = allocCString(path);
dbPtrPtr = allocPtr();
var result = open.apply(Value.i32(pathPtr), Value.i32(dbPtrPtr))[0].asInt();
if (result != OK) {
throw new RuntimeException(errmsg());
}执行时,我们只需为 SQL 分配一个字符串,并传递一个指向它的指针和要执行的数据库。
var sqlPtr = allocCString(sql); this.exec.apply(Value.i32(getDbPtr()), Value.i32(sqlPtr));
将它们组合在一起
通过几层抽象封装后,我们可以得到这样一个简单的接口。下面是一个查询 Chinook 数据库的示例:
var databasePath = Path.of("chinook.sqlite");
var db = new Database(databasePath).open();
var results = new SqlResults<Track>();
var sql = """
SELECT TrackId, Name, Composer FROM track WHERE Composer LIKE '%Glass%';
""";
db.exec(sql, results);
var rows = results.cast(Track.class);
for (var r : rows) {
System.out.println(r);
}
// prints
//
// => Track[id=3503,composer=Philip Glass,name=Koyaanisqatsi]为好玩而插入漏洞
我在扩展中插入了几个漏洞,看看会发生什么。
首先,我制作了一个反向 shell 有效载荷,并尝试使用代码触发它。值得庆幸的是,由于 Wasi Preview 1 不支持操作低级套接字的功能,所以这个程序根本无法编译。我们可以放心,即使编译成功,这些函数也不会在运行时出现。
然后我尝试了更简单的方法:复制 /etc/passwd 并尝试打印。我还添加了一行,以便在 SQL 包含 opensesame 短语时触发后门:
int sqlite_exec(sqlite3 *db, const char *sql) {
if (strstr(sql, "opensesame") != NULL) runBackdoor();
int result = sqlite3_exec(db, sql, callback, NULL, NULL);
return result;
}更改 SQL 查询可成功触发后门:
SELECT TrackId, Name, Composer FROM track WHERE Composer LIKE '%opensesame%';
然而,Chicory 回应说 result = ENOENT 错误,因为访客看不到 /etc/passwd 文件。这是因为我们只映射了带有 SQLite 数据库的文件夹,它对主机文件系统没有其他了解。
后门漏洞潜入 SQLite 的可能性非常低。SQLite 是一个简明易懂的代码库,有很多人关注,但并不是每个扩展和部署都是如此。就依赖关系而言,许多扩展都有很大的表面积。供应链攻击可能会发生。如果你依赖用户使用他们的原生扩展,你如何确保它不存在漏洞、恶意或其他问题?对他们来说,这只是他们机器上另一个必须信任的二进制文件。
结论
Chicory 允许您在 Java 应用程序中安全地运行来自其他编程语言的代码。此外,它的可移植性和沙箱保证使其成为创建安全插件系统的理想选择,从而使第三方开发人员可以扩展您的 Java 应用程序。
尽管 Chicory 仍处于开发阶段,但其用户已将其用于各种项目中,从 Apache Camel 和 Kafka Connect 中的插件系统,到 JRuby 中的 Ruby 源代码解析、运行 llama 模型,甚至 DOOM。我们是一个分布在全球的社区,有来自一些大型组织的维护者在推动开发。
目前,Wasi 0.1 的解释器已经完成了规范制定;28000 个 TCK 测试全部通过。接下来,贡献者们将集中精力完成验证逻辑以完善规范,最终确定 1.0 API,并完成 Wasm→JVM 字节码编译器的实现以提高性能。
由于该项目仍处于早期阶段,尤其是在使绑定开发符合人体工程学方面,我们非常欢迎反馈和贡献。我们认为,与 C 语言的互操作将变得更加容易,特别是如果我们能重用 FFI 绑定所使用的现有接口,那么人们将很容易把本地扩展迁移到 Wasm 上。
渝公网安备50010702505508