ZohoOwned :: Zoho ManageEngine Desktop Central 上的关键身份验证绕过

2022-01-23 01:25:11 浏览数 (1)

2021 年 12 月 3 日,Zoho在其 ManageEngine Desktop Central 和 Desktop Central MSP 产品中发布了CVE-2021-44515下的安全公告,以绕过身份验证。2021 年 12 月 17 日,FBI 发布了紧急警报,其中包括威胁参与者使用的技术细节和妥协指标 (IOC)。不久之后,William Vu在做了一些静态分析后发布了一个Attackerkb条目。与此同时,整个十二月,我都在度假!

为什么这很重要?好吧,事实证明,我在 2019 年 12 月审核 Desktop Central 时发现了一些错误。其中之一是绕过身份验证,在阅读了 FBI 报告后,我很快意识到我们正在处理同一个零日!

当时,我只能利用该漏洞触发目录遍历并将 zip 文件写入目标系统(与野外使用的漏洞相同)。由于我没有任何可利用的向量,而且我已经有了CVE-2020-10189,因此我决定不理会它,并将其作为我在模块 5 中的全栈 Web 攻击培训的一部分(在ManageEngine 桌面中心)。我什至向一些学生暗示了部分身份验证绕过!;->

所以从假期回来后,我决定给这个错误一些正义,并理解/改进威胁参与者发起的攻击。首先,我们在这里处理的是什么?

StateFilter 任意转发认证绕过漏洞

在该web.xml文件中,我们找到以下条目:

代码语言:javascript复制
<filter>
  <filter-name>StateFilter</filter-name>
  <filter-class>com.adventnet.client.view.web.StateFilter</filter-class>
</filter>

