Skip to content

[WTF] StackBounds: a stack limit that cannot be read counts as no limit - #935

Draft
robobun wants to merge 2 commits into
mainfrom
robobun/635ce8f2/stackbounds-checked-limit
Draft

robobun wants to merge 2 commits into
mainfrom
robobun/635ce8f2/stackbounds-checked-limit

Conversation

@robobun

@robobun robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Related to oven-sh/bun#42688

Problem

  • Regression on Bun main since Upgrade WebKit to 982277c9dd3e bun#47035, in no release. When a seccomp filter denies prlimit64() on Linux, every recursion check on Bun's main thread fails: error: Failed to parse package.json: StackOverflow.
  • currentThreadStackBoundsInternal() (Source/WTF/wtf/StackBounds.cpp:185) ignores the result of getrlimit(RLIMIT_STACK) and reads a rlimit that nothing wrote. Before [JSC] Ahead-of-time compilation #757, pthread_attr_init() zeroed that stack slot.

Fix

  • The Linux and Darwin arms start from a limit of RLIM_INFINITY. A limit that was not read counts as no limit, for which WTF takes 8 MB.
  • When the read fails and libc gave no stack either, the Linux arm returns empty bounds.
  • Correct because a readable limit gives the same bounds as before: 18 of 18 start modes.
  • Verified: JSTests/complex/bun-stack-bounds-unreadable-stack-limit.js (new). The jsc of 8c6c963 gets SIGSEGV for EPERM and for ENOSYS.

Background

  • StackBounds holds a stack's origin (highest address) and bound (lowest usable address). JSC and Bun check the stack pointer against the bound before they recurse.
  • glibc implements getrlimit() with prlimit64(). glibc and this file's FreeBSD arm treat a failed read as no limit.
  • Considered a bounds check in Bun: JSC keeps the bad bound. The legacy getrlimit syscall: more code on a rare path.

Downsides

  • A real limit below 8 MB that cannot be read: runaway recursion ends with SIGSEGV.
  • bun --no-orphans under the filter still stops at an assertion, as in Bun 1.4.4 (Notes).
  • The Darwin arm was read, not run.
Notes

Status. Draft until Bun is checked against the preview build of this head. The Bun PR follows.

Scope. An earlier version of this PR also kept a thread's bounds in a thread_local, so that Bun and WTF::Thread get one value. That is a separate change with its own case (bun --no-orphans with a large environment under a low stack limit, a crash that Bun 1.4.4 also has). It is on the branch robobun/635ce8f2/stackbounds-one-computation and not part of this PR.

What the stale slot held. gdb on Bun canary 1.4.4-canary.1+11c367163 under the filter: the slot of limit is 0, size -= pagesize wraps, and the bound is one page above the origin (origin 0x7ffffffff000, bound 0x800000000000). In the jsc of the same build the slot holds 0x3480e00, and the bound claims 52 MB of stack under an 8 MB limit.

Before #757 the main thread ran newThreadStackBounds() first. pthread_attr_init() zeroed the slot, and glibc's pthread_getattr_np() failed, because it needs the limit for the main thread. The bounds were {0, 0x1000}: no check tripped in Bun, and JSC stopped at RELEASE_ASSERT(stack.contains(m_lastStackTop)) in VM::setLastStackTop.

Empty bounds. When the read fails and libc gave no stack (the filter, plus environ moved before the request, so that endOfMainThreadStack() gives nothing), the Linux arm returns empty bounds. Bun's checks then do not trip and JSC stops at the assertion above. That is what Bun 1.4.4 does there. Without this clause the bound would be counted down from a null origin, and every check would say that the stack is used up. The test is the base that libc returned, so that it also holds for bionic, which leaves a stack size in the attributes when it fails. With a readable limit that case is as on main.

bun --no-orphans under the filter. Bun calls setenv() before JSC asks for the bounds a second time. That request then has no origin, and JSC stops at the assertion. Bun 1.4.4 and Bun main do the same.

Harness. The real StackBounds.cpp, compiled alone with clang 23 -O2 (x64, glibc 2.41, ASLR off), main thread:

run 8c6c963 this branch
no filter, 8 MB limit bound 0x7fffff800000 same
no filter, 1024 KB limit bound 0x7ffffff00000 same
no filter, no limit bound 0x7fffff800000 same
prlimit64 answers EPERM or ENOSYS, or only for RLIMIT_STACK bound 0x800000000000, above the stack bound 0x7fffff800000
the filter, and setenv() before the request {0, 0x1000} empty

The bounds are the same as at 8c6c963 in 18 of 18 start modes with a readable limit: default, empty environment, 700 KB of environment, 300 KB of arguments, a symlink, the loader (default, empty, 400 KB, 700 KB), limits of 1024, 4096 and 65536 KB with and without a large environment, and no limit (default, 700 KB, loader).

The test. complex.yaml runs jsc twice behind the new helper Tools/Scripts/jsc-stress-test-helpers/deny-prlimit64. The helper sets a stack limit of 8 MB, installs the filter with fiddle (no compiler), checks that getrlimit() fails under it, and starts jsc. The test holds the bound from both sides: 10,000 nested groups must compile (8 MB hold 34,627 in the x64 LTO build) and 100,000 must throw. It does not pin the 8 MB exactly. The entry skips itself under ASan, whose own start reads the limit and aborts, in a bundle for another machine, and where the helper cannot install the filter (it asks the helper first).

Darwin. XNU can fail a system call for a sandboxed process before its handler runs (unix_syscall, bsd/dev/arm/systemcalls.c, the mac_proc_check_syscall_unix branch). The arm gets the same start value. No macOS lane runs the new test.

Not changed here.

  • newThreadStackBounds() ignores the result of pthread_getattr_np().
  • size -= pagesize wraps for a limit below one page.
  • Upstream WebKit has the same two unchecked calls.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Preview build of 2c80070: autobuild-preview-pr-935-2c800703

@robobun
robobun force-pushed the robobun/635ce8f2/stackbounds-checked-limit branch from 30aa4d1 to 4dcee73 Compare October 29, 2026 10:48
currentThreadStackBoundsInternal() called getrlimit(RLIMIT_STACK) for
the main thread and read the limit without a look at the result. A
call that fails writes nothing. On Linux a seccomp filter that denies
prlimit64() makes it fail, and the bound then came from whatever the
stack slot held. With a slot of 0 the bound is one page above the
origin, and every recursion check on the main thread says that the
stack is used up.

The Linux and Darwin arms now start from a limit of RLIM_INFINITY, so
a limit that was not read counts as no limit and the size is the 8 MB
that WTF takes for one. glibc counts a limit it cannot read as no limit
too, when it sizes the stacks of threads. The Linux arm tests for the
main thread once.

When the read fails and libc gave no stack either (glibc and bionic
need the limit for the main thread), the Linux arm returns empty
bounds. A limit that can be read gives the bounds it gave before.
complex.yaml runs the new test on Linux twice: prlimit64() fails with
EPERM and with ENOSYS. The new helper deny-prlimit64 gives jsc a stack
limit of 8 MB, installs the filter with fiddle (no compiler), checks
that getrlimit() fails under it, and starts jsc. The entry asks the
helper first whether it can do that, and skips itself otherwise. It
also skips itself under ASan, whose own start reads the limit, and in
a bundle for another machine.

The test compiles a regular expression of 10,000 nested groups, which
fits in 8 MB, and expects a RangeError for 100,000, which do not. The
jsc of 8c6c963 reads a stale stack slot for the limit and ends with
SIGSEGV in both runs.
@robobun
robobun force-pushed the robobun/635ce8f2/stackbounds-checked-limit branch from 4dcee73 to 2c80070 Compare October 29, 2026 12:25
@robobun robobun changed the title [WTF] StackBounds: check getrlimit(RLIMIT_STACK) and compute a thread's bounds once [WTF] StackBounds: a stack limit that cannot be read counts as no limit Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant