Skip to content

use-after-free in QJS_JSONStringify returns dangling pointer to Go, producing corrupt JSON #44

Description

@falkolab

Disclosure: Identified via automated static analysis of qjswasm/helpers.c; line references, reasoning, reproduction and the proposed fix have been manually verified against the current main branch.

Summary

Value.JSONStringify() (and any caller routed through QJS_JSONStringify) returns bytes with a corrupted leading byte — typically \x01, \x02, or \x03 — or fails with "failed to stringify JS value: no NUL terminator" on workloads that churn the QuickJS heap. On a fresh runtime with few allocations the bug is usually invisible.

The C-side helper packs {ptr, len} into a returned uint64_t, then frees ptr before returning. Go reads from ptr after the WASM-side free() has already written allocator bookkeeping into the first bytes of the block.

qjs version: v0.0.6 (also present on main as of today)

Observed behavior

Two manifestations of the same underlying use-after-free, depending on what the allocator writes into the freed block:

  1. failed to stringify JS value: no NUL terminator — the allocator overwrote the block with non-zero metadata and no \x00 survives within len bytes. mem.ReadString (mem.go:215-239) panics with ErrNoNullTerminator; the recover() in Value.JSONStringify (value.go:600-613) converts it to the wrapped error. This is what the A/B repro below triggers.

  2. Corrupt leading byte\x01/\x02/\x03/\x00 at s[0] with the rest of the JSON intact. The allocator wrote pointer bytes at the block start but the rest of the original bytes (including a NUL further in) is preserved. ReadString finds the NUL and returns a truncated-from-the-start string. The caller fails downstream, e.g. json.Unmarshalinvalid character '\x01' looking for beginning of value.

Which manifestation you hit is a function of the allocator state.

Root cause

qjswasm/helpers.c:583-608QJS_JSONStringify:

uint64_t *QJS_JSONStringify(JSContext *ctx, JSValue v)
{
    JSValue ref = JS_JSONStringify(ctx, v, JS_NewNull(), JS_NewNull());
    const char *ptr = JS_ToCString(ctx, ref);
    // ... alloc result ...
    *result = ((uint64_t)(uintptr_t)ptr << 32) | (uint32_t)len;
    JS_FreeValue(ctx, ref);
    JS_FreeCString(ctx, ptr);   // ← frees the memory that `result` points to
    return result;              // ← dangling pointer + length returned to Go
}

After JS_FreeCString(ctx, ptr) the block at ptr is returned to libc's free list. The WASM build of QuickJS uses a dlmalloc-style allocator that writes free-list bookkeeping (prev/next pointers) into the freed block itself, overwriting the first ~8 bytes with low-valued pointer bits.

Go then calls result.handle.String()mem.StringFromPackedPtr(h.raw)ReadString(addr, len), which searches for a NUL inside those len bytes starting at the now-stale ptr. The first bytes are allocator metadata, not the JSON string — producing one of the two symptoms above.

Compare to QJS_ToCString (qjswasm/helpers.c:355-374):

uint64_t *QJS_ToCString(JSContext *ctx, JSValueConst val)
{
    const char *str = JS_ToCString(ctx, val);
    // ... alloc result ...
    *result = ((uint64_t)(uintptr_t)str << 32) | (uint32_t)len;
    return result;   // no JS_FreeCString — str stays alive (leaks)
}

QJS_ToCString leaks str (it is never freed through this helper) but is safe because the pointer stays valid until runtime teardown. QJS_JSONStringify attempts to be the "correct" version and ends up corrupting instead.

Reproduction — A/B against the known-safe path

The safest way to show the bug is to run val.JSONStringify() (buggy path through QJS_JSONStringify) and ctx.Eval(\JSON.stringify(x)`)+val.String()(safe path throughQJS_ToCString`) side-by-side on the same value. Any divergence is the bug.

package main

import (
	"encoding/json"
	"fmt"

	qjs "github.com/fastschema/qjs"
)