<filter-mapping>
  <filter-name>StateFilter</filter-name>
  <url-pattern>/STATE_ID/*</url-pattern>
</filter-mapping>

过滤器是通过预认证触发的,通常用于验证客户端数据,例如 csrf 令牌、会话等。让我们检查一下doFilter方法:

代码语言:javascript复制
/*     */   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
/*     */     try {
/*  41 */       Long startTime = new Long(System.currentTimeMillis());
/*  42 */       request.setAttribute("TIME_TO_LOAD_START_TIME", startTime);
/*  43 */       logger.log(Level.FINEST, "doFilter called for {0} ", ((HttpServletRequest)request).getRequestURI());
/*  44 */       StateParserGenerator.processState((HttpServletRequest)request, (HttpServletResponse)response); // 1
/*  45 */       String forwardPath = ((HttpServletRequest)request).getRequestURI();
/*  46 */       if (!WebClientUtil.isRestful((HttpServletRequest)request) || forwardPath.indexOf("STATE_ID") != -1) { // 8 
/*     */         
/*  48 */         String path = getForwardPath((HttpServletRequest)request); // 9
/*  49 */         RequestDispatcher rd = request.getRequestDispatcher(path); // 10
/*  50 */         rd.forward(request, response); // 11
/*     */       }
/*     */       //...

[1]处,代码调用stateParserGenerator.processState攻击者控制的请求:

代码语言:javascript复制
/*     */   public static void processState(HttpServletRequest request, HttpServletResponse response) throws Exception {
/* 288 */     if (StateAPI.prevStateDataRef.get() != null) {
/*     */       return;
/*     */     }
/*     */     
/* 292 */     Cookie[] cookiesList = request.getCookies();
/* 293 */     if (cookiesList == null)
/*     */     {
/* 295 */       throw new ClientException(2, null);
/*     */     }
/*     */ 
/*     */     
/* 299 */     TreeSet set = new TreeSet(new StateUtils.CookieComparator()); // 2
/* 300 */     String contextPath = request.getContextPath();
/* 301 */     contextPath = (contextPath == null || contextPath.trim().length() == 0) ? "/" : contextPath;
/*     */     
/* 303 */     String sessionIdName = request.getServletContext().getSessionCookieConfig().getName();
/* 304 */     sessionIdName = (sessionIdName != null) ? sessionIdName : "JSESSIONID";
/*     */     
/* 306 */     for (int i = 0; i < cookiesList.length; i  ) {
/*     */       //...
/* 316 */       String cookieName = cookie.getName();
/*     */       //...    
/* 334 */       if (cookieName.startsWith("_")) {
/*     */         
/* 336 */         cookiesList[i].setPath(contextPath);
/* 337 */         response.addCookie(cookiesList[i]);
/*     */       }
/* 339 */       else if (cookieName.startsWith("STATE_COOKIE")) {
/*     */         
/* 341 */         set.add(cookiesList[i]); // 3
/*     */       }
/*     */     //...
/* 369 */     if (set.size() == 0) { // 4
/*     */       
/* 371 */       request.setAttribute("STATE_MAP", NULLOBJ);
/* 372 */       if (!WebClientUtil.isRestful(request))
/*     */       {
/* 374 */         throw new ClientException(2, null);
/*     */       }
/*     */       return;
/*     */     }
/* 378 */     Iterator iterator = set.iterator();
/* 379 */     StringBuffer cookieValue = new StringBuffer();
/* 380 */     while (iterator.hasNext()) {
/* 381 */       Cookie currentCookie = (Cookie)iterator.next();
/* 382 */       String value = currentCookie.getValue();
/* 383 */       cookieValue.append(value);
/*     */     } 
/* 385 */     request.setAttribute("PREVCLIENTSTATE", cookieValue.toString());
/* 386 */     Map state = parseState(cookieValue.toString()); // 5
/*     */     //...
/* 388 */     Iterator ite = state.keySet().iterator();
/* 389 */     while (ite.hasNext()) {
/*     */       
/* 391 */       String uniqueId = (String)ite.next();
/* 392 */       Map viewMap = (Map)state.get(uniqueId);
/* 393 */       refIdVsId.put(viewMap.get("ID")   "", uniqueId);
/*     */     } 
/* 395 */     StateAPI.prevStateDataRef.set((state != null) ? state : NULLOBJ);
/* 396 */     if (state != null) {
/*     */       
/* 398 */       if (!WebClientUtil.isRestful(request)) {
/*     */         
/* 400 */         long urlTime = getTimeFromUrl(request.getRequestURI());
/* 401 */         long reqTime = Long.parseLong((String)StateAPI.getRequestState("_TIME")); // 6
/* 402 */         ((Map)state.get("_REQS")).put("_ISBROWSERREFRESH", String.valueOf((urlTime != reqTime && !StateAPI.isSubRequest(request)))); // 7
/*     */       }

为了生存StateParserGenerator.processState,攻击者需要用at [3]TreeSet填充at [2] ,这样他们就不会在[4]处崩溃和烧毁。此外,攻击者需要使用[5]中的方法来制作包含值的特殊映射以生存[6][7]。没有办法从 中返回 null ,我已经想到了!STATE_COOKIEStateParserGenerator.processStatestateStateParserGenerator.parseState

一旦攻击者可以继续前进StateParserGenerator.processState,他们可以使用提供的 URI 设置forwardPath[8],然后设置path[9]

代码语言:javascript复制
/*     */   private String getForwardPath(HttpServletRequest request) {
/*  88 */     String path = request.getContextPath()   "/STATE_ID/";
/*  89 */     String forwardPath = request.getRequestURI();
/*  90 */     if (!forwardPath.startsWith(path))
/*     */     {
/*  92 */       return forwardPath;
/*     */     }
/*  94 */     int index = forwardPath.indexOf('/', path.length());
/*  95 */     if (WebClientUtil.isRestful(request)) {
/*     */       
/*  97 */       forwardPath = forwardPath.substring(path.length() - 1);
/*     */ 
/*     */     
/*     */     }
/* 101 */     else if (index > 0) {
/*     */       
/* 103 */       forwardPath = forwardPath.substring(index);
/*     */     } 
/*     */ 
/*     */     
/* 107 */     return forwardPath;
/*     */   }

现在,该方法的[10][11]处的代码StateFilter.doFilter转发传入请求并绕过过滤器链中的任何其他过滤器或拦截器。转发发生在过滤器内部这一事实非常强大,这意味着任何 HTTP 动词都可用于访问危险的 API。

AgentLogUploadServlet 目录遍历远程代码执行漏洞

StateFilter在修补任意转发之前,此特定错误已在早期版本中修补。和往常一样,我们从web.xml文件开始:

代码语言:javascript复制
<servlet>
  <servlet-name>AgentLogUploadServlet</servlet-name>
  <servlet-class>com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet</servlet-class>
</servlet>

<servlet-mapping>
  <servlet-name>AgentLogUploadServlet</servlet-name>
  <url-pattern>/agentLogUploader</url-pattern>
</servlet-mapping>

正如威胁参与者发现的那样,可以使用StateFilter任意转发访问此 servlet:

代码语言:javascript复制
/*     */   public void doPost(HttpServletRequest request, HttpServletResponse response) {
/*  35 */     reader = null;
/*  36 */     PrintWriter printWriter = null;
/*     */     try {
/*  38 */       computerName = request.getParameter("computerName"); // 1
/*  39 */       String domName = request.getParameter("domainName");
/*  40 */       String customerIdStr = request.getParameter("customerId");
/*  41 */       String resourceidStr = request.getParameter("resourceid");
/*  42 */       String logType = request.getParameter("logType");
/*  43 */       String fileName = request.getParameter("filename"); // 2
/*     */       //... 
/*  66 */       if (managedResourceID != null || branchId != null) {
/*     */         //... 
/*  73 */         String localDirToStore = baseDir   File.separator   wanDir   File.separator   customerIdStr   File.separator   domName   File.separator   computerName; // 3  
/*     */         //... 
/*  84 */         fileName = fileName.toLowerCase();
/*     */         
/*  86 */         if (fileName != null && FileUploadUtil.hasVulnerabilityInFileName(fileName, "zip|7z|gz")) { // 4
/*  87 */           this.logger.log(Level.WARNING, "AgentLogUploadServlet : Going to reject the file upload {0}", fileName);
/*  88 */           response.sendError(403, "Request Refused");
/*     */           
/*     */           return;
/*     */         } 
/*  92 */         String absoluteFileName = localDirToStore   File.separator   fileName; // 5
/*     */         
/*  94 */         this.logger.log(Level.WARNING, "absolute File Name {0} ", new Object[] { fileName });
/*     */ 
/*     */         
/*  97 */         in = null;
/*  98 */         fout = null;
/*     */         try {
/* 100 */           in = request.getInputStream();
/* 101 */           fout = new FileOutputStream(absoluteFileName);
/*     */           
/* 103 */           byte[] bytes = new byte[10000]; int i;
/* 104 */           while ((i = in.read(bytes)) != -1) {
/* 105 */             fout.write(bytes, 0, i); // 6
/*     */           }
/* 107 */           fout.flush();
/* 108 */         } catch (Exception e1) {
/* 109 */           e1.printStackTrace();
/*     */         } finally {
/* 111 */           if (fout != null) {
/* 112 */             fout.close();
/*     */           }
/* 114 */           if (in != null) {
/* 115 */             in.close();
/*     */           }
/*     */         } 

[1][2]处,代码从传入请求中获取computerNamefilename参数,然后在[3]处,代码使用受控的computerName. 然后在[4]处,代码调用FileUploadUtil.hasVulnerabilityInFileName使用zip|7z|gz作为过滤器:

代码语言:javascript复制
/*     */   public static boolean hasVulnerabilityInFileName(String fileName, String allowedFileExt) {
/* 227 */     if (isContainDirectoryTraversal(fileName) || isCompletePath(fileName) || !isValidFileExtension(fileName, allowedFileExt)) {
/* 228 */       return true;
/*     */     }
/* 230 */     return false;
/*     */   }

代码检查文件扩展名是 zip、7z 或 gz 并检查遍历,但没有检查localDirToStoreat [5]中的遍历,稍后用于[6]的受控写入。

补丁

Zoho 通过将 URI 模式添加到安全上下文来修补任意转发,这意味着需要在版本上验证的身份验证10.1.2137.3

代码语言:javascript复制
<security-constraint>
 <web-resource-collection>
     <web-resource-name>Secured Core Context</web-resource-name>
     ...
      <url-pattern>/STATE_ID/*</url-pattern>
 </web-resource-collection>

Zoho 还在AgentLogUploadServlet2021 年 5 月至 2021 年 11 月之间的某个时间修补了目录遍历。doPost保护中的附加检查computerName已在版本上验证10.1.2137.2

代码语言:javascript复制
/*  67 */       if ((domName != null && FileUploadUtil.hasVulnerabilityInFileName(domName)) || (computerName != null && FileUploadUtil.hasVulnerabilityInFileName(computerName)) || (customerIdStr != null && FileUploadUtil.hasVulnerabilityInFileName(customerIdStr)) || (branchId != null && FileUploadUtil.hasVulnerabilityInFileName(branchId)) || 
/*  68 */         !SoMUtil.getInstance().isValidDomainName(domName) || !SoMUtil.getInstance().isValidComputerName(computerName) || !branchId.matches(regex) || !resourceidStr.matches(regex) || !customerIdStr.matches(regex)) {
/*     */         
/*  70 */         this.logger.log(Level.WARNING, "AgentLogUploadServlet : Going to reject the file upload {0} for  computer  {1}  under domain {2} and branch office {4} of customer id {3} ", new Object[] { fileName, computerName, domName, customerIdStr, branchId });
/*  71 */         response.sendError(403, "Request Refused");
/*     */         
/*     */         return;
/*     */       }

开发

在发现时,我无法利用这个漏洞,并且在阅读了 FBI 报告后,很明显,威胁参与者将一个 zip 文件写入C:Program FilesDesktopCentral_Serverlib目录并等待服务器重新启动或强制重新启动。

事实上,它可以是任何扩展,而且它显然没有在Tomcat 文档中提及!这反过来加载了覆盖核心类的恶意 jar 文件(隐藏为 zip 文件)。当这些类在重新启动时从服务器/进程加载时,它们的代码将执行。

如果服务器已启动,威胁参与者还使用/fos/statuscheck安全返回字符串的端点。OK

代码语言:javascript复制
/*    */   private void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/*    */     try {
/* 33 */       String slaveId = ServletUtil.Param.optionalValue(request, "slaveId");
/* 34 */       if (MonitorPool.isEnabled())
/*    */       {
/* 36 */         if (slaveId != null)
/*    */         {
/* 38 */           MonitorPool.getInst().getOrCreate(slaveId).updateLastAccessTime();
/*    */         }
/*    */       }
/* 41 */       ServletUtil.Write.text(response, "ok");
/*    */     }
/*    */   //...
/*    */   }
/*    */ }

有了这个,我决定研究代码以找到可以使用可从StateFilter任意转发访问的 API 重新启动进程和/或服务器的位置,但我在这次尝试中没有成功。

攻击链限制

威胁参与者使用的攻击链有 4 个主要限制:

  1. StateFilter任意转发只是部分身份验证绕过。可以访问 servlet 端点,但不能访问任何 REST api 或 struts ActionForward 类。这是攻击的一个显着弱点。
  2. 目录遍历仅使AgentLogUploadServlet攻击者能够编写 7z、zip 或 gz 文件。
  3. AgentLogUploadServlet目录遍历在比任意转发更早的版本中进行了修补,这StateFilter意味着存在链被破坏的版本
  4. 攻击链需要重新启动服务器,AFAIK 不可能直接由威胁参与者控制。

绕过所有限制

我终于设法找到了一种更好的方法来(ab)StateFilter通过到达ChangeAmazonPasswordServlet. 起初我忽略了这个 servlet,因为我想,无论如何更改 Amazon 密码有什么意义。

代码语言:javascript复制
/*    */ public class ChangeAmazonPasswordServlet
/*    */   extends HttpServlet
/*    */ {
/* 23 */   private Logger logger = Logger.getLogger(ChangeAmazonPasswordServlet.class.getName());
/*    */ 
/*    */ 
/*    */   
/*    */   protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
/* 28 */     String loginName = request.getParameter("loginName");
/*    */     
/*    */     try {
/* 31 */       String productCode = ProductUrlLoader.getInstance().getValue("productcode");
/*    */       
/* 33 */       String newUserPassword = request.getParameter("newUserPassword");
/*    */       
/* 35 */       SYMClientUtil.changeDefaultAwsPassword(loginName, newUserPassword); // 1

[1] 处,代码SYMClientUtil.changeDefaultAwsPassword使用攻击者提供的调用loginNamenewUserPassword

代码语言:javascript复制
/*     */   public static void changeDefaultAwsPassword(String loginName, String newPasswd) throws Exception {
/*     */     try {
/* 139 */       String serviceName = getServiceName(loginName);
/*     */       
/* 141 */       DMUserHandler.addOrUpdateAPIKeyForLoginId(DMUserHandler.getLoginIdForUser(loginName));
/*     */       
/* 143 */       AuthUtil.changePassword(loginName, serviceName, newPasswd); // 2
/* 144 */       SyMUtil.updateSyMParameter("IS_PASSWORD_CHANGED", "true");
/* 145 */       SyMUtil.updateServerParameter("IS_AMAZON_DEFAULT_PASSWORD_CHANGED", "true");
/*     */     }

当我看到[2] 时,我非常怀疑,因为我看到了AuthUtil.changePassword. 当我之前审核时,我记得看到该功能用于其他密码重置功能,因此我决定对其进行快速外部参照:

此代码可以从未经身份验证的上下文更改管理员密码吗?是的!

现在我们已经更改了密码,我们可以登录并访问 Desktop Central 中的任何代理,以获得针对它们的远程代码执行:

此漏洞利用链会影响所有版本,最高可达10.1.2137.2. 在撰写本文时,仍然可以使用最新版本的访客帐户重置管理员密码和/或触发StateFilter任意转发,因为我有不向 Zoho 报告漏洞的习惯,哦不!

这种攻击的唯一限制是更改管理员密码是相当公开的,并且很可能会泄露发生了妥协。

结论

威胁演员,加油!如果你被困在一个错误上,即使已经过去了好几年,也要以全新的心态重新思考它。作为一名专业工程师,您的技能集发展缓慢,有时检查似乎不相关的代码很重要。

这不是我第一次写关于导致身份验证绕过的任意转发漏洞的文章,而且威胁参与者很可能正在阅读这个博客。非常感谢 William Vu 聆听我现场调试此应用程序并允许我在此过程中问他许多问题。

0 人点赞