Skip to content

install: stop reading dangling dependency pointer in enqueueDependencyToRoot#29483

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/90e5a7e6/install-enqueue-dangling-dep
Apr 22, 2026
Merged

install: stop reading dangling dependency pointer in enqueueDependencyToRoot#29483
Jarred-Sumner merged 5 commits into
mainfrom
farm/90e5a7e6/install-enqueue-dangling-dep

Conversation

@robobun

@robobun robobun commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator

Fuzzilli found a use-after-poison in the runtime auto-install path.

enqueueDependencyToRoot passed &lockfile.buffers.dependencies.items[dep_id] into enqueueDependencyWithMainAndSuccessFn. When the manifest for the requested package is already cached (on disk or in memory) but the extracted tarball is not, control reaches getOrPutResolvedPackageWithFindResult, which calls Lockfile.Package.fromNPM. That grows buffers.dependencies via ensureUnusedCapacity to make room for the package's own dependencies, reallocating the backing storage. The subsequent .extract branch then read dependency.behavior.isRequired() from the freed buffer.

#0 getOrPutResolvedPackageWithFindResult   PackageManagerEnqueue.zig:1520  dependency.behavior.isRequired()
#1 getOrPutResolvedPackage                 PackageManagerEnqueue.zig:1778
#2 enqueueDependencyWithMainAndSuccessFn   PackageManagerEnqueue.zig:523
#3 enqueueDependencyToRoot                 PackageManagerEnqueue.zig:321
#4 Resolver.enqueueDependencyToResolve     resolver.zig:2356
...
#14 Bun__resolveSync
#15 functionImportMeta__resolveSyncPrivate  (runtime require() path)

Two changes:

  • enqueueDependencyToRoot now copies the Dependency to the stack before taking its address, matching every other caller of enqueueDependencyWithMainAndSuccessFn (processDependencyListItem, processPeerDependencyList, etc.).
  • The one read that ran after fromNPM now uses the behavior parameter that was already passed by value, instead of re-dereferencing dependency.

Repro (debug/ASAN only): auto-install a package with a warm on-disk manifest but no extracted tarball — fromNPM appending even a single dependency forces a realloc of the one-entry buffer. The new test warms the cache, removes the extracted tarballs, and runs require() via -e so it goes through Bun__resolveSyncenqueueDependencyToRoot.

…yToRoot

enqueueDependencyToRoot passed &lockfile.buffers.dependencies.items[dep_id]
into enqueueDependencyWithMainAndSuccessFn. When the manifest for that
package is already cached (on disk or in memory) but the tarball is not,
getOrPutResolvedPackageWithFindResult calls Lockfile.Package.fromNPM which
grows buffers.dependencies via ensureUnusedCapacity, reallocating the
backing storage. The subsequent .extract branch then read
dependency.behavior from the freed buffer (ASAN: use-after-poison).

All other callers already copy the Dependency to the stack before passing
its address; do the same here. Also switch the one post-resize read to use
the behavior parameter that was already copied by value.
@robobun

robobun commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:33 PM PT - Apr 19th, 2026

@robobun, your commit a7d5b22 has 1 failures in Build #46527 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29483

That installs a local version of the PR into your bun-29483 executable, so you can run:

bun-29483 --bun

@coderabbitai

coderabbitai Bot commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Copied dependency values to local stack variables before passing their addresses into enqueue routines to prevent dangling-pointer/read-after-resize issues; fixed a behavior argument use; added a regression test reproducing auto-install with a cached manifest but missing extracted tarball artifacts.

Changes

