Format is a nice medium linux machine on HackTheBox. It features a custom web application for creating blogs that is vulnerable to arbitrary read and write, which is easy to detect as the full application source code is accessible in a public Gitea repo. Once inside, you’ll be recovering some creds from the backend redis server, and exploiting a format string vulnerability in a python program to gain root
.
Format
Recon
NMAP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Nmap 7.80 scan initiated Wed Aug 23 11:38:22 2023 as: nmap -sC -sV -oN nmap.txt -v 10.10.11.213
Nmap scan report for 10.10.11.213
Host is up (0.50s latency).
Not shown: 997 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
80/tcp open http nginx 1.18.0
| http-methods:
|_ Supported Methods: GET HEAD
|_http-server-header: nginx/1.18.0
|_http-title: Site doesn't have a title (text/html).
3000/tcp open http nginx 1.18.0
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0
|_http-title: Did not follow redirect to http://microblog.htb:3000/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Aug 23 11:39:36 2023 -- 1 IP address (1 host up) scanned in 73.75 seconds
Web
A very neat looking web app for bloggers;
Was able to register an account and login;
Created a blog named testing
, which interestingly spawned a new subdomain on the site;
Going to the edit
link, we were given options to add contents to the blog;
Gitea
Port 3000
is running Gitea, and one public repo was found;
This seems to contain the source code of the blogging web app, so I downloaded it for analysis;
Nothing special was found when going through previous commits. Going through the source code, the code used to register a user account showed the application using a redis server as backend database via a unix socket file, and there is a user setting named pro
, which is set to false
by default;
The code for creating blogs showed they are stored in /var/www/microblog/
+ the name of the blog (which is strictly lowercase alphabets, so no room for code injection);
No part of the code made any changes to Nginx config, so I am guessing it is somehow configured to serve all directories in that location as subdomains.
Going through the code used to add contents to the blog in the edits page, it is vulnerable to arbitrary write as no attempt was made to validate the value of the POST parameter id
before it was opened as a file for writing;
I tried to exploit this and write to the PHP files, but it didn’t work. This is because when creating a new blog, the template files were first made non-writable before being copied to their web directory using cp -rp
, where the p
preserves the permissions.
Notice the application is storing the id
to a file named order.txt
. This is the same file used by the application when building the page;
This gives us an LFI vulnerability because even if we attempt to write to a file we can’t write to, the code won’t error out, and the filename will be stored in order.txt
, which will be loaded when building the page. Testing this by setting id
to /etc/passwd
during edit, it worked;
Foothold
With the LFI established, I am interested in how Nginx is configured in this box. Dumping the default config showed something interesting;
Notice how the config uses parts of requested URL when routing using proxy_pass
. For a request like /static/dom/path
, this will resolve to http://dom.microbucket.htb/path
.
We know the application is communicating with redis through a unix socket file, and that the data of each user is stored with the user’s username as key in the redis server;
1
2
3
4
5
$redis->HSET(trim($_POST['username']), "username", trim($_POST['username']));
$redis->HSET(trim($_POST['username']), "password", trim($_POST['password']));
$redis->HSET(trim($_POST['username']), "first-name", trim($_POST['first-name']));
$redis->HSET(trim($_POST['username']), "last-name", trim($_POST['last-name']));
$redis->HSET(trim($_POST['username']), "pro", "false"); //not ready yet, license keys coming soon
The proxy_pass
command of Nginx can communicate with unix socket files using the unix
protocol. Testing this, I was able to update my user testuser
to pro
by requesting the path unix:/var/run/redis/redis.sock:testuser pro true /path
, which expands to http://unix:/var/run/redis/redis.sock:testuser pro true .microbucket.htb/path
. Since we are still using the HTTP protocol, the request method, followed with everything after redis.sock:
are writting to the socket;
The above request sent HSET testuser pro true
to our redis socket, likely with other inputs too which will simply be treated as invalid commands and ignored. This gave us pro access to the application;
I now have access to the image upload feature, which creates a new upload/
directory in the blog;
The following code showed the upload
directory is made writable when we have pro permissions;
This means we could use the arbitrary write vulnerability discovered earlier to write PHP files into this directory. Testing this, I was able to gain code execution and a shell on the box;
User
The box has redis-cli
installed, so I used it to explore the redis server. I got a password;
There is a local user named cooper
on the box, so I tried login into his account, and it worked;
PrivEsc
cooper
is not part of any special group, but he does have a sudo permission on a custom python script;
1
2
3
4
5
6
7
8
9
cooper@format:~$ sudo -l
Matching Defaults entries for cooper on format:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User cooper may run the following commands on format:
(root) /usr/bin/license
cooper@format:~$ file /usr/bin/license
/usr/bin/license: Python script, ASCII text executable
cooper@format:~$ ~
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#!/usr/bin/python3
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.fernet import Fernet
import random
import string
from datetime import date
import redis
import argparse
import os
import sys
class License():
def __init__(self):
chars = string.ascii_letters + string.digits + string.punctuation
self.license = ''.join(random.choice(chars) for i in range(40))
self.created = date.today()
if os.geteuid() != 0:
print("")
print("Microblog license key manager can only be run as root")
print("")
sys.exit()
parser = argparse.ArgumentParser(description='Microblog license key manager')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-p', '--provision', help='Provision license key for specified user', metavar='username')
group.add_argument('-d', '--deprovision', help='Deprovision license key for specified user', metavar='username')
group.add_argument('-c', '--check', help='Check if specified license key is valid', metavar='license_key')
args = parser.parse_args()
r = redis.Redis(unix_socket_path='/var/run/redis/redis.sock')
secret = [line.strip() for line in open("/root/license/secret")][0]
secret_encoded = secret.encode()
salt = b'microblogsalt123'
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(),length=32,salt=salt,iterations=100000,backend=default_backend())
encryption_key = base64.urlsafe_b64encode(kdf.derive(secret_encoded))
f = Fernet(encryption_key)
l = License()
#provision
if(args.provision):
user_profile = r.hgetall(args.provision)
if not user_profile:
print("")
print("User does not exist. Please provide valid username.")
print("")
sys.exit()
existing_keys = open("/root/license/keys", "r")
all_keys = existing_keys.readlines()
for user_key in all_keys:
if(user_key.split(":")[0] == args.provision):
print("")
print("License key has already been provisioned for this user")
print("")
sys.exit()
prefix = "microblog"
username = r.hget(args.provision, "username").decode()
firstlast = r.hget(args.provision, "first-name").decode() + r.hget(args.provision, "last-name").decode()
license_key = (prefix + username + "{license.license}" + firstlast).format(license=l)
print("")
print("Plaintext license key:")
print("------------------------------------------------------")
print(license_key)
print("")
license_key_encoded = license_key.encode()
license_key_encrypted = f.encrypt(license_key_encoded)
print("Encrypted license key (distribute to customer):")
print("------------------------------------------------------")
print(license_key_encrypted.decode())
print("")
with open("/root/license/keys", "a") as license_keys_file:
license_keys_file.write(args.provision + ":" + license_key_encrypted.decode() + "\n")
#deprovision
if(args.deprovision):
print("")
print("License key deprovisioning coming soon")
print("")
sys.exit()
#check
if(args.check):
print("")
try:
license_key_decrypted = f.decrypt(args.check.encode())
print("License key valid! Decrypted value:")
print("------------------------------------------------------")
print(license_key_decrypted.decode())
except:
print("License key invalid")
print("")
Taking a close look at the script, it seems to be loading some secret from /root/license/secret
into the variable secret
. It then connects to the redis database and query users depending on the arguments that were passed.
When given the -p
flag followed by a username, the script process it as follows;
- Checks to see if the username exist in the redis database. If not, it exit.
- Checks to see if the username is in
/root/license/keys
, which seems to be used to store username and license key. If it is, it exits. - Uses the user’s
username
,first-name
, andlast-name
in a call toformat()
to build a string that is later printed.
This last point is very interesting because both username
, first-name
, and last-name
are values we control, and passing untrusted data to format()
can cause bad things as we could be able to leak data into the string;
The only object we will have access to in the context of format()
is license
, which is an instance of the License
class, and doesn’t really have anything interesting. However, all class objects in python has the attribute __init__.__globals__
, which is a dictionary containing key-value mappings of all global objects in it’s scope;
Since our secret
variable was defined globally outside of any function, we could leak it by setting any of the values we control that are used in the format()
call to {license.__init__.__globals__}
;
This leaked the secret successfully;
Using that as a password, I was able to login as root
;
Summary
- NMAP discovered port 22 (SSH), 80 (Nginx), and 3000 (Gitea)
- Gitea instance has a public repo with containing the full source of the web app
- Exploited an SSRF to query backend redis, and elevate to pro user
- Exploited an arbitrary write vulnerability to gain a shell as
www-data
- Exploited an arbitrary write vulnerability to gain a shell as
- Inside the box as
www-data
;- Found creds for a local user
cooper
in the redis database
- Found creds for a local user
- Inside the box as
cooper
;- Exploited a call to
format()
with user-controlled value in a custom sudo script to leak root password.
- Exploited a call to