├── .gitignore ├── LICENSE ├── README.md ├── assets └── terminal.png ├── docs ├── README.md ├── development.md ├── kb.md ├── release.md ├── sample │ └── krb5.conf └── usage.md ├── evil_winrm_py ├── __init__.py ├── _ps │ ├── __init__.py │ ├── fetch.ps1 │ └── send.ps1 └── evil_winrm_py.py ├── requirements-dev.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | # Test Files 177 | test_*.py 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Aditya Telange 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # evil-winrm-py 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/evil-winrm-py)](https://pypi.org/project/evil-winrm-py/) 4 | ![Python](https://img.shields.io/badge/python-3.9+-blue.svg) 5 | ![License](https://img.shields.io/github/license/adityatelange/evil-winrm-py) 6 | 7 | `evil-winrm-py` is a python-based tool for executing commands on remote Windows machines using the WinRM (Windows Remote Management) protocol. It provides an interactive shell with enhanced features like file upload/download, command history, and colorized output. It supports various authentication methods including NTLM, Pass-the-Hash, Certificate, and Kerberos. 8 | 9 | ![](https://raw.githubusercontent.com/adityatelange/evil-winrm-py/refs/tags/v1.0.0/assets/terminal.png) 10 | 11 | > [!NOTE] 12 | > This tool is designed strictly for educational, ethical use, and authorized penetration testing. Always ensure you have explicit authorization before accessing any system. Unauthorized access or misuse of this tool is both illegal and unethical. 13 | 14 | ## Motivation 15 | 16 | The original evil-winrm is written in Ruby, which can be a hurdle for some users. Rewriting it in Python makes it more accessible and easier to use, while also allowing us to leverage Python’s rich ecosystem for added features and flexibility. 17 | 18 | I also wanted to learn more about winrm and its internals, so this project will also serve as a learning experience for me. 19 | 20 | ## Features 21 | 22 | - Execute commands on remote Windows machines via an interactive shell. 23 | - Support for NTLM authentication. 24 | - Support for Pass-the-Hash authentication. 25 | - Support for Certificate authentication. 26 | - Support for Kerberos authentication with SPN (Service Principal Name) prefix and hostname options. 27 | - Support for SSL to secure communication with the remote host. 28 | - Support for custom WSMan URIs. 29 | - Download files from the remote host to the local machine. 30 | - Upload files from the local machine to the remote host. 31 | - Auto-complete local and remote file paths with tab completion. 32 | - Support for paths with spaces using quotes. 33 | - Enable logging and debugging for better traceability. 34 | - Navigate command history using up/down arrow keys. 35 | - Display colorized output for improved readability.. 36 | - Lightweight and Python-based for ease of use. 37 | - Keyboard Interrupt (Ctrl+C) support to terminate long-running commands gracefully. 38 | 39 | Detailed documentation can be found in the [docs](docs/) directory. 40 | 41 | ## Installation (Windows/Linux) 42 | 43 | #### Installation of Kerberos prerequisites on Linux 44 | 45 | ```bash 46 | sudo apt install gcc python3-dev libkrb5-dev krb5-pkinit 47 | # Optional: krb5-user 48 | ``` 49 | 50 | ### Install `evil-winrm-py` 51 | 52 | > You may use [pipx](https://pipx.pypa.io/stable/) or [uv](https://docs.astral.sh/uv/) instead of pip to install evil-winrm-py. `pipx`/`uv` is a tool to install and run Python applications in isolated environments, which helps prevent dependency conflicts by keeping the tool's dependencies separate from your system's Python packages. 53 | 54 | ```bash 55 | pip install evil-winrm-py 56 | pip install evil-winrm-py[kerberos] # for kerberos support on Linux 57 | ``` 58 | 59 | or if you want to install with latest commit from the main branch you can do so by cloning the repository and installing it with `pip`/`pipx`/`uv`: 60 | 61 | ```bash 62 | git clone https://github.com/adityatelange/evil-winrm-py 63 | cd evil-winrm-py 64 | pip install . 65 | ``` 66 | 67 | ### Update 68 | 69 | ```bash 70 | pip install --upgrade evil-winrm-py 71 | ``` 72 | 73 | ### Uninstall 74 | 75 | ```bash 76 | pip uninstall evil-winrm-py 77 | ``` 78 | 79 | ## Usage 80 | 81 | Details on how to use `evil-winrm-py` can be found in the [Usage Guide](./docs/usage.md). 82 | 83 | ```bash 84 | usage: evil-winrm-py [-h] -i IP -u USER [-p PASSWORD] [-H HASH] [--no-pass] [-k] [--spn-prefix SPN_PREFIX] [--spn-hostname SPN_HOSTNAME] [--priv-key-pem PRIV_KEY_PEM] 85 | [--cert-pem CERT_PEM] [--uri URI] [--ssl] [--port PORT] [--log] [--debug] [--no-colors] [--version] 86 | 87 | options: 88 | -h, --help show this help message and exit 89 | -i IP, --ip IP remote host IP or hostname 90 | -u USER, --user USER username 91 | -p PASSWORD, --password PASSWORD 92 | password 93 | -H HASH, --hash HASH nthash 94 | --no-pass do not prompt for password 95 | -k, --kerberos use kerberos authentication 96 | --spn-prefix SPN_PREFIX 97 | specify spn prefix 98 | --spn-hostname SPN_HOSTNAME 99 | specify spn hostname 100 | --priv-key-pem PRIV_KEY_PEM 101 | local path to private key PEM file 102 | --cert-pem CERT_PEM local path to certificate PEM file 103 | --uri URI wsman URI (default: /wsman) 104 | --ssl use ssl 105 | --port PORT remote host port (default 5985) 106 | --log log session to file 107 | --debug enable debug logging 108 | --no-colors disable colors 109 | --version show version 110 | ``` 111 | 112 | Example: 113 | 114 | ```bash 115 | evil-winrm-py -i 192.168.1.100 -u Administrator -p P@ssw0rd --ssl 116 | ``` 117 | 118 | ## Menu Commands (inside evil-winrm-py shell) 119 | 120 | ```bash 121 | Menu: 122 | [+] upload - Upload a file 123 | [+] download - Download a file 124 | [+] menu - Show this menu 125 | [+] clear, cls - Clear the screen 126 | [+] exit - Exit the shell 127 | Note: Use absolute paths for upload/download for reliability. 128 | ``` 129 | 130 | ## Credits 131 | 132 | - Original evil-winrm project - https://github.com/Hackplayers/evil-winrm 133 | - PowerShell Remoting Protocol for Python - https://github.com/jborean93/pypsrp 134 | - Prompt Toolkit - https://github.com/prompt-toolkit/python-prompt-toolkit 135 | - tqdm - https://github.com/tqdm/tqdm 136 | - Thanks to [Github Coplilot](https://github.com/features/copilot) and [Google Gemini](https://gemini.google.com/app) for code suggestions and improvements. 137 | 138 | ## Stargazers over time 139 | 140 | [![Stargazers over time](https://starchart.cc/adityatelange/evil-winrm-py.svg?background=%23ffffff00&axis=%23858585&line=%236b63ff)](https://starchart.cc/adityatelange/evil-winrm-py) 141 | -------------------------------------------------------------------------------- /assets/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adityatelange/evil-winrm-py/8287abd60478db3cfdc482fa295da2f8eb5c2507/assets/terminal.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## Quick Links 4 | - [Development Guide](./development.md) 5 | - [Release Guide](./release.md) 6 | - [Knowledge Base](./kb.md) 7 | - [Usage Guide](./usage.md) 8 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Setup 4 | 5 | Download the repository. 6 | 7 | ```bash 8 | git clone https://github.com/adityatelange/evil-winrm-py 9 | cd evil-winrm-py 10 | ``` 11 | 12 | Create a virtual environment (optional but recommended): 13 | 14 | ```bash 15 | python3 -m venv venv 16 | source venv/bin/activate 17 | ``` 18 | 19 | Install the required packages: 20 | 21 | ```bash 22 | pip install -r requirements.txt 23 | ``` 24 | 25 | ## Create a test file 26 | 27 | ```python 28 | # File: test.py 29 | from evil_winrm_py.evil_winrm_py import main 30 | 31 | if __name__ == "__main__": 32 | main() 33 | ``` 34 | 35 | ## Run the test file 36 | 37 | ```bash 38 | python test.py -h 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/kb.md: -------------------------------------------------------------------------------- 1 | # Knowledge Base 2 | 3 | ## Negotiate authentication 4 | 5 | A negotiated, single sign on type of authentication that is the Windows implementation of [Simple and Protected GSSAPI Negotiation Mechanism (SPNEGO)](https://learn.microsoft.com/en-us/windows/win32/winrm/windows-remote-management-glossary). SPNEGO negotiation determines whether authentication is handled by Kerberos or NTLM. Kerberos is the preferred mechanism. Negotiate authentication on Windows-based systems is also called Windows Integrated Authentication. 6 | 7 | Reference: 8 | 9 | - https://learn.microsoft.com/en-us/windows/win32/winrm/windows-remote-management-glossary#:~:text=A%20negotiated%2C%20single,Windows%20Integrated%20Authentication 10 | - https://learn.microsoft.com/en-us/windows/win32/winrm/authentication-for-remote-connections#negotiate-authentication 11 | 12 | ## WinRM - Types of Authentication 13 | 14 | 1. Basic Authentication 15 | 2. Digest Authentication 16 | 3. Kerberos Authentication 17 | 4. Negotiate Authentication 18 | 5. NTLM Authentication 19 | 6. Certificate Authentication 20 | 7. CredSSP Authentication 21 | 22 | Reference: https://learn.microsoft.com/en-us/windows/win32/winrm/authentication-for-remote-connections 23 | 24 | Enable Auth 25 | 26 | ```powershell 27 | Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true 28 | ``` 29 | 30 | ## Configure WinRM HTTPS with self-signed certificate 31 | 32 | ```powershell 33 | # https://gist.github.com/gregjhogan/dbe0bfa277d450c049e0bbdac6142eed 34 | $cert = New-SelfSignedCertificate -CertstoreLocation Cert:\LocalMachine\My -DnsName $env:COMPUTERNAME 35 | Enable-PSRemoting -SkipNetworkProfileCheck -Force 36 | New-Item -Path WSMan:\LocalHost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $cert.Thumbprint –Force 37 | 38 | New-NetFirewallRule -DisplayName "Windows Remote Management (HTTPS-In)" -Name "Windows Remote Management (HTTPS-In)" -Profile Any -LocalPort 5986 -Protocol TCP 39 | ``` 40 | 41 | Reference: https://learn.microsoft.com/en-us/windows/win32/winrm/installation-and-configuration-for-windows-remote-management 42 | 43 | - **Get the current WinRM configuration** 44 | 45 | ```powershell 46 | winrm get winrm/config 47 | ``` 48 | 49 | - **Enumerate WinRM listeners** 50 | 51 | ```powershell 52 | winrm enumerate winrm/config/listener 53 | ``` 54 | 55 | ## Configure WinRM Certificate Authentication 56 | 57 | Certificate authentication is a method of authenticating to a remote computer using a certificate. The certificate must be installed on the remote computer and the client must have access to the private key of the certificate. 58 | 59 | **Enable Certificate Authentication** 60 | 61 | ```powershell 62 | Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true 63 | ``` 64 | 65 | **Generate a certificate using PowerShell** 66 | 67 | ```powershell 68 | # Set the username to the name of the user the certificate will be mapped to 69 | $username = 'local-user' 70 | 71 | $clientParams = @{ 72 | CertStoreLocation = 'Cert:\CurrentUser\My' 73 | NotAfter = (Get-Date).AddYears(1) 74 | Provider = 'Microsoft Software Key Storage Provider' 75 | Subject = "CN=$username" 76 | TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.2","2.5.29.17={text}upn=$username@localhost") 77 | Type = 'Custom' 78 | } 79 | $cert = New-SelfSignedCertificate @clientParams 80 | $certKeyName = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey( 81 | $cert).Key.UniqueName 82 | 83 | # Exports the public cert.pem and key cert.pfx 84 | Set-Content -Path "cert.pem" -Value @( 85 | "-----BEGIN CERTIFICATE-----" 86 | [Convert]::ToBase64String($cert.RawData) -replace ".{64}", "$&`n" 87 | "-----END CERTIFICATE-----" 88 | ) 89 | $certPfxBytes = $cert.Export('Pfx', '') 90 | [System.IO.File]::WriteAllBytes("$pwd\cert.pfx", $certPfxBytes) 91 | 92 | # Removes the private key and cert from the store after exporting 93 | $keyPath = [System.IO.Path]::Combine($env:AppData, 'Microsoft', 'Crypto', 'Keys', $certKeyName) 94 | Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force 95 | Remove-Item -LiteralPath $keyPath -Force 96 | ``` 97 | 98 | We now have `cert.pem` and `cert.pfx` files. 99 | 100 | **Import Certificate to the Certificate Store** 101 | 102 | ```powershell 103 | $store = Get-Item -LiteralPath Cert:\LocalMachine\Root 104 | $store.Open('ReadWrite') 105 | $store.Add($cert) 106 | $store.Dispose() 107 | ``` 108 | 109 | **Mapping Certificate to a Local Account** 110 | 111 | ```powershell 112 | # Will prompt for the password of the user. 113 | $credential = Get-Credential local-user 114 | 115 | $certChain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() 116 | [void]$certChain.Build($cert) 117 | $caThumbprint = $certChain.ChainElements.Certificate[-1].Thumbprint 118 | 119 | $certMapping = @{ 120 | Path = 'WSMan:\localhost\ClientCertificate' 121 | Subject = $cert.GetNameInfo('UpnName', $false) 122 | Issuer = $caThumbprint 123 | Credential = $credential 124 | Force = $true 125 | } 126 | New-Item @certMapping 127 | ``` 128 | 129 | **Convert to PEM format** 130 | 131 | ```bash 132 | openssl pkcs12 \ 133 | -in cert.pfx \ 134 | -nocerts \ 135 | -nodes \ 136 | -passin pass: | 137 | sed -ne '/-BEGIN PRIVATE KEY-/,/-END PRIVATE KEY-/p' > priv-key.pem 138 | ``` 139 | 140 | User `local-user` can now auth using private key `priv_key.pem` and public key `cert.pem`. 141 | 142 | Reference: https://docs.ansible.com/ansible/latest/os_guide/windows_winrm_certificate.html 143 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Releasing a new version on PyPI 2 | 3 | Read More: https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/ 4 | 5 | ## Setup 6 | 7 | ```bash 8 | python3 -m pip install --upgrade build 9 | python3 -m pip install --upgrade twine 10 | ``` 11 | 12 | ## Bump version 13 | 14 | ```bash 15 | # File: evil_winrm_py/__init__.py 16 | __version__ = "X.Y.Z" # update this to the new version 17 | ``` 18 | 19 | ## Build 20 | 21 | ```bash 22 | python3 -m build 23 | ``` 24 | 25 | # Sceenshot 26 | 27 | Creating screenshots for the README using the [freeze](https://github.com/charmbracelet/freeze) tool. 28 | 29 | ```bash 30 | freeze --execute "evil-winrm-py -h" -o assets/terminal.png --padding 5 --wrap 120 --border.radius 4 31 | ``` 32 | 33 | ## Upload 34 | 35 | ```bash 36 | python3 -m twine upload dist/evil_winrm_py-$VERSION* 37 | # example: python3 -m twine upload dist/evil_winrm_py-0.0.2* 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/sample/krb5.conf: -------------------------------------------------------------------------------- 1 | # Sample Kerberos configuration file 2 | # Location: /etc/krb5.conf or //krb5.conf 3 | 4 | [libdefaults] 5 | default_realm = SEVENKINGDOMS.LOCAL 6 | dns_lookup_realm = true 7 | dns_lookup_kdc = true 8 | [realms] 9 | SEVENKINGDOMS.LOCAL = { 10 | kdc = kingslanding.sevenkingdoms.local 11 | admin_server = kingslanding.sevenkingdoms.local 12 | default_domain = sevenkingdoms.local 13 | } 14 | [domain_realm] 15 | .sevenkingdoms.local = SEVENKINGDOMS.LOCAL 16 | sevenkingdoms.local = SEVENKINGDOMS.LOCAL 17 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | ## Authentication Methods 4 | 5 | ### NTLM Authentication 6 | 7 | ```bash 8 | evil-winrm-py -i -u -p 9 | ``` 10 | 11 | ### Kerberos Authentication 12 | 13 | Kerberos authentication supports both password-based and ticket-based authentication. 14 | 15 | #### Password-based Kerberos Authentication 16 | 17 | This will request a Kerberos ticket and store it in memory for the session. 18 | 19 | ```bash 20 | evil-winrm-py -i -u -p --kerberos 21 | ``` 22 | 23 | #### Ticket-based Kerberos Authentication 24 | 25 | If you already have a Kerberos ticket (e.g., from `kinit`), you can use it directly without providing a password. 26 | 27 | Specify the `KRB5CCNAME` and `KRB5_CONFIG` environment variables to point to your Kerberos ticket cache and configuration file, respectively. Sample `krb5.conf` file can be found [here](sample/krb5.conf). 28 | 29 | ```bash 30 | export KRB5CCNAME=/path/to/your/krb5cc_file 31 | export KRB5_CONFIG=/path/to/your/krb5.conf 32 | # By default, the ticket cache is stored in `/tmp/krb5cc_` on Unix-like systems. 33 | # By default, the Kerberos configuration file is located at `/etc/krb5.conf` on Unix-like systems. 34 | ``` 35 | 36 | Then, you can run the command without a password: 37 | 38 | ```bash 39 | evil-winrm-py -i -u --kerberos --no-pass 40 | ``` 41 | 42 | Optionally, you can specify the Kerberos realm and SPN prefix/hostname 43 | If you have a Kerberos ticket, you can use it with the following options: 44 | 45 | ```bash 46 | evil-winrm-py -i -u --kerberos --no-pass --spn-prefix --spn-hostname 47 | ``` 48 | 49 | ### Pass-the-Hash Authentication 50 | 51 | If you have the NTLM hash of the user's password, you can use it for authentication without needing the plaintext password. 52 | 53 | ```bash 54 | evil-winrm-py -i -u -H 55 | ``` 56 | 57 | ### Certificate Authentication 58 | 59 | If you want to use certificate-based authentication, you can specify the private key and certificate files in PEM format. 60 | 61 | ```bash 62 | evil-winrm-py -i -u --priv-key-pem --cert-pem 63 | ``` 64 | 65 | ## Connection Options 66 | 67 | ### Using SSL 68 | 69 | This will use port 5986 for SSL connections by default. If you want to use a different port, you can specify it with [custom port option](#using-custom-port). 70 | 71 | ```bash 72 | evil-winrm-py -i -u -p --ssl 73 | ``` 74 | 75 | ### Using Custom URI 76 | 77 | If the target server has a custom WinRM URI, you can specify it using the `--uri` option. This is useful if the WinRM service is hosted on a different path than the default. 78 | 79 | ```bash 80 | evil-winrm-py -i -u -p --uri 81 | ``` 82 | 83 | ### Using Custom Port 84 | 85 | If the target server is using a non-standard port for WinRM, you can specify the port using the `--port` option. The default port for WinRM over HTTP is 5985, and for HTTPS it is 5986. 86 | 87 | ```bash 88 | evil-winrm-py -i -u -p --port 89 | ``` 90 | 91 | ## Logging and Debugging 92 | 93 | Logging will create a log file in the current directory named `evil-winrm-py.log`. 94 | 95 | ```bash 96 | evil-winrm-py -i -u -p --log 97 | ``` 98 | 99 | ### Debugging 100 | 101 | If Debug mode is enabled, it will also log debug information, including debug messages and stack traces from libraries used by the tool. 102 | 103 | ```bash 104 | evil-winrm-py -i -u -p --debug 105 | ``` 106 | 107 | Debugging for kerberos authentication can be enabled by setting the `KRB5_TRACE` environment variable to a file path where you want to log the Kerberos debug information. 108 | 109 | ```bash 110 | export KRB5_TRACE=/path/to/kerberos_debug.log 111 | ``` 112 | 113 | or you can set it to `stdout` to print the debug information to the console. 114 | 115 | ```bash 116 | export KRB5_TRACE=stdout evil-winrm-py -i -u -p --kerberos 117 | ``` 118 | 119 | ## Interactive Shell 120 | 121 | Once you have successfully authenticated, you will be dropped into an interactive shell where you can execute commands on the remote Windows machine. 122 | 123 | ``` 124 | ▘▜ ▘ 125 | █▌▌▌▌▐ ▄▖▌▌▌▌▛▌▛▘▛▛▌▄▖▛▌▌▌ 126 | ▙▖▚▘▌▐▖ ▚▚▘▌▌▌▌ ▌▌▌ ▙▌▙▌ 127 | ▌ ▄▌ v1.0.0 128 | [*] Connecting to 192.168.1.100 as Administrator 129 | evil-winrm-py PS C:\Users\Administrator\Documents> █ 130 | ``` 131 | 132 | You can execute commands just like you would in a normal Windows command prompt. To exit the interactive shell, type `exit` or press `Ctrl+D`. 133 | If you want to cancel a command that is currently running, you can use `Ctrl+C`. 134 | 135 | ### Menu Commands 136 | 137 | Inside the interactive shell, you can use the following commands: 138 | 139 | ``` 140 | Menu: 141 | [+] upload - Upload a file 142 | [+] download - Download a file 143 | [+] menu - Show this menu 144 | [+] clear, cls - Clear the screen 145 | [+] exit - Exit the shell 146 | ``` 147 | 148 | ### File Transfer 149 | 150 | You can upload and download files using the following commands: 151 | 152 | ``` 153 | evil-winrm-py PS C:\Users\Administrator\Documents> upload 154 | ``` 155 | 156 | ``` 157 | evil-winrm-py PS C:\Users\Administrator\Documents> download 158 | ``` 159 | 160 | ## Additional Options 161 | 162 | ### Using No Colors 163 | 164 | If you want to disable colored output in the terminal, you can use the `--no-colors` option. This is useful for logging or when your terminal does not support colors. 165 | 166 | ```bash 167 | evil-winrm-py -i -u -p --no-colors 168 | ``` 169 | 170 | ### Using No Password Prompt 171 | 172 | ```bash 173 | evil-winrm-py -i -u --no-pass 174 | ``` 175 | -------------------------------------------------------------------------------- /evil_winrm_py/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.0" 2 | -------------------------------------------------------------------------------- /evil_winrm_py/_ps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adityatelange/evil-winrm-py/8287abd60478db3cfdc482fa295da2f8eb5c2507/evil_winrm_py/_ps/__init__.py -------------------------------------------------------------------------------- /evil_winrm_py/_ps/fetch.ps1: -------------------------------------------------------------------------------- 1 | # This script is part of evil-winrm-py project https://github.com/adityatelange/evil-winrm-py 2 | # It reads a file in chunks, converts each chunk to Base64, and outputs metadata and chunks as JSON. 3 | 4 | # --- Define Parameters --- 5 | param ( 6 | [Parameter(Mandatory=$true, Position=0)] 7 | [string]$FilePath 8 | ) 9 | 10 | # --- Configuration --- 11 | $bufferSize = 65536 # Read in 64 KB chunks 12 | 13 | # --- Variables for disposal --- 14 | $fileStream = $null # Initialize as null to handle disposal 15 | $fileInfo = $null # To store file information 16 | 17 | # --- Pre-check and initial metadata --- 18 | if (-not (Test-Path -Path $FilePath -PathType Leaf)) { 19 | [PSCustomObject]@{ 20 | Type = "Error" 21 | Message = "Error: The specified file path does not exist or is not a file: '$FilePath'" 22 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 23 | exit 1 # Exit the script with an error code 24 | } 25 | 26 | try { 27 | $fileInfo = Get-Item -Path $FilePath 28 | $fileSize = $fileInfo.Length # Total file size in bytes 29 | $totalChunks = [System.Math]::Ceiling($fileSize / $bufferSize) # Calculate total chunks, rounding up 30 | $fileHash = (Get-FileHash -Path $FilePath -Algorithm MD5).Hash 31 | 32 | # Output initial file metadata as JSON 33 | [PSCustomObject]@{ 34 | Type = "Metadata" 35 | FilePath = $FilePath 36 | FileSize = $fileSize 37 | ChunkSize = $bufferSize 38 | TotalChunks = $totalChunks 39 | FileHash = $fileHash 40 | FileName = $fileInfo.Name 41 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 42 | 43 | } 44 | catch { 45 | [PSCustomObject]@{ 46 | Type = "Error" 47 | Message = "Error getting file information or outputting metadata: $($_.Exception.Message)" 48 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 49 | exit 1 50 | } 51 | 52 | # --- File Reading and Processing for Base64 Chunks --- 53 | try { 54 | $fileStream = New-Object System.IO.FileStream($FilePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) 55 | $buffer = New-Object byte[] $bufferSize 56 | 57 | $chunkCounter = 0 58 | $totalBytesRead = 0 59 | 60 | while (($bytesRead = $fileStream.Read($buffer, 0, $buffer.Length)) -gt 0) { 61 | $chunkCounter++ # Increment chunk counter 62 | 63 | # 1. Convert bytes to Base64 64 | $chunkBytes = New-Object byte[] $bytesRead 65 | [System.Array]::Copy($buffer, 0, $chunkBytes, 0, $bytesRead) 66 | $base64Chunk = [System.Convert]::ToBase64String($chunkBytes) 67 | 68 | # 2. Output the Base64 chunk as a JSON object 69 | [PSCustomObject]@{ 70 | Type = "Chunk" 71 | ChunkNumber = $chunkCounter 72 | Base64Data = $base64Chunk 73 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 74 | 75 | $totalBytesRead += $bytesRead 76 | } 77 | 78 | } 79 | catch { 80 | [PSCustomObject]@{ 81 | Type = "Error" 82 | Message = "Error during Base64 chunk processing: $($_.Exception.Message)" 83 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 84 | } 85 | finally { 86 | if ($fileStream) { 87 | $fileStream.Dispose() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /evil_winrm_py/_ps/send.ps1: -------------------------------------------------------------------------------- 1 | # This script is part of evil-winrm-py project https://github.com/adityatelange/evil-winrm-py 2 | # It reads a Base64 encoded chunk of bytes, writes it to a file, and optionally appends to an existing file. 3 | # It also calculates the MD5 hash of the file after writing if required. 4 | 5 | # --- Define Parameters --- 6 | param ( 7 | [Parameter(Mandatory=$true, Position=0)] 8 | [string]$Base64Chunk, # The Base64 encoded chunk of bytes 9 | [Parameter(Mandatory=$true, Position=1)] 10 | [int]$ChunkType = 0, # 0 for new file, 1 for appending to existing file 11 | [Parameter(Mandatory=$false, Position=2)] 12 | [string]$TempFilePath, # The temporary file path to write/append the bytes to 13 | [Parameter(Mandatory=$false, Position=3)] 14 | [string]$FilePath, # The file path to write/append the bytes to 15 | [Parameter(Mandatory=$false, Position=4)] 16 | [string]$FileHash # The MD5 hash of the file 17 | ) 18 | 19 | # --- Variables for disposal --- 20 | $fileStream = $null # Initialize as null for safety in finally block 21 | 22 | 23 | # --- Pre-checks --- 24 | # IF chunkPosition is 0 or 3 its a new file 25 | if ($ChunkType -eq 0 -or $ChunkType -eq 3) { 26 | # If this is the first chunk, create a unique temporary file path 27 | $TempFilePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) 28 | # Output initial file metadata as JSON 29 | [PSCustomObject]@{ 30 | Type = "Metadata" 31 | TempFilePath = $TempFilePath 32 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 33 | } 34 | 35 | # --- Main Logic --- 36 | 37 | try { 38 | # Decode the Base64 chunk into bytes 39 | $chunkBytes = [System.Convert]::FromBase64String($Base64Chunk) 40 | 41 | # Open the file in Append mode. 42 | # If the file doesn't exist, it will be created. 43 | # If it exists, new bytes will be added to the end. 44 | $fileStream = New-Object System.IO.FileStream( 45 | $TempFilePath, 46 | [System.IO.FileMode]::Append, # Use Append mode 47 | [System.IO.FileAccess]::Write 48 | ) 49 | 50 | # Write the decoded bytes to the file 51 | # $ChunkSize here is critical and should be the actual length of $chunkBytes for this specific chunk 52 | $fileStream.Write($chunkBytes, 0, $chunkBytes.Length) # Use $chunkBytes.Length for safety 53 | $fileStream.Close() 54 | } 55 | catch { 56 | $FullExceptionMessage = "$($_.Exception.GetType().FullName): $($_.Exception.Message)" 57 | [PSCustomObject]@{ 58 | Type = "Error" 59 | Message = "Error processing chunk or writing to file: $FullExceptionMessage" 60 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 61 | } 62 | finally { 63 | # Ensure the file stream is closed to release the file lock and flush buffers 64 | if ($fileStream) { 65 | $fileStream.Dispose() 66 | } 67 | } 68 | 69 | # --- Calculate checksum --- 70 | # Caculate the MD5 hash of the file after writing if ChunkType is 1 or 3 71 | if ($ChunkType -eq 1 -or $ChunkType -eq 3) { 72 | try { 73 | if ($TempFilePath) { 74 | # If a file hash is provided, verify it 75 | $calculatedHash = (Get-FileHash -Path $TempFilePath -Algorithm MD5).Hash 76 | if ($calculatedHash -eq $FileHash) { 77 | # If the hash matches, move the temporary file to the final destination 78 | [System.IO.File]::Delete($FilePath) 79 | [System.IO.File]::Move($TempFilePath, $FilePath) 80 | 81 | $fileInfo = Get-Item -Path $FilePath 82 | $fileSize = $fileInfo.Length # Total file size in bytes 83 | $fileHash = (Get-FileHash -Path $FilePath -Algorithm MD5).Hash 84 | 85 | # Output initial file metadata as JSON 86 | [PSCustomObject]@{ 87 | Type = "Metadata" 88 | FilePath = $FilePath 89 | FileSize = $fileSize 90 | FileHash = $fileHash 91 | FileName = $fileInfo.Name 92 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 93 | } else { 94 | [PSCustomObject]@{ 95 | Type = "Error" 96 | Message = "File hash mismatch. Expected: $FileHash, Calculated: $calculatedHash" 97 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 98 | } 99 | } else { 100 | [PSCustomObject]@{ 101 | Type = "Error" 102 | Message = "File hash not provided for verification." 103 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 104 | } 105 | } 106 | catch { 107 | $FullExceptionMessage = "$($_.Exception.GetType().FullName): $($_.Exception.Message)" 108 | [PSCustomObject]@{ 109 | Type = "Error" 110 | Message = "Error processing chunk or writing to file: $FullExceptionMessage" 111 | } | ConvertTo-Json -Compress | Write-Output # Pipe JSON to stdout 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /evil_winrm_py/evil_winrm_py.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | evil-winrm-py 6 | https://github.com/adityatelange/evil-winrm-py 7 | """ 8 | 9 | import argparse 10 | import base64 11 | import hashlib 12 | import json 13 | import logging 14 | import os 15 | import re 16 | import shutil 17 | import signal 18 | import sys 19 | import tempfile 20 | import time 21 | import traceback 22 | from importlib import resources 23 | from pathlib import Path 24 | 25 | from prompt_toolkit import PromptSession, prompt 26 | from prompt_toolkit.completion import Completer, Completion 27 | from prompt_toolkit.document import Document 28 | from prompt_toolkit.formatted_text import ANSI 29 | from prompt_toolkit.history import FileHistory 30 | from prompt_toolkit.shortcuts import clear 31 | from pypsrp.complex_objects import PSInvocationState 32 | from pypsrp.exceptions import AuthenticationError, WinRMTransportError, WSManFaultError 33 | from pypsrp.powershell import DEFAULT_CONFIGURATION_NAME, PowerShell, RunspacePool 34 | from pypsrp.wsman import WSMan, requests 35 | from spnego.exceptions import NoCredentialError, OperationNotAvailableError 36 | from tqdm import tqdm 37 | 38 | # check if kerberos is installed 39 | try: 40 | from krb5._exceptions import Krb5Error 41 | 42 | is_kerb_available = True 43 | except ImportError: 44 | is_kerb_available = False 45 | 46 | # If kerberos is not available, define a dummy exception 47 | class Krb5Error(Exception): 48 | pass 49 | 50 | 51 | from evil_winrm_py import __version__ 52 | 53 | # --- Constants --- 54 | LOG_PATH = Path.cwd().joinpath("evil_winrm_py.log") 55 | HISTORY_FILE = Path.home().joinpath(".evil_winrm_py_history") 56 | HISTORY_LENGTH = 1000 57 | MENU_COMMANDS = [ 58 | "upload", 59 | "download", 60 | "menu", 61 | "clear", 62 | "exit", 63 | ] 64 | 65 | # --- Colors --- 66 | # ANSI escape codes for colored output 67 | RESET = "\033[0m" 68 | RED = "\033[31m" 69 | GREEN = "\033[32m" 70 | YELLOW = "\033[33m" 71 | BLUE = "\033[34m" 72 | MAGENTA = "\033[35m" 73 | CYAN = "\033[36m" 74 | BOLD = "\033[1m" 75 | 76 | 77 | # --- Logging Setup --- 78 | log = logging.getLogger(__name__) 79 | 80 | 81 | # --- Helper Functions --- 82 | class DelayedKeyboardInterrupt: 83 | """ 84 | A context manager to delay the handling of a SIGINT (Ctrl+C) signal until 85 | the enclosed block of code has completed execution. 86 | 87 | This is useful for ensuring that critical sections of code are not 88 | interrupted by a keyboard interrupt, while still allowing the signal 89 | to be handled after the block finishes. 90 | """ 91 | 92 | def __enter__(self): 93 | self.signal_received = False 94 | self.old_handler = signal.getsignal(signal.SIGINT) 95 | 96 | def handler(sig, frame): 97 | print(RED + "\n[-] Caught Ctrl+C. Stopping current command..." + RESET) 98 | self.signal_received = (sig, frame) 99 | 100 | signal.signal(signal.SIGINT, handler) 101 | 102 | def __exit__(self, type, value, traceback): 103 | signal.signal(signal.SIGINT, self.old_handler) 104 | if self.signal_received: 105 | # raise the signal after the task is done 106 | self.old_handler(*self.signal_received) 107 | 108 | 109 | def run_ps(r_pool: RunspacePool, command: str) -> tuple[str, list, bool]: 110 | """Runs a PowerShell command and returns the output, streams, and error status.""" 111 | log.info("Executing command: {}".format(command)) 112 | ps = PowerShell(r_pool) 113 | ps.add_cmdlet("Invoke-Expression").add_parameter("Command", command) 114 | ps.add_cmdlet("Out-String").add_parameter("Stream") 115 | ps.invoke() 116 | return "\n".join(ps.output), ps.streams, ps.had_errors 117 | 118 | 119 | def get_prompt(r_pool: RunspacePool) -> str: 120 | """Returns the prompt string for the interactive shell.""" 121 | output, streams, had_errors = run_ps( 122 | r_pool, "$pwd.Path" 123 | ) # Get current working directory 124 | if not had_errors: 125 | return f"{RED}evil-winrm-py{RESET} {YELLOW}{BOLD}PS{RESET} {output}> " 126 | return "PS ?> " # Fallback prompt 127 | 128 | 129 | def show_menu() -> None: 130 | """Displays the help menu for interactive commands.""" 131 | print(BOLD + "\nMenu:" + RESET) 132 | commands = [ 133 | # ("command", "description") 134 | ("upload ", "Upload a file"), 135 | ("download ", "Download a file"), 136 | ("menu", "Show this menu"), 137 | ("clear, cls", "Clear the screen"), 138 | ("exit", "Exit the shell"), 139 | ] 140 | 141 | for command, description in commands: 142 | print(f"{CYAN}[+] {command:<55} - {description}{RESET}") 143 | print("Note: Use absolute paths for upload/download for reliability.\n") 144 | 145 | 146 | def get_directory_and_partial_name(text: str, sep: str) -> tuple[str, str]: 147 | """ 148 | Parses the input text to find the directory prefix and the partial name. 149 | """ 150 | if sep not in ["\\", "/"]: 151 | raise ValueError("Separator must be either '\\' or '/'") 152 | # Find the last unquoted slash or backslash 153 | last_sep_index = text.rfind(sep) 154 | if last_sep_index == -1: 155 | # No separator found, the whole text is the partial name in the current directory 156 | directory_prefix = "" 157 | partial_name = text 158 | else: 159 | split_at = last_sep_index + 1 160 | directory_prefix = text[:split_at] 161 | partial_name = text[split_at:] 162 | return directory_prefix, partial_name 163 | 164 | 165 | def get_remote_path_suggestions( 166 | r_pool: RunspacePool, 167 | directory_prefix: str, 168 | partial_name: str, 169 | dirs_only: bool = False, 170 | ) -> list[str]: 171 | """ 172 | Returns a list of remote path suggestions based on the current directory 173 | and the partial name entered by the user. 174 | """ 175 | 176 | exp = "FullName" 177 | attrs = "" 178 | if not re.match(r"^[a-zA-Z]:", directory_prefix): 179 | # If the path doesn't start with a drive letter, prepend the current directory 180 | pwd, streams, had_errors = run_ps( 181 | r_pool, "$pwd.Path" 182 | ) # Get current working directory 183 | directory_prefix = f"{pwd}\\{directory_prefix}" 184 | exp = "Name" 185 | 186 | if dirs_only: 187 | attrs = "-Attributes Directory" 188 | 189 | command = f'Get-ChildItem -LiteralPath "{directory_prefix}" -Filter "{partial_name}*" {attrs} -Fo | select -Exp {exp}' 190 | ps = PowerShell(r_pool) 191 | ps.add_cmdlet("Invoke-Expression").add_parameter("Command", command) 192 | ps.add_cmdlet("Out-String").add_parameter("Stream") 193 | ps.invoke() 194 | return ps.output 195 | 196 | 197 | def get_local_path_suggestions(directory_prefix: str, partial_name: str) -> list[str]: 198 | """ 199 | Returns a list of local path suggestions based on path entered by the user. 200 | """ 201 | suggestions = [] 202 | 203 | # Get all files and directories in the specified path 204 | try: 205 | entries = Path(directory_prefix).iterdir() 206 | for entry in entries: 207 | if entry.match(f"{partial_name}*"): 208 | if entry.is_dir(): 209 | entry = ( 210 | f"{entry}{os.sep}" # Append a trailing slash for directories 211 | ) 212 | suggestions.append(str(entry)) 213 | except (FileNotFoundError, NotADirectoryError, PermissionError): 214 | pass 215 | finally: 216 | return suggestions 217 | 218 | 219 | class CommandPathCompleter(Completer): 220 | """ 221 | Completer for command paths in the interactive shell. 222 | This completer suggests command names based on the user's input. 223 | """ 224 | 225 | def __init__(self, r_pool: RunspacePool): 226 | self.r_pool = r_pool 227 | 228 | def get_completions(self, document: Document, complete_event): 229 | dirs_only = False # Whether to suggest only directories 230 | text_before_cursor = document.text_before_cursor.lstrip() 231 | tokens = text_before_cursor.split(maxsplit=1) 232 | 233 | if not tokens: # Empty input, suggest all commands 234 | for cmd_sugg in MENU_COMMANDS: 235 | yield Completion(cmd_sugg, start_position=0, display=cmd_sugg) 236 | return 237 | 238 | command_typed_part = tokens[0] 239 | 240 | # Case 1: Completing the command name itself 241 | # There's only one token and no trailing space. 242 | if len(tokens) == 1 and not text_before_cursor.endswith(" "): 243 | # User is typing the command, -> "downl" 244 | for cmd_sugg in MENU_COMMANDS: 245 | if cmd_sugg.startswith(command_typed_part): 246 | yield Completion( 247 | cmd_sugg + " ", # Full suggested command 248 | start_position=-len( 249 | command_typed_part 250 | ), # Replace the typed part 251 | display=cmd_sugg, 252 | ) 253 | return 254 | 255 | # Case 2: Completing a path argument 256 | path_typed_segment = "" # What the user has typed for the current path argument 257 | if len(tokens) == 2: 258 | path_typed_segment = tokens[1] 259 | 260 | actual_command_name = command_typed_part.strip().lower() 261 | 262 | args = quoted_command_split(path_typed_segment.strip()) 263 | 264 | suggestions = [] 265 | current_arg_text_being_completed = "" 266 | directory_prefix = partial_name = "" 267 | 268 | if actual_command_name == "upload": 269 | # syntax: upload 270 | num_args_present = len(args) 271 | 272 | if num_args_present == 0: 273 | # User typed "upload " 274 | # Completing the 1st argument (local_path), currently empty 275 | current_arg_text_being_completed = "" 276 | directory_prefix, partial_name = get_directory_and_partial_name( 277 | current_arg_text_being_completed, sep=os.sep 278 | ) 279 | suggestions = get_local_path_suggestions(directory_prefix, partial_name) 280 | elif num_args_present == 1: 281 | # We have one argument part, e.g., "upload arg1" or "upload local_path " 282 | if path_typed_segment.endswith(" "): 283 | # 1st argument (local_path) is complete 284 | # Completing the 2nd argument (remote_path), currently empty 285 | current_arg_text_being_completed = "" 286 | directory_prefix, partial_name = get_directory_and_partial_name( 287 | current_arg_text_being_completed, sep="\\" 288 | ) 289 | suggestions = get_remote_path_suggestions( 290 | self.r_pool, directory_prefix, partial_name 291 | ) 292 | else: 293 | # Still completing the 1st argument (local_path), e.g., "upload arg1" 294 | current_arg_text_being_completed = path_being_completed = args[0] 295 | if path_being_completed.startswith('"'): 296 | path_being_completed = current_arg_text_being_completed.strip( 297 | '"' 298 | ) 299 | directory_prefix, partial_name = get_directory_and_partial_name( 300 | path_being_completed, sep=os.sep 301 | ) 302 | suggestions = get_local_path_suggestions( 303 | directory_prefix, partial_name 304 | ) 305 | elif num_args_present == 2: 306 | # We have two argument parts 307 | # e.g., "upload local_path arg2" or "upload local_path remote_path " 308 | if path_typed_segment.endswith(" "): 309 | # 2nd argument (remote_path) is complete. No more suggestions for "upload". 310 | pass 311 | else: 312 | # Completing the 2nd argument (remote_path), e.g., "upload local_path arg2" 313 | current_arg_text_being_completed = path_being_completed = args[1] 314 | if path_being_completed.startswith('"'): 315 | path_being_completed = current_arg_text_being_completed.strip( 316 | '"' 317 | ) 318 | directory_prefix, partial_name = get_directory_and_partial_name( 319 | path_being_completed, sep="\\" 320 | ) 321 | suggestions = get_remote_path_suggestions( 322 | self.r_pool, directory_prefix, partial_name 323 | ) 324 | else: 325 | # More than 2 arguments, e.g., "upload local_path remote_path extra_arg" 326 | pass 327 | elif actual_command_name == "download": 328 | # syntax: download 329 | num_args_present = len(args) 330 | 331 | if num_args_present == 0: 332 | # User typed "download " 333 | # Completing 1st arg (remote_path), empty 334 | current_arg_text_being_completed = "" 335 | directory_prefix, partial_name = get_directory_and_partial_name( 336 | current_arg_text_being_completed, sep="\\" 337 | ) 338 | suggestions = get_remote_path_suggestions( 339 | self.r_pool, directory_prefix, partial_name 340 | ) 341 | elif num_args_present == 1: 342 | # We have "download arg1" or "download local_path " 343 | if path_typed_segment.endswith(" "): 344 | # First arg (remote_path) is complete. Completing 2nd arg (local_path), empty. 345 | current_arg_text_being_completed = "" 346 | directory_prefix, partial_name = get_directory_and_partial_name( 347 | current_arg_text_being_completed, sep=os.sep 348 | ) 349 | suggestions = get_local_path_suggestions( 350 | directory_prefix, partial_name 351 | ) 352 | else: 353 | # Still completing 1st arg (remote_path) 354 | current_arg_text_being_completed = path_being_completed = args[0] 355 | if path_being_completed.startswith('"'): 356 | path_being_completed = current_arg_text_being_completed.strip( 357 | '"' 358 | ) 359 | directory_prefix, partial_name = get_directory_and_partial_name( 360 | path_being_completed, sep="\\" 361 | ) 362 | suggestions = get_remote_path_suggestions( 363 | self.r_pool, directory_prefix, partial_name 364 | ) 365 | elif num_args_present == 2: 366 | # We have two argument parts 367 | # e.g., "download remote_path arg2" or "download remote_path local_path " 368 | if path_typed_segment.endswith(" "): 369 | # 2nd argument (local_path) is complete. No more suggestions for "download". 370 | pass 371 | else: 372 | # Completing 2nd arg (local_path) 373 | current_arg_text_being_completed = path_being_completed = args[1] 374 | if path_being_completed.startswith('"'): 375 | path_being_completed = current_arg_text_being_completed.strip( 376 | '"' 377 | ) 378 | directory_prefix, partial_name = get_directory_and_partial_name( 379 | path_being_completed, sep=os.sep 380 | ) 381 | suggestions = get_local_path_suggestions( 382 | directory_prefix, partial_name 383 | ) 384 | else: 385 | # More than 2 arguments, e.g., "download remote_path local_path extra_arg" 386 | pass 387 | else: 388 | if actual_command_name == "cd": 389 | dirs_only = True 390 | 391 | current_arg_text_being_completed = path_being_completed = path_typed_segment 392 | 393 | if path_being_completed.startswith('"'): 394 | path_being_completed = current_arg_text_being_completed.strip('"') 395 | 396 | directory_prefix, partial_name = get_directory_and_partial_name( 397 | path_being_completed, sep="\\" 398 | ) 399 | suggestions = get_remote_path_suggestions( 400 | self.r_pool, directory_prefix, partial_name, dirs_only 401 | ) 402 | 403 | for sugg_path in suggestions: 404 | 405 | # If the path doesn't start with a drive letter, prepend the directory_prefix 406 | if ( 407 | not re.match(r"^[a-zA-Z]:", directory_prefix) 408 | and directory_prefix 409 | and directory_prefix.endswith("\\") 410 | ): 411 | sugg_path = f"{directory_prefix}{sugg_path}" 412 | 413 | text_to_insert_in_prompt = sugg_path 414 | 415 | if " " in sugg_path: 416 | # If the path contains spaces, quote it 417 | text_to_insert_in_prompt = f'"{sugg_path}"' 418 | 419 | yield Completion( 420 | text_to_insert_in_prompt, 421 | start_position=-len( 422 | current_arg_text_being_completed 423 | ), # Use the length of quoted part 424 | display=sugg_path, 425 | ) 426 | 427 | 428 | def get_ps_script(script_name: str) -> str: 429 | """ 430 | Returns the content of a PowerShell script from the package resources. 431 | """ 432 | try: 433 | with resources.path("evil_winrm_py._ps", script_name) as script_path: 434 | return script_path.read_text() 435 | except FileNotFoundError: 436 | print(RED + f"[-] Script {script_name} not found." + RESET) 437 | log.error(f"Script {script_name} not found.") 438 | return "" 439 | 440 | 441 | def quoted_command_split(command: str) -> list[str]: 442 | """ 443 | Splits a command string into parts, respecting quoted strings. 444 | This is useful for handling paths with spaces or special characters. 445 | """ 446 | actual_command_parts = [] 447 | continuation = False 448 | cursor = 0 449 | 450 | command_parts = command.split(" ") 451 | for part in command_parts: 452 | if not part: 453 | continue 454 | if continuation: 455 | actual_command_parts[cursor] = actual_command_parts[cursor] + " " + part 456 | if part.endswith('"'): 457 | continuation = False 458 | cursor += 1 459 | else: 460 | if part.startswith('"'): 461 | actual_command_parts += [part] 462 | continuation = True 463 | elif part.find('"') != -1: 464 | # #TODO: decide later how to handle this case 465 | pass 466 | else: 467 | actual_command_parts += [part] 468 | cursor += 1 469 | return actual_command_parts 470 | 471 | 472 | def download_file(r_pool: RunspacePool, remote_path: str, local_path: str) -> None: 473 | ps = PowerShell(r_pool) 474 | script = get_ps_script("fetch.ps1") 475 | ps.add_script(script) 476 | ps.add_parameter("FilePath", remote_path) 477 | ps.begin_invoke() 478 | 479 | ts = int(time.time()) 480 | tmp_file_path = Path(tempfile.gettempdir()) / f"evil-winrm-py.file_{ts}.tmp" 481 | 482 | try: 483 | # Create a temporary file to store the downloaded data 484 | with open(tmp_file_path, "ab+") as bin: 485 | cursor = 0 486 | metadata = {} 487 | while ps.state == PSInvocationState.RUNNING: 488 | with DelayedKeyboardInterrupt(): 489 | ps.poll_invoke() 490 | output = ps.output 491 | if cursor == 0: 492 | line = json.loads(output[0]) 493 | if line["Type"] == "Error": 494 | print(RED + f"[-] Error: {line['Message']}" + RESET) 495 | log.error(f"Error: {line['Message']}") 496 | return 497 | elif line["Type"] == "Metadata": 498 | metadata = line 499 | pbar = tqdm( 500 | total=metadata["FileSize"], 501 | unit="B", 502 | unit_scale=True, 503 | unit_divisor=1024, 504 | desc=f"Downloading {remote_path}", 505 | dynamic_ncols=True, 506 | mininterval=0.1, 507 | ) 508 | for line in output[cursor:]: 509 | line = json.loads(line) 510 | if line["Type"] == "Chunk": 511 | Base64Data = line["Base64Data"] 512 | chunk = base64.b64decode(Base64Data) 513 | bin.write(chunk) 514 | pbar.update(metadata["ChunkSize"]) 515 | if line["Type"] == "Error": 516 | print(RED + f"[-] Error: {line['Message']}" + RESET) 517 | log.error(f"Error: {line['Message']}") 518 | return 519 | cursor = len(output) 520 | pbar.close() 521 | bin.close() 522 | 523 | if ps.had_errors: 524 | if ps.streams.error: 525 | for error in ps.streams.error: 526 | print(error) 527 | 528 | except KeyboardInterrupt: 529 | if "pbar" in locals() and pbar: 530 | pbar.leave = ( 531 | False # Make the progress bar disappear on close after interrupt 532 | ) 533 | pbar.close() 534 | Path(tmp_file_path).unlink(missing_ok=True) 535 | if ps.state == PSInvocationState.RUNNING: 536 | log.info("Stopping command execution.") 537 | ps.stop() 538 | return 539 | 540 | # Verify the downloaded file's hash 541 | hexdigest = hashlib.md5(open(tmp_file_path, "rb").read()).hexdigest() 542 | if metadata["FileHash"].lower() == hexdigest: 543 | # If the hash matches, rename the temporary file to the final name 544 | tmp_file_path = Path(tmp_file_path) 545 | try: 546 | shutil.move(tmp_file_path, local_path) 547 | except Exception as e: 548 | print(RED + f"[-] Error saving file: {e}" + RESET) 549 | log.error(f"Error saving file: {e}") 550 | return 551 | print( 552 | GREEN 553 | + "[+] File downloaded successfully and saved as: " 554 | + local_path 555 | + RESET 556 | ) 557 | log.info("File downloaded successfully and saved as: {}".format(local_path)) 558 | else: 559 | print(RED + "[-] File hash mismatch. Downloaded file may be corrupted." + RESET) 560 | log.error("File hash mismatch. Downloaded file may be corrupted.") 561 | 562 | 563 | def upload_file(r_pool: RunspacePool, local_path: str, remote_path: str) -> None: 564 | hexdigest = hashlib.md5(open(local_path, "rb").read()).hexdigest().upper() 565 | with open(local_path, "rb") as bin: 566 | file_size = Path(local_path).stat().st_size 567 | chunk_size_bytes = 65536 # 64 KB 568 | total_chunks = (file_size + chunk_size_bytes - 1) // chunk_size_bytes 569 | metadata = {"FileHash": ""} # Declare a psuedo metadata 570 | 571 | pbar = tqdm( 572 | total=file_size, 573 | unit="B", 574 | unit_scale=True, 575 | unit_divisor=1024, 576 | desc=f"Uploading {local_path}", 577 | dynamic_ncols=True, 578 | mininterval=0.1, 579 | ) 580 | try: 581 | temp_file_path = "" 582 | for i in range(total_chunks): 583 | start_offset = i * chunk_size_bytes 584 | bin.seek(start_offset) 585 | chunk = bin.read(chunk_size_bytes) 586 | 587 | if not chunk: # End of file 588 | break 589 | 590 | elif i == 0: 591 | chunk_type = 0 # First chunk, tells PS script to create file 592 | if len(chunk) < chunk_size_bytes: 593 | chunk_type = 3 594 | elif i == total_chunks - 1: 595 | chunk_type = 1 # Last chunk, tells PS script to calculate hash 596 | else: 597 | chunk_type = 2 # Intermediate chunk 598 | 599 | base64_chunk = base64.b64encode(chunk).decode("utf-8") 600 | 601 | script = get_ps_script("send.ps1") 602 | with DelayedKeyboardInterrupt(): 603 | ps = PowerShell(r_pool) 604 | ps.add_script(script) 605 | ps.add_parameter("Base64Chunk", base64_chunk) 606 | ps.add_parameter("ChunkType", chunk_type) 607 | 608 | if chunk_type == 1: 609 | # If it's the last chunk, we provide the file path and hash 610 | ps.add_parameter("TempFilePath", temp_file_path) 611 | ps.add_parameter("FilePath", remote_path) 612 | ps.add_parameter("FileHash", hexdigest) 613 | elif chunk_type == 2: 614 | ps.add_parameter("TempFilePath", temp_file_path) 615 | elif chunk_type == 3: 616 | ps.add_parameter("FilePath", remote_path) 617 | ps.add_parameter("FileHash", hexdigest) 618 | 619 | ps.begin_invoke() 620 | 621 | while ps.state == PSInvocationState.RUNNING: 622 | ps.poll_invoke() 623 | output = ps.output 624 | 625 | for line in output: 626 | line = json.loads(line) 627 | if line["Type"] == "Metadata": 628 | metadata = line 629 | if "TempFilePath" in metadata: 630 | temp_file_path = metadata["TempFilePath"] 631 | 632 | if line["Type"] == "Error": 633 | print(RED + f"[-] Error: {line['Message']}" + RESET) 634 | log.error(f"Error: {line['Message']}") 635 | pbar.leave = False # Make the progress bar disappear on close 636 | return 637 | if ps.had_errors: 638 | if ps.streams.error: 639 | for error in ps.streams.error: 640 | print(error) 641 | if chunk_type == 3: 642 | pbar.update(file_size) 643 | else: 644 | pbar.update(chunk_size_bytes) 645 | pbar.close() 646 | 647 | # Verify the downloaded file's hash 648 | if metadata["FileHash"] == hexdigest: 649 | print( 650 | GREEN 651 | + "[+] File uploaded successfully as: " 652 | + metadata["FilePath"] 653 | + RESET 654 | ) 655 | log.info( 656 | "File uploaded successfully as: {}".format(metadata["FilePath"]) 657 | ) 658 | else: 659 | print( 660 | RED 661 | + "[-] File hash mismatch. Uploaded file may be corrupted." 662 | + RESET 663 | ) 664 | log.error("File hash mismatch. Uploaded file may be corrupted.") 665 | 666 | except KeyboardInterrupt: 667 | if "pbar" in locals() and pbar: 668 | pbar.leave = ( 669 | False # Make the progress bar disappear on close after interrupt 670 | ) 671 | pbar.close() 672 | if ps.state == PSInvocationState.RUNNING: 673 | log.info("Stopping command execution.") 674 | ps.stop() 675 | 676 | 677 | def interactive_shell( 678 | wsman: WSMan, configuration_name: str = DEFAULT_CONFIGURATION_NAME 679 | ) -> None: 680 | """Runs the interactive pseudo-shell.""" 681 | log.info("Starting interactive PowerShell session...") 682 | 683 | # Set up history file 684 | if not HISTORY_FILE.exists(): 685 | Path(HISTORY_FILE).touch() 686 | prompt_history = FileHistory(HISTORY_FILE) 687 | prompt_session = PromptSession(history=prompt_history) 688 | 689 | with wsman, RunspacePool(wsman, configuration_name=configuration_name) as r_pool: 690 | completer = CommandPathCompleter(r_pool) 691 | 692 | while True: 693 | try: 694 | prompt_text = ANSI(get_prompt(r_pool)) 695 | command = prompt_session.prompt( 696 | prompt_text, 697 | completer=completer, 698 | complete_while_typing=False, 699 | ) 700 | 701 | if not command: 702 | continue 703 | 704 | command = command.strip() # Remove leading/trailing whitespace 705 | command_lower = command.lower() 706 | 707 | # Check for exit command 708 | if command_lower == "exit": 709 | log.info("Exiting interactive shell.") 710 | return 711 | elif command_lower in ["clear", "cls"]: 712 | log.info("Clearing the screen.") 713 | clear() # Clear the screen 714 | continue 715 | elif command_lower == "menu": 716 | log.info("Displaying menu.") 717 | show_menu() 718 | continue 719 | elif command_lower.startswith("download"): 720 | command_parts = quoted_command_split(command) 721 | if len(command_parts) < 3: 722 | print( 723 | RED 724 | + "[-] Usage: download " 725 | + RESET 726 | ) 727 | continue 728 | remote_path = command_parts[1].strip('"') 729 | local_path = command_parts[2].strip('"') 730 | 731 | remote_file, streams, had_errors = run_ps( 732 | r_pool, f"(Resolve-Path -Path '{remote_path}').Path" 733 | ) 734 | if not remote_file: 735 | print( 736 | RED 737 | + f"[-] Remote file '{remote_path}' does not exist or you do not have permission to access it." 738 | + RESET 739 | ) 740 | continue 741 | 742 | file_name = remote_file.split("\\")[-1] 743 | 744 | if Path(local_path).is_dir() or local_path.endswith(os.sep): 745 | local_path = Path(local_path).resolve().joinpath(file_name) 746 | else: 747 | local_path = Path(local_path).resolve() 748 | 749 | download_file(r_pool, remote_file, str(local_path)) 750 | continue 751 | elif command_lower.startswith("upload"): 752 | command_parts = quoted_command_split(command) 753 | if len(command_parts) < 3: 754 | print( 755 | RED + "[-] Usage: upload " + RESET 756 | ) 757 | continue 758 | local_path = command_parts[1].strip('"') 759 | remote_path = command_parts[2].strip('"') 760 | 761 | if not Path(local_path).exists(): 762 | print( 763 | RED 764 | + f"[-] Local file '{local_path}' does not exist." 765 | + RESET 766 | ) 767 | continue 768 | 769 | file_name = local_path.split(os.sep)[-1] 770 | 771 | if not re.match(r"^[a-zA-Z]:", remote_path): 772 | # If the path doesn't start with a drive letter, prepend the current directory 773 | pwd, streams, had_errors = run_ps(r_pool, "$pwd.Path") 774 | if remote_path == ".": 775 | remote_path = f"{pwd}\\{file_name}" 776 | else: 777 | remote_path = f"{pwd}\\{remote_path}" 778 | 779 | if remote_path.endswith("\\"): 780 | remote_path = f"{remote_path}{file_name}" 781 | 782 | upload_file(r_pool, str(Path(local_path).resolve()), remote_path) 783 | continue 784 | else: 785 | try: 786 | ps = PowerShell(r_pool) 787 | ps.add_cmdlet("Invoke-Expression").add_parameter( 788 | "Command", command 789 | ) 790 | ps.add_cmdlet("Out-String").add_parameter("Stream") 791 | ps.begin_invoke() 792 | log.info("Executing command: {}".format(command)) 793 | 794 | cursor = 0 795 | while ps.state == PSInvocationState.RUNNING: 796 | with DelayedKeyboardInterrupt(): 797 | ps.poll_invoke() 798 | output = ps.output 799 | for line in output[cursor:]: 800 | print(line) 801 | cursor = len(output) 802 | log.info("Command execution completed.") 803 | log.info("Output: {}".format("\n".join(output))) 804 | 805 | if ps.streams.error: 806 | for error in ps.streams.error: 807 | print(RED + error._to_string + RESET) 808 | log.error("Error: {}".format(error._to_string)) 809 | log.error("\tCategoryInfo: {}".format(error.message)) 810 | log.error( 811 | "\tFullyQualifiedErrorId: {}".format(error.fq_error) 812 | ) 813 | except KeyboardInterrupt: 814 | if ps.state == PSInvocationState.RUNNING: 815 | log.info("Stopping command execution.") 816 | ps.stop() 817 | except KeyboardInterrupt: 818 | print("\nCaught Ctrl+C. Type 'exit' or press Ctrl+D to exit.") 819 | continue # Allow user to continue or type exit 820 | except EOFError: 821 | return # Exit on Ctrl+D 822 | 823 | 824 | # --- Main Function --- 825 | def main(): 826 | print( 827 | """ ▘▜ ▘ 828 | █▌▌▌▌▐ ▄▖▌▌▌▌▛▌▛▘▛▛▌▄▖▛▌▌▌ 829 | ▙▖▚▘▌▐▖ ▚▚▘▌▌▌▌ ▌▌▌ ▙▌▙▌ 830 | ▌ ▄▌ v{}""".format( 831 | __version__ 832 | ) 833 | ) 834 | parser = argparse.ArgumentParser() 835 | 836 | parser.add_argument( 837 | "-i", 838 | "--ip", 839 | required=True, 840 | help="remote host IP or hostname", 841 | ) 842 | parser.add_argument("-u", "--user", required=True, help="username") 843 | parser.add_argument("-p", "--password", help="password") 844 | parser.add_argument("-H", "--hash", help="nthash") 845 | parser.add_argument( 846 | "--no-pass", action="store_true", help="do not prompt for password" 847 | ) 848 | if is_kerb_available: 849 | parser.add_argument( 850 | "-k", "--kerberos", action="store_true", help="use kerberos authentication" 851 | ) 852 | parser.add_argument( 853 | "--spn-prefix", 854 | help="specify spn prefix", 855 | ) 856 | parser.add_argument( 857 | "--spn-hostname", 858 | help="specify spn hostname", 859 | ) 860 | parser.add_argument( 861 | "--priv-key-pem", 862 | help="local path to private key PEM file", 863 | ) 864 | parser.add_argument( 865 | "--cert-pem", 866 | help="local path to certificate PEM file", 867 | ) 868 | parser.add_argument("--uri", default="wsman", help="wsman URI (default: /wsman)") 869 | parser.add_argument("--ssl", action="store_true", help="use ssl") 870 | parser.add_argument( 871 | "--port", type=int, default=5985, help="remote host port (default 5985)" 872 | ) 873 | parser.add_argument("--log", action="store_true", help="log session to file") 874 | parser.add_argument("--debug", action="store_true", help="enable debug logging") 875 | parser.add_argument("--no-colors", action="store_true", help="disable colors") 876 | parser.add_argument( 877 | "--version", action="version", version=__version__, help="show version" 878 | ) 879 | 880 | args = parser.parse_args() 881 | 882 | # Set Default values 883 | auth = "ntlm" # this can be 'negotiate' 884 | encryption = "auto" 885 | 886 | # --- Run checks on provided arguments --- 887 | if args.no_colors: 888 | global RESET, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, BOLD 889 | RESET = RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = BOLD = "" 890 | 891 | if is_kerb_available: 892 | if args.kerberos: 893 | auth = "kerberos" 894 | # User needs to set environment variables `KRB5CCNAME` and `KRB5_CONFIG` as per requirements 895 | # example: export KRB5CCNAME=/tmp/krb5cc_1000 896 | # example: export KRB5_CONFIG=/etc/krb5.conf 897 | elif args.spn_prefix or args.spn_hostname: 898 | args.spn_prefix = args.spn_hostname = None # Reset to None 899 | print( 900 | MAGENTA 901 | + "[%] SPN prefix/hostname is only used with Kerberos authentication." 902 | + RESET 903 | ) 904 | else: 905 | args.spn_prefix = args.spn_hostname = None 906 | 907 | if args.cert_pem or args.priv_key_pem: 908 | auth = "certificate" 909 | encryption = "never" 910 | args.ssl = True 911 | args.no_pass = True 912 | if not args.cert_pem or not args.priv_key_pem: 913 | print( 914 | RED 915 | + "[-] Both cert.pem and priv-key.pem must be provided for certificate authentication." 916 | + RESET 917 | ) 918 | sys.exit(1) 919 | 920 | if args.hash and args.password: 921 | print(RED + "[-] You cannot use both password and hash." + RESET) 922 | sys.exit(1) 923 | 924 | if args.hash: 925 | ntlm_hash_pattern = r"^[0-9a-fA-F]{32}$" 926 | if re.match(ntlm_hash_pattern, args.hash): 927 | args.password = "00000000000000000000000000000000:{}".format(args.hash) 928 | else: 929 | print(RED + "[-] Invalid NTLM hash format." + RESET) 930 | sys.exit(1) 931 | 932 | if args.no_pass: 933 | args.password = None 934 | elif not args.password: 935 | args.password = prompt("Password: ", is_password=True) 936 | if not args.password: 937 | args.password = None 938 | 939 | if args.uri: 940 | if args.uri.startswith("/"): 941 | args.uri = args.uri.lstrip("/") 942 | 943 | if args.ssl and (args.port == 5985): 944 | args.port = 5986 945 | 946 | if args.log or args.debug: 947 | level = logging.INFO 948 | # Disable all loggers except the root logger 949 | if args.debug: 950 | print(BLUE + "[*] Debug logging enabled." + RESET) 951 | level = logging.DEBUG 952 | else: 953 | # Disable all loggers except the root logger 954 | for name in logging.root.manager.loggerDict: 955 | if not name.startswith("evil_winrm_py"): 956 | logging.getLogger(name).disabled = True 957 | # Set up logging to a file 958 | logging.basicConfig( 959 | level=level, 960 | format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", 961 | filename=LOG_PATH, 962 | ) 963 | print(BLUE + "[*] Logging session to {}".format(LOG_PATH) + RESET) 964 | else: 965 | log.disabled = True 966 | 967 | # --- Initialize WinRM Session --- 968 | log.info("--- Evil-WinRM-Py v{} started ---".format(__version__)) 969 | try: 970 | log.info("Connecting to {}:{} as {}".format(args.ip, args.port, args.user)) 971 | print( 972 | BLUE 973 | + "[*] Connecting to {}:{} as {}".format(args.ip, args.port, args.user) 974 | + RESET 975 | ) 976 | 977 | with WSMan( 978 | server=args.ip, 979 | port=args.port, 980 | auth=auth, 981 | encryption=encryption, 982 | username=args.user, 983 | password=args.password, 984 | ssl=args.ssl, 985 | cert_validation=False, 986 | path=args.uri, 987 | negotiate_service=args.spn_prefix, 988 | negotiate_hostname_override=args.spn_hostname, 989 | certificate_key_pem=args.priv_key_pem, 990 | certificate_pem=args.cert_pem, 991 | ) as wsman: 992 | interactive_shell(wsman) 993 | except WinRMTransportError as wte: 994 | print(RED + "[-] WinRM transport error: {}".format(wte) + RESET) 995 | log.error("WinRM transport error: {}".format(wte)) 996 | sys.exit(1) 997 | except requests.exceptions.ConnectionError as ce: 998 | print(RED + "[-] Connection error: {}".format(ce) + RESET) 999 | log.error("Connection error: {}".format(ce)) 1000 | sys.exit(1) 1001 | except AuthenticationError as ae: 1002 | print(RED + "[-] Authentication failed: {}".format(ae) + RESET) 1003 | log.error("Authentication failed: {}".format(ae)) 1004 | sys.exit(1) 1005 | except WSManFaultError as wfe: 1006 | print(RED + "[-] WSMan fault error: {}".format(wfe) + RESET) 1007 | log.error("WSMan fault error: {}".format(wfe)) 1008 | sys.exit(1) 1009 | except Krb5Error as ke: 1010 | print(RED + "[-] Kerberos error: {}".format(ke) + RESET) 1011 | log.error("Kerberos error: {}".format(ke)) 1012 | sys.exit(1) 1013 | except (OperationNotAvailableError, NoCredentialError) as se: 1014 | print(RED + "[-] SpnegoError error: {}".format(se) + RESET) 1015 | log.error("SpnegoError error: {}".format(se)) 1016 | sys.exit(1) 1017 | except Exception as e: 1018 | traceback.print_exc() 1019 | log.exception("An unexpected error occurred: {}".format(e), exc_info=True) 1020 | sys.exit(1) 1021 | finally: 1022 | log.info("--- Evil-WinRM-Py v{} ended ---".format(__version__)) 1023 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | certifi==2025.4.26 2 | cffi==1.17.1 3 | charset-normalizer==3.4.2 4 | cryptography==45.0.2 5 | decorator==5.2.1 6 | gssapi==1.9.0 7 | idna==3.10 8 | krb5==0.7.1 9 | prompt_toolkit==3.0.51 10 | pycparser==2.22 11 | pypsrp==0.8.1 12 | pyspnego==0.11.2 13 | requests==2.32.3 14 | tqdm==4.67.1 15 | urllib3==2.4.0 16 | wcwidth==0.2.13 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | from os import path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | pwd = path.abspath(path.dirname(__file__)) 7 | with io.open(path.join(pwd, "README.md"), encoding="utf-8") as readme: 8 | desc = readme.read() 9 | 10 | setup( 11 | name="evil-winrm-py", 12 | version=__import__("evil_winrm_py").__version__, 13 | description="Execute commands interactively on remote Windows machines using the WinRM protocol", 14 | long_description=desc, 15 | long_description_content_type="text/markdown", 16 | author="adityatelange", 17 | license="MIT", 18 | url="https://github.com/adityatelange/evil-winrm-py", 19 | download_url="https://github.com/adityatelange/evil-winrm-py/archive/v%s.zip" 20 | % __import__("evil_winrm_py").__version__, 21 | packages=find_packages(), 22 | classifiers=[ 23 | "Topic :: Security", 24 | "Operating System :: Unix", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3", 27 | ], 28 | install_requires=[ 29 | "pypsrp==0.8.1", 30 | "prompt_toolkit==3.0.51", 31 | "tqdm==4.67.1", 32 | ], 33 | extras_require={ 34 | "kerberos": [ 35 | "pypsrp[kerberos]==0.8.1", 36 | ] 37 | }, 38 | python_requires=">=3.9", 39 | entry_points={ 40 | "console_scripts": ["evil-winrm-py = evil_winrm_py.evil_winrm_py:main"] 41 | }, 42 | package_data={ 43 | "evil_winrm_py": ["_ps/*.ps1"], 44 | }, 45 | ) 46 | --------------------------------------------------------------------------------