class MTimeProtectedFastFileStorage

Stores PHP code in files with securely hashed names.

The goal of this class is to ensure that if a PHP file is replaced with an untrusted one, it does not get loaded. Since mtime granularity is 1 second, we cannot prevent an attack that happens within one second of the initial save(). However, it is very unlikely for an attacker exploiting an upload or file write vulnerability to also know when a legitimate file is being saved, discover its hash, undo its file permissions, and override the file with an upload all within a single second. Being able to accomplish that would indicate a site very likely vulnerable to many other attack vectors.

Each file is stored in its own unique containing directory. The hash is based on the virtual file name, the containing directory's mtime, and a cryptographically hard to guess secret string. Thus, even if the hashed file name is discovered and replaced by an untrusted file (e.g., via a move_uploaded_file() invocation by a script that performs insufficient validation), the directory's mtime gets updated in the process, invalidating the hash and preventing the untrusted file from getting loaded.

This class does not protect against overwriting a file in-place (e.g. a malicious module that does a file_put_contents()) since this will not change the mtime of the directory. MTimeProtectedFileStorage protects against this at the cost of an additional system call for every load() and exists().

The containing directory is created with the same name as the virtual file name (slashes removed) to assist with debugging, since the file itself is stored with a name that's meaningless to humans.

Hierarchy

Expanded class hierarchy of MTimeProtectedFastFileStorage

2 files declare their use of MTimeProtectedFastFileStorage
DrupalKernelTest.php in drupal/core/modules/system/lib/Drupal/system/Tests/DrupalKernel/DrupalKernelTest.php
Contains Drupal\system\Tests\DrupalKernel\DrupalKernelTest.
file.inc in drupal/core/includes/file.inc
API for handling file uploads and server file management.

File

drupal/core/lib/Drupal/Component/PhpStorage/MTimeProtectedFastFileStorage.php, line 42
Definition of Drupal\Component\PhpStorage\MTimeProtectedFastFileStorage.

Namespace

Drupal\Component\PhpStorage
View source
class MTimeProtectedFastFileStorage extends FileStorage {

  /**
   * The .htaccess code to make a directory private.
   */
  const HTACCESS = "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nDeny from all\nOptions None\nOptions +FollowSymLinks";

  /**
   * The secret used in the HMAC.
   *
   * @var string
   */
  protected $secret;

  /**
   * Constructs this MTimeProtectedFastFileStorage object.
   *
   * @param array $configuration
   *   An associated array, containing at least these keys (the rest are
   *   ignored):
   *   - directory: The directory where the files should be stored.
   *   - secret: A cryptographically hard to guess secret string.
   *   -bin. The storage bin. Multiple storage objects can be instantiated with
   *   the same configuration, but for different bins.
   */
  public function __construct(array $configuration) {
    parent::__construct($configuration);
    $this->secret = $configuration['secret'];
  }

  /**
   * Implements Drupal\Component\PhpStorage\PhpStorageInterface::save().
   */
  public function save($name, $data) {
    $this
      ->ensureDirectory();

    // Write the file out to a temporary location. Prepend with a '.' to keep it
    // hidden from listings and web servers.
    $temporary_path = $this->directory . '/.' . str_replace('/', '#', $name);
    if (!@file_put_contents($temporary_path, $data)) {
      return FALSE;
    }
    chmod($temporary_path, 0400);

    // Prepare a directory dedicated for just this file. Ensure it has a current
    // mtime so that when the file (hashed on that mtime) is moved into it, the
    // mtime remains the same (unless the clock ticks to the next second during
    // the rename, in which case we'll try again).
    $directory = $this
      ->getContainingDirectoryFullPath($name);
    if (file_exists($directory)) {
      $this
        ->cleanDirectory($directory);
      touch($directory);
    }
    else {
      mkdir($directory);
    }

    // Move the file to its final place. The mtime of a directory is the time of
    // the last file create or delete in the directory. So the moving will
    // update the directory mtime. However, this update will very likely not
    // show up, because it has a coarse, one second granularity and typical
    // moves takes significantly less than that. In the unlucky case the clock
    // ticks during the move, we need to keep trying until the mtime we hashed
    // on and the updated mtime match.
    $previous_mtime = 0;
    $i = 0;
    while (($mtime = $this
      ->getUncachedMTime($directory)) && $mtime != $previous_mtime) {
      $previous_mtime = $mtime;
      chmod($directory, 0700);

      // Reset the file back in the temporary location if this is not the first
      // iteration.
      if ($i > 0) {
        rename($full_path, $temporary_path);

        // Make sure to not loop infinitely on a hopelessly slow filesystem.
        if ($i > 10) {
          $this
            ->unlink($temporary_path);
          return FALSE;
        }
      }
      $full_path = $this
        ->getFullPath($name, $directory, $mtime);
      rename($temporary_path, $full_path);

      // Leave the directory neither readable nor writable. Since the file
      // itself is not writable (set to 0400 at the beginning of this function),
      // there's no way to tamper with it without access to change permissions.
      chmod($directory, 0100);
      $i++;
    }
    return TRUE;
  }

