You are here

November 2021

A persistent Drupal 7 exploit using a pluggable variable

A couple of years ago I was asked to take a look at a Drupal 7 site that was performing poorly where a colleague had spotted a strange function call in an Application Performance Management (APM) system.

The APM traces we were looking at included a __lamda_func under which was a class called Ratel. Under those were some apparent external calls to some dodgy looking domains.

One of my very excellent colleagues had done some digging and found some more details about the domains which confirmed their apparent dodginess.

They had also come across a github gist which looked relevant - it had the PHP source code for a Ratel class which appears to be an SEO spam injection tool:

This gist included encoded versions of the dodgy URLs we'd seen when trying to analyse what was slowing the site down.

However it wasn't immediately obvious how this code was running within the infected Drupal site.

We'd grepped the file system and not found any signs of this compromise. One trick that's sometimes useful is to search a recent database dump.

Doing so turned up a reference to the Ratel class within the cache tables, but when we took a closer look inside the cache there wasn't much more info to go on:

$ drush ev 'print_r(cache_get("lookup_cache", "cache_bootstrap"));'
stdClass Object
    [cid] => lookup_cache
    [data] => Array
            [cRatel] => 
            [iRatel] => 
            [tRatel] => 

So this was more evidence that the malicious code had been injected into Drupal, but didn't tell us how.

I took a closer look at the malicious source code and noticed something it was doing to try and hide from logged in users:

  if (function_exists('is_user_logged_in')) {
    if (is_user_logged_in()) {
      return FALSE;

Being so used to reading Drupal code, I think I'd initially thought this was a Drupal API call.

However, on closer inspection I realised it's actually a very similarly named WordPress function.

That meant that the function almost certainly would not exist in this Drupal site, and that gave me a way to hook into the malicious code and find out more about how it had got into this site.

I temporarily added a definition for this function to the site's settings.php within which I output some backtrace information to a static file - something like this:

function is_user_logged_in() {
  $debug = debug_backtrace();
  file_put_contents('/tmp/debug.txt', print_r($debug, TRUE), FILE_APPEND);
  return FALSE;

This quickly yielded some useful info - along the lines of:

$ cat debug.txt 
    [0] => Array
            [file] => /path/to/drupal/sites/default/files/a.jpg(9) : runtime-created function
            [line] => 1
            [function] => is_user_logged_in
            [args] => Array
    [1] => Array
            [file] => /path/to/drupal/sites/default/files/a.jpg
            [line] => 10
            [function] => __lambda_func
            [args] => Array
    [2] => Array
            [file] => /path/to/drupal/includes/
            [line] => 2524
            [args] => Array
                    [0] => /path/to/drupal/sites/default/files/a.jpg
            [function] => require_once

Wow, so it looked like the malicious code was hiding inside a fake jpg file in the site's files directory.

Having a look at the fake image, it did indeed contain code very similar to what we'd been looking at in the gist, albeit further wrapped in obfuscation.

$ file sites/default/files/a.jpg    
sites/default/files/a.jpg: PHP script, ASCII text, with very long lines, with CRLF line terminators

The malicious Ratel code had been encoded and serialized, and the fake image file was turning that obfuscated string back into executable code and creating a dynamic function from it:

$serialized = '** LONG STRING OF OBFUSCATED CODE **';
$rawData = array_map("base64_decode", unserialize($serialized));
$rawData = implode($rawData);
$outputData = create_function(false, $rawData);

That's where the lamda function we'd been seeing had come from.

The final piece of the puzzle was how this fake image file was actually being executed during the Drupal bootstrap.

The backtrace we'd extracted gave us the answer; the require_once call on line 2524 of was this:

2524           require_once DRUPAL_ROOT . '/' . variable_get('session_inc', 'includes/');
2525           drupal_session_initialize();
2526           break;

So the attacker had managed to inject the path to their fake image into the session_inc Drupal variable.

This was further confirmed by the fact that the malicious code in the fake image actually included the real Drupal session code itself, so as not to interfere with Drupal's normal operation.


So although the Ratel class had perhaps initially been put together with WordPress in mind, the attacker had tailored the exploit very specifically to Drupal 7.

Drupal has a mechanism to disallow uploaded files from being executed as PHP but that didn't help in this case as the code was being included from within Drupal itself.

At some point there must have been something like a Remote Code Execution or SQL Injection vulnerability on this site which allowed the attacker to inject their variable into the database.

It's possible that was one of the notorious Drupal vulnerabilities often referred to as Drupalgeddon 1 and 2, but we don't know for sure. We believe that the site was most likely infected while at a previous host.

On the other hand, perhaps it was as simple as a poorly protected phpMyAdmin or something similar which allowed the attacker to manipulate the variables table.

This technique doesn't represent a vulnerability in itself, as the attacker needed to be able to upload the fake image and (most importantly) inject their malicious variable into the site.

It was, however, quite an interesting technique for achieving persistence within the Drupal site.

Once we'd uncovered all of these details, cleaning up the infection was as simple as deleting the injected variable and removing the malicious fake image file.

What could the site have done to defend itself against this attack?

Well the injection of the variable may have been via an exploit of an unpatched vulnerability on the site. Keeping up-to-date with patches from the Drupal Security Team is always advisable.

I'd certainly recommend against having a tool like phpMyAdmin publicly accessible (although we don't know for sure that's what had happened in this case).

Other than that, something like the mimedetect module might have been able to prevent the upload of the fake image file. Note that newer versions of Drupal have this capability built-in.

A manual review of the variables in the site's database could have caught this; there are a handful of variables that provide "pluggability" in D7 but session_inc is probably one of the most attractive from an attacker's point of view as it's typically invoked on most bootstraps unlike some of the others:

drupal-7.x$ grep -orh "variable_get.*\.inc')" includes modules | sort | uniq
variable_get('lock_inc', 'includes/')
variable_get('menu_inc', 'includes/')
variable_get('password_inc', 'includes/')
variable_get('path_inc', 'includes/')
variable_get('session_inc', 'includes/')

A simple drush command can show whether any of these variables are set:

$ drush vget _inc
No matching variable found.

Once we knew what had happened to the site we found a couple of references online to similar exploits: