VYPR
Medium severity4.3NVD Advisory· Published Jun 2, 2026

CVE-2026-10661

CVE-2026-10661

Description

Arbitrary file read vulnerability in blender-mcp allows attackers to exfiltrate any file accessible by the Blender process.

AI Insight

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

Arbitrary file read vulnerability in blender-mcp allows attackers to exfiltrate any file accessible by the Blender process.

Vulnerability

A vulnerability exists in the Open function within src/blender_mcp/server.py of ahujasid blender-mcp up to commit 7636d13bded82eca58eb93c3f4cd8708dfdfbe8b. The input_image_url parameter is used to read local files without proper validation, leading to an arbitrary file read vulnerability. This affects versions prior to the patch 5b37be25242e73dc4cf1328974d30458b9e5d67e [1].

Exploitation

An attacker can exploit this vulnerability by providing a local file path as the input_image_url parameter. If the provided path does not start with http:// or https://, the server treats it as a local file path and reads it using Python's open() function. The contents of the file are then base64-encoded and sent to an external API endpoint, allowing for remote exploitation [1].

Impact

Successful exploitation allows an attacker to read any file accessible by the Blender process. The contents of these files can then be exfiltrated to an attacker-controlled server, potentially leading to sensitive information disclosure [1].

Mitigation

A patch is available with the commit 5b37be25242e73dc4cf1328974d30458b9e5d67e [3]. The fix includes validation of local file paths to ensure they have image file extensions and resolves symlinks before reading. Additionally, it prevents requests to private, loopback, and link-local addresses for SSRF vulnerabilities [4]. The product follows a rolling release approach, so specific version numbers for affected or updated releases are not provided [1].

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

Affected products

2

Patches

2
5b37be25242e

Fix arbitrary file read and SSRF in Hunyuan3D integration