Cohort / File(s) Summary
PackageManager enqueue safety
src/install/PackageManager/PackageManagerEnqueue.zig
Copy each Dependency into a local stack variable before calling enqueueDependencyWithMainAndSuccessFn, avoiding passing pointers into lockfile.buffers.dependencies.items[...]; switch generateNetworkTaskForTarball requiredness from dependency.behavior.isRequired() to the behavior.isRequired() function parameter.
Install manager enqueue loops
src/install/PackageManager/install_with_manager.zig
Converted pointer-based iteration to index-based loops using a snapshot length; copy each dependency into a local before passing &dependency to enqueueDependencyWithMain/addDependencyError to prevent pointer invalidation if buffers.dependencies grows.
Regression test added
test/cli/run/autoinstall-cached-manifest.test.ts
Add test that runs bun --install=force twice with BUN_INSTALL_CACHE_DIR warmed, removes extracted tarball artifacts but keeps cached .npm manifests between runs, and asserts the second run succeeds (verifies no dangling dependency pointer when re-downloading/extracting).
Docs formatting
docs/runtime/bunfig.mdx
Adjusted table column spacing/formatting for install.prefer valid-values table; no semantic or content changes.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a dangling pointer read in enqueueDependencyToRoot, which is the core issue addressed across multiple files.
Description check ✅ Passed The description exceeds the template requirements, providing detailed context about the Fuzzilli finding, the root cause, the fix rationale, and repro steps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. auto-install crashes when an error is thrown #14432 - Stack trace goes directly through enqueueDependencyToRoot in the auto-install path with segfault address 0xAAAAAAAAAAAAAAAA (use-after-poison marker), matching the exact bug this PR fixes
  2. Bun segmentation fault #22407 - Stack trace crashes in PackageManagerEnqueue.zig:345 through the auto-install resolver path, consistent with the dangling pointer bug this PR fixes

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #14432
Fixes #22407

🤖 Generated with Claude Code

Comment thread src/install/PackageManager/PackageManagerEnqueue.zig
robobun added 2 commits April 19, 2026 11:38
Same hazard as enqueueDependencyToRoot: the overrides_changed and
catalogs_changed loops in install_with_manager.zig captured |*dependency|
directly from buffers.dependencies.items and passed it to
enqueueDependencyWithMain, which can call Lockfile.Package.fromNPM and
reallocate that buffer. Both the captured pointer and the for-loop's own
slice would then be dangling.

Iterate by index against a snapshot of the original length and copy each
entry to the stack, matching the add/update loop immediately below and
every other caller.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/cli/run/autoinstall-cached-manifest.test.ts`:
- Around line 50-55: The test currently removes non-manifest files from cacheDir
without asserting any were present; update the test around the entries variable
so it first filters non-manifest entries (entries that do NOT endWith(".npm")),
assert that there is at least one non-manifest entry to remove
(expect(nonManifestEntries.length).toBeGreaterThan(0)), then perform the rmSync
loop on those non-manifest entries and optionally assert that the remaining
directory contains only .npm manifests; refer to the entries array and the
cacheDir variable in your changes.
- Around line 30-31: The test is swallowing require() failures (the script
`const script = ...` catches errors and still prints "ok"), which yields false
positives; update the `script` strings (the `script` variable occurrences around
the failing cases) so that a failed require either is not caught or the catch
rethrows/logs and exits non‑zero (e.g., rethrow the error or call
process.exit(1) in the catch) so that the test fails when auto-install did not
succeed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d98a7150-6686-4e6f-b2cc-3c45eab217a9

📥 Commits

Reviewing files that changed from the base of the PR and between 8a3d0c7 and ca80b48.

📒 Files selected for processing (1)
  • test/cli/run/autoinstall-cached-manifest.test.ts

Comment on lines +30 to +31
const script = `try { require("is-even"); } catch {} console.log("ok");`;

@coderabbitai coderabbitai Bot Apr 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t swallow require() failures; this can produce false positives.

The current script prints "ok" even if require("is-even") fails, so the test can pass without validating that auto-install actually succeeded.

Suggested fix
-  const script = `try { require("is-even"); } catch {} console.log("ok");`;
+  const script = `require("is-even"); console.log("ok");`;

Also applies to: 44-45, 69-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/cli/run/autoinstall-cached-manifest.test.ts` around lines 30 - 31, The
test is swallowing require() failures (the script `const script = ...` catches
errors and still prints "ok"), which yields false positives; update the `script`
strings (the `script` variable occurrences around the failing cases) so that a
failed require either is not caught or the catch rethrows/logs and exits
non‑zero (e.g., rethrow the error or call process.exit(1) in the catch) so that
the test fails when auto-install did not succeed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the try/catch intentionally. This test is about whether the process crashes during auto-install resolution (use-after-poison → SIGABRT), not whether is-even loads — the crash happens inside the resolver before the module is evaluated, so a bare require that throws for an unrelated reason would just add flakiness.

The false-positive concern is addressed instead by d415c6e, which asserts the first run actually produced both manifests and extracted tarballs in the cache. If auto-install didn't reach the registry, the test now fails at that assertion rather than silently skipping the interesting path.

