Skip to content

Commit 97e51d3

Browse files
committed
sandlock-core: force child path-arg rewrites through /proc/pid/mem to survive read-only buffers
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent cab2ae3 commit 97e51d3

4 files changed

Lines changed: 138 additions & 6 deletions

File tree

crates/sandlock-core/src/chroot/dispatch.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use tokio::sync::Mutex;
4949

5050
use crate::chroot::resolve::{confine, resolve_existing_in_root, resolve_in_root};
5151
use crate::sys::fs::openat2_in_root;
52-
use crate::seccomp::notif::{read_child_mem, write_child_mem, NotifAction};
52+
use crate::seccomp::notif::{read_child_mem, write_child_mem, write_child_mem_force, NotifAction};
5353
use crate::seccomp::state::{ChrootState, CowState};
5454
use crate::sys::structs::{SeccompNotif, SeccompNotifAddfd, SECCOMP_IOCTL_NOTIF_ADDFD};
5555

@@ -868,8 +868,12 @@ pub(crate) async fn handle_chroot_exec(
868868
return NotifAction::Errno(libc::EIO);
869869
}
870870

871+
// Force-write past read-only page protections: the child commonly passes a
872+
// .rodata path literal to execve, which process_vm_writev can't overwrite.
873+
// No length guard needed — execve replaces the address space on success, so
874+
// a write past the original buffer is harmless.
871875
let fd_path = format!("/proc/self/fd/{}\0", child_fd);
872-
if write_child_mem(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
876+
if write_child_mem_force(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
873877
return NotifAction::Errno(libc::EFAULT);
874878
}
875879

@@ -1637,6 +1641,9 @@ pub(crate) async fn handle_chroot_chdir(
16371641
Some(p) => p,
16381642
None => return NotifAction::Continue,
16391643
};
1644+
// Bytes the child mapped for the path argument (string + NUL): the upper
1645+
// bound for an in-place rewrite that must not overflow into adjacent memory.
1646+
let orig_path_buf_len = path.len() + 1;
16401647

16411648
// Build the full virtual path from AT_FDCWD + path.
16421649
let was_absolute = Path::new(&path).is_absolute();
@@ -1725,7 +1732,17 @@ pub(crate) async fn handle_chroot_chdir(
17251732
}
17261733

17271734
let fd_path = format!("/proc/self/fd/{}\0", child_fd);
1728-
if write_child_mem(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
1735+
// chdir leaves the process running, so the rewrite must not overflow the
1736+
// child's path buffer into adjacent memory. Only force-write when the
1737+
// redirect fits; otherwise the original (short) buffer can't be redirected.
1738+
// src_fd is already closed (above); the injected child fd is O_CLOEXEC and
1739+
// is reclaimed on the child's next exec/exit.
1740+
if orig_path_buf_len < fd_path.len() {
1741+
return NotifAction::Errno(libc::ENAMETOOLONG);
1742+
}
1743+
// Force-write past read-only page protections (a .rodata chdir literal that
1744+
// process_vm_writev can't overwrite).
1745+
if write_child_mem_force(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
17291746
return NotifAction::Errno(libc::EFAULT);
17301747
}
17311748

crates/sandlock-core/src/cow/dispatch.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use tokio::sync::Mutex as AsyncMutex;
3434
use crate::arch;
3535
use crate::cow::seccomp::SeccompCowBranch;
3636
use crate::procfs::{build_dirent64, DT_DIR, DT_LNK, DT_REG};
37-
use crate::seccomp::notif::{read_child_mem, write_child_mem, NotifAction};
37+
use crate::seccomp::notif::{read_child_mem, write_child_mem, write_child_mem_force, NotifAction};
3838
use crate::seccomp::state::{CowState, PerProcessState, ProcessIndex};
3939
use crate::sys::structs::SeccompNotif;
4040

@@ -1015,8 +1015,11 @@ pub(crate) async fn handle_cow_exec(
10151015
// injected fd. execve replaces the whole address space on success, so
10161016
// (unlike chdir) writing past the original buffer is harmless — the
10171017
// only memory the kernel still reads is argv/envp, which sit elsewhere.
1018+
// Force-write past read-only protections (a .rodata exec path literal). No
1019+
// length guard: execve replaces the address space on success (see above), so
1020+
// a write past the original buffer is harmless.
10181021
let fd_path = format!("/proc/self/fd/{}\0", child_fd);
1019-
if write_child_mem(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
1022+
if write_child_mem_force(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
10201023
return NotifAction::Errno(libc::EFAULT);
10211024
}
10221025

@@ -1300,7 +1303,9 @@ pub(crate) async fn handle_cow_chdir(
13001303
// fd has O_CLOEXEC so it will be cleaned up on exit/exec.
13011304
return NotifAction::Errno(libc::ENOENT);
13021305
}
1303-
if write_child_mem(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
1306+
// Force-write past read-only protections (a .rodata chdir path literal).
1307+
// The fit guard above keeps the redirect from overflowing the buffer.
1308+
if write_child_mem_force(notif_fd, notif.id, notif.pid, path_ptr, fd_path.as_bytes()).is_err() {
13041309
return NotifAction::Errno(libc::EFAULT);
13051310
}
13061311

crates/sandlock-core/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ pub enum NotifError {
100100
#[error("child memory read failed: {0}")]
101101
ChildMemoryRead(#[source] std::io::Error),
102102

103+
#[error("child memory write failed: {0}")]
104+
ChildMemoryWrite(#[source] std::io::Error),
105+
103106
#[error("notification ioctl failed: {0}")]
104107
Ioctl(#[source] std::io::Error),
105108
}

crates/sandlock-core/src/seccomp/notif.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,62 @@ pub fn write_child_mem(
11201120
Ok(())
11211121
}
11221122

1123+
/// Write bytes to a child, forcing past read-only page protections.
1124+
///
1125+
/// [`write_child_mem`] uses `process_vm_writev`, which honors the target VMA's
1126+
/// protection bits and so returns `EFAULT` when the destination page is
1127+
/// read-only — e.g. a `.rodata` path literal a program hands to
1128+
/// `chdir`/`execve`. This variant writes through `/proc/<pid>/mem`, whose writes
1129+
/// use `FOLL_FORCE` and therefore copy-on-write past a read-only mapping (the
1130+
/// same mechanism a debugger uses to plant a breakpoint in read-only `.text`).
1131+
/// It handles writable and read-only destinations through one path, so the
1132+
/// argument-rewrite sites need no `EFAULT` fallback.
1133+
///
1134+
/// Use ONLY for rewriting an *input* path argument the child owns that the
1135+
/// kernel re-reads under `SECCOMP_USER_NOTIF_FLAG_CONTINUE` (the `chdir`/`execve`
1136+
/// path rewrites). Do NOT use it for syscall *output* buffers (`stat`,
1137+
/// `getdents`, `getcwd`, `readlink`, the `getsockname`/`recvfrom` source
1138+
/// address): if a child supplies a read-only output buffer the real syscall
1139+
/// would `EFAULT`, and faithful emulation must too — keep those on
1140+
/// [`write_child_mem`]. (`bind`/`connect` need nothing here: they are emulated
1141+
/// on-behalf on a duped fd and never rewrite the child's sockaddr.)
1142+
///
1143+
/// Opening `/proc/<pid>/mem` for write needs `PTRACE_MODE_ATTACH_FSCREDS` over
1144+
/// the child (no actual attach or stop): satisfied by the supervisor as the
1145+
/// child's same-uid parent under the common Yama scopes. Returns `Err` (so the
1146+
/// caller can still surface `EFAULT`) if the `mem` file can't be opened or
1147+
/// written — it never silently no-ops. TOCTOU-safe: brackets the write with
1148+
/// `id_valid` like [`write_child_mem`].
1149+
pub fn write_child_mem_force(
1150+
notif_fd: RawFd,
1151+
id: u64,
1152+
pid: u32,
1153+
addr: u64,
1154+
data: &[u8],
1155+
) -> Result<(), NotifError> {
1156+
id_valid(notif_fd, id).map_err(NotifError::Ioctl)?;
1157+
write_child_mem_proc(pid, addr, data)?;
1158+
id_valid(notif_fd, id).map_err(NotifError::Ioctl)?;
1159+
Ok(())
1160+
}
1161+
1162+
/// Write bytes to a process's memory via `/proc/<pid>/mem` (open + `pwrite`).
1163+
///
1164+
/// Inner helper for [`write_child_mem_force`]; the file offset is the target
1165+
/// virtual address. Writes here use `FOLL_FORCE`, so they copy-on-write past a
1166+
/// read-only destination page where [`write_child_mem_vm`] (`process_vm_writev`)
1167+
/// returns `EFAULT`.
1168+
fn write_child_mem_proc(pid: u32, addr: u64, data: &[u8]) -> Result<(), NotifError> {
1169+
use std::os::unix::fs::FileExt;
1170+
let mem = std::fs::OpenOptions::new()
1171+
.write(true)
1172+
.open(format!("/proc/{}/mem", pid))
1173+
.map_err(NotifError::ChildMemoryWrite)?;
1174+
mem.write_all_at(data, addr)
1175+
.map_err(NotifError::ChildMemoryWrite)?;
1176+
Ok(())
1177+
}
1178+
11231179
// ============================================================
11241180
// Response dispatch
11251181
// ============================================================
@@ -2267,6 +2323,57 @@ mod tests {
22672323
assert_eq!(data, 0x1234567890ABCDEF);
22682324
}
22692325

2326+
/// The force-write path (`/proc/<pid>/mem`, FOLL_FORCE) must overwrite a
2327+
/// read-only page where `process_vm_writev` (`write_child_mem_vm`) refuses.
2328+
/// A read-only private page stands in for the `.rodata` path literal a child
2329+
/// hands to chdir/execve — the exact case the chroot/cow rewrite sites hit.
2330+
#[test]
2331+
fn write_child_mem_proc_forces_past_readonly_page() {
2332+
const PAGE: usize = 4096;
2333+
let orig = b"/some/rodata/path\0";
2334+
let newb = b"/proc/self/fd/7\0\0"; // fits within the page, near orig's len
2335+
2336+
let addr = unsafe {
2337+
libc::mmap(
2338+
std::ptr::null_mut(),
2339+
PAGE,
2340+
libc::PROT_READ | libc::PROT_WRITE,
2341+
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
2342+
-1,
2343+
0,
2344+
)
2345+
};
2346+
assert_ne!(addr, libc::MAP_FAILED, "mmap failed");
2347+
unsafe { std::ptr::copy_nonoverlapping(orig.as_ptr(), addr as *mut u8, orig.len()) };
2348+
2349+
// Flip the page read-only: a normal store would now SIGSEGV, and
2350+
// process_vm_writev honors the protection and fails.
2351+
assert_eq!(
2352+
unsafe { libc::mprotect(addr, PAGE, libc::PROT_READ) },
2353+
0,
2354+
"mprotect PROT_READ failed"
2355+
);
2356+
2357+
let pid = std::process::id();
2358+
let uaddr = addr as u64;
2359+
2360+
assert!(
2361+
write_child_mem_vm(pid, uaddr, newb).is_err(),
2362+
"process_vm_writev must fail on a read-only page"
2363+
);
2364+
2365+
// FOLL_FORCE via /proc/<pid>/mem copies-on-write past the RO page.
2366+
write_child_mem_proc(pid, uaddr, newb)
2367+
.expect("force-write through /proc/pid/mem must succeed on a read-only page");
2368+
2369+
// The write landed at the target address (page is still RO-mapped, so a
2370+
// plain read is fine and reflects the COW'd contents).
2371+
let got = unsafe { std::slice::from_raw_parts(addr as *const u8, newb.len()) };
2372+
assert_eq!(got, newb, "forced write must be visible at the target address");
2373+
2374+
unsafe { libc::munmap(addr, PAGE) };
2375+
}
2376+
22702377
#[test]
22712378
fn denylist_blocks_matching_cidr_allows_rest() {
22722379
use crate::network::IpCidr;

0 commit comments

Comments
 (0)