https://github.com/bergskenop/blender-mcpAcelogicMar 11, 2026via nvd-ref
2 files changed · +117 7
  • addon.py+62 6 modified
    @@ -18,8 +18,53 @@
     from datetime import datetime
     import hashlib, hmac, base64
     import os.path as osp
    +import ipaddress
    +from urllib.parse import urlparse
     from contextlib import redirect_stdout, suppress
     
    +# Allowed image file extensions for local path validation
    +ALLOWED_IMAGE_EXTENSIONS = {
    +    '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
    +    '.webp', '.svg', '.ico', '.heic', '.heif',
    +}
    +
    +
    +def validate_image_path(path: str) -> str | None:
    +    """Validate that a local file path points to an image file.
    +
    +    Returns None if the path is valid, or an error message string otherwise.
    +    """
    +    resolved = os.path.realpath(path)
    +    ext = os.path.splitext(resolved)[1].lower()
    +    if ext not in ALLOWED_IMAGE_EXTENSIONS:
    +        return (
    +            f"Invalid image file type '{ext}'. "
    +            f"Allowed types: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}"
    +        )
    +    if not os.path.isfile(resolved):
    +        return f"File not found: {resolved}"
    +    return None
    +
    +
    +def validate_url_not_internal(url: str) -> str | None:
    +    """Check that a URL does not target private/loopback/link-local addresses.
    +
    +    Returns None if the URL is safe, or an error message string otherwise.
    +    """
    +    parsed = urlparse(url)
    +    hostname = parsed.hostname
    +    if not hostname:
    +        return "URL has no hostname"
    +    try:
    +        addr_infos = socket.getaddrinfo(hostname, parsed.port or 443)
    +    except socket.gaierror:
    +        return f"Could not resolve hostname: {hostname}"
    +    for family, _, _, _, sockaddr in addr_infos:
    +        ip = ipaddress.ip_address(sockaddr[0])
    +        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
    +            return f"URL resolves to a non-public address ({ip}), request blocked"
    +    return None
    +
     bl_info = {
         "name": "Blender MCP",
         "author": "BlenderMCP",
    @@ -2091,14 +2136,17 @@ def create_hunyuan_job_main_site(
                     if re.match(r'^https?://', image, re.IGNORECASE) is not None:
                         data["ImageUrl"] = image
                     else:
    +                    err = validate_image_path(image)
    +                    if err:
    +                        return {"error": err}
                         try:
                             # Convert to Base64 format
    -                        with open(image, "rb") as f:
    +                        with open(os.path.realpath(image), "rb") as f:
                                 image_base64 = base64.b64encode(f.read()).decode("ascii")
                             data["ImageBase64"] = image_base64
                         except Exception as e:
                             return {"error": f"Image encoding failed: {str(e)}"}
    -            
    +
                 # Get signed headers
                 headers, endpoint = self.get_tencent_cloud_sign_headers("POST", "/", headParams, data, service, region, secret_id, secret_key)
     
    @@ -2154,11 +2202,14 @@ def create_hunyuan_job_local_site(
                             image_base64 = base64.b64encode(resImg.content).decode("ascii")
                             data["image"] = image_base64
                         except Exception as e:
    -                        return {"error": f"Failed to download or encode image: {str(e)}"} 
    +                        return {"error": f"Failed to download or encode image: {str(e)}"}
                     else:
    +                    err = validate_image_path(image)
    +                    if err:
    +                        return {"error": err}
                         try:
                             # Convert to Base64 format
    -                        with open(image, "rb") as f:
    +                        with open(os.path.realpath(image), "rb") as f:
                                 image_base64 = base64.b64encode(f.read()).decode("ascii")
                             data["image"] = image_base64
                         except Exception as e:
    @@ -2249,11 +2300,16 @@ def import_generated_asset_hunyuan(self, *args, **kwargs):
         def import_generated_asset_hunyuan_ai(self, name: str , zip_file_url: str):
             if not zip_file_url:
                 return {"error": "Zip file not found"}
    -        
    +
             # Validate URL
             if not re.match(r'^https?://', zip_file_url, re.IGNORECASE):
                 return {"error": "Invalid URL format. Must start with http:// or https://"}
    -        
    +
    +        # Block requests to private/internal networks (SSRF protection)
    +        ssrf_err = validate_url_not_internal(zip_file_url)
    +        if ssrf_err:
    +            return {"error": ssrf_err}
    +
             # Create a temporary directory
             temp_dir = tempfile.mkdtemp(prefix="tencent_obj_")
             zip_file_path = osp.join(temp_dir, "model.zip")
    
  • src/blender_mcp/server.py+55 1 modified
    @@ -9,10 +9,52 @@
     from contextlib import asynccontextmanager
     from typing import AsyncIterator, Dict, Any, List
     import os
    +import ipaddress
     from pathlib import Path
     import base64
     from urllib.parse import urlparse
     
    +# Allowed image file extensions for local path validation
    +ALLOWED_IMAGE_EXTENSIONS = {
    +    '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
    +    '.webp', '.svg', '.ico', '.heic', '.heif',
    +}
    +
    +
    +def _validate_image_path(path: str) -> str | None:
    +    """Validate that a local file path points to an image file.
    +
    +    Returns None if the path is valid, or an error message string otherwise.
    +    """
    +    resolved = os.path.realpath(path)
    +    ext = os.path.splitext(resolved)[1].lower()
    +    if ext not in ALLOWED_IMAGE_EXTENSIONS:
    +        return (
    +            f"Invalid image file type '{ext}'. "
    +            f"Allowed types: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}"
    +        )
    +    return None
    +
    +
    +def _validate_url_not_internal(url: str) -> str | None:
    +    """Check that a URL does not target private/loopback/link-local addresses.
    +
    +    Returns None if the URL is safe, or an error message string otherwise.
    +    """
    +    parsed = urlparse(url)
    +    hostname = parsed.hostname
    +    if not hostname:
    +        return "URL has no hostname"
    +    try:
    +        addr_infos = socket.getaddrinfo(hostname, parsed.port or 443)
    +    except socket.gaierror:
    +        return f"Could not resolve hostname: {hostname}"
    +    for family, _, _, _, sockaddr in addr_infos:
    +        ip = ipaddress.ip_address(sockaddr[0])
    +        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
    +            return f"URL resolves to a non-public address ({ip}), request blocked"
    +    return None
    +
     # Import telemetry
     from .telemetry import record_startup, get_telemetry
     from .telemetry_decorator import telemetry_tool
    @@ -1012,6 +1054,12 @@ def generate_hunyuan3d_model(
         - Returns error message if the operation fails
         """
         try:
    +        # Validate local image paths to prevent arbitrary file reads
    +        if input_image_url and not input_image_url.startswith(("http://", "https://")):
    +            err = _validate_image_path(input_image_url)
    +            if err:
    +                return f"Error: {err}"
    +
             blender = get_blender_connection()
             result = blender.send_command("create_hunyuan_job", {
                 "text_prompt": text_prompt,
    @@ -1027,7 +1075,7 @@ def generate_hunyuan3d_model(
         except Exception as e:
             logger.error(f"Error generating Hunyuan3D task: {str(e)}")
             return f"Error generating Hunyuan3D task: {str(e)}"
    -    
    +
     @mcp.tool()
     def poll_hunyuan_job_status(
         ctx: Context,
    @@ -1073,6 +1121,12 @@ def import_generated_asset_hunyuan(
         Return if the asset has been imported successfully.
         """
         try:
    +        # Block requests to private/internal networks (SSRF protection)
    +        if zip_file_url:
    +            ssrf_err = _validate_url_not_internal(zip_file_url)
    +            if ssrf_err:
    +                return f"Error: {ssrf_err}"
    +
             blender = get_blender_connection()
             kwargs = {
                 "name": name
    
e0f9ca6dcee8

Merge 2503f375eb2c373cb13a3e0cb7a6de3fcbdece87 into 7636d13bded82eca58eb93c3f4cd8708dfdfbe8b

https://github.com/ahujasid/blender-mcpMiguel CruzMar 11, 2026via nvd-ref
2 files changed · +182 10
  • addon.py+108 9 modified
    @@ -18,8 +18,70 @@
     from datetime import datetime
     import hashlib, hmac, base64
     import os.path as osp
    +import ipaddress
    +from urllib.parse import urlparse
     from contextlib import redirect_stdout, suppress
     
    +# Allowed image file extensions for local path validation
    +ALLOWED_IMAGE_EXTENSIONS = {
    +    '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
    +    '.webp', '.svg', '.ico', '.heic', '.heif',
    +}
    +
    +# Maximum local image file size (10 MB)
    +MAX_LOCAL_IMAGE_BYTES = 10 * 1024 * 1024
    +
    +
    +def validate_image_path(path: str) -> str | None:
    +    """Validate that a local file path points to an image file.
    +
    +    Returns None if the path is valid, or an error message string otherwise.
    +    """
    +    resolved = os.path.realpath(path)
    +    ext = os.path.splitext(resolved)[1].lower()
    +    if ext not in ALLOWED_IMAGE_EXTENSIONS:
    +        return (
    +            f"Invalid image file type '{ext}'. "
    +            f"Allowed types: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}"
    +        )
    +    if not os.path.isfile(resolved):
    +        return f"File not found: {resolved}"
    +    try:
    +        size = os.path.getsize(resolved)
    +    except OSError as e:
    +        return f"Cannot read file size: {e}"
    +    if size > MAX_LOCAL_IMAGE_BYTES:
    +        return (
    +            f"Image file too large ({size} bytes). "
    +            f"Maximum allowed size is {MAX_LOCAL_IMAGE_BYTES} bytes"
    +        )
    +    return None
    +
    +
    +def validate_url_not_internal(url: str) -> tuple[None, list[str]] | tuple[str, None]:
    +    """Check that a URL does not target private/loopback/link-local addresses.
    +
    +    Returns (None, validated_ips) if the URL is safe, where validated_ips are
    +    the resolved IP strings that should be pinned for the actual request to
    +    prevent DNS rebinding (TOCTOU) attacks.
    +    Returns (error_message, None) otherwise.
    +    """
    +    parsed = urlparse(url)
    +    hostname = parsed.hostname
    +    if not hostname:
    +        return ("URL has no hostname", None)
    +    try:
    +        addr_infos = socket.getaddrinfo(hostname, parsed.port or 443)
    +    except socket.gaierror:
    +        return (f"Could not resolve hostname: {hostname}", None)
    +    validated_ips = []
    +    for family, _, _, _, sockaddr in addr_infos:
    +        ip = ipaddress.ip_address(sockaddr[0])
    +        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
    +            return (f"URL resolves to a non-public address ({ip}), request blocked", None)
    +        validated_ips.append(sockaddr[0])
    +    return (None, validated_ips)
    +
     bl_info = {
         "name": "Blender MCP",
         "author": "BlenderMCP",
    @@ -2089,16 +2151,22 @@ def create_hunyuan_job_main_site(
                 # Handling image
                 if image:
                     if re.match(r'^https?://', image, re.IGNORECASE) is not None:
    +                    ssrf_err, _validated_ips = validate_url_not_internal(image)
    +                    if ssrf_err:
    +                        return {"error": ssrf_err}
                         data["ImageUrl"] = image
                     else:
    +                    err = validate_image_path(image)
    +                    if err:
    +                        return {"error": err}
                         try:
                             # Convert to Base64 format
    -                        with open(image, "rb") as f:
    +                        with open(os.path.realpath(image), "rb") as f:
                                 image_base64 = base64.b64encode(f.read()).decode("ascii")
                             data["ImageBase64"] = image_base64
                         except Exception as e:
                             return {"error": f"Image encoding failed: {str(e)}"}
    -            
    +
                 # Get signed headers
                 headers, endpoint = self.get_tencent_cloud_sign_headers("POST", "/", headParams, data, service, region, secret_id, secret_key)
     
    @@ -2148,17 +2216,34 @@ def create_hunyuan_job_local_site(
                 # Handling image
                 if image:
                     if re.match(r'^https?://', image, re.IGNORECASE) is not None:
    +                    # Validate URL is not targeting internal networks
    +                    ssrf_err, validated_ips = validate_url_not_internal(image)
    +                    if ssrf_err:
    +                        return {"error": ssrf_err}
    +                    # Pin to validated IP to prevent DNS rebinding
    +                    parsed_img = urlparse(image)
    +                    pinned_img_url = image.replace(
    +                        f"{parsed_img.scheme}://{parsed_img.hostname}",
    +                        f"{parsed_img.scheme}://{validated_ips[0]}",
    +                        1,
    +                    )
                         try:
    -                        resImg = requests.get(image)
    +                        resImg = requests.get(
    +                            pinned_img_url,
    +                            headers={"Host": parsed_img.hostname},
    +                        )
                             resImg.raise_for_status()
                             image_base64 = base64.b64encode(resImg.content).decode("ascii")
                             data["image"] = image_base64
                         except Exception as e:
    -                        return {"error": f"Failed to download or encode image: {str(e)}"} 
    +                        return {"error": f"Failed to download or encode image: {str(e)}"}
                     else:
    +                    err = validate_image_path(image)
    +                    if err:
    +                        return {"error": err}
                         try:
                             # Convert to Base64 format
    -                        with open(image, "rb") as f:
    +                        with open(os.path.realpath(image), "rb") as f:
                                 image_base64 = base64.b64encode(f.read()).decode("ascii")
                             data["image"] = image_base64
                         except Exception as e:
    @@ -2249,20 +2334,34 @@ def import_generated_asset_hunyuan(self, *args, **kwargs):
         def import_generated_asset_hunyuan_ai(self, name: str , zip_file_url: str):
             if not zip_file_url:
                 return {"error": "Zip file not found"}
    -        
    +
             # Validate URL
             if not re.match(r'^https?://', zip_file_url, re.IGNORECASE):
                 return {"error": "Invalid URL format. Must start with http:// or https://"}
    -        
    +
    +        # Block requests to private/internal networks (SSRF protection)
    +        ssrf_err, validated_ips = validate_url_not_internal(zip_file_url)
    +        if ssrf_err:
    +            return {"error": ssrf_err}
    +
    +        # Pin the download to a validated IP to prevent DNS rebinding
    +        parsed = urlparse(zip_file_url)
    +        pinned_url = zip_file_url.replace(
    +            f"{parsed.scheme}://{parsed.hostname}",
    +            f"{parsed.scheme}://{validated_ips[0]}",
    +            1,
    +        )
    +        pin_headers = {"Host": parsed.hostname}
    +
             # Create a temporary directory
             temp_dir = tempfile.mkdtemp(prefix="tencent_obj_")
             zip_file_path = osp.join(temp_dir, "model.zip")
             obj_file_path = osp.join(temp_dir, "model.obj")
             mtl_file_path = osp.join(temp_dir, "model.mtl")
     
             try:
    -            # Download ZIP file
    -            zip_response = requests.get(zip_file_url, stream=True)
    +            # Download ZIP file using pinned IP
    +            zip_response = requests.get(pinned_url, headers=pin_headers, stream=True)
                 zip_response.raise_for_status()
                 with open(zip_file_path, "wb") as f:
                     for chunk in zip_response.iter_content(chunk_size=8192):
    
  • src/blender_mcp/server.py+74 1 modified
    @@ -9,10 +9,71 @@
     from contextlib import asynccontextmanager
     from typing import AsyncIterator, Dict, Any, List
     import os
    +import ipaddress
     from pathlib import Path
     import base64
     from urllib.parse import urlparse
     
    +# Allowed image file extensions for local path validation
    +ALLOWED_IMAGE_EXTENSIONS = {
    +    '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
    +    '.webp', '.svg', '.ico', '.heic', '.heif',
    +}
    +
    +# Maximum local image file size (10 MB)
    +MAX_LOCAL_IMAGE_BYTES = 10 * 1024 * 1024
    +
    +
    +def _validate_image_path(path: str) -> str | None:
    +    """Validate that a local file path points to an image file.
    +
    +    Returns None if the path is valid, or an error message string otherwise.
    +    """
    +    resolved = os.path.realpath(path)
    +    ext = os.path.splitext(resolved)[1].lower()
    +    if ext not in ALLOWED_IMAGE_EXTENSIONS:
    +        return (
    +            f"Invalid image file type '{ext}'. "
    +            f"Allowed types: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}"
    +        )
    +    if not os.path.isfile(resolved):
    +        return f"File not found: {resolved}"
    +    try:
    +        size = os.path.getsize(resolved)
    +    except OSError as e:
    +        return f"Cannot read file size: {e}"
    +    if size > MAX_LOCAL_IMAGE_BYTES:
    +        return (
    +            f"Image file too large ({size} bytes). "
    +            f"Maximum allowed size is {MAX_LOCAL_IMAGE_BYTES} bytes"
    +        )
    +    return None
    +
    +
    +def _validate_url_not_internal(url: str) -> tuple[None, list[str]] | tuple[str, None]:
    +    """Check that a URL does not target private/loopback/link-local addresses.
    +
    +    Returns (None, validated_ips) if the URL is safe, where validated_ips are
    +    the resolved IP strings that should be pinned for the actual request to
    +    prevent DNS rebinding (TOCTOU) attacks.
    +    Returns (error_message, None) otherwise.
    +    """
    +    parsed = urlparse(url)
    +    hostname = parsed.hostname
    +    if not hostname:
    +        return ("URL has no hostname", None)
    +    try:
    +        addr_infos = socket.getaddrinfo(hostname, parsed.port or 443)
    +    except socket.gaierror:
    +        return (f"Could not resolve hostname: {hostname}", None)
    +    validated_ips = []
    +    for family, _, _, _, sockaddr in addr_infos:
    +        ip = ipaddress.ip_address(sockaddr[0])
    +        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
    +            return (f"URL resolves to a non-public address ({ip}), request blocked", None)
    +        validated_ips.append(sockaddr[0])
    +    return (None, validated_ips)
    +
     # Import telemetry
     from .telemetry import record_startup, get_telemetry
     from .telemetry_decorator import telemetry_tool
    @@ -1012,6 +1073,12 @@ def generate_hunyuan3d_model(
         - Returns error message if the operation fails
         """
         try:
    +        # Validate local image paths to prevent arbitrary file reads
    +        if input_image_url and not input_image_url.startswith(("http://", "https://")):
    +            err = _validate_image_path(input_image_url)
    +            if err:
    +                return f"Error: {err}"
    +
             blender = get_blender_connection()
             result = blender.send_command("create_hunyuan_job", {
                 "text_prompt": text_prompt,
    @@ -1027,7 +1094,7 @@ def generate_hunyuan3d_model(
         except Exception as e:
             logger.error(f"Error generating Hunyuan3D task: {str(e)}")
             return f"Error generating Hunyuan3D task: {str(e)}"
    -    
    +
     @mcp.tool()
     def poll_hunyuan_job_status(
         ctx: Context,
    @@ -1073,6 +1140,12 @@ def import_generated_asset_hunyuan(
         Return if the asset has been imported successfully.
         """
         try:
    +        # Block requests to private/internal networks (SSRF protection)
    +        if zip_file_url:
    +            ssrf_err, _validated_ips = _validate_url_not_internal(zip_file_url)
    +            if ssrf_err:
    +                return f"Error: {ssrf_err}"
    +
             blender = get_blender_connection()
             kwargs = {
                 "name": name
    

Vulnerability mechanics

Root cause

"The application did not validate local file paths provided as input, allowing arbitrary file reads."

Attack vector

An attacker can exploit this vulnerability by providing a local file path, such as `/etc/passwd`, as the `input_image_url` parameter to the `generate_hunyuan3d_model` tool. This path is then passed to the Blender addon without sufficient validation. The addon attempts to open and read the file, base64-encodes its contents, and sends it to a configured API endpoint, enabling data exfiltration [ref_id=1].

Affected code

The vulnerability exists in the `generate_hunyuan3d_model` function within `src/blender_mcp/server.py` and the corresponding handling in `addon.py`. Specifically, the `input_image_url` parameter is passed to the Blender addon without adequate validation, leading to arbitrary file reads when it's not a URL [ref_id=1].

What the fix does

The patch introduces input validation for the `input_image_url` parameter in both the server and addon layers [patch_id=4547981]. For local file paths, it now validates that the path has an allowed image file extension and resolves symlinks before reading. Additionally, for URLs, it resolves hostnames and blocks requests to private, loopback, and link-local addresses to prevent Server-Side Request Forgery (SSRF) [patch_id=4547981].

Preconditions

  • inputThe attacker must be able to control the `input_image_url` parameter, for example, through prompt injection or by directly interacting with the MCP client.
  • configThe Blender MCP server must be running and configured to use an API endpoint that can receive and exfiltrate the file contents.

Reproduction

1. Set up a capture server to receive exfiltrated data. 2. Configure Blender MCP to use the capture server as its API endpoint in LOCAL_API mode. 3. Use MCP Inspector to call the `generate_hunyuan3d_model` tool with `input_image_url` set to a sensitive local file path (e.g., `/etc/passwd`). 4. Observe the capture server receiving the base64-encoded contents of the specified file.

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

References

7

News mentions

0

No linked articles in our index yet.