diff --git a/.travis.yml b/.travis.yml index b14482939..d5103b989 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,29 @@ +# Infection Monkey travis.yml. See Travis documentation for information about this file structure. + group: travis_latest language: python cache: pip python: - - 2.7 +- 3.7 install: - #- pip install -r requirements.txt - - pip install flake8 # pytest # add another testing frameworks later +- pip install -r monkey/monkey_island/requirements.txt # for unit tests +- pip install flake8 pytest dlint # for next stages +- pip install -r monkey/infection_monkey/requirements_linux.txt # for unit tests before_script: - # stop the build if there are Python syntax errors or undefined names - - flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics +- flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics # Check syntax errors +- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics # warn about linter issues. --exit-zero + # means this stage will not fail the build. This is (hopefully) a temporary measure until all warnings are suppressed. +- python monkey/monkey_island/cc/set_server_config.py testing # Set the server config to `testing`, for the UTs to use + # mongomaock and pass. script: - - true # pytest --capture=sys # add other tests here +- cd monkey # This is our source dir +- python -m pytest # Have to use `python -m pytest` instead of `pytest` to add "{$builddir}/monkey/monkey" to sys.path. notifications: - on_success: change - on_failure: change # `always` will be the setting once code changes slow down + slack: # Notify to slack + rooms: + - infectionmonkey:QaXbsx4g7tHFJW0lhtiBmoAg#ci # room: #ci + on_success: change + on_failure: always + email: + on_success: change + on_failure: always diff --git a/README.md b/README.md index 67b5b2e8b..2d7490bfe 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ Infection Monkey ==================== +[![Build Status](https://travis-ci.com/guardicore/monkey.svg?branch=develop)](https://travis-ci.com/guardicore/monkey) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/guardicore/monkey)](https://github.com/guardicore/monkey/releases) +![GitHub stars](https://img.shields.io/github/stars/guardicore/monkey) +![GitHub commit activity](https://img.shields.io/github/commit-activity/m/guardicore/monkey) -### Data center Security Testing Tool +## Data center Security Testing Tool ------------------------ Welcome to the Infection Monkey! The Infection Monkey is an open source security tool for testing a data center's resiliency to perimeter breaches and internal server infection. The Monkey uses various methods to self propagate across a data center and reports success to a centralized Monkey Island server. + @@ -50,6 +55,12 @@ If you only want to build the monkey from source, see [Setup](https://github.com and follow the instructions at the readme files under [infection_monkey](infection_monkey) and [monkey_island](monkey_island). +### Build status +| Branch | Status | +| ------ | :----: | +| Develop | [![Build Status](https://travis-ci.com/guardicore/monkey.svg?branch=develop)](https://travis-ci.com/guardicore/monkey) | +| Master | [![Build Status](https://travis-ci.com/guardicore/monkey.svg?branch=master)](https://travis-ci.com/guardicore/monkey) | + License ======= Copyright (c) Guardicore Ltd diff --git a/envs/monkey_zoo/blackbox/island_client/monkey_island_requests.py b/envs/monkey_zoo/blackbox/island_client/monkey_island_requests.py index e62cb2121..8ac53996b 100644 --- a/envs/monkey_zoo/blackbox/island_client/monkey_island_requests.py +++ b/envs/monkey_zoo/blackbox/island_client/monkey_island_requests.py @@ -1,4 +1,5 @@ import requests +import functools # SHA3-512 of '1234567890!@#$%^&*()_nothing_up_my_sleeve_1234567890!@#$%^&*()' import logging @@ -8,6 +9,7 @@ NO_AUTH_CREDS = '55e97c9dcfd22b8079189ddaeea9bce8125887e3237b800c6176c9afa80d206 LOGGER = logging.getLogger(__name__) +# noinspection PyArgumentList class MonkeyIslandRequests(object): def __init__(self, server_address): self.addr = "https://{IP}/".format(IP=server_address) @@ -21,29 +23,43 @@ class MonkeyIslandRequests(object): "Unable to connect to island, aborting! Error information: {}. Server: {}".format(err, self.addr)) assert False + class _Decorators: + @classmethod + def refresh_jwt_token(cls, request_function): + @functools.wraps(request_function) + def request_function_wrapper(self, *args,**kwargs): + self.token = self.try_get_jwt_from_server() + # noinspection PyArgumentList + return request_function(self, *args, **kwargs) + return request_function_wrapper + def get_jwt_from_server(self): resp = requests.post(self.addr + "api/auth", json={"username": NO_AUTH_CREDS, "password": NO_AUTH_CREDS}, verify=False) return resp.json()["access_token"] + @_Decorators.refresh_jwt_token def get(self, url, data=None): return requests.get(self.addr + url, headers=self.get_jwt_header(), params=data, verify=False) + @_Decorators.refresh_jwt_token def post(self, url, data): return requests.post(self.addr + url, data=data, headers=self.get_jwt_header(), verify=False) + @_Decorators.refresh_jwt_token def post_json(self, url, dict_data): return requests.post(self.addr + url, json=dict_data, headers=self.get_jwt_header(), verify=False) + @_Decorators.refresh_jwt_token def get_jwt_header(self): return {"Authorization": "JWT " + self.token} diff --git a/envs/monkey_zoo/blackbox/test_blackbox.py b/envs/monkey_zoo/blackbox/test_blackbox.py index fc20c8b39..8581b6fbe 100644 --- a/envs/monkey_zoo/blackbox/test_blackbox.py +++ b/envs/monkey_zoo/blackbox/test_blackbox.py @@ -13,7 +13,7 @@ from envs.monkey_zoo.blackbox.log_handlers.test_logs_handler import TestLogsHand DEFAULT_TIMEOUT_SECONDS = 5*60 MACHINE_BOOTUP_WAIT_SECONDS = 30 -GCP_TEST_MACHINE_LIST = ['sshkeys-11', 'sshkeys-12', 'elastic-4', 'elastic-5', 'haddop-2', 'hadoop-3', 'mssql-16', +GCP_TEST_MACHINE_LIST = ['sshkeys-11', 'sshkeys-12', 'elastic-4', 'elastic-5', 'hadoop-2', 'hadoop-3', 'mssql-16', 'mimikatz-14', 'mimikatz-15', 'struts2-23', 'struts2-24', 'tunneling-9', 'tunneling-10', 'tunneling-11', 'weblogic-18', 'weblogic-19', 'shellshock-8'] LOG_DIR_PATH = "./logs" diff --git a/monkey/common/network/segmentation_utils_test.py b/monkey/common/network/segmentation_utils_test.py index 56a560922..221f1d9bf 100644 --- a/monkey/common/network/segmentation_utils_test.py +++ b/monkey/common/network/segmentation_utils_test.py @@ -11,20 +11,20 @@ class TestSegmentationUtils(IslandTestCase): # IP not in both self.assertIsNone(get_ip_in_src_and_not_in_dst( - [text_type("3.3.3.3"), text_type("4.4.4.4")], source, target + ["3.3.3.3", "4.4.4.4"], source, target )) # IP not in source, in target self.assertIsNone(get_ip_in_src_and_not_in_dst( - [text_type("2.2.2.2")], source, target + ["2.2.2.2"], source, target )) # IP in source, not in target self.assertIsNotNone(get_ip_in_src_and_not_in_dst( - [text_type("8.8.8.8"), text_type("1.1.1.1")], source, target + ["8.8.8.8", "1.1.1.1"], source, target )) # IP in both subnets self.assertIsNone(get_ip_in_src_and_not_in_dst( - [text_type("8.8.8.8"), text_type("1.1.1.1")], source, source + ["8.8.8.8", "1.1.1.1"], source, source )) diff --git a/monkey/infection_monkey/exploit/sambacry.py b/monkey/infection_monkey/exploit/sambacry.py index e48a21616..e3825eac9 100644 --- a/monkey/infection_monkey/exploit/sambacry.py +++ b/monkey/infection_monkey/exploit/sambacry.py @@ -395,7 +395,7 @@ class SambaCryExploiter(HostExploiter): if fileName != '': smb2Create['Buffer'] = fileName.encode('utf-16le') else: - smb2Create['Buffer'] = '\x00' + smb2Create['Buffer'] = b'\x00' if createContexts is not None: smb2Create['Buffer'] += createContexts diff --git a/monkey/infection_monkey/exploit/shellshock.py b/monkey/infection_monkey/exploit/shellshock.py index edc4851e9..52be145cc 100644 --- a/monkey/infection_monkey/exploit/shellshock.py +++ b/monkey/infection_monkey/exploit/shellshock.py @@ -207,7 +207,7 @@ class ShellShockExploiter(HostExploiter): LOG.debug("Header is: %s" % header) LOG.debug("Attack is: %s" % attack) r = requests.get(url, headers={header: attack}, verify=False, timeout=TIMEOUT) - result = r.content + result = r.content.decode() return result except requests.exceptions.RequestException as exc: LOG.debug("Failed to run, exception %s" % exc) diff --git a/monkey/infection_monkey/exploit/smbexec.py b/monkey/infection_monkey/exploit/smbexec.py index 18331e994..348b6803d 100644 --- a/monkey/infection_monkey/exploit/smbexec.py +++ b/monkey/infection_monkey/exploit/smbexec.py @@ -108,16 +108,15 @@ class SmbExploiter(HostExploiter): cmdline = MONKEY_CMDLINE_DETACHED_WINDOWS % {'monkey_path': remote_full_path} + \ build_monkey_commandline(self.host, get_monkey_depth() - 1) - for str_bind_format, port in list(SmbExploiter.KNOWN_PROTOCOLS.values()): + smb_conn = False + for str_bind_format, port in SmbExploiter.KNOWN_PROTOCOLS.values(): rpctransport = transport.DCERPCTransportFactory(str_bind_format % (self.host.ip_addr,)) rpctransport.set_dport(port) - if hasattr(rpctransport, 'preferred_dialect'): rpctransport.preferred_dialect(SMB_DIALECT) if hasattr(rpctransport, 'set_credentials'): # This method exists only for selected protocol sequences. - rpctransport.set_credentials(user, password, '', - lm_hash, ntlm_hash, None) + rpctransport.set_credentials(user, password, '', lm_hash, ntlm_hash, None) rpctransport.set_kerberos(SmbExploiter.USE_KERBEROS) scmr_rpc = rpctransport.get_dce_rpc() @@ -125,13 +124,14 @@ class SmbExploiter(HostExploiter): try: scmr_rpc.connect() except Exception as exc: - LOG.warning("Error connecting to SCM on exploited machine %r: %s", - self.host, exc) - return False + LOG.debug("Can't connect to SCM on exploited machine %r port %s : %s", self.host, port, exc) + continue smb_conn = rpctransport.get_smb_connection() break + if not smb_conn: + return False # We don't wanna deal with timeouts from now on. smb_conn.setTimeout(100000) scmr_rpc.bind(scmr.MSRPC_UUID_SCMR) diff --git a/monkey/infection_monkey/exploit/sshexec.py b/monkey/infection_monkey/exploit/sshexec.py index cb0c1d63a..4a88c4593 100644 --- a/monkey/infection_monkey/exploit/sshexec.py +++ b/monkey/infection_monkey/exploit/sshexec.py @@ -9,6 +9,7 @@ from infection_monkey.exploit import HostExploiter from infection_monkey.exploit.tools.helpers import get_target_monkey, get_monkey_depth, build_monkey_commandline from infection_monkey.exploit.tools.helpers import get_interface_to_target from infection_monkey.model import MONKEY_ARG +from infection_monkey.exploit.tools.exceptions import FailedExploitationError from infection_monkey.network.tools import check_tcp_port from common.utils.exploit_enum import ExploitType from common.utils.attack_utils import ScanStatus @@ -37,15 +38,16 @@ class SSHExploiter(HostExploiter): LOG.debug("SFTP transferred: %d bytes, total: %d bytes", transferred, total) self._update_timestamp = time.time() - def exploit_with_ssh_keys(self, port, ssh): + def exploit_with_ssh_keys(self, port) -> paramiko.SSHClient: user_ssh_key_pairs = self._config.get_exploit_user_ssh_key_pairs() - exploited = False - for user, ssh_key_pair in user_ssh_key_pairs: # Creating file-like private key for paramiko pkey = io.StringIO(ssh_key_pair['private_key']) ssh_string = "%s@%s" % (ssh_key_pair['user'], ssh_key_pair['ip']) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) try: pkey = paramiko.RSAKey.from_private_key(pkey) except(IOError, paramiko.SSHException, paramiko.PasswordRequiredException): @@ -54,52 +56,49 @@ class SSHExploiter(HostExploiter): ssh.connect(self.host.ip_addr, username=user, pkey=pkey, - port=port, - timeout=None) + port=port) LOG.debug("Successfully logged in %s using %s users private key", self.host, ssh_string) - exploited = True self.report_login_attempt(True, user, ssh_key=ssh_string) - break - except Exception as exc: + return ssh + except Exception: + ssh.close() LOG.debug("Error logging into victim %r with %s" " private key", self.host, ssh_string) self.report_login_attempt(False, user, ssh_key=ssh_string) continue - return exploited + raise FailedExploitationError - def exploit_with_login_creds(self, port, ssh): + def exploit_with_login_creds(self, port) -> paramiko.SSHClient: user_password_pairs = self._config.get_exploit_user_password_pairs() - exploited = False - for user, current_password in user_password_pairs: + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) try: ssh.connect(self.host.ip_addr, username=user, password=current_password, - port=port, - timeout=None) + port=port) LOG.debug("Successfully logged in %r using SSH. User: %s, pass (SHA-512): %s)", self.host, user, self._config.hash_sensitive_data(current_password)) - exploited = True self.add_vuln_port(port) self.report_login_attempt(True, user, current_password) - break + return ssh except Exception as exc: LOG.debug("Error logging into victim %r with user" " %s and password (SHA-512) '%s': (%s)", self.host, user, self._config.hash_sensitive_data(current_password), exc) self.report_login_attempt(False, user, current_password) + ssh.close() continue - return exploited + raise FailedExploitationError def _exploit_host(self): - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) port = SSH_PORT # if ssh banner found on different port, use that port. @@ -112,14 +111,14 @@ class SSHExploiter(HostExploiter): LOG.info("SSH port is closed on %r, skipping", self.host) return False - # Check for possible ssh exploits - exploited = self.exploit_with_ssh_keys(port, ssh) - if not exploited: - exploited = self.exploit_with_login_creds(port, ssh) - - if not exploited: - LOG.debug("Exploiter SSHExploiter is giving up...") - return False + try: + ssh = self.exploit_with_ssh_keys(port) + except FailedExploitationError: + try: + ssh = self.exploit_with_login_creds(port) + except FailedExploitationError: + LOG.debug("Exploiter SSHExploiter is giving up...") + return False if not self.host.os.get('type'): try: diff --git a/monkey/infection_monkey/exploit/tools/exceptions.py b/monkey/infection_monkey/exploit/tools/exceptions.py index eabe8d9d7..a322dc5bd 100644 --- a/monkey/infection_monkey/exploit/tools/exceptions.py +++ b/monkey/infection_monkey/exploit/tools/exceptions.py @@ -2,4 +2,7 @@ class ExploitingVulnerableMachineError(Exception): """ Raise when exploiter failed, but machine is vulnerable""" - pass + + +class FailedExploitationError(Exception): + """ Raise when exploiter fails instead of returning False""" diff --git a/monkey/infection_monkey/exploit/vsftpd.py b/monkey/infection_monkey/exploit/vsftpd.py index 136a8a36b..d4116c96c 100644 --- a/monkey/infection_monkey/exploit/vsftpd.py +++ b/monkey/infection_monkey/exploit/vsftpd.py @@ -45,7 +45,7 @@ class VSFTPDExploiter(HostExploiter): s.connect((ip_addr, port)) return True except socket.error as e: - LOG.error('Failed to connect to %s', self.host.ip_addr) + LOG.info('Failed to connect to %s: %s', self.host.ip_addr, str(e)) return False def socket_send_recv(self, s, message): @@ -53,7 +53,7 @@ class VSFTPDExploiter(HostExploiter): s.send(message) return s.recv(RECV_128).decode('utf-8') except socket.error as e: - LOG.error('Failed to send payload to %s', self.host.ip_addr) + LOG.info('Failed to send payload to %s: %s', self.host.ip_addr, str(e)) return False def socket_send(self, s, message): @@ -61,7 +61,7 @@ class VSFTPDExploiter(HostExploiter): s.send(message) return True except socket.error as e: - LOG.error('Failed to send payload to %s', self.host.ip_addr) + LOG.info('Failed to send payload to %s: %s', self.host.ip_addr, str(e)) return False def _exploit_host(self): diff --git a/monkey/infection_monkey/exploit/wmiexec.py b/monkey/infection_monkey/exploit/wmiexec.py index 947fd57a1..a1da97efe 100644 --- a/monkey/infection_monkey/exploit/wmiexec.py +++ b/monkey/infection_monkey/exploit/wmiexec.py @@ -104,9 +104,9 @@ class WmiExploiter(HostExploiter): ntpath.split(remote_full_path)[0], None) - if (0 != result.ProcessId) and (0 == result.ReturnValue): - LOG.info("Executed dropper '%s' on remote victim %r (pid=%d, exit_code=%d, cmdline=%r)", - remote_full_path, self.host, result.ProcessId, result.ReturnValue, cmdline) + if (0 != result.ProcessId) and (not result.ReturnValue): + LOG.info("Executed dropper '%s' on remote victim %r (pid=%d, cmdline=%r)", + remote_full_path, self.host, result.ProcessId, cmdline) self.add_vuln_port(port='unknown') success = True diff --git a/monkey/infection_monkey/network/smbfinger.py b/monkey/infection_monkey/network/smbfinger.py index 1e765114c..8a267e9d1 100644 --- a/monkey/infection_monkey/network/smbfinger.py +++ b/monkey/infection_monkey/network/smbfinger.py @@ -12,7 +12,7 @@ SMB_SERVICE = 'tcp-445' LOG = logging.getLogger(__name__) -class Packet(object): +class Packet: fields = odict([ ("data", ""), ]) @@ -25,78 +25,79 @@ class Packet(object): else: self.fields[k] = v - def __str__(self): - return "".join(map(str, list(self.fields.values()))) + def to_byte_string(self): + content_list = [(x.to_byte_string() if hasattr(x, "to_byte_string") else x) for x in self.fields.values()] + return b"".join(content_list) ##### SMB Packets ##### class SMBHeader(Packet): fields = odict([ - ("proto", "\xff\x53\x4d\x42"), - ("cmd", "\x72"), - ("errorcode", "\x00\x00\x00\x00"), - ("flag1", "\x00"), - ("flag2", "\x00\x00"), - ("pidhigh", "\x00\x00"), - ("signature", "\x00\x00\x00\x00\x00\x00\x00\x00"), - ("reserved", "\x00\x00"), - ("tid", "\x00\x00"), - ("pid", "\x00\x00"), - ("uid", "\x00\x00"), - ("mid", "\x00\x00"), + ("proto", b"\xff\x53\x4d\x42"), + ("cmd", b"\x72"), + ("errorcode", b"\x00\x00\x00\x00"), + ("flag1", b"\x00"), + ("flag2", b"\x00\x00"), + ("pidhigh", b"\x00\x00"), + ("signature", b"\x00\x00\x00\x00\x00\x00\x00\x00"), + ("reserved", b"\x00\x00"), + ("tid", b"\x00\x00"), + ("pid", b"\x00\x00"), + ("uid", b"\x00\x00"), + ("mid", b"\x00\x00"), ]) class SMBNego(Packet): fields = odict([ - ("wordcount", "\x00"), - ("bcc", "\x62\x00"), + ("wordcount", b"\x00"), + ("bcc", b"\x62\x00"), ("data", "") ]) def calculate(self): - self.fields["bcc"] = struct.pack("i", len(packet_)) + packet_.encode() + packet_ = h.to_byte_string() + n.to_byte_string() + buffer = struct.pack(">i", len(packet_)) + packet_ s.send(buffer) data = s.recv(2048) - if data[8:10] == "\x72\x00": - header = SMBHeader(cmd="\x73", flag1="\x18", flag2="\x17\xc8", uid="\x00\x00") + if data[8:10] == b"\x72\x00": + header = SMBHeader(cmd=b"\x73", flag1=b"\x18", flag2=b"\x17\xc8", uid=b"\x00\x00") body = SMBSessionFingerData() body.calculate() - packet_ = str(header) + str(body) - buffer = struct.pack(">i", len(packet_)) + packet_.encode() + packet_ = header.to_byte_string() + body.to_byte_string() + buffer = struct.pack(">i", len(packet_)) + packet_ s.send(buffer) data = s.recv(2048) - if data[8:10] == "\x73\x16": + if data[8:10] == b"\x73\x16": length = struct.unpack(' res.json()) .then(res => { - res.edges.forEach(edge => { - edge.color = {'color': edgeGroupToColor(edge.group)}; - }); - this.setState({graph: res}); - this.props.onStatusChange(); + if (res.hasOwnProperty("edges")) { + res.edges.forEach(edge => { + edge.color = {'color': edgeGroupToColor(edge.group)}; + }); + this.setState({graph: res}); + this.props.onStatusChange(); + } }); }; @@ -55,14 +57,16 @@ class MapPageComponent extends AuthComponent { this.authFetch('/api/telemetry-feed?timestamp='+this.state.telemetryLastTimestamp) .then(res => res.json()) .then(res => { - let newTelem = this.state.telemetry.concat(res['telemetries']); + if ('telemetries' in res) { + let newTelem = this.state.telemetry.concat(res['telemetries']); - this.setState( - { - telemetry: newTelem, - telemetryLastTimestamp: res['timestamp'] - }); - this.props.onStatusChange(); + this.setState( + { + telemetry: newTelem, + telemetryLastTimestamp: res['timestamp'] + }); + this.props.onStatusChange(); + } }); }; diff --git a/monkey/monkey_island/cc/ui/src/services/AuthService.js b/monkey/monkey_island/cc/ui/src/services/AuthService.js index 9c62bde63..962329720 100644 --- a/monkey/monkey_island/cc/ui/src/services/AuthService.js +++ b/monkey/monkey_island/cc/ui/src/services/AuthService.js @@ -7,6 +7,8 @@ export default class AuthService { "55e97c9dcfd22b8079189ddaeea9bce8125887e3237b800c6176c9afa80d2062" + "8d2c8d0b1538d2208c1444ac66535b764a3d902b35e751df3faec1e477ed3557"; + SECONDS_BEFORE_JWT_EXPIRES = 20; + login = (username, password) => { return this._login(username, this.hashSha3(password)); }; @@ -96,7 +98,7 @@ export default class AuthService { _isTokenExpired(token) { try { - return decode(token)['exp'] < Date.now() / 1000; + return decode(token)['exp'] - this.SECONDS_BEFORE_JWT_EXPIRES < Date.now() / 1000; } catch (err) { return false; diff --git a/monkey/monkey_island/requirements.txt b/monkey/monkey_island/requirements.txt index c6088a3ea..77ff9a620 100644 --- a/monkey/monkey_island/requirements.txt +++ b/monkey/monkey_island/requirements.txt @@ -1,3 +1,5 @@ +pytest +bson python-dateutil tornado werkzeug diff --git a/monkey/pytest.ini b/monkey/pytest.ini new file mode 100644 index 000000000..3d355a4ac --- /dev/null +++ b/monkey/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +log_cli = 1 +log_cli_level = DEBUG +log_cli_format = %(asctime)s [%(levelname)s] %(module)s.%(funcName)s.%(lineno)d: %(message)s +log_cli_date_format=%H:%M:%S +addopts = -v --capture=sys