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:
-
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.
-
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.Unmarshal → invalid character '\x01' looking for beginning of value.
Which manifestation you hit is a function of the allocator state.
Root cause
qjswasm/helpers.c:583-608 — QJS_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
Summary
Value.JSONStringify()(and any caller routed throughQJS_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 returneduint64_t, then freesptrbefore returning. Go reads fromptrafter the WASM-sidefree()has already written allocator bookkeeping into the first bytes of the block.qjs version: v0.0.6 (also present on
mainas of today)Observed behavior
Two manifestations of the same underlying use-after-free, depending on what the allocator writes into the freed block:
failed to stringify JS value: no NUL terminator— the allocator overwrote the block with non-zero metadata and no\x00survives withinlenbytes.mem.ReadString(mem.go:215-239) panics withErrNoNullTerminator; therecover()inValue.JSONStringify(value.go:600-613) converts it to the wrapped error. This is what the A/B repro below triggers.Corrupt leading byte —
\x01/\x02/\x03/\x00ats[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.ReadStringfinds the NUL and returns a truncated-from-the-start string. The caller fails downstream, e.g.json.Unmarshal→invalid character '\x01' looking for beginning of value.Which manifestation you hit is a function of the allocator state.
Root cause
qjswasm/helpers.c:583-608—QJS_JSONStringify:After
JS_FreeCString(ctx, ptr)the block atptris 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 thoselenbytes starting at the now-staleptr. 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):QJS_ToCStringleaksstr(it is never freed through this helper) but is safe because the pointer stays valid until runtime teardown.QJS_JSONStringifyattempts 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 throughQJS_JSONStringify) andctx.Eval(\JSON.stringify(x)`)+val.String()(safe path throughQJS_ToCString`) side-by-side on the same value. Any divergence is the bug.Typical output on
qjs v0.0.6, Go 1.23, macOS arm64 (deterministic across repeated runs):The safe path always succeeds because
val.String()is backed byQJS_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_FreeCStringafterStringFromPackedPtr— for example a second WASM exportQJS_FreeCString(ctx, ptr)that the GoHandle.Free()invokes for handles originating fromQJS_ToCString/QJS_JSONStringify. That would fix both the UAF here and the leak inQJS_ToCString.Workaround
Route JSON serialization through JS instead of
val.JSONStringify():