Drupal 8 Remote Code Execution by estimating installation time of site

CVE-2020-13664 | Pentest


Lorenzo Grespan & Sam Thomas

A Cross-Site Request Forgery vulnerability existed in the “interface translation” core module that could be abused to trick a victim into creating arbitrary directories on the server. With the directory path information and a reasonable estimate of the installation time of the site, a remote, unauthenticated attacker could create a state that allowed for the execution of arbitrary code on a Drupal 8 or 9 installation.

Affected versions:

  • Drupal < 8.8.8
  • Drupal < 8.9.1
  • Drupal < 9.0.1

Drupal 7.x was not vulnerable.

** Update **

As suggested by @julianpentest, the use of the “Last-Modified” HTTP header can provide a very reasonable guess of the installation time of a site. Using a list of known files will help narrow down the required value to a small set, which could significantly reduce the time required for the brute forcing.


The attack works best against Windows, even though it is theoretically possible against Linux.

The requirements for the attack to succeed are:

  1. Knowledge of the site installation time. The value can be approximated based on public knowledge, for example by monitoring changes to the site and correlating recent releases of Drupal, and subsequently brute-forced to find the exact value.
  2. Ability to create a directory in a specific location in the document root. For this demonstration, this was achieved via a Cross-Site Request Forgery attack by enticing an administrative user to view a malicious web page while logged on the site. This CSRF attack only works if the site has the “interface translation” module installed. Other methods may exist to create arbitrary directories on a Drupal site with less stringent requirements.

An attacker able to satisfy these requirements would be able to set up a “test site” on a live Drupal Windows installation and write arbitrary content to the root of the web server, ultimately achieving remote code execution.

While the previous requirements are sufficient against a Windows installation, for the attack to be feasible against Linux the requirements vary slightly:

  1. Knowledge of the following properties of the file “bootstrap.inc”:
    – change time (not creation time)
    – inode number
  2. Ability to create a directory as above

The inode value is sequential; even though it could be estimated based on the inode number of other files, it would significantly increase the time required for brute-forcing. This makes Linux systems harder to attack.

Root Cause 

To determine whether a request is meant for a “test site”, Drupal inspects the value of every request’s “User-Agent” HTTP header against a pseudo-random value calculated by combining various sources of entropy.

This code snippet taken from the file “boostrap.inc” demonstrates the problem:

