Avoiding unrestricted file upload – straight to the source

Escrito por  Thiago Bispo

File uploads have become essential for numerous web applications, ranging from social media platforms to cloud storage services. They enable users to easily share documents, images, videos, and other data, enhancing both functionality and user experience. However, alongside these benefits, file uploads introduce a myriad of security challenges that, if not adequately addressed, can lead to significant vulnerabilities and potential data breaches. This article was written to show the risks associated with unrestricted file upload and describe the impacts it could bring to organizations.

Applications used by end users to upload files, such as profile pictures, receipts, etc., can bring uncontrolled costs if they are not limited. To limit file uploads, we can use rate limiting, file type validation, and max size support, controlling what customers can upload straight to the source. Proper file upload control could lead to many vulnerabilities. One example is an attacker obtaining control of the server via command execution. However, many other problems can also arise, such as storage costs that may result in overbilling. While cloud storage prices have risen over the years, every effort to reduce the need to store files is welcome. 

Vulnerabilities associated with unrestricted file upload

To demonstrate vulnerabilities caused by unrestricted file upload and the associated controls, I will use Laravel Framework 11.3.1, a framework for developing web applications in PHP. I have created a simple file upload form at route /files/create, and as I upload files to the server, it populates the route /files with the file name and size.

Empty uploaded files route

Route for uploaded files

Uploading "banana.jpg"

Uploading “banana.jpg”

At the moment, no validation has been applied. Below, you can see the code snippet for the store method in my file upload controller:

public function store(Request $request)
{
    // Store the file
    $file = $request->file('file_upload');
    $filePath = $file->store('uploads', 'public');
    // Redirect to /files 
    if(Storage::disk('public')->assertExists($filePath)){
        return redirect()->route('uploads.index')->with('success', "File uploaded successfully.");
    }
    else{
        return redirect()->route('uploads.index')->withErrors("Failed to upload file.");
    }  
}

To show the harms of the lack of proper validation, I will use two pictures of bananas for scale, obviously:

File banana.jpg, with an approximate size of 11 KB

File banana.jpg, with an approximate size of 11 KB

File very_bananas.jpg, with an approximate size of 214 KB

File very_bananas.jpg, with an approximate size of 214 KB

Storage overload

Let’s consider the potential consequences of a storage overload. I started by uploading banana.jpg once and saw it had been uploaded to the files route. By default, Laravel stores the file with a random name, so the file won’t be overwritten if I upload the same file again:

The uploaded file (banana.jpg) is stored with a random name.

A malicious actor trying to exploit this application will start by checking if there is a size limit for file upload. For the sake of demonstration, we are going to use very_bananas.jpg as our big file:

Uploading a big file (very_bananas.jpg) to check for a file size limit.

As the file gets uploaded successfully, the malicious actor could try to repeat the upload process to overload the storage. If the files were being stored in a cloud provider, this could heavily increase the amount of space used, potentially leading to a significant increase in the storage bill at the end of the month. In our case, it is a banana overload:

Intercepting the file upload POST request in Burp Suite

Intercepting the file upload POST request in Burp Suite

Sending the request to the Intruder module and configuring it to send 50 requests

Sending the request to the Intruder module and configuring it to send 50 requests

Requests are sent without a limit, filling the server with big files (very_bananas.jpg)

Requests are sent without a limit, filling the server with big files (very_bananas.jpg)

This is terrible news, as someone has to pay for our Monkey’s Heaven storage. 

Remote command execution

Comparatively to other vulnerabilities, the most harmful thing an attacker could do to our application is upload something malicious, such as a .php file with web shell code, and execute it (accessing via browser the path to the stored file) to obtain control of our server and run any command. Below is a simple webshell PHP file named “webshell.php.php”.

<?php
    system($_GET['cmd']);
?>
Uploading an HTML with a stored XSS proof of concept (PoC) with extension .html because Laravel strips the last extension by default

Uploading an HTML with a stored XSS proof of concept (PoC) with extension .html because Laravel strips the last extension by default

Executing command ‘whoami’ via web shell uploaded. An attacker could get the file's name in the /files route but would have to “guess” the absolute path where the file was stored.

Executing command ‘whoami’ via web shell uploaded. An attacker could get the file’s name in the /files route but have to “guess” the absolute path where the file was stored.

Stored cross-site scripting

Another vulnerability an attacker could exploit in this scenario is Cross-site Scripting (XSS). He could upload an HTML file containing a stored XSS payload and use the link to create social engineering attacks.

Uploading an HTML with a stored XSS proof of concept (PoC) with extension .html because Laravel strips the last extension by default

Uploading an HTML with a stored XSS proof of concept (PoC) with extension .html because Laravel strips the last extension by default

The content of the PoC can be seen below:

<html>
    <img src=x onerror=alert(document.cookie)>
</html>

When we directly access the file’s path, it executes the alert, characterizing a stored XSS.

Accessing the PoC file directly triggers the XSS.

Accessing the PoC file directly triggers the XSS.

Storage of a phishing web page

An attacker could also use the file storage to upload a malicious HTML file that could be used as a phishing page.

Uploading a phishing web page

Uploading a phishing web page

