GITHUB.COM/CANONICAL/LXD · CVE-2026-28385 · Runtime
FIX → 0.0.0-20261005133302-c4a919f3f4e2
LXD's image import-from-URL endpoint makes server-side requests to any user-supplied address, including loopback, private ranges and cloud metadata endpoints, without checks. An authenticated user with the image-creation permission can use the error messages to map open ports on internal networks reachable from the LXD host.
first public Jun 26, 2026 · 2 sources
Check it with your coding agent
It checks your repository and tells you if you’re affected, with the fix.
Hosts running LXD where users hold the can_create_images entitlement, which the advisory calls a permission commonly delegated to regular users. The exposure is the LXD daemon's network position, so hosts on cloud networks with metadata endpoints are the most sensitive. Deployments where only admins can create images have a much smaller exposure.
Not affected if admin-only-image-creation
Who cares cwe-918, linux-containers, lxd, self-hosted
go:github.com/canonical/lxd range in the advisory (affected before 0.0.0-20261005133302-c4a919f3f4e2).can_create_images entitlement; unknown whether the sources list a specific command for this.Upgrade to 0.0.0-20261005133302-c4a919f3f4e2
The LXD image import-from-URL endpoint (POST /1.0/images with source.type=url) makes server-side HTTP requests to an attacker-supplied URL without any private-IP or loopback blocking. An authenticated user with the can_create_images entitlement can force the LXD daemon to connect to internal network addresses (127.0.0.1, RFC1918 ranges, cloud metadata endpoints), enabling port scanning via error-based inference and interaction with internal HTTP services from the daemon's privileged network position.
can_create_images entitlement, a non-admin permission commonly delegated to regular users.Are you affected?
Tell InfraStack what you run. It checks this one, and every next one, against your stack.
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
0.0.0-20261005133302-c4a919f3f4e2 or later, per the product ranges.lxd/images.go — imgPostURLInfo (lines 668–755) — Makes HEAD request to user-supplied URL without any SSRF protections.lxd/util/http.go — HTTPClient (lines 102–147) — Creates HTTP client with no IP blocking or URL validation.shared/network.go — RFC3493Dialer (lines 20–49) — Dials arbitrary hosts including private/loopback ranges.lxd/daemon_images.go — ImageDownload (lines 478–510, "direct" case) — Makes GET request to URL from attacker-controlled response header.When a user imports an image from a URL, the imgPostURLInfo function makes an HTTP HEAD request directly to the user-supplied URL with zero validation:
// lxd/images.go:668-703
func imgPostURLInfo(ctx context.Context, s *state.State, req api.ImagesPost, ...) (*api.Image, error) {
if req.Source.URL == "" {
return nil, errors.New("Missing URL")
}
myhttp, err := util.HTTPClient("", s.Proxy)
// ...
// Resolve the image URL
head, err := http.NewRequest("HEAD", req.Source.URL, nil)
// No URL validation, no scheme check, no IP range check
// ...
raw, err := myhttp.Do(head) // Makes request to arbitrary destination
The HTTP client is created via util.HTTPClient which uses shared.RFC3493Dialer — a standard dialer with zero private-IP filtering:
// shared/network.go:20-49
func RFC3493Dialer(context context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
// ...
addrs, err := net.LookupHost(host)
// ...
for _, a := range addrs {
c, err := net.DialTimeout(network, net.JoinHostPort(a, port), 10*time.Second)
// No check: IsLoopback? IsPrivate? IsLinkLocalUnicast?
// Connects to ANY resolved address including 127.0.0.1, 10.x.x.x, 169.254.169.254
The HTTP client also follows redirects unconditionally:
// lxd/util/http.go:139-144
myhttp.CheckRedirect = func(req *http.Request, via []*http.Request) error {
// Replicate the headers
req.Header = via[len(via)-1].Header
return nil // Always follows redirects — no destination validation
}
An exhaustive search for private-IP checks (IsLoopback, IsPrivate, IsLinkLocalUnicast), URL allowlists, or SSRF-specific protections found zero results in any HTTP client or outbound request path. The codebase has no SSRF defense infrastructure.
LXD-Image-URL response headerAfter the HEAD request, the LXD-Image-URL response header is used as the target for a second HTTP GET request, creating a chained SSRF:
// lxd/images.go:705-725
hash := raw.Header.Get("LXD-Image-Hash") // Attacker controls this
url := raw.Header.Get("LXD-Image-URL") // Attacker controls this
info, err := ImageDownload(ctx, s, op, &ImageDownloadArgs{
Server: url, // Attacker-controlled URL from response header
Protocol: "direct", // Forces unprotected direct HTTP GET
Alias: hash, // Attacker-controlled hash
// ...
})
In ImageDownload, the "direct" case makes a GET to the attacker-controlled URL:
// lxd/daemon_images.go:478-510
case "direct":
httpClient, err := util.HTTPClient(args.Certificate, s.Proxy)
// ...
req, err := http.NewRequest(http.MethodGet, args.Server, nil) // args.Server = attacker URL
// ...
raw, doneCh, err := cancel.CancelableDownload(canceler, httpClient.Do, req)
This means an attacker who controls the HEAD target can redirect the second GET request to any internal service. The response body is downloaded to disk but hash validation prevents the attacker from reading it:
// lxd/daemon_images.go:541-544
result := hex.EncodeToString(sha256.Sum(nil))
if result != fp {
return nil, fmt.Errorf("Hash mismatch for %q: %s != %s", args.Server, result, fp)
}
Errors from the outbound request propagate back to the caller via the operation result:
// lxd/operations/operations.go:612-614
if op.err != nil {
retOp.Err = response.SmartError(op.err).String()
}
The error messages differ based on the target's state:
"Missing LXD-Image-Hash header" (HEAD succeeded, response had no expected headers)"connection refused" (connection rejected immediately)"context deadline exceeded" / "no route to host" (timeout or routing error)These differences allow systematic port scanning of internal networks from the LXD daemon's network position.
POST /1.0/images with {"source":{"type":"url","url":"http://127.0.0.1:6379/"}}imagesPost handler verifies can_create_images entitlement (line 1163)imgPostURLInfo (line 1361)imgPostURLInfo creates HTTP client via util.HTTPClient — no SSRF protections (line 675)http://127.0.0.1:6379/ — no IP validation (line 700)RFC3493Dialer resolves 127.0.0.1 and dials it — no private-IP check (line 33)GET /1.0/operations/{id}/wait and reads the error messageFor chained SSRF:
source.url to their own serverLXD-Image-Hash: <any> and LXD-Image-URL: http://169.254.169.254/latest/meta-data/http://169.254.169.254/... (line 493 in daemon_images.go)# Safe PoC: force daemon to connect to loopback port 1 (closed port).
# Error message will confirm server-side request occurred.
SOCK=/var/lib/lxd/unix.socket
# Create the operation
OP=$(curl -s --unix-socket "$SOCK" \
-H 'Content-Type: application/json' \
-X POST http://localhost/1.0/images \
-d '{"source":{"type":"url","url":"http://127.0.0.1:1/"}}' \
| sed -n 's/.*"operation":"\([^"]*\)".*/\1/p')
# Wait for result — expect "connection refused" confirming SSRF
curl -s --unix-socket "$SOCK" "http://localhost${OP}/wait"
# Response will contain error with "connection refused"
This page as .md · .json · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.