Skip to content

install: don't issue openat2/fchmodat2 on Android (seccomp SIGSYS) - #39084

Open
robobun wants to merge 3 commits into
mainfrom
farm/b7f0d27d/android-sigsys-openat2
Open

robobun wants to merge 3 commits into
mainfrom
farm/b7f0d27d/android-sigsys-openat2

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun install is killed by SIGSYS (exit 159, "Bad system call") on Android/Termux while linking package bins (Android LD_PRELOAD workaround for bun install SIGSYS crashes caused by openat2 and fchmodat2 #39060).
  • Android's app seccomp policy denies openat2(2) and fchmodat2(2) with SECCOMP_RET_TRAP: the process gets SIGSYS instead of an ENOSYS return, so the existing errno-based fallbacks never get a chance to run.
  • Call sites: openat2(RESOLVE_BENEATH) in bin-target validation (src/install/bin.rs:1416 via src/sys/lib.rs), raw syscall 452 in sys::lchmod (src/sys/lib.rs) when chmod'ing bin targets, and openat2(RESOLVE_IN_ROOT) used by directory routes.
  • The syscalls can't be probed safely either: the probe itself would be SIGSYS-killed.

Fix

  • Detect Android at runtime, once, in bun_sys (is_android_kernel): cfg!(target_os = "android"), an "android" kernel release string (uname -r, all GKI kernels carry it), or the ANDROID_ROOT/ANDROID_DATA env vars Android init sets for every process.
  • On Android, never issue the two syscalls and use the fallbacks that already exist for old kernels:
    • openat2_beneath fails with ENOSYS, so bin linking takes its existing realpath containment check (same security property, no openat2).
    • openat2_in_root goes straight to the existing plain-openat fallback.
    • lchmod uses an O_PATH + chmod("/proc/self/fd/N") emulation (musl's own fallback strategy). It cannot call libc's fchmodat(.., AT_SYMLINK_NOFOLLOW) instead: current glibc and musl implement that flag by trying fchmodat2(2) internally first, which would be SIGSYS-killed.
  • Verified with test/cli/install/bun-install-android-seccomp.test.ts: compiles a small helper that installs the same seccomp filter Android uses (SECCOMP_RET_TRAP for syscalls 437/452) and runs bun install of a local dependency with a bin entry under it, with ANDROID_ROOT/ANDROID_DATA set. Fails with SIGSYS before the fix, installs and chmods the bin after.
  • Also ran test/cli/install/bun-install-native-binlink.test.ts (bin containment checks), test/js/bun/http/serve-directory-routes.test.ts (openat2_in_root path), and test/js/node/fs/fs-stat-seccomp-linux.test.ts: all pass.

Background

  • seccomp is a Linux kernel facility that filters a process's syscalls with a BPF program. A filter decides per syscall: allow, return an errno, or SECCOMP_RET_TRAP, which delivers SIGSYS to the process (fatal by default). Android uses the trap action for syscalls outside its allowlist, so "syscall not available" looks like a crash, not an error return.
  • openat2(2) (Linux 5.6) is openat plus resolve flags: RESOLVE_BENEATH fails path resolution that escapes the starting directory (bun uses it to validate that a package's bin target stays inside the package), RESOLVE_IN_ROOT treats the starting directory as / (directory routes). Both uses already have fallbacks for kernels older than 5.6.
  • fchmodat2(2) (Linux 6.6) is fchmodat with a working AT_SYMLINK_NOFOLLOW, i.e. lchmod: chmod that refuses to follow a final-component symlink. The emulation opens the file with O_PATH|O_NOFOLLOW (no permissions needed, does not follow a final symlink) and chmods it through the /proc/self/fd/N magic link.
  • Bun ships target_os = "linux" binaries that Termux runs on the Android kernel, so the detection must happen at runtime; compile-time cfg gates can't catch this.
Reproduction details

Reproduced on plain Linux with a seccomp wrapper installing the equivalent filter (SECCOMP_RET_TRAP for nrs 437/452), matching the reporter's strace exactly:

$ sigsys-wrap bun add fast-deep-equal
bun add v1.4.0-canary.1
Bad system call (core dumped)
exit: 159

Blocking each syscall individually kills the install either way, so handling only one of them is not enough. A SIGSYS-handler probe (as used for close_range in #30769) was considered and rejected here: these syscalls are first issued from bin linking, where multiple threads run, and a temporarily-installed process-wide SIGSYS handler that siglongjmps is only safe while single-threaded.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install-android-seccomp.test.ts

Android's app seccomp policy denies openat2(2) and fchmodat2(2) with
SECCOMP_RET_TRAP, killing the process with SIGSYS instead of returning
ENOSYS, so the existing errno fallbacks never run and bun install dies
while linking package bins under Termux.

Detect Android at runtime (kernel release string or the
ANDROID_ROOT/ANDROID_DATA env vars Android init sets) and take the
existing fallbacks without issuing the syscalls: openat2_beneath fails
with ENOSYS so bin linking uses its realpath containment check,
openat2_in_root goes straight to plain openat, and lchmod uses an
O_PATH + /proc/self/fd chmod emulation (libc's fchmodat
AT_SYMLINK_NOFOLLOW can itself invoke fchmodat2 internally).

Fixes #39060
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 639c3051-0934-4903-b5bb-da43531b9899

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 6f5f119.

📒 Files selected for processing (3)
  • src/bun_core/env_var.rs
  • src/sys/lib.rs
  • test/cli/install/bun-install-android-seccomp.test.ts

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

@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.

I reviewed this PR and didn't find any bugs. Because it adds a runtime platform-detection heuristic that gates syscall availability in bun_sys and introduces a new lchmod emulation path, a human look would still be worthwhile.

What was reviewed:

  • is_android_kernel() caching/detection and its inputs (cached_uname, c_field_bytes, the new ANDROID_* env-var accessors) — helpers exist and signatures match.
  • openat2_beneath ENOSYS return traced to its only caller (src/install/bin.rs:1416), which already routes ENOSYS to the realpath-containment fallback.
  • lchmod_no_fchmodat2: fd closed on every exit path; /proc/self/fd/N buffer sizing (32 bytes vs. ≤25 needed); ZStr::from_buf NUL-at-len precondition satisfied by the trailing \0 in the format string.
Extended reasoning...

Overview

The PR touches three files: two new ANDROID_ROOT/ANDROID_DATA string env-var declarations in src/bun_core/env_var.rs, ~90 lines in src/sys/lib.rs (a cached is_android_kernel() runtime probe, guards on openat2_beneath/openat2_in_root/lchmod that skip openat2(2)/fchmodat2(2) when it returns true, and a new lchmod_no_fchmodat2 helper implementing musl's O_PATH + chmod("/proc/self/fd/N") fallback), and a new Linux-only test that installs an Android-equivalent seccomp filter and runs bun install under it.

Security risks

openat2_beneath is used by bin-target validation to enforce that a package's declared bin path resolves inside the package directory. On the Android path it now returns ENOSYS unconditionally, and the sole caller in src/install/bin.rs maps ENOSYS to None, which falls through to the pre-existing realpath containment check. That fallback already exists for pre-5.6 kernels, so no new containment gap is introduced — but a human should confirm they're comfortable with the env-var heuristic being able to force this weaker path on non-Android hosts (both ANDROID_ROOT and ANDROID_DATA must be non-empty, so accidental collision is unlikely). The lchmod emulation opens with O_PATH|O_NOFOLLOW and rejects symlinks with EOPNOTSUPP before chmod'ing, matching the documented libc semantics.

Level of scrutiny

Medium-high. This is the low-level syscall wrapper layer shared by install, directory routes, and node:fs, and the change is heuristic-driven (uname substring / env vars) rather than a compile-time gate. The mechanism, rationale, and rejected alternative (SIGSYS-handler probe unsafe under multi-threaded bin linking) are well-argued in the description, and the test genuinely reproduces the failure mode with a real seccomp filter. Still, this is not a mechanical change I'd want to land without a maintainer glancing at the detection heuristic and the /proc/self/fd fallback.

Other factors

The implementation follows house conventions (bun_core::strings, bun_core::env_var, ZStr::from_buf, bun_sys error tagging), closes the O_PATH fd on every branch, and the test drains stdout/stderr concurrently, gates on isLinux, distinguishes host-can't-build (skip) from source regression (throw), and asserts both exit status and the observable effect (symlink created, target executable). No prior human review comments are outstanding.

Comment thread src/bun_core/env_var.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/bun_core/env_var.rs
Comment thread src/sys/lib.rs
Comment thread src/sys/lib.rs
Comment thread src/sys/lib.rs
Comment thread src/sys/lib.rs

@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.

I reviewed this PR and didn't find bugs. The ENOSYS return from openat2_beneath correctly routes bin.rs:1416 to its realpath fallback, lchmod_no_fchmodat2 closes the O_PATH fd on every exit and the /proc path fits the 32-byte buffer, and the seccomp test asserts the bin is actually chmod'd rather than silently skipped. That said, this touches shared syscall wrappers and adds an env-var-triggered downgrade of the RESOLVE_BENEATH hardening, and the comment-cop bot is still flagging several doc comments after the consolidation commit — a human should confirm both the detection heuristic and whether the remaining comments are acceptable.

Extended reasoning...

Overview

The PR gates three Linux syscall sites in src/sys/lib.rs — openat2_beneath, openat2_in_root, and the raw fchmodat2 in lchmod — behind a new cached is_android_kernel() runtime check, so they are never issued on Android where seccomp delivers SIGSYS instead of ENOSYS. It adds a new lchmod_no_fchmodat2 fallback (O_PATH + chmod("/proc/self/fd/N"), musl's strategy), registers ANDROID_ROOT/ANDROID_DATA in env_var.rs, and adds a Linux-only test that compiles a seccomp wrapper mimicking Android's SECCOMP_RET_TRAP filter for syscalls 437/452 and runs bun install of a local dependency with a bin under it.

Security risks

openat2(RESOLVE_BENEATH) is the primary containment check that prevents a package's bin entry from escaping the package directory. On Android (or on any Linux host where a user sets both ANDROID_ROOT and ANDROID_DATA), this now short-circuits to ENOSYS and bin.rs falls back to its pre-existing realpath containment check — the same path already taken on kernels < 5.6, so it's not a new hole, but it is an env-var-controlled downgrade of a hardening layer. The new lchmod fallback rejects symlinks with EOPNOTSUPP before chmod'ing via /proc/self/fd, matching libc semantics, so no symlink-follow TOCTOU is introduced there.

Level of scrutiny

Medium-high. src/sys/lib.rs is core infrastructure used by install, directory routes, and node:fs; the new fallback has manual fd lifecycle across four exit paths; and the detection heuristic (uname release substring OR two env vars both set) is a design choice that affects which security path is taken on non-Android Linux. This is the kind of platform-gated FFI change REVIEW.md calls out for cross-platform review.

Other factors

The comment-cop github-actions bot left ten automated "paragraph-long comment" flags; the author responded with a "consolidate Android detection comments" commit, but the bot re-fired on five locations in the consolidated version (env_var.rs:43, sys/lib.rs:1956/1977/2873/2904). The remaining comments read to me as necessary context (they explain kernel behavior, not code hacks), but per the guidelines outstanding automated feedback should be resolved by a maintainer, not auto-approved past. The test is well-constructed (fails-right check via SIGSYS, asserts the bin was actually made executable, clean skips when cc/headers/seccomp are unavailable) but is Linux-only and was not run locally per the PR footer.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:12 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit 6f5f1196 (Build #98321) was cancelled — waiting for the next build...

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the remaining failures (socket.test.ts keep-alive, shell mv FIFO timeout, inspect-error-leak, watch-mode kill-signal, bun-patch, napi 1_hello_world) are all flaky, they passed on retry or when run alone, and none touch this diff. Same set flaked on the previous run. The new seccomp test passes on all lanes. Ready for review.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

#39775 fixes this without Android detection. It installs a SIGSYS handler at startup that makes a trapped syscall return ENOSYS, so openat2_beneath takes its realpath fallback and lchmod takes its fchmodat fallback (the libc-internal fchmodat2 attempt gets ENOSYS from the same handler and libc emulates). The same handler covers close_range at startup (#30766, #30769) and pidfd_open. Its test traps 437 and 452 during bun install of a local package with a nested bin and checks the link and the mode. If #39775 lands, this PR is not needed.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Checked this PR against #39775 at d6171ce (the handler that turns a seccomp trap into ENOSYS). I built that branch as is. It contains none of the src/ changes from this PR.

  • test/cli/install/bun-install-android-seccomp.test.ts from this PR passes on that build (1 pass, the filter was installed, no skip). On current bun the same test fails: bun install dies with SIGSYS, exit 159.
  • test/cli/run/seccomp-trap.test.ts in Turn seccomp SIGSYS traps into ENOSYS so the syscall fallbacks run on Android #39775 already runs this scenario: openat2 (437) and fchmodat2 (452) trapped during an install of a local package with a nested bin, with the link and the mode asserted. So I did not add the test from this PR there.

This PR stays open for now. #39403 carries it as commit 6dd1b74. The two changes touch different files and do not conflict.

This branch has not been deployed

No deployments
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.

1 participant