Comment thread test/cli/run/autoinstall-cached-manifest.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the overrides/catalogs loop fixes from my prior review are in place.

Extended reasoning...

Overview

The PR fixes a use-after-poison in (PackageManagerEnqueue.zig) and a second set of equivalent UAF bugs in the overrides_changed and catalogs_changed loops in install_with_manager.zig. All three fixes apply the same stack-copy pattern already used everywhere else in the code: copy the value to the stack before taking its address and passing it into . A regression test covers the original crash path (warm manifest cache, missing tarball, runtime auto-install).

Security risks

No security-sensitive surfaces are touched. The bugs fixed are memory-safety issues (use-after-poison), not authentication or permission concerns.

Level of scrutiny

This is a targeted, minimal bug fix in the package manager install path. Each change is mechanical (for-loop pointer capture → index-based loop with stack copy, or single parameter substitution), and the pattern is already established by the surrounding code. The fix is low risk relative to leaving the UAF in place.

Other factors

My previous review (at 10:56 UTC) identified the additional bugs in install_with_manager.zig. The author addressed them in commit 8a3d0c7. The only open item from the bug tracker is a pre-existing memory leak with no user-visible impact (CLI process; OS reclaims at exit). All substantive concerns have been resolved.

Comment on lines 368 to 386
if (manager.summary.overrides_changed and all_name_hashes.len > 0) {
for (manager.lockfile.buffers.dependencies.items, 0..) |*dependency, dependency_i| {
const dependencies_len = manager.lockfile.buffers.dependencies.items.len;
var dependency_i: usize = 0;
while (dependency_i < dependencies_len) : (dependency_i += 1) {
const dependency = manager.lockfile.buffers.dependencies.items[dependency_i];
if (std.mem.indexOfScalar(PackageNameHash, all_name_hashes, dependency.name_hash)) |_| {
manager.lockfile.buffers.resolutions.items[dependency_i] = invalid_package_id;
manager.enqueueDependencyWithMain(
@truncate(dependency_i),
dependency,
&dependency,
invalid_package_id,
false,
) catch |err| {
addDependencyError(manager, dependency, err);
addDependencyError(manager, &dependency, err);
};
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing memory leak: all_name_hashes is allocated with bun.default_allocator.alloc at line 242 of install_with_manager.zig when overrides_changed is true and hashes_len > 0, but there is no corresponding defer bun.default_allocator.free(all_name_hashes) anywhere in the function. The fix is to add a deferred free immediately after the allocation block, guarded on all_name_hashes.len > 0.

Extended reasoning...

What the bug is and how it manifests

The all_name_hashes slice is conditionally allocated via bun.default_allocator.alloc(PackageNameHash, hashes_len) inside the brk block around line 242 of install_with_manager.zig, when both manager.summary.overrides_changed is true and hashes_len > 0. The two early-exit paths (overrides_changed == false and hashes_len == 0) return &.{} (empty slices that require no free), but the actual allocation path has no corresponding defer bun.default_allocator.free(all_name_hashes). Grep confirms no free of this variable exists anywhere in the file.

The specific code path that triggers it

  1. bun install runs with changed overrides (e.g., adding or removing an override in package.json).
  2. manager.summary.overrides_changed is true; hashes_len is non-zero.
  3. bun.default_allocator.alloc(PackageNameHash, hashes_len) succeeds and allocates a small heap buffer.
  4. The buffer is used by the new overrides_changed loop (lines 368-384) added by this PR.
  5. installWithManager returns; the buffer is leaked — no free is ever called.

Why existing code does not prevent it

The three early-return arms inside the brk block all produce &.{}, which correctly requires no cleanup. However, the successful allocation arm does not register a defer. The PR touched this exact code to fix the dangling-pointer bug in the loops below, but did not add the missing cleanup.

What the impact would be

For a short-lived CLI process like bun install, the OS reclaims all memory at exit, so there is zero user-visible effect in practice. However, under leak-detection builds (ASAN, Valgrind, or custom allocator tracking), the missing free will surface as a definite leak. The allocation is proportional to the number of override entries (a few u64 values), so it is always small.

How to fix it

Add a deferred free immediately after the allocation block, guarded to only run when actual allocation occurred:

This mirrors the standard Zig idiom and is consistent with the CLAUDE.md guidance to use defer for allocator cleanup.

Step-by-step proof

  1. User runs bun install after adding an override to package.json.
  2. overrides_changed = true, hashes_len = 2 (one old + one new override key).
  3. bun.default_allocator.alloc(PackageNameHash, 2) returns a 16-byte heap buffer at address A.
  4. The overrides_changed loop at lines 368-384 reads from address A.
  5. installWithManager returns normally; no free is called for address A.
  6. Under ASAN, the allocator reports a 16-byte leak from this allocation site. Under normal builds, the OS reclaims it at process exit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted — this one's pre-existing and unrelated to the dangling-pointer fix (the allocation was already there before this PR touched the loops below it). Leaving it out to keep the diff focused on the UAF; it's a few u64s in a short-lived CLI process so there's no user-visible impact.

Ensures the test actually exercises the cached-manifest + missing-tarball
path instead of silently becoming a no-op if the first auto-install run
did not reach the registry.
@coderabbitai

coderabbitai Bot commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

No merge conflicts detected when merging into main.

Your branch is good to go!

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the author addressed the pointer-safety issue I flagged in the overrides/catalogs loops; the remaining test nits are minor and already called out by other reviewers.

Extended reasoning...

Overview

The PR fixes a use-after-poison in the runtime auto-install path: enqueueDependencyToRoot was passing a raw interior pointer into lockfile.buffers.dependencies.items to enqueueDependencyWithMainAndSuccessFn, which could call Lockfile.Package.fromNPM, reallocating the backing storage and making the pointer dangling. Two changes: copy the Dependency to the stack before passing its address in enqueueDependencyToRoot, and use the already-passed behavior parameter instead of re-dereferencing dependency in the .extract branch.

A follow-up commit (8a3d0c7) applied the same stack-copy fix to the overrides_changed and catalogs_changed loops in install_with_manager.zig, which had the same vulnerability. Both commits are now on main.

Security risks

No security-sensitive code paths (auth, crypto, permissions). The bug was a memory-safety issue (UAF) triggered through the package install path. The fix is safe and mechanical.

Level of scrutiny

The core logic change is small and follows an established pattern already used by every other caller of enqueueDependencyWithMainAndSuccessFn. The test is a new regression file that exercises the specific scenario (warm manifest cache, missing tarball). Scrutiny is appropriate for a focused memory-safety fix with clear intent.

Other factors

The remaining open items (test try/catch swallowing require() failures, missing assertion that tarballs were actually removed, pre-existing all_name_hashes leak) are nits already flagged by CodeRabbit and the bug hunter. None affect the correctness of the core fix. The memory leak is pre-existing and inconsequential in a short-lived CLI process.

Comment thread test/cli/run/autoinstall-cached-manifest.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all prior concerns addressed; the stack-copy fixes are correct and consistent with existing patterns throughout the codebase.

Extended reasoning...

Overview

The PR fixes a use-after-poison crash (Fuzzilli-found) in three places: enqueueDependencyToRoot in PackageManagerEnqueue.zig, and the overrides_changed/catalogs_changed loops in install_with_manager.zig. All three now copy the Dependency value to the stack before passing its address to enqueueDependencyWithMainAndSuccessFn/enqueueDependencyWithMain, which can call Lockfile.Package.fromNPM and reallocate buffers.dependencies. A secondary fix uses the behavior parameter (already passed by value) instead of re-dereferencing the potentially-stale dependency pointer in the .extract branch.

Security Risks

No security-sensitive code (auth, crypto, permissions) is touched. The bug itself was a memory-safety issue (use-after-poison → crash) with no exploitation surface.

Level of Scrutiny

The changes are mechanical stack-copy fixes that match the pattern used by every other caller in the same file (processDependencyListItem, the add/update loop at line 407, etc.). The diff is small, focused, and self-evidently correct.

Other Factors

All reviewer feedback from prior rounds has been addressed: the two additional UAF loops in install_with_manager.zig were fixed in response to my earlier inline comments; the test now asserts both manifests and extracted tarballs are present before pruning; the intentional try/catch is justified because the regression manifests as a native SIGABRT (not a JS exception). The pre-existing all_name_hashes memory leak is out of scope and has no user-visible impact in a short-lived CLI process.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all UAF fixes are correct, the stack-copy pattern matches existing callers, and all prior feedback has been addressed.

Extended reasoning...

Overview

The PR fixes a use-after-poison in the runtime auto-install path. Three targeted changes: (1) enqueueDependencyToRoot now copies the Dependency to the stack before passing its address; (2) a subsequent read in getOrPutResolvedPackageWithFindResult now uses the already-passed-by-value behavior parameter instead of re-dereferencing dependency after a potential realloc; (3) the overrides/catalogs loops in install_with_manager.zig now iterate by index against a snapshot length and copy each entry to the stack. A regression test was added that warms the cache, prunes extracted tarballs, and asserts the second run succeeds.

Security risks

No security implications. The change eliminates UB/memory-safety issues rather than introducing new ones. No auth, crypto, or permission-sensitive paths are touched.

Level of scrutiny

Medium. This touches the package-manager install hot path, but the changes are mechanical and minimal -- identical stack-copy patterns are already used throughout the same file by processDependencyListItem, processPeerDependencyList, and the add/update loop. The fix is consistent with the code's own existing defensive comments.

Other factors

All prior reviewer concerns were addressed: the overrides/catalogs loop fix landed in a follow-up commit, the test now asserts both manifests and extracted tarballs are present before pruning, and the intentional try/catch was explained clearly (ASAN crashes produce non-zero exit regardless of JS exception handling). No bugs found by the automated system.

@robobun robobun force-pushed the farm/90e5a7e6/install-enqueue-dangling-dep branch from 9f36465 to d415c6e Compare April 19, 2026 14:23
@robobun

robobun commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped the merge of main (9f364653 → back to d415c6e). The merge pulled in test/js/bun/memfd-disabled.test.ts from 91d066b which is currently failing on every Linux shard, plus the Windows binary-size regression from the glibc-2.17 / workaround-missing-symbols changes — none of that is related to this PR.

At d415c6e (build 46478) all Linux test jobs including debian-13-x64-asan-test-bun passed. The remaining failures there are:

My test (autoinstall-cached-manifest.test.ts) passed on every platform it ran on, including the ASAN job that exercises the fix.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all previously flagged issues have been addressed.

Extended reasoning...

Overview

This PR fixes a use-after-poison in the runtime auto-install path. Three changes: (1) now copies the to the stack before passing its address; (2) the branch uses the parameter instead of re-dereferencing after may have reallocated the buffer; (3) both the and loops in now iterate by index against a snapshot length and copy each entry to the stack — matching the safe pattern already used by the add/update loop below them.

Security risks

No new security surface introduced. This is a memory-safety fix that eliminates a use-after-free (use-after-poison in ASAN builds) reachable via the runtime require() auto-install path. The fix reduces attack surface rather than expanding it.

Level of scrutiny

The changes are small and mechanical: copy-to-stack before taking address, and use a value parameter instead of re-dereferencing a potentially-stale pointer. The pattern is already established throughout the codebase. The regression test exercises the specific code path (warm manifest + missing tarball → reallocation) that triggered the original crash.

Other factors

All three rounds of review feedback have been addressed: the overrides/catalogs loop UAF was fixed, the test now asserts both manifests and extracted tarballs exist before pruning, and the try/catch is intentionally kept since a native SIGABRT cannot be suppressed by JS exception handling. The pre-existing memory leak was acknowledged and reasonably deferred (pre-existing, tiny allocation in a short-lived CLI process).

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: for (0...dependencies_len) |dep_id| is better

@Jarred-Sumner Jarred-Sumner merged commit 7a4e667 into main Apr 22, 2026
63 of 65 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/90e5a7e6/install-enqueue-dangling-dep branch April 22, 2026 01:22
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
…yToRoot (oven-sh#29483)

Fuzzilli found a use-after-poison in the runtime auto-install path.

`enqueueDependencyToRoot` passed
`&lockfile.buffers.dependencies.items[dep_id]` into
`enqueueDependencyWithMainAndSuccessFn`. When the manifest for the
requested package is already cached (on disk or in memory) but the
extracted tarball is not, control reaches
`getOrPutResolvedPackageWithFindResult`, which calls
`Lockfile.Package.fromNPM`. That grows `buffers.dependencies` via
`ensureUnusedCapacity` to make room for the package's own dependencies,
reallocating the backing storage. The subsequent `.extract` branch then
read `dependency.behavior.isRequired()` from the freed buffer.

```
#0 getOrPutResolvedPackageWithFindResult   PackageManagerEnqueue.zig:1520  dependency.behavior.isRequired()
#1 getOrPutResolvedPackage                 PackageManagerEnqueue.zig:1778
#2 enqueueDependencyWithMainAndSuccessFn   PackageManagerEnqueue.zig:523
#3 enqueueDependencyToRoot                 PackageManagerEnqueue.zig:321
#4 Resolver.enqueueDependencyToResolve     resolver.zig:2356
...
oven-sh#14 Bun__resolveSync
oven-sh#15 functionImportMeta__resolveSyncPrivate  (runtime require() path)
```

Two changes:

- `enqueueDependencyToRoot` now copies the `Dependency` to the stack
before taking its address, matching every other caller of
`enqueueDependencyWithMainAndSuccessFn` (`processDependencyListItem`,
`processPeerDependencyList`, etc.).
- The one read that ran after `fromNPM` now uses the `behavior`
parameter that was already passed by value, instead of re-dereferencing
`dependency`.

Repro (debug/ASAN only): auto-install a package with a warm on-disk
manifest but no extracted tarball — `fromNPM` appending even a single
dependency forces a realloc of the one-entry buffer. The new test warms
the cache, removes the extracted tarballs, and runs `require()` via `-e`
so it goes through `Bun__resolveSync` → `enqueueDependencyToRoot`.
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…yToRoot (oven-sh#29483)

Fuzzilli found a use-after-poison in the runtime auto-install path.

`enqueueDependencyToRoot` passed
`&lockfile.buffers.dependencies.items[dep_id]` into
`enqueueDependencyWithMainAndSuccessFn`. When the manifest for the
requested package is already cached (on disk or in memory) but the
extracted tarball is not, control reaches
`getOrPutResolvedPackageWithFindResult`, which calls
`Lockfile.Package.fromNPM`. That grows `buffers.dependencies` via
`ensureUnusedCapacity` to make room for the package's own dependencies,
reallocating the backing storage. The subsequent `.extract` branch then
read `dependency.behavior.isRequired()` from the freed buffer.

```
#0 getOrPutResolvedPackageWithFindResult   PackageManagerEnqueue.zig:1520  dependency.behavior.isRequired()
oven-sh#1 getOrPutResolvedPackage                 PackageManagerEnqueue.zig:1778
oven-sh#2 enqueueDependencyWithMainAndSuccessFn   PackageManagerEnqueue.zig:523
oven-sh#3 enqueueDependencyToRoot                 PackageManagerEnqueue.zig:321
oven-sh#4 Resolver.enqueueDependencyToResolve     resolver.zig:2356
...
oven-sh#14 Bun__resolveSync
oven-sh#15 functionImportMeta__resolveSyncPrivate  (runtime require() path)
```

Two changes:

- `enqueueDependencyToRoot` now copies the `Dependency` to the stack
before taking its address, matching every other caller of
`enqueueDependencyWithMainAndSuccessFn` (`processDependencyListItem`,
`processPeerDependencyList`, etc.).
- The one read that ran after `fromNPM` now uses the `behavior`
parameter that was already passed by value, instead of re-dereferencing
`dependency`.

Repro (debug/ASAN only): auto-install a package with a warm on-disk
manifest but no extracted tarball — `fromNPM` appending even a single
dependency forces a realloc of the one-entry buffer. The new test warms
the cache, removes the extracted tarballs, and runs `require()` via `-e`
so it goes through `Bun__resolveSync` → `enqueueDependencyToRoot`.
ericsssan added a commit to ericsssan/zbc that referenced this pull request May 27, 2026
Detects `const X = &list.items[i]; list.append(...); use(X.*)` —
taking a pointer to a specific element in an ArrayList's backing
storage, then growing the list (which may reallocate), then using
the now-dangling pointer.

Companion to `arraylist-items-slice` (full-slice borrow) and
`hashmap-getptr-rehash`. Evidence: oven-sh/bun#29483, where
`&lockfile.buffers.dependencies.items[dep_id]` was passed to a
function that internally called `fromNPM` → `ensureUnusedCapacity`,
reallocating the buffer (ASAN: use-after-poison).

Includes 5 unit tests covering the TP shape, pre-grow use (safe),
different-receiver (safe), insert variant, and AssumeCapacity (safe).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants