security: harden exec-id ffi path handling
ober
d5e1f3c61e9d59d97c9b7cbfa16ad51f22fd3be4
--- a/docs/ffi-audit.md +++ b/docs/ffi-audit.md @@ -23,19 +23,19 @@ Latest summary from `tools/ffi-audit-report.ss`: (ffi-audit-report (schema "jerboa.ffi-audit/1") (scheme - (file-count 1190) + (file-count 1187) (site-count 782) (foreign-procedure 716) (load-shared-object 55) (define-ftype 4) (foreign-callable 7) - (pointer-sites 299) + (pointer-sites 298) (width-sensitive-sites 156) - (blocking-candidates 46) + (blocking-candidates 47) (blocking-without-collect-safe 0) - (hazard-site-count 352) + (hazard-site-count 351) (blocking-review-sites 0) - (safety-review-sites 352)) + (safety-review-sites 351)) (native (rust-file-count 29) (c-file-count 5) @@ -174,8 +174,13 @@ uses the same copy/free pattern before printing degraded diagnostics. The retired `(std os sandbox)` callback surface also removes its dead shell-command thunk, refuses bare Capsicum entry without fd-rights, and carries local scanner suppressions for platform metadata, rights literals, and checked FFI -slots so its focused `jerboa_security_scan` result is clean. -`secure_fs.rs` now documents +slots so its focused `jerboa_security_scan` result is clean. `(std os exec-id)` +now treats `realpath(3)` as a collect-safe blocking libc call while passing only +foreign-allocated `void*` buffers across the FFI boundary, checks NULL returns +before reading the output path, rejects interior NUL path strings, frees all +temporary C buffers with `dynamic-wind`, and bounds optional executable hashing +to 256 MiB before allocating a digest input bytevector. `secure_fs.rs` now +documents descriptor-relative `openat`/`mkdirat`, fd duplication/ownership transfer, stat buffers, directory stream lifecycle, caller output slots, atomic output publication, and test-owned fd conversion --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -718,8 +718,12 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit callback surface also removes its dead shell-command thunk, refuses bare Capsicum entry without fd-rights, and scans cleanly after local suppressions for non-secret platform metadata, Capsicum rights literals, and checked FFI - slots. - `wasm_sm.rs` now compiles under `unsafe_op_in_unsafe_fn` for the + slots. `(std os exec-id)` now resolves `realpath(3)` through a collect-safe + `void*` binding with foreign-allocated path/output buffers, explicit + NULL-return checks before path reads, dynamic-wind cleanup for temporary C + buffers, interior-NUL path rejection, and a 256 MiB cap before optional + executable hashing reads into memory. `wasm_sm.rs` now compiles under + `unsafe_op_in_unsafe_fn` for the SpiderMonkey feature, bounds C ABI buffers, validates host-memory offsets, converts poisoned handle-store locks into normal FFI errors, and documents SpiderMonkey native callback, realm, and WASM memory-copy invariants. The @@ -734,7 +738,8 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit rejects invalid accepted fds before ownership transfer, and documents socket shutdown invariants for blocked TLS I/O. The generated report now shows 390 annotated native unsafe sites, 0 remaining unsafe review sites, and 0 Scheme - blocking calls missing `__collect_safe`. + blocking calls missing `__collect_safe`; the clean-tree Scheme FFI inventory + now reports 351 remaining provisional safety-review sites. Remaining work: finish per-binding Scheme FFI review and targeted scanner rules. --- a/docs/security-reference.md +++ b/docs/security-reference.md @@ -718,7 +718,7 @@ Callers should not treat `allow-degraded?` as successful sandbox installation. ## 7. Parser Hardening -Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) now has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; per-binding remediation remains open. +Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, and `exec-id` are remediated, while broad Scheme per-binding remediation remains open. ### Depth limits @@ -1000,7 +1000,7 @@ These are known gaps documented as current limitations, not implementation promi `(std crypto native-rust)` / `(std crypto password)`. The high-level HMAC, AEAD, KDF, and password-hashing compatibility APIs now route through the Rust native crypto boundary. -- **FFI audit (Phase 5 of parser hardening) is in progress.** `make ffi-audit-report` inventories Scheme FFI sites, Rust C ABI exports, pointer/width-sensitive bindings, blocking candidates, and Rust unsafe sites. Per-binding null-return, bounds, ownership, GC-safety, and unsafe-invariant review remains open. +- **FFI audit (Phase 5 of parser hardening) is in progress.** `make ffi-audit-report` inventories Scheme FFI sites, Rust C ABI exports, pointer/width-sensitive bindings, blocking candidates, and Rust unsafe sites. Native unsafe review and selected Scheme FFI slices are remediated; the remaining Scheme per-binding null-return, bounds, ownership, and GC-safety review is tracked in [ffi-audit.md](ffi-audit.md). - **No red team evaluation.** No independent adversarial testing has been performed. - **Secure memory still exposes a raw region escape hatch.** The high-level `secure-bytevector` API is bounds-checked and integrated with --- a/docs/status.md +++ b/docs/status.md @@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`, | Area | Current state | Remaining work | |---|---|---| | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. | -| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, and the retired `(std os sandbox)` compatibility surface now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. | +| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, and `(std os exec-id)` realpath/hash helpers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. | | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. | | Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup, Linux Landlock filesystem/TCP-connect setup for requested axes, macOS Seatbelt deny-default path/exec/no-network setup for supported axes, standard worker-eval Capsicum entry on FreeBSD, explicit sandbox-axis refusal, egress proxy env wiring, and platform CI smoke for Linux/macOS/FreeBSD sandbox paths. | Keep Linux/macOS/FreeBSD parity tests current; finish arbitrary-command/proxy-aware Capsicum worker paths. | | Fuzzing | `tests/fuzz/corpus/` has 15 checked-in seed inputs, `tests/fuzz/regression/` has 11 crash/rejection regressions, `make fuzz-smoke` runs the deterministic regression gate first, and GitHub CI runs smoke fuzzing normally plus deep fuzzing on scheduled daily runs. | Keep adding minimized corpus and regression inputs for every parser/security bug found. | --- a/lib/std/os/exec-id.ss +++ b/lib/std/os/exec-id.ss @@ -45,30 +45,78 @@ (import (chezscheme) (only (jerboa core) def defstruct try catch finally) - (only (std os posix) posix-stat stat-dev stat-ino free-stat + (only (std os posix) posix-stat stat-dev stat-ino stat-size free-stat posix-access X_OK F_OK) (only (std crypto sha256-pure) sha256-hex)) ;; ---------- FFI ---------- ;; realpath(3): canonicalize a path; returns NULL on error. - ;; We pass our own buffer (PATH_MAX=4096) so the result is owned by us. + ;; The collect-safe call uses foreign-allocated C buffers only; no movable + ;; Scheme bytevector/string is borrowed across the blocking libc boundary. (def c-realpath - (foreign-procedure "realpath" (string u8*) void*)) + (foreign-procedure + __collect_safe "realpath" ; jerboa-security: suppress collect-safe-unpinned-bytevector -- realpath* passes only foreign-allocated void* path/output buffers + (void* void*) + void*)) ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- realpath* checks numeric NULL (= rc 0) before reading the output buffer (def PATH_MAX 4096) + (def (non-null-ptr? ptr) + (and ptr (not (= ptr 0)))) + + (def (string-contains-nul? s) + (let ([n (string-length s)]) + (let lp ([i 0]) + (cond + [(= i n) #f] + [(char=? (string-ref s i) #\nul) #t] + [else (lp (+ i 1))])))) + + (def (copy-bytevector-to-foreign! bv ptr len) + (let lp ([i 0]) + (when (< i len) + (foreign-set! 'unsigned-8 ptr i (bytevector-u8-ref bv i)) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- i is loop-bounded by len and callers allocate at least len bytes before copying + (lp (+ i 1))))) + + (def (call-with-foreign-buffer size k) + (let ([ptr #f]) + (dynamic-wind + (lambda () + (set! ptr (foreign-alloc size))) + (lambda () + (and (non-null-ptr? ptr) (k ptr))) + (lambda () + (when (non-null-ptr? ptr) + (foreign-free ptr)))))) + + (def (call-with-c-string s k) + (and (not (string-contains-nul? s)) + (let* ([bv (string->utf8 s)] + [len (bytevector-length bv)]) + (call-with-foreign-buffer (+ len 1) + (lambda (ptr) + (copy-bytevector-to-foreign! bv ptr len) + (foreign-set! 'unsigned-8 ptr len 0) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- ptr was allocated as len+1 bytes above, so the NUL terminator write is in bounds + (k ptr)))))) + + (def (c-buffer->string ptr limit) + (let loop ([i 0] [acc '()]) + (if (or (= i limit) + (= (foreign-ref 'unsigned-8 ptr i) 0)) ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- ptr is a non-null scoped buffer and i is checked against limit before this read + (list->string (reverse acc)) + (loop (+ i 1) + (cons (integer->char (foreign-ref 'unsigned-8 ptr i)) + acc))))) + (def (realpath* path) - (let ([buf (make-bytevector PATH_MAX 0)]) - (let ([rc (c-realpath path buf)]) - (if (= rc 0) - #f - (let loop ([i 0] [acc '()]) - (if (or (= i PATH_MAX) - (= (bytevector-u8-ref buf i) 0)) - (list->string (reverse acc)) - (loop (+ i 1) - (cons (integer->char (bytevector-u8-ref buf i)) - acc)))))))) + (call-with-c-string path + (lambda (path-ptr) + (call-with-foreign-buffer PATH_MAX + (lambda (buf) + (let ([rc (c-realpath path-ptr buf)]) + (if (= rc 0) + #f + (c-buffer->string buf PATH_MAX)))))))) ;; ---------- Record ---------- @@ -151,14 +199,36 @@ ;; ---------- Hashing ---------- - (def (read-whole-file path) - (let* ([port (open-file-input-port path)] - [bv (get-bytevector-all port)]) - (close-port port) - (if (eof-object? bv) (make-bytevector 0) bv))) + (def MAX_EXEC_HASH_BYTES (* 256 1024 1024)) + + (def (file-size path) + (try (let ([buf (posix-stat path)]) + (try (stat-size buf) + (finally (free-stat buf)))) + (catch (e) #f))) + + (def (read-file-bytevector-limited path max-bytes) + (let ([size (file-size path)]) + (cond + [(not size) #f] + [(or (< size 0) (> size max-bytes)) #f] + [else + (let ([port (open-file-input-port path)]) + (try (let ([bv (make-bytevector size 0)]) + (let lp ([off 0]) + (cond + [(= off size) + (if (eof-object? (get-u8 port)) bv #f)] + [else + (let ([n (get-bytevector-n! port bv off (- size off))]) + (if (or (eof-object? n) (= n 0)) + #f + (lp (+ off n))))]))) + (finally (close-port port))))]))) (def (hash-file path) - (try (sha256-hex (read-whole-file path)) + (try (let ([bv (read-file-bytevector-limited path MAX_EXEC_HASH_BYTES)]) + (and bv (sha256-hex bv))) (catch (e) #f))) ;; ---------- Stat helpers ---------- --- a/tests/test-limits-primitives.ss +++ b/tests/test-limits-primitives.ss @@ -188,6 +188,13 @@ (test-pred "exec-id resolves /bin/sh" (exec-id-resolve "/bin/sh") (lambda (id) (and id (exec-id? id)))) +(test-pred "exec-id hashes /bin/sh with bounded reader" + (exec-id-resolve "/bin/sh" 'hash?: #t) + (lambda (id) + (and id + (exec-id? id) + (string? (exec-id-sha256 id)) + (= (string-length (exec-id-sha256 id)) 64)))) (test "exec-id-same-file? same path" (let ([a (exec-id-resolve "/bin/sh")]