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
文件中,我们找到以下条目:
<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
方法:
/* */ 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
攻击者控制的请求:
/* */ 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]
/* */ 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
文件开始:
<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:
/* */ 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]处,代码从传入请求中获取computerName
和filename
参数,然后在[3]处,代码使用受控的computerName
. 然后在[4]处,代码调用FileUploadUtil.hasVulnerabilityInFileName
使用zip|7z|gz
作为过滤器:
/* */ 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 并检查遍历,但没有检查localDirToStore
at [5]中的遍历,稍后用于[6]的受控写入。
补丁
Zoho 通过将 URI 模式添加到安全上下文来修补任意转发,这意味着需要在版本上验证的身份验证10.1.2137.3
<security-constraint>
<web-resource-collection>
<web-resource-name>Secured Core Context</web-resource-name>
...
<url-pattern>/STATE_ID/*</url-pattern>
</web-resource-collection>
Zoho 还在AgentLogUploadServlet
2021 年 5 月至 2021 年 11 月之间的某个时间修补了目录遍历。doPost
保护中的附加检查computerName
已在版本上验证10.1.2137.2
:
/* 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
/* */ 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 个主要限制:
StateFilter
任意转发只是部分身份验证绕过。可以访问 servlet 端点,但不能访问任何 REST api 或 struts ActionForward 类。这是攻击的一个显着弱点。- 目录遍历仅使
AgentLogUploadServlet
攻击者能够编写 7z、zip 或 gz 文件。 AgentLogUploadServlet
目录遍历在比任意转发更早的版本中进行了修补,这StateFilter
意味着存在链被破坏的版本- 攻击链需要重新启动服务器,AFAIK 不可能直接由威胁参与者控制。
绕过所有限制
我终于设法找到了一种更好的方法来(ab)StateFilter
通过到达ChangeAmazonPasswordServlet
. 起初我忽略了这个 servlet,因为我想,无论如何更改 Amazon 密码有什么意义。
/* */ 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
使用攻击者提供的调用loginName
和newUserPassword
:
/* */ 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 聆听我现场调试此应用程序并允许我在此过程中问他许多问题。