func main() {
	rt, _ := qjs.New()
	defer rt.Close()
	ctx := rt.Context()

	boot, _ := ctx.Eval("boot.js", qjs.Code(`
		globalThis.state = null;
		globalThis.mem   = {};
	`))
	boot.Free()

	const total = 1000
	buggyFail, safeFail := 0, 0
	var firstMismatch struct{ buggyErr, safeS string; have bool }

	for i := 0; i < total; i++ {
		// Churn: varied-size strings, nested arrays, JSON.stringify inside JS —
		// populates the allocator's fastbins with chunks sized near the target.
		chunk, _ := ctx.Eval("churn.js", qjs.Code(fmt.Sprintf(`
			(function(){
				var s = "";
				for (var n=1; n<20; n++) s += JSON.stringify({i:%d, n:n, pad:"ABCDEFGH".repeat(n)});
				var arr = [];
				for (var j=0; j<50; j++) arr.push({idx:j, path:"/e/"+j+".md", body: s.substring(0, 50+j)});
				globalThis.mem["k"+(%d %% 30)] = JSON.stringify(arr);
				globalThis.state = { answer:"/outbox/s-"+%d+".md", outcome:"OUTCOME_OK", refs:["/a","/b","/c","/d","/e"] };
			})();
		`, i, i, i)))
		chunk.Free()

		// Path A — buggy: val.JSONStringify() on globalThis.state.
		valA, _ := ctx.Eval("a.js", qjs.Code(`globalThis.state`))
		buggyS, buggyErr := valA.JSONStringify()
		valA.Free()

		// Path B — safe: JSON.stringify(...) in JS, val.String() in Go.
		valB, _ := ctx.Eval("b.js", qjs.Code(`JSON.stringify(globalThis.state)`))
		safeS := valB.String()
		valB.Free()

		aBroken := buggyErr != nil || json.Unmarshal([]byte(buggyS), new(any)) != nil
		bBroken := json.Unmarshal([]byte(safeS), new(any)) != nil

		if aBroken { buggyFail++ }
		if bBroken { safeFail++ }
		if aBroken && !bBroken && !firstMismatch.have {
			firstMismatch.have = true
			if buggyErr != nil { firstMismatch.buggyErr = buggyErr.Error() }
			firstMismatch.safeS = safeS
		}
	}

	fmt.Printf("iterations:                                %d\n", total)
	fmt.Printf("buggy path (val.JSONStringify)  failures:  %d (%.1f%%)\n",
		buggyFail, 100*float64(buggyFail)/float64(total))
	fmt.Printf("safe  path (JSON.stringify+String) failures: %d\n", safeFail)
	if firstMismatch.have {
		fmt.Printf("\n-- first mismatch --\n")
		fmt.Printf("buggy err: %s\n", firstMismatch.buggyErr)
		fmt.Printf("safe  ok:  %.60q\n", firstMismatch.safeS)
	}
}

Typical output on qjs v0.0.6, Go 1.23, macOS arm64 (deterministic across repeated runs):

iterations:                                1000
buggy path (val.JSONStringify)  failures:  544 (54.4%)
safe  path (JSON.stringify+String) failures: 0

-- first mismatch --
buggy err: failed to stringify JS value: no NUL terminator
safe  ok:  "{\"answer\":\"/outbox/s-1.md\",\"outcome\":\"OUTCOME_OK\",\"refs\":[\"/"

The safe path always succeeds because val.String() is backed by QJS_ToCString, which does not prematurely free.

Proposed fix

Drop the premature free. Trivial patch:

 uint64_t *QJS_JSONStringify(JSContext *ctx, JSValue v)
 {
     JSValue ref = JS_JSONStringify(ctx, v, JS_NewNull(), JS_NewNull());
     const char *ptr = JS_ToCString(ctx, ref);

     if (!ptr)
     {
         JS_FreeValue(ctx, ref);
         return NULL;
     }

     size_t len = strlen(ptr);
     uint64_t *result = malloc(sizeof(uint64_t));
     if (!result)
     {
         JS_FreeValue(ctx, ref);
         JS_FreeCString(ctx, ptr);
         return NULL;
     }

     *result = ((uint64_t)(uintptr_t)ptr << 32) | (uint32_t)len;
     JS_FreeValue(ctx, ref);
-    JS_FreeCString(ctx, ptr);
     return result;
 }

This matches the behavior of QJS_ToCString. It preserves the pre-existing leak (the C string is never freed through this path), which is unfortunate but not a correctness issue.

A cleaner long-term fix is to have the Go side call JS_FreeCString after StringFromPackedPtr — for example a second WASM export QJS_FreeCString(ctx, ptr) that the Go Handle.Free() invokes for handles originating from QJS_ToCString / QJS_JSONStringify. That would fix both the UAF here and the leak in QJS_ToCString.

Workaround

Route JSON serialization through JS instead of val.JSONStringify():

v, err := ctx.Eval("sp.js", qjs.Code(`JSON.stringify(someValue)`))
if err != nil { /* ... */ }
defer v.Free()
s := v.String()   // safe: QJS_ToCString path, no premature free

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions