Add binary hardening modules: antidebug, seccomp, integrity
ober
26766fc8260e205c4f72cba4f86191d3c5648c62
new file mode 100644 --- /dev/null +++ b/docs/harden-usage.md @@ -0,0 +1,527 @@ +# Hardening API Usage Guide + +Practical guide for using Jerboa's binary hardening modules from consumer projects like jerboa-shell. Covers the three new libraries — `(std os antidebug)`, `(std os seccomp)`, `(std os integrity)` — plus integration with the existing `(std os landlock-native)` and `(std crypto secure-mem)`. + +All functions are backed by `libjerboa_native.so` (Rust/ring/libc). No OpenSSL dependency. + +--- + +## Prerequisites + +The consuming project needs `libjerboa_native.so` accessible at runtime. Options: + +```bash +# Option 1: Copy to your project's lib/ directory +cp ~/mine/jerboa/lib/libjerboa_native.so ~/mine/jerboa-shell/lib/ + +# Option 2: Build it fresh +cd ~/mine/jerboa/jerboa-native-rs && cargo build --release +cp target/release/libjerboa_native.so ~/mine/jerboa-shell/lib/ + +# Option 3: For static musl builds, link libjerboa_native.a +cd ~/mine/jerboa/jerboa-native-rs && cargo build --release +# produces target/release/libjerboa_native.a +``` + +The Scheme wrappers search for the library in this order: +1. `libjerboa_native.so` (system library path / `LD_LIBRARY_PATH`) +2. `lib/libjerboa_native.so` (relative to working directory) +3. `./lib/libjerboa_native.so` + +For static binaries (musl builds), symbols are pre-registered via `Sforeign_symbol` in the C main — no runtime loading needed. + +--- + +## Quick Start: Minimal Hardening + +Add this to your program's startup (e.g., in `main.sls` or the entry point script): + +```scheme +(import (std os antidebug) + (std os seccomp) + (std os integrity)) + +;; 1. Block debugger attachment (one-shot, irreversible) +(guard (e [#t (void)]) ; tolerate failure in dev mode + (antidebug-ptrace!)) + +;; 2. Check for existing tracers +(when (antidebug-traced?) + (display "integrity violation\n" (current-error-port)) + (exit 1)) + +;; 3. Check for library injection +(when (antidebug-ld-preload?) + (display "integrity violation\n" (current-error-port)) + (exit 1)) + +;; 4. Kernel-block debug syscalls (irreversible) +(when (seccomp-available?) + (seccomp-lock!)) +``` + +--- + +## Module Reference + +### (std os antidebug) + +#### antidebug-ptrace! + +```scheme +(antidebug-ptrace!) → void +``` + +Calls `PTRACE_TRACEME` on the current process. If successful, no debugger can attach afterward. Raises `&antidebug-error` if a debugger is already attached. + +**Irreversible.** Calling twice always fails the second time (process is already self-traced). Wrap in `guard` if you want to tolerate failure in development: + +```scheme +(guard (e [(antidebug-error? e) (void)]) + (antidebug-ptrace!)) +``` + +#### antidebug-traced? + +```scheme +(antidebug-traced?) → boolean +``` + +Reads `/proc/self/status` and checks `TracerPid`. Returns `#t` if a debugger/tracer is attached, `#f` if clean. Raises on error (e.g., procfs unavailable). + +#### antidebug-ld-preload? + +```scheme +(antidebug-ld-preload?) → boolean +``` + +Checks both the current environment and `/proc/self/environ` for `LD_PRELOAD`. The `/proc/self/environ` check catches cases where an attacker set `LD_PRELOAD` at exec time then cleared it from the process environment. + +Returns `#t` if `LD_PRELOAD` is set to a non-empty value, `#f` if clean. + +#### antidebug-breakpoint? + +```scheme +(antidebug-breakpoint? addr) → boolean +``` + +Checks if the byte at `addr` (a `uptr`, unsigned pointer) is `0xCC` (INT3 software breakpoint). Useful for verifying that key function entry points haven't been patched by a debugger. + +**Warning:** `addr` must point to readable memory in the process's `.text` section. Passing an invalid address will crash. + +To get a function's address in Chez Scheme: + +```scheme +;; Use foreign-callable or inspect compiled code object addresses +;; Most useful in the C main, where you can take &function_name +``` + +#### antidebug-timing-anomaly? + +```scheme +(antidebug-timing-anomaly? max-ns) → boolean +``` + +Runs a calibration loop and measures elapsed time. If it takes longer than `max-ns` nanoseconds, returns `#t` (indicating single-stepping in a debugger). Recommended threshold: `50000000` (50ms). + +```scheme +(when (antidebug-timing-anomaly? 50000000) + (exit 1)) +``` + +#### antidebug-check-all + +```scheme +(antidebug-check-all) → alist +``` + +Runs all non-destructive checks in one call. Returns an alist: + +```scheme +((traced . #f) (ld-preload . #f) (timing . #f)) +``` + +Any `#t` value indicates a detection. The timing check uses a 50ms internal threshold. + +```scheme +(let ([results (antidebug-check-all)]) + (when (ormap cdr results) + (display "debug environment detected\n" (current-error-port)) + (exit 1))) +``` + +Or check individually: + +```scheme +(let ([results (antidebug-check-all)]) + (when (cdr (assq 'traced results)) + (log "tracer detected")) + (when (cdr (assq 'ld-preload results)) + (log "LD_PRELOAD detected"))) +``` + +--- + +### (std os seccomp) + +#### seccomp-available? + +```scheme +(seccomp-available?) → boolean +``` + +Returns `#t` if the kernel supports seccomp-bpf filtering. Should be `#t` on any Linux 3.5+ kernel with `CONFIG_SECCOMP_FILTER`. + +#### seccomp-lock! + +```scheme +(seccomp-lock!) → void +``` + +Installs a BPF filter that kills the process if any of these syscalls are attempted: + +| Syscall | Number | Why blocked | +|---------|--------|-------------| +| `ptrace` | 101 | Prevents debugger attach after startup | +| `process_vm_readv` | 310 | Prevents cross-process memory reads | +| `process_vm_writev` | 311 | Prevents cross-process memory writes | +| `personality` | 135 | Prevents `READ_IMPLIES_EXEC` (NX bypass) | + +All other syscalls remain allowed. **Irreversible** — the filter persists for the process lifetime. Also sets `PR_SET_NO_NEW_PRIVS` (required by seccomp, prevents suid escalation). + +Raises `&seccomp-error` on failure. + +**Call this AFTER all initialization is complete** — after loading shared libraries, opening files, spawning initial threads, etc. + +#### seccomp-lock-strict! + +```scheme +(seccomp-lock-strict! syscall-list) → void +``` + +Whitelist mode: ONLY the listed syscall numbers are allowed. Everything else kills the process. **Much more restrictive** — use only if you know exactly what syscalls your program needs. + +```scheme +;; Minimal set for a program that only does I/O and exits +(seccomp-lock-strict! + '(0 ; read + 1 ; write + 3 ; close + 9 ; mmap + 11 ; munmap + 12 ; brk + 60 ; exit + 231 ; exit_group + )) +``` + +**Warning:** Chez Scheme's runtime (GC, threads, I/O) uses many syscalls. Getting the whitelist wrong will kill your process with SIGSYS. Start with `seccomp-lock!` (blocklist mode) unless you have a specific need for strict mode. + +--- + +### (std os integrity) + +#### integrity-hash-self + +```scheme +(integrity-hash-self) → bytevector +``` + +Reads `/proc/self/exe` and returns its SHA-256 hash as a 32-byte bytevector. This always reads the actual binary on disk, even if the process was started via a symlink or with a different `argv[0]`. + +```scheme +(let ([hash (integrity-hash-self)]) + (printf "binary SHA-256: ~a~%" + (bytevector->hex hash))) ; you'd need a hex conversion helper + +;; Or just compare: +(define expected-hash #vu8(... 32 bytes ...)) +(unless (integrity-verify-hash expected-hash) + (exit 1)) +``` + +#### integrity-verify-hash + +```scheme +(integrity-verify-hash expected-hash) → boolean +``` + +Reads `/proc/self/exe`, SHA-256 hashes it, and compares against `expected-hash` using constant-time comparison (prevents timing side-channels). + +`expected-hash` must be a 32-byte bytevector. Raises `&integrity-error` if not. + +Returns `#t` if the binary matches, `#f` if modified. + +#### integrity-verify-signature + +```scheme +(integrity-verify-signature pubkey signature exclude-offset exclude-len) → boolean +``` + +Reads `/proc/self/exe`, optionally zeros out a region (where the signature is embedded), and verifies an Ed25519 signature. + +- `pubkey`: 32-byte bytevector (Ed25519 public key) +- `signature`: 64-byte bytevector (Ed25519 signature) +- `exclude-offset`: byte offset of embedded signature in binary (0 if external) +- `exclude-len`: length of region to zero (0 if external) + +Returns `#t` if the signature is valid, `#f` if not. Raises on invalid input sizes. + +**Build-time signing workflow:** + +1. Build the binary with a zeroed signature slot. +2. Hash the binary (with zeroed slot). +3. Sign the hash with your Ed25519 private key offline. +4. Patch the signature into the binary. +5. At runtime, `integrity-verify-signature` zeros the same region and verifies. + +```scheme +;; Runtime verification (pubkey embedded in source or config) +(define my-pubkey #vu8(... 32 bytes ...)) +(define my-sig #vu8(... 64 bytes ...)) + +(unless (integrity-verify-signature my-pubkey my-sig + #x1000 ; signature lives at offset 0x1000 + 64) ; 64 bytes to zero + (display "signature verification failed\n" (current-error-port)) + (exit 1)) +``` + +#### integrity-hash-file + +```scheme +(integrity-hash-file path) → bytevector +``` + +SHA-256 hash of an entire file. `path` is a string. Returns 32-byte bytevector. + +```scheme +;; Verify a companion file hasn't been tampered with +(define expected #vu8(...)) +(unless (bytevector=? (integrity-hash-file "/etc/myapp/config.enc") expected) + (error 'startup "config file tampered")) +``` + +#### integrity-hash-region + +```scheme +(integrity-hash-region path offset length) → bytevector +``` + +SHA-256 hash of a specific byte range within a file. `offset` and `length` are exact integers. Pass `length` = 0 to hash from `offset` to end of file. + +Useful for hashing only the `.text` section of an ELF (more robust than full-file hashing since ELF headers and debug sections may be modified by tools). + +--- + +## Integration Patterns for jerboa-shell + +### Pattern 1: Startup Hardening in main.sls + +Add a hardening phase early in the jsh startup sequence, before loading user config files: + +```scheme +;; In (jsh main) or a new (jsh harden) module + +(define (harden-startup!) + ;; Phase 1: Anti-debug (before anything sensitive loads) + (guard (e [#t (void)]) ; don't crash in dev/test + (antidebug-ptrace!)) + + ;; Phase 2: Environment checks + (let ([checks (antidebug-check-all)]) + (when (cdr (assq 'traced checks)) + (exit 1)) + (when (cdr (assq 'ld-preload checks)) + (exit 1))) + + ;; Phase 3: Kernel lockdown (AFTER all .so loading is complete) + (when (seccomp-available?) + (seccomp-lock!))) +``` + +Call `(harden-startup!)` from `main` after `Sbuild_heap` / library loading but before processing user input. + +### Pattern 2: Conditional Hardening via Environment Variable + +For development, you probably want to disable hardening: + +```scheme +(define (harden-startup!) + (unless (getenv "JSH_DEV") ; skip in dev mode + (guard (e [#t (void)]) + (antidebug-ptrace!)) + (let ([checks (antidebug-check-all)]) + (when (ormap cdr checks) + (exit 1))) + (when (seccomp-available?) + (seccomp-lock!)))) +``` + +### Pattern 3: Self-Integrity for Static Binary + +For `jsh-musl` (the static musl binary), verify the binary hash at startup: + +```scheme +;; Expected hash computed at build time and embedded in source +;; or loaded from a signed manifest file +(define (verify-binary-integrity!) + (let ([expected (load-expected-hash)]) ; from embedded data or signed file + (unless (integrity-verify-hash expected) + (display "binary integrity check failed\n" (current-error-port)) + (exit 1)))) +``` + +For the build script (`build-binary-jsh.ss`), compute the hash after linking: + +```bash +# After building jsh-musl: +HASH=$(sha256sum jsh-musl | cut -d' ' -f1) +echo "Expected hash: $HASH" +# Embed in a config file or use as a deployment check +``` + +### Pattern 4: Layered Defense in C Main (gsh-main.c / jsh-main.c) + +For the strongest protection, add checks in the C main before Chez even starts: + +```c +#include <sys/ptrace.h> + +extern int jerboa_antidebug_ptrace(void); +extern int jerboa_antidebug_check_tracer(void); +extern int jerboa_antidebug_check_ld_preload(void); +extern int jerboa_integrity_verify_hash(const unsigned char *, size_t); +extern int jerboa_seccomp_lock(void); + +int main(int argc, char *argv[]) { + // Phase 0: Before Chez init — C-level checks + if (jerboa_antidebug_ptrace() != 0) _exit(1); + if (jerboa_antidebug_check_tracer() != 0) _exit(1); + if (jerboa_antidebug_check_ld_preload() != 0) _exit(1); + + // Phase 1: Chez init + Sscheme_init(NULL); + Sregister_boot_file_bytes(...); + Sbuild_heap(NULL, NULL); + + // Phase 2: After all loading, lock syscalls + jerboa_seccomp_lock(); + + // Phase 3: Run the Scheme program + return Sscheme_script(prog_path, argc, argv); +} +``` + +To link against `libjerboa_native.a` in the musl build: + +```bash +# In build-jsh-musl.sh, add to the link step: +musl-gcc -static -o jsh-musl \ + jsh-main.o ffi-shim.o \ + -L ~/mine/jerboa/jerboa-native-rs/target/release \ + -ljerboa_native \ + -lkernel -llz4 -lz -lm -ldl -lpthread +``` + +### Pattern 5: Sandboxing with Landlock After Init + +Combine hardening with Landlock to restrict filesystem access: + +```scheme +(import (std os landlock-native) + (std os antidebug) + (std os seccomp)) + +(define (full-lockdown! home-dir) + ;; 1. Anti-debug + (guard (e [#t (void)]) (antidebug-ptrace!)) + (when (antidebug-traced?) (exit 1)) + + ;; 2. Filesystem sandbox via Landlock + (when (landlock-available?) + (landlock-enforce! + ;; Read-only paths + (list "/etc" home-dir) + ;; Read-write paths + (list (string-append home-dir "/.jsh_history") + "/tmp") + ;; Executable paths + (list "/usr/bin" "/bin"))) + + ;; 3. Syscall lockdown (LAST — after all setup) + (when (seccomp-available?) + (seccomp-lock!))) +``` + +### Pattern 6: Watchdog Thread + +For ongoing protection, spawn a background thread that periodically re-checks: + +```scheme +(import (chezscheme) + (std os antidebug)) + +(define (start-watchdog!) + (fork-thread + (lambda () + (let loop () + (sleep (make-time 'time-duration 0 5)) ; every 5 seconds + (when (antidebug-traced?) + (exit 1)) + (loop))))) +``` + +--- + +## Error Handling + +All three modules define condition types for structured error handling: + +```scheme +;; Antidebug errors +(guard (e [(antidebug-error? e) + (printf "antidebug: ~a~%" (antidebug-error-reason e))]) + (antidebug-ptrace!)) + +;; Seccomp errors +(guard (e [(seccomp-error? e) + (printf "seccomp: ~a~%" (seccomp-error-reason e))]) + (seccomp-lock!)) + +;; Integrity errors +(guard (e [(integrity-error? e) + (printf "integrity: ~a~%" (integrity-error-reason e))]) + (integrity-hash-self)) +``` + +For the Rust-side error detail, `(std os integrity)` exposes `native-last-error` internally, and integrity errors include it in the message condition. + +--- + +## Security Notes + +**Order matters.** The recommended sequence is: + +1. `antidebug-ptrace!` — must be first (blocks debugger attach) +2. `antidebug-check-all` — detect existing tracers/injection +3. `integrity-verify-hash` or `integrity-verify-signature` — verify binary +4. Load all shared libraries and open all files +5. `landlock-enforce!` — filesystem restrictions (blocks new .so loading) +6. `seccomp-lock!` — syscall restrictions (must be LAST) + +Reversing steps 5 and 6 is fine. But `seccomp-lock!` must come after all library loading and file opening, because the BPF filter is permanent. + +**Don't leak detection details.** Use the same generic error message for all failures. Don't tell an attacker which check caught them: + +```scheme +;; Good: generic message +(when (antidebug-traced?) (exit 1)) + +;; Bad: tells attacker what to bypass +(when (antidebug-traced?) + (display "TracerPid check failed\n") + (exit 1)) +``` + +**Dev mode escape hatch.** Always provide a way to disable hardening for development and testing. An environment variable (`JSH_DEV=1`) is the simplest approach. Never ship with the escape hatch enabled. new file mode 100644 --- /dev/null +++ b/docs/harden.md @@ -0,0 +1,711 @@ +# Binary Hardening Guide + +Techniques for producing Jerboa binaries that resist tampering, debugging, and reverse engineering. All techniques target the single-binary ELF described in [single-binary.md](single-binary.md) and leverage the existing Rust native backend (`jerboa-native-rs`). + +## Implementation Status + +The following Rust modules and Scheme wrappers are **implemented and tested**: + +| Module | Rust | Scheme | Tests | +|--------|------|--------|-------| +| Anti-debug (ptrace, TracerPid, LD_PRELOAD, timing) | `antidebug.rs` | `(std os antidebug)` | 4/4 pass | +| seccomp-bpf filtering | `seccomp.rs` | `(std os seccomp)` | 2/2 pass | +| Integrity (SHA-256 self-hash, Ed25519 verify, file hash) | `integrity.rs` | `(std os integrity)` | 13/13 pass | +| Landlock sandboxing | `landlock.rs` | `(std os landlock-native)` | existing | +| Secure memory | `secure_mem.rs` | `(std crypto secure-mem)` | existing | + +Sections 7 (Encrypted Boot Files) and 12 (Build Pipeline) are design-only — they require a C main entry point (e.g., `jsh-main.c`) which lives in the consuming project (jerboa-shell), not this repo. + +--- + +## Table of Contents + +1. [Threat Model](#1-threat-model) +2. [Ed25519 Code Signing](#2-ed25519-code-signing) +3. [Self-Integrity Check (SHA-256)](#3-self-integrity-check-sha-256) +4. [Anti-Debug: ptrace Self-Trace](#4-anti-debug-ptrace-self-trace) +5. [Anti-Debug: TracerPid and Environment Checks](#5-anti-debug-tracerpid-and-environment-checks) +6. [Anti-Debug: Timing Checks](#6-anti-debug-timing-checks) +7. [Encrypted Boot Files](#7-encrypted-boot-files) +8. [Symbol Stripping and Obfuscation](#8-symbol-stripping-and-obfuscation) +9. [Landlock Self-Sandboxing](#9-landlock-self-sandboxing) +10. [seccomp Post-Init Filter](#10-seccomp-post-init-filter) +11. [Secure Memory for Keys](#11-secure-memory-for-keys) +12. [Build Pipeline Integration](#12-build-pipeline-integration) +13. [Comparison with Other Languages](#13-comparison-with-other-languages) +14. [Limitations and Honest Caveats](#14-limitations-and-honest-caveats) + +--- + +## 1. Threat Model + +These protections target: + +- **Binary modification**: An attacker patches the ELF to change behavior (bypass auth, remove license checks, inject code). +- **Dynamic analysis**: An attacker attaches gdb/strace/ltrace to inspect runtime state, extract keys, or trace control flow. +- **Static analysis**: An attacker runs `strings`, `objdump`, or Ghidra on the binary to understand the Scheme code embedded in boot files. +- **Library injection**: An attacker uses `LD_PRELOAD` or `LD_LIBRARY_PATH` to intercept function calls. + +These protections do NOT target: + +- Kernel-level attackers (root with kernel module access). +- Hardware-level attacks (cold boot, bus snooping). +- Side-channel attacks on the crypto itself (ring already handles that). + +No userspace binary can fully protect itself from a sufficiently privileged attacker. The goal is to raise the cost of attack above the value of what's protected. + +--- + +## 2. Ed25519 Code Signing + +The strongest tamper-detection mechanism. An attacker who modifies the binary cannot forge a valid signature without the private key. + +### How It Works + +1. Build the binary normally. +2. Hash the ELF (excluding the signature region). +3. Sign the hash with an Ed25519 private key (kept offline). +4. Append or embed the 64-byte signature. +5. At startup, verify the signature using the embedded public key. + +### Rust Implementation + +Add to `jerboa-native-rs/src/integrity.rs`: + +```rust +use ring::signature::{Ed25519KeyPair, UnparsedPublicKey, ED25519}; +use std::fs; + +/// Verify the binary's Ed25519 signature. +/// Returns 1 if valid, 0 if invalid, -1 on error. +#[no_mangle] +pub extern "C" fn jerboa_verify_self_signature( + pubkey: *const u8, // 32-byte Ed25519 public key + sig_offset: u64, // byte offset where 64-byte signature lives +) -> i32 { + // Read /proc/self/exe (always resolves to the real binary) + let binary = match fs::read("/proc/self/exe") { + Ok(b) => b, + Err(_) => return -1, + }; + + let sig_off = sig_offset as usize; + if sig_off + 64 > binary.len() { return -1; } + + // Extract signature, then zero it for verification + let signature = binary[sig_off..sig_off + 64].to_vec(); + let mut message = binary; + // Zero out the signature region (hash what the binary looked like before signing) + for b in &mut message[sig_off..sig_off + 64] { + *b = 0; + } + + let pk = unsafe { std::slice::from_raw_parts(pubkey, 32) }; + let verify_key = UnparsedPublicKey::new(&ED25519, pk); + + match verify_key.verify(&message, &signature) { + Ok(()) => 1, + Err(_) => 0, + } +} +``` + +### C Main Integration + +In `jsh-main.c`, before `Sbuild_heap`: + +```c +// Embedded at build time by the signing script +static const unsigned char ed25519_pubkey[32] = { /* ... */ }; +#define SIG_OFFSET 0x00000000ULL // patched by signing script + +extern int jerboa_verify_self_signature(const unsigned char *pubkey, uint64_t sig_offset); + +int main(int argc, char *argv[]) { + if (jerboa_verify_self_signature(ed25519_pubkey, SIG_OFFSET) != 1) { + write(2, "integrity check failed\n", 23); + _exit(1); + } + // ... normal Chez init ... +} +``` + +### Build-Time Signing Script + +```bash +#!/bin/bash +# sign-binary.sh — run after linking jsh +BINARY="$1" +PRIVKEY="$2" # Ed25519 private key (keep offline) + +# 1. Find the signature slot (64 zero bytes at a known symbol) +SIG_OFFSET=$(nm "$BINARY" | grep '__jerboa_signature' | awk '{print "0x"$1}') + +# 2. Zero the slot, hash, sign +dd if=/dev/zero of="$BINARY" bs=1 count=64 seek=$((SIG_OFFSET)) conv=notrunc +HASH=$(sha256sum "$BINARY" | awk '{print $1}') + +# 3. Sign with Ed25519 (via a small Rust tool or openssl) +SIGNATURE=$(jerboa-sign --key "$PRIVKEY" --hash "$HASH") + +# 4. Write signature into the slot +echo -n "$SIGNATURE" | xxd -r -p | dd of="$BINARY" bs=1 count=64 seek=$((SIG_OFFSET)) conv=notrunc +``` + +### Providing the Signature Slot + +In `jsh-main.c`, reserve a known location: + +```c +// 64-byte signature slot — zeroed at compile time, filled by signing script +__attribute__((section(".jerboa_sig"))) +volatile const unsigned char __jerboa_signature[64] = {0}; +``` + +--- + +## 3. Self-Integrity Check (SHA-256) + +A simpler alternative to full code signing. Less secure (an attacker can recompute the hash), but useful as a quick sanity check or as a complement to signing. + +### The Bootstrapping Problem + +You cannot embed a SHA-256 hash of a file inside that same file. Three solutions: + +**Option A: Hash with exclusion zone** + +Hash everything except a known 32-byte region. The build script writes the hash into that region post-link. + +```c +// Reserve the hash slot +__attribute__((section(".jerboa_hash"))) +volatile const unsigned char __jerboa_expected_hash[32] = {0}; + +static int check_self_hash(void) { + // Read /proc/self/exe + int fd = open("/proc/self/exe", O_RDONLY); + // ... read entire file into buffer ... + + // Zero out the hash slot before hashing + memset(buffer + hash_slot_offset, 0, 32); + + // SHA-256 the modified buffer + unsigned char actual[32]; + jerboa_sha256(buffer, file_size, actual, 32); + + // Compare + return jerboa_timing_safe_equal(actual, 32, + (const unsigned char *)__jerboa_expected_hash, 32); +} +``` + +**Option B: ELF segment hashing** + +Hash only `.text` + `.rodata` sections (the code and constant data). This is more robust against tools that modify ELF headers or debug sections. + +```c +#include <elf.h> + +static int check_code_segments(void) { + // Parse ELF headers from /proc/self/exe + // Find PT_LOAD segments with PF_X (executable) flag + // Hash those segments only + // Compare against embedded expected hash +} +``` + +**Option C: Detached signature file** + +Store the hash in a separate `jsh.sig` file. Simplest to implement but requires distributing two files. + +### Build Script for Option A + +```bash +#!/bin/bash +BINARY="$1" +HASH_OFFSET=$(nm "$BINARY" | grep '__jerboa_expected_hash' | awk '{print "0x"$1}') + +# Zero the slot +dd if=/dev/zero of="$BINARY" bs=1 count=32 seek=$((HASH_OFFSET)) conv=notrunc + +# Hash the binary with zeroed slot +HASH=$(sha256sum "$BINARY" | cut -d' ' -f1) + +# Write hash into slot +echo -n "$HASH" | xxd -r -p | dd of="$BINARY" bs=1 count=32 seek=$((HASH_OFFSET)) conv=notrunc +``` + +--- + +## 4. Anti-Debug: ptrace Self-Trace + +A process can only have one tracer. By tracing yourself, you prevent gdb/strace from attaching. + +### Implementation + +In `jsh-main.c`, as the very first thing in `main()`: + +```c +#include <sys/ptrace.h> + +static void anti_debug_ptrace(void) { + if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) { + // PTRACE_TRACEME failed — something is already tracing us + _exit(1); + } +} +``` + +### Hardening the Check + +A single ptrace call is trivially patchable (NOP out the branch). Make it harder: + +```c +static void anti_debug_ptrace(void) { + // Call from multiple places with different consequences + volatile int result = ptrace(PTRACE_TRACEME, 0, NULL, NULL); + + // Don't branch immediately — use the result later + // to derive a value needed for decryption (see section 7) + if (result == -1) { + // Corrupt a key byte — decryption will fail silently later + // rather than giving an obvious "debugger detected" message + boot_key[0] ^= 0xFF; + } +} +``` + +This ties the anti-debug check to the decryption path. An attacker who patches out the check still gets garbage when decrypting boot files. + +--- + +## 5. Anti-Debug: TracerPid and Environment Checks + +Complementary checks that catch different attack vectors than ptrace. + +### TracerPid + +```c +static int check_tracer_pid(void) { + FILE *f = fopen("/proc/self/status", "r"); + if (!f) return 0; // can't check, proceed cautiously + + char line[256]; + while (fgets(line, sizeof(line), f)) { + if (strncmp(line, "TracerPid:", 10) == 0) { + long pid = strtol(line + 10, NULL, 10); + fclose(f); + return pid != 0; // nonzero = debugger attached + } + } + fclose(f); + return 0; +} +``` + +### LD_PRELOAD Detection + +```c +static int check_ld_preload(void) { + // Check environment + if (getenv("LD_PRELOAD") != NULL) return 1; + + // Also check /proc/self/environ in case env was cleared after load + int fd = open("/proc/self/environ", O_RDONLY); + if (fd < 0) return 0; + char buf[4096]; + ssize_t n = read(fd, buf, sizeof(buf)); + close(fd); + + // Search for LD_PRELOAD in the raw environ block + for (ssize_t i = 0; i < n - 10; i++) { + if (memcmp(buf + i, "LD_PRELOAD", 10) == 0) return 1; + } + return 0; +} +``` + +### Breakpoint Detection + +Check for `INT3` (0xCC) instructions at key function entry points: + +```c +static int check_breakpoints(void) { + // Check our own function entry points for software breakpoints + unsigned char *check_fn = (unsigned char *)&check_self_hash; + unsigned char *main_fn = (unsigned char *)&main; + + if (*check_fn == 0xCC || *main_fn == 0xCC) return 1; + return 0; +} +``` + +--- + +## 6. Anti-Debug: Timing Checks + +Debuggers slow execution. Measure critical sections and abort if they take too long. + +```c +#include <time.h> + +static void timed_check(void (*fn)(void), long max_ns) { + struct timespec t1, t2; + clock_gettime(CLOCK_MONOTONIC, &t1); + fn(); + clock_gettime(CLOCK_MONOTONIC, &t2); + + long elapsed = (t2.tv_sec - t1.tv_sec) * 1000000000L + + (t2.tv_nsec - t1.tv_nsec); + + if (elapsed > max_ns) { + _exit(1); + } +} + +// Usage: integrity check should complete in <100ms +timed_check(check_self_hash, 100000000L); +``` + +### Continuous Background Check + +After Chez is initialized, spawn a thread that periodically re-checks: + +```c +static void *watchdog_thread(void *arg) { + while (1) { + usleep(5000000); // every 5 seconds + if (check_tracer_pid()) _exit(1); + if (check_breakpoints()) _exit(1); + } + return NULL; +} + +// After Sbuild_heap, before Sscheme_script: +pthread_t watchdog; +pthread_create(&watchdog, NULL, watchdog_thread, NULL); +``` + +--- + +## 7. Encrypted Boot Files + +This is the highest-value technique unique to Jerboa's architecture. Boot files contain all Scheme source in compiled form — encrypting them prevents static analysis. + +### Architecture + +``` +Build time: + petite.boot ──→ AES-256-GCM encrypt ──→ petite_boot_enc.h (ciphertext + nonce + tag) + scheme.boot ──→ AES-256-GCM encrypt ──→ scheme_boot_enc.h + jsh.boot ──→ AES-256-GCM encrypt ──→ jsh_boot_enc.h + +Runtime: + Read encrypted arrays from .rodata + ──→ Derive decryption key + ──→ AES-256-GCM decrypt into mmap'd memory + ──→ Sregister_boot_file_bytes(decrypted) + ──→ Wipe decrypted copy after Chez loads it +``` + +### Key Derivation + +The decryption key should not be a single static value (too easy to extract). Combine multiple sources: + +```c +static void derive_boot_key(unsigned char key[32]) { + unsigned char material[128]; + int offset = 0; + + // Component 1: Embedded key fragment (32 bytes, split across functions) + memcpy(material + offset, key_fragment_1, 16); offset += 16; + memcpy(material + offset, key_fragment_2, 16); offset += 16; + + // Component 2: Binary's own code hash (ties key to unmodified binary) + unsigned char code_hash[32]; + hash_text_section(code_hash); + memcpy(material + offset, code_hash, 32); offset += 32; + + // Component 3: Compile-time constant (changes per build) + memcpy(material + offset, build_nonce, 16); offset += 16; + + // HKDF-SHA256 to derive final key + jerboa_sha256(material, offset, key, 32); + + // Wipe intermediates + explicit_bzero(material, sizeof(material)); + explicit_bzero(code_hash, sizeof(code_hash)); +} +``` + +Component 2 is the critical trick: the key depends on the binary's own `.text` section hash. If an attacker patches the anti-debug checks, the `.text` hash changes, the derived key changes, and decryption fails. This creates a cryptographic binding between the code integrity and the ability to run. + +### Decryption at Startup + +```c +static void *decrypt_boot(const unsigned char *enc_data, unsigned int enc_size, + const unsigned char *nonce, unsigned int *out_size) { + unsigned char key[32]; + derive_boot_key(key); + + // Allocate via secure memory (mlock'd, no core dump, no fork) + unsigned char *output = (unsigned char *)jerboa_secure_alloc(enc_size); + + size_t pt_len = 0; + int rc = jerboa_aead_open( + key, 32, + nonce, 12, + enc_data, enc_size, + NULL, 0, // no AAD + output, enc_size, + &pt_len + ); + + explicit_bzero(key, 32); + + if (rc != 0) { + jerboa_secure_free(output, enc_size); + return NULL;