VYPR
High severityNVD Advisory· Published Oct 7, 2025· Updated Oct 7, 2025

LLaMA Factory's Chat API has Critical SSRF and LFI Vulnerabilities

CVE-2025-61784

Description

LLaMA-Factory is a tuning library for large language models. Prior to version 0.9.4, a Server-Side Request Forgery (SSRF) vulnerability in the chat API allows any authenticated user to force the server to make arbitrary HTTP requests to internal and external networks. This can lead to the exposure of sensitive internal services, reconnaissance of the internal network, or interaction with third-party services. The same mechanism also allows for a Local File Inclusion (LFI) vulnerability, enabling users to read arbitrary files from the server's filesystem. The vulnerability exists in the _process_request function within src/llamafactory/api/chat.py. This function is responsible for processing incoming multimodal content, including images, videos, and audio provided via URLs. The function checks if the provided URL is a base64 data URI or a local file path (os.path.isfile). If neither is true, it falls back to treating the URL as a web URI and makes a direct HTTP GET request using requests.get(url, stream=True).raw without any validation or sanitization of the URL. Version 0.9.4 fixes the underlying issue.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

LLaMA-Factory prior to 0.9.4 has SSRF and LFI in its chat API, allowing authenticated users to proxy HTTP requests or read server files.

Vulnerability

Description

CVE-2025-61784 identifies a Server-Side Request Forgery (SSRF) and Local File Inclusion (LFI) vulnerability in LLaMA-Factory, an LLM tuning library. The flaw resides in the _process_request function within src/llamafactory/api/chat.py. This function handles multimodal content (images, videos, audio) supplied via URLs. It checks if a URL is a base64 data URI or a local file path using os.path.isfile, but if neither matches, it simply performs an HTTP GET request via requests.get(url, stream=True).raw without any URL validation or sanitization [2][3].

Exploitation and

Attack Surface

An authenticated user can trigger this vulnerability by crafting a POST request to the /v1/chat/completions endpoint with a message containing a malicious URL in the multimodal content fields. The server then issues an HTTP request to the attacker-specified URL, which can target internal or external network services. Additionally, because the fallback logic treats any non-base64, non-filepath string as a web URI, an attacker could use a file:// scheme or path traversal to read arbitrary files from the server's filesystem, as the os.path.isfile check is bypassed for other URI schemes [3].

Impact

Successful exploitation enables an attacker to probe internal network services (SSRF), potentially exposing sensitive systems or interacting with third-party APIs. The same mechanism grants the ability to read local files (LFI), such as configuration files containing secrets or model data, leading to information disclosure and further compromise [2][3].

Mitigation

The issue is fixed in LLaMA-Factory version 0.9.4. The commit introduces input validation functions (check_ssrf_url and check_lfi_path) that restrict network requests to safe destinations and limit file access to a designated safe media directory, raising HTTP errors for invalid or blocked URLs [4]. Users are advised to upgrade immediately.

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
llamafactoryPyPI
< 0.9.40.9.4

Affected products

2

Patches

1
95b7188090a1

Merge commit from fork

