From 7d5b0bba43705a814dc3d891a3ce0fe7a648958e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 6 Oct 2026 16:17:26 -0700 Subject: [PATCH] Test that denied paths stay hidden through their aliases A new guest test maps a read-write directory that holds a denied directory and a denied file. It adds host links, a junction on Windows, and host hard links, and has the workload try `..`, those aliases, links of its own, and renames, removals, and creations at the denied names. The guest resolves every link itself, so each lookup of a denied path, or of another name for a denied object, fails, and absolute Windows links and junctions cannot be followed. OpenVMM knows only the denied objects themselves: a host hard link to a file inside a denied directory stays readable, and a directory that holds a host hard link to a denied file cannot always be listed. The README says so, and no longer claims that a denied path is inaccessible through every name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- aci_edge_sandboxes/README.md | 16 +- aci_edge_sandboxes/tests/nvxhost_guest.rs | 201 ++++++++++++++++++++++ 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/aci_edge_sandboxes/README.md b/aci_edge_sandboxes/README.md index bb4d6e3b..91132e24 100644 --- a/aci_edge_sandboxes/README.md +++ b/aci_edge_sandboxes/README.md @@ -255,6 +255,15 @@ must be deprovisioned and provisioned again to accept the change. into the RAM overlay at its guest path, read-only where requested. The bind mounts travel on the guest's 1024-byte kernel command line, which leaves room for roughly a dozen typical paths; provision rejects a policy whose mounts do not fit with `policy_validation`. +- Denied paths stay hidden through their aliases: `..`, host symbolic links and junctions, + links that workloads create, and other names of a denied object, such as a host hard link to + a denied file. OpenVMM never follows a link on the host, so the guest resolves every link + itself, and each lookup of a denied path fails with `EACCES`; an absolute Windows symbolic + link or a junction cannot be followed at all (`EPERM`). OpenVMM recognizes only the denied + objects themselves: a host hard link that already joins a file inside a denied directory to a + name outside it stays readable through that name. A directory that holds a host hard link to + a denied file may list the link's name, and a listing that also looks its entries up, as the + guest's first read of a directory does, fails with `EACCES`. - Workloads run as the guest's root with only the default container capabilities (`CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID`, `KILL`, `SETGID`, `SETUID`, `NET_BIND_SERVICE`, and `AUDIT_WRITE`), with `no_new_privs`, and without user namespaces, so they cannot remount or @@ -548,9 +557,10 @@ translation, for example to turn a host working directory into a Exporting a whole volume (`/` or `C:\`) is refused, so mapped paths must share a directory below it. - Denied paths inside the export are hidden by OpenVMM itself: they are absent - from listings and inaccessible through any name. OpenVMM requires hidden - paths below the export to contain no whitespace, colons, or backslashes and - no links, and accepts at most 128 of them. + from listings and inaccessible through any name, except a host hard link to a + file inside a denied directory, which OpenVMM cannot tell from any other file. + OpenVMM requires hidden paths below the export to contain no whitespace, + colons, or backslashes and no links, and accepts at most 128 of them. - Mapped paths must exist, be directories or regular files, and share one volume. A path listed both read-only and read-write is mapped read-only. A denied path must not contain a mapped path, and a denied path that does not diff --git a/aci_edge_sandboxes/tests/nvxhost_guest.rs b/aci_edge_sandboxes/tests/nvxhost_guest.rs index 982a40b4..69fa7f8f 100644 --- a/aci_edge_sandboxes/tests/nvxhost_guest.rs +++ b/aci_edge_sandboxes/tests/nvxhost_guest.rs @@ -1087,6 +1087,207 @@ fn host_paths_follow_the_filesystem_policy() { assert_no_new_openvmm(&before); } +/// Reports what the guest reaches through aliases of the denied paths `secret` and `secret.txt` +/// in the read-write mapping `root`: each entry is `ok`, `ok:` and what was read or listed, or +/// the name of the error. +const ALIAS_PROBE: &str = r#" +import errno, json, os, sys +root = sys.argv[1] +def attempt(action): + try: + result = action() + except OSError as error: + return errno.errorcode.get(error.errno, str(error.errno)) + return "ok" if result is None else "ok:" + result +def read(path): + with open(path) as file: + return file.read() +def listing(path): + return ",".join(sorted(os.listdir(path))) +def at(*parts): + return os.path.join(root, *parts) +report = { + "listing": attempt(lambda: listing(root)), + "dot-dot": attempt(lambda: read(at("sub", "..", "secret", "token"))), + "dot-dot listing": attempt(lambda: listing(at("sub", ".."))), + "above the mapping": attempt( + lambda: read(os.path.join(root, "..", os.path.basename(root), "secret.txt"))), + "host link": attempt(lambda: read(at("links", "allowed"))), + "host link into denied directory": attempt(lambda: read(at("links", "token"))), + "host link to denied file": attempt(lambda: read(at("links", "file"))), + "host link to denied directory": attempt(lambda: listing(at("links", "secret"))), + "host absolute link": attempt(lambda: read(at("links", "absolute"))), + "host hard link": attempt(lambda: read(at("hard", "secret.txt"))), + "host hard link listing": attempt(lambda: listing(at("hard"))), + "host hard link into denied directory": attempt(lambda: read(at("links", "hard-token"))), +} +if os.path.lexists(at("links", "junction")): + report["host junction"] = attempt(lambda: listing(at("links", "junction"))) +# Links that the guest creates are stored as links, which the host never follows. +os.symlink("secret/token", at("guest-link")) +os.symlink(at("secret"), at("guest-directory-link")) +report["guest link"] = attempt(lambda: read(at("guest-link"))) +report["guest directory link"] = attempt(lambda: listing(at("guest-directory-link"))) +report["guest hard link"] = attempt(lambda: os.link(at("secret.txt"), at("guest-hard-link"))) +with open(at("decoy"), "w") as file: + file.write("decoy") +report["rename over"] = attempt(lambda: os.rename(at("decoy"), at("secret.txt"))) +report["rename away"] = attempt(lambda: os.rename(at("secret.txt"), at("moved"))) +report["unlink"] = attempt(lambda: os.unlink(at("secret.txt"))) +report["create inside"] = attempt(lambda: os.mkdir(at("secret", "new"))) +report["link at the name"] = attempt(lambda: os.symlink("allowed.txt", at("secret.txt"))) +print(json.dumps(report)) +"#; + +/// Creates the host symbolic link `link` to `target`, which needs Developer Mode or the +/// symbolic-link privilege on Windows. +fn host_link(target: &Path, link: &Path, directory: bool) { + #[cfg(unix)] + let created = { + let _ = directory; + std::os::unix::fs::symlink(target, link) + }; + #[cfg(windows)] + let created = if directory { + std::os::windows::fs::symlink_dir(target, link) + } else { + std::os::windows::fs::symlink_file(target, link) + }; + created.unwrap_or_else(|error| { + panic!( + "cannot link {} to {}; Windows needs Developer Mode or the symbolic-link privilege: \ + {error}", + link.display(), + target.display() + ) + }); +} + +#[test] +#[ignore = "requires an approved private library, edge initramfs, GPT image, and a hypervisor host"] +fn denied_paths_stay_hidden_through_aliases() { + let state = tempfile::tempdir().unwrap(); + let host = tempfile::tempdir().unwrap(); + let before = openvmm_processes(); + let root = host.path().join("shared"); + for directory in ["secret", "sub", "links", "hard"] { + std::fs::create_dir_all(root.join(directory)).unwrap(); + } + for (file, content) in [ + ("allowed.txt", "allowed"), + ("secret/token", "token"), + ("secret.txt", "secret"), + ] { + std::fs::write(root.join(file), content).unwrap(); + } + std::fs::hard_link( + root.join("secret.txt"), + root.join("hard").join("secret.txt"), + ) + .unwrap(); + let (links, up) = (root.join("links"), Path::new("..")); + std::fs::hard_link(root.join("secret").join("token"), links.join("hard-token")).unwrap(); + host_link(&up.join("allowed.txt"), &links.join("allowed"), false); + host_link( + &up.join("secret").join("token"), + &links.join("token"), + false, + ); + host_link(&up.join("secret.txt"), &links.join("file"), false); + host_link(&up.join("secret"), &links.join("secret"), true); + // Linux maps a path unchanged, so this link names the denied file's guest path too, once + // the link targets the canonical path that the mapping uses. + let absolute = root.join("secret").join("token"); + let absolute = if cfg!(unix) { + std::fs::canonicalize(&absolute).unwrap() + } else { + absolute + }; + host_link(&absolute, &links.join("absolute"), false); + #[cfg(windows)] + { + let created = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(links.join("junction")) + .arg(root.join("secret")) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(created.success(), "mklink /J failed: {created}"); + } + let backend = backend(state.path()); + let client = AciEdgeSandbox::from_shared(backend.clone()); + let sandbox = started( + &client, + &backend, + &ProvisionRequest::new().with_filesystem(FilesystemPolicy { + readwrite_paths: vec![root.clone()], + denied_paths: vec![root.join("secret"), root.join("secret.txt")], + ..FilesystemPolicy::default() + }), + ); + let printed = python(&client, &sandbox.id, ALIAS_PROBE, &[&guest(&root)]); + // The guest resolves every link itself, and OpenVMM refuses each lookup of a denied path, + // and of another name for a denied object. OpenVMM never reads the target of an absolute + // Windows link or junction for the guest. It knows only the denied objects themselves, so a + // host hard link to a file inside a denied directory stays readable. + let absolute = if cfg!(windows) { "EPERM" } else { "EACCES" }; + let mut expected = serde_json::json!({ + "listing": "ok:allowed.txt,hard,links,sub", + "dot-dot": "EACCES", + "dot-dot listing": "ok:allowed.txt,hard,links,sub", + "above the mapping": "EACCES", + "host link": "ok:allowed", + "host link into denied directory": "EACCES", + "host link to denied file": "EACCES", + "host link to denied directory": "EACCES", + "host absolute link": absolute, + "host hard link": "EACCES", + "host hard link into denied directory": "ok:token", + "guest link": "EACCES", + "guest directory link": "EACCES", + "guest hard link": "EACCES", + "rename over": "EACCES", + "rename away": "EACCES", + "unlink": "EACCES", + "create inside": "EACCES", + "link at the name": "EACCES", + }); + if cfg!(windows) { + expected["host junction"] = serde_json::json!("EPERM"); + } + let mut report = serde_json::from_str::(&printed).unwrap(); + // A directory that holds a host hard link to a denied file lists the link's name when the + // guest reads only names, and fails when it also looks the entries up, as its first read of + // a directory does. + let listing = report + .as_object_mut() + .unwrap() + .remove("host hard link listing"); + assert!( + matches!( + listing.as_ref().and_then(serde_json::Value::as_str), + Some("EACCES" | "ok:secret.txt") + ), + "host hard link listing: {listing:?}" + ); + assert_eq!(report, expected); + assert_graceful(client.stop(&sandbox.id)); + client.deprovision(&sandbox.release()).unwrap(); + for (file, content) in [ + ("secret/token", "token"), + ("secret.txt", "secret"), + ("hard/secret.txt", "secret"), + ("decoy", "decoy"), + ] { + assert_eq!(std::fs::read_to_string(root.join(file)).unwrap(), content); + } + for absent in ["moved", "guest-hard-link", "secret/new"] { + assert!(!root.join(absent).exists(), "{absent}"); + } + assert_no_new_openvmm(&before); +} + /// Reports the workload's capabilities and the outcome of operations that would lift its mount /// restrictions, given a read-only mapping inside a read-write one and a file path in the latter. const CONTAINMENT_PROBE: &str = r#"