The security audit's record for the runtime stage (issue #363). It covers what
was checked, how, and what was found, and names the test that pins each
answer. Line numbers in the findings table refer to main at bc6ad66.
| File | What was read |
|---|---|
runtime/runtime.c |
the arena (nish_arena_grow, nish_alloc_struct, marks, nish_arena_keep), every string function, the integer and Ryu formatters and their buffers, nish_parse_number, Math.random, nish_alloc_array, nish_argv_init, nish_array_grow, the panics |
runtime/nish.h |
every prototype and layout against its definition, and every contract comment against what the code does |
runtime/runtime-os.c |
the file reads and writes, writeFileBytesSync (O_NOFOLLOW as writeFileSync, and a close that fails answers its errno rather than the full length), writeBytes (nish_write_bytes, after the emitter's range check), the shared write loop nish_put_all, renameSync and unlinkSync (a NUL in a path answers -EINVAL before the call; neither follows a link, and unlinkSync never falls back to rmdir), isDirectorySync, mkdirSync, writeFileSyncMode, createFileExclusiveSync and mkdirSyncMode (RT-24 to RT-26), readdirSync and its sort, both spawns, spawnPiped (nish_spawn_piped, nish_pipe: its pipes' descriptors, the poll loop, the output cap, the SIGPIPE mask), getenv, realpathSync |
runtime/runtime-host.c |
Date.now, crypto.getRandomValues (short reads, EINTR, the limit), statMtimeSync, the signal pipe and its handler |
runtime/runtime-net.c |
every address, length and control-message buffer, descriptor flags and leaks on each error path, the readiness loop's output bounds, the socket-option whitelist |
runtime/runtime-parallel.c |
the partition arithmetic, the scope task list, thread-local state, thread-creation failure |
runtime/runtime-wasm.c |
the linear-memory arena, nish_alloc_array, nish_array_grow |
runtime/runtime-term.c |
its signal handlers' flags, read here for the waitpid property; the rest of the file is audited in term.md |
Read beside them, without owning them: src/runtime.ts (the declarations and
the inline allocator), the range checks src/emit-builtins.ts puts in front of
the network calls and writeBytes, src/interop-wasm.ts (the loader's nish_alloc_array),
runtime/shim.mjs (the Node twin) and docs/security/codegen.md, whose K1-6,
CG-3 and CG-9 were routed here.
The attacker controls the input of a well-typed program: every byte it reads
from a file, a socket or the environment, every name in a directory it lists,
how large a file is, what sits at a path it writes, and the strings it builds
from all of these. The attacker does not write the program and does not pick
its flags. The programmer is honest but writes ordinary code: a path assembled
from a request, a suffix check before an open, a listing of an upload
directory. A C host that calls nish.h directly is trusted to pass the sizes
it means.
A finding is any of these, reachable through a runtime entry point:
- an out-of-bounds access, or a block shorter than its header claims;
- a length the compiler's bounds-check elimination trusts and should not;
- a file opened, written or created other than the one the program named;
- memory or descriptors handed to the program uninitialised or leaked to a child;
- a cost in time or memory that grows faster than the attacker's input.
- Every size computation, read for overflow and checked against its
caller. Each
nish_alloc_structandmallocin the C and wasm runtimes was traced back to where its size comes from. A size that could wrap was driven to wrap from a C harness. For the wasm runtime that harness was a module built withscripts/build.sh --profile wasmand run under Node. - Every length the runtime hands back, checked against 2^31 − 1. Arrays
and strings made from outside data (files, listings, concatenation, the host
entry) were made at and past the limit, read with a sparse file where the
input is a file, and their
lengthread back under--number-mode i32. Exactly 2^31 − 1 is checked to still succeed for a file read, a concatenation and the host entry, so a limit one too low fails too. Those three checks run in a child whose arena points at 4 GiB of address space reserved with no access: a private,PROT_NONEmapping of a sparse file, with one read-write page for the headers. The host entry's elements are never touched. The concatenation's source starts in the inaccessible pages, and so does the file read's destination. The child faults on the first byte copied, and its handler checks that the arena handed out the whole block for that length. Nothing commits more than a page, so each check costs about a millisecond and no memory, and a run that passes is deterministic. Each was run against a build with the one>turned into>=and seen to fail. - Every path, name and argument, given an embedded NUL. Each one was
passed
name\0suffixfrom a compiled program and directly, and checked for whether the call answered forname. - Every file the runtime creates, given a symbolic link at its name. That
covers both writers and both
spawnSyncTostreams. The creation modes were read back under umask 022. - Every buffer and descriptor in
runtime-net.c, read against the kernel's contract. That meansrecvmsg'smsg_namelenandmsg_controllen,CMSG_NXTHDR's bounds,accept4's flags, the 64-entry cap in the readiness loop, and thei32range of every byte count, which the compiled range check (emitNetCall) and Linux'sMAX_RW_COUNTbound together. - Every loop over attacker-sized input, timed.
readdirSyncwas given 3,000 and 100,000 names with its compares counted, by interposingstrcmp. - The thread and signal code, read for races. Each function was checked
for which state is thread-local, which is shared and how that is ordered.
The signal handler was checked for async-signal safety and
errno.nish_parallel_rangewas built with-fsanitize=signed-integer-overflow -fsanitize-trap=.... - Every fix shown failing first. Each regression test was run against the
runtime at
bc6ad66and seen to fail, then against this change and seen to pass. The.text*budget of every unit was measured with the suite's own check.
| Id | Severity | Where (bc6ad66) |
Description | Disposition |
|---|---|---|---|---|
| RT-1 | High | runtime/runtime-os.c:90, :95 (nish_read_file_or_null), :112 (nish_read_file_bytes), :120 (nish_read_file) |
The runtime half of CG-3. A file of 2^31 bytes or more was read whole into a string or a u8[] whose length, under --number-mode i32, reads back negative. Bounds-check elimination trusts a length to be non-negative, so const n = xs.length; const j = n - 1; xs[j] on a 2^31 + 16 byte file read xs[-2147483633] unchecked: SIGSEGV in the reproducer, an out-of-bounds read in general. The attacker only has to make the file large, which a sparse file does at no cost |
Fixed. nish_read_file_or_null refuses a file longer than 2^31 − 1 bytes before it allocates anything. That reaches all three readers through the failure path each already has for an unreadable file: readFileSyncOrNull and readFileBytesSync answer null, and readFileSync exits 1 with nish: cannot read. The runtime cannot tell which number mode compiled its caller, so the limit holds in both, as nish_array_grow's does. It is also Node's own limit (ERR_FS_FILE_TOO_LARGE), so the two runtimes now agree. A file of exactly 2^31 − 1 bytes is still read. Tests: tests/link/rt_sec_read_cap, RT-1 in tests/runtime-test.c (both sides of the limit) |
| RT-2 | High | runtime/runtime.c:199 (nish_str_concat) |
a + b and every template literal concatenate through this function, with no limit on the result. Two strings read from files, each under the RT-1 limit, gave a string longer than 2^31 − 1 bytes, with RT-1's negative length and the same unchecked reads |
Fixed. A result past 2^31 − 1 bytes fails as an allocation does (nish: out of memory, exit 1), checked before anything is copied. nish_oom became one cold function rather than a macro, so the seven call sites stopped paying for the message's address, and that saving keeps runtime.c under its ceiling. A result of exactly 2^31 − 1 bytes is still made. Test: RT-2 in tests/runtime-test.c (both sides of the limit) |
| RT-3 | Medium | runtime/runtime-os.c:87, :127, :152, :159, :184, :194–:195, :243, :293, :324; runtime/runtime-host.c:101 |
Every path, environment name and spawn argument went to the kernel as s->data, which the kernel reads to its first NUL, so a string holding a NUL named something else. "README.md\0.txt" passes endsWith(".txt") and reads README.md. A program that checks a suffix, a prefix or a pattern on a name it was given and then opens, writes, lists or spawns with it acted on a file it never checked. Node refuses all of these (ERR_INVALID_ARG_VALUE), so the native runtime also answered differently from shim.mjs |
Fixed. nish_cpath, one static inline in nish.h that every path, name and spawn argument goes through, hands the kernel "" for a string holding a NUL. "" names nothing, so each call answers as it does for a missing path: null, false, cannot read/cannot write and exit 1, or a spawn that answers -1. A spawn argument holding a NUL answers -1, because its C string is not its own bytes. statMtimeSync answers NaN, and the RT-9 primitives answer -1 or false. Tests: tests/link/rt_sec_path_nul, RT-3 in tests/runtime-test.c |
| RT-4 | Medium | runtime/runtime-os.c:127 (nish_put_file), :194–:195 (spawnSyncTo's file actions) |
CLI-6 (docs/security/cli.md). writeFileSync, appendFileSync and spawnSyncTo's streams opened with O_CREAT and no O_NOFOLLOW. nish file.ts writes file.ll beside its source, so a symbolic link planted at that name in a checkout truncated any file the user could write. Any program writing to a name in a directory someone else can write had the same flaw |
Fixed. All four opens pass O_NOFOLLOW. A link as the last component is refused: cannot write and exit 1 from the writers, -1 from spawnSyncTo. A link among the directories on the way is still followed. Every caller in src/, std/ and tests/ writes a path it made itself or /dev/null, and none writes through a link. The compiler picks this up from the release that ships this runtime, because build/nish-test is linked with the seed's own runtime. One visible change: on Linux /dev/stdout is a link to /proc/self/fd/1, so writeFileSync("/dev/stdout", …) now fails, and console.log or write is the way to stdout. Test: RT-4 in tests/runtime-test.c |
| RT-5 | Medium | runtime/runtime-os.c:255–:266 (nish_readdir) |
readdirSync sorted its listing with an insertion sort. Whoever fills a directory chooses its length: 100,000 names took 2,507,163,583 compares and 12.5 s, and the cost grows with the square of the count |
Fixed. An in-place heapsort: 3,019,323 compares and 0.071 s for the same 100,000 names, at most 2 n (log2 n + 1), with no allocation. Under -ffunction-sections the function grew 94 bytes. nish_getenv and nish_realpath now build their answers with nish_str_new, and spawnSyncTo's two streams share one loop; between them that pays for the sort and RT-3 inside the unit's ceiling. Test: RT-5 in tests/runtime-test.c, which counts compares and checks the edges of the heap (0 to 3 names) |
| RT-6 | Low | runtime/runtime-net.c:235 (nish_net_put), :380–:402 (nish_udp_recv_from) |
recvmsg reports no sender on a connected or Unix-domain socket (msg_namelen 0) and leaves the address buffer as it was: uninitialised stack. udpRecvFrom then copied 18 of those stack bytes into the program's from. The program chooses the descriptor, so this is a leak of this process's own stack to itself |
Fixed. nish_net_put takes the length the kernel reported. It writes an address only for a full AF_INET or AF_INET6 one, and eighteen zeros otherwise; tcpAccept passes its length too. Test: RT-6 in tests/runtime-test.c (a Unix datagram pair, over a dirtied stack) |
| RT-7 | Low | runtime/runtime-wasm.c:228–:236 (nish_arena_grow), :241 (nish_alloc_struct), :258 (nish_alloc_array), :265 (nish_array_grow); runtime/runtime.c:1110 (nish_alloc_array) |
CG-9 and its neighbours. In the wasm runtime, off + size could wrap 2^64 into a small number the fast path took for "fits", moving the offset backwards so the next block landed on the first. A page count of 2^32 or more was truncated to the 32-bit memory.grow argument: a request of 2^48 bytes grew nothing and claimed 2^48 bytes of capacity. nish_alloc_array(8, 2^61) wrapped len * elem_size to 0 behind a header claiming 2^61 elements, in both runtimes. nish_array_grow in the wasm runtime had no K1-6 limit. Compiled code does not reach these: its own allocator's zero-fill traps first, and the loader cannot pass a JavaScript length that large. Each is reached by a C caller |
Fixed. The wasm grow traps on a wrapped or 4 GiB-overflowing need. Its fast path compares size <= cap - off and traps on a size that rounding would wrap. Both runtimes' nish_alloc_array refuse a length past 2^31 − 1 (native: nish: out of memory; wasm: a trap), and the wasm nish_array_grow now keeps runtime.c's limit. Exactly 2^31 − 1 elements is still handed back by both. Tests: tests/link/rt_sec_wasm_arena (five probes, each in a fresh instance under Node, one of them at exactly 2^31 − 1), RT-7 in tests/runtime-test.c (both sides of the limit) |
| RT-8 | Low | runtime/runtime-parallel.c:112 (nish_parallel_range) |
(len + grain - 1) / grain is signed overflow, which is undefined behaviour, once len is within grain of 2^63. At -O0 it ran the whole range on one thread; clang 18 at -O2 happens to fold it into the right answer |
Fixed. len / grain + (len % grain != 0). With -fsanitize=signed-integer-overflow -fsanitize-trap=signed-integer-overflow the old code traps and the new one divides the range. Test: RT-8 in tests/runtime-test.c (threads build), which checks that a range of INT64_MAX is divided |
| RT-9 | Low | (new) runtime/runtime-host.c |
CLI-7 and CLI-9 asked for primitives to check who owns a path and whether a program runs: lstat's owner and mode, and access(X_OK) |
Added, then used by #486 (CLI-7, CLI-9, now fixed): nish_lstat_owner_mode, nish_euid, nish_is_executable, declared in nish.h, 133 bytes of runtime-host.c between them. src/ may call a runtime function only once the last release declares it (the rolling freeze), so CLI-7 and CLI-9 waited for the release after this one. Test: RT-9 in tests/runtime-test.c |
| RT-10 | Low | runtime/runtime-host.c:143 (nish_signal_fd) |
Two threads whose first signalFd() calls overlap each make a pipe. One pipe's write end goes to the handler, and the other thread's read end never hears a signal; the loser's pipe leaks too |
Fixed (#387). Both ends live in one 64-bit word, published by a single compare-and-swap. The caller that loses closes its own pipe and answers the winner's read end, and every first caller installs the handler before it returns. The overlap cannot be timed from a program, so the test makes it happen: its own pipe2 runs a whole other first call in the middle of one. Before the fix the two calls answered different descriptors and the losing pipe stayed open. Test: RT-10 in tests/runtime-test.c (Linux, the default build) |
| RT-11 | Low | runtime/runtime.c:253 (nish_write) |
console.log, write and the panic message make one write(2) and ignore a short count. Output to a non-blocking stdout, or to a pipe when a signal arrives mid-write, is cut short silently |
Fixed (#387). nish_write writes until every byte is out or a write fails. A failure still ends the output, EAGAIN on a non-blocking stdout included, because spinning on it would burn a core. EINTR needs a handler installed without SA_RESTART, and the runtime installs none, so an interrupted write restarts in the kernel or comes back short. The newline is one byte and is never written in part. nish_die writes its message with fputs to the unbuffered stderr, whose libc loop does the same. Fitting the loop under runtime.c's ceiling took 12 bytes from nish_str_concat and nish_die. Revised (#611, anchors at 015e092e): nish_write now retries EINTR as well (runtime/runtime.c:321), the trailing newline included, because a C host may install a handler without SA_RESTART. panic's and expect's messages go through it, and so do nish_die's (runtime/runtime.c:115), the arithmetic and out-of-memory panics, which used fputs: glibc's loop finishes a short write but gives up on EINTR, and the message was lost. The index and slice panics, the file and entropy failures (dprintf) and runtime-parallel.c's out of memory (one write) still do not retry EINTR. 62 bytes, recorded in tests/runtime-budgets.js. Test: RT-11 in tests/runtime-test.c, whose own write takes three bytes a call: before the fix console.log("hello, world") sent hel and the newline. Three more RT-11 checks fail one write with EINTR: the message's first, the newline's, and an arithmetic panic's in a child, which also counts that the message went through the runtime's loop. Before the revision the first sent nothing, the second lost the newline, and the third printed nothing |
| RT-12 | Low | runtime/runtime-os.c:87, :127 |
The descriptors the file reads and writes open are not O_CLOEXEC. A child another thread spawns while one is open inherits it |
Fixed (#387). Both opens pass O_CLOEXEC. opendir already is close-on-exec, and spawnSyncTo's files are opened by the child. Test: RT-12 in tests/runtime-test.c, whose own pread and write read the flags of the descriptor they are handed |
| RT-13 | Low | runtime/runtime-os.c:98 |
The read loop stops at the first pread that fails, EINTR included, and answers the bytes so far as the whole file. A local disk does not interrupt a read; NFS mounted intr and FUSE can |
Fixed (#387). The read loop retries a pread that fails with EINTR, and so does writeFileSync's write loop, which used to say cannot write and exit. Any other failure still ends the read, and the bytes so far are the answer, as before. runtime-os.c pays for this with two simplifications: getenv and realpathSync share one copy into the arena, and a spawn always builds its list of file actions, empty or not. Test: RT-13 in tests/runtime-test.c, whose own pread and write fail once with EINTR: before the fix the read answered "" and the write exited 1 |
| RT-14 | Medium | (new, anchors at 680d9e1) src/emit-strings.ts:509 (emitForeignString; the check from :520), :485 (foreignStringIsNulFree); src/escape.ts:718 (the borrow rule); src/declarations.ts:165 |
WP27 S3.1 lets a string cross into a declare function as const char *, the first pointer this compiler allocated that C sees. Two hazards come with it. A C function that keeps the address (putenv) or writes through it (strtok) reads a released arena or corrupts a value the program still holds, and nothing about a C body can be checked. A string holding a NUL is read by C to that NUL, so "secret\0.txt" passes a .txt check in the program and names secret in C: RT-3's bug, on calls the runtime does not make |
Decided and fixed. The borrow is a rule, not a proof (docs/wp27-ffi.md §8b): C reads the bytes for the length of the call. The escape analysis treats the argument as a read, the arena scope is released after the call returns, and a call with a string argument is never tail. A function that keeps or writes its argument is declared to take a CPtr, as LANGUAGE.md's "Calling C" says with strdup/putenv. The NUL is a panic, ffi-nul, before the call: one nish_str_index_of for "\0", lowered in the IR, because runtime.c has no room under its ceiling for nish_ffi_cstr. A literal with no NUL in it is proved and not checked. nish_cpath's "" is not used, because a foreign function has no answer for an empty name that the program can rely on. Tests: tests/cases/ffi_string_param, ffi_string_param_arena (a C sidecar copies what it read, after the scope that built it would have released), panics_ffi_nul, reject_ffi_string_array_param; "ffi_string: puts("hello") has no ffi-nul site and no NUL check in its IR", "ffi_string: a constructor's string argument to C is an ffi-nul site, and new is a call site via it", "ffi_string: a string handed to C inside a using block, and one read through this stored out of a constructor, are each an ffi-nul site" in tests/ffi-string.js |
| RT-15 | Medium | (new, anchors at 3e02a2a) src/emit-strings.ts:605 (emitForeignStringResult), :575 (foreignCopy, the @nish.ffi.str helper); src/escape.ts:490 (the local allocation site); src/emit.ts:655 (the tail-call refusal); src/declarations.ts:195. The loop and arena-block clause anchors at 5bee0324: src/attributes.ts:3559 (the foreign facts), src/escape.ts:1629 (PassWalk.isOld reads them), :1228 (reclaimsReturnedString) |
WP27 S3.2 lets a declare function return string, the first pointer C answers that the program reads as its own value. Two hazards come with it. C's const char * points into storage C keeps (strerror's table, getenv's environment, setlocale's static), which a later call may overwrite or move, so a string that kept the pointer would change under the program or read freed bytes. And C may answer NULL, which a string cannot hold: a read of its length would dereference address 0 |
Decided and fixed. The bytes are copied into the arena at the call (docs/wp27-ffi.md §8d): one scan for the NUL and one nish_str_new, lowered in the IR because runtime.c has no room under its ceiling for nish_ffi_str. C's pointer is never held past the copy, and the copy is an allocation of the calling function, placed, kept and released by the escape analysis like any other: the foreign function's facts say it allocates and returns its allocation, so a loop pass that keeps one keeps it past the pass, and a using a = arena() block that would release one held outside it is refused (until 5bee0324 the facts said neither, and a loop released the copy it kept). A call whose result is copied is never tail. NULL is tested before the copy: `string |
| RT-16 | Medium | (new, anchors at b982d4f) src/emit-strings.ts:602 (emitCStringCopy, the @nish.ffi.str copy readCString shares with RT-15); src/builtins.ts:744 (checkReadCString); src/escape.ts:139 (the copy is a fresh allocation); src/expressions.ts:2510 (refuseForeignNullable) |
WP27 S3.3 adds readCString(p: CPtr): string to nish:unsafe, the first read of bytes behind a pointer C answered and the program holds. Nothing proves the pointer is live, that its bytes end in a NUL, or that it is not NULL: a `CPtr |
nullresult must be narrowed first, but adeclare functionwhose result is declared plainCPtris trusted, gets noffi-nulltest, and a declaration that lies handsNULLto the copy, which reads address 0. The same stage lets aCPtr |
| RT-17 | Low | (new, anchors at 87fa0ce, rewritten there for TCP_NODELAY) runtime/runtime-net.c:249 (nish_tcp_listen_flags), :214 (the SO_REUSEPORT in nish_net_bound), :217 (its TCP_NODELAY on the listener), :291 (nish_tcp_accept, which reads it back and sets it on the accepted socket) |
tcpListenFlags (#478) sets SO_REUSEPORT on a TCP listener, so a second socket can bind a port a first one holds. Two hazards come with it. Another process that binds the same port takes a share of the connections meant for this one: on Linux it must be of the same effective uid and have asked for the option too, but on a system that checks less, or for a server that runs as a shared account, that is a process the program did not start reading its clients. And a flags word with a bit nobody defined would be passed on as whatever the kernel makes of it |
Decided. The option is set only when the program asks for it by name, flag 1, on that socket. tcpListen is unchanged and never sets it, so a plain listener still refuses a second bind (-98), and a flagged listener cannot take a port a plain one holds, nor a plain one a flagged one's. Who may share is the kernel's rule, not the runtime's: on Linux the same effective uid, and the program that asks for the flag accepts that any process of its user may share the port, as LANGUAGE.md's row says. Flag 2 (#760) sets TCP_NODELAY, which changes when bytes leave and nothing about who may connect or what they read: the runtime sets it on the listener, and tcpAccept reads it back with getsockopt and sets it on each accepted socket itself rather than trusting the kernel to copy it, which Linux does and other systems need not. A failed set on the accepted socket closes it and answers the error, so no connection is handed out without the option its listener asked for, and the descriptor does not leak. Any bit but 1 and 2, a negative word included, answers -22 before a socket is made, so a later flag cannot change the meaning of an old program's call. The socket is close-on-exec and non-blocking as every listener is. Tests: test_tcp_reuseport in tests/runtime-test.c (the option on a flagged socket and not on a plain one, a second flagged bind, -98 both ways, -22 for 4 and -1), test_tcp_nodelay in tests/runtime-test.c (a listener given TCP_NODELAY only after the handshake, which Linux does not copy, still hands out a connection that has it, so the set in nish_tcp_accept is what this checks), tests/cases/net_tcp_reuseport, tests/link/net_tcp_reuseport_spread, tests/cases/reject_net_reuseport_wasm; for flag 2, ${name}: native output matches .out for net_tcp_nodelay (getsockopt reads TCP_NODELAY 1 on a socket accepted from a flag-2 or flag-3 listener and 0 from a flag-0 or flag-1 one, and -22 for 4, 6, 8 and -1), link/${name}: exits with ${wantCode} and stdout matches expected.out for net_tcp_nodelay_shared (two flag-3 listeners on one port carry a round trip), and ${name}: rejected with ${needles...} for reject_net_nodelay_wasm |
| RT-18 | Medium | (new, anchors at 9ca082d) src/emit-strings.ts:571 (emitForeignBuffers; the ffi.length block from :592); src/attributes.ts:5475 (foreignBufferCountProven), :2904 (the ffi-length site); src/emit.ts:898 (the i8* parameter); src/escape.ts:762 (the borrow), :1634 (no pointer C holds past a call); src/parallel.ts:462 (the task refusal) |
WP40 B1.2 lets a u8[] or readonly u8[] cross into a declare function as the array's data pointer, with a count C trusts. Three hazards come with it. A count larger than the array's length lets C read or write past the data block: a heap overflow the program cannot see. A count computed before a later argument pops or pushes the same array is stale by the time C runs. And C writes through a u8[] it is passed, so a task that reaches C with an array its parent is reading races it |
Decided and fixed. Every buffer argument is compared with its own header before C runs: icmp ule i64 n, len, unsigned because the count is a u64, branching to an ffi-length panic (exit 1) that names the function, the count and the buffer. The length and the data pointer are loaded after the last argument is evaluated, so a resize by a later argument is seen. The one count proved and not checked is toU64(b.length) of the buffer itself, where b is a const local or a parameter and no later argument may resize it; --deny-panics refuses any other and names that form. The pointer is lent for the call, as a string's bytes are (RT-14), and a u8[] C writes holds bytes, not pointers. A spawned task whose body reaches a foreign call with a buffer is refused by the existing "writes memory its caller can see" rule. On wasm32 the pointer is 32 bits and the count stays i64, a gap docs/LANGUAGE.md records. Tests: tests/cases/panics_ffi_length, panics_ffi_length_memcpy, panics_ffi_length_wrapped, panics_ffi_length_unchecked, panics_ffi_length_shrink, panics_ffi_length_inline_pop, ffi_buffer_late_load, ffi_buffer_count_proved, ffi_buffer_read, ffi_buffer_export_written, ffi_buffer_call_sites, reject_ffi_buffer_call_spawn, reject_ffi_buffer_scope_race, reject_ffi_buffer_scope_field_race, reject_ffi_buffer_scope_element_race; "ffi_buffer: toU64(b.length) of the buffer itself is a proved ffi-length site", "ffi_buffer: a proved count compiles under --deny-panics", "ffi_buffer: --deny-panics refuses an unproved count handed to C in a constructor", "ffi_buffer: a constructor's count to C is an ffi-length site, and new is a call site via it", "ffi_buffer: a count handed to C inside a two-declarator using block, and one for a buffer read through this stored out of a constructor, are each an ffi-length site", "ffi_buffer: --deny ffi refuses a call that passes a buffer", "ffi_buffer: --deny ffi refuses a buffer call reached from a constructor by new", "ffi_buffer: a task that reaches C with a buffer the parent can see is refused, through ${how}", "ffi_buffer: an exported u8[] parameter C writes is nish_array *, and one C only reads is const nish_array *", "ffi_buffer: an exported u8[] parameter C writes loses readonly, and one C only reads keeps it", "ffi_buffer: the wasm glue copies back a u8[] parameter C writes, and not one C only reads", "ffi_buffer: a --target wasm32 build passes llvm-as, and llc reads the data pointer as an i32.load at offset 16 and the count check as i64.le_u", "ffi_buffer: the freestanding --profile wasm link of host IR resolves write against a stub with B1's signature, and hands it the bytes the native run writes" in tests/ffi-buffer.js, which tests/run.js runs |
| RT-19 | Low | (new, anchors at 6ab976b) runtime/runtime-os.c:325 (nish_kill), :342 (nish_wait_pid) |
kill(pid, sig) (WP34 N12) signals any process this user owns, not only a child the program spawned: kill(2) checks the uid and nothing else, so a program handed a pid it did not start can stop it. Two wider reaches of kill(2) itself would have come with a raw pass-through: a pid of 0 or below addresses a process group or every process of the user, and a signal number means different signals on different hosts |
Accepted, narrowed. The reach is the one kill(1) has, and a supervisor that adopts a child it did not start (a pid file) needs it; the label process.spawn says a program has it, and a capability policy can deny it. What is left out is refused before kill(2) is called: a pid of 0 or below, and every signal but 0, 2, 9 and 15, are -22, and waitPid refuses a process-group pid and any flag bit but WNOHANG the same way. Tests: tests/cases/os_kill, os_wait; test_process in tests/runtime-test.c |
| RT-20 | Medium | (new, anchors at 943d8231) src/emit-parallel.ts:789 (openScope, the handle kind), :851 (closeHandle), :876 (closeScope), :905 (emitScopeJoins and its later test), :922 (closeBlockScopes); src/emit.ts:1127 (each declarator opened before the next initialiser, Q12(a)); src/attributes.ts:2863 (noteHandleClose, the ffi offer at the using); src/handles.ts:139 (openedHandle) |
WP40 H1.2 lets a using own a C resource: a handle class's CPtr, closed by the C function its dispose names. Three hazards come with it. An exit that skips the close leaks the resource (a descriptor, a FILE * and its buffer); an exit that runs it twice frees it twice, which is heap corruption in C; and a close that runs before the last read, or while a later declarator's initialiser is still running, hands C a freed pointer or leaks the earlier handle |
Decided and fixed. The object goes onto the scope stack beside task scopes, arenas and locks, and every exit of its block runs the close once, inline: fall-through, return (the value computed first, so a call returned from inside the block is never tail), break, continue, an orReturn() and a scope's join. Closes run in reverse order of opening. Each declarator opens as soon as its initialiser returns, so an orReturn() in a later one closes the earlier handles. The object cannot be named after the close and its dispose cannot be called (H1.1's placement rules, docs/wp40-ffi-buffers-handles.md §4c, §4d), and a callee lent it cannot keep it (H1.3), so no code follows the close. What stays a C contract, as LANGUAGE.md's rule says: the constructor's C call returns a pointer the object owns, and the class's methods neither close nor keep it through another C function. A panic or process.exit does not close: the process ends, the kernel takes descriptors back, and a panic's exit(1) flushes stdio while a failed check's or out-of-memory's _exit(1) does not. Tests: tests/cases/ffi_handle_file (a driver that counts each open and close through an fclose wrapper and prints a second close or a leaked file), ffi_handle_exits, ffi_handle_arena, caps_ffi_handle, reject_ffi_handle_deny, reject_ffi_handle_use_after_close, reject_ffi_handle_use_after_close_loop, reject_ffi_handle_use_after_close_do_while, reject_ffi_handle_use_after_close_for_update, reject_ffi_handle_use_after_close_switch, reject_ffi_handle_use_after_close_switch_clause, reject_ffi_handle_use_after_close_else, reject_ffi_handle_use_after_close_ctor, reject_ffi_handle_use_after_close_keep, reject_ffi_handle_use_after_close_keep_module, reject_ffi_handle_use_after_close_keep_module_expr, reject_ffi_handle_use_after_close_keep_field, reject_ffi_handle_use_after_close_keep_this, reject_ffi_handle_use_after_close_keep_map, reject_ffi_handle_use_after_close_keep_channel, reject_ffi_handle_ctor_this_lend, reject_ffi_handle_ctor_this_receiver, reject_ffi_handle_ctor_this_store, reject_ffi_handle_ctor_this_alias, tests/link/ffi_handle_mutex (a lock and a handle in one using, both orders); "ffi_handle: a --target wasm32 build passes llvm-as, and llc calls counted_fclose with the handle's pointer as an i32", "ffi_handle: the --profile wasi link of ffi_handle_file and its driver (sysroot ${wasiSysroot}) closes each file once and prints the native expected .out under Node's WASI" in tests/ffi-handle.js, which tests/run.js runs |
| RT-21 | Low | (new, anchors at 5d80b08) runtime/runtime.c:1239 (nish_argv_init) |
Found while building the runtime under UBSan for the fuzzing stage (#888). nish_argv_init computed a + 1 from malloc's answer before it tested that answer for null. Arithmetic on a null pointer is undefined behaviour, so a compiler may infer a is not null and drop the nish_oom() test after it, leaving the stores below it to write through null when malloc fails. clang 18 at -O2 keeps the test (two nish_oom references in the function's IR, before and after the fix), so no build is known to be affected |
Fixed. The null test comes first. No regression test: the path needs malloc to fail for the program's first allocation, and the compiled code is the same before and after. The runtime is now built under -fsanitize=address,undefined by tests/run.js's runtime_sanitize section, which tests/fuzz/README.md describes. Tests: runtime_sanitize: the runtime unit test (${label}) runs clean under ASan and UBSan |
| RT-22 | Low | (new) runtime/runtime-net.c:327 (nish_tcp_connect), :334 (its TCP_NODELAY) |
tcpConnect left Nagle's algorithm on (#942), so a client that writes a small request behind another one waits on a delayed ACK; libpq and Go's net package turn it off on every client socket, and a program wanting the same had to declare setsockopt itself, an untyped call into libc with a length the program spells (bench/postgres/main.ts did). It changes when bytes leave, not who may connect or what anyone reads |
Fixed. The runtime sets TCP_NODELAY on the socket before connect, so the option holds from the first byte whether or not the connection finishes at once. A refused set closes the socket and answers the error, so no descriptor leaks and no socket is handed out without the option. The signature is unchanged, and the bench's setsockopt declaration is gone. Test: ${name}: native output matches .out for net_tcp_nodelay (its connecting export opens a client socket to a flag-0 listener, and getsockopt reads TCP_NODELAY 1 on it; 0 before the fix) |
| RT-23 | Low | (new) runtime/runtime-net.c:396 (nish_net_set_option), :105 (ENOTSOCK in nish_net_err) |
netSetOption (part of #982) hands a program setsockopt on a descriptor it names. Passed through raw, a level, an option number and a value would let a program set any option the kernel has, among them ones that change who may bind or read (SO_REUSEPORT, SO_BINDTODEVICE) or that take a pointer-sized struct the runtime would then read as a bare int, and an option number means different things on Linux and Darwin |
Decided. The program names an option by the runtime's own id, 1 to 5, and the runtime maps it to a fixed level and name: TCP_NODELAY, TCP_QUICKACK (Linux only, -95 on Darwin, where it does not exist), SO_KEEPALIVE, SO_SNDBUF and SO_RCVBUF. Every one takes an int, which is what is passed, by the runtime's own sizeof, so no length is the program's to spell. Any other id, 0 and negatives included, is -22 before any call, so there is no passthrough and a later id cannot change an old program's meaning. None of the five changes who may connect or what anyone reads: the two TCP_ options change when bytes leave or are acknowledged, keep-alive when an idle peer is probed, and the buffers how much the kernel holds, within its own rmem_max / wmem_max caps for an unprivileged process. The descriptor is the program's, as for every nish:net call; one that is closed answers -9 and one that is not a socket -88, Darwin's ENOTSOCK translated to Linux's number as the other codes are. Tests: ${name}: native output matches .out for net_set_option (getsockopt reads each option back after a set on and off, both buffers at least what was asked, Linux's doubled send buffer, -22 for ids 0, 6 and -1, -9 on a closed listener and -88 on a pipe), ${name}: rejected with ${needles...} for reject_net_set_option_wasm |
| RT-24 | Medium | (new, anchors at fe02a75b) runtime/runtime-os.c:264 (nish_put_file_mode, the fchmod at :274), :284 (nish_write_file_mode), :292 (nish_create_file_exclusive); runtime/node.mjs (putFileMode) |
A program writing a secret (#992's credential store) had only writeFileSync, which creates at 0644 under the umask and never changes the mode of a file already there: a key written over a 0644 file stays world-readable, and the chmod a program could add afterwards leaves a window in which another user can open the file and read the secret. The open's own mode argument does not close it either: the umask can only narrow it, so a 0o640 asked for may come out 0o600, and a file that exists keeps its old mode |
Fixed by the design of writeFileSyncMode and createFileExclusiveSync. Each opens with the mode and then fchmods the descriptor to exactly mode before nish_put_all writes the first byte, so the file is never readable under a wider mode while it holds the data, the umask cannot change the answer, and a 0644 file is narrowed before its contents become the secret. A mode outside 0..0o777 is -EINVAL before anything is opened: the setuid, setgid and sticky bits are refused rather than set on a secret, where fchmod would apply them and open would drop them. A winner of createFileExclusiveSync whose write or close then fails unlinks the path before it answers (the unlink after the close in nish_put_file_mode, and fs.unlinkSync in putFileMode), so a failed create leaves no half-written lock for every later caller to find taken. Node's twin opens with the mode and fchmodSyncs before its write loop. What fchmod cannot reach: a descriptor another process opened while an existing file was wider is not revoked and reads what is written after, so the guide and LANGUAGE.md replace a secret by writing a temporary at 0600 beside it and renameSyncing it over, a new inode no earlier descriptor names. The write is in place, after O_TRUNC: a file the process may write but does not own is emptied before fchmod answers -EPERM, and a crash leaves it empty or partial, which the same temporary-and-rename route also covers. Under WASI the three answer -ENOSYS; no test runs that path. Tests: fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js read each mode back by stat under umask 022 and 077 (a new file at 0600, a 0644 file rewritten at 0600 with the new contents, a 0644 asked for under umask 077 kept 0644, and an exclusive create whose write fails under ulimit -f 0 answering -27 EFBIG with nothing left at the path); tests/cases/io_write_file_mode (setuid 0o4600 is -22), io_create_exclusive (setgid 0o2600 is -22 and creates nothing) |
| RT-25 | Medium | (new, anchors at fe02a75b) runtime/runtime-os.c:264 (O_NOFOLLOW in nish_put_file_mode's open) |
A secret written to a path in a directory another user can write is the classic link attack: plant a symbolic link at the name and the write goes to the file it names, or fchmod narrows or widens a file the attacker chose. O_EXCL alone refuses a link that exists, but a truncating write does not |
Fixed. The open passes O_NOFOLLOW (with O_CLOEXEC), so a symbolic link as the last component is -ELOOP and neither the link nor its target is opened, written or fchmoded; O_EXCL answers -EEXIST for a link, dangling or not, before it would be followed. A linked directory on the way still resolves, as RT-4's writes do. Under Node the flag set carries O_NOFOLLOW the same way. Tests: fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js (a link at the path: -40 ELOOP and the target's contents and mode untouched; createFileExclusiveSync at a dangling link: -17, and nothing created where it points) |
| RT-26 | Low | (new, anchors at fe02a75b) runtime/runtime-os.c:300 (nish_mkdir_mode) |
mkdirSyncMode cannot set an exact mode the way the file pair does: mkdir(2) applies the umask, and a chmod after it is by path, so it can be raced onto a name swapped in its place. A directory that already exists keeps its mode, which may be wider than asked |
Decided, by contract. The umask applies and is documented in docs/LANGUAGE.md: it only ever narrows, so a 0o700 directory is 0o700 under any umask that leaves the owner's bits, which is the mode a secret's directory wants; no chmod follows. An existing name answers -17 EEXIST and its mode is left as it was, never changed by path, so a caller that needs the mode checks it with lstatOwnerModeSync. Tests: fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js (0700 under umask 022, then -17 with a 0755 directory left at 0755); tests/cases/io_mkdir_mode (the sticky 0o1700 is -22) |
| RT-27 | Medium | (new, anchors at e8c5b66c) runtime/runtime-os.c:478 (nish_spawn_piped's poll loop), :483 (the stdin write) |
spawnPiped both writes a child's stdin and reads its stdout and stderr. Done in turn, write everything then read, it waits forever on any child that prints more than a pipe holds (64 KiB on Linux) before it has read all its input: cat on a large input, or any filter, fills its stdout, stops reading, and the parent stops writing. Whoever chooses the input or the child's output size hangs the program |
Fixed before landing. The three parent ends are non-blocking and one poll loop writes stdin and drains both outputs as each is ready, so neither side waits on the other. Node's spawnSync (the Node host's spawnPiped) does the same. A child that hands its pipes to a grandchild which keeps them open makes the call wait for that grandchild, as spawnSync does. Test: ${name}: native output matches .out for io_spawn_piped, its big: 0 1572864 true line (1.5 MiB through cat both ways; written in turn, it hangs) |
| RT-28 | Medium | (new, anchors at e8c5b66c) runtime/runtime-os.c:498–:505 (the cap), :519 (the kill) |
The child chooses how much it prints, and every byte is appended to an arena array. A child that never stops (yes, or one an attacker controls) grows the array until the arena fails and the program exits out of memory |
Fixed before landing. Each stream stops at NISH_PIPED_CAP, 16 MiB, the cap the Node host passes spawnSync as maxBuffer. The loop reads at most one byte past the cap, which tells a stream that ends at exactly 16 MiB from one that goes on; that byte is dropped, the child is killed with SIGKILL and reaped, and the call answers -2 with the array holding the first 16 MiB. The Node host answers -2 for ENOBUFS. Tests: ${name}: native output matches .out for io_spawn_piped, its flood: -2 16777216 line (yes on stdout), stderr flood: -2 16777216 line (yes >&2) and at cap: 0 16777216 line (exactly 16 MiB is kept whole, status 0) |
| RT-29 | Low | (new, anchors at e8c5b66c) runtime/runtime-os.c:465, :518 (pthread_sigmask), :516 (sigwait) |
A child that exits, or closes its stdin, before reading all of it makes the next write raise SIGPIPE, whose default action ends this process: a program whose child rejected its input died silently. The first fix ignored SIGPIPE for the call, and the review found that wrong twice: the disposition is process-wide, so another thread's write to a closed pipe stopped ending the process for that window, and posix_spawn keeps an ignored signal ignored across exec, so the child itself, and every child another thread started meanwhile, ran with SIGPIPE ignored and never died of a reader that had gone (sh -c 'kill -PIPE $$; echo alive' printed alive and exited 0) |
Fixed before landing. SIGPIPE is blocked on the calling thread only, after the child has started, so the child begins with the mask and dispositions this program had. A write's SIGPIPE is directed at the thread that wrote, so it stays pending; it is taken off with sigwait if it was not pending before the call, and the thread's mask is put back before the answer. A SIGPIPE another process sends this one with kill inside that window is taken too. Tests: ${name}: native output matches .out for io_spawn_piped, its unread: 0 line (true given 1.5 MiB, and the program goes on) and child sigpipe: 141 line (0 before) |
| RT-30 | Medium | (new, anchors at e8c5b66c) runtime/runtime-os.c:422–:438 (nish_pipe), :458 (the child's ends closed), :531 (every end closed on every path); :329 (nish_spawn_start's dup2 actions) |
Three pipes per call, one carrying the stdin a secret travels on. A pipe end without close-on-exec is inherited by every child the program starts later, or that another thread starts meanwhile: a later child holding the write end of an earlier child's stdin keeps that child from ever seeing end of file, so the earlier call hangs, and a later child holding a read end can read what the program sent another. An end that lands on descriptor 0, 1 or 2 (a program that closed its own stdin) would be dup2ed onto itself, which leaves close-on-exec set, and the child would start without that stream |
Fixed before landing. Linux creates each pipe with pipe2(O_CLOEXEC), and an end on 0 to 2 is moved above them with F_DUPFD_CLOEXEC. posix_spawn's dup2 clears close-on-exec on the child's three copies only. The parent closes the child's ends as soon as the spawn returns, and every end it still holds on each exit, a failed spawn and a failed pipe included. Open on Darwin: it has no pipe2, so an end is close-on-exec only once F_DUPFD_CLOEXEC has moved it, and a child another thread starts in that window inherits it; the runtime spawns from one thread, so this needs a program that spawns from two. Test: ${name}: native output matches .out for io_spawn_piped, its inherited: [] line (a child started after every other call holds no descriptor from 3 to 9: []; with plain pipe the run hangs at its first call, cat never seeing end of file) |
| RT-31 | Low | (new, anchors at e8c5b66c) runtime/runtime-os.c:483; src/builtins.ts (spawnPiped's checker) |
A program that hands a secret to a child (a password to a keychain tool, a key to openssl) had spawn's argv, the environment or a file to put it in. Argv is world-readable in /proc/<pid>/cmdline and ps on Linux, the environment is readable by the same user in /proc/<pid>/environ and is inherited by everything the child starts, and a file outlives the call |
Decided. spawnPiped takes stdin as a u8[] the runtime writes from the caller's array as it stands, so the bytes reach the child through a pipe only, and no copy is made: the kernel's pipe buffer and the child are the only other holders. The array is the caller's to wipe after the call. Test: ${name}: native output matches .out for io_spawn_piped, its secret visible: false argv printed: true line (the secret is fed to a shell that prints its own argument vector and its environment; the secret appears in neither, and the marker argument shows the vector was printed) |
| CG-3 (rest) | High | src/emit-arrays.ts (join) |
join builds its string inline in the IR, not in the runtime, so it can still exceed 2^31 − 1 bytes |
Fixed (#427): a join past 2^31 − 1 bytes fails as a concatenation does (codegen.md, CG-3), and is counted there. With RT-1 and RT-2 fixed, every other runtime source of a string or array is bounded. A listing is bounded by nish_array_grow, a name by the file system (255 bytes), getenv and argv by MAX_ARG_STRLEN (128 KiB), and realpathSync by PATH_MAX. /dev/stdin as a pipe is unreadable here, since lseek fails on a pipe |
| Property | How it holds | Pinned by |
|---|---|---|
| No string or array the runtime makes is longer than 2^31 − 1, and one of exactly 2^31 − 1 is still made | RT-1, RT-2 and RT-7 for reads, concatenation and the host entry, K1-6 for push, the bounds above for the rest |
tests/link/rt_sec_read_cap; RT-1, RT-2, RT-7 in tests/runtime-test.c; tests/link/cg_sec_push_limit |
| A path, name or argument holding a NUL names nothing | nish_cpath in nish.h, the one copy of the test, which the spawn argument loop also uses |
tests/link/rt_sec_path_nul; RT-3 in tests/runtime-test.c |
| No file is written or truncated through a symbolic link at its name; a linked directory on the way still works | O_NOFOLLOW on all four opens |
RT-4 in tests/runtime-test.c |
| Files are created 0644 and directories 0777, both under the umask (Node creates 0666; this is tighter) | the modes in runtime-os.c |
"modes" in tests/runtime-test.c |
A file writeFileSyncMode or createFileExclusiveSync writes holds exactly the mode asked for, whatever the umask or its old mode, before it holds any of the data; neither writes through a symbolic link at its name |
RT-24, RT-25: fchmod before the first write, O_NOFOLLOW |
fs_mode: under the native build the modes read back with fs.statSync are the ones asked for, a link is refused, a taken path is -17 and a failed exclusive create leaves no file and its runtime/nish.mjs twin in tests/run.js |
Of any number of processes racing createFileExclusiveSync for one path, exactly one creates it |
O_CREAT|O_EXCL |
fs_mode: of eight children racing createFileExclusiveSync for one path exactly one wins, the other seven answer -17, and the lock is the winner's at 0600 in tests/run.js |
readdirSync is sorted by bytes in O(n log n) compares |
the heapsort | RT-5 in tests/runtime-test.c; tests/cases/io_readdir |
crypto.getRandomValues fills every byte from the kernel's CSPRNG, loops on short reads and EINTR, never falls back, and refuses more than 65,536 bytes |
nish_random_fill |
"entropy" in tests/runtime-test.c; tests/cases/os_random, os_random_limit |
Math.random is not used for key material |
the standard library never calls it, and std/crypto/x509.ts tells callers to draw keys and serials from crypto.getRandomValues |
nothing automated; checked with grep -rn Math.random std/ |
| The number formatters fit their buffers at the widest value | 21 bytes for an i64 (20 digits and a sign), 32 for a double (25 used at most) | tests/runtime-test.c (INT64_MIN, and five doubles of 21 to 25 characters) |
nish_parse_number stops at the string's own end |
every string is NUL-terminated after its len bytes (literals, nish_str_new, join, argv, the reads), and the embedded-NUL case is pinned |
tests/runtime-test.c ("5\0") |
| No network call writes outside the caller's array | each checks its u8[]'s length before the call (-22); the byte ranges are checked in the IR as i32s, so a count fits the i32 answer; pollWait writes at most ready.length / 2 pairs |
test_net, test_udp, test_poll in tests/runtime-test.c; tests/cases/net_tcp_bounds, net_udp_bounds |
A TCP listener shares its port only when it asked to with tcpListenFlags flag 1, flag 2's TCP_NODELAY is set by the runtime on each accepted socket, every tcpConnect socket holds TCP_NODELAY, and an undefined flag is refused |
RT-17, RT-22 | test_tcp_reuseport and test_tcp_nodelay in tests/runtime-test.c, tests/cases/net_tcp_nodelay |
netSetOption sets only its five whitelisted options, each an int the runtime sizes, and refuses any other id |
RT-23 | tests/cases/net_set_option |
| Every socket, accepted connection, loop and signal pipe is close-on-exec and non-blocking from creation on Linux | SOCK_CLOEXEC, accept4, EPOLL_CLOEXEC, pipe2 |
test_net, test_udp, test_poll in tests/runtime-test.c |
| Every file descriptor the runtime opens is close-on-exec | O_CLOEXEC on the reads and writes (RT-12); glibc's opendir sets it itself |
RT-12 in tests/runtime-test.c |
| No output, file write or file read is cut short by a short count or an interrupted call | the loops of RT-11 and RT-13 | RT-11 and RT-13 in tests/runtime-test.c |
A handle a using opens is closed exactly once on every exit of its block that is not a panic or process.exit |
RT-20 | tests/cases/ffi_handle_file, ffi_handle_exits; tests/link/ffi_handle_mutex |
Overlapping first signalFd() calls answer one descriptor, and it hears the signal |
RT-10's compare-and-swap | RT-10 in tests/runtime-test.c |
The signal handler is async-signal-safe and keeps errno |
it makes one non-blocking write |
tests/cases/os_signal; the threads block of tests/runtime-test.c |
| No wasm allocation can claim memory the module does not have | RT-7 | tests/link/rt_sec_wasm_arena |
| A parallel range is divided without overflow and every chunk runs, however many threads could be made | RT-8; the chunks a failed pthread_create left run on the caller |
RT-8 and test_parallel_* in tests/runtime-test.c |
kill reaches one process, never a group or every process, and sends only 0, SIGINT, SIGKILL or SIGTERM |
RT-19 | tests/cases/os_kill; test_process in tests/runtime-test.c |
waitpid is not interrupted into leaving a child behind |
every handler the runtime installs is SA_RESTART: the signal pipe's for SIGINT and SIGTERM in runtime-host.c, and nish:term's in runtime-term.c (nish_term_catch) |
by reading; nothing automated |
spawnPiped cannot deadlock against its child, holds at most 16 MiB of each stream, leaves no pipe end in another child, and starts the child with SIGPIPE as the program had it |
RT-27 to RT-30 | tests/cases/io_spawn_piped |
Every runtime unit stays inside its .text* ceiling |
measured after #387: runtime.c 3,583 / 3,584 (3,708 / 3,840 threaded), runtime-os.c 1,530 / 1,536, runtime-host.c 764 / 768, runtime-net.c 2,087 / 2,304, runtime-parallel.c 286 / 320 (905 / 1,024 threaded); after WP34 N12 (RT-19), runtime-os.c 2,383 / 2,560 and runtime-parallel.c threaded 2,522 / 2,560; after netSetOption (RT-23), runtime-net.c 2,588 / 2,816; after the mode-taking writes (RT-24 to RT-26), runtime-os.c 3,174 / 3,328; after spawnPiped (RT-27 to RT-31), runtime-os.c 4,450 / 4,608 |
the budget checks in tests/run.js |
Every native binary the toolchain links carries the platform's exploit
mitigations: what nish --link and nish run build, and the released nish,
which scripts/bootstrap.sh links through the same scripts/build.sh. The
anchors are by name, not line: the hardening block in scripts/build.sh
(after the --cpu line), HARDENING_FLAGS and HARDENING_ATTRS in
src/target.ts, and their use in src/ir.ts and src/emit.ts. It closes no
finding; it raises the cost of exploiting a memory-safety bug that the
findings above did not find.
| Protection | Flag | What it closes | Where |
|---|---|---|---|
| Position independence | clang's default PIE, checked | a fixed load address for return-oriented code | ELF and Mach-O |
| Full RELRO | -Wl,-z,relro,-z,now |
overwriting a GOT entry after start-up: the table is read-only once relocated | ELF |
| Non-executable stack | -Wl,-z,noexecstack |
code injected onto the stack, even when one object lacks the .note.GNU-stack note |
ELF (Mach-O's stack is non-executable unless MH_ALLOW_STACK_EXECUTION, which is checked) |
| Stack-clash probes | -fstack-clash-protection; in the IR, "probe-stack"="inline-asm" on every definition and the probe-stack module flag |
a frame larger than the guard page stepping over it into another mapping | ELF |
| Control-flow protection | -fcf-protection=full on x86-64, -mbranch-protection=standard on arm64; in the IR, the cf-protection-*, branch-target-enforcement and sign-return-address module flags |
an indirect jump or call to anything but a function entry (IBT, BTI), and a return to an overwritten address (shadow stack, PAC), where the CPU and kernel enforce it | ELF and Mach-O |
| Stack protector | -fstack-protector-strong |
a linear overflow of a C runtime frame's buffer reaching its return address | the C runtime only |
_FORTIFY_SOURCE=3 |
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3, not in debug |
a memcpy, memset or snprintf in the C runtime past an object whose size the compiler knows |
the C runtime only |
The IR flags are what make the program's own code agree: on a .ll input clang
takes no stack-protector or code-generation flag from its command line, and the
linker marks a binary with IBT, SHSTK or BTI only when every object asks for it.
Nish code needs no stack protector or FORTIFY: an array access is
bounds-checked, and the compiler emits no memcpy it has not sized.
Cost. Measured with bench/run.mjs's programs before and after, each
linked by scripts/build.sh from the same IR less the hardening, on x86-64
Linux with clang 18, 11 interleaved rounds (21 where the first pass was
unclear), the median of each, the first round dropped. Size grows by 328 to
1,136 bytes per binary under speed, 328 to 944 under size, and about
6.6 KB (5%) under debug. Time, under speed: 12 of 14 programs moved within
the noise band (a copy of the same binary moved up to ±1.5%, 4% on lexer);
cursor is 6.1% and aes-gcm 3.6% slower, and a build without only
-fcf-protection brings both back inside the band, so CET is the cost there
(endbr64 at each entry, .plt.sec, and the layout they shift). A build
without the stack protector and FORTIFY measured the same as the full set.
fib's −9.9% (speed) and +16.4% (size), and getbyte's +9.5% (size),
are placement: their hot loops are the same instructions at a new address.
Instructions retired (bench/run.mjs --instructions, cachegrind, speed):
strbuild runs 53,754,318 against 53,229,282 for the same build without
-fstack-protector-strong (+0.99%), the guard on the runtime's string
builders. The same guard on str_from_digits (a 21-byte digit buffer, run
once per integer formatted) adds about 166,000 instructions to the map
benchmarks that format each key: map-wordcount +0.11%, map-presize
+0.24%. Those three baselines in bench/instructions.json move with this
change.
The landing pads also end the speed link's LTO partitions on x86-64 and
arm64 (scripts/build.sh): a partition boundary turns the internal functions
called across it into external ones, each opening with endbr64, and fib
ran 492,835,930 instructions with two to eight partitions against 462,974,845
with one (+6.5%; Permute +3.9%, Towers +3.2%). One partition costs link
time instead: 11.5 s against 8.3 s for tests/link/net_relay_f64 on four
cores, compile included.
What it does not cover.
build/nish-test, the stage-1 compilerscripts/bootstrap.shlinks with the seed's ownscripts/build.sh, is hardened only once a release carries this change. The programs it links, and stage 2, are hardened now.- The
wasmandwasiprofiles: linear memory has no stack to protect in this way, and the module flags are not emitted for a wasm triple. - Mach-O gets no stack-clash probes (clang ignores the flag there) and no
RELRO flag: dyld applies the binary's fixups itself, so what ELF's
-z relro -z nowasks for is dyld's to give. The check readsMH_PIEand the stack flag alone. - The x86 feature note is an AND over every object linked, the C library's
start files included. A distribution whose
crt1.olacks it gives a binary without the note, and so without IBT and the shadow stack, whatever Nish compiled.scripts/check-hardening.mjsreports that; on AArch64 it falls back to findingbtiandpaciaspin the code. - Enforcement is the CPU's and the kernel's. CET runs only where the kernel enables user shadow stacks for the binary; the marking costs nothing where it does not.
Tests: "hardening: a --profile debug link of hello is PIE, full RELRO, a
non-executable stack and control-flow protected", the same for
--profile speed, --profile size and "a nish run binary" (templated as
`hardening: ${what} of hello is PIE, full RELRO, a non-executable stack and control-flow protected`),
and "hardening: a link with every protection off is reported for PIE, RELRO,
binding, the stack, CET and the import", in tests/run.js; CI's bootstrap
job runs scripts/check-hardening.mjs on build/nish, and its test job
reads the four positive checks from the log.
.github/workflows/codeql.yml runs CodeQL's security-and-quality suite over
runtime/*.c on every change to runtime/, and weekly so a new query pack is
picked up without one. Each translation unit is compiled by itself, the way
scripts/build.sh compiles it beside a program; runtime-wasm.c is compiled
for wasm32, freestanding. Its actions are pinned to commits. The first run,
on 804120e with CodeQL 2.23.3, raised four alerts and no defect:
| Rule | Where (804120e) |
Triage |
|---|---|---|
cpp/overflow-buffer (9.3) |
runtime.c:194, nish_free_arena |
False positive. memset(&nish_arena, 0, sizeof nish_arena) clears the object by its own size. struct nish_arena is defined twice, in runtime.c (64-bit pointers) and runtime-wasm.c (32-bit), and the one database holds both; the alert pairs this unit's 32-byte sizeof with the other unit's layout. The two never link together. |
cpp/path-injection (7.5) |
runtime-os.c:396, nish_cgroup_cpus |
Not a defect. The path is the 0:: line of /proc/self/cgroup, which the kernel writes, under /sys/fs/cgroup. Whoever can rename this process's cgroup already controls the process. The copy is bounded (len > sizeof path - 24 returns early), the file is opened read-only, and only a leading number is parsed from it. |
cpp/implicit-function-declaration ×2 |
runtime-wasm.c:55, :61 |
False positive. __builtin_wasm_memory_size and __builtin_wasm_memory_grow are clang builtins for wasm32, which CodeQL's extractor does not know. |
A new alert is triaged the same way: fixed with a before/after test and given an RT id, or added here with the reason it is not a defect.
Not built. Recorded so the work can be picked up as one change; it touches
scripts/build.sh, which the binary-hardening work also changes.
The hazard. nish_arena_release and nish_reset_arena (runtime/runtime.c)
free every chunk newer than the mark with free, and rewind off inside the
chunk the mark is in. A value bumped after the mark and still referenced after
the release (the checker's escape rules are meant to make that impossible, and
nish_arena_keep is the one sanctioned way out) reads memory that the next
allocation reuses: freed chunks go back to malloc, the rewound part of the
kept chunk to the next bump. Nothing traps; the program reads whatever was
written there next. That is the use-after-free a compiler bug in the escape
analysis would turn into, and today only review and the arena tests stand
against it.
The mode. A --debug-arena flag (on nish build, nish run and --link)
links a runtime compiled with -DNISH_ARENA_DEBUG, and changes nothing in the
IR. Under it:
nish_arena_growmaps each chunk withmmap, page-aligned, its header on a page of its own, instead ofmalloc.- A release or reset never returns memory for reuse. The whole pages past the
mark, in the mark's chunk and in every newer chunk, are
mprotected toPROT_NONEand kept mapped; the partial page holding the mark is filled with a poison byte (0xDB) from the mark to its end.capis cut to the mark, so the next allocation grows a fresh chunk rather than bumping into the poisoned page. - A stale read of a whole freed page traps with SIGSEGV at the read; a read of
the poisoned tail reads
0xDBbytes, which the arena tests can assert. - The runtime's SIGSEGV path names the arena when the fault address lies in a protected chunk, so a failure reads as a stale arena reference, not a crash.
Memory only grows under the mode, which is the cost of never reusing an address; it is for tests and fuzzing, never for a release build.
What it needs. The flag in src/compile.ts and its --help line; a
debug-arena runtime variant in scripts/build.sh and in the runtime cache's
key, so a cached normal runtime is never linked under it; a docs/LANGUAGE.md
rule and a guide page for the flag; the arena tests (tests/cases/arena_* and the
runtime's arena checks in tests/run.js) run once more under it; and a negative test, a C
harness that keeps a pointer across nish_arena_release and must die with
SIGSEGV. The fuzzers would then run under it.
docs/LANGUAGE.md, the file builtins:readFileSync,readFileSyncOrNullandreadFileBytesSynctreat a file longer than 2^31 − 1 bytes as unreadable, in both number modes. That matches Node.writeFileSync,appendFileSyncandspawnSyncTo's two paths refuse a symbolic link as the last component, where Node follows it.- Every path, name and argument holding a NUL is refused: null, false, -1
or
cannot …, as for a missing path. Node also refuses these, by throwing.
docs/LANGUAGE.md, strings: a concatenation or template whose result would pass 2^31 − 1 bytes fails as an allocation does (nish: out of memory, exit 1), in both number modes.joindoes not yet (CG-3). Since #427,joindoes too, anddocs/LANGUAGE.mdsays so.runtime/shim.mjs(the Node twin): done in #387.writeFileSync,appendFileSyncandspawnImpl'sstreamopen withO_NOFOLLOW, so the two runtimes agree on RT-4, andruntime/nish.d.ts's comments on those builtins say so. No check intests/run.jsdrives the shim through a link yet.docs/ARCHITECTURE.md(the runtime budget table) anddocs/wp7-runtime.md§"Runtime additions and budget": the "Today" figures are now those in the last row of the properties table. No ceiling moved.docs/ARCHITECTURE.mdanddocs/wp7-runtime.md:nish_oomis a function, andruntime-host.calso holds the RT-9 primitives.docs/ARCHITECTURE.md(the Runtime row, "The header is the public C ABI for both"): the header also defines onestatic inline,nish_cpath, which every path the runtime hands the OS goes through. It is written with__builtin_strlen, so the header still includes no libc header and still compiles for wasm32.docs/security/codegen.md: CG-9 is fixed (RT-7), and the runtime sources of CG-3 are closed (RT-1, RT-2); onlyjoinremains. Since #427joinis bounded too, so none remains.docs/security/cli.md: CLI-6 is fixed in the runtime (RT-4) and reaches the compiler with the next release. CLI-7 and CLI-9 have their primitives (RT-9).std/README.md(crypto): the K1-6 caveat fromcrypto-k1.mdnow reads: no array or string the runtime makes passes 2^31 − 1, and a string built byjoinstill can (CG-3). Since #427 no string built byjoincan either, and the caveat says nothing passes 2^31 − 1.src/runtime.ts(CG-8): done in #387. The declarations of the file calls,nish_str_concatandnish_alloc_arraysay theirwillreturnis the approximation CG-8 records. Dropping it is #382's. Since done by #427: none of them iswillreturnnow (CG-8).