Skip to content

Commit 52d6783

Browse files
committed
feat(checkpoint): relocate vDSO/vvar on restore so libc programs resume
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 0098225 commit 52d6783

5 files changed

Lines changed: 268 additions & 102 deletions

File tree

crates/sandlock-core/src/checkpoint/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ impl MemoryMap {
5959

6060
pub fn is_special(&self) -> bool {
6161
self.path.as_ref().map_or(false, |p| {
62-
p.starts_with("[vdso]") || p.starts_with("[vvar]") || p.starts_with("[vsyscall]")
62+
// `[vvar_vclock]` is a newer-kernel split of `[vvar]`; it is also
63+
// kernel-provided and relocated (not captured) during restore.
64+
p.starts_with("[vdso]")
65+
|| p.starts_with("[vvar]")
66+
|| p.starts_with("[vvar_vclock]")
67+
|| p.starts_with("[vsyscall]")
6368
})
6469
}
6570
}

crates/sandlock-core/src/checkpoint/resume.rs

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,74 @@ pub(crate) fn build_fd_plan(fds: &[FdInfo]) -> (Vec<FdInfo>, Vec<SkippedFd>) {
6969
(restorable, skipped)
7070
}
7171

72+
/// The (start, end) of the mapping named exactly `name` (e.g. `"[vdso]"`) in
73+
/// `maps`, or `None` if absent.
74+
fn find_named_map(maps: &[MemoryMap], name: &str) -> Option<(u64, u64)> {
75+
maps.iter()
76+
.find(|m| m.path.as_deref() == Some(name))
77+
.map(|m| (m.start, m.end))
78+
}
79+
80+
/// One planned relocation of a kernel special mapping ([vdso]/[vvar]): move the
81+
/// mapping currently at `cur_start` (length `len`) to `target_start`.
82+
#[derive(Debug, PartialEq, Eq)]
83+
pub(crate) struct VdsoMove {
84+
pub cur_start: u64,
85+
pub len: u64,
86+
pub target_start: u64,
87+
}
88+
89+
/// Plan the relocations that put the stub's `[vvar]`/`[vdso]` at the addresses
90+
/// the checkpoint recorded. A mapping is moved only when present in both layouts
91+
/// and its current base differs from the target. The returned moves are ordered
92+
/// so that no move's *target* range overlaps a not-yet-executed move's *current*
93+
/// range, which would destroy a source before it is relocated. A layout that
94+
/// would require swapping two overlapping ranges (a dependency cycle) is
95+
/// reported as an error rather than corrupting the mappings.
96+
///
97+
/// Rationale for same-kernel restore: the vDSO/vvar *code and data* the kernel
98+
/// mapped into the stub are identical to the checkpoint's (same kernel); only
99+
/// the ASLR base differs. Moving each mapping to its recorded base makes every
100+
/// pointer glibc cached into the vDSO resolve correctly on resume. The constant
101+
/// vvar-to-vdso distance is preserved automatically because both are moved to
102+
/// recorded addresses that already encode that distance.
103+
pub(crate) fn plan_vdso_moves(
104+
cur: &[MemoryMap],
105+
cp: &[MemoryMap],
106+
) -> Result<Vec<VdsoMove>, String> {
107+
let mut remaining: Vec<VdsoMove> = Vec::new();
108+
for name in ["[vvar]", "[vvar_vclock]", "[vdso]"] {
109+
if let (Some((cs, ce)), Some((ts, _))) =
110+
(find_named_map(cur, name), find_named_map(cp, name))
111+
{
112+
if cs != ts {
113+
remaining.push(VdsoMove { cur_start: cs, len: ce - cs, target_start: ts });
114+
}
115+
}
116+
}
117+
118+
let mut ordered = Vec::new();
119+
while !remaining.is_empty() {
120+
// Pick a move whose target does not overlap any *other* remaining move's
121+
// current range: relocating it cannot clobber a source we still need.
122+
let pick = remaining.iter().position(|m| {
123+
let (t_start, t_end) = (m.target_start, m.target_start + m.len);
124+
!remaining
125+
.iter()
126+
.any(|o| o != m && t_start < (o.cur_start + o.len) && o.cur_start < t_end)
127+
});
128+
match pick {
129+
Some(i) => ordered.push(remaining.remove(i)),
130+
None => {
131+
return Err(
132+
"vdso/vvar relocation requires swapping overlapping ranges".into(),
133+
)
134+
}
135+
}
136+
}
137+
Ok(ordered)
138+
}
139+
72140
fn prot_from_perms(perms: &str) -> libc::c_int {
73141
let mut prot = 0;
74142
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 {
90158
/// Limitation: file-backed regions are restored `MAP_PRIVATE` from the on-disk
91159
/// file, so a checkpointed `MAP_SHARED` mapping is restored as private
92160
/// (documented M1 limitation).
93-
/// Limitation: transparent restore currently works for vDSO-free programs.
94-
/// libc/glibc programs that call vDSO functions (e.g. `clock_gettime`) crash
95-
/// on resume because the vDSO is not yet relocated/restored (known limitation,
96-
/// next milestone).
161+
/// vDSO handling: the kernel-provided `[vdso]`/`[vvar]`/`[vvar_vclock]` mappings
162+
/// are relocated onto the checkpoint-recorded bases (see `plan_vdso_moves`), so
163+
/// libc/glibc programs whose cached vDSO pointers (e.g. `clock_gettime`) target
164+
/// the checkpoint-era base resume correctly. This assumes a same-kernel restore
165+
/// (the vDSO code is byte-identical; only the ASLR base differs).
166+
/// Limitation: the stub's own leftover mappings (launcher text, pre-exec stack,
167+
/// inherited anon) are not swept, so the restored address space is the union of
168+
/// the checkpoint image and those leftovers; `/proc/self/maps` shows them and
169+
/// they remain readable to the workload.
97170
#[cfg(target_arch = "x86_64")]
98171
pub(crate) fn restore_into(
99172
pid: i32,
@@ -106,6 +179,7 @@ pub(crate) fn restore_into(
106179
const MMAP: u64 = 9;
107180
const MPROTECT: u64 = 10;
108181
const MUNMAP: u64 = 11;
182+
const MREMAP: u64 = 25;
109183
const OPEN: u64 = 2;
110184
const CLOSE: u64 = 3;
111185
const LSEEK: u64 = 8;
@@ -257,6 +331,42 @@ pub(crate) fn restore_into(
257331
}
258332
}
259333

334+
// Relocate the kernel-provided [vdso]/[vvar] to the addresses the checkpoint
335+
// recorded. glibc caches vDSO function pointers (clock_gettime, getcpu, ...)
336+
// in process memory at the vDSO's original base; without this, a restored
337+
// libc program jumps to the checkpoint-era base, which the fresh stub mapped
338+
// elsewhere under ASLR, and faults on its first vDSO call. Same-kernel
339+
// restore only: the vDSO code is byte-identical, so moving it to the recorded
340+
// base makes every cached pointer valid. A vDSO-free checkpoint yields no
341+
// moves.
342+
{
343+
let cur = crate::checkpoint::capture::parse_proc_maps(pid)
344+
.map_err(|e| err(format!("restore read maps for vdso relocation: {e}")))?;
345+
let moves = plan_vdso_moves(&cur, &cp.process_state.memory_maps)
346+
.map_err(|m| err(format!("restore vdso relocation: {m}")))?;
347+
for mv in moves {
348+
let flags = (libc::MREMAP_MAYMOVE | libc::MREMAP_FIXED) as u64;
349+
let r = inject::inject_syscall_at(
350+
pid,
351+
tramp,
352+
MREMAP,
353+
[mv.cur_start, mv.len, mv.len, flags, mv.target_start, 0],
354+
)
355+
.map_err(|e| {
356+
err(format!(
357+
"restore mremap {:#x}->{:#x}: {e}",
358+
mv.cur_start, mv.target_start
359+
))
360+
})?;
361+
if r as u64 != mv.target_start {
362+
return Err(err(format!(
363+
"restore mremap {:#x}->{:#x} -> {r:#x}",
364+
mv.cur_start, mv.target_start
365+
)));
366+
}
367+
}
368+
}
369+
260370
// Unmap the RWX trampoline as the very last injected syscall, after all
261371
// region and fd injections (which need it), and before the register restores
262372
// (which use ptrace, not the trampoline). The `syscall` instruction at
@@ -370,6 +480,76 @@ mod tests {
370480
"/sys/ paths must be skipped");
371481
}
372482

483+
fn map(start: u64, end: u64, path: Option<&str>) -> MemoryMap {
484+
MemoryMap { start, end, perms: "rw-p".into(), offset: 0, path: path.map(Into::into) }
485+
}
486+
487+
#[test]
488+
fn vdso_moves_relocate_present_mappings_only() {
489+
// Stub layout (cur) and checkpoint layout (cp) disagree on vdso/vvar
490+
// bases; a non-special region present in both is ignored.
491+
let cur = vec![
492+
map(0x1000, 0x2000, Some("[vvar]")),
493+
map(0x2000, 0x3000, Some("[vdso]")),
494+
map(0x9000, 0xa000, None),
495+
];
496+
let cp = vec![
497+
map(0x5000, 0x6000, Some("[vvar]")),
498+
map(0x6000, 0x7000, Some("[vdso]")),
499+
];
500+
let moves = plan_vdso_moves(&cur, &cp).expect("no cycle");
501+
assert_eq!(moves.len(), 2, "both special mappings relocate");
502+
assert!(moves.iter().any(|m| m.cur_start == 0x1000 && m.target_start == 0x5000));
503+
assert!(moves.iter().any(|m| m.cur_start == 0x2000 && m.target_start == 0x6000));
504+
}
505+
506+
#[test]
507+
fn vdso_moves_skip_when_base_already_matches() {
508+
let cur = vec![map(0x5000, 0x6000, Some("[vdso]"))];
509+
let cp = vec![map(0x5000, 0x6000, Some("[vdso]"))];
510+
assert!(plan_vdso_moves(&cur, &cp).unwrap().is_empty(),
511+
"no move when the base already matches");
512+
}
513+
514+
#[test]
515+
fn vdso_moves_skip_when_absent_from_checkpoint() {
516+
// A freestanding checkpoint records no vdso; nothing to relocate.
517+
let cur = vec![map(0x2000, 0x3000, Some("[vdso]"))];
518+
let cp = vec![map(0x2000, 0x3000, None)];
519+
assert!(plan_vdso_moves(&cur, &cp).unwrap().is_empty());
520+
}
521+
522+
#[test]
523+
fn vdso_moves_order_avoids_clobbering_a_source() {
524+
// vvar must move onto the range vdso currently occupies. Moving vvar
525+
// first would destroy vdso's source, so vdso must be scheduled first.
526+
let cur = vec![
527+
map(0x1000, 0x2000, Some("[vvar]")),
528+
map(0x5000, 0x6000, Some("[vdso]")),
529+
];
530+
let cp = vec![
531+
map(0x5000, 0x6000, Some("[vvar]")), // vvar target == vdso's current base
532+
map(0x8000, 0x9000, Some("[vdso]")),
533+
];
534+
let moves = plan_vdso_moves(&cur, &cp).expect("no cycle");
535+
assert_eq!(moves[0].cur_start, 0x5000, "vdso relocates before vvar overwrites its base");
536+
assert_eq!(moves[1].cur_start, 0x1000);
537+
}
538+
539+
#[test]
540+
fn vdso_moves_reject_unresolvable_swap() {
541+
// Each mapping's target is the other's current base: no safe order.
542+
let cur = vec![
543+
map(0x1000, 0x2000, Some("[vvar]")),
544+
map(0x5000, 0x6000, Some("[vdso]")),
545+
];
546+
let cp = vec![
547+
map(0x5000, 0x6000, Some("[vvar]")),
548+
map(0x1000, 0x2000, Some("[vdso]")),
549+
];
550+
assert!(plan_vdso_moves(&cur, &cp).is_err(), "a pure swap is rejected, not corrupted");
551+
}
552+
373553
#[test]
374554
fn plan_classifies_regions() {
375555
let maps = vec![

crates/sandlock-core/src/sandbox.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -874,10 +874,9 @@ impl Sandbox {
874874
/// transparently recreated are recorded on this `Sandbox`; query them with
875875
/// [`Sandbox::restore_skipped`]. x86_64 restore engine only.
876876
///
877-
/// Limitation: transparent restore currently works for vDSO-free programs.
878-
/// libc/glibc programs that call vDSO functions (e.g. `clock_gettime`) crash
879-
/// on resume because the vDSO is not yet relocated/restored (known limitation,
880-
/// next milestone).
877+
/// The kernel vDSO is relocated onto the checkpoint-recorded base during
878+
/// restore, so ordinary libc/glibc programs that call vDSO functions (e.g.
879+
/// `clock_gettime`) resume correctly. Assumes a same-kernel restore.
881880
///
882881
/// On error the child may be left half-built; the caller should drop/kill the
883882
/// Sandbox (Drop reaps it).

0 commit comments

Comments
 (0)