function drupal_valid_test_ua($new_prefix = NULL) { 
    $key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey'; 
    if (!is_readable($key_file)) { 
      header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden'); 
    $private_key = file_get_contents($key_file); 
    // The file properties add more entropy not easily accessible to others. 
    $key = $private_key . filectime(__FILE__) . fileinode(__FILE__); 

As the code shows, the “key” is calculated by concatenating:

  • The content of a “.htkey” file contained in a specific path (more on this later)
  • The creation time and inode of the file “boostrap.inc”

The problems with this approach were:

  1. If the file “.htkey” was to be a directory, file_get_contents() would return an empty string
  2. Microsoft Windows has no concept of inode number (although there’s file id on NTFS, but that is not relevant at the moment); this value is set by PHP to 0

When a user accesses a site for the first time, Drupal would present them with a friendly interface to set it up as a new installation. This includes creating an administrative user and asking the user for details of the database to connect to.

By creating a directory in a carefully chosen path and by guessing the creation time of “boostrap.inc”, an attacker could trick Drupal into believing certain requests were meant for a “test site” by submitting carefully crafted “User-Agent” strings.

Because the test site would not have been configured, an attacker would then be able to point the new site to an external database (under their control) to use as back-end.

Then, by inserting a malicious payload in the database and visiting a specific page on the newly installed malicious site, the attacker could trigger a deserialisation vulnerability that would ultimately write arbitrary content on disk, thus allowing full system-level access to the underlying server with the same privilege level as the user running the web server.

None of the above would disrupt the actual site, as the malicious test site would only be accessible with the correct “User-Agent” string.

To sum up, the attack consisted in the following steps:

  1. Creation of a directory in the document root (via cross-Site Request Forgery or other means)
  2. Brute-forcing the timestamp of the “boostrap.inc” file via custom “User-Agent” strings
  3. Installation of a hidden “test site” using an attacker-controlled database as back-end
  4. Injection of a de-serialisation payload in the database and request of a specific page, triggering the creation of arbitrary content on the root of the web server

Details on the “test site” header bypass 
The following code shows in more details how the check to determine whether the request was meant for a test site worked:

  if (isset($user_agent) && preg_match("/^simple(\w+\d+):(.+):(.+):(.+)$/", $user_agent, $matches)) { 
    list(, $prefix, $time, $salt, $hmac) = $matches; 
    $check_string = $prefix . ':' . $time . ':' . $salt; 
    $test_hmac = Crypt::hmacBase64($check_string, $key); 
    if ($time_diff >= 0 && $time_diff <= 600 && hash_equals($test_hmac, $hmac)) { 
        // it’s a test site 

As the bottom two lines of the snippet show, the final test passes if the two values “test_hmac” and “hmac” are identical (ignoring the “time_diff” comparison for now). The first one “test_hmac” is calculated by applying the “hmacBase64” function to “check_string” and “key”;  this value must match the second one “hmac” which is taken from the user-agent string.

Let us dig into how “test_hmac” is made first by using the following “User-Agent” header as an example:

User-Agent: simpletest5:ts:salt:bTEL6q4[...]p226Q

The header value is composed of four parts:

  1. A prefix starting with “simpletest” (the number just demonstrates that the attack could be repeated several times, using different numeric values)
  2. A timestamp “ts”
  3. A salt
  4. An alphanumeric value (“hmac” in the source code above)

The “check_string” string would then be a concatenation of:

  • The “simpletest5” prefix
  • The timestamp “ts”
  • The salt

To calculate “test_hmac”, the Message Authentication Code (MAC) for “check_string” is computed using “key”, which in turn is generated by the following code:

$key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey'; 
$private_key = file_get_contents($key_file); 
// The file properties add more entropy not easily accessible to others. 
$key = $private_key . filectime(__FILE__) . fileinode(__FILE__);   

Reading from bottom to top, “key” is a concatenation of:

  • The content of the file “.htkey” in the $test_db->getTestSitePath() location; as explained earlier, if “.htkey” is a directory, “key_file” will be the empty string
  • the creation time and inode number of the file “bootstrap.inc”

Keeping in mind that on Windows, PHP would return 0 as the file inode number, the only unknown value left is the creation time of “boostrap.inc”; everything else is under the control of the attacker.

To complicate matters slightly, the timestamp value needs to be within 600 seconds (10 minutes) with respect to server time.

To recap, building a valid “User-agent” string required:

  1. A “check string” composed of a valid site name (“simplesite5”), a timestamp within 600 seconds of the request we’re going to send, and a salt (arbitrary string)
  2. A “key”, generated by concatenating the creation time of “boostrap.inc” with the value 0
  3. A HMAC (message authentication code) of “check string” using “key”

As the only unknown, the creation time of “boostrap.inc” can be determined by estimating the installation time of the site and refined by brute-force. The required granularity is seconds.

For example, if the attacker can estimate the installation day, then brute-forcing the exact time would require 60 * 60 * 24 = 86,400 (60 seconds * 60 minutes * 24 hours) requests, which can easily be sent in parallel.

There might be other ways to know the installation time or time of the latest update of the Drupal site, for example public caches, scripts for uptime measurement or search engine indexes. Other exploits might reveal this information as well.

Creation of the “.htkey” directory 

The “.htkey” directory could be created in several manners, for example:

  • Through legitimate creation of files and folders via SFTP, if allowed by the hosting provider;
  • By exploiting other vulnerabilities;

For this demonstration we’re going to exploit a vulnerability in a core module.

The vulnerable core module was “Interface Translation”, which contained a form that was vulnerable to Cross-Site Request Forgery (CSRF) attacks. Under normal circumstances, the form would be used to create folders to use for storing user-supplied translations of pages. By abusing this functionality, an attacker could create a malicious payload that would create the “.htkey” directory when viewed by an administrative user without the need for any interaction.

Once the directory had been created, it was possible to verify its presence by checking whether requesting it returns 403 or 404. An attacker would then be certain that their malicious web page would have succeeded and could then move to the next step: brute-forcing the creation time of the “boostrap.inc” file.

Brute-forcing creation time 

With an estimate of the site installation time, an attacker could brute-force the creation time of the file “boostrap.inc” by using incremental values to calculate the “key” to compute the “hmac” part of the “User-Agent” HTTP header.

As the request can be carried out asynchronously, the brute-force requests could be run in parallel. Upon a successful request, the application would respond with a specific HTTP return code, indicating the right value had been found

Once found, the valid string can only be used for 600 seconds (10 minutes). However, it is trivial to re-compute it from a known creation time value, thus making the attack repeatable without the need to brute-force the time again.


The “test site” installation process can be fully automated; once the correct User-Agent string had been identified, the entire attack could be completed in less than a minute providing the site could reach the attacker-controlled database.

Once the site had been installed, the attacker would insert a malicious serialised object in the database and request a page on the site. By rendering the page, Drupal would write arbitrary content on disk (for example a PHP web shell), thus allowing the attacker remote code execution with the privileges of the user running the web server.

A variant of the known “Guzzle” gadget  was used as a deserialisation payload, which abuses the destructor of the FileCookieJar class to write arbitrary strings in a cookie on disk.

The deserialised payload was injected in the database as a “dblog” entry, which is a database-backed log that shows events such as administrative log-ins. By visiting the URL of the event, the gadget writes a reverse shell to the root directory. Accessing the shell without the need for any special “User-Agent” string resulted in remote code execution with the privileges of the user running the web server.


Install the latest updates from Drupal: 

  • Drupal 7.x: not vulnerable 
  • Drupal 8.x: install at least version 8.8.8 or 8.9.1 
  • Drupal 9.x: install at least version 9.0.1 

share this post

Share on linkedin
Share on twitter
Share on facebook
Share on reddit