Home Format - HackTheBox
Post
Cancel

Format - HackTheBox

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;

  1. Checks to see if the username exist in the redis database. If not, it exit.
  2. 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.
  3. Uses the user’s username, first-name, and last-name in a call to format() 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
  • Inside the box as www-data;
    • Found creds for a local user cooper in the redis database
  • Inside the box as cooper;
    • Exploited a call to format() with user-controlled value in a custom sudo script to leak root password.
This post is licensed under CC BY 4.0 by the author.
Contents