背景
这个事情的背景是来自公司安全部门的一个安全工单,工单指出我负责的一个系统存在 XML外部实体注入漏洞(XXE注入),工单的大致内容是这样的:
分析
根据工单的内容,发现漏洞源于基于shiro+cas的单点认证框架,当系统收到cas server发来的logoutRequest登出通知时,如果对请求报文xml进行特殊构建,就会触发该漏洞,所以,实际上这个漏洞是对xml内容解析时导致的。
至此,我们对漏洞的所在有了明确的了解,解决方式有两种:一是: 定位到xml解析的工具类,对工具类进行定制来消除次漏洞; 二是: 直接升级依赖版本
系统当前使用的依赖版本为shiro 1.11
<properties>
<version.shiro>1.11.0</version.shiro>
<properties>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>${version.shiro}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-cas</artifactId>
<version>${version.shiro}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>${version.shiro}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>${version.shiro}</version>
</dependency>
无论哪种方式,都需要在代码上定位到漏洞所在
源码分析
上面提到,系统收到cas server登出通知时,shiro进行登出操作时触发的,所以我们需要看下登出时shiro都做了哪些操作,首先找到登出的Filter
/**
* Licensed to Jasig under one or more contributor license
* agreements. See the NOTICE file distributed with this work
* for additional information regarding copyright ownership.
* Jasig licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a
* copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.jasig.cas.client.session;
import org.jasig.cas.client.util.AbstractConfigurationFilter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* Implements the Single Sign Out protocol. It handles registering the session and destroying the session.
*
* @author Scott Battaglia
* @version $Revision$ $Date$
* @since 3.1
*/
public final class SingleSignOutFilter extends AbstractConfigurationFilter {
private static final SingleSignOutHandler handler = new SingleSignOutHandler();
public void init(final FilterConfig filterConfig) throws ServletException {
if (!isIgnoreInitConfiguration()) {
handler.setArtifactParameterName(getPropertyFromInitParams(filterConfig, "artifactParameterName", "ticket"));
handler.setLogoutParameterName(getPropertyFromInitParams(filterConfig, "logoutParameterName", "logoutRequest"));
}
handler.init();
}
public void setArtifactParameterName(final String name) {
handler.setArtifactParameterName(name);
}
public void setLogoutParameterName(final String name) {
handler.setLogoutParameterName(name);
}
public void setSessionMappingStorage(final SessionMappingStorage storage) {
handler.setSessionMappingStorage(storage);
}
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;
if (handler.isTokenRequest(request)) {
handler.recordSession(request);
} else if (handler.isLogoutRequest(request)) {
handler.destroySession(request);
// Do not continue up filter chain
return;
} else {
log.trace("Ignoring URI " + request.getRequestURI());
}
filterChain.doFilter(servletRequest, servletResponse);
}
public void destroy() {
// nothing to do
}
protected static SingleSignOutHandler getSingleSignOutHandler() {
return handler;
}
}
这是系统默认的登出filter,看源码我们可以知道,登出的相关逻辑都是在 SingleSignOutHandler中进行的,SingleSignOutHandler 中有一个方法:
public void destroySession(final HttpServletRequest request) {
final String logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName);
if (log.isTraceEnabled()) {
log.trace ("Logout request:\n" + logoutMessage);
}
final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
if (CommonUtils.isNotBlank(token)) {
final HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token);
if (session != null) {
String sessionID = session.getId();
if (log.isDebugEnabled()) {
log.debug ("Invalidating session [" + sessionID + "] for token [" + token + "]");
}
try {
session.invalidate();
} catch (final IllegalStateException e) {
log.debug("Error invalidating session.", e);
}
}
}
}
安全工单指出的XEE漏洞就发生在第7行,XmlUtils对报文进行解析的时候;通过查看XmlUtils源码:
package org.jasig.cas.client.util;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.helpers.XMLReaderFactory;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
/**
* Common utilities for easily parsing XML without duplicating logic.
*
* @author Scott Battaglia
* @version $Revision: 11729 $ $Date: 2007-09-26 14:22:30 -0400 (Tue, 26 Sep 2007) $
* @since 3.0
*/
public final class XmlUtils {
/**
* Static instance of Commons Logging.
*/
private final static Log LOG = LogFactory.getLog(XmlUtils.class);
/**
* Get an instance of an XML reader from the XMLReaderFactory.
*
* @return the XMLReader.
*/
public static XMLReader getXmlReader() {
try {
return XMLReaderFactory.createXMLReader();
} catch (final SAXException e) {
throw new RuntimeException("Unable to create XMLReader", e);
}
}
...
}
发现真正用来解析xml报文的是:org.xml.sax.XMLReader的实现类,具体用哪个实现类的逻辑在XMLReaderFactory.createXMLReader():
public static XMLReader createXMLReader () throws SAXException
{
String className = null;
ClassLoader cl = ss.getContextClassLoader();
// 1. try the JVM-instance-wide system property
//private static final String property = "org.xml.sax.driver";
try {
className = ss.getSystemProperty(property);
}
catch (RuntimeException e) { /* continue searching */ }
// 2. if that fails, try META-INF/services/
if (className == null) {
if (!_jarread) {
_jarread = true;
String service = "META-INF/services/" + property;
InputStream in;
BufferedReader reader;
try {
if (cl != null) {
in = ss.getResourceAsStream(cl, service);
// If no provider found then try the current ClassLoader
if (in == null) {
cl = null;
in = ss.getResourceAsStream(cl, service);
}
} else {
// No Context ClassLoader, try the current ClassLoader
in = ss.getResourceAsStream(cl, service);
}
if (in != null) {
reader = new BufferedReader (new InputStreamReader (in, "UTF8"));
_clsFromJar = reader.readLine ();
in.close ();
}
} catch (Exception e) {
}
}
className = _clsFromJar;
}
// 3. Distro-specific fallback
if (className == null) {
className = "com.sun.org.apache.xerces.internal.parsers.SAXParser";
}
...
}
概括一下就是:依次尝试读取system property:org.xml.sax.driver、"META-INF/services/" + property、"com.sun.org.apache.xerces.internal.parsers.SAXParser"。如果前两个都没有获取到的话,默认使用"com.sun.org.apache.xerces.internal.parsers.SAXParser"。
解决
我的项目并没有单独引入过其他的xml解析器,所以默认使用"com.sun.org.apache.xerces.internal.parsers.SAXParser",明确实现类之后,我们通过查阅资料可以知道,可以通过禁止 doctype的方式修复XEE漏洞,具体方式是修改getXmlReader()方式:
public static XMLReader getXmlReader() {
try {
XMLReader xmlReader = XMLReaderFactory.createXMLReader();
xmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
xmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false);
xmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
return xmlReader;
} catch (SAXException var1) {
throw new RuntimeException("Unable to create XMLReader", var1);
}
}
回到项目本身,问题找到了,也知道XmlUtils要如何修改了,怎么用修改过的XmlUtils替换源码中的XmlUtils呢?
首先我们copy一份XmlUtils,仅对getXmlReader()做如上修改:
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.helpers.XMLReaderFactory;
public final class XmlUtils {
private static final Log LOG = LogFactory.getLog(XmlUtils.class);
public XmlUtils() {
}
public static XMLReader getXmlReader() {
try {
XMLReader xmlReader = XMLReaderFactory.createXMLReader();
xmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
// This may not be strictly required as DTDs shouldn't be allowed at all, per previous line.
xmlReader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
xmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false);
xmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
return xmlReader;
} catch (SAXException var1) {
throw new RuntimeException("Unable to create XMLReader", var1);
}
}
...
}
其次,我们通过上面的分析知道,XmlUtils在SingleSignOutHandler中被调用,SingleSignOutHandler对象在 SingleSignOutFilter 中被创建,并使用,而shiro+cas框架是允许我们自定义 登出filter的,所以我们只需要自定义SingleSignOutFilter的实现 CustomSingleSignOutFilter :
package com.morzorz.portal.auth;
import org.jasig.cas.client.session.SessionMappingStorage;
import org.jasig.cas.client.util.AbstractConfigurationFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
public class CustomSingleSignOutFilter extends AbstractConfigurationFilter {
private static final CustomSingleSignOutHandler handler = new CustomSingleSignOutHandler();
public void init(final FilterConfig filterConfig) throws ServletException {
if (!isIgnoreInitConfiguration()) {
handler.setArtifactParameterName(getPropertyFromInitParams(filterConfig, "artifactParameterName", "ticket"));
handler.setLogoutParameterName(getPropertyFromInitParams(filterConfig, "logoutParameterName", "logoutRequest"));
}
handler.init();
}
public void setArtifactParameterName(final String name) {
handler.setArtifactParameterName(name);
}
public void setLogoutParameterName(final String name) {
handler.setLogoutParameterName(name);
}
public void setSessionMappingStorage(final SessionMappingStorage storage) {
handler.setSessionMappingStorage(storage);
}
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;
if (handler.isTokenRequest(request)) {
handler.recordSession(request);
} else if (handler.isLogoutRequest(request)) {
handler.destroySession(request);
// Do not continue up filter chain
return;
} else {
log.trace("Ignoring URI " + request.getRequestURI());
}
filterChain.doFilter(servletRequest, servletResponse);
}
public void destroy() {
// nothing to do
}
protected static CustomSingleSignOutHandler getSingleSignOutHandler() {
return handler;
}
}
将原来的handler替换成我们自定义的CustomSingleSignOutHandler,你可以发现这里的XmlUtils已经替换成了我们修改过的
import com.morzorz.portal.util.XmlUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jasig.cas.client.session.HashMapBackedSessionMappingStorage;
import org.jasig.cas.client.session.SessionMappingStorage;
import org.jasig.cas.client.util.CommonUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
public class CustomSingleSignOutHandler {
private final Log log = LogFactory.getLog(this.getClass());
private SessionMappingStorage sessionMappingStorage = new HashMapBackedSessionMappingStorage();
private String artifactParameterName = "ticket";
private String logoutParameterName = "logoutRequest";
public CustomSingleSignOutHandler() {
}
public void setSessionMappingStorage(SessionMappingStorage storage) {
this.sessionMappingStorage = storage;
}
public SessionMappingStorage getSessionMappingStorage() {
return this.sessionMappingStorage;
}
public void setArtifactParameterName(String name) {
this.artifactParameterName = name;
}
public void setLogoutParameterName(String name) {
this.logoutParameterName = name;
}
public void init() {
CommonUtils.assertNotNull(this.artifactParameterName, "artifactParameterName cannot be null.");
CommonUtils.assertNotNull(this.logoutParameterName, "logoutParameterName cannot be null.");
CommonUtils.assertNotNull(this.sessionMappingStorage, "sessionMappingStorage cannote be null.");
}
public boolean isTokenRequest(HttpServletRequest request) {
return CommonUtils.isNotBlank(CommonUtils.safeGetParameter(request, this.artifactParameterName));
}
public boolean isLogoutRequest(HttpServletRequest request) {
return "POST".equals(request.getMethod()) && !this.isMultipartRequest(request) && CommonUtils.isNotBlank(CommonUtils.safeGetParameter(request, this.logoutParameterName));
}
public void recordSession(HttpServletRequest request) {
HttpSession session = request.getSession(true);
String token = CommonUtils.safeGetParameter(request, this.artifactParameterName);
if (this.log.isDebugEnabled()) {
this.log.debug("Recording session for token " + token);
}
try {
this.sessionMappingStorage.removeBySessionById(session.getId());
} catch (Exception var5) {
}
this.sessionMappingStorage.addSessionById(token, session);
}
public void destroySession(HttpServletRequest request) {
String logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName);
if (this.log.isTraceEnabled()) {
this.log.trace("Logout request:\n" + logoutMessage);
}
String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
if (CommonUtils.isNotBlank(token)) {
HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token);
if (session != null) {
String sessionID = session.getId();
if (this.log.isDebugEnabled()) {
this.log.debug("Invalidating session [" + sessionID + "] for token [" + token + "]");
}
try {
session.invalidate();
} catch (IllegalStateException var7) {
this.log.debug("Error invalidating session.", var7);
}
}
}
}
private boolean isMultipartRequest(HttpServletRequest request) {
return request.getContentType() != null && request.getContentType().toLowerCase().startsWith("multipart");
}
}
到此,我们只需要将我们自定义的filter注册一下就可以了:
@Bean
public CustomSingleSignOutFilter singleSignOutFilter() {
CustomSingleSignOutFilter ssof = new CustomSingleSignOutFilter();
ssof.setLogoutParameterName(casServerUrlPrefix);
return ssof;
}
/**
* 注册单点登出filter
* @return
*/
@Bean
public FilterRegistrationBean logOutFilter(CustomSingleSignOutFilter singleSignOutFilter){
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(singleSignOutFilter); // 该值缺省为false,表示生命周期由SpringApplicationContext管理,设置为true则表示由ServletContainer管理
filterRegistration.addInitParameter("targetFilterLifecycle", "true");
filterRegistration.setEnabled(true);
filterRegistration.setOrder(1); // 注意,此处需小于 shiroFilter的顺序值 eg:值越小,filter执行顺序越靠前
filterRegistration.addUrlPatterns("/*");
filterRegistration.addServletNames("default");
return filterRegistration;
}
OK,大功告成。