Skip to content

Harden the vendored libraries and the runtime's stack - #3442

Merged
ctate merged 19 commits into
mainfrom
train/security-2
Nov 8, 2026
Merged

ctate merged 19 commits into
mainfrom
train/security-2

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

The runtime packs are compiled with compiler protections, split by where they cost: the vendored libraries that parse untrusted input get all of them, the runtime units get the two that cost typed code almost nothing. Supersedes #1197, which applied all five everywhere.

What is compiled with what

ProtectionFlagsCost alone when applied everywhere, as #1197 did (instructions executed, 24 benchmark workloads)Vendored zlib, brotli, zstd, mbedtlsRuntime units
Bounds traps-fsanitize=bounds -fsanitize-trap=bounds+2.15% (one workload +25.5%)yesno
Stack canaries-fstack-protector-strong+0.86% (up to +2.5%)yes (not on WASI)no
Fortified libc calls-D_FORTIFY_SOURCE=2+0.15% (up to +1.4%)yes (glibc, Darwin, Bionic, MinGW; musl and WASI ignore it)no
Zero-initialized locals up to 64 bytes-ftrivial-auto-var-init=zero -ftrivial-auto-var-init-max-size=64+0.07% (up to +0.45%)yesyes
Stack-clash probes-fstack-clash-protection0.00%yes (Linux)yes (Linux)

A compiler without the zero-initialization size cap (the Android NDK 27 clang) gets no zero-initialization rather than the unlimited form; probeHardeningSupport asks the pack's compiler once per build.

Not done, and why

The runtime's own units get no stack canaries, no fortified libc calls and no bounds traps. Each was measured alone and each makes typed programs execute more instructions: bounds traps +2.15% across the benchmark set and +25.5% on one workload, canaries +0.86% (up to +2.5%), _FORTIFY_SOURCE=2 +0.15% (up to +1.4%). With all five on the runtime units, as #1197 had it, the same set executed +3.16% more instructions, four benchmarks read slower in wall-clock time (up to +4.9%), and every executable grew by a median 3.29%. Typed programs are meant to pay nothing they can avoid, so these three stay off the code every typed program runs through. Any of them can be turned on for the runtime units in runtimeHardeningFlags (one list in packages/runtime-pack-common/scripts/hardening.mjs) at the cost in the table.

What rides, and why

  • The vendored archives take everything. zlib, brotli, zstd and mbedtls parse compressed and network input an attacker can choose, and typed code does not run through them. zstd is new since Harden the C runtime packs with compiler protections #1197 and is covered in both build scripts.
  • The runtime units take zero-initialization and the stack-clash probes only. Every typed program runs through them. The probes execute nothing on the benchmark set. Zero-initialization is kept although it is not strictly free: it turns a read of an uninitialized small local in the C runtime into a defined zero for +0.07% instructions, which no wall-clock measurement here can see.
  • Unchanged: libregexp keeps its flags (hardening it slowed the regex, CSV-number and error-validation benchmarks by 5 to 7% in Harden the C runtime packs with compiler protections #1197's measurement), ICU is built as before, and glibc Linux executables already link as PIE with full RELRO, immediate binding and a non-executable stack.
  • Not covered: when the compiler builds the runtime and the vendored sources itself with a host C compiler (--sanitize, the sanitized test lanes), these flags are not applied; the packs are what release executables link.
  • scr_glibc_math.c: the IFUNC resolvers and the CPU probe they call carry no_stack_protector. They run before libc sets up thread-local storage, where a canary read would fault; the packs build the runtime units without canaries, and the attribute keeps that true under a compiler that enables them by default.

What the linux-x64-gnu pack's objects carry

Objects that reference __stack_chk_failFortified calls (__*_chk)Bounds-trap instructions (ud1)
zlib0 → 600 → 33
brotli0 → 120 → 10 → 104
zstd0 → 1600 → 157
mbedtls0 → 870 → 560 → 152
libregexp000
Runtime units (433 release objects)000

Cost

This changes the bytes of every executable, by design: every program links runtime units compiled with the two flags. It is recorded as one whole-runtime exception to the zero-cost gate.

  • Identity: 6,224 programs compared (--gate-corpus=all). IR and LLVM are identical on every one; 5,868 executables differ (every program that builds), 356 fail to build on both sides, none differs in IR or LLVM, no executable gains a dynamic-tier symbol. The compiler itself is byte-identical to main's.
  • Size: every executable grows. All 5,868: +0.02% to +3.04%, median +0.33% (+64 to +50,328 bytes, median +1,664). The 5,616 that link only changed runtime units: +0.02% to +0.60%, median +0.32% (median +1,600 bytes). The 252 that also link hardened vendored code (205 mbedtls, 112 zlib, 6 brotli, 4 zstd): +0.36% to +3.04%, median +2.20% (median +45,928 bytes). The 66 benchmark executables: +0.07% to +0.42%, median +0.26%.
  • What changes inside: 1,040 runtime functions change size in at least one program (they hold a small local that is now zeroed first, or a frame large enough to probe); 98.7% of the occurrences grow. Eight small runtime functions change inlining in a few programs. In the vendored code 865 functions change size (mbedtls 523, zstd 199, brotli 122, zlib 21).
  • Run time: bench-runtime, 73 workloads, 4 layouts x 16 runs: geomean -0.1%, 71 neutral, 2 faster, 0 slower.
  • Instructions executed (cachegrind, 24 workloads, the same flags on an earlier tree): geomean +0.07%; the largest are json-records +0.45%, dictionary-walks +0.34%, validate-errors +0.19%; 10 workloads change by less than 0.005%.
  • Compile time: benchmarks +0.19% [-0.59%, +1.14%], within tolerance.
  • The committed runtime profile is not regenerated. Its test counts functions whose control flow changed since the profile was collected; zeroing a local and probing a frame add no branch. 54 of the 1,459 functions it describes mismatch (3.70%, limit 10%; 2.45% of the counts, limit 5%), against 48 on main four merges earlier. With all five protections on the runtime units it was 299.

Validation

All in Vercel Sandboxes (Linux x64). The head is e46c1a5562 = the validated tree 2d0e27507e plus a merge of main 6c1e5ac877 that touches none of this PR's files.

  • Full round on 2d0e27507e against main 783c54deee: type check; lanes plain 25,531 passed, 0 failed; sanitized 22,059 passed, 5 failed, all five on time limits (below); both native bootstraps to the end (plain: commands, rebuild; sanitized: commands, rebuild-emit, rebuild-frontend) with the hardened pack; the zero-cost gate over all 6,224 programs as described under Cost; bench-runtime as above; Test262 (Function, Reflect, JSON, Proxy, Object): 0 regressed.
  • The five sanitized failures are time limits, not this change: chalk4 (Node island) and commander-calc in npm.test.ts and npm-shape-overlay.test.ts (120 s each) and the Effect generator case of npm-static.test.ts (900 s). A sanitized build compiles the runtime from source with its own flags and never links the packs this PR changes, and the built compiler is byte-identical to main's. Run alone with the lane's environment: chalk4 passes in 76 s and the Effect case in 469 s on this tree; commander-calc runs into its 120 s limit on this tree and on main's own tree 783c54deee alike (111 to 116 s on main four merges earlier).
  • On the merged head e46c1a5562: build and type check, pnpm lint, pnpm format:check, hardening.test.ts (16 tests), runtime-profile.test.ts without regenerating the profile (55 of 1,458 functions mismatch, 3.77%), all of packages/runtime-pack-common/test, the supply-chain, pack cache-key, CI-lane and code-quality tests (20 files, 169 tests). The zero-cost gate again on this head against main 6c1e5ac877: 6,234 programs compared, 5,876 executables differ (every program that builds), 358 fail on both sides, IR and LLVM identical on every one, sizes +0.02% to +3.04%, median +0.33%, compile time +0.18% [-0.92%, +1.05%].
  • Other targets built in the sandbox: runtime-linux-x64-musl (canaries in zlib, brotli, zstd and mbedtls; no fortified calls, musl has none; none of either in the runtime units) and runtime-wasm32-wasi (no canaries, as designed). Eight programs that run through zlib, brotli, zstd and mbedtls, built for musl with that pack, match Node in stdout, stderr and exit status.
  • Not run here: the packs for macOS, Windows, Linux arm64, Android and iOS are built by CI only. Harden the C runtime packs with compiler protections #1197's CI built the macOS arm64, Windows, Android and iOS packs with a superset of these flags on the runtime units and the same flags on zlib, brotli and mbedtls; zstd with them is new on those targets.

Also in this PR

.gitattributes keeps scripts/verify-toolchain.sh and scripts/toolchain.sha256 LF in every checkout, the follow-up from the review of #3430 (a Windows checkout that converts line endings would hand sh and sha256sum a carriage return per line); tests/harness/ci-supply-chain.test.ts checks the two lines.

Supersedes #1197.

🤖 Generated with Claude Code

cramforce and others added 19 commits October 17, 2026 19:22
Build the runtime units, and the vendored zlib and mbedtls archives that
parse untrusted input, with zero-initialized locals up to 64 bytes,
trapping bounds checks, stack canaries (except WASI), stack-clash probes on
Linux and _FORTIFY_SOURCE=2 for optimized flavors outside musl and WASI.
QuickJS and libregexp keep their flags. runtimeHardeningFlags selects the
flags per target for both pack builders.

On Linux x64 bench-runtime, the release and speed geomeans change by +0.6%
and executables grow by a median 3.4%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Android NDK 27 clang rejects -ftrivial-auto-var-init-max-size. The
pack builders now probe the compiler for the size cap and omit
zero-initialization where it is missing, since the unlimited form slowed
the HTTP benchmark by 11%. Every other CI toolchain accepts the cap.

Clang's array-bounds checks emit unused sanitizer metadata even in trap
mode, and without optimization it stays in the object with absolute
source paths that -ffile-prefix-map does not rewrite. Dev flavors now use
only local-bounds, which is checked in LLVM IR and embeds nothing, so the
darwin-arm64 dev objects pass verify.mjs again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Harden the vendored brotli archive like zlib and mbedtls: it also parses
untrusted compressed input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Static PIE links run IFUNC resolvers while relocating, before libc sets up
thread-local storage. -fstack-protector-strong gave the CPU probe a canary
read from %fs:0x28, so --static executables that call floor, ceil or trunc
crashed in _dl_relocate_static_pie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Linux x64 runtime pack's speed units are optimized with the committed
runtime profile, and the hardening flags change the control flow of the
runtime functions. The pack test reported 768 of the profile's 4005
runtime functions changed since it was collected, so the profile was
regenerated with scripts/refresh-runtime-profile.mjs on the hardened
runtime: the runtime benchmark workloads at --optimization=speed plus an
instrumented tsc-ts checking its own sources.

Co-Authored-By: Claude 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main dropped import bitcode from the runtime pack build (c1ce07e);
the hardening flags this branch adds to the runtime units and the
zlib/brotli/mbedtls archives are unaffected, so the merged build keeps
the hardening imports and main's bitcode-free unit plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scripts/refresh-runtime-profile.mjs on Linux x64 against the hardened
runtime pack: the runtime benchmark workloads (48 workloads, 3 runs
each) plus an instrumented tsc-ts self-check trained 333 raw profiles,
merged with --sparse --keep-functions so the profile names only runtime
functions (76,604 bytes gzipped). runtime-profile.test.ts and
hardening.test.ts pass against the rebuilt pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The C runtime packs are compiled with compiler protections (#1197).

Conflict, resolved by reading both sides:

- packages/runtime-pack-common/scripts/build.mjs, the import list: the
  train's import of the runtime unit helpers (with hostHelperPath) and the
  branch's import of the hardening flags, both kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A proposal for measurement, not yet agreed. Instruction counts on 24
benchmark workloads, per protection, against main: bounds traps +2.15 %
(one workload +25 %), stack canaries +0.86 %, FORTIFY level 2 +0.15 % (one
workload +1.4 %), zero-initialization up to 64 bytes +0.07 %, stack-clash
probes 0.00 %.

The runtime units are on the path of every typed program, so they keep
only zero-initialization and the stack-clash probes. The vendored archives
that parse untrusted input keep every protection, and zstd, which main
gained after the branch was cut, joins zlib, brotli and mbedtls.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Merges the runtime-pack hardening of #1197 in the form that was decided
after measuring each protection alone (instructions executed on 24
benchmark workloads, against main): bounds traps +2.15 %, stack canaries
+0.86 %, _FORTIFY_SOURCE=2 +0.15 %, zero-initialization of locals up to
64 bytes +0.07 %, stack-clash probes 0.00 %.

- The vendored zlib, brotli, mbedtls and zstd archives, which parse
  untrusted input, are compiled with all five protections.
- The runtime units, which every typed program runs through, get the
  stack-clash probes and the zero-initialization only. Canaries, fortified
  calls and bounds traps stay off them.

The commit that says "Proposal" in this branch is that split; it is the
decided set.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No flag changes. The comments in hardening.mjs and the two pack build
scripts said the runtime units carry every protection and named QuickJS,
whose archive the packs no longer build; they now say what is compiled:
zero-initialized small locals and stack-clash probes on the runtime units,
all five protections on the vendored zlib, brotli, zstd and mbedtls
archives, libregexp as before. The note beside the math IFUNC resolvers
says why they keep no_stack_protector although the packs build the runtime
units without canaries. hardening.test.ts is formatted with oxfmt and its
two unoptimized cases are named after the vendored builds they describe.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A follow-up from the review of the pinned downloads: a checkout that
converts line endings (Windows with core.autocrlf) would hand sh and
sha256sum a carriage return at the end of every line of
scripts/verify-toolchain.sh and scripts/toolchain.sha256. The Windows job
passes today; .gitattributes makes that independent of the runner's git
configuration, and the supply-chain test checks the two lines.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 8, 2026 6:53pm UTC

@ctate
ctate merged commit f05fb76 into main Nov 8, 2026
26 of 27 checks passed
@ctate
ctate deleted the train/security-2 branch November 8, 2026 18:59
@cramforce

Copy link
Copy Markdown
Contributor Author

CI on this PR's head e46c1a5562 and on main's merge commit f05fb76ae9, by cause.

Main's run on the merge commit: green, 103 jobs, among them both native bootstraps with the hardened pack (plain: commands, rebuild; sanitized: commands, rebuild-emit, rebuild-frontend).

This PR's run: every Linux and Windows job is green (67 jobs), including the pack builds and tests of the other targets: Linux x64 native packages, the musl LLVM-artifacts lane, runtime-android-arm64, runtime-ios-arm64, runtime-ios-simulator-arm64, Windows x64 MSVC LLVM artifacts, the Windows CLI smoke test, and the macOS arm64 native packages build. Nine macOS test jobs are red, on two causes, neither from this change:

  1. The clock program 9811-node-island-load-reads-clock prints process.hrtime() at load, then the program's: false where Node prints true. This is the failure the macOS jobs already had before this PR (it was red on Train security-1: one compile-time evaluator under both compilers; toolchain downloads pinned by SHA-256 and actions by commit #3430's run as well). Jobs: macOS x64 LLVM artifacts ("Node islands (Intel)"), macOS arm64 LLVM artifacts 1/5 ("Node island harness") and 4/5 (release/dev differential), darwin plain 2/3, darwin san 1/3.
  2. Node itself aborts on macOS while running two corpus programs as the oracle: node … tests/corpus/9444-process-stream-write-error.ts and 9445-process-stream-write-error-js.mjs end with Assertion failed: (fd > STDERR_FILENO), function uv__close, file core.c, line 646 (SIGABRT). No scriptc executable is involved in that step. The programs close descriptor 1 and then write to process.stdout; they reached main with Train cli-22 (cut G): island values keep their origin under declared types, union arms by their own discriminant, process stream listeners, Headers iteration, relayed island bindings #3425 at 16:03 UTC, and macOS jobs are skipped on main's own runs and were skipped on the three PRs merged since that I looked at (Train cli 21: declared overloads checked at the call, template-keyed member reads, package JavaScript with typed arguments, JSON.stringify of wide unions #3426, Train lang 8: a default behind an element read keeps its own type, and a derived class's construction no longer warns of a copy #3435, Train cli-23 (cut H): function values printed by name and kind, tested island values, optional parameters on island subclasses, exit listeners passed by name #3437), so this run appears to be the first to execute them on macOS. Jobs: darwin plain 1/3 and 2/3, darwin san 2/3 and 3/3, macOS arm64 LLVM artifacts 4/5 and 5/5.

The aggregate test job is red because of those nine. No review comment and no review on this PR.

🤖 Generated with Claude Code

cramforce added a commit that referenced this pull request Nov 8, 2026
Main gained train cli 21 (#3426), the timer test programs (#3441, #3443) and the hardened runtime (#3442). No conflict. Seven files changed on both sides and merge by position: binary.ts, generic-functions.ts, lower-calls.ts, lower-stmts.ts, lowerer.ts, coverage.test.ts and the island value matrix's table.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — e46c1a55 Deployed Nov 8, 2026 by vercel[bot]
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.

2 participants