Once the malicious file was uploaded, the attacker could spread the link to the HTML page to people, trying to trick them into clicking and downloading malware.

Malicious web pages stored

Malicious web pages stored

When the stored file is accessed, the phishing page can be seen, which could trick a victim into clicking to open an Excel spreadsheet and downloading malware instead.

Opening the malicious web page stored. An attacker could share the link to this page to people to distribute malware.

Opening the malicious web page stored. An attacker could share the link to this page with people to distribute malware.

It is essential to state that if a malicious file is stored in public cloud storage, an attacker could use it as a payload globally, abusing the organization’s storage to distribute attacks in its name.

Impacts of unrestricted file upload

A summary of the impacts presented by unrestricted file upload can be seen below:

  • Storage Overload:
    • Without restrictions, attackers can upload excessively large files or numerous files to consume storage space, leading to storage overflows. These can impact normal operations, prevent legitimate users from uploading necessary files, and result in overbilling with storage providers.
  • Remote Code Execution
    • If an attacker uploads a file the server executes, it can lead to remote code execution (RCE). This allows the attacker to run arbitrary commands on the server, potentially taking full control of the system. This vulnerability could also result in additional impacts, such as an attacker downloading other malicious files or malware or establishing command and control artifacts.
  • Cross-site Scripting (XSS)
    • Uploaded files containing malicious scripts can be used to perform cross-site scripting attacks. When other users download or view these files, the embedded scripts can execute in their browsers, potentially stealing cookies, session tokens, or other sensitive information. This would be shared in the organization’s domain where the vulnerability was exploited.
  • Phishing
    • In the same way that malicious scripts can be stored to perform XSS, a phishing page could be stored, tricking a user into clicking a link that could download malware or lead him to another malicious website. This would also be shared in the organization’s domain where the vulnerability was exploited.

Security controls to avoid unrestricted file upload

As shown, the lack of file upload security controls could bring a lot of risk to our application. So, to avoid letting malicious actors abuse the file upload, there are some controls we can establish:

1 – File type validation:

It is possible to use the mime type for file type validation:

$request->validate([
    'file_upload' => 'required|mimes:pdf,jpg,png',
]);

If the file is stored in a cloud storage service, an attacker could not execute or access the uploaded file directly, but the malicious file would have been uploaded anyway. So, it is always good practice to limit the uploaded file type via mime type (which checks the file’s content). If you validate only by the file extension or name, the attacker could easily bypass it. Now, if the attacker tries to upload a malicious PHP file, he gets an error message:

Unable to upload php file.

Unable to upload php file.

2 – Max Size:

The max size validation only stores the file if its size is less or equal to the stated value. Updating the code, it would look like this:

$request->validate([
    'file_upload' => 'required|mimes:pdf,jpg,png|max:200',
]);

The default unit is in KB. Now, uploading a file of more than 200 KB is impossible. When we try to upload our very_bananas.jpg with this validation in place, we get an error message:

Default max size validation violation message, shown in the frontend.

The default max size validation violation message is shown on the front end.

3 – Rate Limit:

A malicious actor could still fill our storage with the small (banana.jpg) file, even with the last two controls. To avoid this, we can place a rate limit, which limits the rate of the requests a user can make. To do this, we update our store function by adding the manual Rate Limiting control:

(RateLimiter::tooManyAttempts('upload-file:'.$request->session()->get('laravel_session'), $perMinute = 2)) {
    return response("Too many attempts!", 429);
}      

RateLimiter::increment('upload-file:'.$request->session()->get('laravel_session'));

With this, we can only make two requests per minute. Our updated store method is shown below:

public function store(Request $request)

{

    if (RateLimiter::tooManyAttempts('upload-file:'.$request->session()->get('laravel_session'), $perMinute = 2)) {

        return response("Too many attempts!", 429);

    }

   

    RateLimiter::increment('upload-file:'.$request->session()->get('laravel_session'));


    // Validate the uploaded file

    $request->validate([

        'file_upload' => 'required|mimes:pdf,jpg,png|max:200',

    ]);


    // Store the file

    $file = $request->file('file_upload');

    $filePath = $file->store('uploads', 'public');


        // Redirects to /files

    if(Storage::disk('public')->assertExists($filePath)){

        return redirect()->route('uploads.index')->with('success', "File uploaded successfully.");

    }

    else{

        return redirect()->route('uploads.index')->withErrors("Failed to upload file.");

    }

}

Note that I have created the rate limit bound to the user session (laravel_session), but you can use anything that is out of user control here, so he could not bypass the limitation. When we try to upload more than two pictures in the same minute manually, we receive an error message:

Error trigged when rate limit is reached.

An error was triggered because the rate limit was reached.

Even trying to automate the upload using the Burp Suite Intruder module, we get the 429 status code at each third request in the minute (executing three requests per minute):

Burp Suite Intruder shows the requests, status code, and a response from the server with rate limit.

Burp Suite Intruder shows the requests, status codes, and responses from the server with a rate limit.

Conclusion

The steps shown explain why those validation controls are essential to avoid unrestricted file upload vulnerabilities. The same could be applied to any other programming language or framework with a little more or less effort. Still, it is necessary to use those security controls to eliminate the risks of unrestricted file uploads.

Autor

Logo da Hakai.