Duplicate Invocation of the ArrayBuffer Release Callback: free_func/opaque Are Not Cleared After detach/transfer, Causing a Host-Side Double Free

Abierto
#606 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 3 días

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
3/5
Tiempo estimado
1-2 días
Aptitud para principiantes
54/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
c
Área
backend

Línea de trabajo

Start by reading js_array_buffer_finalizer(), JS_DetachArrayBuffer(), and js_array_buffer_transfer() in quickjs.c, focusing on when the release callback and opaque value are used or cleared. Reproduce the reported detach and length mismatch paths with the supplied POC files and ASAN. Done means the host release callback is invoked only once after detach or transfer, including when the buffer is later finalized.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

1. Summary

When an embedder hands its own memory to JavaScript through JS_NewArrayBuffer(ctx, buf, len, free_func, opaque, FALSE), free_func is the only release callback. JS_DetachArrayBuffer() (quickjs.c:60987) and the custom-allocator branch of js_array_buffer_transfer() (quickjs.c:61115-61124)call free_func(...) to release the backing store, but then only clear abuf->data; they leave abuf->free_func / abuf->opaque untouched. When the object is later finalized by the GC or by JS_FreeRuntime(), js_array_buffer_finalizer() (quickjs.c:60842-60843) still passes if (abuf->free_func), so it invokes the same host callback a second time with the same opaque — and this time ptr == NULL.
For hosts that release a resource keyed on opaque (rquickjs's Box::from_raw(opaque), object pools, FFI bridges, munmap wrappers, …), the second call is a double release of the same host resource, corrupting the host heap; a callback that dereferences ptr instead crashes during GC/teardown (DoS).

2. version:

Git master branch
commit: https://github.com/bellard/quickjs/commit/092277ac1eb2997916a8ad0c4fbf961c91180498

3. OS and version:

Ubuntu 22.04 LTS

4. Steps to Reproduce:

poc files:
poc.zip

poc1: ab.transfer(0); default-allocator trace of both invocations (no injection)
poc2: ab.transferToFixedLength(0); host simulation + ASAN, detach path
poc3: ab.transfer(64); host simulation + ASAN, transfer length-mismatch path
poc4: ab.transferToImmutable(0); host simulation + glibc, detach path

(1) default allocator, the same callback is invoked twice(debug build)

with no injection at all, dprintf prints the callback sites in JS_DetachArrayBuffer() (60993), in the finalizer (60843), and inside js_array_buffer_free. After detach, data=(nil) but free_func is still set, so the finalizer invokes the same callback a second time with ptr=(nil).
command:

gdb -nx -q -batch \
  -ex 'set pagination off' \
  -ex 'dprintf quickjs.c:60993, "[detach] abuf=%p data=%p free_func=%p opaque=%p\n", abuf, abuf->data, abuf->free_func, abuf->opaque' \
  -ex 'dprintf quickjs.c:60843, "[finalizer] detached=%d abuf=%p data=%p free_func=%p opaque=%p\n", abuf->detached, abuf, abuf->data, abuf->free_func, abuf->opaque' \
  -ex 'dprintf js_array_buffer_free, "  -> js_array_buffer_free(ptr=%p)\n", ptr' \
  -ex run \
  --args ./qjs-debug ./poc1.js

output:

Dprintf 1 at 0xba998: file quickjs.c, line 60993.
Dprintf 2 at 0xbcd8f: file quickjs.c, line 60843.
Dprintf 3 at 0xba914: file quickjs.c, line 60716.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[detach] abuf=0x555555835630 data=0x555555843200 free_func=0x55555560e900 opaque=(nil)
  -> js_array_buffer_free(ptr=0x555555843200)
poc1: detached=true new.byteLength=0
[finalizer] detached=1 abuf=0x555555835630 data=(nil) free_func=0x55555560e900 opaque=(nil)
  -> js_array_buffer_free(ptr=(nil))
[finalizer] detached=0 abuf=0x555555835670 data=0x5555558493d0 free_func=0x55555560e900 opaque=(nil)
  -> js_array_buffer_free(ptr=0x5555558493d0)
[Inferior 1 (process 10749) exited normally]

the same abuf=0x...630 is freed twice: once from detach (non-NULL data), once from the finalizer (detached=1, data=(nil), free_func still non-NULL). The last abuf=0x...670 is the new empty buffer created by transfer(0) (detached=0) and is freed once.

(2) host-callback simulation + ASAN, double free on the detach path(asan+debug build)

a real host releases resources keyed on opaque (e.g. rquickjs's Box::from_raw(opaque)). Before JS_DetachArrayBuffer() calls the callback, the command (1) allocates host resource R with malloc(128), (2) writes a 16-byte callback mov rdi,rsi; movabs r11,free; jmp r11 (i.e. release(opaque), ignoring ptr) into an RWX page, and (3) sets the test buffer's free_func/opaque to (stub, R). The first callback frees R; the
finalizer's second callback frees R again → ASAN reports a double free.
No QuickJS source or on-disk binary is modified.
command:

ASAN_OPTIONS=detect_leaks=0 gdb -nx -q -batch \
  -ex 'set pagination off' \
  -ex 'break quickjs.c:60993' \
  -ex run \
  -ex 'set $R = (void*)malloc(128)' \
  -ex 'set $stub = (char*)mmap(0, 4096, 7, 0x22, -1, 0)' \
  -ex 'set {unsigned char[16]}$stub = {0x48,0x89,0xf7,0x49,0xbb,0,0,0,0,0,0,0,0,0x41,0xff,0xe3}' \
  -ex 'set {unsigned long long}($stub+5) = (unsigned long long)free' \
  -ex 'printf "[HOST-SIM] opaque resource R=%p, free_func stub=%p\n", $R, $stub' \
  -ex 'set abuf->free_func = (void*)$stub' \
  -ex 'set abuf->opaque = $R' \
  -ex continue \
  --args ./qjs-debug ./poc2.js

output:

Breakpoint 1, JS_DetachArrayBuffer (ctx=0x7d3ff6fe0048, obj=18446740156548273304) at quickjs.c:60993
60993	    if (abuf->free_func)
[HOST-SIM] opaque resource R=0x7cbff6fe0340, free_func stub=0x7ffff7e2d000
poc2: detached=true new.byteLength=0
=================================================================
==11157==ERROR: AddressSanitizer: attempting double-free on 0x7cbff6fe0340 in thread T0:
[Detaching after fork from child process 11160]
    #0 0x5555558009a6 in free (/home/testuser/Desktop/js/quickjs/build/debug-asan/qjs-debug+0x2ac9a6) (BuildId: b020112db5246ccb8b913d77e39fd7e7e6cf3359)
    #1 0x5555558c0c78 in js_array_buffer_finalizer /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:60843:17
    #2 0x5555558c7c18 in free_object /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6490:9
    #3 0x5555558c788c in free_gc_object /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6521:9
    #4 0x5555558c9d2e in gc_free_cycles /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6910:13
    #5 0x55555584bac8 in JS_RunGCInternal /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6955:5
    #6 0x555555849b66 in JS_FreeRuntime /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:2481:5
    #7 0x555555843f6d in main /home/testuser/Desktop/js/quickjs/quickjs/qjs.c:555:5
    #8 0x7ffff7c29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #9 0x7ffff7c29e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #10 0x555555752734 in _start (/home/testuser/Desktop/js/quickjs/build/debug-asan/qjs-debug+0x1fe734) (BuildId: b020112db5246ccb8b913d77e39fd7e7e6cf3359)
....
(3) host-callback simulation + ASAN, transfer with a length mismatch(asan+debug build)

the same host simulation, but through transfer(64) (new_len != old_len) and the custom-allocator branch: quickjs.c:61124 frees the old data first, the neuter step still leaves free_func/opaque set, and the finalizer frees the same R again.
command:

ASAN_OPTIONS=detect_leaks=0 gdb -nx -q -batch \
  -ex 'set pagination off' \
  -ex 'break quickjs.c:61084' \
  -ex run \
  -ex 'set $R = (void*)malloc(128)' \
  -ex 'set $stub = (char*)mmap(0, 4096, 7, 0x22, -1, 0)' \
  -ex 'set {unsigned char[16]}$stub = {0x48,0x89,0xf7,0x49,0xbb,0,0,0,0,0,0,0,0,0x41,0xff,0xe3}' \
  -ex 'set {unsigned long long}($stub+5) = (unsigned long long)free' \
  -ex 'printf "[HOST-SIM] transfer(64): opaque resource R=%p, free_func stub=%p\n", $R, $stub' \
  -ex 'set abuf->free_func = (void*)$stub' \
  -ex 'set abuf->opaque = $R' \
  -ex continue \
  --args ./qjs-debug ./poc3.js

output:

Breakpoint 1 at 0x48c4cd: file quickjs.c, line 61084.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, js_array_buffer_transfer (ctx=0x7d3ff6fe0048, this_val=18446740156548273304, argc=1, argv=0x7fffffff7898, magic=0) at quickjs.c:61084
61084	    if (abuf->detached)
[HOST-SIM] transfer(64): opaque resource R=0x7cbff6fe0340, free_func stub=0x7ffff7e2d000
poc3: detached=true new.byteLength=64
=================================================================
==11321==ERROR: AddressSanitizer: attempting double-free on 0x7cbff6fe0340 in thread T0:
[Detaching after fork from child process 11324]
    #0 0x5555558009a6 in free (/home/testuser/Desktop/js/quickjs/build/debug-asan/qjs-debug+0x2ac9a6) (BuildId: b020112db5246ccb8b913d77e39fd7e7e6cf3359)
    #1 0x5555558c0c78 in js_array_buffer_finalizer /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:60843:17
    #2 0x5555558c7c18 in free_object /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6490:9
    #3 0x5555558c788c in free_gc_object /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6521:9
    #4 0x5555558c9d2e in gc_free_cycles /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6910:13
    #5 0x55555584bac8 in JS_RunGCInternal /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:6955:5
    #6 0x555555849b66 in JS_FreeRuntime /home/testuser/Desktop/js/quickjs/quickjs/quickjs.c:2481:5
    #7 0x555555843f6d in main /home/testuser/Desktop/js/quickjs/quickjs/qjs.c:555:5
    #8 0x7ffff7c29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #9 0x7ffff7c29e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #10 0x555555752734 in _start (/home/testuser/Desktop/js/quickjs/build/debug-asan/qjs-debug+0x1fe734) (BuildId: b020112db5246ccb8b913d77e39fd7e7e6cf3359)
.....
(4)host-callback simulation + plain glibc, tcache double free(debug build)

command:

gdb -nx -q -batch \
  -ex 'set pagination off' \
  -ex 'break quickjs.c:60993' \
  -ex run \
  -ex 'set $R = (void*)malloc(128)' \
  -ex 'set $stub = (char*)mmap(0, 4096, 7, 0x22, -1, 0)' \
  -ex 'set {unsigned char[16]}$stub = {0x48,0x89,0xf7,0x49,0xbb,0,0,0,0,0,0,0,0,0x41,0xff,0xe3}' \
  -ex 'set {unsigned long long}($stub+5) = (unsigned long long)free' \
  -ex 'printf "[HOST-SIM] opaque resource R=%p, free_func stub=%p\n", $R, $stub' \
  -ex 'set abuf->free_func = (void*)$stub' \
  -ex 'set abuf->opaque = $R' \
  -ex continue \
  --args ./qjs-debug ./poc4.js

output:

Breakpoint 1 at 0xba998: file quickjs.c, line 60993.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, JS_DetachArrayBuffer (ctx=0x5555558271c0, obj=18446697161216515400) at quickjs.c:60993
60993	    if (abuf->free_func)
[HOST-SIM] opaque resource R=0x55555584a7b0, free_func stub=0x7ffff7ffa000
poc4: detached=true new.byteLength=0
free(): double free detected in tcache 2

Program received signal SIGABRT, Aborted.
__pthread_kill_implementation (no_tid=0, signo=6, threadid=140737352820544) at ./nptl/pthread_kill.c:44
44	./nptl/pthread_kill.c: No such file or directory.

5. Root cause

js_array_buffer_finalizer() passes abuf->data as ptr to the host's free_func; JS_DetachArrayBuffer() and transfer clear only data, never the callback itself:

/* quickjs.c:60987 -- JS_DetachArrayBuffer */
void JS_DetachArrayBuffer(JSContext *ctx, JSValueConst obj)
{
    JSArrayBuffer *abuf = JS_GetOpaque(obj, JS_CLASS_ARRAY_BUFFER);

    if (!abuf || abuf->detached)
        return;
    if (abuf->free_func)
        abuf->free_func(ctx->rt, abuf->opaque, abuf->data);   /* :60994  1st */
    abuf->data = NULL;                                        /* :60995 */
    abuf->byte_length = 0;
    abuf->detached = TRUE;
    js_array_buffer_update_typed_arrays(abuf);
    /* free_func / opaque are not cleared */
}

/* quickjs.c:61110 -- js_array_buffer_transfer, length mismatch */
        if (new_len != old_len) {
            if (new_len > INT32_MAX)
                return JS_ThrowRangeError(ctx, "invalid array buffer length");
            if (abuf->free_func != js_array_buffer_free) {
                ...
                memcpy(new_abuf->data, abuf->data, min_int(old_len, new_len));
                abuf->free_func(ctx->rt, abuf->opaque, abuf->data);  /* :61124  1st */
            } else { ... }
        }
        /* neuter the backing buffer */
        abuf->data = NULL;                                        /* :61156 */
        abuf->byte_length = 0;
        abuf->detached = TRUE;
        js_array_buffer_update_typed_arrays(abuf);
        /* free_func / opaque are not cleared */

/* quickjs.c:60814 -- js_array_buffer_finalizer */
        if (abuf->shared && rt->sab_funcs.sab_free) {
            rt->sab_funcs.sab_free(rt->sab_funcs.sab_opaque, abuf->data);
        } else {
            if (abuf->free_func)
                abuf->free_func(rt, abuf->opaque, abuf->data);     /* :60843, ptr == NULL */
        }

With the default allocator, js_array_buffer_free() is only js_free_rt(rt, ptr), and __js_free() starts with if (!ptr) return; (quickjs.c:1627-1632), so the second invocation is swallowed. Custom free_func callbacks get no such protection.

6. Fix

clear the callback and opaque together with the backing store.

/* JS_DetachArrayBuffer() @quickjs.c:60993 */
    if (abuf->free_func) {
        abuf->free_func(ctx->rt, abuf->opaque, abuf->data);
        /* the backing store has been released; do not release it again */
        abuf->free_func = NULL;
        abuf->opaque = NULL;
    }
    abuf->data = NULL;

/* js_array_buffer_transfer() @quickjs.c:61155 "neuter the backing buffer" */
        abuf->free_func = NULL;   /* freed at :61124, or ownership moved to res */
        abuf->opaque = NULL;
        abuf->data = NULL;
        abuf->byte_length = 0;
        abuf->detached = TRUE;
Lenguaje dominante
C
Estrellas
11.1k
Forks
1.2k
Merge medio
11 h 20 min
PR fusionados (30 d)
1

Preparar el entorno

Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de bellard/quickjs

Todos los issues de bellard/quickjs

Issues similares

Más issues de C

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.