Home/Blog

LMDeploy CVE-2026-33626: Your Image URL Field Is an SSRF Endpoint

An LMDeploy image loader SSRF was exploited 12.5 hours after disclosure. How to lock down any LLM endpoint that fetches user URLs, with tested Python.

LMDeploy CVE-2026-33626: Your Image URL Field Is an SSRF Endpoint

LMDeploy CVE-2026-33626: Your Image URL Field Is an SSRF Endpoint

On April 21, 2026 at 15:04 UTC, GitHub published advisory GHSA-6w67-hwm5-92mq for CVE-2026-33626, a server-side request forgery in LMDeploy, the InternLM toolkit for serving large language models. The vision-language code fetched any URL a client put in an image_url field, including http://169.254.169.254/. Twelve and a half hours later, Sysdig's honeypot recorded the first exploitation attempt. If you run LMDeploy 0.12.2 or earlier with a vision model, you are affected. If you run any other service that takes a URL from a user and fetches it on the server, keep reading: the bug is not specific to LMDeploy.

What the bug was

The advisory is short and precise. The load_image() function in lmdeploy/vl/utils.py "fetches arbitrary URLs without validating internal/private IP addresses, allowing attackers to access cloud metadata services, internal networks, and sensitive resources." A sibling function, encode_image_base64(), had the same pattern. The advisory rates it CVSS 3.1 7.5 (network vector, no privileges, no user interaction, high confidentiality impact) and classifies it as CWE-918. Igor Stepansky of Orca Security is credited with the report.

The vulnerable code in v0.12.2 makes the problem obvious:

elif image_url.startswith('http'):
    response = requests.get(image_url, headers=headers, timeout=FETCH_TIMEOUT)
    response.raise_for_status()
    img = Image.open(BytesIO(response.content))

Any string starting with http was fetched. No scheme check beyond a prefix, no host check, no IP check. The advisory also lists the conditions that made it reachable from the internet: the server binds to 0.0.0.0 by default and API keys are off by default. The attack is one request to the OpenAI-compatible /v1/chat/completions endpoint with an image_url pointing at an internal address.

Timeline

The fix shipped before the advisory. That gap matters for how you think about patch windows.

Date (UTC)EventSource
2026-03-27PR #4447 "fix security issues" merged, adding _is_safe_url()GitHub PR #4447
2026-04-08LMDeploy v0.12.3 released with the fixv0.12.3 release
2026-04-18Repository security advisory publishedRepo advisory
2026-04-20CVE record published to NVDNVD
2026-04-21 15:04Advisory reviewed and published in the GitHub Advisory DatabaseGHSA-6w67-hwm5-92mq
2026-04-22 03:35First exploitation observed by Sysdig, 12 hours 31 minutes laterSysdig

Teams that upgraded to 0.12.3 in the two weeks after April 8 were covered before anyone knew there was something to cover. Teams that waited for a CVE to show up in a scanner had about half a day.

What the attacker did

Sysdig's write-up is the most detailed primary account. A single source IP, 103.116.72.119, ran an eight-minute session of ten requests against their LMDeploy honeypot, switching between the internlm-xcomposer2 and OpenGVLab/InternVL2-8B vision models. The targets it tried through the image_url field:

  • 169.254.169.254, the AWS Instance Metadata Service, where IAM role credentials live
  • 127.0.0.1:6379 (Redis) and 127.0.0.1:3306 (MySQL)
  • 127.0.0.1:8080, a secondary HTTP admin interface
  • an out-of-band DNS callback on requestrepo.com, to confirm the server makes outbound requests at all

The attacker also hit /distserve/p2p_drop_connect, an LMDeploy-specific endpoint for its disaggregated serving mode. That is someone who read the project, not a generic scanner. Sysdig's conclusion is the line every maintainer should read twice: "The advisory text itself contained enough detail to construct a working exploit from scratch, including the affected file, parameter name, and the absence of scheme or host validation."

