Bypassing file upload filter by source code review in Bolt CMS

I discovered multiple vulnerabilities in an open-source PHP application, Bolt CMS. Chaining them led to a single-click RCE. If you want to read about all the found vulnerabilities in detail, you can find the full advisory here and the exploit here. This article only focuses on the file upload bypass that led to the RCE. The idea is to share how source code can be very helpful in an audit.

So, I already found CSRF and XSS vulnerabilities in the application. I could chain these two vulnerabilities as an unauthenticated attacker, to perform actions in the context of the admin of Bolt CMS. The next step was to get RCE in the application. Typically, applications tend to allow admins to upload any file, including PHP files. But, even as an admin, I could not upload PHP files in Bolt CMS. Neither could I upload files with alternate PHP extensions such as php4, php5, etc. So, I decided to look into the function that is validating the uploaded file, in the hopes of finding a flaw in the logic.

The function that was validating the extension was validateFileExtension()

private function validateFileExtension($filename)
{
    // no UNIX-hidden files
    if ($filename[0] === '.') {
        return false;
    }
    // only whitelisted extensions
    $extension = pathinfo($filename, PATHINFO_EXTENSION);
    $allowedExtensions = $this->getAllowedUploadExtensions();

    return $extension === '' || in_array(mb_strtolower($extension),
$allowedExtensions);
}

This function was not allowing files that started with a dot. So, I could not upload a .htaccess file to add additional valid PHP extensions. Then, the function takes the string after the last dot as the extension and was checking if the extension was present in an array of whitelisted extensions. If not, then the upload stops. Either the file should not have an extension, or the extension should be there in the whitelist.

I could upload “shell.php.” with a dot at the end, but the server does not consider it as a valid PHP file and hence does not execute the code.

This validation logic seemed pretty good and I couldn’t come up with any ideas to circumvent it. So, I moved on and started checking for other issues in the same file upload functionality. There were multiple folders to which an admin can upload files to. So, maybe path traversal?

Let’s say that there were two directories A and B in the location where the admin was allowed to upload. If the file was named ./A/text.txt or ./B/text.txt the upload was successful but anything that is outside the current directory like ../text.txt was not allowed. I wanted to see how this protection was implemented in the code. So, I started XDebug and VSCode to debug the code while a request was made.

Protip: If you’re auditing source code and not using XDebug, you probably should give it a try.

I noticed that when I send a file upload request, after validating the extension, the server has to name the file properly and used a function called rename() for that.

public function rename($path, $newPath)
{
    $path = $this->normalizePath($path);
    $newPath = $this->normalizePath($newPath);
    $this->assertPresent($path);
    $this->assertAbsent($newPath);

    $this->doRename($path, $newPath);
}

And the rename function calls another function called normalizePath(). This was interesting to me because that function “normalized” the path. Here’s what I mean by that. When the name “./A/../text.txt” was passed into normalizePath() function, it returns “text.txt“. The same happens if you pass “./text.txt” because,

./A/../text.txt == ./text.txt == text.txt

So, why was this interesting? Because this manipulation of the filename was happening after validating the extension, and there was no validation after this manipulation. My goal was to upload a PHP file, to get RCE. Here is how I was able to exploit this flow to achieve that.

I uploaded a file with the name “shell.php/.” without quotes. This filename is first passed into the validateFileExtension() function which will consider this as a valid filename because, there were no characters after the last dot, and hence it thinks that it’s a file with no extension. Still, as I mentioned earlier, this wouldn’t get executed because “shell.php/.” is not a valid PHP filename.

Here comes the interesting part, when the name gets passed into the normalizePath() function. Now, according to the logic of this function the last two characters of the filename “/.” refer to the current directory.

./A/. == ./A == A

So it removes it from the name and now the name is “shell.php” and this is exactly what was needed! In the end, I was able to chain my CSRF and XSS to upload a file named “shell.php/.” which got uploaded as “shell.php” and gave me RCE.

I hope this demonstrates how the logic can be unique to the application, and how reviewing source code can be very useful.

Related links

  1. Full advisory
  2. CSRF to RCE exploit
  3. https://github.com/bolt/bolt/security/advisories/GHSA-2q66-6cc3-6xm8
  4. https://github.com/bolt/bolt/security/advisories/GHSA-68q3-7wjp-7q3j