Skip to content

Commit 35c09eb

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(security): validate registry content digest to block path-traversal host write (#141)
CRITICAL. A malicious or compromised registry (or a MITM when A3S_REGISTRY_PROTOCOL=http) could write an arbitrary file to the HOST — and delete an arbitrary host directory — when an image is pulled, in the DEFAULT config. oci-distribution returns the manifest digest as the `Docker-Content-Digest` HTTP header VERBATIM (no hex/format validation). a3s-box did `digest.strip_prefix("sha256:").unwrap_or(&digest)` and fed the remainder straight into `Path::join` at two sinks with no validation: - registry.rs:357 blobs_dir.join(<digest>) + std::fs::write(manifest_json) -> `sha256:../../../../etc/cron.d/x` writes the attacker-shaped manifest JSON to an arbitrary host path (persistent arbitrary file WRITE). - pull.rs:237 store_dir/tmp/<digest> + remove_dir_all -> arbitrary host directory DELETE, plus store escape. `Path::join` does not normalize `..`, and the signature gate is a no-op by default (SignaturePolicy::Skip), so nothing blocked it. The box runtime often runs as root, amplifying impact (write to cron.d / authorized_keys -> RCE). This exceeds the recently-fixed HIGH whiteout host-file *deletion*: here it is a persistent arbitrary host file *write* with attacker-controlled content. Fix: `validated_digest_hex` validates the registry-returned digest at the trust boundary — require canonical `sha256:<64 lowercase hex>` and reject anything else — applied before the digest is used to build any on-disk path (registry manifest write, pull temp dir / store key). The config/layer blob path was already safe (stream_and_verify_blob SHA-256-verifies the body and rejects non-hex digests before rename). Test: validated_digest_hex_rejects_path_traversal_and_non_hex (accepts a real 64-hex digest; rejects `sha256:../..`, `sha256:abc/../def`, `/`-prefixed, uppercase, non-hex, wrong-length, sha512, unprefixed). Neuter-verified on the KVM server (disabling the predicate makes it FAIL); fmt + clippy clean. Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent ae8991c commit 35c09eb

2 files changed

Lines changed: 71 additions & 5 deletions

File tree

src/runtime/src/oci/pull.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,13 @@ impl ImagePuller {
216216
// keeps its canonical reference (full_ref) for storage and identity.
217217
let fetch = self.mirror_reference(reference);
218218

219-
// Get the manifest digest for storage key
219+
// Get the manifest digest for storage key. The registry returns it
220+
// verbatim (the Docker-Content-Digest header), so validate it before it is
221+
// used to build any on-disk path below (the temp dir that gets
222+
// remove_dir_all'd, the store key) — a `sha256:../../x` value would
223+
// otherwise be a path-traversal arbitrary-dir-delete / store-escape.
220224
let digest = self.puller.pull_manifest_digest(&fetch).await?;
225+
super::registry::validated_digest_hex(&digest)?;
221226

222227
// Check if we already have this digest (different tag, same content)
223228
if let Some(stored) = self.store.get_by_digest(&digest).await {

src/runtime/src/oci/registry.rs

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,29 @@ fn registry_protocol_from_env() -> ClientProtocol {
2525
}
2626
}
2727

28+
/// Validate a registry-supplied content digest and return its hex body.
29+
///
30+
/// `oci-distribution` returns the `Docker-Content-Digest` header verbatim with
31+
/// no validation, so a malicious/compromised registry (or a MITM when
32+
/// `A3S_REGISTRY_PROTOCOL=http`) can return e.g. `sha256:../../../../etc/cron.d/x`.
33+
/// That value is used to build on-disk paths (the manifest/blob write, the pull
34+
/// temp dir, the store key); without this check the `..` components make it a
35+
/// path-traversal arbitrary-file write/delete primitive that runs in the default
36+
/// config (signature policy is Skip). Require the canonical
37+
/// `sha256:<64 lowercase hex>` form and reject anything else.
38+
pub(crate) fn validated_digest_hex(digest: &str) -> Result<&str> {
39+
digest
40+
.strip_prefix("sha256:")
41+
.filter(|hex| {
42+
hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
43+
})
44+
.ok_or_else(|| {
45+
BoxError::OciImageError(format!(
46+
"registry returned a malformed content digest (expected sha256:<64 hex>): {digest:?}"
47+
))
48+
})
49+
}
50+
2851
/// An `AsyncWrite` that streams bytes straight to a file while computing their
2952
/// SHA-256, so a pulled blob is hashed and written in bounded chunks instead of
3053
/// being fully buffered in memory.
@@ -352,11 +375,12 @@ impl RegistryPuller {
352375
});
353376
}
354377

355-
// Write manifest blob
378+
// Write manifest blob. Validate the registry-returned digest first: it is
379+
// the Docker-Content-Digest header verbatim, and feeding `sha256:../../x`
380+
// into blobs_dir.join() would write the (attacker-shaped) manifest JSON to
381+
// an arbitrary host path outside the store.
356382
let manifest_json = serde_json::to_vec(&image_manifest)?;
357-
let manifest_digest_hex = manifest_digest
358-
.strip_prefix("sha256:")
359-
.unwrap_or(&manifest_digest);
383+
let manifest_digest_hex = validated_digest_hex(&manifest_digest)?;
360384
std::fs::write(blobs_dir.join(manifest_digest_hex), &manifest_json).map_err(|e| {
361385
BoxError::RegistryError {
362386
registry: reference.registry.clone(),
@@ -712,6 +736,43 @@ mod tests {
712736
assert!(auth.password.is_none());
713737
}
714738

739+
#[test]
740+
fn validated_digest_hex_rejects_path_traversal_and_non_hex() {
741+
// A real digest passes and yields the bare hex (used as a path component).
742+
let good = format!("sha256:{}", "a".repeat(64));
743+
assert_eq!(validated_digest_hex(&good).unwrap(), "a".repeat(64));
744+
assert_eq!(
745+
validated_digest_hex(
746+
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
747+
)
748+
.unwrap(),
749+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
750+
);
751+
752+
// SECURITY: every path-traversal / malformed digest a malicious registry
753+
// could return MUST be rejected before it reaches blobs_dir.join().
754+
for evil in [
755+
"sha256:../../../../etc/cron.d/x",
756+
"sha256:..",
757+
"sha256:../x",
758+
"sha256:abc/../def",
759+
"sha256:/etc/passwd",
760+
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde/", // 63 + slash
761+
&format!("sha256:{}", "A".repeat(64)), // uppercase not allowed (non-canonical)
762+
&format!("sha256:{}", "g".repeat(64)), // non-hex
763+
&format!("sha256:{}", "a".repeat(63)), // too short
764+
&format!("sha256:{}", "a".repeat(65)), // too long
765+
"sha512:0000000000000000000000000000000000000000000000000000000000000000",
766+
"../../../etc/passwd",
767+
"",
768+
] {
769+
assert!(
770+
validated_digest_hex(evil).is_err(),
771+
"must reject malicious/malformed digest: {evil:?}"
772+
);
773+
}
774+
}
775+
715776
#[test]
716777
fn test_resolve_target_arch() {
717778
// os/arch[/variant] and bare arch, normalized to OCI/Docker names.

0 commit comments

Comments
 (0)