diff --git a/crates/sandlock-core/src/checkpoint/mod.rs b/crates/sandlock-core/src/checkpoint/mod.rs index ea6c6950..d77ef72d 100644 --- a/crates/sandlock-core/src/checkpoint/mod.rs +++ b/crates/sandlock-core/src/checkpoint/mod.rs @@ -59,7 +59,12 @@ impl MemoryMap { pub fn is_special(&self) -> bool { self.path.as_ref().map_or(false, |p| { - p.starts_with("[vdso]") || p.starts_with("[vvar]") || p.starts_with("[vsyscall]") + // `[vvar_vclock]` is a newer-kernel split of `[vvar]`; it is also + // kernel-provided and relocated (not captured) during restore. + p.starts_with("[vdso]") + || p.starts_with("[vvar]") + || p.starts_with("[vvar_vclock]") + || p.starts_with("[vsyscall]") }) } } diff --git a/crates/sandlock-core/src/checkpoint/resume.rs b/crates/sandlock-core/src/checkpoint/resume.rs index bf01e3fb..ebe5a936 100644 --- a/crates/sandlock-core/src/checkpoint/resume.rs +++ b/crates/sandlock-core/src/checkpoint/resume.rs @@ -69,6 +69,74 @@ pub(crate) fn build_fd_plan(fds: &[FdInfo]) -> (Vec, Vec) { (restorable, skipped) } +/// The (start, end) of the mapping named exactly `name` (e.g. `"[vdso]"`) in +/// `maps`, or `None` if absent. +fn find_named_map(maps: &[MemoryMap], name: &str) -> Option<(u64, u64)> { + maps.iter() + .find(|m| m.path.as_deref() == Some(name)) + .map(|m| (m.start, m.end)) +} + +/// One planned relocation of a kernel special mapping ([vdso]/[vvar]): move the +/// mapping currently at `cur_start` (length `len`) to `target_start`. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct VdsoMove { + pub cur_start: u64, + pub len: u64, + pub target_start: u64, +} + +/// Plan the relocations that put the stub's `[vvar]`/`[vdso]` at the addresses +/// the checkpoint recorded. A mapping is moved only when present in both layouts +/// and its current base differs from the target. The returned moves are ordered +/// so that no move's *target* range overlaps a not-yet-executed move's *current* +/// range, which would destroy a source before it is relocated. A layout that +/// would require swapping two overlapping ranges (a dependency cycle) is +/// reported as an error rather than corrupting the mappings. +/// +/// Rationale for same-kernel restore: the vDSO/vvar *code and data* the kernel +/// mapped into the stub are identical to the checkpoint's (same kernel); only +/// the ASLR base differs. Moving each mapping to its recorded base makes every +/// pointer glibc cached into the vDSO resolve correctly on resume. The constant +/// vvar-to-vdso distance is preserved automatically because both are moved to +/// recorded addresses that already encode that distance. +pub(crate) fn plan_vdso_moves( + cur: &[MemoryMap], + cp: &[MemoryMap], +) -> Result, String> { + let mut remaining: Vec = Vec::new(); + for name in ["[vvar]", "[vvar_vclock]", "[vdso]"] { + if let (Some((cs, ce)), Some((ts, _))) = + (find_named_map(cur, name), find_named_map(cp, name)) + { + if cs != ts { + remaining.push(VdsoMove { cur_start: cs, len: ce - cs, target_start: ts }); + } + } + } + + let mut ordered = Vec::new(); + while !remaining.is_empty() { + // Pick a move whose target does not overlap any *other* remaining move's + // current range: relocating it cannot clobber a source we still need. + let pick = remaining.iter().position(|m| { + let (t_start, t_end) = (m.target_start, m.target_start + m.len); + !remaining + .iter() + .any(|o| o != m && t_start < (o.cur_start + o.len) && o.cur_start < t_end) + }); + match pick { + Some(i) => ordered.push(remaining.remove(i)), + None => { + return Err( + "vdso/vvar relocation requires swapping overlapping ranges".into(), + ) + } + } + } + Ok(ordered) +} + fn prot_from_perms(perms: &str) -> libc::c_int { let mut prot = 0; if perms.as_bytes().first() == Some(&b'r') { prot |= libc::PROT_READ; } @@ -90,10 +158,15 @@ fn prot_from_perms(perms: &str) -> libc::c_int { /// Limitation: file-backed regions are restored `MAP_PRIVATE` from the on-disk /// file, so a checkpointed `MAP_SHARED` mapping is restored as private /// (documented M1 limitation). -/// Limitation: transparent restore currently works for vDSO-free programs. -/// libc/glibc programs that call vDSO functions (e.g. `clock_gettime`) crash -/// on resume because the vDSO is not yet relocated/restored (known limitation, -/// next milestone). +/// vDSO handling: the kernel-provided `[vdso]`/`[vvar]`/`[vvar_vclock]` mappings +/// are relocated onto the checkpoint-recorded bases (see `plan_vdso_moves`), so +/// libc/glibc programs whose cached vDSO pointers (e.g. `clock_gettime`) target +/// the checkpoint-era base resume correctly. This assumes a same-kernel restore +/// (the vDSO code is byte-identical; only the ASLR base differs). +/// Limitation: the stub's own leftover mappings (launcher text, pre-exec stack, +/// inherited anon) are not swept, so the restored address space is the union of +/// the checkpoint image and those leftovers; `/proc/self/maps` shows them and +/// they remain readable to the workload. #[cfg(target_arch = "x86_64")] pub(crate) fn restore_into( pid: i32, @@ -106,6 +179,7 @@ pub(crate) fn restore_into( const MMAP: u64 = 9; const MPROTECT: u64 = 10; const MUNMAP: u64 = 11; + const MREMAP: u64 = 25; const OPEN: u64 = 2; const CLOSE: u64 = 3; const LSEEK: u64 = 8; @@ -257,6 +331,42 @@ pub(crate) fn restore_into( } } + // Relocate the kernel-provided [vdso]/[vvar] to the addresses the checkpoint + // recorded. glibc caches vDSO function pointers (clock_gettime, getcpu, ...) + // in process memory at the vDSO's original base; without this, a restored + // libc program jumps to the checkpoint-era base, which the fresh stub mapped + // elsewhere under ASLR, and faults on its first vDSO call. Same-kernel + // restore only: the vDSO code is byte-identical, so moving it to the recorded + // base makes every cached pointer valid. A vDSO-free checkpoint yields no + // moves. + { + let cur = crate::checkpoint::capture::parse_proc_maps(pid) + .map_err(|e| err(format!("restore read maps for vdso relocation: {e}")))?; + let moves = plan_vdso_moves(&cur, &cp.process_state.memory_maps) + .map_err(|m| err(format!("restore vdso relocation: {m}")))?; + for mv in moves { + let flags = (libc::MREMAP_MAYMOVE | libc::MREMAP_FIXED) as u64; + let r = inject::inject_syscall_at( + pid, + tramp, + MREMAP, + [mv.cur_start, mv.len, mv.len, flags, mv.target_start, 0], + ) + .map_err(|e| { + err(format!( + "restore mremap {:#x}->{:#x}: {e}", + mv.cur_start, mv.target_start + )) + })?; + if r as u64 != mv.target_start { + return Err(err(format!( + "restore mremap {:#x}->{:#x} -> {r:#x}", + mv.cur_start, mv.target_start + ))); + } + } + } + // Unmap the RWX trampoline as the very last injected syscall, after all // region and fd injections (which need it), and before the register restores // (which use ptrace, not the trampoline). The `syscall` instruction at @@ -370,6 +480,76 @@ mod tests { "/sys/ paths must be skipped"); } + fn map(start: u64, end: u64, path: Option<&str>) -> MemoryMap { + MemoryMap { start, end, perms: "rw-p".into(), offset: 0, path: path.map(Into::into) } + } + + #[test] + fn vdso_moves_relocate_present_mappings_only() { + // Stub layout (cur) and checkpoint layout (cp) disagree on vdso/vvar + // bases; a non-special region present in both is ignored. + let cur = vec![ + map(0x1000, 0x2000, Some("[vvar]")), + map(0x2000, 0x3000, Some("[vdso]")), + map(0x9000, 0xa000, None), + ]; + let cp = vec![ + map(0x5000, 0x6000, Some("[vvar]")), + map(0x6000, 0x7000, Some("[vdso]")), + ]; + let moves = plan_vdso_moves(&cur, &cp).expect("no cycle"); + assert_eq!(moves.len(), 2, "both special mappings relocate"); + assert!(moves.iter().any(|m| m.cur_start == 0x1000 && m.target_start == 0x5000)); + assert!(moves.iter().any(|m| m.cur_start == 0x2000 && m.target_start == 0x6000)); + } + + #[test] + fn vdso_moves_skip_when_base_already_matches() { + let cur = vec![map(0x5000, 0x6000, Some("[vdso]"))]; + let cp = vec![map(0x5000, 0x6000, Some("[vdso]"))]; + assert!(plan_vdso_moves(&cur, &cp).unwrap().is_empty(), + "no move when the base already matches"); + } + + #[test] + fn vdso_moves_skip_when_absent_from_checkpoint() { + // A freestanding checkpoint records no vdso; nothing to relocate. + let cur = vec![map(0x2000, 0x3000, Some("[vdso]"))]; + let cp = vec![map(0x2000, 0x3000, None)]; + assert!(plan_vdso_moves(&cur, &cp).unwrap().is_empty()); + } + + #[test] + fn vdso_moves_order_avoids_clobbering_a_source() { + // vvar must move onto the range vdso currently occupies. Moving vvar + // first would destroy vdso's source, so vdso must be scheduled first. + let cur = vec![ + map(0x1000, 0x2000, Some("[vvar]")), + map(0x5000, 0x6000, Some("[vdso]")), + ]; + let cp = vec![ + map(0x5000, 0x6000, Some("[vvar]")), // vvar target == vdso's current base + map(0x8000, 0x9000, Some("[vdso]")), + ]; + let moves = plan_vdso_moves(&cur, &cp).expect("no cycle"); + assert_eq!(moves[0].cur_start, 0x5000, "vdso relocates before vvar overwrites its base"); + assert_eq!(moves[1].cur_start, 0x1000); + } + + #[test] + fn vdso_moves_reject_unresolvable_swap() { + // Each mapping's target is the other's current base: no safe order. + let cur = vec![ + map(0x1000, 0x2000, Some("[vvar]")), + map(0x5000, 0x6000, Some("[vdso]")), + ]; + let cp = vec![ + map(0x5000, 0x6000, Some("[vvar]")), + map(0x1000, 0x2000, Some("[vdso]")), + ]; + assert!(plan_vdso_moves(&cur, &cp).is_err(), "a pure swap is rejected, not corrupted"); + } + #[test] fn plan_classifies_regions() { let maps = vec![ diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 7773f4b0..43951921 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -874,10 +874,9 @@ impl Sandbox { /// transparently recreated are recorded on this `Sandbox`; query them with /// [`Sandbox::restore_skipped`]. x86_64 restore engine only. /// - /// Limitation: transparent restore currently works for vDSO-free programs. - /// libc/glibc programs that call vDSO functions (e.g. `clock_gettime`) crash - /// on resume because the vDSO is not yet relocated/restored (known limitation, - /// next milestone). + /// The kernel vDSO is relocated onto the checkpoint-recorded base during + /// restore, so ordinary libc/glibc programs that call vDSO functions (e.g. + /// `clock_gettime`) resume correctly. Assumes a same-kernel restore. /// /// On error the child may be left half-built; the caller should drop/kill the /// Sandbox (Drop reaps it). diff --git a/crates/sandlock-core/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index f4091fdb..e6ff7802 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -1,100 +1,54 @@ use sandlock_core::Sandbox; - -/// Freestanding x86_64 program (no libc, no vDSO; raw syscalls only) that opens -/// an output file ONCE, then loops forever incrementing a counter and writing -/// it through that same kept-open fd, sleeping via `nanosleep` between writes. -/// -/// It is deliberately libc/vDSO-free: glibc caches vDSO function pointers in -/// process memory, and the injection-based restore engine does not relocate the -/// kernel vDSO, so a libc program (python, sh) resumes but crashes on its first -/// vDSO call (e.g. clock_gettime). A raw-syscall program avoids that and lets -/// the test prove the restore engine itself: memory (the loop counter), -/// registers (resumed mid-`nanosleep`), and the reopened open fd. -fn counter_source(out_path: &str) -> String { - format!( - r##" -#define SYS_write 1 -#define SYS_open 2 -#define SYS_nanosleep 35 -#define SYS_lseek 8 -#define SYS_ftruncate 77 -#define O_WRONLY 1 -#define O_CREAT 0100 -#define O_TRUNC 01000 -static long sys3(long n, long a, long b, long c){{ - long r; __asm__ volatile("syscall":"=a"(r):"a"(n),"D"(a),"S"(b),"d"(c):"rcx","r11","memory"); return r; -}} -struct ts {{ long sec; long nsec; }}; -void _start(void){{ - const char *path = "{out_path}"; - long fd = sys3(SYS_open, (long)path, O_WRONLY|O_CREAT|O_TRUNC, 0644); - unsigned long i = 0; - char buf[24]; - struct ts t; t.sec = 0; t.nsec = 20000000; - for(;;){{ - i++; - int p = 0; unsigned long v = i; char tmp[24]; int k=0; - if(v==0){{ tmp[k++]='0'; }} while(v){{ tmp[k++]='0'+(v%10); v/=10; }} - while(k>0){{ buf[p++]=tmp[--k]; }} buf[p++]='\n'; - sys3(SYS_lseek, fd, 0, 0); - sys3(SYS_ftruncate, fd, 0, 0); - sys3(SYS_write, fd, (long)buf, p); - sys3(SYS_nanosleep, (long)&t, 0, 0); - }} -}} -"## - ) +use std::path::PathBuf; + +/// Path to the static rootfs-helper binary (compiled by build.rs). Its +/// `clock-loop` command is a single-process, single-fd counter loop that calls +/// `clock_gettime(CLOCK_MONOTONIC)` every iteration — the vDSO fast path — so it +/// exercises the full restore engine (memory, registers, reopened fd) plus vDSO +/// relocation without any embedded C in the test. +fn helper_binary() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/rootfs-helper") + .canonicalize() + .expect("rootfs-helper not found — build.rs should have compiled it") } -/// End-to-end proof of transparent restore of a real program into a fresh, -/// fully-sandboxed process. Checkpoint the running counter program, kill the -/// original, then `restore_interactive` a fresh sandbox from the checkpoint and -/// prove the restored process resumes mid-loop and keeps advancing the counter, -/// all under the live Landlock + seccomp policy. +/// End-to-end proof that an ordinary libc program surviving a checkpoint/restore +/// keeps making vDSO calls. Run the static-musl helper's `clock-loop` (which +/// calls `clock_gettime` each iteration and advances an on-disk counter), +/// checkpoint it mid-loop, kill the original, restore into a fresh sandbox, and +/// confirm the restored process resumes and advances the counter — which it can +/// only do if every post-restore `clock_gettime` (a vDSO call) succeeds. Before +/// vDSO relocation, glibc/musl's cached vDSO pointer would reference the +/// checkpoint-era base and the restored process would fault on its first call. #[tokio::test] -async fn test_restore_real_program_resumes() { +async fn test_restore_glibc_vdso_program_resumes() { if cfg!(not(target_arch = "x86_64")) { eprintln!("skipping: injection-based restore is x86_64-only"); return; } - let tmp = std::env::temp_dir().join(format!("sandlock-restore-{}", std::process::id())); + let helper = helper_binary(); + let helper_dir = helper.parent().unwrap().to_path_buf(); + + let tmp = std::env::temp_dir().join(format!("sandlock-vdso-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); - let src = tmp.join("counter.c"); - let bin = tmp.join("counter"); - let counter = tmp.join("counter.cnt"); + let counter = tmp.join("clock.cnt"); let counter_s = counter.to_str().unwrap().to_string(); - std::fs::write(&src, counter_source(&counter_s)).unwrap(); - - // Build the freestanding binary; skip the test gracefully if no C compiler. - let cc = if which("cc") { "cc" } else if which("gcc") { "gcc" } else { - eprintln!("skipping: no C compiler (cc/gcc) available"); - let _ = std::fs::remove_dir_all(&tmp); - return; - }; - let build = std::process::Command::new(cc) - .args(["-static", "-nostdlib", "-no-pie", "-O0", "-o"]) - .arg(&bin).arg(&src) - .output().unwrap(); - if !build.status.success() { - eprintln!("skipping: build failed: {}", String::from_utf8_lossy(&build.stderr)); - let _ = std::fs::remove_dir_all(&tmp); - return; - } - + // Static musl helper needs only its own binary readable and the output dir + // writable; clock_gettime routes through the kernel-provided vDSO (no fs). let policy = Sandbox::builder() - .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") - .fs_read("/proc") - .fs_read(&tmp) // the binary lives here (exec needs read) - .fs_write(&tmp) // ... and so does its output file + .fs_read(&helper_dir) + .fs_read(&tmp) + .fs_write(&tmp) .build().unwrap(); - let bin_s = bin.to_str().unwrap().to_string(); - let mut sb = policy.clone().with_name("restore-src"); - sb.spawn_interactive(&[bin_s.as_str()]).await.unwrap(); + let helper_s = helper.to_str().unwrap().to_string(); + let mut sb = policy.clone().with_name("vdso-src"); + sb.spawn_interactive(&[helper_s.as_str(), "clock-loop", counter_s.as_str()]) + .await.unwrap(); - // Let it run so the counter is well past a few. tokio::time::sleep(std::time::Duration::from_millis(400)).await; let cp = sb.checkpoint().await.unwrap(); @@ -112,9 +66,7 @@ async fn test_restore_real_program_resumes() { // Sentinel: prove the *restored* process (not a leftover original) is writing. std::fs::write(&counter, b"0\n").unwrap(); - // Restore into a fresh, fully-sandboxed process. The returned Process is - // the handle; the sandbox owns the child, so dropping it here is fine. - let mut sb2 = policy.clone().with_name("restore-dst"); + let mut sb2 = policy.clone().with_name("vdso-dst"); let _ = sb2.restore_interactive(&cp).await.unwrap(); eprintln!("restore skipped fds: {:?}", sb2.restore_skipped()); @@ -141,14 +93,7 @@ async fn test_restore_real_program_resumes() { assert!( advanced, - "restored process must resume mid-loop and advance the counter past {baseline}; \ - last seen {last}, restored exit {exit:?}" + "restored process must resume and keep calling clock_gettime past \ + baseline {baseline}; last seen {last}, restored exit {exit:?}" ); } - -/// Minimal PATH lookup so the test does not depend on extra crates. -fn which(prog: &str) -> bool { - std::env::var_os("PATH").map_or(false, |paths| { - std::env::split_paths(&paths).any(|d| d.join(prog).is_file()) - }) -} diff --git a/tests/rootfs-helper.c b/tests/rootfs-helper.c index 135d1bc9..12f6300c 100644 --- a/tests/rootfs-helper.c +++ b/tests/rootfs-helper.c @@ -582,6 +582,42 @@ static int cmd_spawn_loop(int argc, char **argv) { return 0; } +/* ── clock-loop (non-standard: single-process vDSO counter loop) ──────────── */ +/* + * Opens once, then loops forever calling clock_gettime(CLOCK_MONOTONIC) + * — the canonical vDSO fast path — and publishing an incrementing counter + * through that kept-open fd (single fixed-width 21-byte overwrite, never + * truncating, so a reader always sees a complete value), sleeping via nanosleep + * between writes. Unlike spawn-loop it never forks, so the whole process is a + * single thread with one open fd: exactly the shape the checkpoint/restore + * engine supports. Used by the restore test to prove a restored process keeps + * making vDSO calls (which requires the engine to relocate the vDSO onto the + * checkpoint-recorded base); as a static-musl binary its clock_gettime routes + * through the kernel vDSO just as a glibc program's would. + */ +static int cmd_clock_loop(int argc, char **argv) { + if (argc < 1) { fprintf(stderr, "clock-loop: missing file operand\n"); return 1; } + int fd = open(argv[0], O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { perror("clock-loop: open"); return 1; } + unsigned long i = 0; + char buf[24]; + struct timespec t = { 0, 20000000 }; + struct timespec now; + for (;;) { + i++; + clock_gettime(CLOCK_MONOTONIC, &now); /* vDSO fast path */ + unsigned long v = i; + for (int d = 19; d >= 0; d--) { buf[d] = '0' + (v % 10); v /= 10; } + buf[20] = '\n'; + /* Touch `now` so the vDSO call cannot be optimized away. */ + if (now.tv_sec < 0) buf[0] = '?'; + lseek(fd, 0, SEEK_SET); + if (write(fd, buf, 21) < 0) _exit(1); + nanosleep(&t, NULL); + } + return 0; +} + /* ── chdir (non-standard: chdir from a READ-ONLY path buffer) ─── */ /* * Copies the target path onto a freshly mmap'd page, flips it to PROT_READ, @@ -719,6 +755,7 @@ static int dispatch(const char *cmd, int argc, char **argv) { if (strcmp(cmd, "listxattr") == 0) return cmd_listxattr(argc, argv); if (strcmp(cmd, "fstat-fd") == 0) return cmd_fstat_fd(argc, argv); if (strcmp(cmd, "spawn-loop") == 0) return cmd_spawn_loop(argc, argv); + if (strcmp(cmd, "clock-loop") == 0) return cmd_clock_loop(argc, argv); if (strcmp(cmd, "true") == 0) return 0; if (strcmp(cmd, "false") == 0) return 1;