Escaping Sandbox via File Upload

Race Condition File Upload

Modern servers generally don't upload the files directly into thier intended location which is thier filesys hierachy, instead they first upload it to thier TEMPORARY SANDBOXED directory first.

Then they perform validation on the uploaded file to check whether they contain any sort of payloads or malware, and only then they'll transfer it to the destinated folder.

These vulnerabilities are often extremely subtle, making them difficult to detect during blackbox testing unless you can find a way to leak the relevant source code.

<?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 above source code tells us :)

Our target directory is "avatars/".

Our uploaded file is the target file which should be appended to the target directory.

Once we upload our file, it's moved to the temporary sandbox.

Dev has declared a function called checkviruses, and it is called in the if statement.

Dev has also declared a function called checkfiletype, and it is called in the same if statement which checks if the target file matches the backend's MIME type that is jpg/png, then it prints the file is been uploaded [status 200].

If the target file doesn't satisfy the condition, then it unlinks saying there was an error uploading the file [status 400].

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; 
	} 
}

Exploitation

So to exploit this type of race-condition, As you can see from the source code above, 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's possible to execute the file in the small time-window before it is removed by sending an invalid request followed by multiple requests so that the backend get's confused which one to validate first.

Due to the generous time window for this race condition, it is possible to exploit manually by sending two or more than two requests in quick succession using Burp Repeater.

Upload an image whereva there is an image upload functionality checking for jpeg and png.

Notice the image was fetched using a GET request and when we try to access it gives us the location of our image. Ex- /files/avatar/jason.jpg

Now when we try to upload our malicious php file, it restricts us from uploading as it doesn't contain .jpg/png extension and the Content-type is not an image, Even if we tamper all these there's no use because the backend doesn't exec our code. which means our file fails when it was validated by the backend.

Now to send multiple requests, we can install the TURBO INTRUDER from the BApp Store. Select the POST request which contains our exploit.php, send it to the turbo intruder under extensions tab.

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

    request1 = '''<YOUR-POST-REQUEST>'''

    request2 = '''<YOUR-GET-REQUEST>'''

    # 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)

The above python code tells us to paste our POST req and our GET req as request 1 and 2.

Which then eventually iterates in a for loop. The basic functionality is to wait until every race1 request is ready, then send the final byte of each request.

After everything is set, 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.

In the results if we notice that some of the GET requests received a 200 response. These requests hit the server after the PHP file was uploaded, but before it failed validation and was deleted.

NOTE => If the above attack fails, remember to manually terminate the GET request with proper sequence of \r\n\r\n\r

Last updated