Fix FreeBSD build and add chroot + Capsicum security hardening
FreeBSD User
8160d21332724acc90b96ff871a084173b540c66
--- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SCHEME ?= scheme -JERBOA ?= $(HOME)/mine/jerboa/lib +JERBOA ?= $(HOME)/jerboa/lib LIBDIRS = lib:$(JERBOA) .PHONY: all build test clean jdns jdns-data --- a/bin/jdns-data.ss +++ b/bin/jdns-data.ss @@ -1,3 +1,3 @@ #!chezscheme (import (chezscheme) (jerboa-dns main)) -(apply run-jdns-data! (cdr (command-line))) +(run-jdns-data! (cdr (command-line))) --- a/bin/jdns.ss +++ b/bin/jdns.ss @@ -1,3 +1,3 @@ #!chezscheme (import (chezscheme) (jerboa-dns main)) -(apply run-jdns! (cdr (command-line))) +(run-jdns! (cdr (command-line))) --- a/lib/jerboa-dns/cdb.sls +++ b/lib/jerboa-dns/cdb.sls @@ -17,7 +17,7 @@ cdb-hash ;; Reader - open-cdb-reader cdb-reader? + open-cdb-reader open-cdb-reader/bytevector cdb-reader? cdb-reader-close! cdb-find cdb-find-all @@ -80,6 +80,11 @@ (loop (+ off n))))))) (make-cdb-reader bv #f))) + (define (open-cdb-reader/bytevector bv) + ;; Create a CDB reader from a pre-read bytevector. + ;; Used when the file was read via openat() in Capsicum capability mode. + (make-cdb-reader bv #f)) + (define (get-file-size path) (let ([p (open-file-input-port path)]) (let ([len (port-length p)]) --- a/lib/jerboa-dns/server.sls +++ b/lib/jerboa-dns/server.sls @@ -2,8 +2,22 @@ ;;; (jerboa-dns server) — UDP DNS server with privilege drop and sandboxing ;;; ;;; Translates djbdns server.c + tinydns.c startup sequence. -;;; Enhanced with Landlock filesystem confinement and seccomp syscall -;;; filtering after privilege drop. +;;; Security layers (applied in order): +;;; 1. Bind socket to port (requires root for port 53) +;;; 2. chroot() to data directory (FreeBSD) or chdir (Linux) +;;; 3. Drop privileges via setgid/setuid +;;; 4. FreeBSD: cap_rights_limit on non-socket fds (restrict operations) +;;; 5. Linux: Landlock filesystem restriction (if available) +;;; +;;; After sandboxing, the process can only: +;;; - Receive/send UDP on the bound socket +;;; - Read files in the chroot (data.cdb for zone lookups) +;;; - Write to stderr (logging) +;;; +;;; Capsicum cap_enter() is NOT used because sendto() on UDP sockets +;;; requires routing table access which is blocked in capability mode +;;; (ECAPMODE). Instead we use chroot + privdrop + cap_rights_limit, +;;; matching djbdns's own security model. (library (jerboa-dns server) (export @@ -20,19 +34,43 @@ (jerboa-dns response) (jerboa-dns lookup)) + ;; ========== Platform Detection ========== + + (define (string-contains-substr str sub) + (let ([slen (string-length str)] + [sublen (string-length sub)]) + (let lp ([i 0]) + (cond + [(> (+ i sublen) slen) #f] + [(string=? (substring str i (+ i sublen)) sub) #t] + [else (lp (+ i 1))])))) + + (define *machine-type-str* (symbol->string (machine-type))) + (define *on-freebsd* (string-contains-substr *machine-type-str* "fb")) + (define *on-linux* (string-contains-substr *machine-type-str* "le")) + ;; ========== Server Configuration ========== (define-record-type server-config (fields ip ;; string (bind address, default "0.0.0.0") port ;; integer (default 53) - root-dir ;; string (chdir here after binding) + root-dir ;; string (chroot here after binding) uid ;; integer or #f (setuid after binding) gid ;; integer or #f (setgid after binding) data-file ;; string (CDB filename, default "data.cdb") ) (nongenerative server-config)) + ;; ========== Load libc ========== + ;; On FreeBSD, socket/network functions require explicit libc loading. + + (define _libc + (or (guard (e [#t #f]) (load-shared-object "libc.so.7")) + (guard (e [#t #f]) (load-shared-object "libc.so.6")) + (guard (e [#t #f]) (load-shared-object "libc.so")) + (guard (e [#t #f]) (load-shared-object "")))) + ;; ========== FFI for socket operations ========== (define c-socket (foreign-procedure "socket" (int int int) int)) @@ -49,27 +87,66 @@ (define c-setgid (foreign-procedure "setgid" (unsigned) int)) (define c-chdir (foreign-procedure "chdir" (string) int)) - ;; Constants + ;; ========== FFI for chroot (FreeBSD/Linux) ========== + + (define c-chroot + (guard (e [#t (lambda (path) -1)]) + (foreign-procedure "chroot" (string) int))) + + ;; ========== FFI for Capsicum cap_rights_limit ========== + + (define c-cap-rights-limit + (if *on-freebsd* + (guard (e [#t (lambda (fd rights) -1)]) + (foreign-procedure "cap_rights_limit" (int void*) int)) + (lambda (fd rights) -1))) + + ;; ========== Platform-specific Constants ========== + (define AF_INET 2) (define SOCK_DGRAM 2) - (define SOL_SOCKET 1) - (define SO_REUSEADDR 2) + + ;; SOL_SOCKET: 0xffff on FreeBSD/macOS, 1 on Linux + (define SOL_SOCKET (if *on-freebsd* #xffff 1)) + ;; SO_REUSEADDR: 4 on FreeBSD, 2 on Linux + (define SO_REUSEADDR (if *on-freebsd* 4 2)) + (define SOCKADDR_IN_SIZE 16) (define INET_ADDRSTRLEN 16) (define MAX-PACKET 512) ;; DNS UDP limit + ;; Capsicum rights constants (raw bits, without CAPRIGHT version marker) + ;; pack-cap-rights adds the (1<<57) / (1<<58) index markers. + (define CAP_READ #x0000000000000001) + (define CAP_WRITE #x0000000000000002) + (define CAP_SEEK #x000000000000000c) + (define CAP_FSTAT #x0000000000080000) + (define CAP_LOOKUP #x0000000000000400) + ;; Index-1 rights + (define CAP_EVENT #x0000000000000020) + + ;; cap_rights_t layout: cr_rights[0] = (1<<57) | idx0-bits + ;; cr_rights[1] = (1<<58) | idx1-bits + (define CAP_RIGHTS_SIZE 16) + ;; ========== Socket Helpers ========== (define (make-sockaddr-in address port) (let ([buf (foreign-alloc SOCKADDR_IN_SIZE)]) - ;; Zero + ;; Zero the buffer (do ([i 0 (+ i 1)]) ((= i SOCKADDR_IN_SIZE)) (foreign-set! 'unsigned-8 buf i 0)) - ;; sin_family = AF_INET - (foreign-set! 'unsigned-short buf 0 AF_INET) - ;; sin_port = htons(port) + ;; sin_family — layout differs between FreeBSD and Linux + (if *on-freebsd* + (begin + ;; FreeBSD: sin_len (uint8) at offset 0, sin_family (uint8) at offset 1 + (foreign-set! 'unsigned-8 buf 0 SOCKADDR_IN_SIZE) + (foreign-set! 'unsigned-8 buf 1 AF_INET)) + ;; Linux: sin_family (uint16) at offset 0 + (foreign-set! 'unsigned-short buf 0 AF_INET)) + ;; sin_port = htons(port) at offset 2 (same on both) (foreign-set! 'unsigned-short buf 2 (c-htons port)) - ;; sin_addr (offset 4) + ;; sin_addr at offset 4 (same on both) (when (= (c-inet-pton AF_INET address (+ buf 4)) 0) (foreign-free buf) (error 'make-sockaddr-in "invalid address" address)) @@ -79,12 +156,74 @@ (c-ntohs (foreign-ref 'unsigned-short sa 2))) (define (sockaddr-in-addr-str sa) + ;; Read the IP address as a dotted-quad string from sockaddr_in. + ;; inet_ntop writes a null-terminated C string to the buffer. (let ([out (foreign-alloc INET_ADDRSTRLEN)]) (c-inet-ntop AF_INET (+ sa 4) out INET_ADDRSTRLEN) - (let ([s (foreign-ref 'string out 0)]) + (let ([s (foreign-cstring->string out)]) (foreign-free out) s))) + (define (foreign-cstring->string ptr) + ;; Read a null-terminated C string from foreign memory. + (let loop ([i 0] [chars '()]) + (let ([b (foreign-ref 'unsigned-8 ptr i)]) + (if (= b 0) + (list->string (reverse chars)) + (loop (+ i 1) (cons (integer->char b) chars)))))) + + ;; ========== Capsicum fd Restriction (FreeBSD) ========== + + (define (pack-cap-rights idx0-bits idx1-bits) + ;; Pack capability rights into a cap_rights_t structure. + (let ([mem (foreign-alloc CAP_RIGHTS_SIZE)]) + ;; cr_rights[0] = (1 << 57) | index-0 rights + (foreign-set! 'unsigned-64 mem 0 + (bitwise-ior (bitwise-arithmetic-shift-left 1 57) idx0-bits)) + ;; cr_rights[1] = (1 << 58) | index-1 rights + (foreign-set! 'unsigned-64 mem 8 + (bitwise-ior (bitwise-arithmetic-shift-left 1 58) idx1-bits)) + mem)) + + (define (cap-rights-limit! fd idx0-bits idx1-bits) + ;; Restrict fd to only the specified Capsicum rights. + ;; Works regardless of whether the process is in capability mode. + (let ([rights (pack-cap-rights idx0-bits idx1-bits)]) + (let ([rc (c-cap-rights-limit fd rights)]) + (foreign-free rights) + (when (< rc 0) + (display (format "jdns: warning: cap_rights_limit failed for fd ~a\n" fd) + (current-error-port)) + (flush-output-port (current-error-port)))))) + + (define (apply-capsicum-fd-restrictions!) + ;; Restrict stdio fds using Capsicum cap_rights_limit. + ;; These restrictions are permanent and enforced at the kernel level, + ;; independent of capability mode. + ;; + ;; Note: We restrict stdin/stdout/stderr but must include seek rights + ;; because Chez Scheme probes fd capabilities (e.g., fstat, lseek) + ;; when initializing ports. Without these, port operations may fail. + + ;; Stdin: read + fstat + seek + (cap-rights-limit! 0 + (bitwise-ior CAP_READ CAP_FSTAT CAP_SEEK) + CAP_EVENT) + + ;; Stdout: write + fstat + seek + (cap-rights-limit! 1 + (bitwise-ior CAP_WRITE CAP_FSTAT CAP_SEEK) + CAP_EVENT) + + ;; Stderr: write + fstat + seek + (cap-rights-limit! 2 + (bitwise-ior CAP_WRITE CAP_FSTAT CAP_SEEK) + CAP_EVENT) + + (display "jdns: capsicum fd restrictions applied\n" + (current-error-port)) + (flush-output-port (current-error-port))) + ;; ========== Query Processing ========== ;; One response-state reused across queries (like djbdns global) @@ -96,7 +235,14 @@ (bytevector-u8-set! pkt i (foreign-ref 'unsigned-8 pkt-buf i))) (guard (e [#t - ;; On any error, send SERVFAIL + ;; On any error, send SERVFAIL and log + (guard (e2 [#t (void)]) + (display (format "jdns: query error: ~a\n" + (if (message-condition? e) + (condition-message e) + "unknown")) + (current-error-port)) + (flush-output-port (current-error-port))) (let ([err-rs (new-response-state)]) (when (>= pkt-len 12) (response-query! err-rs (make-bytevector 1 0) DNS-T-A DNS-C-IN) @@ -121,7 +267,7 @@ (when cdb ;; Build client IP bytevector for location lookup (let ([client-ip (make-bytevector 4 0)]) - ;; Extract from sockaddr + ;; Extract from sockaddr (sin_addr at offset 4) (do ([i 0 (+ i 1)]) ((= i 4)) (bytevector-u8-set! client-ip i (foreign-ref 'unsigned-8 (+ client-addr 4) i))) @@ -194,12 +340,27 @@ (current-error-port)) (flush-output-port (current-error-port)) - ;; 4. chdir to data directory + ;; 4. chroot + chdir to data directory + ;; chroot restricts filesystem access to this directory tree. + ;; Must be done before dropping privileges (requires root). (when root-dir - (when (= (c-chdir root-dir) -1) - (error 'run-server! "cannot chdir" root-dir))) - - ;; 5. Drop privileges (gid before uid) + (let ([chroot-ok (= (c-chroot root-dir) 0)]) + (if chroot-ok + (begin + (c-chdir "/") + (display (format "jdns: chroot to ~a\n" root-dir) + (current-error-port)) + (flush-output-port (current-error-port))) + (begin + ;; chroot failed (not root?) — fall back to chdir + (when (= (c-chdir root-dir) -1) + (error 'run-server! "cannot chdir" root-dir)) + (display (format "jdns: chdir to ~a (chroot unavailable)\n" root-dir) + (current-error-port)) + (flush-output-port (current-error-port)))))) + + ;; 5. Drop privileges (gid before uid, as setuid may remove + ;; the ability to call setgid) (when gid (when (= (c-setgid gid) -1) (error 'run-server! "cannot setgid" gid))) @@ -213,7 +374,17 @@ (current-error-port)) (flush-output-port (current-error-port))) - ;; 6. Main loop + ;; 6. Apply OS-level sandbox (defense in depth) + (cond + [*on-freebsd* + ;; Capsicum: restrict stdio fd rights (kernel-enforced, permanent) + ;; chroot already restricts filesystem, cap_rights_limit hardens fds + (apply-capsicum-fd-restrictions!)] + [*on-linux* + ;; Landlock: restrict filesystem access (if available) + (enter-landlock-sandbox!)]) + + ;; 7. Main loop (let ([recv-buf (foreign-alloc 65536)] [client-addr (foreign-alloc SOCKADDR_IN_SIZE)] [addrlen-buf (foreign-alloc 4)] @@ -238,4 +409,31 @@ (loop)))))) + ;; ========== Landlock Sandbox (Linux) ========== + + (define (enter-landlock-sandbox!) + ;; On Linux, restrict filesystem access to current directory only. + ;; Uses Landlock ABI if available (Linux 5.13+). + ;; Graceful fallback if not available. + (guard (e [#t + (display "jdns: landlock not available — running without filesystem sandbox\n" + (current-error-port)) + (flush-output-port (current-error-port))]) + ;; Try to load the Jerboa Landlock module dynamically + (let ([ll-available? (guard (e [#t #f]) + (eval '(begin + (import (std security landlock)) + (landlock-available?)) + (interaction-environment)))]) + (when ll-available? + (eval '(begin + (import (std security landlock)) + (let ([rs (make-landlock-ruleset)]) + (landlock-add-read-only! rs ".") + (landlock-install! rs))) + (interaction-environment)) + (display "jdns: landlock filesystem sandbox active\n" + (current-error-port)) + (flush-output-port (current-error-port)))))) + ) ;; end library new file mode 100644 --- /dev/null +++ b/vs.md @@ -0,0 +1,45 @@ +# jerboa-dns (Scheme) vs djbdns (C) — Security Comparison + +## Where jerboa-dns is More Secure + +**Memory-safe DNS parsing.** The entire protocol, CDB, response, lookup, and zone-compiler modules (7 of 8 files, ~1500 lines) use Chez Scheme bytevectors with automatic bounds checking. A malformed DNS packet that would be a buffer overflow in C becomes a caught exception here. DJB was famously careful, but his bounds checks are manual — one mistake and you have a CVE. Here, the runtime enforces it. + +**No integer overflow in hash/length math.** Scheme has arbitrary-precision integers. CDB hash computation, packet length arithmetic, domain label walking — none of these can silently wrap around. In djbdns's C, these are `uint32` operations where wrapping is a known class of bugs. + +**GC eliminates use-after-free and double-free.** The CDB reader, response state, bytevectors — all garbage collected. In djbdns, the global response buffer and CDB mmap'd regions require careful lifetime management. + +## Where djbdns is More Secure + +**Massive runtime attack surface.** djbdns is ~2000 lines of C with zero library dependencies (not even libc for the critical path — DJB reimplemented string ops). jerboa-dns pulls in the entire Chez Scheme runtime: JIT compiler, garbage collector, foreign function interface, port system. A vulnerability in Chez itself (JIT codegen, GC heap corruption) would be exploitable. DJB's attack surface is tiny by comparison. + +**The FFI boundary is just as unsafe as C.** The server module has 44 FFI operations — `foreign-alloc`, `foreign-ref`, `foreign-set!` with raw pointers. These have zero bounds checking. The `foreign-cstring->string` helper scans memory until a null byte — if `inet_ntop` somehow didn't null-terminate, that's an unbounded read, same as C. The sockaddr struct packing is manual offset math, same as C. + +**GC pauses under load.** djbdns has deterministic, bounded response times — no allocator locks, no stop-the-world pauses. jerboa-dns allocates bytevectors per-query (CDB reader, response state, domain copies). Under heavy query load, GC pauses could cause packet drops or timing side-channels. + +**Larger binary = larger ROP gadget surface.** A Chez Scheme process has a JIT code heap, a rich standard library, and `eval` capabilities. An attacker who gets code execution has far more to work with than in a stripped, static djbdns binary. + +## Roughly Equal + +**OS-level sandboxing.** Both use the same model: bind socket, chroot, drop privileges. jerboa-dns adds `cap_rights_limit` on stdio fds on FreeBSD, which djbdns doesn't do. Both are constrained by the same UDP `sendto` vs Capsicum limitation. + +**CDB atomic updates.** Both open-read-close the CDB per query, allowing live zone updates via rename. + +**Protocol correctness.** The lookup engine is a direct translation of `tdlookup.c` — same logic, same CNAME-following depth limit, same wildcard walk. + +## Summary Table + +| Dimension | jerboa-dns (Scheme) | djbdns (C) | +|---|---|---| +| Buffer overflow in DNS parsing | Impossible (runtime bounds check) | Possible (manual checks) | +| Integer overflow | Impossible (bignums) | Possible (uint32 wrapping) | +| Use-after-free | Impossible (GC) | Possible (manual lifetime) | +| FFI/socket code safety | Same as C | Same | +| Runtime attack surface | Large (Chez JIT + GC + stdlib) | Tiny (~2000 LOC, no deps) | +| Latency determinism | GC pauses | Deterministic | +| Exploit gadgets | Abundant (JIT heap, eval) | Minimal (stripped static) | + +## Bottom Line + +The DNS *application logic* is meaningfully safer — you can't corrupt memory through a malformed query. But the *system boundary* (FFI, runtime) is a larger target than djbdns's hand-rolled C. DJB traded developer convenience for minimal attack surface. jerboa-dns trades attack surface for memory safety in the application layer. Neither dominates the other — they're different points on the security trade-off curve. + +The biggest real-world difference: if someone finds a new class of DNS packet exploit, jerboa-dns survives (bounds-checked bytevectors) and djbdns might not. If someone finds a Chez Scheme runtime vulnerability, jerboa-dns is exploitable and djbdns is immune.