Skip to main content
Back to blog Samuel
Mortenson

Drupal services private file access bypass via IDOR

There’s a feature in Drupal that not a lot of people know about, but is a great target for security research - private files. Private files allow you to upload files to a non-public directory on your server, then serve them through Drupal instead of through your HTTP server. Drupal is then able to check access for files to determine if the current user can download them.

To determine if a private file can be downloaded, Drupal asks modules implementing “hook_file_download” to perform access checking and return headers if file access is allowed. Most implementations of this are trivial (i.e. allow admins to access an exported archive), but the bulk of the work happens in file_file_download() and editor_file_download(). These hook implementations allow access to a private file if the referencing entity (i.e. the node) is accessible.

An interesting side effect of this logic is that if two nodes are referencing the same private file, you only need access to one node to access the private file. Given this, one way of accessing private files is to somehow get an entity you have access to to reference an existing private file you cannot access.

Knowing all this, I decided to target the Drupal 7 branch of the Services module to see if I could exploit its file handling routes to let me access a private file. Services exposes a REST-like API for performing CRUD operations on entities. Among its default APIs, there’s one for uploading and attaching files to existing entities: /rest/node/%/attach_file. The callback for this resource is _node_resource_attach_file().

Let’s take a look at what a normal curl request to this resource looks like:

curl http://[localdomain]/rest/node/1/attach_file -X POST -F 'files[field_private_file][email protected]’ -F 'field_name=field_private_file' -F 'attach=0'

This request would upload “tmp.txt” to the “field_private_file” field for node 1. Let’s assume the current user has access to create and update nodes of this type, but does not have access to view all nodes, or another user’s nodes.

So how can we use this to download another user’s upload? Let’s look at the code:

function _node_resource_attach_file($nid, $field_name, $attach, $field_values) { [...]   foreach ($file_objs as $key => $file_obj) {     if (isset($field_values[$key])) {       foreach ($field_values[$key] as $key => $value) {         $file_obj->$key = $value;       }     } [...] }

This loop is interesting - it basically allows you to provide field values for your new file when it’s uploaded, which could be used to store things like title and alt text. What else could you set on $file_obj? From parsing file.inc and file_save_upload(), here’s what I saw:

function file_save_upload($form_field_name, $validators = array(), $destination = FALSE, $replace = FILE_EXISTS_RENAME) { [...]   // Begin building file object.   $file = new stdClass();   $file->uid      = $user->uid;   $file->status   = 0;   $file->filename = trim(drupal_basename($_FILES['files']['name'][$form_field_name]), '.');   $file->uri      = $_FILES['files']['tmp_name'][$form_field_name];   $file->filemime = file_get_mimetype($file->filename);   $file->filesize = $_FILES['files']['size'][$form_field_name]; [...]      $file->fid = $existing->fid; [...] }

Do you see the target there? “$file->fid” is the ID of the file, which is set on the file object. If this is set and a file is saved, Drupal will assume it’s an existing file and perform an update operation instead of a create operation.

So, if I know the file ID of another user’s upload (or I just spray a ton of IDs), I might be able to get access to another user’s file. I then tried a new curl command to exploit this:

curl http://[localdomain]/rest/node/2/attach_file -X POST -F 'files[field_private_file][email protected]' -F 'field_name=field_private_file' -F 'field_values[0][fid]=1' -F 'attach=0'

And it worked! Private file 1, which was uploaded by another user, was now referenced by a node that I created. As a result, I now had access to download private file 1.

The services maintainers were quick to respond and address the issue. The fix they landed on was to simply ignore updates to the “fid” property of file objects, which worked out great. Here’s the relevant SA for this bug: https://www.drupal.org/sa-contrib-2019-043

Access bypass, or more specifically IDOR (insecure direct object references) in this case, is a common bug that’s often overlooked, maybe because it’s less exciting than code execution. When communicating the risk of something like this you have to tell a story like “Imagine a site where users can upload tax returns...”, then the severity becomes pretty clear. While Services is now hardened, I’m sure there are many similar bugs floating around Drupal. Happy hunting!