├── 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 |
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"""<_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"""<_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 | 
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 | 
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 | 
47 |
48 | In this one, I'm enumerating a list of ESXi system users via the ESXi SOAP API.
49 | 
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 | 
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 | 
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 | 
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 = """<_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 = """<_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 = """<_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 = """<_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 = """<_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"""<_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 = """<_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 |
--------------------------------------------------------------------------------