feat(sandbox): lease task identities and drop initialization privileges

This commit is contained in:
2026-10-07 18:22:58 +08:00
parent 329c9222a3
commit 46714bd9ea
16 changed files with 888 additions and 3 deletions
+23
View File
@@ -329,3 +329,26 @@ stdin 只保留在监督器的独立快照中,不挂载到用户工作区,
`inputs_vm.rs` 在独立受限 VM 验收真实只读写入 EROFS、执行/noexec 区分、输入祖先
保护、stdin 不可见、不同任务隔离,以及链接/已有文件/空间不足导致的部分挂载回滚。
身份降权、capabilities/seccomp、用户进程树与 stdio 仍须完成后才能启用 `run`。
## 生产身份与权限收缩
`launcher/identity.rs` 的 `IdentityReducedTarget` 先在可信 state 根下的 root-owned
0700 `identities/uid-N` 目录中原子租用任务身份,再准备输入和推进降权。活跃、遗留或
未知标记一律不接管;池耗尽返回 EAGAIN。目标树终止、子进程回收和 root 清理均成功后
才移除自有租约;清理失败保留标记、独立报告错误,Drop 也遵循同样顺序。服务重启
后的遗留资源核验/回收仍是后续阶段,当前不会自动复用残留身份。
`isolation/identity.rs` 仅在固定单线程 PID 1 中执行:清空 supplementary groups,
关闭 keepcaps/ambient;锁定 root 特权、ambient 提升与 keepcaps,保留并锁定正常 UID
切换清权行为;遍历内核实际支持的全部 capability 位清空 bounding set,设置
`no_new_privs`,统一 real/effective/saved/filesystem UID/GID,并显式清空
effective/permitted/inheritable。凭据变更后重新设置 PDEATHSIG=SIGKILL。
初始化器读取 securebits/keepcaps/NNP/全部 capability 与组,监督器读取同一已持有 PID
的内核 status,再校验 root/cwd/目录 FD 和 STOP/freeze。未知 ABI、失败或不一致拒绝。
`identity_vm.rs` 与固定 VM fixture 验收真正的集成 init 凭据、并发独占/池耗尽/清理
后复用/失败留租约,以及精确生产 reducer 代码在 fork/线程/exec 中的权限继承;固定
setuid-root 文件无法恢复权限,跨 UID 信号/ptrace/文件访问拒绝。测试故障过滤器只
注入真实 setgroups EPERM,验证失败回滚;它不构成生产 seccomp 策略。fixture 仅可在
规定独立 VM 使用,没有向 CLI 增加用户执行命令。后续 seccomp、全任务树/stdio/计量
和运行后清理仍未验收,`run` 继续拒绝。
+4
View File
@@ -14,3 +14,7 @@ rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mou
[lints]
workspace = true
[[example]]
name = "identity_probe"
test = false
@@ -0,0 +1,66 @@
//! Fixed VM-only fixture; compiles the exact private production reducer.
//! It supplies no production user execution capability or arbitrary command.
mod launcher {
pub mod entry {
pub use kf_sandbox::launcher::entry::ensure_single_threaded;
}
}
#[allow(dead_code)]
#[path = "../src/isolation/identity.rs"]
mod identity;
#[path = "../tests/support/mod.rs"]
#[allow(dead_code)]
mod support;
fn main() {
support::require_vm();
let args: Vec<_> = std::env::args().collect();
assert_eq!(args.len(), 3);
assert_eq!(args[1], "fixed-probe");
let control = std::process::Command::new("/kf/bin/identity-inherit-probe")
.arg("--suid-control-child")
.env_clear()
.output()
.unwrap();
assert!(
control.status.success(),
"{}",
String::from_utf8_lossy(&control.stderr)
);
assert_eq!(
control.stdout,
b"KF_SUID_CONTROL_PROOF works_without_nnp=1\n"
);
print!("{}", String::from_utf8_lossy(&control.stdout));
rustix::thread::set_thread_groups(&[rustix::process::Gid::from_raw(123)]).unwrap();
let mut caps = rustix::thread::capabilities(None).unwrap();
caps.inheritable |= rustix::thread::CapabilitySet::CHOWN;
rustix::thread::set_capabilities(None, caps).unwrap();
rustix::thread::configure_capability_in_ambient_set(rustix::thread::CapabilitySet::CHOWN, true)
.unwrap();
rustix::thread::set_keep_capabilities(true).unwrap();
identity::reduce_current(100000, 100000).unwrap();
for program in [
"/kf/bin/identity-inherit-probe",
"/kf/bin/identity-suid-probe",
] {
let mut command = std::process::Command::new(program);
command.env_clear();
if let Some(pid) = args.get(2) {
let pid: u32 = pid.parse().unwrap();
assert!(pid > 1);
command.arg(pid.to_string());
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{:?} {:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout,b"KF_IDENTITY_INHERIT_PROBE ids=1 groups=0 caps=0 nnp=1 securebits=1 escalation_denied=1 fork_thread_inherit=1 peer_denied=1\n");
}
println!(
"KF_IDENTITY_REDUCER_PROOF primed_inheritable_ambient_groups_keepcaps_cleared=1 setuid_exec_denied=1 fork_thread_inherit=1 peer_access_denied=1"
);
}
@@ -0,0 +1,144 @@
//! Irreversible credential reduction, called only by the fixed single-threaded init.
use rustix::process::{Gid, Signal, Uid};
use rustix::thread::{CapabilitiesSecureBits as Bits, CapabilitySet, CapabilitySets};
use std::io;
fn denied(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
const SECURE: Bits = Bits::NO_ROOT
.union(Bits::NO_ROOT_LOCKED)
.union(Bits::NO_SETUID_FIXUP_LOCKED)
.union(Bits::KEEP_CAPS_LOCKED)
.union(Bits::NO_CAP_AMBIENT_RAISE)
.union(Bits::NO_CAP_AMBIENT_RAISE_LOCKED);
pub(crate) fn valid(uid: u32, gid: u32) -> bool {
uid >= 100_000 && gid >= 100_000 && uid != u32::MAX && gid != u32::MAX
}
// Probe every supported bit rather than silently dropping only constants known
// to this library. The 64-bit capability ABI is a deliberate fail-closed bound.
fn last_capability() -> io::Result<u32> {
for bit in 0..64 {
match rustix::thread::capability_is_in_bounding_set(CapabilitySet::from_bits_retain(
1 << bit,
)) {
Ok(_) => {}
Err(rustix::io::Errno::INVAL) if bit > 0 => return Ok(bit - 1),
Err(error) => return Err(error.into()),
}
}
Err(denied("unsupported capability ABI"))
}
pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> {
crate::launcher::entry::ensure_single_threaded()?;
if !valid(uid, gid) || !rustix::process::geteuid().is_root() {
return Err(denied("task identity precondition"));
}
let last = last_capability()?;
rustix::thread::set_thread_groups(&[])?;
rustix::thread::set_keep_capabilities(false)?;
rustix::thread::clear_ambient_capability_set()?;
// Lock keepcaps and no-setuid-fixup OFF, not ON. Normal UID transition must
// still clear privilege; root and ambient acquisition are locked off too.
rustix::thread::set_capabilities_secure_bits(SECURE)?;
for bit in 0..=last {
rustix::thread::remove_capability_from_bounding_set(CapabilitySet::from_bits_retain(
1 << bit,
))?;
}
rustix::thread::set_no_new_privs(true)?;
let gid = Gid::from_raw(gid);
let uid = Uid::from_raw(uid);
rustix::thread::set_thread_res_gid(gid, gid, gid)?;
rustix::thread::set_thread_res_uid(uid, uid, uid)?;
let empty = CapabilitySets {
effective: CapabilitySet::empty(),
permitted: CapabilitySet::empty(),
inheritable: CapabilitySet::empty(),
};
rustix::thread::set_capabilities(None, empty)?;
rustix::thread::clear_ambient_capability_set()?;
// Linux clears PDEATHSIG on credential changes. Restore it before ACK;
// the supervisor also checks the same held bootstrap/init parent relation.
rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?;
if rustix::thread::capabilities(None)? != empty
|| !rustix::process::getgroups()?.is_empty()
|| rustix::process::getuid() != uid
|| rustix::process::geteuid() != uid
|| rustix::process::getgid() != gid
|| rustix::process::getegid() != gid
|| rustix::thread::get_keep_capabilities()?
|| rustix::thread::capabilities_secure_bits()? != SECURE
|| !rustix::thread::no_new_privs()?
|| rustix::process::parent_process_death_signal()? != Some(Signal::KILL)
{
return Err(denied("task credential readback"));
}
for bit in 0..=last {
let cap = CapabilitySet::from_bits_retain(1 << bit);
if rustix::thread::capability_is_in_bounding_set(cap)?
|| rustix::thread::capability_is_in_ambient_set(cap)?
{
return Err(denied("task capability readback"));
}
}
verify_status(
&std::fs::read_to_string("/proc/self/status")?,
uid.as_raw(),
gid.as_raw(),
)
}
pub(crate) fn reduce_init(uid: u32, gid: u32) -> io::Result<()> {
if rustix::process::getpid().as_raw_pid() != 1 {
return Err(denied("credential stage requires namespace init"));
}
reduce_current(uid, gid)
}
fn field<'a>(status: &'a str, name: &str) -> io::Result<&'a str> {
status
.lines()
.find_map(|line| line.strip_prefix(name))
.ok_or_else(|| denied("missing credential field"))
}
pub(crate) fn verify_status(status: &str, uid: u32, gid: u32) -> io::Result<()> {
for (name, expected) in [("Uid:", uid), ("Gid:", gid)] {
let ids: Vec<_> = field(status, name)?.split_whitespace().collect();
if ids.len() != 4 || ids.iter().any(|s| s.parse::<u32>().ok() != Some(expected)) {
return Err(denied("real/effective/saved/filesystem identity"));
}
}
if !field(status, "Groups:")?.trim().is_empty() || field(status, "NoNewPrivs:")?.trim() != "1" {
return Err(denied("groups/no_new_privs"));
}
for name in ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"] {
if u64::from_str_radix(field(status, name)?.trim(), 16).ok() != Some(0) {
return Err(denied("nonempty capability set"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const STATUS: &str = "Uid:\t100000 100000 100000 100000\nGid:\t100001 100001 100001 100001\nGroups:\t\nNoNewPrivs:\t1\nCapInh:\t0000\nCapPrm:\t0000\nCapEff:\t0000\nCapBnd:\t0000\nCapAmb:\t0000\n";
#[test]
fn readback_rejects_saved_ids_groups_missing_fields_and_any_capability() {
verify_status(STATUS, 100000, 100001).unwrap();
for invalid in [
STATUS.replace("100000 100000 100000 100000", "100000 100000 0 100000"),
STATUS.replace("Groups:\t", "Groups:\t0"),
STATUS.replace("NoNewPrivs:\t1", "NoNewPrivs:\t0"),
STATUS.replace("CapBnd:\t0000", "CapBnd:\t0001"),
STATUS.replace("CapAmb:\t0000\n", ""),
] {
assert!(verify_status(&invalid, 100000, 100001).is_err());
}
}
}
@@ -1,5 +1,6 @@
//! Fixed Linux isolation operations; launcher owns process/control orchestration.
pub(crate) mod identity;
pub(crate) mod inputs;
pub mod namespaces;
pub(crate) mod proc_dev;
+1 -1
View File
@@ -98,7 +98,7 @@ fn safe_directory(fd: &OwnedFd) -> io::Result<()> {
}
Ok(())
}
fn open_directory(path: &Path) -> io::Result<OwnedFd> {
pub(crate) fn open_directory(path: &Path) -> io::Result<OwnedFd> {
if !path.is_absolute() {
return Err(invalid("root capability must be absolute"));
}
@@ -0,0 +1,240 @@
//! Identity ownership is retained through target teardown, including failed cleanup.
use super::blocked::{InitializationFailure, failure};
use super::inputs::InputBoundTarget;
use crate::artifacts::ArtifactRoot;
use crate::isolation::root::RootError;
use crate::outcome::{ErrorCode, ExecutionStage};
use crate::runtime::ValidatedRuntimeConfig;
use crate::spec::ValidatedSpec;
use rustix::fd::OwnedFd;
use rustix::fs::{AtFlags, Mode, OFlags};
use std::{io, io::Write, path::Path};
const DIR: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC);
fn denied(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
fn private(fd: &OwnedFd) -> io::Result<()> {
let s = rustix::fs::fstat(fd)?;
if s.st_uid != 0 || s.st_gid != 0 || s.st_mode & 0o7777 != 0o700 {
return Err(denied("unsafe identity lease directory"));
}
Ok(())
}
struct IdentityLease {
parent: OwnedFd,
slot: OwnedFd,
name: String,
uid: u32,
gid: u32,
removed: bool,
}
impl IdentityLease {
fn acquire(config: &ValidatedRuntimeConfig, spec: &ValidatedSpec) -> Result<Self, RootError> {
if !rustix::process::geteuid().is_root() {
return Err(denied("identity allocator requires privileged supervisor").into());
}
let state = crate::isolation::root::open_directory(Path::new(&config.config().state_root))?;
match rustix::fs::mkdirat(&state, "identities", Mode::from_raw_mode(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(e) => return Err(e.into()),
}
let parent = rustix::fs::openat(&state, "identities", DIR, Mode::empty())?;
private(&parent)?;
for index in 0..config.config().max_concurrent_runs {
let (uid, gid) = config
.task_identity(index)
.ok_or_else(|| denied("identity slot mapping"))?;
let name = format!("uid-{uid}");
match rustix::fs::mkdirat(&parent, name.as_str(), Mode::from_raw_mode(0o700)) {
Err(rustix::io::Errno::EXIST) => continue, // Never adopt a crash/foreign marker.
Err(e) => return Err(e.into()),
Ok(()) => {}
}
let slot = match rustix::fs::openat(&parent, name.as_str(), DIR, Mode::empty()) {
Ok(fd) => fd,
Err(e) => {
return Err(RootError {
source: e.into(),
cleanup_error: rustix::fs::unlinkat(
&parent,
name.as_str(),
AtFlags::REMOVEDIR,
)
.err()
.map(Into::into),
});
}
};
let mut lease = Self {
parent,
slot,
name,
uid,
gid,
removed: false,
};
let setup = (|| {
rustix::fs::fchmod(&lease.slot, Mode::from_raw_mode(0o700))?;
private(&lease.slot)?;
let marker = rustix::fs::openat(
&lease.slot,
"owner.json",
OFlags::WRONLY
| OFlags::CREATE
| OFlags::EXCL
| OFlags::CLOEXEC
| OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
)?;
let mut marker = std::fs::File::from(marker);
serde_json::to_writer(
&mut marker,
&serde_json::json!({"version":1,"run_id":spec.spec().run_id,"uid":uid,"gid":gid}),
)?;
marker.write_all(b"\n")?;
marker.sync_all()?;
rustix::fs::fsync(&lease.slot)?;
rustix::fs::fsync(&lease.parent)?;
Ok::<_, io::Error>(())
})();
if let Err(source) = setup {
return Err(RootError {
source,
cleanup_error: lease.release().err(),
});
}
return Ok(lease);
}
Err(rustix::io::Errno::AGAIN.into())
}
// There is intentionally no automatic lease release in IdentityLease::Drop.
// Only a wrapper that has confirmed complete teardown may remove a marker.
fn release(&mut self) -> io::Result<()> {
if self.removed {
return Ok(());
}
let named = rustix::fs::openat(&self.parent, self.name.as_str(), DIR, Mode::empty())?;
let a = rustix::fs::fstat(&named)?;
let b = rustix::fs::fstat(&self.slot)?;
if (a.st_dev, a.st_ino) != (b.st_dev, b.st_ino) {
return Err(denied("identity marker changed"));
}
match rustix::fs::unlinkat(&self.slot, "owner.json", AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
Err(e) => return Err(e.into()),
}
rustix::fs::unlinkat(&self.parent, self.name.as_str(), AtFlags::REMOVEDIR)?;
self.removed = true;
rustix::fs::fsync(&self.parent)?;
Ok(())
}
}
fn release_after_cleanup(lease: &mut IdentityLease, result: &mut InitializationFailure) {
if result.cleanup_failure.is_none()
&& let Err(e) = lease.release()
{
result.cleanup_failure = Some(failure(
ExecutionStage::Cleanup,
ErrorCode::CleanupIncomplete,
Some(&e),
));
}
}
pub struct IdentityReducedTarget {
target: Option<InputBoundTarget>,
lease: IdentityLease,
}
impl IdentityReducedTarget {
pub fn prepare(
config: &ValidatedRuntimeConfig,
spec: &ValidatedSpec,
executable: &Path,
artifacts: &ArtifactRoot,
) -> Result<Self, InitializationFailure> {
let mut lease =
IdentityLease::acquire(config, spec).map_err(|e| InitializationFailure {
failure: failure(
ExecutionStage::Identity,
ErrorCode::OperationFailed,
Some(&e.source),
),
cleanup_failure: e.cleanup_error.as_ref().map(|e| {
failure(
ExecutionStage::Cleanup,
ErrorCode::CleanupIncomplete,
Some(e),
)
}),
})?;
let mut target = match InputBoundTarget::prepare(config, spec, executable, artifacts) {
Ok(target) => target,
Err(mut e) => {
release_after_cleanup(&mut lease, &mut e);
return Err(e);
}
};
if let Err(e) = target.target.root.initialize_identity(lease.uid, lease.gid) {
let mut e = target
.target
.root
.refuse_stage(ExecutionStage::Identity, Some(&e));
release_after_cleanup(&mut lease, &mut e);
return Err(e);
}
Ok(Self {
target: Some(target),
lease,
})
}
pub fn root(&self) -> &super::root::RootedTarget {
self.target.as_ref().expect("owned target").root()
}
pub fn identity(&self) -> (u32, u32) {
(self.lease.uid, self.lease.gid)
}
pub fn stdin(&self) -> Option<&crate::artifacts::SnapshotFile> {
self.target.as_ref().expect("owned target").stdin()
}
pub fn refuse_remaining_isolation(mut self) -> InitializationFailure {
let target = self.target.take().expect("owned target");
let mut result = target
.target
.root
.refuse_stage(ExecutionStage::Seccomp, None);
release_after_cleanup(&mut self.lease, &mut result);
result
}
}
impl Drop for IdentityReducedTarget {
fn drop(&mut self) {
if let Some(target) = self.target.take() {
let mut result = target
.target
.root
.refuse_stage(ExecutionStage::Seccomp, None);
release_after_cleanup(&mut self.lease, &mut result);
// An error leaves the durable marker reserved for restart recovery.
}
}
}
pub(crate) fn initialize(
channel: &mut super::protocol::ControlChannel,
uid: u32,
gid: u32,
) -> io::Result<()> {
let result = crate::isolation::identity::reduce_init(uid, gid);
if let Err(e) = &result {
let _ = channel.send(&super::protocol::NamespaceEvent::Failed {
errno: e.raw_os_error().and_then(|n| u16::try_from(n).ok()),
});
return result;
}
channel.send(&super::protocol::NamespaceEvent::IdentityInitialized {})
}
+1
View File
@@ -12,6 +12,7 @@ pub mod blocked;
#[cfg(target_os = "linux")]
pub mod entry;
#[cfg(target_os = "linux")]
pub mod identity;
pub mod inputs;
#[cfg(target_os = "linux")]
pub mod namespaces;
@@ -189,7 +189,14 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::
NamespaceCommand::Cancel {} => return Ok(()),
_ => return Err(denied("input command before preparation")),
}
let _ = channel.receive::<NamespaceCommand>()?;
match channel.receive::<NamespaceCommand>()? {
NamespaceCommand::PrepareIdentity { uid, gid } => {
super::identity::initialize(&mut channel, uid, gid)?;
let _ = channel.receive::<NamespaceCommand>()?;
}
NamespaceCommand::Cancel {} => return Ok(()),
_ => return Err(denied("identity command before inputs")),
}
}
NamespaceCommand::Cancel {} => return Ok(()),
_ => {}
@@ -211,6 +218,7 @@ pub struct NamespacedTarget {
root_ready: bool,
proc_dev_ready: bool,
inputs_ready: bool,
identity_ready: bool,
init_pid: u32,
ids: NamespaceIds,
}
@@ -304,6 +312,7 @@ impl NamespacedTarget {
root_ready: false,
proc_dev_ready: false,
inputs_ready: false,
identity_ready: false,
}),
Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))),
}
@@ -378,6 +387,24 @@ impl NamespacedTarget {
Ok(())
}
pub(crate) fn initialize_identity(&mut self, uid: u32, gid: u32) -> io::Result<()> {
if !self.inputs_ready || self.identity_ready || !crate::isolation::identity::valid(uid, gid)
{
return Err(denied("credential initialization order"));
}
self.initialize_filesystem(
NamespaceCommand::PrepareIdentity { uid, gid },
NamespaceEvent::IdentityInitialized {},
)?;
crate::isolation::identity::verify_status(
&std::fs::read_to_string(format!("/proc/{}/status", self.init_pid))?,
uid,
gid,
)?;
self.identity_ready = true;
Ok(())
}
fn expect_event(&mut self, expected: NamespaceEvent) -> io::Result<()> {
match self.control.receive::<NamespaceEvent>()? {
event if event == expected => Ok(()),
@@ -63,6 +63,10 @@ pub enum NamespaceCommand {
executable: bool,
},
FinishInputs {},
PrepareIdentity {
uid: u32,
gid: u32,
},
Cancel {},
}
@@ -75,6 +79,7 @@ pub enum NamespaceEvent {
InputsStarted {},
InputAttached {},
InputsInitialized {},
IdentityInitialized {},
Failed { errno: Option<u16> },
Cancelled {},
}
@@ -110,6 +115,7 @@ impl WireMessage for NamespaceCommand {
crate::spec::path(path, "input").is_ok()
&& *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES
}
Self::PrepareIdentity { uid, gid } => crate::isolation::identity::valid(*uid, *gid),
_ => true,
}
}
+17 -1
View File
@@ -148,6 +148,22 @@ impl RootedTarget {
crate::isolation::inputs::verify(self.init_pid(), &inputs.files)
}
pub(crate) fn initialize_identity(&mut self, uid: u32, gid: u32) -> io::Result<()> {
self.target.initialize_identity(uid, gid)?;
self.lease.verify_root(self.init_pid())?;
self.lease.verify_root(self.bootstrap_pid())
}
pub(crate) fn refuse_stage(
mut self,
stage: ExecutionStage,
error: Option<&io::Error>,
) -> InitializationFailure {
let mut result = self.target.prepared.refuse_at(stage, error);
attach_cleanup(&mut result, self.lease.cleanup().err());
result
}
pub(crate) fn refuse_root_error(mut self, error: &io::Error) -> InitializationFailure {
let mut failure = self
.target
@@ -157,7 +173,7 @@ impl RootedTarget {
failure
}
/// Restricted proc/dev, identity, capabilities and seccomp are still absent.
/// This base type has no proof of later input/identity/seccomp stages.
pub fn refuse_remaining_isolation(mut self) -> InitializationFailure {
let mut failure = self
.target
+6
View File
@@ -148,6 +148,12 @@ impl RuntimeConfig {
{
return Err(invalid("roots", "invalid_layout"));
}
let identity_root = format!("{}/identities", self.state_root);
if [&self.tasks_root, &self.image_root].iter().any(|root| {
*root == &identity_root || inside(root, &identity_root) || inside(&identity_root, root)
}) {
return Err(invalid("roots", "identity_lease_overlap"));
}
if !inside(&self.delegated_cgroup_root, "/sys/fs/cgroup") {
return Err(invalid("delegated_cgroup_root", "not_delegated_subtree"));
}
+29
View File
@@ -0,0 +1,29 @@
/* VM-only kernel failure injector. It execs one fixed trusted test case. */
#define _GNU_SOURCE
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <errno.h>
#include <stddef.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void) {
const char *vm=getenv("KF_SANDBOX_INDEPENDENT_VM");
if(!vm||vm[0]!='1'||vm[1]||access("/kf/INDEPENDENT_VM",R_OK))return 2;
struct sock_filter filter[]={
BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,arch)),
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,AUDIT_ARCH_X86_64,1,0),
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_KILL_PROCESS),
BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,nr)),
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,__NR_setgroups,0,1),
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ERRNO|EPERM),
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ALLOW)
};
struct sock_fprog program={.len=sizeof filter/sizeof *filter,.filter=filter};
if(prctl(PR_SET_NO_NEW_PRIVS,1,0,0,0)||prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&program))return 3;
setenv("KF_SANDBOX_DENY_SETGROUPS","1",1);
execl("/kf/bin/identity-vm-tests","identity-vm-tests","--ignored","--test-threads=1","--exact","kernel_credential_failure_refuses_and_releases_own_lease","--nocapture",(char*)0);
return 4;
}
@@ -0,0 +1,67 @@
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <linux/capability.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/prctl.h>
#include <sys/ptrace.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
static int check(void) {
uid_t r,e,s; gid_t gr,ge,gs;
if(getresuid(&r,&e,&s)||getresgid(&gr,&ge,&gs)) return 1;
if(r!=100000||e!=r||s!=r||gr!=r||ge!=r||gs!=r||getgroups(0,NULL)!=0) return 2;
struct __user_cap_header_struct h={_LINUX_CAPABILITY_VERSION_3,0};
struct __user_cap_data_struct d[2]={{0}};
if(syscall(SYS_capget,&h,d)) return 3;
for(int i=0;i<2;i++) if(d[i].effective||d[i].permitted||d[i].inheritable) return 4;
if(prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)!=1||prctl(PR_GET_SECUREBITS,0,0,0,0)!=0xeb) return 5;
for(int cap=0;cap<64;cap++) {
int b=prctl(PR_CAPBSET_READ,cap,0,0,0);
if(b==-1 && errno==EINVAL) break;
if(b!=0||prctl(PR_CAP_AMBIENT,PR_CAP_AMBIENT_IS_SET,cap,0,0)!=0) return 6;
}
if(setuid(0)!=-1||errno!=EPERM||setgid(0)!=-1||errno!=EPERM) return 7;
gid_t group=0;
if(setgroups(1,&group)!=-1||errno!=EPERM) return 8;
if(mount("tmpfs","/tmp", "tmpfs",0,NULL)!=-1||errno!=EPERM) return 9;
d[0].permitted=d[0].effective=1U<<CAP_SYS_ADMIN;
if(syscall(SYS_capset,&h,d)!=-1||errno!=EPERM) return 10;
if(prctl(PR_SET_SECUREBITS,0,0,0,0)!=-1||errno!=EPERM) return 11;
return 0;
}
static void *thread_check(void *unused) { (void)unused; return (void *)(long)check(); }
int main(int argc,char **argv) {
if(argc==2 && !strcmp(argv[1],"--suid-control-child")) {
if(access("/kf/INDEPENDENT_VM",R_OK)||geteuid()!=0||prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)!=0) return 30;
if(setgroups(0,NULL)||setresgid(100000,100000,100000)||setresuid(100000,100000,100000))return 31;
execl("/kf/bin/identity-suid-probe","identity-suid-probe","--suid-positive",(char *)0);return 32;
}
if(argc==2 && !strcmp(argv[1],"--suid-positive")) {
if(getuid()!=100000||geteuid()!=0) return 33;
puts("KF_SUID_CONTROL_PROOF works_without_nnp=1");return 0;
}
int result=check(); if(result){fprintf(stderr,"credential check=%d\n",result);return result;}
pthread_t thread; void *thread_result;
if(pthread_create(&thread,NULL,thread_check,NULL)||pthread_join(thread,&thread_result)||thread_result) return 20;
pid_t child=fork();if(child<0)return 21;if(!child)_exit(check());
int status;if(waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status))return 22;
if(argc==2) {
pid_t peer=(pid_t)strtol(argv[1],NULL,10); if(peer<=1)return 23;
if(kill(peer,0)!=-1||errno!=EPERM)return 24;
if(ptrace(PTRACE_ATTACH,peer,NULL,NULL)!=-1||errno!=EPERM)return 25;
char path[64];snprintf(path,sizeof path,"/proc/%d/root/work/main",peer);
int fd=open(path,O_RDONLY|O_CLOEXEC);if(fd>=0){close(fd);return 26;}
if(errno!=EACCES&&errno!=EPERM)return 27;
} else if(argc!=1) return 28;
puts("KF_IDENTITY_INHERIT_PROBE ids=1 groups=0 caps=0 nnp=1 securebits=1 escalation_denied=1 fork_thread_inherit=1 peer_denied=1");
return 0;
}
+253
View File
@@ -0,0 +1,253 @@
#![cfg(target_os = "linux")]
mod support;
use kf_sandbox::artifacts::ArtifactRoot;
use kf_sandbox::launcher::identity::IdentityReducedTarget;
use kf_sandbox::outcome::ExecutionStage;
use std::os::unix::fs::PermissionsExt;
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
use support::{Root, binary, children};
const LEASES: &str = "/var/lib/kf-judge/identities";
struct Publications(PathBuf);
impl Publications {
fn new() -> Self {
support::require_vm();
let path = PathBuf::from(format!("/kf/identity-publications-{}", std::process::id()));
fs::create_dir(&path).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(path.join("program-001"), b"fixed-non-task-payload").unwrap();
fs::write(path.join("input-001"), b"private-stdin").unwrap();
Self(path)
}
fn root(&self) -> ArtifactRoot {
ArtifactRoot::from_directory(
rustix::fs::open(
&self.0,
rustix::fs::OFlags::PATH
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.unwrap(),
)
.unwrap()
}
}
impl Drop for Publications {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
fn prepare(
root: &Root,
publications: &Publications,
id: &str,
) -> Result<IdentityReducedTarget, kf_sandbox::launcher::blocked::InitializationFailure> {
IdentityReducedTarget::prepare(
&root.validated(),
&support::spec(id, 64 << 20, 8),
&binary(),
&publications.root(),
)
}
fn leases() -> Vec<String> {
let mut result: Vec<_> = fs::read_dir(LEASES)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
result.sort();
result
}
fn verify_ids(target: &IdentityReducedTarget) {
let (uid, gid) = target.identity();
let status = fs::read_to_string(format!("/proc/{}/status", target.root().init_pid())).unwrap();
for (name, id) in [("Uid:", uid), ("Gid:", gid)] {
let line = status.lines().find(|l| l.starts_with(name)).unwrap();
assert_eq!(
line.split_whitespace()
.skip(1)
.map(|s| s.parse::<u32>().unwrap())
.collect::<Vec<_>>(),
vec![id; 4]
);
}
for name in ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"] {
assert_eq!(
u64::from_str_radix(
status
.lines()
.find(|l| l.starts_with(name))
.unwrap()
.split_whitespace()
.nth(1)
.unwrap(),
16
)
.unwrap(),
0
);
}
assert_eq!(
status
.lines()
.find(|l| l.starts_with("Groups:"))
.unwrap()
.split_whitespace()
.count(),
1
);
assert!(status.contains("NoNewPrivs:\t1\n"));
assert!(target.root().snapshot_outputs().unwrap().is_empty());
}
#[test]
#[ignore = "requires independent capped VM and fixed credential inheritance fixture"]
fn production_init_reduces_credentials_and_leases_distinct_ids_until_cleanup() {
let root = Root::new();
let publications = Publications::new();
let before = children();
let one = prepare(&root, &publications, "identity-one").unwrap();
let two = prepare(&root, &publications, "identity-two").unwrap();
assert_eq!(one.identity(), (100000, 100000));
assert_eq!(two.identity(), (100001, 100001));
verify_ids(&one);
verify_ids(&two);
assert_eq!(leases(), ["uid-100000", "uid-100001"]);
let Err(busy) = prepare(&root, &publications, "identity-busy") else {
panic!("live identity reused");
};
assert_eq!(busy.failure.stage, ExecutionStage::Identity);
assert_eq!(busy.failure.errno, Some(11));
assert!(busy.cleanup_failure.is_none());
let output = Command::new("/kf/bin/identity-probe")
.args(["fixed-probe", &two.root().init_pid().to_string()])
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{:?} {:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let proof = String::from_utf8(output.stdout).unwrap();
assert!(proof.contains("KF_IDENTITY_REDUCER_PROOF"));
print!("{proof}");
let result = one.refuse_remaining_isolation();
assert_eq!(result.failure.stage, ExecutionStage::Seccomp);
assert!(result.cleanup_failure.is_none());
let reuse = prepare(&root, &publications, "identity-reuse").unwrap();
assert_eq!(reuse.identity(), (100000, 100000));
verify_ids(&reuse);
drop(reuse);
drop(two);
assert!(leases().is_empty());
assert_eq!(children(), before);
for id in [
"identity-one",
"identity-two",
"identity-busy",
"identity-reuse",
] {
assert!(!root.path.join(format!("kf-{id}")).exists());
assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists());
}
println!(
"KF_IDENTITY_PROOF all_four_ids=1 groups=0 five_cap_sets=0 nnp=1 distinct_leases=1 exhaustion_refused=1 release_after_cleanup=1 safe_reuse=1 drop_cleanup=1 fixed_fixture_not_user_exec=1"
);
}
#[test]
#[ignore = "requires independent capped VM"]
fn foreign_markers_and_unsafe_lease_roots_are_not_adopted() {
let root = Root::new();
let publications = Publications::new();
let before = children();
fs::create_dir_all(LEASES).unwrap();
fs::set_permissions(LEASES, fs::Permissions::from_mode(0o700)).unwrap();
let foreign = Path::new(LEASES).join("uid-100000");
fs::create_dir(&foreign).unwrap();
fs::set_permissions(&foreign, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(foreign.join("foreign"), b"must retain").unwrap();
let target = prepare(&root, &publications, "identity-foreign").unwrap();
assert_eq!(target.identity(), (100001, 100001));
drop(target);
assert_eq!(fs::read(foreign.join("foreign")).unwrap(), b"must retain");
fs::remove_file(foreign.join("foreign")).unwrap();
fs::remove_dir(&foreign).unwrap();
fs::set_permissions(LEASES, fs::Permissions::from_mode(0o777)).unwrap();
let Err(error) = prepare(&root, &publications, "identity-unsafe") else {
panic!("writable lease root accepted");
};
assert_eq!(error.failure.stage, ExecutionStage::Identity);
assert!(error.cleanup_failure.is_none());
assert!(leases().is_empty());
fs::set_permissions(LEASES, fs::Permissions::from_mode(0o700)).unwrap();
fs::remove_file(publications.0.join("program-001")).unwrap();
let Err(error) = prepare(&root, &publications, "identity-bad-input") else {
panic!("missing input accepted");
};
assert_eq!(error.failure.stage, ExecutionStage::Preparation);
assert!(error.cleanup_failure.is_none());
assert!(leases().is_empty());
assert_eq!(children(), before);
println!(
"KF_IDENTITY_LEASE_POLICY_PROOF foreign_retained=1 writable_root_refused=1 failed_preparation_slot_released=1 no_target_leftover=1"
);
}
#[test]
#[ignore = "requires independent capped VM; cleanup failure injection"]
fn incomplete_target_cleanup_keeps_identity_reserved() {
let root = Root::new();
let publications = Publications::new();
let before = children();
let target = prepare(&root, &publications, "identity-cleanup-failure").unwrap();
let task = Path::new("/var/lib/kf-judge/tasks/kf-identity-cleanup-failure");
fs::write(task.join("foreign"), b"keep").unwrap();
let error = target.refuse_remaining_isolation();
assert_eq!(error.failure.stage, ExecutionStage::Seccomp);
assert!(error.cleanup_failure.is_some());
assert_eq!(leases(), ["uid-100000"]);
assert!(!root.path.join("kf-identity-cleanup-failure").exists());
assert_eq!(children(), before);
let next = prepare(&root, &publications, "identity-after-failed-cleanup").unwrap();
assert_eq!(next.identity(), (100001, 100001));
drop(next);
assert_eq!(fs::read(task.join("foreign")).unwrap(), b"keep");
// VM operator restores the test baseline only after proving both task
// cgroups/children gone. Production restart recovery is still unimplemented.
fs::remove_file(task.join("foreign")).unwrap();
fs::remove_dir(task).unwrap();
fs::remove_file(Path::new(LEASES).join("uid-100000/owner.json")).unwrap();
fs::remove_dir(Path::new(LEASES).join("uid-100000")).unwrap();
assert!(leases().is_empty());
println!(
"KF_IDENTITY_CLEANUP_PROOF cleanup_error_separate=1 failed_slot_reserved=1 alternate_slot_only=1 foreign_not_deleted=1 test_baseline_operator_restore=1"
);
}
#[test]
#[ignore = "requires independent capped VM with kernel setgroups failure injector"]
fn kernel_credential_failure_refuses_and_releases_own_lease() {
let root = Root::new();
assert_eq!(
std::env::var("KF_SANDBOX_DENY_SETGROUPS").as_deref(),
Ok("1")
);
let publications = Publications::new();
let before = children();
let Err(error) = prepare(&root, &publications, "identity-kernel-denied") else {
panic!("credential error ignored");
};
assert_eq!(error.failure.stage, ExecutionStage::Identity);
assert_eq!(error.failure.errno, Some(1));
assert!(error.cleanup_failure.is_none());
assert!(leases().is_empty());
assert_eq!(children(), before);
assert!(!root.path.join("kf-identity-kernel-denied").exists());
assert!(!Path::new("/var/lib/kf-judge/tasks/kf-identity-kernel-denied").exists());
println!(
"KF_IDENTITY_FAILURE_PROOF real_kernel_eperm=1 identity_ack_refused=1 slot_released_after_teardown=1 child_reaped=1 task_cleanup=1"
);
}
@@ -46,6 +46,8 @@ fn roots_reject_traversal_aliases_system_locations_and_overlapping_storage() {
for path in [
"/var/lib/kf-judge/tasks",
"/var/lib/kf-judge/tasks/image",
"/var/lib/kf-judge/identities",
"/var/lib/kf-judge/identities/image",
"/var/lib/kf-judge",
"/srv/unrelated",
] {