  /**
   * Implements Drupal\Component\PhpStorage\PhpStorageInterface::delete().
   */
  public function delete($name) {
    $directory = dirname($this
      ->getFullPath($name));
    if (file_exists($directory)) {
      $this
        ->cleanDirectory($directory);
      return rmdir($directory);
    }
    return FALSE;
  }

  /**
   * Ensures the root directory exists and has correct permissions.
   */
  protected function ensureDirectory() {
    if (!file_exists($this->directory)) {
      mkdir($this->directory, 0700, TRUE);
    }
    chmod($this->directory, 0700);
    $htaccess_path = $this->directory . '/.htaccess';
    if (!file_exists($htaccess_path) && file_put_contents($htaccess_path, self::HTACCESS)) {
      @chmod($htaccess_path, 0444);
    }
  }

  /**
   * Removes everything in a directory, leaving it empty.
   *
   * @param string $directory
   *   The directory to be emptied out.
   */
  protected function cleanDirectory($directory) {
    chmod($directory, 0700);
    foreach (new DirectoryIterator($directory) as $fileinfo) {
      if (!$fileinfo
        ->isDot()) {
        $this
          ->unlink($fileinfo
          ->getPathName());
      }
    }
  }

  /**
   * Returns the full path where the file is or should be stored.
   *
   * This function creates a file path that includes a unique containing
   * directory for the file and a file name that is a hash of the virtual file
   * name, a cryptographic secret, and the containing directory mtime. If the
   * file is overridden by an insecure upload script, the directory mtime gets
   * modified, invalidating the file, thus protecting against untrusted code
   * getting executed.
   *
   * @param string $name
   *   The virtual file name. Can be a relative path.
   * @param string $directory
   *   (optional) The directory containing the file. If not passed, this is
   *   retrieved by calling getContainingDirectoryFullPath().
   * @param int $directory_mtime
   *   (optional) The mtime of $directory. Can be passed to avoid an extra
   *   filesystem call when the mtime of the directory is already known.
   *
   * @return string
   *   The full path where the file is or should be stored.
   */
  protected function getFullPath($name, &$directory = NULL, &$directory_mtime = NULL) {
    if (!isset($directory)) {
      $directory = $this
        ->getContainingDirectoryFullPath($name);
    }
    if (!isset($directory_mtime)) {
      $directory_mtime = file_exists($directory) ? filemtime($directory) : 0;
    }
    return $directory . '/' . hash_hmac('sha256', $name, $this->secret . $directory_mtime) . '.php';
  }

  /**
   * Returns the full path of the containing directory where the file is or should be stored.
   */
  protected function getContainingDirectoryFullPath($name) {
    return $this->directory . '/' . str_replace('/', '#', $name);
  }

  /**
   * Clears PHP's stat cache and returns the directory's mtime.
   */
  protected function getUncachedMTime($directory) {
    clearstatcache(TRUE, $directory);
    return filemtime($directory);
  }

}

Members

Namesort descending Modifiers Type Description Overrides
FileStorage::$directory protected property The directory where the files should be stored.
FileStorage::deleteAll public function Implements Drupal\Component\PhpStorage\PhpStorageInterface::deleteAll(). Overrides PhpStorageInterface::deleteAll
FileStorage::exists public function Implements Drupal\Component\PhpStorage\PhpStorageInterface::exists(). Overrides PhpStorageInterface::exists 2
FileStorage::load public function Implements Drupal\Component\PhpStorage\PhpStorageInterface::load(). Overrides PhpStorageInterface::load 1
FileStorage::unlink protected function Deletes files and/or directories in the specified path.
FileStorage::writeable public function Implements Drupal\Component\PhpStorage\PhpStorageInterface::writeable(). Overrides PhpStorageInterface::writeable
MTimeProtectedFastFileStorage::$secret protected property The secret used in the HMAC.
MTimeProtectedFastFileStorage::cleanDirectory protected function Removes everything in a directory, leaving it empty.
MTimeProtectedFastFileStorage::delete public function Implements Drupal\Component\PhpStorage\PhpStorageInterface::delete(). Overrides FileStorage::delete
MTimeProtectedFastFileStorage::ensureDirectory protected function Ensures the root directory exists and has correct permissions.
MTimeProtectedFastFileStorage::getContainingDirectoryFullPath protected function Returns the full path of the containing directory where the file is or should be stored.
MTimeProtectedFastFileStorage::getFullPath protected function Returns the full path where the file is or should be stored. Overrides FileStorage::getFullPath
MTimeProtectedFastFileStorage::getUncachedMTime protected function Clears PHP's stat cache and returns the directory's mtime.
MTimeProtectedFastFileStorage::HTACCESS constant The .htaccess code to make a directory private.
MTimeProtectedFastFileStorage::save public function Implements Drupal\Component\PhpStorage\PhpStorageInterface::save(). Overrides FileStorage::save
MTimeProtectedFastFileStorage::__construct public function Constructs this MTimeProtectedFastFileStorage object. Overrides FileStorage::__construct