https://github.com/hiyouga/LLaMAFactoryWu WenhaoOct 7, 2025via ghsa
2 files changed · +69 1
  • src/llamafactory/api/chat.py+7 1 modified
    @@ -26,7 +26,7 @@
     from ..extras.constants import AUDIO_PLACEHOLDER, IMAGE_PLACEHOLDER, VIDEO_PLACEHOLDER
     from ..extras.misc import is_env_enabled
     from ..extras.packages import is_fastapi_available, is_pillow_available, is_requests_available
    -from .common import dictify, jsonify
    +from .common import check_lfi_path, check_ssrf_url, dictify, jsonify
     from .protocol import (
         ChatCompletionMessage,
         ChatCompletionResponse,
    @@ -121,8 +121,10 @@ def _process_request(
                         if re.match(r"^data:image\/(png|jpg|jpeg|gif|bmp);base64,(.+)$", image_url):  # base64 image
                             image_stream = io.BytesIO(base64.b64decode(image_url.split(",", maxsplit=1)[1]))
                         elif os.path.isfile(image_url):  # local file
    +                        check_lfi_path(image_url)
                             image_stream = open(image_url, "rb")
                         else:  # web uri
    +                        check_ssrf_url(image_url)
                             image_stream = requests.get(image_url, stream=True).raw
     
                         images.append(Image.open(image_stream).convert("RGB"))
    @@ -132,8 +134,10 @@ def _process_request(
                         if re.match(r"^data:video\/(mp4|mkv|avi|mov);base64,(.+)$", video_url):  # base64 video
                             video_stream = io.BytesIO(base64.b64decode(video_url.split(",", maxsplit=1)[1]))
                         elif os.path.isfile(video_url):  # local file
    +                        check_lfi_path(video_url)
                             video_stream = video_url
                         else:  # web uri
    +                        check_ssrf_url(video_url)
                             video_stream = requests.get(video_url, stream=True).raw
     
                         videos.append(video_stream)
    @@ -143,8 +147,10 @@ def _process_request(
                         if re.match(r"^data:audio\/(mpeg|mp3|wav|ogg);base64,(.+)$", audio_url):  # base64 audio
                             audio_stream = io.BytesIO(base64.b64decode(audio_url.split(",", maxsplit=1)[1]))
                         elif os.path.isfile(audio_url):  # local file
    +                        check_lfi_path(audio_url)
                             audio_stream = audio_url
                         else:  # web uri
    +                        check_ssrf_url(audio_url)
                             audio_stream = requests.get(audio_url, stream=True).raw
     
                         audios.append(audio_stream)
    
  • src/llamafactory/api/common.py+62 0 modified
    @@ -12,14 +12,29 @@
     # See the License for the specific language governing permissions and
     # limitations under the License.
     
    +import ipaddress
     import json
    +import os
    +import socket
     from typing import TYPE_CHECKING, Any
    +from urllib.parse import urlparse
    +
    +from ..extras.misc import is_env_enabled
    +from ..extras.packages import is_fastapi_available
    +
    +
    +if is_fastapi_available():
    +    from fastapi import HTTPException, status
     
     
     if TYPE_CHECKING:
         from pydantic import BaseModel
     
     
    +SAFE_MEDIA_PATH = os.environ.get("SAFE_MEDIA_PATH", os.path.join(os.path.dirname(__file__), "safe_media"))
    +ALLOW_LOCAL_FILES = is_env_enabled("ALLOW_LOCAL_FILES", "1")
    +
    +
     def dictify(data: "BaseModel") -> dict[str, Any]:
         try:  # pydantic v2
             return data.model_dump(exclude_unset=True)
    @@ -32,3 +47,50 @@ def jsonify(data: "BaseModel") -> str:
             return json.dumps(data.model_dump(exclude_unset=True), ensure_ascii=False)
         except AttributeError:  # pydantic v1
             return data.json(exclude_unset=True, ensure_ascii=False)
    +
    +
    +def check_lfi_path(path: str) -> None:
    +    """Checks if a given path is vulnerable to LFI. Raises HTTPException if unsafe."""
    +    if not ALLOW_LOCAL_FILES:
    +        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Local file access is disabled.")
    +
    +    try:
    +        os.makedirs(SAFE_MEDIA_PATH, exist_ok=True)
    +        real_path = os.path.realpath(path)
    +        safe_path = os.path.realpath(SAFE_MEDIA_PATH)
    +
    +        if not real_path.startswith(safe_path):
    +            raise HTTPException(
    +                status_code=status.HTTP_403_FORBIDDEN, detail="File access is restricted to the safe media directory."
    +            )
    +    except Exception:
    +        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or inaccessible file path.")
    +
    +
    +def check_ssrf_url(url: str) -> None:
    +    """Checks if a given URL is vulnerable to SSRF. Raises HTTPException if unsafe."""
    +    try:
    +        parsed_url = urlparse(url)
    +        if parsed_url.scheme not in ["http", "https"]:
    +            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only HTTP/HTTPS URLs are allowed.")
    +
    +        hostname = parsed_url.hostname
    +        if not hostname:
    +            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid URL hostname.")
    +
    +        ip_info = socket.getaddrinfo(hostname, parsed_url.port)
    +        ip_address_str = ip_info[0][4][0]
    +        ip = ipaddress.ip_address(ip_address_str)
    +
    +        if not ip.is_global:
    +            raise HTTPException(
    +                status_code=status.HTTP_403_FORBIDDEN,
    +                detail="Access to private or reserved IP addresses is not allowed.",
    +            )
    +
    +    except socket.gaierror:
    +        raise HTTPException(
    +            status_code=status.HTTP_400_BAD_REQUEST, detail=f"Could not resolve hostname: {parsed_url.hostname}"
    +        )
    +    except Exception as e:
    +        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid URL: {e}")
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.