Note what this is: a port scan of localhost and a credential grab attempt, run through a model's image loader. The image does not need to be an image. A Redis port answering with an error, versus a closed port timing out, is enough signal to map the internal network.

Every URL field is a request you did not write

LMDeploy is one instance of a pattern that shows up wherever LLMs meet the network:

  • Vision inputs. OpenAI-compatible APIs accept image_url with a remote URL. Every self-hosted server that implements that schema has to fetch it somewhere.
  • Document ingestion. "Summarize this PDF from a link" means your RAG pipeline downloads from an address chosen by the user.
  • Agent tools. A fetch_url or browse tool lets the model choose the URL, and the model's input can come from a web page, an email or a retrieved document. Prompt injection turns that into an SSRF the user never typed. We covered the wider threat model in Securing AI Agents in Production.
  • Webhooks and callbacks. Any "notify this URL when done" setting.

In all of these, the server process has network position the caller lacks: it sits inside your VPC, next to your databases, and on cloud compute it can reach a metadata service that hands out credentials. SSRF is the caller borrowing that position. The OWASP SSRF Prevention Cheat Sheet has treated this as a standard application bug for years. LLM serving stacks are relearning it because "fetch the image" feels like a data problem, not a network problem.

Why the patch is a starting point, not the finish

The fix in PR #4447 adds _is_safe_url(), which resolves the hostname with socket.getaddrinfo, rejects any address where ip.is_global is false, and only then calls requests.get. Its tests cover loopback, link-local metadata, IPv6 unique local addresses and a hostname returning a mix of public and private records. It is a real improvement, and you should upgrade to it.

Reading the 0.12.3 code, two gaps remain that apply to anyone who writes validation this way:

  1. Check, then fetch, resolves twice. _is_safe_url() resolves the name, then requests resolves it again when it connects. A DNS server that answers with a public IP and a zero TTL on the first query, then 169.254.169.254 on the second, passes the check and hits the metadata service. This is DNS rebinding, and the only reliable defense is to connect to the exact IP you validated.
  2. Redirects are followed without re-checking. The 0.12.3 code sets max_redirects = 3 and allow_redirects=True. Only the first URL goes through _is_safe_url(). A public server that returns 302 Location: http://169.254.169.254/latest/meta-data/ sends the fetcher to the metadata service on the next hop.

Neither is exotic. Both are standard entries in SSRF test suites. The lesson for your own code: validation has to happen on the connection, not on the string.

Layer 1: Take the metadata service off the table

The single highest-value control is making the credential endpoint useless to a blind GET. The clouds differ here.

AWS. IMDSv1 answers a plain GET. IMDSv2 requires a PUT to /latest/api/token first, then the token in a header on every read. An SSRF that can only issue a GET with fixed headers, like the LMDeploy one, cannot complete that handshake. Require it:

aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-endpoint enabled

Before flipping it, check the MetadataNoToken CloudWatch metric for the instance; AWS documents that zero IMDSv1 calls means it is ready to require IMDSv2. Set account defaults per Region so new instances launch that way, and keep the PUT response hop limit at 1 unless containers on the host genuinely need metadata access (AWS notes a hop limit of 1 can cause issues in container environments, which is the point when the container is your inference server). If the instance does not need IAM credentials at all, set --http-endpoint disabled.

Google Cloud. The metadata server at metadata.google.internal and 169.254.169.254 rejects any request without the header Metadata-Flavor: Google. A GET-only SSRF with no header control fails.

Azure. IMDS at 169.254.169.254 requires the header Metadata: true and rejects requests carrying X-Forwarded-For.

These header checks defeat the simplest SSRF shape, not all of them. If your fetch tool lets the model or user set headers, or your HTTP client forwards incoming headers, they do nothing. Treat them as one layer, and scope the instance role so stolen credentials are worth little. Cloud IAM Best Practices Across AWS, Azure and GCP covers least-privilege roles per provider.

Layer 2: Egress rules at the network

Your inference nodes should not be able to open connections to your databases, your admin ports, or the metadata IP unless they need to. Enforce that below the application, where a code bug cannot undo it.

On Kubernetes, a NetworkPolicy on the inference pods can allow DNS and public egress while carving out private and link-local space:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: vlm-egress
  namespace: inference
spec:
  podSelector:
    matchLabels:
      app: vlm-server
  policyTypes: ["Egress"]
  egress:
    - to:
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8
              - 172.16.0.0/12
              - 192.168.0.0/16
              - 169.254.0.0/16
              - 100.64.0.0/10
              - 127.0.0.0/8
      ports:
        - protocol: TCP
          port: 443
        - protocol: TCP
          port: 80

NetworkPolicy is only enforced if your CNI plugin supports it, so test it from inside a pod with curl -m 3 http://169.254.169.254/ and confirm it hangs. On a plain VM, an owner match rule does the same job for one service account:

iptables -A OUTPUT -d 169.254.169.254 -m owner --uid-owner lmdeploy -j REJECT

Better still, if the product only needs images from your own storage, route all fetches through an egress proxy with a hostname allowlist and give the inference nodes no direct internet route at all. An allowlist beats a denylist every time you can afford one.

Layer 3: A fetch wrapper that validates the connection

When you must fetch arbitrary public URLs, do it through one function that resolves the name once, checks every returned address, connects to the IP it checked, verifies TLS against the original hostname, and handles redirects itself. This version uses urllib3 2.x, which lets you connect to an IP while sending the right SNI and checking the certificate against the hostname:

from __future__ import annotations
 
import ipaddress
import socket
from urllib.parse import urljoin, urlsplit
 
import urllib3
 
MAX_BYTES = 10 * 1024 * 1024
MAX_REDIRECTS = 3
ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443}
ALLOWED_TYPES = ("image/png", "image/jpeg", "image/webp", "image/gif")
TIMEOUT = urllib3.Timeout(connect=3.0, read=10.0)
 
 
class BlockedURL(ValueError):
    pass
 
 
def _check_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
    ip = ipaddress.ip_address(raw.split("%")[0])
    if ip.version == 6 and ip.ipv4_mapped:
        ip = ip.ipv4_mapped
    if not ip.is_global or ip.is_multicast:
        raise BlockedURL(f"non-public address {ip}")
    return ip
 
 
def _resolve_and_pin(host: str, port: int) -> str:
    try:
        infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
    except socket.gaierror as exc:
        raise BlockedURL(f"cannot resolve {host}") from exc
    ips = [_check_ip(info[4][0]) for info in infos]
    if not ips:
        raise BlockedURL(f"no addresses for {host}")
    ips.sort(key=lambda ip: ip.version)
    return str(ips[0])
 
 
def _pool_for(scheme: str, host: str, ip: str, port: int):
    if scheme == "https":
        return urllib3.HTTPSConnectionPool(
            ip, port,
            server_hostname=host,
            assert_hostname=host,
            cert_reqs="CERT_REQUIRED",
            timeout=TIMEOUT, retries=False, maxsize=1,
        )
    return urllib3.HTTPConnectionPool(ip, port, timeout=TIMEOUT, retries=False, maxsize=1)
 
 
def safe_fetch(url: str, allowed_hosts: set[str] | None = None) -> bytes:
    for _ in range(MAX_REDIRECTS + 1):
        parts = urlsplit(url)
        scheme, host = parts.scheme.lower(), parts.hostname
        if scheme not in ALLOWED_SCHEMES or not host:
            raise BlockedURL(f"bad scheme or host in {url!r}")
        if parts.username or parts.password:
            raise BlockedURL("credentials in URL")
        port = parts.port or (443 if scheme == "https" else 80)
        if port not in ALLOWED_PORTS:
            raise BlockedURL(f"port {port} not allowed")
        if allowed_hosts is not None and host not in allowed_hosts:
            raise BlockedURL(f"{host} not on allowlist")
 
        ip = _resolve_and_pin(host, port)
        path = parts.path or "/"
        if parts.query:
            path += "?" + parts.query
 
        pool = _pool_for(scheme, host, ip, port)
        resp = pool.urlopen(
            "GET", path,
            headers={"Host": parts.netloc.rsplit("@", 1)[-1]},
            redirect=False, preload_content=False,
        )
        try:
            if resp.status in (301, 302, 303, 307, 308):
                location = resp.headers.get("Location")
                if not location:
                    raise BlockedURL("redirect without Location")
                url = urljoin(url, location)
                continue
            if resp.status != 200:
                raise BlockedURL(f"upstream status {resp.status}")
            ctype = resp.headers.get("Content-Type", "").split(";")[0].strip()
            if ctype not in ALLOWED_TYPES:
                raise BlockedURL(f"content type {ctype!r} not allowed")
            body = bytearray()
            for chunk in resp.stream(64 * 1024):
                body += chunk
                if len(body) > MAX_BYTES:
                    raise BlockedURL("response too large")
            return bytes(body)
        finally:
            resp.release_conn()
            pool.close()
    raise BlockedURL("too many redirects")

What each piece buys you:

  • Resolve once, connect to that IP. The pool is opened against the validated address, so a second DNS answer never gets a say. That closes the rebinding window.
  • Every address must be public. If a name returns one public and one private record, it is rejected. is_global covers RFC 1918, loopback, link-local (the metadata range), carrier-grade NAT, and IPv6 unique local space. IPv4-mapped IPv6 addresses such as ::ffff:169.254.169.254 are unwrapped first.
  • Numeric tricks resolve to the truth. http://2130706433/ and http://0x7f000001/ both resolve to 127.0.0.1 through getaddrinfo, and are blocked on the resolved value rather than by string matching.
  • Redirects loop back through the same checks. Each hop is parsed, resolved, validated and pinned again.
  • Ports, types and size are bounded. No reaching Redis on 6379, no HTML error pages fed to a vision encoder, no multi-gigabyte downloads.

Before publishing, the wrapper was run against loopback, 169.254.169.254, decimal and hex IP encodings, an IPv4-mapped metadata address, file://, a redirect to the metadata service and real public PNGs over HTTP and HTTPS. It blocked every internal target and fetched the images. Pair it with Pillow's default Image.MAX_IMAGE_PIXELS guard when decoding, and run it in a process whose network is already restricted by Layer 2. The wrapper is a second wall, not the only one.

What to do today

PriorityActionDone when
1Upgrade LMDeploy to 0.12.3 or later on every vision deploymentpip show lmdeploy reports 0.12.3 or later
2Put an API key and a private network boundary in front of the serverUnauthenticated /v1/chat/completions returns 401 from outside
3Require IMDSv2 on inference hosts, or disable IMDS if unusedaws ec2 describe-instances shows HttpTokens: required
4Rotate the instance role credentials if a vulnerable version was internet-facingNew role session, old keys unusable
5Add egress rules blocking private and link-local ranges from inference podscurl to 169.254.169.254 from the pod times out
6Route every user-supplied URL fetch (images, documents, agent tools) through one safe fetch functionCode search finds no direct requests.get(user_url)
7Alert on outbound connections from inference processes to private rangesTest request fires an alert

For step 4, if you are on AWS, look in CloudTrail for the instance role's credentials being used from IP addresses outside your VPC. That is the signature of a stolen IMDS credential. For step 6, the search is broader than your model server: every agent tool, ingestion job and webhook sender belongs on the list. API Security Best Practices has the wider checklist for the endpoints in front of them.

The takeaway

CVE-2026-33626 was one requests.get call. It became a working exploit in half a day because the advisory described it well and because self-hosted inference servers are easy to find. The fix for your stack is not a better regex on URLs. It is three independent layers: a metadata service that refuses blind requests, a network that refuses private destinations, and a fetch function that validates the connection it actually makes.

If you want a second pair of eyes on how your AI endpoints reach the network, get in touch.

Sources