├── src └── esxi_testing_toolkit │ ├── __init__.py │ ├── cli │ ├── __init__.py │ ├── base_commands.py │ ├── vm_commands.py │ └── host_commands.py │ ├── core │ ├── __init__.py │ ├── command_metadata.py │ ├── authenticator.py │ ├── config_manager.py │ └── connection.py │ └── __main__.py ├── demo ├── demo.gif ├── install.gif ├── get-users.gif ├── list-all-vm.gif ├── list_command.gif ├── change_welcome_message.gif ├── delete_snapshots_with_verbose.gif ├── list_command.tape ├── get-users.tape ├── delete_snapshots_with_verbose.tape ├── change_welcome_message.tape ├── demo.tape ├── list-all-vm.tape └── install.tape ├── requirements.txt ├── presentations └── BSidesSeattle2025_NathanBurns_EngineeringESXi.pptx ├── SECURITY.md ├── detections ├── ssh_delete_vm_snapshots.yml ├── api_power_off_vm.yml ├── ssh_enable_ssh.yml ├── ssh_power_off_vm.yml ├── api_disable_autostart.yml ├── ssh_disable_autostart.yml ├── ssh_get_system_info.yml ├── api_delete_vm_snapshots.yml ├── ssh_get_system_users.yml ├── ssh_get_network_information.yml ├── ssh_change_syslog_directory.yml ├── ssh_unrestrict_vib_acceptance_level.yml ├── ssh_disable_firewall.yml ├── ssh_modify_firewall.yml ├── ssh_change_welcome_message.yml ├── ssh_disable_coredump.yml ├── ssh_get_system_storage.yml └── ssh_get_all_vm_ids.yml ├── tests.sh ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /src/esxi_testing_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/demo.gif -------------------------------------------------------------------------------- /demo/install.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/install.gif -------------------------------------------------------------------------------- /demo/get-users.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/get-users.gif -------------------------------------------------------------------------------- /demo/list-all-vm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/list-all-vm.gif -------------------------------------------------------------------------------- /demo/list_command.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/list_command.gif -------------------------------------------------------------------------------- /demo/change_welcome_message.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/change_welcome_message.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | typer~=0.15.1 2 | xmltodict~=0.14.2 3 | requests~=2.32.3 4 | python-dotenv 5 | paramiko~=3.5.0 6 | tabulate~=0.9.0 -------------------------------------------------------------------------------- /demo/delete_snapshots_with_verbose.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/demo/delete_snapshots_with_verbose.gif -------------------------------------------------------------------------------- /presentations/BSidesSeattle2025_NathanBurns_EngineeringESXi.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbinoGazelle/esxi-testing-toolkit/HEAD/presentations/BSidesSeattle2025_NathanBurns_EngineeringESXi.pptx -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | All versions are supported for reporting vulnerabilities. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Open an [issue](https://github.com/AlbinoGazelle/esxi-testing-toolkit/issues) with a label of `vulnerability` to report vulnerabilities. 10 | -------------------------------------------------------------------------------- /demo/list_command.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output demo.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 15 6 | Set Width 2000 7 | Set Height 600 8 | 9 | # Run another command 10 | Type "esxi-testing-toolkit base list --mitre T1562.001" 11 | 12 | Enter 13 | 14 | Sleep 7s 15 | -------------------------------------------------------------------------------- /demo/get-users.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output demo.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 23 6 | Set Width 1700 7 | Set Height 800 8 | 9 | # Run another command 10 | Type "esxi-testing-toolkit host get-system-users --method=api" 11 | 12 | Enter 13 | 14 | Sleep 5s 15 | -------------------------------------------------------------------------------- /demo/delete_snapshots_with_verbose.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output delete_snapshots_with_verbose.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 23 6 | Set Width 1700 7 | Set Height 800 8 | 9 | # Run another command 10 | Type "esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --verbose" 11 | 12 | Enter 13 | 14 | Sleep 5s 15 | -------------------------------------------------------------------------------- /demo/change_welcome_message.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output demo.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 23 6 | Set Width 1700 7 | Set Height 800 8 | 9 | # Run another command 10 | Type "esxi-testing-toolkit host change-welcome-message --method=SSH --utility=esxcli --message='hello world!'" 11 | 12 | Enter 13 | 14 | Sleep 7s 15 | -------------------------------------------------------------------------------- /demo/demo.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output demo.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 23 6 | Set Width 1700 7 | Set Height 800 8 | 9 | # Run another command 10 | Type "esxi-testing-toolkit host get-all-vm-ids --method=api" 11 | 12 | Enter 13 | 14 | Sleep 5s 15 | 16 | Type "esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --method=ssh" 17 | 18 | Enter 19 | 20 | Sleep 7s 21 | -------------------------------------------------------------------------------- /demo/list-all-vm.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output list-all-vim.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 23 6 | Set Width 1700 7 | Set Height 800 8 | 9 | # Run another command 10 | Type "esxi-testing-toolkit host get-all-vm-ids --method=SSH --utility=esxcli" 11 | 12 | Enter 13 | 14 | Sleep 4s 15 | 16 | Type "esxi-testing-toolkit host get-all-vm-ids --method=SSH --utility=vim-cmd" 17 | 18 | Enter 19 | 20 | Sleep 5s 21 | -------------------------------------------------------------------------------- /demo/install.tape: -------------------------------------------------------------------------------- 1 | # Where should we write the GIF? 2 | Output demo.gif 3 | 4 | # Set up a 1200x600 terminal with 46px font. 5 | Set FontSize 23 6 | Set Width 1700 7 | Set Height 800 8 | 9 | # Run another command 10 | Type "python3 -m pip install --user pipx" 11 | 12 | Enter 13 | 14 | Sleep 1s 15 | 16 | Type "python3 -m pipx ensurepath --force" 17 | 18 | Enter 19 | 20 | Sleep 1s 21 | 22 | Type "pipx install 'git+https://github.com/AlbinoGazelle/esxi-testing-toolkit.git'" 23 | 24 | Enter 25 | 26 | Sleep 5s 27 | 28 | Type "esxi-testing-toolkit --install-completion" 29 | 30 | Enter 31 | 32 | Sleep 5s 33 | -------------------------------------------------------------------------------- /detections/ssh_delete_vm_snapshots.yml: -------------------------------------------------------------------------------- 1 | title: ESXi VM Snapshots Deleted via VIM-CMD 2 | id: c50a1afa-ce52-4ea2-9697-1b6d89e83c9a 3 | status: experimental 4 | description: Detects when vim-cmd is used to delete snapshots for an ESXi virtual machine. 5 | references: 6 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/vim-cmd/ 7 | author: Nathan Burns 8 | date: 2024-11-21 9 | tags: 10 | - attack.t1485 11 | logsource: 12 | category: process_creation 13 | product: linux 14 | detection: 15 | selection: 16 | Image|endswith: '/vim-cmd' 17 | CommandLine|contains: 'vmsvc/snapshot.removeall' 18 | condition: selection 19 | falsepositives: 20 | - Legitimate system administration actions 21 | level: high -------------------------------------------------------------------------------- /detections/api_power_off_vm.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Virtual Machine Powered Off via ESXi API 2 | id: efb66be2-c1f1-4625-93dd-8862d7c2cd80 3 | status: experimental 4 | description: Detects when a Virtual Machine is powered off via the ESXi API 5 | references: 6 | - 'undocumented ESXi API on $HOST_IP/sdk/' 7 | author: Nathan Burns 8 | date: 2025-01-13 9 | tags: 10 | - attack.t1529 11 | logsource: 12 | category: hostd.log 13 | product: linux 14 | detection: 15 | selection_message: 16 | Message|contains: 17 | - 'Poweroff for VM' 18 | condition: all of selection_* 19 | falsepositives: 20 | - Legitimate system administration actions 21 | level: low 22 | testing_command: 'esxi-testing-toolkit vm power-off-vm --vm-id=1 --method=api --verbose' -------------------------------------------------------------------------------- /detections/ssh_enable_ssh.yml: -------------------------------------------------------------------------------- 1 | title: SSH Enable on ESXi Host via VIM-CMD 2 | id: fefed8a8-1cc0-46b1-9e62-5b5b32df9bb7 3 | status: experimental 4 | description: Detects when vim-cmd is used to enable SSH on an ESXi host. 5 | references: 6 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/vim-cmd/ 7 | author: Nathan Burns 8 | date: 2024-11-22 9 | tags: 10 | - attack.t1021.004 11 | logsource: 12 | category: process_creation 13 | product: linux 14 | detection: 15 | selection: 16 | Image|endswith: '/vim-cmd' 17 | CommandLine|contains: 'hostsvc/enable_ssh' 18 | condition: selection 19 | falsepositives: 20 | - Legitimate system administration actions 21 | level: medium 22 | testing_command: 'esxi-testing-toolkit host enable-ssh --method=ssh --verbose' -------------------------------------------------------------------------------- /detections/ssh_power_off_vm.yml: -------------------------------------------------------------------------------- 1 | title: ESXi VM Powered Off via VIM-CMD 2 | id: 7e38eb5c-10b6-4853-bb8f-11163776401d 3 | status: experimental 4 | description: Detects when vim-cmd is used to power off an ESXi virtual machine. 5 | references: 6 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/vim-cmd/ 7 | author: Nathan Burns 8 | date: 2024-11-22 9 | tags: 10 | - attack.t1529 11 | logsource: 12 | category: process_creation 13 | product: linux 14 | detection: 15 | selection: 16 | Image|endswith: '/vim-cmd' 17 | CommandLine|contains: 'vmsvc/power.off' 18 | condition: selection 19 | falsepositives: 20 | - Legitimate system administration actions. 21 | level: medium 22 | testing_command: 'esxi-testing-toolkit host power-off-vm --vm-id=$VM_ID --method=ssh --verbose' -------------------------------------------------------------------------------- /detections/api_disable_autostart.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Autostart Settings Modified via API 2 | id: 9b36af19-d6ac-4cda-b8b0-cb054c6ca3a3 3 | status: experimental 4 | description: Detects when autostart configuration settings are modified via the ESXi API 5 | references: 6 | - 'undocumented ESXi API on $HOST_IP/sdk/' 7 | author: Nathan Burns 8 | date: 2025-01-13 9 | tags: 10 | - attack.t1529 11 | logsource: 12 | category: hostd.log 13 | product: linux 14 | detection: 15 | selection_message: 16 | Message|contains|all: 17 | - 'AutoStartManager' 18 | - 'reconfigure' 19 | - 'Task' 20 | condition: all of selection_* 21 | falsepositives: 22 | - Legitimate system administration actions 23 | level: high 24 | testing_command: 'esxi-testing-toolkit host disable-autostart --method=api --verbose' -------------------------------------------------------------------------------- /tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -x 2 | 3 | # uninstall pipx packages 4 | pipx uninstall-all 5 | 6 | # install testing toolkit 7 | pipx install . 8 | 9 | # execute tests 10 | esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --method=api --verbose 11 | 12 | esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --method=ssh --verbose 13 | 14 | esxi-testing-toolkit vm power-off-vm --vm-id=1 --method=api --verbose 15 | 16 | esxi-testing-toolkit vm power-off-vm --vm-id=1 --method=ssh --verbose 17 | 18 | esxi-testing-toolkit host disable-autostart --method=ssh --verbose 19 | 20 | esxi-testing-toolkit host disable-autostart --method=api --verbose 21 | 22 | esxi-testing-toolkit host enable-ssh --method=ssh --verbose 23 | 24 | esxi-testing-toolkit host enable-ssh --method=api --verbose 25 | 26 | esxi-testing-toolkit host get-all-vm-ids --method=api --verbose -------------------------------------------------------------------------------- /detections/ssh_disable_autostart.yml: -------------------------------------------------------------------------------- 1 | title: ESXi VM Autostart Disabled via VIM-CMD 2 | id: 28f12744-6c57-4498-bfdc-aa727fbece49 3 | status: experimental 4 | description: Detects when vim-cmd is used to disable the autostart of an ESXi virtual machine. 5 | references: 6 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/vim-cmd/ 7 | author: Nathan Burns 8 | date: 2024-11-22 9 | tags: 10 | - attack.t1529 11 | logsource: 12 | category: process_creation 13 | product: linux 14 | detection: 15 | selection_img: 16 | Image|endswith: '/vim-cmd' 17 | CommandLine|contains: 'hostsvc/autostartmanager/enable_autostart' 18 | selection_check: 19 | CommandLine|contains: 20 | - '0' 21 | - 'false' 22 | condition: all of selection_* 23 | falsepositives: 24 | - Legitimate system administration actions. 25 | level: high -------------------------------------------------------------------------------- /detections/ssh_get_system_info.yml: -------------------------------------------------------------------------------- 1 | title: ESXi System Information Discovery via VIM-CMD 2 | id: d1270942-f26a-476c-a391-0fa1d25315a8 3 | status: experimental 4 | description: Detects when vim-cmd is used to discover information of an ESXi host. 5 | references: 6 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/vim-cmd/ 7 | author: Nathan Burns 8 | date: 2024-11-22 9 | tags: 10 | - attack.t1082 11 | logsource: 12 | category: process_creation 13 | product: linux 14 | detection: 15 | selection: 16 | Image|endswith: '/vim-cmd' 17 | CommandLine|contains: 18 | - 'hostsvc/hostsummary' 19 | - 'vmsvc/getallvms' 20 | condition: selection 21 | falsepositives: 22 | - Legitimate system administration actions 23 | level: medium 24 | testing_command: 'esxi-testing-toolkit host get-system-info --method=ssh --utility=vim-cmd' -------------------------------------------------------------------------------- /detections/api_delete_vm_snapshots.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Virtual Machine Snapshots Deleted via API 2 | id: 5531733a-ce9c-4789-b461-7bb797e14007 3 | status: experimental 4 | description: Detects when snapshots for a Virtual Machine are deleted via the ESXi API 5 | references: 6 | - '2025-01-13T19:50:15.542Z verbose hostd[69548] [Originator@6876 sub=Vmsvc.vm:/vmfs/volumes/673fd53b-ef4df1e9-b63e-000c2994365f/test/test.vmx opID=esxui-e243-8ff9 user=root] Removeallsnapshots received. Consolidate: true' 7 | author: Nathan Burns 8 | date: 2025-01-13 9 | tags: 10 | - attack.t1485 11 | logsource: 12 | category: hostd.log 13 | product: linux 14 | detection: 15 | selection_message: 16 | Message|contains: 17 | - 'Removeallsnapshots received' 18 | condition: all of selection_* 19 | falsepositives: 20 | - Legitimate system administration actions 21 | level: low 22 | testing_command: 'esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --method=api --verbose' -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "esxi-testing-toolkit" 3 | version = "0.1.8" 4 | description = "Simple and easy to use CLI tool to test ESXi detections." 5 | readme = "README.md" 6 | dependencies = [ 7 | "typer~=0.15.1", 8 | "xmltodict~=0.14.2", 9 | "requests~=2.32.3", 10 | "python-dotenv", 11 | "paramiko~=3.5.0", 12 | "tabulate~=0.9.0" 13 | ] 14 | authors = [ 15 | {name = "Nathan Burns", email = "contact@nburns.tech"} 16 | ] 17 | maintainers = [ 18 | {name = "Nathan Burns", email = "contact@nburns.tech"} 19 | ] 20 | license = {text = "MIT License"} 21 | keywords = ["security", "esxi", "infosec", "security-tools", "threat-detection", "detection-engineering"] 22 | 23 | [project.urls] 24 | Repository = "https://github.com/AlbinoGazelle/esxi-testing-toolkit.git" 25 | 26 | [project.scripts] 27 | esxi-testing-toolkit = "esxi_testing_toolkit.__main__:main" 28 | 29 | [build-system] 30 | requires = ["setuptools>=61.0"] 31 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /detections/ssh_get_system_users.yml: -------------------------------------------------------------------------------- 1 | title: ESXi System Users Enumerated via ESXCLI 2 | id: 3f974fc2-522f-48fa-97e1-9635084e6fee 3 | status: experimental 4 | description: Detects when ESXCLI is used to enumerate a listing of all ESXi users. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_system.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1082 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: '/esxcli' 18 | CommandLine|contains|all: 19 | - 'system' 20 | - 'account' 21 | - 'list' 22 | condition: all of selection_* 23 | falsepositives: 24 | - Legitimate system administration actions 25 | level: low 26 | testing_command: 'esxi-testing-toolkit host get-system-users --method=ssh --verbose' -------------------------------------------------------------------------------- /detections/ssh_get_network_information.yml: -------------------------------------------------------------------------------- 1 | title: ESXi System Network Information Enumerated via ESXCLI 2 | id: 3e1b0a17-be19-445c-9a44-1bfe4111c3d9 3 | status: experimental 4 | description: Detects when ESXCLI is used to enumerate network information on an ESXi host. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_network.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1082 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: '/esxcli' 18 | selection_cmd: 19 | CommandLine|contains: 20 | - 'network' 21 | - 'ip' 22 | - 'interface' 23 | - 'get' 24 | - 'list' 25 | condition: all of selection_* 26 | falsepositives: 27 | - Legitimate system administration actions 28 | level: low 29 | 30 | testing_command: 'esxi-testing-toolkit host get-network-information --verbose' -------------------------------------------------------------------------------- /detections/ssh_change_syslog_directory.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Syslog Configuration Changed via ESXCLI 2 | id: 70f02885-981a-461a-b476-37494d6dc6ec 3 | status: experimental 4 | description: Detects when ESXCLI is used to change the syslog configuration on an ESXi host. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_system.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1562.001 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: 18 | - '/esxcli' 19 | selection_cmd: 20 | CommandLine|contains|all: 21 | - 'system' 22 | - 'syslog' 23 | - 'config' 24 | - 'set' 25 | condition: all of selection_* 26 | falsepositives: 27 | - Legitimate system administration actions 28 | level: high 29 | testing_command: 'esxi-testing-toolkit host change-syslog-directory --path=/tmp --verbose' 30 | -------------------------------------------------------------------------------- /detections/ssh_unrestrict_vib_acceptance_level.yml: -------------------------------------------------------------------------------- 1 | title: ESXi VIB Acceptance Level Set to Community Supported via ESXCLI 2 | id: c821828c-e57b-48fc-995d-080bd8615c6d 3 | status: experimental 4 | description: Detects when the VIB host acceptance level is set to CommunitySupported. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_software.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1562.001 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: '/esxcli' 18 | CommandLine|contains|all: 19 | - 'software' 20 | - 'acceptance' 21 | - 'set' 22 | - '--level' 23 | - 'CommunitySupported' 24 | condition: all of selection_* 25 | falsepositives: 26 | - Legitimate system administration actions 27 | level: high 28 | testing_command: 'esxi-testing-toolkit host unrestrict-vib-acceptance-level --verbose' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nathan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /detections/ssh_disable_firewall.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Firewall Disabled via ESXCLI 2 | id: 18fba7a0-8f63-49d3-9fc4-6192fe34793c 3 | status: experimental 4 | description: Detects when the ESXi firewall is disabled via esxcli. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_network.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2024-11-20 10 | tags: 11 | - attack.t1562.004 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: '/esxcli' 18 | CommandLine|contains|all: 19 | - 'network' 20 | - 'firewall' 21 | - 'set' 22 | - 'false' 23 | selection_enable_switch: 24 | CommandLine|contains: 25 | - '--enabled' 26 | - '-e' 27 | condition: all of selection_* 28 | falsepositives: 29 | - Legitimate system administration actions 30 | level: high 31 | testing_command: 'esxi-testing-toolkit host disable-firewall --method=ssh --utility=esxcli --verbose' -------------------------------------------------------------------------------- /detections/ssh_modify_firewall.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Firewall Default Action Set to Pass 2 | id: e0f2e697-0352-49a3-b488-11b3dcf1c9fd 3 | status: experimental 4 | description: Detects when the ESXi firewall default action is set to PASS instead of DROP. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_network.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2024-11-20 10 | tags: 11 | - attack.t1562.004 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: '/esxcli' 18 | CommandLine|contains|all: 19 | - 'network' 20 | - 'firewall' 21 | - 'set' 22 | - 'true' 23 | selection_default_action_switch: 24 | CommandLine|contains: 25 | - '--default-action' 26 | - '-d' 27 | condition: all of selection_* 28 | falsepositives: 29 | - Legitimate system administration actions 30 | level: high 31 | testing_command: 'esxi-testing-toolkit host modify-firewall --verbose' -------------------------------------------------------------------------------- /detections/ssh_change_welcome_message.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Welcome Message Changed via ESXCLI 2 | id: 2d79ce52-ff97-4167-887e-af46ccaa3cba 3 | status: experimental 4 | description: Detects when ESXCLI is used to change the welcome message of an ESXi host. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_system.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1491.001 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: 18 | - '/esxcli' 19 | selection_cmd: 20 | CommandLine|contains: 21 | - 'system' 22 | - 'welcomemsg' 23 | - 'set' 24 | selection_cmd_message_switch: 25 | CommandLine|contains: 26 | - '-m' 27 | - '--message' 28 | condition: selection_img and 1 of selection_cmd_* 29 | falsepositives: 30 | - Legitimate system administration actions 31 | level: high 32 | testing_command: 'esxi-testing-toolkit host change-welcome-message --message=$MESSAGE --verbose' -------------------------------------------------------------------------------- /detections/ssh_disable_coredump.yml: -------------------------------------------------------------------------------- 1 | title: ESXi Coredump Generation Disabled via ESXCLI 2 | id: d7f0cb0c-ba04-4b96-b99e-fe634b9d26d0 3 | status: experimental 4 | description: Detects when ESXCLI is used to disable coredump generation on an ESXi host. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_system.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1562.001 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: 18 | - '/esxcli' 19 | selection_cmd: 20 | CommandLine|contains|all: 21 | - 'system' 22 | - 'coredump' 23 | - 'file' 24 | - 'set' 25 | selection_unconfigure_switch: 26 | CommandLine|contains: 27 | - '-u' 28 | - '--unconfigure' 29 | condition: selection_img and selection_cmd and selection_unconfigure_switch 30 | falsepositives: 31 | - Legitimate system administration actions 32 | level: high 33 | testing_command: 'esxi-testing-toolkit host disable-coredump --verbose' 34 | -------------------------------------------------------------------------------- /detections/ssh_get_system_storage.yml: -------------------------------------------------------------------------------- 1 | title: ESXi System Storage Enumerated via ESXCLI 2 | id: a7b42845-4548-42ed-afd9-77946dd9df6e 3 | status: experimental 4 | description: Detects when ESXCLI is used to enumerate storage information on an ESXi host. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_system.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | author: Nathan Burns 9 | date: 2025-01-13 10 | tags: 11 | - attack.t1082 12 | logsource: 13 | category: process_creation 14 | product: linux 15 | detection: 16 | selection_img: 17 | Image|endswith: '/esxcli' 18 | selection_cmd_filesystem: 19 | CommandLine|contains|all: 20 | - 'storage' 21 | - 'filesystem' 22 | - 'list' 23 | selection_cmd_vsan: 24 | CommandLine|contains|all: 25 | - 'vsan' 26 | - 'debug' 27 | - 'vmdk' 28 | - 'list' 29 | selection_cmd_device: 30 | CommandLine|contains|all: 31 | - 'storage' 32 | - 'core' 33 | - 'device' 34 | - 'list' 35 | condition: selection_img and 1 of selection_cmd 36 | falsepositives: 37 | - Legitimate system administration actions 38 | level: low 39 | testing_command: 'esxi-testing-toolkit host get-system-storage --verbose' -------------------------------------------------------------------------------- /detections/ssh_get_all_vm_ids.yml: -------------------------------------------------------------------------------- 1 | title: ESXi VM IDs Enumerated via ESXCLI or VIM-CMD 2 | id: 49a64cfb-0b2b-4176-b487-f85d83374ee9 3 | status: experimental 4 | description: Detects when ESXCLI or VIM-CMD is used to enumerate Virtual Machine IDs. 5 | references: 6 | - https://developer.broadcom.com/xapis/esxcli-command-reference/7.0.0/namespace/esxcli_vm.html 7 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/esxcli/ 8 | - https://lolesxi-project.github.io/LOLESXi/lolesxi/Binaries/vim-cmd 9 | author: Nathan Burns 10 | date: 2025-01-13 11 | tags: 12 | - attack.t1082 13 | logsource: 14 | category: process_creation 15 | product: linux 16 | detection: 17 | selection_img: 18 | Image|endswith: 19 | - '/esxcli' 20 | - '/vim-cmd' 21 | selection_cmd_vim: 22 | CommandLine|contains: 23 | - 'network' 24 | - 'ip' 25 | - 'interface' 26 | - 'get' 27 | - 'list' 28 | selection_cmd_esxcli: 29 | CommandLine|contains: 30 | - 'vmsvc/getallvms' 31 | condition: 1 of selection_img and 1 of selection_cmd_* 32 | falsepositives: 33 | - Legitimate system administration actions 34 | level: low 35 | testing_command: 36 | - 'esxi-testing-toolkit host get-all-vm-ids --method=ssh --utility=esxcli --verbose' 37 | - 'esxi-testing-toolkit host get-all-vm-ids --method=ssh --utility=vim-cmd --verbose' -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/__main__.py: -------------------------------------------------------------------------------- 1 | # import base packages 2 | import typer 3 | import requests 4 | import logging 5 | import os 6 | # import core components 7 | import esxi_testing_toolkit.cli.vm_commands 8 | import esxi_testing_toolkit.cli.host_commands 9 | import esxi_testing_toolkit.cli.base_commands 10 | # used to suppress insecure request warnings from requests 11 | from urllib3.exceptions import InsecureRequestWarning 12 | # suppress insecure request warning for self-signed SSL cert in ESXi hosts. 13 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) 14 | 15 | 16 | # logging boilerplate 17 | logger = logging.getLogger(__name__) 18 | 19 | FORMAT = '%(asctime)s | %(levelname)-5s | %(message)s' 20 | 21 | logging.basicConfig(level=logging.INFO,format=FORMAT) 22 | 23 | # typer boilerplate 24 | app = typer.Typer() 25 | 26 | # add commands from cli/vm_commands into the app 27 | app.add_typer(esxi_testing_toolkit.cli.vm_commands.app, name="vm", help="Perform actions on Virtual Machines.") 28 | app.add_typer(esxi_testing_toolkit.cli.host_commands.app, name="host", help="Performs actions on the ESXi host.") 29 | app.add_typer(esxi_testing_toolkit.cli.base_commands.app, name="base", help="Display information about available tests.") 30 | 31 | # app entrypoint 32 | def main(): 33 | # first let's check if $HOME/.esxi-testing-toolkit exists 34 | if os.path.isdir(f"{os.path.expanduser('~')}/.esxi-testing-toolkit"): 35 | pass 36 | else: 37 | logging.info(f"{os.path.expanduser('~')}/.esxi-testing-toolkit does not exist. Creating it now..") 38 | os.mkdir(f"{os.path.expanduser('~')}/.esxi-testing-toolkit") 39 | # start app 40 | app() 41 | 42 | if __name__ == "__main__": 43 | main() -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/core/command_metadata.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import List, Optional, Dict, Any, Callable 3 | import inspect 4 | 5 | def command_metadata( 6 | tags: Optional[List[str]] = None, 7 | dependencies: Optional[List[str]] = None, 8 | mitre_attack: Optional[List[str]] = None, 9 | **kwargs: Any 10 | ) -> Callable: 11 | """ 12 | Decorator to attach metadata to command functions. 13 | 14 | Args: 15 | tags: List of tags associated with the command 16 | dependencies: List of other commands that must be run before this one 17 | **kwargs: Additional metadata key-value pairs 18 | """ 19 | def decorator(func: Callable) -> Callable: 20 | @wraps(func) 21 | def wrapper(*args, **kwargs): 22 | return func(*args, **kwargs) 23 | 24 | # Store metadata in function attributes 25 | wrapper.__metadata__ = { 26 | 'risk_level': tags or [], 27 | 'dependencies': dependencies or [], 28 | 'mitre_attack': mitre_attack or [], 29 | **kwargs 30 | } 31 | return wrapper 32 | return decorator 33 | 34 | def get_command_metadata(func: Callable) -> Dict[str, Any]: 35 | """ 36 | Retrieve metadata from a decorated function. 37 | 38 | Args: 39 | func: The function to get metadata from 40 | 41 | Returns: 42 | Dictionary containing the function's metadata 43 | """ 44 | return getattr(func, '__metadata__', {}) 45 | 46 | def get_commands_by_module(tag: str, module) -> List[Callable]: 47 | """ 48 | Find all command functions with a specific tag. 49 | 50 | Args: 51 | tag: The tag to search for 52 | module: The module to search in 53 | 54 | Returns: 55 | List of functions that have the specified tag 56 | """ 57 | commands = [] 58 | for name, obj in inspect.getmembers(module): 59 | if inspect.isfunction(obj): 60 | metadata = get_command_metadata(obj) 61 | if tag in metadata.get('module', []): 62 | commands.append(obj) 63 | return commands 64 | 65 | def get_commands_by_mitre(mitre_attack: str, module) -> List[Callable]: 66 | """ 67 | Find all command functions with a specific tag. 68 | 69 | Args: 70 | tag: The tag to search for 71 | module: The module to search in 72 | 73 | Returns: 74 | List of functions that have the specified tag 75 | """ 76 | commands = [] 77 | for name, obj in inspect.getmembers(module): 78 | if inspect.isfunction(obj): 79 | metadata = get_command_metadata(obj) 80 | if mitre_attack in metadata.get('mitre_attack', []): 81 | commands.append(obj) 82 | return commands -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/cli/base_commands.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from esxi_testing_toolkit.core.command_metadata import get_command_metadata, get_commands_by_module, get_commands_by_mitre 3 | import esxi_testing_toolkit.cli.vm_commands 4 | import esxi_testing_toolkit.cli.host_commands 5 | import esxi_testing_toolkit.cli.host_commands 6 | from tabulate import tabulate 7 | 8 | # typer boilerplate 9 | app = typer.Typer() 10 | 11 | 12 | @app.command() 13 | def list(module: str = typer.Option(default=None), all: bool = False, mitre: str = typer.Option(default=None)): 14 | """ 15 | Lists toolkit tests.\n 16 | Examples: \n 17 | List all tests: esxi-testing-toolkit base list --all\n 18 | List all tests that map to T1469: esxi-testing-toolkit base list --mitre T1469\n 19 | List all tests for the VM module: esxi-testing-toolkit base list --module vm\n 20 | List all tests for the Host module: esxi-testing-toolkit base list --module host\n 21 | """ 22 | # define print order and header names 23 | print_order = ['name', 'dependencies', 'module', 'mitre_attack', 'methods', 'risk_level', 'utilities', 'cleanup'] 24 | headers = {'name':'name', 'risk_level': 'risk level', 'dependencies': 'dependencies', 'mitre_attack': 'MITRE ATT&CK', 'method': 'execution methods', 'module': 'module', 'utilities': 'utilities', 'cleanup': 'clean up command'} 25 | data = [] 26 | if module: 27 | if module == 'vm': 28 | commands = get_commands_by_module(tag=module, module=esxi_testing_toolkit.cli.vm_commands) 29 | for func in commands: 30 | command_metadata = get_command_metadata(func=func) 31 | command_metadata.update({'name': func.__name__.replace("_", "-")}) 32 | pretty_data = {k: command_metadata[k] for k in print_order} 33 | data.append(pretty_data) 34 | elif module == 'host': 35 | commands = get_commands_by_module(tag=module, module=esxi_testing_toolkit.cli.host_commands) 36 | for func in commands: 37 | command_metadata = get_command_metadata(func=func) 38 | command_metadata.update({'name': func.__name__.replace("_", "-")}) 39 | pretty_data = {k: command_metadata[k] for k in print_order} 40 | data.append(pretty_data) 41 | elif mitre: 42 | commands = get_commands_by_mitre(mitre_attack=mitre.upper(), module=esxi_testing_toolkit.cli.host_commands) + get_commands_by_mitre(mitre_attack=mitre.upper(), module=esxi_testing_toolkit.cli.vm_commands) 43 | for func in commands: 44 | command_metadata = get_command_metadata(func=func) 45 | command_metadata.update({'name': func.__name__.replace("_", "-")}) 46 | pretty_data = {k: command_metadata[k] for k in print_order} 47 | data.append(pretty_data) 48 | elif all: 49 | # get all commands in both modules 50 | commands = get_commands_by_module(tag='vm', module=esxi_testing_toolkit.cli.vm_commands) + get_commands_by_module(tag='host', module=esxi_testing_toolkit.cli.host_commands) 51 | for func in commands: 52 | command_metadata = get_command_metadata(func=func) 53 | command_metadata.update({'name': func.__name__.replace("_", "-")}) 54 | pretty_data = {k: command_metadata[k] for k in print_order} 55 | data.append(pretty_data) 56 | 57 | print(tabulate(data, headers=headers, tablefmt='github')) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Old version of script 2 | old.py 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 113 | .pdm.toml 114 | .pdm-python 115 | .pdm-build/ 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/core/authenticator.py: -------------------------------------------------------------------------------- 1 | # Authentication Handling 2 | import requests 3 | import logging 4 | 5 | # logger boilerplate 6 | logger = logging.getLogger(__name__) 7 | 8 | class ESXiAuthenticator: 9 | def __init__(self, host, username, password, verify_ssl=False): 10 | """ 11 | Initialize ESXi authenticator with connection parameters 12 | 13 | :param host: ESXi host IP or hostname 14 | :param username: ESXi username 15 | :param password: ESXi password 16 | :param verify_ssl: SSL certificate verification 17 | """ 18 | 19 | self.host = host 20 | self.username = username 21 | self.password = password 22 | self.verify_ssl = verify_ssl 23 | 24 | self.base_url = f"https://{host}/sdk/" 25 | self.session_cookie = None 26 | self.headers = { 27 | 'Content-Type': 'text/xml', 28 | 'Cookie': 'vmware_client=VMWare', 29 | 'SOAPAction': 'urn:vim25/7.0.3.0' 30 | } 31 | 32 | def _build_login_envelope(self): 33 | """ 34 | Build SOAP login envelope 35 | 36 | :return: SOAP XML login request body 37 | """ 38 | return f""" 40 |
41 | esxui-toolkit 42 |
43 | 44 | 45 | <_this type="SessionManager">ha-sessionmgr 46 | {self.username} 47 | {self.password} 48 | en-US 49 | 50 | 51 |
""" 52 | 53 | def authenticate_api(self): 54 | """ 55 | Authenticate to ESXi host and retrieve session cookie 56 | 57 | :return: Authenticated session headers 58 | :raises AuthenticationError: If authentication fails 59 | """ 60 | try: 61 | response = requests.post( 62 | self.base_url, 63 | data=self._build_login_envelope(), 64 | headers=self.headers, 65 | verify=self.verify_ssl 66 | ) 67 | 68 | response.raise_for_status() # Raise exception for HTTP errors 69 | 70 | if not response.cookies.get("vmware_soap_session"): 71 | logging.error(f'Session cookie not be retrieved from ESXi host. Ensure credentials are correct. Cookies: {response.cookies.items()} Request Response: {response.text}') 72 | raise SystemExit 73 | 74 | # Extract SOAP session cookie 75 | self.session_cookie = response.cookies.get("vmware_soap_session") 76 | 77 | # Update headers with session cookie 78 | self.headers.update({ 79 | 'Cookie': f'vmware_client=VMWare; vmware_soap_session={self.session_cookie}' 80 | }) 81 | 82 | return self.headers 83 | # ESXi host isn't reachable 84 | except requests.exceptions.ConnectionError as e: 85 | logging.error(f'Could not connect to ESXi host. Ensure the system is reachable and try again: {str(e)}') 86 | raise SystemExit() 87 | # ESXi host is reachable but returns HTTP error 88 | except requests.exceptions.HTTPError as e: 89 | logging.error(f'Failed to obtain session cookie for {self.username}. Check credentials and try again: {str(e)}') 90 | raise SystemExit() 91 | # Anything else 92 | except requests.exceptions.RequestException as e: 93 | logging.error(f'Authentication failed. Ensure the ESXi system is reachable and try again {str(e)}') 94 | raise SystemExit() 95 | def authenticate_ssh(self): 96 | """ 97 | Authenticate to ESXi host via SSH. 98 | 99 | :return: SSH session 100 | """ 101 | raise NotImplementedError -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/core/config_manager.py: -------------------------------------------------------------------------------- 1 | # Configuration loading 2 | # via environmental variables or .env files 3 | import os 4 | from dotenv import dotenv_values 5 | import logging 6 | from esxi_testing_toolkit.core.connection import ESXiConnection 7 | from enum import Enum 8 | # logger boilerplate 9 | logger = logging.getLogger(__name__) 10 | 11 | class ExecutionChoice(str, Enum): 12 | """ 13 | Enum for different methods of executing commands. 14 | Required for showing default values in Typer command. 15 | """ 16 | ssh = "ssh" 17 | api = "api" 18 | class UtilityChoice(str, Enum): 19 | """ 20 | Enum for different utilities for SSH commands 21 | """ 22 | esxcli = "esxcli" 23 | vimcmd = "vim-cmd" 24 | 25 | def initialize_api_connection(): 26 | """ 27 | Initializes connection to ESXi API. 28 | 29 | :param: None 30 | :return: ESXiConnection object 31 | """ 32 | # Retrieve secrets from .env or environment variables 33 | secrets = retrieve_secrets() 34 | 35 | # Attempt connection to ESXi host using information in env/.env 36 | logging.info(f'Attempting to connect to {secrets["host"]} as {secrets["username"]}') 37 | connection = ESXiConnection(host=secrets['host'],username=secrets['username'],password=secrets['password'],verify_ssl=False) 38 | connection.connect_api() 39 | # push connection to config manager to allow shared use between modules 40 | return connection 41 | 42 | def initialize_ssh_connection(): 43 | """ 44 | Initializes SSH connection to ESXi host 45 | 46 | :param: None 47 | :return: ESXiConnection Object 48 | """ 49 | # Retrieve secrets from .env or environment variables 50 | secrets = retrieve_secrets() 51 | 52 | # Attempt connection to ESXi host using information in env/.env 53 | logging.info(f'Attempting to connect to {secrets["host"]} as {secrets["username"]} via SSH') 54 | connection = ESXiConnection(host=secrets['host'],username=secrets['username'],password=secrets['password'],verify_ssl=False) 55 | connection.connect_ssh() 56 | return connection 57 | 58 | def retrieve_secrets(): 59 | """ 60 | Retrieves secrets from .env or environmental variables 61 | 62 | :returns: username and password combination 63 | """ 64 | # if we have a .env file try that first 65 | if os.path.exists(f"{os.path.expanduser('~')}/.esxi-testing-toolkit/.env"): 66 | logging.info(f"Reading credentials from {os.path.expanduser('~')}/.esxi-testing-toolkit/.env file") 67 | return retrieve_dotenv() 68 | else: 69 | logging.info(f"No {os.path.expanduser('~')}/.esxi-testing-toolkit/.env file found. Attempting to get secrets from environmental variables.") 70 | return retrieve_env_vars() 71 | 72 | def retrieve_dotenv(): 73 | """ 74 | Retrieves secrets from dotenv file. Assumes esxi-testing-toolkit/.env is the correct location. 75 | 76 | :returns: ESXI_USERNAME and ESXI_PASSWORD values in .env file. 77 | """ 78 | try: 79 | secrets = dotenv_values(f"{os.path.expanduser('~')}/.esxi-testing-toolkit/.env") 80 | logging.info("Retrieved configuration information from .env file") 81 | return {'username': secrets['ESXI_USERNAME'], 'password': secrets['ESXI_PASSWORD'], "host": secrets['ESXI_HOST']} 82 | except Exception as e: 83 | logging.info(f'Cannot retrieve credentials from dotenv file. Attempting environmental variables. Error {e}') 84 | 85 | def retrieve_env_vars(): 86 | """ 87 | Retrieves ESXI_USERNAME and ESXI_PASSWORD from environmental variables. 88 | 89 | :returns: ESXI_USERNAME and ESXI_PASSWORD enviromental variables. 90 | :raises: EnvironmentError if it cannot retrieve environmental variables. 91 | """ 92 | secrets = {} 93 | if 'ESXI_USERNAME' in os.environ and 'ESXI_PASSWORD' in os.environ and 'ESXI_HOST' in os.environ: 94 | secrets.update({'username': os.environ.get('ESXI_USERNAME')}) 95 | secrets.update({'password': os.environ.get('ESXI_PASSWORD')}) 96 | secrets.update({'host': os.environ.get('ESXI_HOST')}) 97 | logging.info("Retrieved configuration information from environmental variables") 98 | return secrets 99 | else: 100 | logging.error(f'Could not retrieve credentials from environmental variables! Please ensure ESXI_USERNAME, ESXI_PASSWORD, and ESXI_HOST environmental variables are set!') 101 | raise SystemExit() -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/cli/vm_commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from esxi_testing_toolkit.core.config_manager import initialize_api_connection, initialize_ssh_connection, ExecutionChoice, UtilityChoice 3 | import typer 4 | from typing_extensions import Annotated 5 | from enum import Enum 6 | from esxi_testing_toolkit.core.command_metadata import command_metadata 7 | 8 | class ExecutionChoice(str, Enum): 9 | """ 10 | Enum for different methods of executing commands. 11 | Required for showing default values in Typer command. 12 | """ 13 | ssh = "ssh" 14 | api = "api" 15 | 16 | class VimCMDUtilityChoice(str, Enum): 17 | """ 18 | Enum for tests that dont require esxcli 19 | """ 20 | vimcmd = "vim-cmd" 21 | # typer boilerplate 22 | app = typer.Typer() 23 | 24 | @command_metadata(module=['vm'], dependencies=['Virtual Machine with Snapshots'], mitre_attack=['T1485'], risk_level=['critical'], methods=['API', 'SSH'], utilities=["vim-cmd"], cleanup = ["none"]) 25 | @app.command() 26 | def delete_vm_snapshots(vm_id: Annotated[str, typer.Option(help="Virtual Machine ID")], utility: Annotated[VimCMDUtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "vim-cmd", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 27 | """ 28 | Deletes all snapshots for a given virtual machine. 29 | Example: esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --method=ssh 30 | """ 31 | if method.value == "api": 32 | connection = initialize_api_connection() 33 | logging.info(f'Sending API request to delete snapshots for vm: {vm_id}') 34 | payload = f"""
esxui-e243
<_this type="VirtualMachine">{vm_id}
""" 35 | request = connection.send_request(payload=payload) 36 | 37 | logging.info(f"Task {request['soapenv:Envelope']['soapenv:Body']['RemoveAllSnapshots_TaskResponse']['returnval']['#text']} successful. All snapshots for VM {vm_id} have been deleted.") 38 | # get hostd logs via SSH here if verbose is enabled 39 | if verbose: 40 | ssh_connection = initialize_ssh_connection() 41 | ssh_connection.retrieve_log('/var/log/hostd.log') 42 | elif method.value == "ssh": 43 | # init SSH connection to ESXi host 44 | connection = initialize_ssh_connection() 45 | # send warning about vim-cmd 46 | logging.warning('vim-cmd does NOT indicate if the snapshot removal was successful or not, only if the VM id exists.') 47 | # send command 48 | command = f'vim-cmd vmsvc/snapshot.removeall {vm_id}' 49 | command_output = connection.send_ssh_command(command) 50 | # if the VM id that was passed doesn't exist, vim-cmd will produce a vim.fault.NotFound error 51 | if "vim.fault.NotFound" in command_output: 52 | logging.error(f'VM Id {vm_id} is not found on the ESXi host.') 53 | raise SystemExit() 54 | else: 55 | logging.info(f'SSH command {command} executed successfully.') 56 | # get shell.log logs here if erbose is enabled 57 | if verbose: 58 | connection.retrieve_log('/var/log/shell.log') 59 | 60 | @command_metadata(module=['vm'], dependencies=['Powered On Virtual Machine'], mitre_attack=['T1529'], risk_level=['medium'], methods=['API', 'SSH'], utilities=["vim-cmd"], cleanup = ["none"]) 61 | @app.command() 62 | def power_off_vm(vm_id: Annotated[str, typer.Option(help="Virtual Machine ID")], utility: Annotated[VimCMDUtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "vim-cmd", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 63 | """ 64 | Powers off a VM. 65 | Example: esxi-testing-toolkit vm power-off-vm --vm-id=1 --method=ssh --verbose 66 | """ 67 | if method.value == "api": 68 | connection = initialize_api_connection() 69 | logging.info(f'Sending API request to power off vm: {vm_id}') 70 | payload = f"""
esxui-d3
<_this type="VirtualMachine">{vm_id}
""" 71 | request = connection.send_request(payload=payload) 72 | logging.info(f"Task {request['soapenv:Envelope']['soapenv:Body']['PowerOffVM_TaskResponse']['returnval']['#text']} successful. VM {vm_id} has been sent a power off signal.") 73 | if verbose: 74 | ssh_connection = initialize_ssh_connection() 75 | ssh_connection.retrieve_log('/var/log/hostd.log') 76 | else: 77 | connection = initialize_ssh_connection() 78 | if utility.value == "vim-cmd": 79 | command = f'vim-cmd vmsvc/power.off {vm_id}' 80 | else: 81 | raise NotImplementedError 82 | command_output = connection.send_ssh_command(command) 83 | if "vim.fault.InvalidPowerState" in command_output: 84 | logging.error(command_output) 85 | logging.error(f'VM id {vm_id} has an invalid state to be shutdown. Examine the output above for remediation.') 86 | else: 87 | logging.info(f'SSH command {command} executed successfully.') 88 | if verbose: 89 | connection.retrieve_log('/var/log/shell.log') 90 | -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/core/connection.py: -------------------------------------------------------------------------------- 1 | # SOAP API and SSH connection management 2 | import paramiko.client 3 | import paramiko.ssh_exception 4 | import logging 5 | import requests 6 | import xmltodict 7 | import paramiko 8 | import time 9 | import xmltodict 10 | import os 11 | from esxi_testing_toolkit.core.authenticator import ESXiAuthenticator 12 | import uuid 13 | 14 | # logger boilerplate 15 | logger = logging.getLogger(__name__) 16 | logging.getLogger('paramiko').setLevel(logging.CRITICAL+1) 17 | class ESXiConnection: 18 | def __init__(self, host, username, password, verify_ssl=False): 19 | """ 20 | Manage ESXi host connections and API interactions 21 | 22 | :param host: ESXi host IP or hostname 23 | :param username: ESXi username 24 | :param password: ESXi password 25 | :param verify_ssl: SSL Certification verification 26 | """ 27 | self.username = username 28 | self.password = password 29 | self.verify_ssh=verify_ssl 30 | self.authenticator = ESXiAuthenticator(host, username=username, password=password, verify_ssl=verify_ssl) 31 | self.base_url = f"https://{host}/sdk/" 32 | self.host = host 33 | self.headers = None 34 | self.ssh_conn = None 35 | self.guid = str(uuid.uuid4()).split('-')[0] 36 | 37 | def connect_api(self): 38 | """ 39 | Establishes connection to ESXi host 40 | 41 | :return: Authenticated connection headers 42 | """ 43 | try: 44 | self.headers = self.authenticator.authenticate_api() 45 | logging.info(f"Successfully authenticated to {self.host} as {self.username}") 46 | except Exception as e: 47 | logging.error(f'Error connecting to {self.host}: {e}') 48 | raise SystemExit() 49 | def connect_ssh(self): 50 | """ 51 | Establishes a connection to the ESXi host via SSH 52 | 53 | :return: SSH session 54 | """ 55 | try: 56 | client = paramiko.client.SSHClient() 57 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 58 | client.connect(self.host, username=self.username, password=self.password) 59 | self.ssh_conn = client 60 | except paramiko.ssh_exception.NoValidConnectionsError as e: 61 | logging.error(f'Error authenticating to {self.host} using SSH. Ensure SSH is enabled and running. {str(e)}') 62 | raise SystemExit() 63 | except paramiko.AuthenticationException as e: 64 | logging.error(f'Error authenticating to {self.host} using provided credentials. Please check supplied credentials and try again. {str(e)}') 65 | raise SystemExit() 66 | except paramiko.SSHException as e: 67 | logging.error(f'Error connecting to {self.host} with SSH. Check to ensure SSH is enabled and running. {str(e)}') 68 | raise SystemExit() 69 | except BlockingIOError as e: 70 | logging.error(f'Error connecting to {self.host} with SSH. Check to ensure SSH is enabled and the host is reachable. {str(e)}') 71 | raise SystemExit() 72 | 73 | 74 | def send_ssh_command(self, command: str): 75 | """ 76 | Sends an SSH command to the ESXi host 77 | 78 | :param: command (str): Command to send. 79 | :return: output (str): Output of command that was executed. 80 | :warning: This function cannot determine if a command was successfully executed or not. You must parse the output and make a determination there. 81 | """ 82 | # need to send command via `invoke_shell` for it to show up in `shell.log` in ESXi system 83 | shell = self.ssh_conn.invoke_shell() 84 | time.sleep(1) 85 | shell.send('echo "executing command with esxi-testing-toolkit"\r') 86 | time.sleep(1) 87 | shell.send(f'{command}\r') 88 | time.sleep(1) 89 | output = shell.recv(-1).decode() 90 | shell.close() 91 | return output 92 | 93 | def retrieve_log(self, log_file: str): 94 | """ 95 | Uses SSH to retrieve log files from host. 96 | 97 | :param: log file full path (/var/log/logfile.log). 98 | :return: True if successful, raises SystemExit if not. 99 | """ 100 | # copy logs to local system using scp or something else 101 | logging.info(f'Retrieving {log_file} from ESXi host.') 102 | sftp = self.ssh_conn.open_sftp() 103 | filename = log_file.split('/')[-1] 104 | local_path = f"{os.path.expanduser('~')}/.esxi-testing-toolkit/{self.guid}_{filename}" 105 | try: 106 | sftp.get(log_file, local_path) 107 | except FileNotFoundError as e: 108 | logging.error(f'{log_file} was not found. Cannot retrieve logs automatically: {e}') 109 | raise SystemExit() 110 | logging.info(f'{local_path} successfully generated.') 111 | sftp.close() 112 | return True 113 | 114 | def send_request(self, payload): 115 | """ 116 | Sends provided payload to ESXi host 117 | 118 | :param: payload 119 | :return: Response from ESXi host 120 | """ 121 | try: 122 | response = requests.post( 123 | self.base_url, 124 | data=payload, 125 | headers=self.headers, 126 | verify=self.verify_ssh 127 | ) 128 | response.raise_for_status() 129 | return xmltodict.parse(response.text) 130 | except requests.exceptions.ConnectionError as e: 131 | logging.error(f'Could not connect to ESXi host. Ensure the system is reachable and try again: {str(e)}') 132 | raise SystemExit() 133 | # ESXi host is reachable but returns HTTP error 134 | except requests.exceptions.HTTPError as e: 135 | logging.error(f'Failed to send payload to {self.host}. Ensure provided details are correct and try again. Error message: {parse_request_error(response.text)}') 136 | raise SystemExit() 137 | # Anything else 138 | except requests.exceptions.RequestException as e: 139 | logging.error(f'Authentication failed. Ensure the ESXi system is reachable and try again {str(e)}') 140 | raise SystemExit() 141 | except Exception as e: 142 | logging.error(f'Failed to send payload: {e}') 143 | 144 | def parse_request_error(request_response: str): 145 | """ 146 | Helper function that parses errors from failed requests. 147 | 148 | :param: request_response (str): Response from failed request as a str. 149 | :return: exact server fault code for failed request 150 | """ 151 | parsed_response = xmltodict.parse(request_response) 152 | return parsed_response['soapenv:Envelope']['soapenv:Body']['soapenv:Fault']['faultstring'] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

3 | 🧰 4 |
ESXi Testing Toolkit 5 |

6 |

7 | Simple and easy to use CLI tool to test ESXi detections. 8 |
9 | About 10 | · 11 | Install 12 | · 13 | Setup 14 | · 15 | Usage 16 | · 17 | Detections 18 | · 19 | Contribute 20 |

21 |

22 | 23 | ![gif of exsi toolkit running delete vm snapshot](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/demo.gif) 24 | 25 | >[!CAUTION] 26 | >ESXi-Testing-Toolkit can modify your ESXi environment to a potentially undesirable state. Please take precautions and only execute it against test environments. 27 | 28 | ## About 29 | 30 | ESXi Testing Toolkit is a command-line utility designed to help security teams test detections deployed in ESXi environments. It takes heavy inspiration from [Atomic Red Team](https://github.com/redcanaryco/atomic-red-team) but provides ESXi-specific enhancements and a simpler user experience 31 | 32 | >[!NOTE] 33 | >Interested in learning more about ESXi Detection Engineering? I wrote a detailed blog post on that topic, you can check it out [here!](https://detect.fyi/vmware-esxi-logging-detection-opportunities-4fb56411ec21) 34 | 35 | ## Features 36 | 37 | ### Diverse Test Suite 38 | ESXi Testing Toolkit supports 21 different tests across 8 different MITRE ATT&CK Techniques. You can get a listing and filter through them with the `esxi-testing-toolkit base list` command. 39 | ![gif of esxi testing toolkit running base list](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/list_command.gif) 40 | 41 | 42 | ### Execution Methods 43 | The testing toolkit currently supports two test execution methods, `SSH` and `API`. 44 | 45 | In this example, I'm changing the ESXi welcome message with ESXCLI over SSH. 46 | ![gif of esxi testing toolkit running change-welcome-message with esxcli via ssh](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/change_welcome_message.gif) 47 | 48 | In this one, I'm enumerating a list of ESXi system users via the ESXi SOAP API. 49 | ![gif of esxi testing toolkit running get-system-users via API](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/get-users.gif) 50 | 51 | ### Multiple Utilities 52 | 53 | If the test can be executed with more than one built in utilities, ESXi Testing Toolkit gives you the option to choose with utility you'd like to use with the `--utility` option. This allows you to test how your detections perform depending on how they're executed. 54 | ![gif of esxi testing toolkit running list-all-vm-ids with esxcli and vim-cmd](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/list-all-vm.gif) 55 | 56 | 57 | ### Verbose Mode 58 | To assist in detection development, the toolkit allows you to provide the `--verbose` command line option to all tests that will retrieve logs from the ESXi host depending on the test. 59 | 60 | ![gif of esxi testing toolkit running delete-all-vm-snapshots with --verbose](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/delete_snapshots_with_verbose.gif) 61 | 62 | ## Tests 63 | Tests are individual implementations of adversarial behavior relating to ESXi systems. In ESXi Testing Toolkit this can range from simply power off a virtual machine all the way to disabling the ESXi firewall. 64 | 65 | Including the name of the test, each contains 8 metadata fields which are described as follows. 66 | 67 | ### Dependencies 68 | This field describes any dependencies that are required prior to test execution. For most tests, this is simply having an ESXi system that is reachable and can be authenticated to. For others, it could mean having at least one running VM or having other infrastructure setup prior to execution. 69 | 70 | ### Modules 71 | ESXi Testing Toolkit is split into two modules, `vm` and `host`. The `vm` module contains tests that interact directly with Virtual Machines such as powering it off or deleting all snapshots. The `host` module impacts the ESXi host itself, including enabling SSH, modifying syslog configuration, enumerating a list of users, and more 72 | 73 | ### MITRE ATT&CK 74 | Self explanatory, this field contains the MITRE ATT&CK technique ID that closest relates to the test. 75 | 76 | ### Methods 77 | This describes the available method of executions available for a test. This can either be `API` or `SSH`, depending on the test. 78 | 79 | ### Risk Level 80 | Each test is assigned a risk level that determines the potential impact of executing a test. For example, discovery related tests that simply enumerate system information have a relatively low risk, while tests that impact the security, integrity or availablily of the system such as deleting snapshots, modifying the firewall, or more have a higher risk level assigned. 81 | 82 | The risk level is ranked as `benign` -> `low` -> `medium` -> `high` -> `critical` 83 | 84 | ### Utilities 85 | For tests that can be executed via `SSH`, a utility value is assigned. This determines which built-in ESXi utility will be used when executing the test. This can either be `vim-cmd`, `esxcli` or a combination of both. 86 | 87 | ### Clean Up Command 88 | Some tests contain clean up commands that can be optionally executed after test execution to restore the system to a pre-test environment. These are noted in this field. 89 | 90 | ## Installation 91 | 92 | ![gif installing esxi-testing-toolkit with pipx](https://github.com/AlbinoGazelle/esxi-testing-toolkit/raw/main/demo/install.gif) 93 | 94 | >[!NOTE] 95 | >I highly recommend using [pipx](https://github.com/pypa/pipx) to install and run the toolkit to prevent dependency conflicts. You can install it with the following commands. 96 | 97 | ``` 98 | python3 -m pip install --user pipx 99 | python3 -m pipx ensurepath 100 | ``` 101 | 102 | ### GitHub (recommended) 103 | 104 | Use pipx to install esxi-testing-toolkit from GitHub 105 | ``` 106 | pipx install "git+https://github.com/AlbinoGazelle/esxi-testing-toolkit.git" 107 | esxi-testing-toolkit --install-completion #optional - adds shell completion 108 | ``` 109 | 110 | Alternatively, you can install with vanilla Python `pip` using 111 | ``` 112 | pip install "git+https://github.com/AlbinoGazelle/esxi-testing-toolkit.git" 113 | esxi-testing-toolkit --install-completion #optional - adds shell completion 114 | ``` 115 | 116 | ### PyPI 117 | Installing from PyPI is similar to GitHub, but you'll miss out on any updates between major releases. 118 | ``` 119 | pipx install esxi-testing-toolkit 120 | ``` 121 | or 122 | ``` 123 | pip install esxi-testing-toolkit 124 | ``` 125 | Restart shell for command completion. 126 | ### Setup 127 | In order to connect to an ESXi system, the toolkit requires credentials for a valid administrator account. This can be provided in two ways. 128 | 129 | #### .env File 130 | The toolkit first checks for valid credentials in the form of a `.env` file located in `$HOME/.esxi-testing-toolkit/.env`. If this folder doesn't exist, it will be created upon the first execution of the toolkit. 131 | 132 | Create the .env file with the following command: 133 | ``` 134 | touch ~/.esxi-testing-toolkit/.env 135 | ``` 136 | Populate the newly created file with three variables `ESXI_USERNAME`, `ESXI_PASSWORD`, and `ESXI_HOST`. 137 | 138 | ``` 139 | # file: ~/.esxi-testing-toolkit/.env 140 | ESXI_USERNAME="USERNAME" 141 | ESXI_PASSWORD="PASSWORD" 142 | ESXI_HOST = "ESXI_SERVER_IP_ADDRESS" 143 | ``` 144 | 145 | #### Environmental Variables 146 | If the toolkit cannot find the `.env` file, it will check the systems environmental variables next. The variable names are `$ESXI_USERNAME`, `$ESXI_PASSWORD`, and `$ESXI_HOST`. 147 | 148 | You can set these variables with the following commands: 149 | 150 | ##### Linux 151 | ``` 152 | export ESXI_USERNAME="ESXI_USERNAME" 153 | export ESXI_PASSWORD="ESXI_PASSWORD" 154 | export ESXI_HOST="ESXI_HOST" 155 | ``` 156 | ##### Windows 157 | ``` 158 | set ESXI_USERNAME="ESXI_USERNAME" 159 | set ESXI_PASSWORD="ESXI_PASSWORD" 160 | set ESXI_HOST="ESXI_HOST" 161 | ``` 162 | 163 | ## Usage 164 | 165 | Using ESXi Testing Toolkit is fairly simple. All you need to do is follow the instructions in [setup](#setup) and then run whatever test you want! The general structure of each test is: 166 | 167 | ``` 168 | esxi-testing-toolkit MODULE COMMAND --option1=X --option2=Y 169 | ``` 170 | 171 | Each command contains a `--help` flag that will tell you which options are required or optional, along with any default values. 172 | 173 | For example, to delete all of the snapshots associated with a VM using `vim-cmd`, I'd execute the following command: 174 | ``` 175 | esxi-testing-toolkit vm delete-vm-snapshots --vm-id=1 --method=ssh 176 | ``` 177 | >[!NOTE] 178 | >I must include `--method=ssh` because the default value for this test is `api`. Specifying the utility with `--utility=vim-cmd` can be omitted as it's the default utility for this test. 179 | 180 | 181 | ## Research & Detections 182 | In order to limit the potential impact of releasing this tool publicly, I've created 18 Sigma detections that detect the techniques that the toolkit emulates. These detections are located in the `/detections` folder and are in the progress of being merged into the main [Sigma](https://github.com/SigmaHQ/sigma) repository. 183 | 184 | 185 | I've also released on a [blog post surrounding detection enginering in ESXi environments](https://medium.com/detect-fyi/vmware-esxi-logging-detection-opportunities-4fb56411ec21), which should help educate defenders on how ESXi logging works and how to detect these techniques in more detail. 186 | 187 | ## Contribute 188 | This project welcomes any and all contributions. 189 | 190 | ## Legal 191 | This tool has the ability to perform destructive actions against ESXi environments. The author has taken necessary steps, including releasing relevant detections that alert on this tools usage, to help prevent abuse by malicious actors. Please ensure you have permissions from system owners before executing this tool. 192 | 193 | All opinions and content represent the author and not their employer. 194 | -------------------------------------------------------------------------------- /src/esxi_testing_toolkit/cli/host_commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typer 3 | from typing_extensions import Annotated 4 | from esxi_testing_toolkit.core.command_metadata import command_metadata 5 | from esxi_testing_toolkit.core.config_manager import initialize_api_connection, initialize_ssh_connection, ExecutionChoice, UtilityChoice 6 | from tabulate import tabulate 7 | import re 8 | # typer boilerplate 9 | app = typer.Typer() 10 | 11 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1529'], risk_level=['high'], methods=['API', 'SSH'], utilities=["vim-cmd"], cleanup = ["enable-autostart"]) 12 | @app.command() 13 | def disable_autostart(method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 14 | """ 15 | Disables Autostart of VMs on the ESXi Host 16 | """ 17 | if method.value == "api": 18 | connection = initialize_api_connection() 19 | logging.info(f'Sending API request to disable VM autostart') 20 | payload = """
esxui-812e
<_this type="HostAutoStartManager">ha-autostart-mgrfalse120120falsepowerOff
""" 21 | connection.send_request(payload=payload) 22 | if verbose: 23 | ssh_connection = initialize_ssh_connection() 24 | ssh_connection.retrieve_log('/var/log/hostd.log') 25 | else: 26 | connection = initialize_ssh_connection() 27 | logging.warning('vim-cmd does NOT indicate if the system already had autostart disabled, only if the command was successful.') 28 | command = f'vim-cmd hostsvc/autostartmanager/enable_autostart false' 29 | command_output = connection.send_ssh_command(command) 30 | if 'Disabled AutoStart' in command_output: 31 | logging.info(f'SSH command {command} executed successfully.') 32 | if verbose: 33 | connection.retrieve_log('/var/log/shell.log') 34 | 35 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1529'], risk_level=['benign'], methods=['API', 'SSH'], utilities=["vim-cmd"], cleanup = ["disable-autostart"]) 36 | @app.command() 37 | def enable_autostart(method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 38 | """ 39 | Enables Autostart of VMs on the ESXi Host 40 | """ 41 | if method.value == "api": 42 | connection = initialize_api_connection() 43 | logging.info(f'Sending API request to enable VM autostart') 44 | payload = """
esxui-812e
<_this type="HostAutoStartManager">ha-autostart-mgrtrue120120falsepowerOff
""" 45 | connection.send_request(payload=payload) 46 | if verbose: 47 | ssh_connection = initialize_ssh_connection() 48 | ssh_connection.retrieve_log('/var/log/hostd.log') 49 | else: 50 | connection = initialize_ssh_connection() 51 | logging.warning('vim-cmd does NOT indicate if the system already had autostart enabled, only if the command was successful.') 52 | command = f'vim-cmd hostsvc/autostartmanager/enable_autostart true' 53 | command_output = connection.send_ssh_command(command) 54 | if 'Enabled AutoStart' in command_output: 55 | logging.info(f'SSH command {command} executed successfully.') 56 | if verbose: 57 | connection.retrieve_log('/var/log/shell.log') 58 | 59 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1021.004'], risk_level=['medium'], methods=['API', 'SSH'], utilities=["vim-cmd"], cleanup = ["disable-ssh"]) 60 | @app.command() 61 | def enable_ssh(method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 62 | """ 63 | Enables SSH access on the ESXi Host 64 | """ 65 | if method.value == "api": 66 | connection = initialize_api_connection() 67 | logging.info('Sending API request to enable SSH access.') 68 | payload = """
esxui-85b0
<_this type="HostServiceSystem">serviceSystemTSM-SSH
""" 69 | request = connection.send_request(payload=payload) 70 | logging.info('Successfully sent request to enable SSH access') 71 | else: 72 | connection = initialize_ssh_connection() 73 | logging.warning('vim-cmd does NOT indicate if following command was successful or not. Verify manually via the ESXi web interface or by attempting an SSH connection.') 74 | command = f'vim-cmd hostsvc/enable_ssh\rvim-cmd hostsvc/start_ssh' 75 | connection.send_ssh_command(command) 76 | if verbose: 77 | connection.retrieve_log('/var/log/shell.log') 78 | 79 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1021.004'], risk_level=['benign'], methods=['API', 'SSH'], utilities=["vim-cmd"], cleanup = ["enable_ssh"]) 80 | @app.command() 81 | def disable_ssh(method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 82 | """ 83 | Disables SSH access on the ESXi Host 84 | """ 85 | if method.value == "api": 86 | connection = initialize_api_connection() 87 | logging.info('Sending API request to disable SSH access.') 88 | payload = """
esxui-85b0
<_this type="HostServiceSystem">serviceSystemTSM-SSH
""" 89 | request = connection.send_request(payload=payload) 90 | logging.info('Successfully sent request to disable SSH access') 91 | else: 92 | connection = initialize_ssh_connection() 93 | logging.warning('vim-cmd does NOT indicate if following command was successful or not. Verify manually via the ESXi web interface or by attempting an SSH connection.') 94 | command = f'vim-cmd hostsvc/disable_ssh\rvim-cmd hostsvc/stop_ssh' 95 | connection.send_ssh_command(command) 96 | if verbose: 97 | connection.retrieve_log('/var/log/shell.log') 98 | 99 | 100 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1082'], risk_level=['low'], methods=['API', 'SSH'], utilities=["vim-cmd", "esxcli"], cleanup = ["none"]) 101 | @app.command() 102 | def get_all_vm_ids(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "vim-cmd", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 103 | """ 104 | Returns a list of VM ids present on the ESXi Host 105 | """ 106 | if method.value == "api": 107 | connection = initialize_api_connection() 108 | logging.info('Sending API request to enumerate all VM ids.') 109 | payload = """
esxui-90za
<_this type="PropertyCollector">ha-property-collectorFolderfalsechildEntityha-folder-vmfalse
""" 110 | # get all VM ids 111 | request = connection.send_request(payload=payload) 112 | vm_ids = [] 113 | # handle edge case where if a system has 1 VM ESXi returns it as a dict instead of list 114 | if type(request["soapenv:Envelope"]["soapenv:Body"]["RetrievePropertiesExResponse"]["returnval"]["objects"]["propSet"]["val"]["ManagedObjectReference"]) != list: 115 | request = [request["soapenv:Envelope"]["soapenv:Body"]["RetrievePropertiesExResponse"]["returnval"]["objects"]["propSet"]["val"]["ManagedObjectReference"]] 116 | else: 117 | request = request["soapenv:Envelope"]["soapenv:Body"]["RetrievePropertiesExResponse"]["returnval"]["objects"]["propSet"]["val"]["ManagedObjectReference"] 118 | # get information on each VM with another API call 119 | for vm_id in request: 120 | id = vm_id["#text"] 121 | payload = f"""
esxui-7770
<_this type="PropertyCollector">ha-property-collectorVirtualMachinefalsenameconfigconfigIssuedatastoreguestruntimesummary.storagesummary.runtimesummary.quickStatslayoutExsnapshoteffectiveRole{id}false
""" 122 | request = connection.send_request(payload=payload) 123 | name = request['soapenv:Envelope']['soapenv:Body']['RetrievePropertiesExResponse']['returnval']['objects']['propSet'][0]['val']['name'] 124 | os_name = request['soapenv:Envelope']['soapenv:Body']['RetrievePropertiesExResponse']['returnval']['objects']['propSet'][0]['val']['guestFullName'] 125 | file = request['soapenv:Envelope']['soapenv:Body']['RetrievePropertiesExResponse']['returnval']['objects']['propSet'][0]['val']['files']['vmPathName'] 126 | vm_ids.append({'id': id, 'name': name, 'os_name': os_name, 'file': file}) 127 | print(tabulate(vm_ids, headers={'id': 'Virtual Machine ID', 'name': 'Virtual Machine Name', 'os_name': 'Operating System', 'file': 'File Path'}, numalign="left")) 128 | 129 | if verbose: 130 | connection = initialize_ssh_connection() 131 | connection.retrieve_log('/var/log/hostd.log') 132 | elif method.value == "ssh": 133 | connection = initialize_ssh_connection() 134 | if utility.value == "vim-cmd": 135 | command = "vim-cmd vmsvc/getallvms" 136 | elif utility.value == "esxcli": 137 | logging.info("ESXCLI can only enumerate running VMs. Use vim-cmd or the API to enumerate both running and non-running.") 138 | command = "esxcli --formatter=csv --format-param=fields==\"WorldID,DisplayName\" vm process list" 139 | output = connection.send_ssh_command(command) 140 | print(output) 141 | if verbose: 142 | connection.retrieve_log('/var/log/shell.log') 143 | 144 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1082'], risk_level=['low'], methods=['SSH'], utilities=["vim-cmd", "esxcli"], cleanup = ["none"]) 145 | @app.command() 146 | def get_system_info(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "vim-cmd", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 147 | """ 148 | Displays information on the ESXi Host. 149 | """ 150 | logging.info("Retrieving information on the ESXi host. Information may vary depending on execution method and utility.") 151 | if method.value == "ssh": 152 | connection = initialize_ssh_connection() 153 | if utility.value == "esxcli": 154 | command = "esxcli system version get\resxcli system hostname get" 155 | print(connection.send_ssh_command(command)) 156 | elif utility.value == "vim-cmd": 157 | command = "vim-cmd hostsvc/hostsummary" 158 | print(connection.send_ssh_command(command)) 159 | if verbose: 160 | connection.retrieve_log('/var/log/shell.log') 161 | else: 162 | logging.error(f"Retrieving system information via {method.value} is not yet supported!") 163 | raise SystemExit() 164 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1082'], risk_level=['low'], methods=['API', 'SSH'], utilities=["esxcli"], cleanup = ["none"]) 165 | @app.command() 166 | def get_system_users(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "api", verbose: bool = False): 167 | """ 168 | Displays a list of users on the ESXi Host. 169 | """ 170 | if method.value == "api": 171 | logging.info("Using ESXi API to retrieve a list of ESXi users.") 172 | connection = initialize_api_connection() 173 | payload = """
esxui-a178
<_this type="UserDirectory">ha-user-directoryfalsetruetrue
""" 174 | response = connection.send_request(payload=payload) 175 | users = response['soapenv:Envelope']['soapenv:Body']['RetrieveUserGroupsResponse']['returnval'] 176 | for user in users: 177 | del user['@xsi:type'] 178 | print(tabulate(users, headers={'principal': 'username', 'fullName': 'name', 'shellAccess': 'Shell Access', 'id': 'id', 'group': 'group'})) 179 | if verbose: 180 | connection = initialize_ssh_connection() 181 | connection.retrieve_log('/var/log/hostd.log') 182 | elif method.value == "ssh": 183 | connection = initialize_ssh_connection() 184 | if utility.value == "esxcli": 185 | command = "esxcli system account list" 186 | else: 187 | logging.error(f"Retrieving the list of ESXi users via {utility.value} is not yet supported!") 188 | raise SystemExit() 189 | print(connection.send_ssh_command(command)) 190 | if verbose: 191 | connection.retrieve_log('/var/log/shell.log') 192 | 193 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1082'], risk_level=['low'], methods=['SSH'], utilities=["esxcli"], cleanup = ["none"]) 194 | @app.command() 195 | def get_system_storage(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 196 | if method.value == "api": 197 | logging.error(f'Getting system storage information with {method.value} is not yet supported!') 198 | raise SystemExit() 199 | elif method.value == "ssh": 200 | if utility.value != "esxcli": 201 | logging.error(f"Getting system storage with {method.value} is not yet supported!") 202 | raise SystemExit() 203 | else: 204 | connection = initialize_ssh_connection() 205 | command = "esxcli storage filesystem list\resxcli vsan debug vmdk list\resxcli --formatter=csv --format-param=fields==\"Device,DevfsPath\" storage core device list" 206 | output = connection.send_ssh_command(command=command) 207 | logging.info(output) 208 | if verbose: 209 | connection.retrieve_log('/var/log/shell.log') 210 | 211 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1491.001'], risk_level=['low'], methods=['SSH'], utilities=["esxcli"], cleanup = ["none"]) 212 | @app.command() 213 | def change_welcome_message(message: Annotated[str, typer.Option(case_sensitive=False, help="Message to update ESXi Welcome Screen to.")], utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 214 | if method.value == "api": 215 | logging.error(f'Getting system storage information with {method.value} is not yet supported!') 216 | raise SystemExit() 217 | elif method.value == "ssh": 218 | if utility.value != "esxcli": 219 | logging.error(f"Changing ESXi welcome message with {method.value} is not yet supported!") 220 | raise SystemExit() 221 | else: 222 | connection = initialize_ssh_connection() 223 | command = f"esxcli system welcomemsg set -m \"{message}\"" 224 | logging.info(f"Sending {command} to change welcome message to {message}") 225 | logging.info(connection.send_ssh_command(command=command)) 226 | 227 | if verbose: 228 | connection.retrieve_log('/var/log/shell.log') 229 | 230 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1491.001'], risk_level=['critical'], methods=['SSH'], utilities=["esxcli"], cleanup = ["enable-firewall"]) 231 | @app.command() 232 | def disable_firewall(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 233 | """ 234 | Disables the ESXi firewall 235 | """ 236 | if method.value == "api": 237 | logging.error(f"Disabling the ESXi firewall with {method.value} is not yet supported!") 238 | raise NotImplementedError() 239 | elif method.value == "ssh": 240 | if utility.value != "esxcli": 241 | logging.error(f"Disabling the ESXi firewall with {utility.value} is not yet supported!") 242 | raise NotImplementedError() 243 | else: 244 | connection = initialize_ssh_connection() 245 | command = "esxcli network firewall set --enabled false" 246 | logging.info(connection.send_ssh_command(command=command)) 247 | 248 | if verbose: 249 | connection.retrieve_log('/var/log/shell.log') 250 | 251 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1491.001'], risk_level=['benign'], methods=['SSH'], utilities=["esxcli"], cleanup = ["disable-firewall"]) 252 | @app.command() 253 | def enable_firewall(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 254 | """ 255 | Enables the ESXi firewall. 256 | """ 257 | if method.value == "api": 258 | logging.error(f"Enabling the ESXi firewall with {method.value} is not yet supported!") 259 | raise NotImplementedError() 260 | elif method.value == "ssh": 261 | if utility.value != "esxcli": 262 | logging.error(f"Enabling the ESXi firewall with {utility.value} is not supported!") 263 | raise NotImplementedError() 264 | else: 265 | connection = initialize_ssh_connection() 266 | command = "esxcli network firewall set --enabled true" 267 | logging.info(connection.send_ssh_command(command=command)) 268 | 269 | if verbose: 270 | connection.retrieve_log('/var/log/shell.log') 271 | 272 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.004'], risk_level=['critical'], methods=['SSH'], utilities=["esxcli"], cleanup = ["unmodify-firewall"]) 273 | @app.command() 274 | def modify_firewall(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 275 | """ 276 | Modifies the ESXi firewall to allow all traffic. 277 | """ 278 | if method.value == "api": 279 | logging.error(f"Modifying the ESXi firewall with {method.value} is not yet supported!") 280 | raise NotImplementedError() 281 | elif method.value == "ssh": 282 | if utility.value != "esxcli": 283 | logging.error(f"Modifying the ESXi firewall with {utility.value} is not supported!") 284 | raise NotImplementedError() 285 | else: 286 | connection = initialize_ssh_connection() 287 | command = "esxcli network firewall set --default-action true" 288 | logging.info(connection.send_ssh_command(command=command)) 289 | 290 | if verbose: 291 | connection.retrieve_log('/var/log/shell.log') 292 | 293 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.004'], risk_level=['benign'], methods=['SSH'], utilities=["esxcli"], cleanup = ["modify-firewall"]) 294 | @app.command() 295 | def unmodify_firewall(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 296 | """ 297 | Unmodifies the ESXi firewall to block matching traffic. 298 | """ 299 | if method.value == "api": 300 | logging.error(f"Unmodifying the ESXi firewall with {method.value} is not yet supported!") 301 | raise NotImplementedError() 302 | elif method.value == "ssh": 303 | if utility.value != "esxcli": 304 | logging.error(f"Unmodifying the ESXi firewall with {utility.value} is not supported!") 305 | raise NotImplementedError() 306 | else: 307 | connection = initialize_ssh_connection() 308 | command = "esxcli network firewall set --default-action false" 309 | logging.info(connection.send_ssh_command(command=command)) 310 | 311 | if verbose: 312 | connection.retrieve_log('/var/log/shell.log') 313 | 314 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.001'], risk_level=['critical'], methods=['SSH'], utilities=["esxcli"], cleanup = ["enable-coredump"]) 315 | @app.command() 316 | def disable_coredump(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 317 | """ 318 | Disables coredump generation on the ESXi host. 319 | """ 320 | if method.value == "api": 321 | logging.error(f"Disabling coredump generation with {method.value} is not yet supported!") 322 | raise NotImplementedError() 323 | elif method.value == "ssh": 324 | if utility.value != "esxcli": 325 | logging.error(f"Disabling coredump generation with {utility.value} is not supported!") 326 | raise NotImplementedError() 327 | else: 328 | connection = initialize_ssh_connection() 329 | command = "esxcli system coredump file set --unconfigure" 330 | logging.info(connection.send_ssh_command(command=command)) 331 | 332 | if verbose: 333 | connection.retrieve_log('/var/log/shell.log') 334 | 335 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.001'], risk_level=['benign'], methods=['SSH'], utilities=["esxcli"], cleanup = ["disable-coredump"]) 336 | @app.command() 337 | def enable_coredump(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 338 | """ 339 | Enables coredump generation on the ESXi host. 340 | """ 341 | if method.value == "api": 342 | logging.error(f"Enabling coredump generation with {method.value} is not yet supported!") 343 | raise NotImplementedError() 344 | elif method.value == "ssh": 345 | if utility.value != "esxcli": 346 | logging.error(f"Enabling coredump generation with {utility.value} is not supported!") 347 | raise NotImplementedError() 348 | else: 349 | connection = initialize_ssh_connection() 350 | get_path = "esxcli system coredump file list" 351 | output = connection.send_ssh_command(command=get_path) 352 | 353 | try: 354 | path = re.search(r".*.dumpfile", output).group() 355 | except AttributeError: 356 | logging.error('Could not retrieve path to old coredump filepath. Try enabling coredump manually!') 357 | raise SystemExit() 358 | 359 | enable_command = f"esxcli system coredump file set -p={path}" 360 | 361 | logging.info(connection.send_ssh_command(enable_command)) 362 | 363 | if verbose: 364 | connection.retrieve_log('/var/log/shell.log') 365 | 366 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1590.005'], risk_level=['low'], methods=['SSH'], utilities=["esxcli"], cleanup = ["none"]) 367 | @app.command() 368 | def get_network_information(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 369 | """ 370 | Gets networking information of the ESXi host. 371 | """ 372 | if method.value == "api": 373 | logging.error(f"Getting network information with {method.value} is not yet supported!") 374 | raise NotImplementedError() 375 | elif method.value == "ssh": 376 | if utility.value != "esxcli": 377 | logging.error(f"Getting network information with {utility.value} is not supported!") 378 | raise NotImplementedError() 379 | else: 380 | connection = initialize_ssh_connection() 381 | command = "esxcli --formatter=csv network ip interface ipv4 get" 382 | logging.info(connection.send_ssh_command(command)) 383 | 384 | if verbose: 385 | connection.retrieve_log('/var/log/shell.log') 386 | 387 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.001'], risk_level=['critical'], methods=['SSH'], utilities=["esxcli"], cleanup = ["restrict_vib_acceptance_level"]) 388 | @app.command() 389 | def unrestrict_vib_acceptance_level(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 390 | """ 391 | Sets the VIB acceptance level to CommunitySupported 392 | """ 393 | if method.value == "api": 394 | logging.error(f"Getting network information with {method.value} is not yet supported!") 395 | raise NotImplementedError() 396 | elif method.value == "ssh": 397 | if utility.value != "esxcli": 398 | logging.error(f"Getting network information with {utility.value} is not supported!") 399 | raise NotImplementedError() 400 | else: 401 | connection = initialize_ssh_connection() 402 | command = "esxcli software acceptance set --level CommunitySupported" 403 | logging.info(connection.send_ssh_command(command)) 404 | 405 | if verbose: 406 | connection.retrieve_log('/var/log/shell.log') 407 | 408 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.001'], risk_level=['benign'], methods=['SSH'], utilities=["esxcli"], cleanup = ["unrestrict_vib_acceptance_level"]) 409 | @app.command() 410 | def restrict_vib_acceptance_level(utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 411 | """ 412 | Sets the VIB acceptance level to PartnerSupported (default ESXi setting) 413 | """ 414 | if method.value == "api": 415 | logging.error(f"Getting network information with {method.value} is not yet supported!") 416 | raise NotImplementedError() 417 | elif method.value == "ssh": 418 | if utility.value != "esxcli": 419 | logging.error(f"Getting network information with {utility.value} is not supported!") 420 | raise NotImplementedError() 421 | else: 422 | connection = initialize_ssh_connection() 423 | command = "esxcli software acceptance set --level PartnerSupported" 424 | logging.info(connection.send_ssh_command(command)) 425 | 426 | if verbose: 427 | connection.retrieve_log('/var/log/shell.log') 428 | 429 | @command_metadata(module=['host'], dependencies=['Reachable ESXi System'], mitre_attack=['T1562.001'], risk_level=['critical'], methods=['SSH'], utilities=["esxcli"], cleanup = ["change-syslog-directory"]) 430 | @app.command() 431 | def change_syslog_directory(path: Annotated[str, typer.Option(help="Path to change to the new Syslog directory.")], utility: Annotated[UtilityChoice, typer.Option(help="Utility to use when executing. Ignored for non-SSH executions.")] = "esxcli", method: Annotated[ExecutionChoice, typer.Option(case_sensitive=False, help="Method of test execution.", show_choices=True)] = "ssh", verbose: bool = False): 432 | """ 433 | Changes the Syslog directory to a supplied path. 434 | """ 435 | if method.value == "api": 436 | logging.error(f"Setting the syslog directory with {method.value} is not yet supported!") 437 | raise NotImplementedError() 438 | elif method.value == "ssh": 439 | if utility.value != "esxcli": 440 | logging.error(f"Setting the syslog directory {utility.value} is not supported!") 441 | raise NotImplementedError() 442 | else: 443 | connection = initialize_ssh_connection() 444 | command = f"esxcli system syslog config set --logdir={path}" 445 | logging.info(connection.send_ssh_command(command)) 446 | 447 | if verbose: 448 | connection.retrieve_log('/var/log/shell.log') 449 | --------------------------------------------------------------------------------