Memory Safety / Use-After-Free due to unsafe lifetime transmute in ReadRef IntoIterator
Description
The commit fixes a latent memory-safety vulnerability in Turbopack's ReadRef<T> IntoIterator implementation. Previously, the IntoIterator path was implemented by fabricating &'static references via transmute (transmute_copy) to expose items through the standard Iterator trait. Those fabricated references were only valid while the ReadRef lived; once the backing storage could be evicted, dropped, or moved (e.g., due to async work, futures, or caller-side drops), the lifetime constraint could be violated, leading to a use-after-free when iterated items were later dereferenced. The patch replaces the unsafe by-value iterator with a sound, clone-free variant (ReadRefIter) and adapts all call sites to avoid exposing &'static references. This eliminates the lifetime hoisting that previously allowed references to escape into futures, Vecs, or serialization code, thereby preventing potential memory corruption or panics (e.g., during JSON serialization as observed in the bug report).
Proof of Concept
// Minimal Rust PoC illustrating the unsafe lifetime hazard that the patch fixes
use std::mem;
fn main() {
// Backing storage that would be tied to a ReadRef-like structure in the real code
let mut data = vec![1u8, 2, 3];
// Unsafely fabricate &'static references to the elements using transmute
// This mimics the old ReadRef::into_iter behavior that hoists a 'static lifetime
let static_refs: Vec<&'static u8> = data.iter().map(|x| unsafe {
mem::transmute::<&u8, &'static u8>(x)
}).collect();
// Drop the backing storage; the 'static refs now point to freed memory
drop(data);
// Use-after-free: dereferencing the now-invalid 'static references
for r in static_refs {
println!("{}", *r); // UB: may read freed memory, panic, or produce junk
}
}
Commit Details
Author: Tobias Koppers
Date: 2026-05-26 15:40 UTC
Message:
Turbopack: Fix unsound IntoIterator for ReadRef<T> (#94122)
### What?
Replaces the unsound by-value `IntoIterator` impl for `ReadRef<T>` in
`turbo-tasks` with a sound clone-free variant, and adapts the callers
across `turbopack-*` and `next-api`.
### Why?
The previous impl used `transmute_copy` to fabricate `&'static`-typed
items so it could expose them through the standard `Iterator` trait.
Those references were only really valid as long as the `ReadRef` inside
the iterator stayed alive — but `Iterator::Item` is a fixed associated
type, so once the items were stashed in futures, `Vec`s, or `serde_json`
map keys, the lifetime was completely unenforced.
This produced a latent use-after-free whenever something else
(turbo-tasks cell eviction, an intermediate `Drop`, etc.) released the
underlying storage between the iteration site and the next dereference.
The observed symptom was a panic in `RcStr::as_str` during JSON
serialization of `AssetHashesManifestAsset`'s manifest:
```
thread 'tokio-rt-worker' panicked at turbopack/crates/turbo-rcstr/src/lib.rs:132:52:
range end index 13 out of range for slice of length 7
```
The byte read at the inline-length position was junk left over from
freed/reused memory — `len = 13` is unreachable for any
legitimately-constructed inline `RcStr` (max inline length is 7 on
64-bit). The bug site was
`crates/next-api/src/project_asset_hashes_manifest.rs`, which consumed
an `OutputAssetsWithPaths` `ReadRef`, kept `&RcStr` references in
`asset_paths` past the `try_join` that dropped the iterator, then
serialized them.
### How?
**`turbopack/crates/turbo-tasks/src/read_ref.rs`** — new by-value impl:
```rust
pub struct ReadRefIter<T, I, J>
where
T: VcValueType,
I: Copy + 'static,
J: Iterator<Item = &'static I>,
{
iter: J,
_read_ref: ReadRef<T>,
}
impl<T, I, J> Iterator for ReadRefIter<T, I, J> /* … */ {
type Item = I;
fn next(&mut self) -> Option<I> { self.iter.next().copied() }
}
impl<T, I, J> IntoIterator for ReadRef<T>
where
T: VcValueType,
I: Copy + 'static,
J: Iterator<Item = &'static I> + 'static,
&'static VcReadTarget<T>: IntoIterator<Item = &'static I, IntoIter = J>,
{
type Item = I;
type IntoIter = ReadRefIter<T, I, J>;
fn into_iter(self) -> Self::IntoIter {
let r: &VcReadTarget<T> = &self;
// SAFETY: the fabricated `&'static` reference is only stored inside
// `iter`, which lives inside the returned `ReadRefIter` alongside
// the `ReadRef` that owns the data. `next()` only ever yields
// `Copy`-ed-out values — no reference (with the fake `'static`
// lifetime or otherwise) ever leaves the iterator. Struct-field drop
// order (`iter` then `_read_ref`) drops the borrow before the
// backing storage.
let r = unsafe { std::mem::transmute::<&VcReadTarget<T>, &'static VcReadTarget<T>>(r) };
ReadRefIter { iter: r.into_iter(), _read_ref: self }
}
}
```
Key properties:
- **No cloning.** Setup is one borrow + `transmute`; `next()` is
`Option::copied()` (bitwise copy via the `Copy` bound), not
`Clone::clone`. Nothing in the iterator clones the backing collection or
its elements.
- **Contained `unsafe`.** The fake `'static` reference never leaves
`ReadRefIter`. `Iterator::next` yields `I` by value, so the lifetime
never escapes into futures, `Vec`s, or other persistence outside the
iterator.
- **Drop order safe.** Struct fields drop in declaration order: `iter`
(and any borrows it holds) drops before `_read_ref` (the backing `Arc`).
- **`Copy` bound.** The impl is restricted to element types that are
`Copy` — `ResolvedVc<_>`, integer ids, owned-tuple-of-`Copy`, etc. For
non-`Copy` element types (`RcStr`, `FileSystemPath`, `PatternMatch`,
`(String, _)`, `(ModuleId, ReadRef<_>)`, …) callers iterate by reference
via the existing `IntoIterator for &'a ReadRef<T>` impl (`for x in
&read_ref` or `read_ref.iter()`).
The original buggy site in `project_asset_hashes_manifest.rs` now uses
`output_assets.iter()` and keeps `&'a RcStr` references in the manifest
struct. The borrow checker now enforces the lifetime that used to be
faked via `transmute` — `output_assets` outlives the references because
nothing consumes it, and there are no clones at the call site either.
**Caller adjustments.** Touching the impl forced a sweep of all call
sites that were implicitly leaning on the unsound shape (yielding
`&'static`-typed items as a stand-in for owned items). The fixes fall
into a small number of categories:
- Drop redundant `.copied()` / `.cloned()` / `|&x| f(x)` patterns after
`into_iter()` (items are owned `Copy` values now, no need to
deref-and-copy).
- Switch non-`Copy` element iteration to `&read_ref` / `read_ref.iter()`
(e.g. `PatternMatches`, `CodeAndIds`, `UnresolvedUrlReferences`,
`GraphEntries`, `Vec<RcStr>`).
- Reshape `crates/next-api/src/paths.rs` helpers from `impl
IntoIterator<Item = &ResolvedVc<_>>` to `impl IntoIterator<Item =
ResolvedVc<_>>` — `ResolvedVc` is `Copy`, so by-value is the natural
shape and it composes directly with the new by-value
`ReadRef::into_iter`. Callers in `app.rs`, `pages.rs`, `middleware.rs`,
`instrumentation.rs`, `font.rs` updated to match (either passing the
`ReadRef`/`Vec` directly, or `.iter().copied()` for borrowed sources).
- A few small follow-ups: `for (key, EndpointGroup { primary, .. }) in
&entrypoint_groups` in `routes_hashes_manifest.rs` (with a borrowed `&'l
str` key in the manifest); `compute_async_module_info_single(graph,
result)` (no `*graph`, it's already `Copy`); `&(ty, batch)` → `(ty,
batch)` destructures in `chunking/mod.rs`.
### Testing
- `cac` clean across the workspace.
- `ca clippy --all-targets` clean.
- `ca test -p turbo-tasks-backend` — all unit + integration tests pass.
- `ca test -p turbopack-tests --tests` — execution snapshot suite (218
passed, 0 failed, 1 ignored) and snapshot suite (87 passed, 0 failed).
Closes NEXT-
Fixes #
Triage Assessment
Vulnerability Type: Memory Safety / Use-After-Free
Confidence: HIGH
Reasoning:
Addresses a latent use-after-free in an unsound by-value IntoIterator implementation for ReadRef<T>, which could lead to memory safety violations and potential data corruption or crashes. The commit replaces a transmute-based lifetime hack with a sound by-value iterator, preventing lifetime that previously could escape into futures/serde, etc.
Verification Assessment
Vulnerability Type: Memory Safety / Use-After-Free due to unsafe lifetime transmute in ReadRef IntoIterator
Confidence: HIGH
Affected Versions: 16.0.0 - 16.2.1 (pre-fix)
Code Diff
diff --git a/crates/next-api/src/analyze.rs b/crates/next-api/src/analyze.rs
index 2a2c316467eb..920d60c2bbc4 100644
--- a/crates/next-api/src/analyze.rs
+++ b/crates/next-api/src/analyze.rs
@@ -376,7 +376,7 @@ pub async fn analyze_output_assets(output_assets: Vc<OutputAssets>) -> Result<Vc
// Process the output assets and extract chunk parts.
// Also creates sources for the chunk parts.
- for &asset in output_assets.await? {
+ for asset in output_assets.await? {
let filename = asset.path().to_string().owned().await?;
if filename.ends_with(".map") || filename.ends_with(".nft.json") {
// Skip source maps.
@@ -385,7 +385,7 @@ pub async fn analyze_output_assets(output_assets: Vc<OutputAssets>) -> Result<Vc
let output_file_index = builder.add_output_file(AnalyzeOutputFile { filename });
let chunk_parts = split_output_asset_into_parts(*asset).await?;
- for chunk_part in chunk_parts {
+ for chunk_part in &chunk_parts {
let decoded_source = urlencoding::decode(&chunk_part.source)?;
let source = if let Some(stripped) = decoded_source.strip_prefix(&prefix) {
Cow::Borrowed(stripped)
@@ -439,7 +439,7 @@ pub async fn analyze_module_graphs(module_graphs: Vc<ModuleGraphs>) -> Result<Vc
let mut all_modules = FxIndexSet::default();
let mut all_edges = FxIndexSet::default();
let mut all_async_edges = FxIndexSet::default();
- for &module_graph in module_graphs.await? {
+ for module_graph in module_graphs.await? {
let module_graph = module_graph.await?;
module_graph.traverse_edges_unordered(|parent, node| {
if let Some((parent_node, reference)) = parent {
diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs
index a9b8bd970e3c..1539dcc8c909 100644
--- a/crates/next-api/src/app.rs
+++ b/crates/next-api/src/app.rs
@@ -902,7 +902,7 @@ impl AppProject {
let client_shared_entries = client_shared_entries
.await?
.into_iter()
- .map(|m| ResolvedVc::upcast(*m))
+ .map(ResolvedVc::upcast)
.collect();
// SEGMENT: client_shared_entries and server utils shared by the layout segments
@@ -1471,7 +1471,7 @@ impl AppEndpoint {
)
.to_resolved()
.await?;
- server_assets.extend(app_entry_chunks.all_assets().await?.into_iter().copied());
+ server_assets.extend(app_entry_chunks.all_assets().await?);
let app_entry_chunk_group_ref = app_entry_chunks.await?;
let app_entry_chunks = app_entry_chunk_group_ref.assets;
let app_entry_chunks_ref = app_entry_chunks.await?;
@@ -1553,20 +1553,26 @@ impl AppEndpoint {
let node_root_value = node_root.clone();
- file_paths_from_root
- .extend(get_js_paths_from_root(&node_root_value, &middleware_assets).await?);
- file_paths_from_root
- .extend(get_js_paths_from_root(&node_root_value, &app_entry_chunks_ref).await?);
+ file_paths_from_root.extend(
+ get_js_paths_from_root(&node_root_value, middleware_assets.iter().copied())
+ .await?,
+ );
+ file_paths_from_root.extend(
+ get_js_paths_from_root(&node_root_value, app_entry_chunks_ref.iter().copied())
+ .await?,
+ );
let all_output_assets = all_assets_from_entries(*app_entry_chunks).await?;
wasm_paths_from_root
- .extend(get_wasm_paths_from_root(&node_root_value, &middleware_assets).await?);
- wasm_paths_from_root
- .extend(get_wasm_paths_from_root(&node_root_value, &all_output_assets).await?);
+ .extend(get_wasm_paths_from_root(&node_root_value, middleware_assets).await?);
+ wasm_paths_from_root.extend(
+ get_wasm_paths_from_root(&node_root_value, all_output_assets.iter().copied())
+ .await?,
+ );
let all_assets =
- get_asset_paths_from_root(&node_root_value, &all_output_assets).await?;
+ get_asset_paths_from_root(&node_root_value, all_output_assets).await?;
let entry_file = rcstr!("app-edge-has-no-entrypoint");
@@ -1595,7 +1601,7 @@ impl AppEndpoint {
server_assets.extend(loadable_manifest_output.iter().copied());
file_paths_from_root.extend(
- get_js_paths_from_root(&node_root_value, &loadable_manifest_output).await?,
+ get_js_paths_from_root(&node_root_value, loadable_manifest_output).await?,
);
}
if emit_manifests != EmitManifests::None {
diff --git a/crates/next-api/src/font.rs b/crates/next-api/src/font.rs
index d814f7e437bf..e6a81c86e15b 100644
--- a/crates/next-api/src/font.rs
+++ b/crates/next-api/src/font.rs
@@ -59,7 +59,7 @@ impl Asset for FontManifest {
// `_next` gets added again later, so we "strip" it here via
// `get_font_paths_from_root`.
let font_paths: Vec<String> =
- get_font_paths_from_root(client_root, &all_client_output_assets)
+ get_font_paths_from_root(client_root, all_client_output_assets)
.await?
.iter()
.filter_map(|p| p.split("_next/").last().map(|f| f.to_string()))
diff --git a/crates/next-api/src/instrumentation.rs b/crates/next-api/src/instrumentation.rs
index c9c63a870a32..f7a27229d42c 100644
--- a/crates/next-api/src/instrumentation.rs
+++ b/crates/next-api/src/instrumentation.rs
@@ -148,7 +148,7 @@ impl InstrumentationEndpoint {
let node_root_value = node_root.clone();
let file_paths_from_root =
- get_js_paths_from_root(&node_root_value, &edge_chunk_group.await?.assets.await?)
+ get_js_paths_from_root(&node_root_value, edge_chunk_group.await?.assets.await?)
.await?;
let mut output_assets = edge_chunk_group.all_assets().owned().await?;
diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs
index e3a8d7a5f211..c36ea911ba4b 100644
--- a/crates/next-api/src/middleware.rs
+++ b/crates/next-api/src/middleware.rs
@@ -263,7 +263,7 @@ impl MiddlewareEndpoint {
let edge_assets = edge_chunk_group_ref.assets.await?;
let file_paths_from_root =
- get_js_paths_from_root(&node_root_value, &edge_assets).await?;
+ get_js_paths_from_root(&node_root_value, edge_assets.iter().copied()).await?;
let entrypoint_asset = *edge_assets
.last()
.context("expected assets for edge middleware endpoint")?;
@@ -278,7 +278,7 @@ impl MiddlewareEndpoint {
get_wasm_paths_from_root(&node_root_value, edge_all_assets.await?).await?;
let all_assets =
- get_asset_paths_from_root(&node_root_value, &edge_all_assets.await?).await?;
+ get_asset_paths_from_root(&node_root_value, edge_all_assets.await?).await?;
let regions = if let Some(regions) = config.preferred_region.as_ref() {
if regions.len() == 1 {
diff --git a/crates/next-api/src/module_graph.rs b/crates/next-api/src/module_graph.rs
index 1a4b8e10b2b9..a805ba0ccd31 100644
--- a/crates/next-api/src/module_graph.rs
+++ b/crates/next-api/src/module_graph.rs
@@ -107,7 +107,6 @@ impl NextDynamicGraphs {
.get_next_dynamic_imports_for_endpoint(entry)
.await?
.into_iter()
- .map(|(k, v)| (*k, *v))
// TODO remove this collect and return an iterator instead
.collect::<Vec<_>>())
})
diff --git a/crates/next-api/src/next_server_nft.rs b/crates/next-api/src/next_server_nft.rs
index dad21b243f25..46dd5e2c1a42 100644
--- a/crates/next-api/src/next_server_nft.rs
+++ b/crates/next-api/src/next_server_nft.rs
@@ -215,7 +215,7 @@ impl ServerNftJsonAsset {
// These are used by packages/next/src/server/require-hook.ts
let shared_entries = ["styled-jsx", "styled-jsx/style", "styled-jsx/style.js"];
- let cache_handler_entries = cache_handler.into_iter().chain(cache_handlers).map(|f| {
+ let cache_handler_entries = cache_handler.iter().chain(cache_handlers.iter()).map(|f| {
asset_context
.process(
Vc::upcast(FileSource::new(f.clone())),
diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs
index 1d5425ddd9e0..feb66e4ddfc7 100644
--- a/crates/next-api/src/pages.rs
+++ b/crates/next-api/src/pages.rs
@@ -1430,8 +1430,9 @@ impl PageEndpoint {
let pages_structure = this.pages_structure.await?;
if pages_structure.should_create_pages_entries {
server_assets.extend(assets_ref.iter().copied());
- file_paths_from_root
- .extend(get_js_paths_from_root(&node_root, &assets_ref).await?);
+ file_paths_from_root.extend(
+ get_js_paths_from_root(&node_root, assets_ref.iter().copied()).await?,
+ );
}
if emit_manifests == EmitManifests::Full {
@@ -1445,28 +1446,30 @@ impl PageEndpoint {
if pages_structure.should_create_pages_entries {
server_assets.extend(loadable_manifest_output.iter().copied());
file_paths_from_root.extend(
- get_js_paths_from_root(&node_root, &loadable_manifest_output)
+ get_js_paths_from_root(&node_root, loadable_manifest_output)
.await?,
);
}
}
- let (wasm_paths_from_root, all_assets) =
- if pages_structure.should_create_pages_entries {
- let all_output_assets = all_assets_from_entries(all_assets).await?;
+ let (wasm_paths_from_root, all_assets) = if pages_structure
+ .should_create_pages_entries
+ {
+ let all_output_assets = all_assets_from_entries(all_assets).await?;
- let mut wasm_paths_from_root = fxindexset![];
- wasm_paths_from_root.extend(
- get_wasm_paths_from_root(&node_root, &all_output_assets).await?,
- );
+ let mut wasm_paths_from_root = fxindexset![];
+ wasm_paths_from_root.extend(
+ get_wasm_paths_from_root(&node_root, all_output_assets.iter().copied())
+ .await?,
+ );
- let all_assets =
- get_asset_paths_from_root(&node_root, &all_output_assets).await?;
+ let all_assets =
+ get_asset_paths_from_root(&node_root, all_output_assets).await?;
- (wasm_paths_from_root, all_assets)
- } else {
- (fxindexset![], vec![])
- };
+ (wasm_paths_from_root, all_assets)
+ } else {
+ (fxindexset![], vec![])
+ };
let named_regex = get_named_middleware_regex(&this.pathname).into();
let matchers = ProxyMatcher {
diff --git a/crates/next-api/src/paths.rs b/crates/next-api/src/paths.rs
index d07b9ebb2012..3898419e8425 100644
--- a/crates/next-api/src/paths.rs
+++ b/crates/next-api/src/paths.rs
@@ -96,7 +96,7 @@ pub async fn all_paths_in_root(
assets: Vc<OutputAssets>,
root: FileSystemPath,
) -> Result<Vc<Vec<RcStr>>> {
- let all_assets = &*all_assets_from_entries(assets).await?;
+ let all_assets = all_assets_from_entries(assets).await?;
Ok(Vc::cell(
get_paths_from_root(&root, all_assets, |_| true).await?,
@@ -105,12 +105,12 @@ pub async fn all_paths_in_root(
pub(crate) async fn get_paths_from_root(
root: &FileSystemPath,
- output_assets: impl IntoIterator<Item = &ResolvedVc<Box<dyn OutputAsset>>>,
+ output_assets: impl IntoIterator<Item = ResolvedVc<Box<dyn OutputAsset>>>,
filter: impl FnOnce(&str) -> bool + Copy,
) -> Result<Vec<RcStr>> {
output_assets
.into_iter()
- .map(move |&file| async move {
+ .map(move |file| async move {
let path = &*file.path().await?;
let Some(relative) = root.get_path_to(path) else {
return Ok(None);
@@ -128,18 +128,18 @@ pub(crate) async fn get_paths_from_root(
pub(crate) async fn get_js_paths_from_root(
root: &FileSystemPath,
- output_assets: impl IntoIterator<Item = &ResolvedVc<Box<dyn OutputAsset>>>,
+ output_assets: impl IntoIterator<Item = ResolvedVc<Box<dyn OutputAsset>>>,
) -> Result<Vec<RcStr>> {
get_paths_from_root(root, output_assets, |path| path.ends_with(".js")).await
}
pub(crate) async fn get_wasm_paths_from_root(
root: &FileSystemPath,
- output_assets: impl IntoIterator<Item = &ResolvedVc<Box<dyn OutputAsset>>>,
+ output_assets: impl IntoIterator<Item = ResolvedVc<Box<dyn OutputAsset>>>,
) -> Result<Vec<(RcStr, ResolvedVc<Box<dyn OutputAsset>>)>> {
output_assets
.into_iter()
- .map(move |&file| async move {
+ .map(move |file| async move {
let path = &*file.path().await?;
let Some(relative) = root.get_path_to(path) else {
return Ok(None);
@@ -157,7 +157,7 @@ pub(crate) async fn get_wasm_paths_from_root(
pub(crate) async fn get_asset_paths_from_root(
root: &FileSystemPath,
- output_assets: impl IntoIterator<Item = &ResolvedVc<Box<dyn OutputAsset>>>,
+ output_assets: impl IntoIterator<Item = ResolvedVc<Box<dyn OutputAsset>>>,
) -> Result<Vec<RcStr>> {
get_paths_from_root(root, output_assets, |path| {
!path.ends_with(".js") && !path.ends_with(".map") && !path.ends_with(".wasm")
@@ -167,7 +167,7 @@ pub(crate) async fn get_asset_paths_from_root(
pub(crate) async fn get_font_paths_from_root(
root: &FileSystemPath,
- output_assets: impl IntoIterator<Item = &ResolvedVc<Box<dyn OutputAss
... [truncated]