Skip to content

Commit 9caf6de

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(oci): block host-file deletion via malicious-image whiteout symlink escape (#140)
A hostile OCI image could DELETE arbitrary files/directories on the HOST when pulled. Layer extraction's whiteout handling is hand-rolled and only guarded the FINAL path component with symlink_metadata — it never checked the PARENT. A layer can legally extract an absolute symlink (`esc -> /etc`; absolute symlink targets are normal in images and the `..`-component check does not inspect symlink targets), then a whiteout whose parent is that symlink: - `esc/.wh.passwd` -> remove_path(target/esc/passwd) follows esc -> deletes /etc/passwd on the HOST - `esc/.wh..wh..opq` -> read_dir(target/esc) follows esc -> wipes ALL of /etc on the HOST (mass deletion) This happens host-side during `pull`/`run`, BEFORE the microVM boots, so VM isolation gives no protection — any user pulling an untrusted image is exposed. The WRITE path is NOT affected: tar's `unpack_in` already refuses to write through a symlinked parent (verified by test); only the hand-rolled whiteout DELETE paths were vulnerable. Fix: resolve the whiteout parent WITHIN the rootfs via `resolve_within` (canonicalize + `starts_with(target_dir)`) before deleting; skip + warn if it escapes. Intra-rootfs symlinks stay allowed (the image may mutate its own files) — only escapes past the extraction target are blocked. Tests (TDD, encode the SECURE expectation — a failure is a real escape): whiteout_does_not_delete_through_symlinked_parent, opaque_whiteout_does_not_wipe_through_symlinked_parent, layer_entry_cannot_write_through_symlinked_parent (already safe via unpack_in). All 12 oci::layers tests pass (3 new + existing whiteout/opaque regression). Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent 35c09eb commit 9caf6de

1 file changed

Lines changed: 134 additions & 6 deletions

File tree

src/runtime/src/oci/layers.rs

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use a3s_box_core::error::{BoxError, Result};
66
use flate2::read::GzDecoder;
77
use std::fs::File;
88
use std::io::{Read, Seek, SeekFrom};
9-
use std::path::Path;
9+
use std::path::{Path, PathBuf};
1010
use tar::Archive;
1111

1212
/// Extract a single OCI layer (tar.gz) to target directory.
@@ -132,20 +132,33 @@ pub fn extract_layer(layer_path: &Path, target_dir: &Path) -> Result<()> {
132132
if file_name == ".wh..wh..opq" {
133133
// Opaque directory marker: discard everything already extracted into
134134
// the parent directory from lower layers, keeping the directory.
135+
// Resolve the parent WITHIN the rootfs first: a malicious layer can
136+
// extract an absolute symlink as the parent, and following it here
137+
// would wipe a host directory OUTSIDE the extraction target.
135138
if let Some(parent) = path.parent() {
136-
if let Ok(read) = std::fs::read_dir(target_dir.join(parent)) {
137-
for child in read.flatten() {
138-
remove_path(&child.path());
139+
if let Some(dir) = resolve_within(target_dir, parent) {
140+
if let Ok(read) = std::fs::read_dir(&dir) {
141+
for child in read.flatten() {
142+
remove_path(&child.path());
143+
}
139144
}
145+
} else {
146+
tracing::warn!(parent = %parent.display(), "Skipping opaque whiteout: parent escapes the rootfs");
140147
}
141148
}
142149
continue;
143150
}
144151

145152
if let Some(victim_name) = file_name.strip_prefix(".wh.") {
146-
// Whiteout marker: remove the named sibling from a lower layer.
153+
// Whiteout marker: remove the named sibling from a lower layer. Resolve
154+
// the parent within the rootfs so a symlinked parent cannot redirect the
155+
// deletion to a host file outside the extraction target.
147156
if let Some(parent) = path.parent() {
148-
remove_path(&target_dir.join(parent).join(victim_name));
157+
if let Some(dir) = resolve_within(target_dir, parent) {
158+
remove_path(&dir.join(victim_name));
159+
} else {
160+
tracing::warn!(parent = %parent.display(), "Skipping whiteout: parent escapes the rootfs");
161+
}
149162
}
150163
continue;
151164
}
@@ -168,6 +181,22 @@ pub fn extract_layer(layer_path: &Path, target_dir: &Path) -> Result<()> {
168181
Ok(())
169182
}
170183

184+
/// Resolve `rel` beneath `target_dir`, following symlinks, returning the real
185+
/// path ONLY if it stays inside `target_dir`.
186+
///
187+
/// A malicious layer can extract an absolute symlink (e.g. `esc -> /etc`) and
188+
/// then a whiteout whose parent is that symlink; without this guard the
189+
/// hand-rolled whiteout deletion would follow it and remove host files OUTSIDE
190+
/// the extraction target. Returns `None` when the parent does not exist or
191+
/// resolves outside the rootfs (caller skips + warns). Intra-rootfs symlinks
192+
/// are allowed — the image may already mutate its own files; only escapes past
193+
/// `target_dir` are blocked.
194+
fn resolve_within(target_dir: &Path, rel: &Path) -> Option<PathBuf> {
195+
let base = target_dir.canonicalize().ok()?;
196+
let resolved = base.join(rel).canonicalize().ok()?;
197+
resolved.starts_with(&base).then_some(resolved)
198+
}
199+
171200
/// Remove a file or directory tree for an applied whiteout, ignoring a missing
172201
/// target. Uses `symlink_metadata` so a symlink is removed as a link, not
173202
/// followed into a lower layer.
@@ -385,6 +414,105 @@ mod tests {
385414
builder.finish().unwrap();
386415
}
387416

417+
/// Build a gzipped layer that first creates a SYMLINK entry, then writes the
418+
/// given follow-on entries — used to probe symlink-directed escapes (a later
419+
/// entry / whiteout that resolves THROUGH the symlinked parent).
420+
fn create_layer_with_symlink(path: &Path, link: &str, target: &Path, then: &[(&str, &[u8])]) {
421+
use flate2::write::GzEncoder;
422+
use flate2::Compression;
423+
use tar::Builder;
424+
425+
let file = File::create(path).unwrap();
426+
let mut builder = Builder::new(GzEncoder::new(file, Compression::default()));
427+
428+
let mut sh = tar::Header::new_gnu();
429+
sh.set_entry_type(tar::EntryType::Symlink);
430+
sh.set_size(0);
431+
sh.set_mode(0o777);
432+
sh.set_uid(0);
433+
sh.set_gid(0);
434+
builder.append_link(&mut sh, link, target).unwrap();
435+
436+
for (name, content) in then {
437+
let mut h = tar::Header::new_gnu();
438+
h.set_size(content.len() as u64);
439+
h.set_mode(0o644);
440+
h.set_uid(0);
441+
h.set_gid(0);
442+
h.set_cksum();
443+
builder.append_data(&mut h, name, *content).unwrap();
444+
}
445+
builder.finish().unwrap();
446+
}
447+
448+
// ---- Malicious-image extraction hardening (host-side, occurs during pull) ----
449+
// A hostile layer must never reach outside the extraction target. These encode
450+
// the SECURE expectation: a failure here is a real escape, not a flaky test.
451+
452+
#[test]
453+
fn whiteout_does_not_delete_through_symlinked_parent() {
454+
let tmp = TempDir::new().unwrap();
455+
let target = tmp.path().join("rootfs");
456+
fs::create_dir_all(&target).unwrap();
457+
// A host file OUTSIDE the target that a malicious image must not delete.
458+
let outside = tmp.path().join("outside");
459+
fs::create_dir_all(&outside).unwrap();
460+
let victim = outside.join("victim");
461+
fs::write(&victim, b"keep me").unwrap();
462+
463+
// esc -> <outside> (absolute symlink target, legal in images), then a
464+
// whiteout `.wh.victim` whose parent is the symlink.
465+
let layer = tmp.path().join("evil.tar.gz");
466+
create_layer_with_symlink(&layer, "esc", &outside, &[("esc/.wh.victim", b"")]);
467+
let _ = extract_layer(&layer, &target);
468+
469+
assert!(
470+
victim.exists(),
471+
"SECURITY: whiteout followed a symlinked parent and deleted a host file outside the target ({})",
472+
victim.display()
473+
);
474+
}
475+
476+
#[test]
477+
fn opaque_whiteout_does_not_wipe_through_symlinked_parent() {
478+
let tmp = TempDir::new().unwrap();
479+
let target = tmp.path().join("rootfs");
480+
fs::create_dir_all(&target).unwrap();
481+
let outside = tmp.path().join("outside");
482+
fs::create_dir_all(&outside).unwrap();
483+
let a = outside.join("a");
484+
let b = outside.join("b");
485+
fs::write(&a, b"a").unwrap();
486+
fs::write(&b, b"b").unwrap();
487+
488+
let layer = tmp.path().join("evil.tar.gz");
489+
create_layer_with_symlink(&layer, "esc", &outside, &[("esc/.wh..wh..opq", b"")]);
490+
let _ = extract_layer(&layer, &target);
491+
492+
assert!(
493+
a.exists() && b.exists(),
494+
"SECURITY: opaque whiteout wiped a host directory through a symlinked parent"
495+
);
496+
}
497+
498+
#[test]
499+
fn layer_entry_cannot_write_through_symlinked_parent() {
500+
let tmp = TempDir::new().unwrap();
501+
let target = tmp.path().join("rootfs");
502+
fs::create_dir_all(&target).unwrap();
503+
let outside = tmp.path().join("outside");
504+
fs::create_dir_all(&outside).unwrap();
505+
506+
let layer = tmp.path().join("evil.tar.gz");
507+
create_layer_with_symlink(&layer, "esc", &outside, &[("esc/pwned", b"owned")]);
508+
let _ = extract_layer(&layer, &target);
509+
510+
assert!(
511+
!outside.join("pwned").exists(),
512+
"SECURITY: a layer wrote through a symlinked parent to outside the target"
513+
);
514+
}
515+
388516
#[test]
389517
fn test_extract_layer_handles_zstd() {
390518
let temp_dir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)