Skip to content

fix(fspy): keep the main executable at dyld image index 0 on macOS - #840

Merged
wan9chi merged 2 commits into
mainfrom
claude/youthful-hawking-e9861d
Oct 12, 2026
Merged

wan9chi merged 2 commits into
mainfrom
claude/youthful-hawking-e9861d

Conversation

@wan9chi

@wan9chi wan9chi commented Oct 12, 2026

Copy link
Copy Markdown
Member

Motivation

On macOS, running pnpm 11's standalone pnpm binary as a task with automatic file-access tracking aborts with exit 134:

#  Node.js[...]: SeaResource node::sea::(anonymous namespace)::SeaDeserializer::Read() at ../src/node_sea.cc:164
#  Assertion failed: (magic) == (kMagic)

The same command works with cache: false or when run directly. deno compile outputs fail the same way (Did not find magic bytes). #571 hit this on emdash and recorded it as a known, unfixed bug.

Root cause

Any inserted library triggers it, not fspy's interceptors: DYLD_INSERT_LIBRARIES=empty.dylib pnpm --version aborts too. Since dyld4 (macOS 12), dyld lists inserted libraries ahead of the main executable (RuntimeState::loadInsertedLibraries, "for correct flat namespace lookups"). Image index 0 is then fspy's preload, not the main executable.

Node finds its single executable application (SEA) blob with postject, whose lookup assumes index 0 is the main executable:

ptr = getsectdata(segment_name, name, &section_size);
ptr += _dyld_get_image_vmaddr_slide(0);

Under injection this adds the preload's ASLR slide instead of the executable's, so Node reads the wrong memory. Deno's libsui copies the same pattern. Bun's compiled binaries locate their payload another way and are unaffected. Linux (postject reads the first dl_iterate_phdr entry, which is always the main program) and Windows (FindResourceA(NULL, ...)) are unaffected.

Changes

  • The macOS preload interposes the three index-based dyld APIs (_dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_get_image_name). It presents the main executable at index 0, followed by the inserted libraries in their DYLD_INSERT_LIBRARIES order. Everything after the main executable keeps its index, so _dyld_image_count and iteration are unchanged.
  • This is the same permutation dyld applies itself, but only for x86_64 apps targeting macOS before 12 (normalizeImageIndex). Where dyld already applies it, the main executable is at index 0 and the interposers map every index to itself.
  • libc deprecates its dyld bindings in favor of mach2, so the three functions and the leading mach_header fields are declared in the preload's libc.rs, as it already does for scandir and friends.
  • New fspy test node_sea: builds a Node SEA from the current node with postject, then runs it under tracking on every platform and checks that its file read is recorded. It's ignored by default like the other Node tests. postject is added to packages/tools.

Notes for reviewers

  • The main executable's index is looked up on every call rather than cached. It's fixed in current dyld (inserted libraries are never unloaded, later images are appended), but that isn't documented.
  • The macOS-x64 job (ci: full) runs x64 Node under Rosetta, where dyld's own remapping already applies. The new test passes there with or without the fix. The macOS arm64 job is where it guards the regression.

🤖 Generated with Claude Code

wan9chi added a commit that referenced this pull request Oct 12, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wan9chi and others added 2 commits October 12, 2026 14:42
dyld lists DYLD_INSERT_LIBRARIES images ahead of the main executable, so
injecting fspy's preload moves the main executable off image index 0.
Node.js single executable applications (postject's
`getsectdata(..) + _dyld_get_image_vmaddr_slide(0)`) and `deno compile`
outputs (denoland/sui, same pattern) then add the preload's slide to their
payload address and fail: pnpm 11's standalone binary aborts with
`Assertion failed: (magic) == (kMagic)`, and Deno reports
`Did not find magic bytes`.

Interpose `_dyld_get_image_header`, `_dyld_get_image_vmaddr_slide` and
`_dyld_get_image_name` to present the main executable at index 0 followed
by the inserted libraries, the same permutation dyld's own
`normalizeImageIndex` applies to old x86_64 apps. Add an fspy test that
builds a Node SEA with postject and runs it under tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wan9chi
wan9chi force-pushed the claude/youthful-hawking-e9861d branch from d9d71d4 to c4f0ac9 Compare October 12, 2026 06:43
@wan9chi wan9chi added the ci: full Run the slow CI jobs (macOS x64, musl) on this pull request label Oct 12, 2026
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​postject@​1.0.0-alpha.610010010084100

View full report

@github-actions

Copy link
Copy Markdown

fspy benchmark

linux

dynamic/launch             change  +0.20%  [-13.68% .. +16.56%]  overhead   +55.19%
dynamic/access             change  -0.27%  [-15.85% .. +17.79%]  overhead    +9.06%
dynamic/access-relative    change  -1.72%  [-23.03% ..  +6.48%]  overhead   +49.00%
dynamic/access-contended   change  +3.39%  [-10.66% .. +16.96%]  overhead   +35.67%
static/launch              change  +0.96%  [ -7.95% .. +10.36%]  overhead  +157.54%
static/access              change  -3.52%  [-10.77% ..  +5.97%]  overhead  +857.37%
static/access-relative     change  +0.02%  [ -4.45% ..  +4.81%]  overhead +1314.67%
static/access-contended    change  -1.38%  [ -7.99% ..  +3.27%]  overhead +2112.80%

macos

dynamic/launch             change  +0.07%  [ -4.94% ..  +5.62%]  overhead  +221.82%
dynamic/access             change  -1.29%  [-14.24% .. +16.84%]  overhead    +4.10%
dynamic/access-relative    change  -4.15%  [-11.95% ..  +5.62%]  overhead  +228.36%
dynamic/access-contended   change  +0.75%  [ -7.94% .. +30.23%]  overhead    +8.09%

windows

dynamic/launch             change  -0.08%  [ -1.89% ..  +1.06%]  overhead   +25.40%
dynamic/access             change  +0.20%  [ -0.77% ..  +1.15%]  overhead    +0.80%
dynamic/access-relative    change  +0.19%  [ -0.38% ..  +0.93%]  overhead    +1.33%
dynamic/access-contended   change  +0.18%  [ -0.55% ..  +0.74%]  overhead    +1.12%

@wan9chi
wan9chi merged commit 2d71804 into main Oct 12, 2026
21 of 31 checks passed
@wan9chi
wan9chi deleted the claude/youthful-hawking-e9861d branch October 12, 2026 06:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci: full Run the slow CI jobs (macOS x64, musl) on this pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant