Repository navigation
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:
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-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 imageReachability
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— importsload_imageinto the public chat-arena Gradio flowfastchat/serve/api_provider.py:104, 114— Gemini provider routing
Default deployment exposes both flows without authentication:
fastchat/serve/openai_api_server.py:100-128starts withapi_keys=None→ anonymous API server- Gradio
chat-arenabinds0.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 withImage.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=Nonean opt-in (require explicit--allow-anonymousflag rather than silent default) - Audit sibling sinks:
controller.py /register_workerunauthenticated by default;model_adapter.pytorch.loadwithoutweights_only=True
References
- Vulnerable file: https://github.com/lm-sys/FastChat/blob/main/fastchat/utils.py#L394
- Gradio import site: https://github.com/lm-sys/FastChat/blob/main/fastchat/serve/gradio_web_server.py#L45
- Auth default: https://github.com/lm-sys/FastChat/blob/main/fastchat/serve/openai_api_server.py#L100
- Bug class: CWE-918 SSRF; OWASP A10:2021
- Related prior issues: Vulnerability Report: model load vulnerable to arbitrary code execution via untrusted file deserialization #3713 (model load RCE), Vulnerability Report:trust_remote_code param is enabled, causing unsafe execution of Python files #3720 (trust_remote_code RCE) — same disclosure pattern
Environment
- FastChat trunk HEAD as of 2026-05-17;
fastchat/utils.py:394-412unchanged from initial reproduction on 2026-05-13 - Verified with
pip install fastchat-llmand from-source clone - PoC reproduces on Linux + macOS
Reporter: Jaeyoung Yun (GitHub: JAE0Y2N)
Activity
Chessing234 commentedon Jul 16, 2026
Follow-up PR rejects local filesystem paths in
load_image(SSRF private-host block is in #3894).fix: block SSRF to private hosts in load_image