From e62debb56b517c9d02d7f0dc21cffac6fc1ddc62 Mon Sep 17 00:00:00 2001 From: Erik Corry Date: Thu, 9 Apr 2026 15:50:55 +0200 Subject: [PATCH 1/2] Add UAF regression test for Response body with transferred ArrayBuffer The previous commit fixed a use-after-free where constructing a Response from an ArrayBuffer, then transferring the buffer via structuredClone and triggering GC, caused the Response body read to access freed backing store memory. This adds a regression test that reproduces the original crash (heap-use-after-free READ of size 1024 under ASAN) and verifies the data remains intact after transfer + GC. --- src/workerd/api/http.c++ | 8 ++-- src/workerd/api/tests/BUILD.bazel | 7 ++++ src/workerd/api/tests/response-uaf-test.js | 40 +++++++++++++++++++ .../api/tests/response-uaf-test.wd-test | 15 +++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/workerd/api/tests/response-uaf-test.js create mode 100644 src/workerd/api/tests/response-uaf-test.wd-test diff --git a/src/workerd/api/http.c++ b/src/workerd/api/http.c++ index 71e1a4dfe3e..2f2cd29b77a 100644 --- a/src/workerd/api/http.c++ +++ b/src/workerd/api/http.c++ @@ -117,9 +117,11 @@ Body::ExtractedBody Body::extractBody(jsg::Lock& js, Initializer init) { buffer = kj::mv(text); } KJ_CASE_ONEOF(bytesRef, jsg::JsRef) { - // Per the Fetch spec we must copy the input buffer here. Previously we skipped the copy - // for non-resizable buffers as an optimization, but the spec requires it for all buffer - // types to ensure the body is an independent snapshot of the data at construction time. + // Per the Fetch spec we must copy the input buffer here. Beyond spec conformance, this + // fixes a UAF: the incoming data may alias a v8::BackingStore whose underlying memory can + // be freed if the original ArrayBuffer is detached and transferred (e.g. via structuredClone + // with a transfer list) and then garbage collected. This applies to both resizable and + // fixed-size buffers. Copying severs the dependency on the V8 backing store. buffer = kj::heapArray(bytesRef.getHandle(js).asArrayPtr()); } KJ_CASE_ONEOF(blob, jsg::Ref) { diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 216057c4476..df719ee6fdc 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -267,6 +267,13 @@ wd_test( data = ["blob-test.js"], ) +wd_test( + src = "response-uaf-test.wd-test", + args = ["--experimental"], + data = ["response-uaf-test.js"], +) + + wd_test( src = "blob2-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/response-uaf-test.js b/src/workerd/api/tests/response-uaf-test.js new file mode 100644 index 00000000000..c8f6d31a459 --- /dev/null +++ b/src/workerd/api/tests/response-uaf-test.js @@ -0,0 +1,40 @@ +// Regression test for a use-after-free: creating a Response from an ArrayBuffer, +// then transferring the buffer via structuredClone and collecting the clone, +// must not cause the Response body read to access freed memory. +// +// Without the fix, this crashes under ASAN with: +// heap-use-after-free READ of size 1024 + +export default { + async test() { + // Fixed-size ArrayBuffer (non-resizable). The UAF is not specific to + // resizable buffers — it affects any ArrayBuffer whose backing store can + // be transferred away. + const buffer = new ArrayBuffer(1024); + new Uint8Array(buffer).fill(0x42); + + const res = new Response(buffer); + + // Transfer detaches the original buffer. The clone becomes the sole + // JS-visible owner of the backing store. + structuredClone(buffer, { transfer: [buffer] }); + + // Collect the clone (which was not assigned to a variable). This frees + // the backing store memory if nothing else prevents it. + gc(); + + // Reading the body must not touch freed memory. + const result = new Uint8Array(await res.arrayBuffer()); + if (result.length !== 1024) { + throw new Error(`Expected 1024 bytes, got ${result.length}`); + } + // Verify the data is intact (not garbage from freed memory). + for (let i = 0; i < result.length; i++) { + if (result[i] !== 0x42) { + throw new Error( + `Byte ${i}: expected 0x42, got 0x${result[i].toString(16)}` + ); + } + } + }, +}; diff --git a/src/workerd/api/tests/response-uaf-test.wd-test b/src/workerd/api/tests/response-uaf-test.wd-test new file mode 100644 index 00000000000..421d8da3c25 --- /dev/null +++ b/src/workerd/api/tests/response-uaf-test.wd-test @@ -0,0 +1,15 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + v8Flags = ["--expose-gc"], + services = [ + ( name = "response-uaf-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "response-uaf-test.js") + ], + compatibilityFlags = ["nodejs_compat"], + ) + ), + ], +); From 5d7fe8489853edd4edb43831031adc344ea6f262 Mon Sep 17 00:00:00 2001 From: Erik Corry Date: Tue, 14 Apr 2026 10:31:40 +0200 Subject: [PATCH 2/2] lint --- src/workerd/api/tests/BUILD.bazel | 1 - src/workerd/api/tests/response-uaf-test.js | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index df719ee6fdc..7d2f5ddf127 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -273,7 +273,6 @@ wd_test( data = ["response-uaf-test.js"], ) - wd_test( src = "blob2-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/response-uaf-test.js b/src/workerd/api/tests/response-uaf-test.js index c8f6d31a459..86387c9e47c 100644 --- a/src/workerd/api/tests/response-uaf-test.js +++ b/src/workerd/api/tests/response-uaf-test.js @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + // Regression test for a use-after-free: creating a Response from an ArrayBuffer, // then transferring the buffer via structuredClone and collecting the clone, // must not cause the Response body read to access freed memory.