From 9394a914f81dd2883dabbbca426a7d278e6a052d Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Fri, 9 Oct 2026 17:49:30 +0800 Subject: [PATCH] feat(sandbox): bind managed requests to pinned runtime identities --- backend/tests/test_judging_image_builder.py | 1 + judge/crates/sandbox/src/image.rs | 28 +- judge/crates/sandbox/src/isolation/seccomp.rs | 6 +- judge/crates/sandbox/src/runtime.rs | 2 +- judge/crates/sandbox/src/spec.rs | 39 ++- judge/crates/sandbox/tests/managed_cpu_vm.rs | 111 +++++++ .../crates/sandbox/tests/managed_profiles.rs | 91 ++++++ .../sandbox/tests/managed_runtime_vm.rs | 291 ++++++++++++++++++ judge/crates/worker/src/lib.rs | 1 + judge/crates/worker/src/pipeline.rs | 2 + scripts/build_judging_images.py | 4 +- 11 files changed, 571 insertions(+), 5 deletions(-) create mode 100644 judge/crates/sandbox/tests/managed_cpu_vm.rs diff --git a/backend/tests/test_judging_image_builder.py b/backend/tests/test_judging_image_builder.py index 27a04d9..f53d704 100644 --- a/backend/tests/test_judging_image_builder.py +++ b/backend/tests/test_judging_image_builder.py @@ -32,6 +32,7 @@ def test_catalog_uses_wire_path_order_including_dot_and_directory_boundaries(tmp {"usr/lib/ld-linux-x86-64.so.2": {"kind": "file", "sha256": builder.sha(library)}}, ) manifest = json.loads((image / "manifest.json").read_bytes()) + assert manifest["architecture"] == "x86_64_v3" paths = [item["path"] for item in manifest["files"]] assert paths == sorted(paths) assert paths.index("usr/lib/module.py") < paths.index("usr/lib/module/a.py") diff --git a/judge/crates/sandbox/src/image.rs b/judge/crates/sandbox/src/image.rs index 558c6e7..293eb0a 100644 --- a/judge/crates/sandbox/src/image.rs +++ b/judge/crates/sandbox/src/image.rs @@ -62,7 +62,7 @@ impl ImageCatalog { .validate_for_language(language) .map_err(|_| refused())?; if self.format_version != 2 - || self.architecture != "x86_64" + || self.architecture != "x86_64_v3" || self.image_kind != identity.image_kind || self.image_revision != identity.image_revision || self.toolchain_id != identity.toolchain_id @@ -143,6 +143,9 @@ pub(crate) fn verify(image: &OwnedFd, identity: &RuntimeIdentity, bytes: &[u8]) } let catalog: ImageCatalog = serde_json::from_slice(bytes).map_err(|_| refused())?; catalog.validate(identity)?; + if !compatible_cpu() { + return Err(refused()); + } let mut files: BTreeMap<_, _> = catalog.files.iter().map(|f| (f.path.as_str(), f)).collect(); let mut dirs: std::collections::BTreeSet<_> = catalog.directories.iter().map(String::as_str).collect(); @@ -156,6 +159,29 @@ pub(crate) fn verify(image: &OwnedFd, identity: &RuntimeIdentity, bytes: &[u8]) } Ok(()) } + +/// Fixed images include an x86-64-v3 support-library closure. Detect the actual +/// processor/OS AVX support in Root, before the first image binary is executed. +#[cfg(target_arch = "x86_64")] +fn compatible_cpu() -> bool { + std::is_x86_feature_detected!("sse3") + && std::is_x86_feature_detected!("ssse3") + && std::is_x86_feature_detected!("sse4.1") + && std::is_x86_feature_detected!("sse4.2") + && std::is_x86_feature_detected!("popcnt") + && std::is_x86_feature_detected!("avx") + && std::is_x86_feature_detected!("avx2") + && std::is_x86_feature_detected!("bmi1") + && std::is_x86_feature_detected!("bmi2") + && std::is_x86_feature_detected!("fma") + && std::is_x86_feature_detected!("f16c") + && std::is_x86_feature_detected!("lzcnt") + && std::is_x86_feature_detected!("cmpxchg16b") +} +#[cfg(not(target_arch = "x86_64"))] +fn compatible_cpu() -> bool { + false +} fn walk<'a>( directory: &OwnedFd, prefix: &str, diff --git a/judge/crates/sandbox/src/isolation/seccomp.rs b/judge/crates/sandbox/src/isolation/seccomp.rs index 36f4a79..b6cfee7 100644 --- a/judge/crates/sandbox/src/isolation/seccomp.rs +++ b/judge/crates/sandbox/src/isolation/seccomp.rs @@ -267,7 +267,11 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result { rules( ioctls .into_iter() - .map(|op| Ok(vec![condition(1, SeccompCmpOp::Eq, op)?])) + // Linux request numbers occupy 32 bits. musl exposes them as + // signed int (including TCGETS2's high bit), glibc as u64. + // Reinterpret that fixed bit pattern, never sign-extend it into + // an allowed high-word ioctl bypass. + .map(|op| Ok(vec![condition(1, SeccompCmpOp::Eq, u64::from(op as u32))?])) .collect::>()?, )?, ); diff --git a/judge/crates/sandbox/src/runtime.rs b/judge/crates/sandbox/src/runtime.rs index b069ec0..3ce6eeb 100644 --- a/judge/crates/sandbox/src/runtime.rs +++ b/judge/crates/sandbox/src/runtime.rs @@ -125,7 +125,7 @@ impl ValidatedRuntimeConfig { pub fn spec_policy(&self) -> SpecPolicy { SpecPolicy::with_limits_ceiling(self.0.per_task_ceiling.clone()) .expect("validated immutable limits ceiling") - .for_image(self.0.image_kind) + .for_image(self.0.image_kind, self.0.runtime_identity.clone()) } /// Slot mapping only; the supervisor must lease slots until the old task diff --git a/judge/crates/sandbox/src/spec.rs b/judge/crates/sandbox/src/spec.rs index 4ce69a7..7f3e055 100644 --- a/judge/crates/sandbox/src/spec.rs +++ b/judge/crates/sandbox/src/spec.rs @@ -152,6 +152,10 @@ pub struct InputArtifact { #[serde(deny_unknown_fields)] pub struct ExecutionSpec { pub schema_version: SchemaVersion, + /// Bind a managed request to the operator's exact immutable image. Omission + /// preserves the original GNU v1 wire bytes, never managed authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_identity: Option, pub run_id: String, pub profile: ExecutionProfile, pub argv: Vec, @@ -172,6 +176,7 @@ pub struct ExecutionSpec { pub struct SpecPolicy { limits_ceiling: ResourceLimits, image_kind: Option, + runtime_identity: Option, } impl Default for SpecPolicy { @@ -179,6 +184,7 @@ impl Default for SpecPolicy { Self { limits_ceiling: ResourceLimits::ceilings(), image_kind: None, + runtime_identity: None, } } } @@ -189,11 +195,17 @@ impl SpecPolicy { Ok(Self { limits_ceiling: limits, image_kind: None, + runtime_identity: None, }) } - pub(crate) fn for_image(mut self, kind: crate::runtime::ImageKind) -> Self { + pub(crate) fn for_image( + mut self, + kind: crate::runtime::ImageKind, + identity: Option, + ) -> Self { self.image_kind = Some(kind); + self.runtime_identity = identity; self } } @@ -235,6 +247,7 @@ impl ValidatedSpec { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ValidationCode { + InvalidRuntimeIdentity, InvalidIdentifier, InvalidArgv, UnsupportedExecutable, @@ -354,6 +367,30 @@ fn validate_limits(limits: &ResourceLimits, cap: &ResourceLimits) -> Result<(), impl ExecutionSpec { pub fn validate(self, policy: &SpecPolicy) -> Result { identifier(&self.run_id, "run_id")?; + let language = match self.profile { + ExecutionProfile::Native | ExecutionProfile::CompileC => { + kf_contracts::judging_v2::Language::C17 + } + ExecutionProfile::CompileCpp => kf_contracts::judging_v2::Language::Cpp20, + ExecutionProfile::PythonCheck | ExecutionProfile::PythonRun => { + kf_contracts::judging_v2::Language::Python313 + } + ExecutionProfile::JavaCompile | ExecutionProfile::JavaRun => { + kf_contracts::judging_v2::Language::Java21 + } + }; + if self.profile.managed_runtime() && self.runtime_identity.is_none() + || self + .runtime_identity + .as_ref() + .is_some_and(|identity| identity.validate_for_language(language).is_err()) + || policy.image_kind.is_some() && self.runtime_identity != policy.runtime_identity + { + return Err(error( + "runtime_identity", + ValidationCode::InvalidRuntimeIdentity, + )); + } if self.argv.is_empty() || self.argv.len() > 128 || self diff --git a/judge/crates/sandbox/tests/managed_cpu_vm.rs b/judge/crates/sandbox/tests/managed_cpu_vm.rs new file mode 100644 index 0000000..77bc107 --- /dev/null +++ b/judge/crates/sandbox/tests/managed_cpu_vm.rs @@ -0,0 +1,111 @@ +#![cfg(all(target_os = "linux", feature = "vm-acceptance"))] +//! Run in a separate guest with AVX2 masked, using the ordinary Root binary. +#[allow(dead_code)] +mod support; +use kf_contracts::judging_v2::RuntimeIdentity; +use kf_sandbox::{ + image::{ImageCatalog, catalog_digest}, + profiles, + runtime::ImageKind, + spec::{ExecutionProfile, ExecutionSpec, InputArtifact, ResourceLimits}, +}; +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, process::Command}; + +#[test] +#[ignore = "independent capped VM, actual CPU lacks AVX2; no host runtime execution"] +fn insufficient_actual_cpu_refuses_before_image_execution() { + support::require_vm(); + assert!(!std::is_x86_feature_detected!("avx2")); + let dir = PathBuf::from(format!("/kf/cpu-refusal-{}", std::process::id())); + fs::create_dir(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + for name in ["inputs", "outputs"] { + fs::create_dir(dir.join(name)).unwrap(); + } + let mut root = support::Root::new(); + root.config.max_concurrent_runs = 1; + root.config.identities.count = 1; + root.config.image_kind = ImageKind::LinuxCPython313X86_64V1; + root.config.image_root = "/var/lib/kf-judge/images/python313".into(); + let data = fs::read(format!("{}/manifest.json", root.config.image_root)).unwrap(); + let catalog: ImageCatalog = serde_json::from_slice(&data).unwrap(); + assert_eq!(catalog.architecture, "x86_64_v3"); + let identity = RuntimeIdentity { + toolchain_id: catalog.toolchain_id.clone(), + image_kind: catalog.image_kind, + image_revision: catalog.image_revision, + image_digest: catalog_digest(&data), + runtime_version: catalog.runtime_version.clone(), + }; + catalog.validate(&identity).unwrap(); + root.config.runtime_identity = Some(identity.clone()); + root.config.clone().validate().unwrap(); + let limits = ResourceLimits::for_profile(ExecutionProfile::PythonRun); + let request = ExecutionSpec { + schema_version: kf_contracts::SchemaVersion, + runtime_identity: Some(identity), + run_id: "cpu-unsupported".into(), + profile: ExecutionProfile::PythonRun, + argv: profiles::argv(ExecutionProfile::PythonRun, &limits).unwrap(), + env: Default::default(), + cwd: ".".into(), + inputs: vec![InputArtifact { + artifact_id: "source".into(), + path: "main.py".into(), + }], + stdin_artifact: None, + outputs: vec![], + limits, + }; + request + .clone() + .validate(&root.validated().spec_policy()) + .unwrap(); + for (name, bytes) in [ + ("runtime.json", serde_json::to_vec(&root.config).unwrap()), + ("spec.json", serde_json::to_vec(&request).unwrap()), + ( + "inputs/source", + b"raise AssertionError('incompatible runtime executed')\n".to_vec(), + ), + ] { + fs::write(dir.join(name), bytes).unwrap(); + fs::set_permissions(dir.join(name), fs::Permissions::from_mode(0o600)).unwrap(); + } + let output = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap()) + .env_clear() + .args(["run", "--config"]) + .arg(dir.join("runtime.json")) + .arg("--spec") + .arg(dir.join("spec.json")) + .arg("--artifacts") + .arg(dir.join("inputs")) + .arg("--output") + .arg(dir.join("outputs")) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let refused: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(refused["error"], "runtime_not_ready"); + assert!(refused["cleanup_failure"].is_null()); + assert_eq!(fs::read_dir(dir.join("outputs")).unwrap().count(), 0); + for name in ["tasks", "identities", "captures"] { + let path = PathBuf::from("/var/lib/kf-judge").join(name); + if path.exists() { + assert_eq!(fs::read_dir(path).unwrap().count(), 0); + } + } + assert!( + !fs::read_dir(&root.path).unwrap().any(|entry| entry + .unwrap() + .file_type() + .unwrap() + .is_dir()) + ); + println!( + "KF_ACTUAL_CPU_REFUSAL_PROOF {}", + serde_json::json!({"actual_avx2":false,"image_architecture":catalog.architecture,"identity":request.runtime_identity,"refusal":refused,"publication_empty":true,"owned_resources_zero":true}) + ); + fs::remove_dir_all(dir).unwrap(); +} diff --git a/judge/crates/sandbox/tests/managed_profiles.rs b/judge/crates/sandbox/tests/managed_profiles.rs index 102b9ed..6b62514 100644 --- a/judge/crates/sandbox/tests/managed_profiles.rs +++ b/judge/crates/sandbox/tests/managed_profiles.rs @@ -6,10 +6,39 @@ use kf_sandbox::{ spec::*, }; +fn identity(profile: ExecutionProfile) -> RuntimeIdentity { + let python = matches!( + profile, + ExecutionProfile::PythonCheck | ExecutionProfile::PythonRun + ); + RuntimeIdentity { + toolchain_id: (if python { + "cpython313-v1" + } else { + "openjdk21-v1" + }) + .to_owned() + .try_into() + .unwrap(), + image_kind: if python { + ContractKind::CPython313 + } else { + ContractKind::OpenJdk21 + }, + image_revision: 1, + image_digest: "a".repeat(64).try_into().unwrap(), + runtime_version: (if python { "3.13.16" } else { "21.0.12.1+1-LTS" }) + .to_owned() + .try_into() + .unwrap(), + } +} + fn request(profile: ExecutionProfile) -> ExecutionSpec { let limits = ResourceLimits::for_profile(profile); ExecutionSpec { schema_version: kf_contracts::SchemaVersion, + runtime_identity: Some(identity(profile)), run_id: "managed-policy".into(), profile, argv: profiles::argv(profile, &limits).unwrap(), @@ -46,6 +75,68 @@ fn request(profile: ExecutionProfile) -> ExecutionSpec { } } +#[test] +fn managed_requests_bind_the_exact_operator_identity() { + let legacy: RuntimeConfig = serde_json::from_str(include_str!( + "../../../../deploy/sandbox.runtime.example.json" + )) + .unwrap(); + for (profile, kind) in [ + ( + ExecutionProfile::PythonRun, + ImageKind::LinuxCPython313X86_64V1, + ), + (ExecutionProfile::JavaRun, ImageKind::LinuxOpenJdk21X86_64V1), + ] { + let mut config = legacy.clone(); + config.image_kind = kind; + config.runtime_identity = Some(identity(profile)); + let policy = config.validate().unwrap().spec_policy(); + let original = request(profile); + assert!(original.clone().validate(&policy).is_ok()); + let mut missing = original.clone(); + missing.runtime_identity = None; + assert_eq!( + missing.validate(&policy).unwrap_err().code, + ValidationCode::InvalidRuntimeIdentity + ); + for field in ["digest", "revision", "version", "kind", "toolchain"] { + let mut wrong = original.clone(); + let id = wrong.runtime_identity.as_mut().unwrap(); + match field { + "digest" => id.image_digest = "b".repeat(64).try_into().unwrap(), + "revision" => id.image_revision += 1, + "version" => id.runtime_version = "21.0.99+1-LTS".to_owned().try_into().unwrap(), + "kind" => id.image_kind = ContractKind::GnuCxx, + "toolchain" => id.toolchain_id = "gnu-cxx-v1".to_owned().try_into().unwrap(), + _ => unreachable!(), + } + assert_eq!( + wrong.validate(&policy).unwrap_err().code, + ValidationCode::InvalidRuntimeIdentity, + "{field}" + ); + } + } + let request: ExecutionSpec = + serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap(); + assert!(request.runtime_identity.is_none()); + assert!( + !serde_json::to_string(&request) + .unwrap() + .contains("runtime_identity") + ); + assert!( + request + .clone() + .validate(&legacy.validate().unwrap().spec_policy()) + .is_ok() + ); + let mut wrong = request; + wrong.runtime_identity = Some(identity(ExecutionProfile::PythonRun)); + assert!(wrong.validate(&SpecPolicy::default()).is_err()); +} + #[test] fn commands_environment_and_source_or_class_sets_are_closed() { for profile in [ diff --git a/judge/crates/sandbox/tests/managed_runtime_vm.rs b/judge/crates/sandbox/tests/managed_runtime_vm.rs index aaf73ce..55c0a25 100644 --- a/judge/crates/sandbox/tests/managed_runtime_vm.rs +++ b/judge/crates/sandbox/tests/managed_runtime_vm.rs @@ -105,6 +105,7 @@ impl Fixture { limits.wall_time_us = 60_000_000; ExecutionSpec { schema_version: kf_contracts::SchemaVersion, + runtime_identity: self.root.config.runtime_identity.clone(), run_id: id.into(), profile, argv: profiles::argv(profile, &limits).unwrap(), @@ -261,7 +262,124 @@ impl Fixture { fn stdout(&self, id: &str) -> Vec { fs::read(self.dir.join("outputs").join(id).join("stdout")).unwrap() } + fn java_program(&self, source: &[u8], id: &str) -> ExecutionSpec { + self.input("source", source); + let compile = self.request(id, ExecutionProfile::JavaCompile); + let result = self.execute(&compile); + assert_eq!( + result["outcome"], + serde_json::json!({"kind":"exited","code":0}) + ); + let mut run = self.request("java-bound-run", ExecutionProfile::JavaRun); + run.inputs.clear(); + for (index, artifact) in result["report"]["artifacts"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let id = format!("class-{index}"); + self.input( + &id, + &fs::read( + self.dir + .join("outputs") + .join(&compile.run_id) + .join(format!("artifact-{index}")), + ) + .unwrap(), + ); + run.inputs.push(InputArtifact { + artifact_id: id, + path: artifact["path"].as_str().unwrap().into(), + }); + } + run + } + fn cancel_through_ordinary_peer(&self, spec: &ExecutionSpec) { + // The independent fixture controller is a bounded ordinary UID client. + // Only the default Root helper performs privileged task operations. + fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o711)).unwrap(); + let inputs = self.dir.join("inputs"); + std::os::unix::fs::chown(&inputs, Some(1000), Some(1000)).unwrap(); + fs::set_permissions(&inputs, fs::Permissions::from_mode(0o700)).unwrap(); + for entry in fs::read_dir(&inputs).unwrap() { + let path = entry.unwrap().path(); + std::os::unix::fs::chown(&path, Some(1000), Some(1000)).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let wire = self.dir.join("wire.json"); + fs::write( + &wire, + serde_json::to_vec(&serde_json::json!({"spec":spec})).unwrap(), + ) + .unwrap(); + std::os::unix::fs::chown(&wire, Some(1000), Some(1000)).unwrap(); + fs::set_permissions(&wire, fs::Permissions::from_mode(0o600)).unwrap(); + let socket = self.dir.join("socket"); + let mut helper = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap()) + .env_clear() + .args(["serve-one", "--config"]) + .arg(&self.config) + .arg("--socket") + .arg(&socket) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while !fs::metadata(&socket).is_ok_and(|m| m.mode() & 0o777 == 0o660) { + assert!( + helper.try_wait().unwrap().is_none(), + "helper failed before peer publication" + ); + assert!( + std::time::Instant::now() < deadline, + "bounded helper publication" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let client = Command::new("/kf/bin/managed-disconnect") + .env_clear() + .arg(&socket) + .arg(&inputs) + .arg(&wire) + .output() + .unwrap(); + assert!(client.status.success(), "ordinary controller failed"); + let output = helper.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["report"]["cleanup"], "complete"); + assert_eq!(result["report"]["errors"], serde_json::json!([])); + assert_eq!(result["report"]["limits"], serde_json::json!([])); + assert!( + result["report"]["cancelled_at_wall_time_us"] + .as_u64() + .unwrap() + > 0 + ); + // Marker comes from actual user main after runtime bootstrap, so a + // cancelled preparation alone cannot pass this execution test. + assert!( + result["report"]["stdout"]["retained_bytes"] + .as_u64() + .unwrap() + >= 5 + ); + assert!(!socket.exists()); + self.empty(); + println!( + "KF_MANAGED_PEER_CANCEL_PROOF {}", + serde_json::json!({"ordinary_uid":1000,"profile":spec.profile,"identity":spec.runtime_identity,"spec":spec,"result":result,"owned_resources_zero":true}) + ); + } fn image_refusals(&mut self, spec: &ExecutionSpec, binary: &str) { + self.identity_refusals(spec); let image = PathBuf::from(&self.root.config.image_root); let executable = image.join(binary); let backup = self.dir.join("trusted-runtime-backup"); @@ -307,6 +425,19 @@ impl Fixture { self.root.config.image_root ); } + fn identity_refusals(&self, spec: &ExecutionSpec) { + let mut wrong = spec.clone(); + wrong.runtime_identity = None; + self.reject(&wrong, "invalid_execution_spec"); + let mut wrong = spec.clone(); + wrong.runtime_identity.as_mut().unwrap().image_digest = "d".repeat(64).try_into().unwrap(); + self.reject(&wrong, "invalid_execution_spec"); + let mut wrong = spec.clone(); + wrong.runtime_identity.as_mut().unwrap().runtime_version = + "1.0.0".to_owned().try_into().unwrap(); + self.reject(&wrong, "invalid_execution_spec"); + println!("KF_MANAGED_REQUEST_IDENTITY_STATUS=0"); + } fn empty(&self) { for name in ["tasks", "captures", "identities"] { let p = PathBuf::from("/var/lib/kf-judge").join(name); @@ -413,3 +544,163 @@ fn java_profile_compiles_class_set_and_runs_same_set() { f.reject(&wrong, "invalid_execution_spec"); f.image_refusals(&wrong, "usr/lib/jvm/bin/java"); } + +#[test] +#[ignore = "malicious and overlimit code only in an independent no-NIC/no-share capped VM"] +fn python_isolation_and_kernel_limits_have_actual_evidence() { + let f = Fixture::new("python313", ImageKind::LinuxCPython313X86_64V1); + f.input("source", br#"import ctypes, errno, os, socket +libc = ctypes.CDLL(None, use_errno=True) +assert os.geteuid() != 0 +for number, args in [(41,(2,1,0)),(101,(0,0,0,0)),(165,(0,0,0,0,0)),(308,(-1,0)),(272,(0x10000000,)),(321,(0,0,0)),(126,(0,0)),(62,(os.getpid(),0))]: + ctypes.set_errno(0) + assert libc.syscall(number,*args) == -1, number + assert ctypes.get_errno() == errno.EPERM, (number,ctypes.get_errno()) +try: + socket.socket() + raise AssertionError('network allowed') +except PermissionError: + pass +for path in ['/kf/INDEPENDENT_VM','/var/lib/kf-judge','/root/.ssh','/home/kronecker','/proc/1/root/kf/INDEPENDENT_VM']: + assert not os.path.exists(path), path +try: + open('/usr/lib/python3.13/os.py','wb') + raise AssertionError('image writable') +except OSError: + pass +print('python-isolation-ok') +"#); + let result = f.execute(&f.request("py-isolation", ExecutionProfile::PythonRun)); + assert_eq!( + result["outcome"], + serde_json::json!({"kind":"exited","code":0}) + ); + assert_eq!(f.stdout("py-isolation"), b"python-isolation-ok\n"); + for (id, source, primary) in [ + ("py-cpu", b"print('cpu-ready',flush=True)\nwhile True: pass\n".as_slice(), "cpu_time"), + ("py-memory", b"print('memory-ready',flush=True)\na=[]\nwhile True: a.append(bytearray(8*1024*1024))\n".as_slice(), "memory"), + ("py-output", b"import os\nwhile True: os.write(1,b'x'*65536)\n".as_slice(), "stdout"), + ] { + f.input("source", source); + let mut spec = f.request(id, ExecutionProfile::PythonRun); + if primary == "cpu_time" { + spec.limits.cpu_time_us = 2_000_000; + spec.limits.wall_time_us = 10_000_000; + } + spec.limits.stdout_bytes = 4096; + let result = f.execute(&spec); + assert_eq!(result["outcome"], serde_json::json!({"kind":"limit_exceeded","primary":primary})); + assert!(result["report"]["usage"]["cpu_time_us"]["value"].as_u64().is_some()); + assert!(result["report"]["usage"]["memory_peak_bytes"]["value"].as_u64().is_some()); + if primary == "memory" { + assert!(result["report"]["usage"]["memory_events"]["value"]["oom_kill"].as_u64().unwrap() > 0); + } + } + f.input( + "source", + b"raise MemoryError('OutOfMemoryError: user text')\n", + ); + let result = f.execute(&f.request("py-user-oom", ExecutionProfile::PythonRun)); + assert_eq!(result["outcome"]["code"], 1); + assert_eq!(result["report"]["limits"], serde_json::json!([])); + assert_eq!( + result["report"]["usage"]["memory_events"]["value"]["oom_kill"], + 0 + ); +} + +#[test] +#[ignore = "JVM security, threads and real cgroup OOM only in an independent capped VM"] +fn java_isolation_and_whole_jvm_limits_have_actual_evidence() { + let f = Fixture::new("java21", ImageKind::LinuxOpenJdk21X86_64V1); + let program = f.java_program(br#"import java.io.*; import java.net.*; import java.nio.file.*; import java.lang.reflect.*; import sun.misc.Unsafe; +public class Main { + public static void main(String[] args) throws Exception { + String mode = new BufferedReader(new InputStreamReader(System.in)).readLine(); + if (mode.equals("normal")) { + if (Runtime.getRuntime().availableProcessors()!=1) throw new AssertionError("cpu ergonomics"); + if (Runtime.getRuntime().maxMemory()>224L*1024*1024) throw new AssertionError("heap ergonomics"); + try { new ServerSocket(0); throw new AssertionError("network allowed"); } catch(IOException | ExceptionInInitializerError expected) { Throwable cause=expected; while(cause.getCause()!=null) cause=cause.getCause(); if(!(cause instanceof IOException) || !cause.getMessage().contains("Operation not permitted")) throw expected; } + for(String p:new String[]{"/kf/INDEPENDENT_VM","/var/lib/kf-judge","/root/.ssh","/home/kronecker"}) if(Files.exists(Path.of(p))) throw new AssertionError("host visible"); + try { Files.write(Path.of("/usr/lib/jvm/release"),new byte[]{1}); throw new AssertionError("image writable"); } catch(IOException expected) {} + System.out.println("java-isolation-ok"); + } else if(mode.equals("fake-oom")) { throw new OutOfMemoryError("user controlled"); + } else if(mode.equals("cpu")) { System.out.println("cpu-ready"); long x=0; while(true) x=Math.multiplyExact(x,1)+1; + } else if(mode.equals("memory")) { + System.out.println("memory-ready"); Field field=Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); Unsafe unsafe=(Unsafe)field.get(null); + while(true) { long p=unsafe.allocateMemory(16L*1024*1024); unsafe.setMemory(p,16L*1024*1024,(byte)1); } + } else if(mode.equals("output")) { byte[] bytes=new byte[65536]; while(true) System.out.write(bytes); + } else if(mode.equals("threads")) { while(true) new Thread(()->{try{Thread.sleep(60000);}catch(InterruptedException e){}}).start(); + } else { throw new AssertionError("mode"); } + } +}"#, "java-security-compile"); + for (id, mode, primary) in [ + ("java-isolation", "normal", None), + ("java-user-oom", "fake-oom", None), + ("java-cpu", "cpu", Some("cpu_time")), + ("java-memory", "memory", Some("memory")), + ("java-output", "output", Some("stdout")), + ("java-threads", "threads", Some("processes")), + ] { + f.input("stdin", format!("{mode}\n").as_bytes()); + let mut spec = program.clone(); + spec.run_id = id.into(); + spec.stdin_artifact = Some("stdin".into()); + if mode == "cpu" { + spec.limits.cpu_time_us = 5_000_000; + spec.limits.wall_time_us = 15_000_000; + } + spec.limits.stdout_bytes = 4096; + if mode == "threads" { + spec.limits.processes = 16; + } + spec.argv = profiles::argv(spec.profile, &spec.limits).unwrap(); + let result = f.execute(&spec); + if let Some(primary) = primary { + assert_eq!( + result["outcome"], + serde_json::json!({"kind":"limit_exceeded","primary":primary}) + ); + } else { + assert_eq!( + result["outcome"]["code"], + if mode == "normal" { 0 } else { 1 } + ); + assert_eq!(result["report"]["limits"], serde_json::json!([])); + assert_eq!( + result["report"]["usage"]["memory_events"]["value"]["oom_kill"], + 0 + ); + } + if mode == "normal" { + assert_eq!(f.stdout(id), b"java-isolation-ok\n"); + } + if mode == "memory" { + assert!( + result["report"]["usage"]["memory_events"]["value"]["oom_kill"] + .as_u64() + .unwrap() + > 0 + ); + } + } +} + +#[test] +#[ignore = "live Python fork tree cancellation only in independent no-NIC/no-share capped VM"] +fn python_live_fork_tree_peer_cancellation_is_drained() { + let f = Fixture::new("python313", ImageKind::LinuxCPython313X86_64V1); + f.input( + "source", + b"import os,time\nprint('py-live',flush=True)\nos.fork()\nwhile True: time.sleep(10)\n", + ); + f.cancel_through_ordinary_peer(&f.request("py-peer-cancel", ExecutionProfile::PythonRun)); +} + +#[test] +#[ignore = "live Java thread tree cancellation only in independent no-NIC/no-share capped VM"] +fn java_live_threads_peer_cancellation_is_drained() { + let f = Fixture::new("java21", ImageKind::LinuxOpenJdk21X86_64V1); + let run = f.java_program(br#"public class Main { public static void main(String[] args) throws Exception { for(int i=0;i<3;i++)new Thread(()->{while(true)try{Thread.sleep(10000);}catch(InterruptedException e){}}).start(); System.out.println("java-live"); while(true)Thread.sleep(10000); } }"#, "java-cancel-compile"); + f.cancel_through_ordinary_peer(&run); +} diff --git a/judge/crates/worker/src/lib.rs b/judge/crates/worker/src/lib.rs index 71603c9..340a9a8 100644 --- a/judge/crates/worker/src/lib.rs +++ b/judge/crates/worker/src/lib.rs @@ -27,6 +27,7 @@ pub mod native { fn execute(&mut self, request: &CaseRequest) -> Result { let spec = ExecutionSpec { schema_version: kf_contracts::SchemaVersion, + runtime_identity: None, run_id: request.run_id.clone(), profile: ExecutionProfile::Native, argv: vec!["/work/main".into()], diff --git a/judge/crates/worker/src/pipeline.rs b/judge/crates/worker/src/pipeline.rs index 4b7b718..4356b06 100644 --- a/judge/crates/worker/src/pipeline.rs +++ b/judge/crates/worker/src/pipeline.rs @@ -140,6 +140,7 @@ pub fn compiler_spec( }; ExecutionSpec { schema_version: kf_contracts::SchemaVersion, + runtime_identity: None, run_id, profile, argv: vec![ @@ -394,6 +395,7 @@ impl RunPort for Attempt<'_, A> { drop(input); let spec = ExecutionSpec { schema_version: kf_contracts::SchemaVersion, + runtime_identity: None, run_id: run_id.into(), profile: ExecutionProfile::Native, argv: vec!["/work/main".into()], diff --git a/scripts/build_judging_images.py b/scripts/build_judging_images.py index 38fb6b9..766fd32 100644 --- a/scripts/build_judging_images.py +++ b/scripts/build_judging_images.py @@ -267,7 +267,9 @@ def finish(args, pin, root, records): raise ValueError("image exceeds catalog capacity") catalog = { "format_version": 2, - "architecture": "x86_64", + # The signed support-library closure requires the x86-64-v3 ISA. + # Publish that minimum explicitly; Root checks it before executing ELF. + "architecture": "x86_64_v3", "image_kind": pin["kind"], "image_revision": 1, "toolchain_id": pin["toolchain_id"],