Skip to content

Commit a3f929d

Browse files
committed
sandlock-core: trap openat2 so it cannot bypass fs deny
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 67efb10 commit a3f929d

3 files changed

Lines changed: 107 additions & 15 deletions

File tree

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

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -470,21 +470,48 @@ fn deny_open_verdict(
470470
if allowed { None } else { Some(libc::EACCES) }
471471
}
472472

473-
/// openat/open argument layout, normalized across the two spellings.
473+
/// open/openat/openat2 argument layout, normalized across the spellings.
474474
struct OpenArgs {
475475
dirfd: i64,
476476
path_ptr: u64,
477477
flags: u64,
478478
mode: u64,
479+
/// `openat2` `resolve` flags (`RESOLVE_*`); 0 for `open`/`openat`.
480+
resolve: u64,
479481
}
480482

481-
fn decode_open_args(notif: &SeccompNotif) -> OpenArgs {
483+
/// Decode the open arguments. `openat2` carries flags/mode/resolve inside a
484+
/// `struct open_how` in child memory, so its decode reads child memory and
485+
/// can fail; `None` means "could not decode" and the caller soft-falls-through
486+
/// (the kernel's own re-read fails the same way).
487+
fn decode_open_args(notif: &SeccompNotif, notif_fd: RawFd) -> Option<OpenArgs> {
482488
let a = &notif.data.args;
483-
if notif.data.nr as i64 == libc::SYS_openat {
484-
OpenArgs { dirfd: a[0] as i64, path_ptr: a[1], flags: a[2], mode: a[3] }
489+
let nr = notif.data.nr as i64;
490+
if nr == libc::SYS_openat {
491+
Some(OpenArgs { dirfd: a[0] as i64, path_ptr: a[1], flags: a[2], mode: a[3], resolve: 0 })
492+
} else if nr == arch::SYS_OPENAT2 {
493+
// openat2(dirfd, pathname, struct open_how *how, size_t size),
494+
// open_how = { u64 flags, u64 mode, u64 resolve }.
495+
let how_ptr = a[2];
496+
let want = (a[3] as usize).min(std::mem::size_of::<OpenHow>());
497+
if how_ptr == 0 || want < 16 {
498+
return None; // need at least flags + mode
499+
}
500+
let bytes = read_child_mem(notif_fd, notif.id, notif.pid, how_ptr, want).ok()?;
501+
if bytes.len() < 16 {
502+
return None;
503+
}
504+
let flags = u64::from_ne_bytes(bytes[0..8].try_into().ok()?);
505+
let mode = u64::from_ne_bytes(bytes[8..16].try_into().ok()?);
506+
let resolve = if bytes.len() >= 24 {
507+
u64::from_ne_bytes(bytes[16..24].try_into().ok()?)
508+
} else {
509+
0
510+
};
511+
Some(OpenArgs { dirfd: a[0] as i64, path_ptr: a[1], flags, mode, resolve })
485512
} else {
486513
// legacy open(path, flags, mode) — AT_FDCWD implied.
487-
OpenArgs { dirfd: libc::AT_FDCWD as i64, path_ptr: a[0], flags: a[1], mode: a[2] }
514+
Some(OpenArgs { dirfd: libc::AT_FDCWD as i64, path_ptr: a[0], flags: a[1], mode: a[2], resolve: 0 })
488515
}
489516
}
490517

@@ -543,6 +570,7 @@ fn create_new_on_behalf(
543570
path: &str,
544571
flags: u64,
545572
mode: u64,
573+
resolve: u64,
546574
policy: &NotifPolicy,
547575
pfs: &super::state::PolicyFnState,
548576
) -> NotifAction {
@@ -565,7 +593,7 @@ fn create_new_on_behalf(
565593
&c_parent,
566594
(libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) as u64,
567595
0,
568-
RESOLVE_NO_MAGICLINKS,
596+
RESOLVE_NO_MAGICLINKS | resolve,
569597
) {
570598
Ok(f) => f,
571599
Err(e) => return NotifAction::Errno(e),
@@ -605,7 +633,11 @@ fn on_behalf_open_for_deny(
605633
return NotifAction::Continue;
606634
}
607635

608-
let OpenArgs { dirfd, path_ptr, flags, mode } = decode_open_args(notif);
636+
let OpenArgs { dirfd, path_ptr, flags, mode, resolve } =
637+
match decode_open_args(notif, notif_fd) {
638+
Some(a) => a,
639+
None => return NotifAction::Continue, // kernel's re-read fails the same way
640+
};
609641

610642
let path = match read_child_cstr(notif_fd, notif.id, notif.pid, path_ptr, 4096) {
611643
Some(p) => p,
@@ -621,12 +653,12 @@ fn on_behalf_open_for_deny(
621653
};
622654

623655
// Side-effect-free probe; mirror the child's no-follow intent for the
624-
// final component.
656+
// final component and any `openat2` RESOLVE_* flags it requested.
625657
let probe_flags = (libc::O_PATH | libc::O_CLOEXEC) as u64 | (flags & libc::O_NOFOLLOW as u64);
626-
match openat2_at(base.as_raw_fd(), &c_path, probe_flags, 0, RESOLVE_NO_MAGICLINKS) {
658+
match openat2_at(base.as_raw_fd(), &c_path, probe_flags, 0, RESOLVE_NO_MAGICLINKS | resolve) {
627659
Ok(probe) => reopen_existing_on_behalf(probe, flags, policy, pfs),
628660
Err(errno) if errno == libc::ENOENT && (flags & libc::O_CREAT as u64) != 0 => {
629-
create_new_on_behalf(&base, &path, flags, mode, policy, pfs)
661+
create_new_on_behalf(&base, &path, flags, mode, resolve, policy, pfs)
630662
}
631663
Err(errno) => NotifAction::Errno(errno),
632664
}
@@ -1255,8 +1287,9 @@ fn resolve_at_path_for_event(notif: &SeccompNotif, dirfd: i64, path: &str) -> Op
12551287
fn resolve_path_for_notif(notif: &SeccompNotif, notif_fd: RawFd) -> Option<String> {
12561288
let nr = notif.data.nr as i64;
12571289
match nr {
1258-
n if n == libc::SYS_openat => {
1259-
// openat(dirfd, pathname, flags, mode)
1290+
n if n == libc::SYS_openat || n == arch::SYS_OPENAT2 => {
1291+
// openat(dirfd, pathname, flags, mode) and
1292+
// openat2(dirfd, pathname, how, size) share (dirfd, pathname).
12601293
let path = read_path_for_event(notif, notif.data.args[1], notif_fd)?;
12611294
resolve_at_path_for_event(notif, notif.data.args[0] as i64, &path)
12621295
}
@@ -1588,7 +1621,7 @@ async fn handle_notification(
15881621
let mut action = {
15891622
let nr = notif.data.nr as i64;
15901623
let mut path_check_nrs = vec![
1591-
libc::SYS_openat, libc::SYS_execve, libc::SYS_execveat,
1624+
libc::SYS_openat, arch::SYS_OPENAT2, libc::SYS_execve, libc::SYS_execveat,
15921625
libc::SYS_linkat, libc::SYS_renameat2, libc::SYS_symlinkat,
15931626
];
15941627
path_check_nrs.extend([
@@ -1614,8 +1647,9 @@ async fn handle_notification(
16141647
// inode and inject the fd so the kernel never re-resolves.
16151648
// Other path syscalls keep the best-effort precheck above
16161649
// (documented follow-up — they return no fd to inject).
1617-
let is_openat_family =
1618-
nr == libc::SYS_openat || Some(nr) == arch::sys_open();
1650+
let is_openat_family = nr == libc::SYS_openat
1651+
|| nr == arch::SYS_OPENAT2
1652+
|| Some(nr) == arch::sys_open();
16191653
if matches!(action, NotifAction::Continue) && is_openat_family && has_denied {
16201654
let pfs = ctx.policy_fn.lock().await;
16211655
on_behalf_open_for_deny(&notif, policy, &pfs, fd)

crates/sandlock-core/src/seccomp_plan.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ fn chroot_path_syscalls() -> Vec<i64> {
234234
fn fs_denied_path_syscalls() -> Vec<i64> {
235235
let mut v = vec![
236236
libc::SYS_openat,
237+
arch::SYS_OPENAT2,
237238
libc::SYS_execve,
238239
libc::SYS_execveat,
239240
libc::SYS_linkat,
@@ -255,6 +256,7 @@ fn fs_denied_path_syscalls() -> Vec<i64> {
255256

256257
const POLICY_EVENT_SYSCALLS: &[i64] = &[
257258
libc::SYS_openat,
259+
arch::SYS_OPENAT2,
258260
libc::SYS_connect,
259261
libc::SYS_sendto,
260262
libc::SYS_bind,

crates/sandlock-core/tests/integration/test_landlock.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,3 +1558,59 @@ async fn test_deny_carveout_on_behalf_open_preserves_io() {
15581558

15591559
let _ = std::fs::remove_dir_all(&dir);
15601560
}
1561+
1562+
#[tokio::test]
1563+
async fn test_deny_openat2_does_not_bypass() {
1564+
// Issue #111 follow-up: openat2 must be trapped and subject to the deny
1565+
// just like openat. A program calling openat2 directly (glibc normally
1566+
// does not) must not escape a deny carve-out, and openat2 of an allowed
1567+
// sibling must still work. SYS_openat2 = 437 (x86_64 test host).
1568+
let dir = temp_file("deny-openat2-dir");
1569+
let _ = std::fs::create_dir_all(&dir);
1570+
let ok = dir.join("ok.txt");
1571+
let secret = dir.join("secret.txt");
1572+
std::fs::write(&ok, "ok-data").unwrap();
1573+
std::fs::write(&secret, "secret-data").unwrap();
1574+
1575+
let policy = Sandbox::builder()
1576+
.fs_read("/usr")
1577+
.fs_read("/lib")
1578+
.fs_read_if_exists("/lib64")
1579+
.fs_read("/bin")
1580+
.fs_read("/etc")
1581+
.fs_read("/proc")
1582+
.fs_read("/dev")
1583+
.fs_read(dir.to_str().unwrap())
1584+
.fs_deny(secret.to_str().unwrap())
1585+
.fs_write("/tmp")
1586+
.build()
1587+
.unwrap();
1588+
1589+
// raw openat2(AT_FDCWD, path, &open_how{O_RDONLY,0,0}, 24)
1590+
let script = format!(
1591+
"import ctypes, struct, sys\n\
1592+
libc = ctypes.CDLL(None, use_errno=True)\n\
1593+
def openat2(p):\n\
1594+
\x20 how = struct.pack('QQQ', 0, 0, 0)\n\
1595+
\x20 buf = ctypes.create_string_buffer(how, len(how))\n\
1596+
\x20 return libc.syscall(437, -100, ctypes.c_char_p(p.encode()), buf, ctypes.c_size_t(len(how)))\n\
1597+
secret = openat2('{}')\n\
1598+
allowed = openat2('{}')\n\
1599+
sys.exit(0 if (secret < 0 and allowed >= 0) else 1)\n",
1600+
secret.display(),
1601+
ok.display(),
1602+
);
1603+
1604+
let r = policy
1605+
.clone()
1606+
.with_name("t")
1607+
.run(&["python3", "-c", &script])
1608+
.await
1609+
.unwrap();
1610+
assert!(
1611+
r.success(),
1612+
"openat2 must be denied for the carve-out and allowed for the sibling"
1613+
);
1614+
1615+
let _ = std::fs::remove_dir_all(&dir);
1616+
}

0 commit comments

Comments
 (0)