Skip to content

Commit 28b8fac

Browse files
author
Roy Lin
committed
fix(security): confine CRI localhostProfile seccomp path to the seccomp root
LOW. `parse_localhost_seccomp_deny` did `std::fs::read_to_string(localhost_ref)` with no path confinement. `localhost_ref` is the pod's `securityContext.seccompProfile.localhostProfile` — attacker-settable through the CRI by anyone who can create pods — so it was an arbitrary host-file OPEN primitive (a path-traversal / file-existence oracle). Information disclosure is limited (the parse error is only warn-logged, never returned to the gRPC caller, and content only escapes as syscall names from valid OCI-seccomp JSON into the attacker's own container), hence LOW — but the missing guard is real. Fix: `confined_seccomp_path` mirrors kubelet/containerd semantics — resolve the ref under the configured seccomp root (default /var/lib/kubelet/seccomp, override A3S_BOX_SECCOMP_PROFILE_ROOT), reject any `..` component, and require an absolute ref to lie within the root. An out-of-root or traversing ref is rejected, and the caller falls back to RuntimeDefault (never unconfined). Test: confines_localhost_profile_to_root_and_rejects_traversal — accepts in-root relative/absolute refs; rejects `../../etc/passwd`, `sub/../../escape`, `/etc/passwd`, `/etc/shadow`, and `/var/lib/kubelet/seccomp-evil/x` (prefix-confusion). Neuter-verified on the KVM server (disabling the root check makes it FAIL on /etc/passwd); fmt + clippy clean. Completes the untrusted-input security audit (4/4): after #141 (CRITICAL digest traversal), #142 (HIGH/MED bombs), #140 (HIGH whiteout deletion).
1 parent 4437d06 commit 28b8fac

1 file changed

Lines changed: 70 additions & 2 deletions

File tree

  • src/cri/src/runtime_service

src/cri/src/runtime_service/mod.rs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,43 @@ struct OciSeccompSyscall {
210210
action: String,
211211
}
212212

213+
/// Confine a CRI `localhostProfile` path to the node's seccomp profile root and
214+
/// reject `..` traversal.
215+
///
216+
/// `localhost_ref` comes from a pod's
217+
/// `securityContext.seccompProfile.localhostProfile` (attacker-settable through
218+
/// the CRI) and is read off the host disk — without confinement it is an
219+
/// arbitrary host-file open primitive. Mirrors kubelet/containerd semantics:
220+
/// profiles live under the configured seccomp root (default
221+
/// `/var/lib/kubelet/seccomp`, override with `A3S_BOX_SECCOMP_PROFILE_ROOT`). A
222+
/// relative ref resolves under the root; an absolute ref must lie within it.
223+
fn confined_seccomp_path(localhost_ref: &str) -> Result<PathBuf, String> {
224+
let root = std::env::var("A3S_BOX_SECCOMP_PROFILE_ROOT")
225+
.unwrap_or_else(|_| "/var/lib/kubelet/seccomp".to_string());
226+
let root = std::path::Path::new(&root);
227+
let r = std::path::Path::new(localhost_ref);
228+
if r.components()
229+
.any(|c| matches!(c, std::path::Component::ParentDir))
230+
{
231+
return Err(format!(
232+
"seccomp profile path contains '..': {localhost_ref}"
233+
));
234+
}
235+
let candidate = if r.is_absolute() {
236+
r.to_path_buf()
237+
} else {
238+
root.join(r)
239+
};
240+
if !candidate.starts_with(root) {
241+
return Err(format!(
242+
"seccomp profile {} is outside the seccomp root {}",
243+
candidate.display(),
244+
root.display()
245+
));
246+
}
247+
Ok(candidate)
248+
}
249+
213250
/// Parse a CRI localhost seccomp profile file and return the syscall names it
214251
/// blocks with `SCMP_ACT_ERRNO`/`SCMP_ACT_KILL*`.
215252
///
@@ -219,8 +256,9 @@ struct OciSeccompSyscall {
219256
/// supported; returns an error so the caller can fall back rather than silently
220257
/// run unconfined.
221258
fn parse_localhost_seccomp_deny(localhost_ref: &str) -> Result<Vec<String>, String> {
222-
let raw = std::fs::read_to_string(localhost_ref)
223-
.map_err(|e| format!("read seccomp profile {localhost_ref}: {e}"))?;
259+
let path = confined_seccomp_path(localhost_ref)?;
260+
let raw = std::fs::read_to_string(&path)
261+
.map_err(|e| format!("read seccomp profile {}: {e}", path.display()))?;
224262
let profile: OciSeccompProfile =
225263
serde_json::from_str(&raw).map_err(|e| format!("parse seccomp profile: {e}"))?;
226264
if !matches!(
@@ -249,6 +287,36 @@ fn parse_localhost_seccomp_deny(localhost_ref: &str) -> Result<Vec<String>, Stri
249287
Ok(deny)
250288
}
251289

290+
#[cfg(test)]
291+
mod seccomp_confine_tests {
292+
use super::confined_seccomp_path;
293+
294+
#[test]
295+
fn confines_localhost_profile_to_root_and_rejects_traversal() {
296+
// Default root is /var/lib/kubelet/seccomp (no env set). The check is
297+
// lexical, so files need not exist.
298+
assert!(confined_seccomp_path("audit.json").is_ok());
299+
assert!(confined_seccomp_path("profiles/audit.json").is_ok());
300+
assert!(confined_seccomp_path("/var/lib/kubelet/seccomp/audit.json").is_ok());
301+
302+
// SECURITY: a malicious localhostProfile must not open arbitrary host
303+
// files — traversal, out-of-root absolute paths, and prefix-confusion
304+
// are all rejected (caller then falls back to RuntimeDefault).
305+
for evil in [
306+
"../../../../etc/passwd",
307+
"sub/../../escape",
308+
"/etc/passwd",
309+
"/etc/shadow",
310+
"/var/lib/kubelet/seccomp-evil/x",
311+
] {
312+
assert!(
313+
confined_seccomp_path(evil).is_err(),
314+
"must reject malicious seccomp path: {evil}"
315+
);
316+
}
317+
}
318+
}
319+
252320
#[derive(Debug, Clone)]
253321
pub struct CriRuntimeOptions {
254322
pub default_agent_image: String,

0 commit comments

Comments
 (0)