记一次XEE漏洞的修复

248 阅读5分钟

背景

这个事情的背景是来自公司安全部门的一个安全工单,工单指出我负责的一个系统存在 XML外部实体注入漏洞(XXE注入),工单的大致内容是这样的:

image.png

分析

根据工单的内容,发现漏洞源于基于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,大功告成。