Skip to content

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

@falkolab

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions