JavaWeb应用修复存储型XSS漏洞

一、问题背景

跨站脚本攻击的英文全称是Cross Site Script,为了和样式表区分,缩写为XSS。发生的原因是网站将用户输入的内容输出到页面上,在这个过程中可能有恶意代码被浏览器执行。跨站脚本攻击,它指的是恶意攻击者往Web页面里插入恶意html代码,当用户浏览该页之时,嵌入其中Web里面的html代码会被执行,从而达到恶意用户的特殊目的。已知的跨站脚本攻击漏洞有三种:1)存储式;2)反射式;3)基于DOM。
1、存储型跨站脚本攻击涉及的功能点:用户输入的文本信息保存到数据库中,并能够在页面展示的功能点,例如用户留言、发送站内消息、个人信息修改等功能点。
2、反射型跨站脚本攻击涉及的功能点:URL参数需要在页面显示的功能点都可能存在反射型跨站脚本攻击,例如站内搜索、查询功能点。
3、基于DOM跨站脚本攻击涉及的功能点:涉及DOM对象的页面程序,包括(不限这些)。

漏洞危害
常见的反射型跨站脚本攻击步骤如下:
1)攻击者创建并测试恶意URL;
2)攻击者确信受害者在浏览器中加载了恶意URL;
3)攻击者采用反射型跨站脚本攻击方式安装键盘记录器、窃取受害者的cookie 、窃取剪贴板内容、改变网页内容(例如下载链接)。

存储型跨站脚本攻击最为常见的场景是将跨站脚本写入文本输入域中,如留言板、博客或新闻发布系统的评论框。当用户浏览留言和评论时,浏览器执行跨站脚本代码。

例如,在某个系统的文本输入框中输入如下的示例攻击代码片段<script>alert(2)</script>,如果弹出如下的弹窗,则说明对应系统存在被XSS攻击的风险:

image.png

加固建议:
总体修复方式:验证所有输入数据,有效检测攻击;对所有输出数据进行适当的编码,以防止任何已成功注入的脚本在浏览器端运行。具体如下 :

1)输入验证:某个数据被接受为可被显示或存储之前,使用标准输入验证机制,验证所有输入数据的长度、类型、语法以及业务规则。

2)输出编码:数据输出前,确保用户提交的数据已被正确进行entity编码,建议对所有字符进行编码而不仅局限于某个子集。

3)明确指定输出的编码方式:不要允许攻击者为你的用户选择编码方式(如ISO 8859-1或 UTF 8)。

4)注意黑名单验证方式的局限性:仅仅查找或替换一些字符(如"<" ">"或类似"script"的关键字),很容易被XSS变种攻击绕过验证机制。

5)警惕规范化错误:验证输入之前,必须进行解码及规范化以符合应用程序当前的内部表示方法。请确定应用程序对同一输入不做两次解码。对客户端提交的数据进行过滤,一般建议过滤掉双引号(”)、尖括号(<、>)等特殊字符,或者对客户端提交的数据中包含的特殊字符进行实体转换,比如将双引号(”)转换成其实体形式",<对应的实体形式是<,<对应的实体形式是>以下为需过滤的常见字符:

6)对参数中的特殊字符进行转义或者编码,如:“’、”、<、>、(、=、.”等特殊字符。

二、修复方案

2.1 统一对输入参数进行拦截和过滤

编写一个Filter过滤器XSSFilter.java

public class XSSFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        /*
         * //设置request字符编码 request.setCharacterEncoding("UTF-8");
         * //设置response字符编码 response.setContentType("text/wrapper;charset=UTF-8");
         */

        HttpServletRequest req = (HttpServletRequest) servletRequest;

        filterChain.doFilter(new XSSRequestWrapper(req), servletResponse);
    }

    @Override
    public void destroy() {

    }
}

接下来需要将该Filter注入到Spring框架中,首先介绍传统的xml配置的方式:

<!-- 配置过滤器 -->
    <filter>
        <filter-name>XSSFilter</filter-name>
        <filter-class>com.test.filter.XSSFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>XSSFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

如果是SpringBoot应用,可以采用如下的配置类的方式:

@Component
public class FilterRegistration {
    /**
     * 配置过滤器
     *
     * @return
     */
    @Bean
    @Order(Integer.MAX_VALUE)
    public FilterRegistrationBean xssFilterRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(XSSFilter());
        registration.addUrlPatterns("/*");
        //registration.setOrder(Integer.MAX_VALUE);//过滤器顺序,也可通过@Order注解配置
        //registration.addInitParameter("paramName", "paramValue");
        registration.setName("XSSFilter");
        return registration;
    }

    /**
     * 创建一个bean
     *
     * @return
     */
    @Bean(name = "XSSFilter")
    public Filter XSSFilter() {
        return new XSSFilter();
    }
}

这里特别说明一下,在一个web应用中,可以开发编写多个Filter,这些Filter组合起来称为一个Filter链。那么有哪些方法可以配置多个Filter之间处理的优先级呢?

如果采用XML配置的方式,web服务器根据Filter在web.xml中的注册顺序,决定先调用哪个Filter,当第一个Filter的doFilter方法被调用时,web服务器会创建一个代表Filter链的FilterChain对象传递给该方法,在doFilter方法中,开发人员如果调用了FilterChain对象的doFilter方法,则web服务器会检查FilterChain对象中是否还有filter,如果有,则调用第二个filter,如果没有,则调用目标资源。详情参考资料1。

如果是采用配置类的方式,可以通过 @Order(Integer.MAX_VALUE)注解,或者registration.setOrder(Integer.MAX_VALUE);其中,填写的参数值越小,执行的优先级就越高。

撰写一个判断JSON格式是否合法的工具类JSONUtils:

public class JSONUtils {
    /**
     * Jackson library
     *
     * @param jsonInString
     * @return
     */
    public final static boolean isJSONValid(String jsonInString) {
        try {
            final ObjectMapper mapper = new ObjectMapper();
            mapper.readTree(jsonInString);
            return true;
        } catch (IOException e) {
            return false;
        }
    }

    public final static boolean isJSONValid1(String test) {
        try {
            JSONObject.parseObject(test);
        } catch (JSONException ex) {
            try {
                JSONObject.parseArray(test);
            } catch (JSONException ex1) {
                return false;
            }
        }
        return true;
    }
}

接下来是重头戏,由于默认的HttpServletRequest是不允许对请求参数和请求路径进行直接修改的(参数是只读的),如果需要修改就需要继承HttpServletRequestWrapper类包装一下,在其提供的钩子方法中修改参数的返回值。

public class XSSRequestWrapper extends HttpServletRequestWrapper {

    private String body;
    private static String[] NO_CHECK_PARAMETER_NAME_LIST = new String[]{};

    private static String[] NO_CHECK_URL_LIST = new String[]{"/test1"};

    private static List<Pattern> NO_CHECK_URL_PATTERN_LIST = Arrays.stream(NO_CHECK_URL_LIST).map(item -> Pattern.compile(item)).collect(Collectors.toList());

    public XSSRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public String getQueryString() {
        return SecurityUtil.escapeHtmlWithoutAmpersand(super.getQueryString());
    }

    @Override
    public Object getAttribute(String name) {
        return super.getAttribute(name);
    }

    /**
     * 重写父类方法
     */
    @Override
    public String getHeader(String name) {
        String value = super.getHeader(name);
        return stripXSS(name, value);
    }

    /**
     * 重写父类方法
     */
    @Override
    public String getParameter(String parameter) {
        String value = super.getParameter(parameter);
        return stripXSS(parameter, value);
    }

    /**
     * 处理query String参数
     */
    @Override
    public String[] getParameterValues(String parameter) {
        String path = super.getServletPath();
        String[] values = super.getParameterValues(parameter);

        // 排除没有请求参数和无需校验的路径
        if (values == null || enableNoCheckURL(NO_CHECK_URL_PATTERN_LIST, path)) {
            return values;
        }

        int count = values.length;
        String[] encodedValues = new String[count];
        for (int i = 0; i < count; i++) {
            encodedValues[i] = stripXSS(parameter, values[i]);
        }
        return encodedValues;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        body = HttpGetBody.getBodyString(super.getRequest());
        ServletInputStream inputStream = null;
        if (StringUtils.isNotEmpty(body)) {
            Map<String, Object> paramMap = JSON.parseObject(body);
            for (Map.Entry<String, Object> entry : paramMap.entrySet()) {
                paramMap.put(StringEscapeUtils.escapeHtml4(entry.getKey()),
                        StringEscapeUtils.escapeHtml4(entry.getValue().toString()));
            }
            body = JSON.toJSONString(paramMap);
            inputStream = new PostServletInputStream(body);
        }
        return inputStream;
    }

    @Override
    public BufferedReader getReader() throws IOException {
        body = HttpGetBody.getBodyString(super.getRequest());
        String encoding = getCharacterEncoding();
        if (encoding == null) {
            encoding = "UTF-8";
        }
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    /**
     * 过滤参数
     *
     * @param value     参数值
     * @param parameter 参数name名
     * @return
     */
    private String stripXSS(String parameter, String value) {
        String newValue = value;

        if (newValue != null && enableNoCheckParameter(parameter)) {
            if (JSONUtils.isJSONValid(newValue)) {
//                HtmlUtils.htmlEscape(value);
//                 StringEscapeUtils.unescapeJson(value);
                newValue = SecurityUtil.escapeJsonWithoutAmpersand(value);
            } else {
//                StringEscapeUtils.escapeHtml4(value);
//                  HtmlUtils.htmlEscape(value);//spring的HtmlUtils进行转义
                newValue = SecurityUtil.escapeHtmlWithoutAmpersand(value);
            }
        }
        return newValue;
    }

    /**
     * 判断name是否应该拦截
     *
     * @param parameter 参数名
     * @return 不拦截返回true,拦截返回false
     */

    private boolean enableNoCheckParameter(String parameter) {
        for (String parameters : NO_CHECK_PARAMETER_NAME_LIST) {
            if (parameter.equals(parameters)) {
                return false;
            }
        }
        return true;
    }

    /**
     * 判断传入的uri是否满足patter
     *
     * @param exclusionPatterns
     * @param uri
     * @return
     */
    public static boolean enableNoCheckURL(List<Pattern> exclusionPatterns, String uri) {
        if (exclusionPatterns != null) {
            uri = uri.trim();
            for (Pattern exclusionPattern : exclusionPatterns) {
                if (isWildCardMatched(uri, exclusionPattern)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * 对指定的文本进行模糊匹配,支持* 和?,不区分大小写
     *
     * @param text    要进行模糊匹配的文本
     * @param pattern 模糊匹配表达式
     * @return
     */
    public static boolean isWildCardMatched(String text, Pattern pattern) {
        Matcher m = pattern.matcher(text);
        return m.matches();
    }
}

这里重点重写了两个方法:getParameter和getParameterValues,getParameter方法是直接通过request获得querystring类型的入参调用的方法。如果是通过springMVC注解类型来获得参数的话,走的是getParameterValues的方法。同时提供了enableNoCheckURLenableNoCheckParameter来实现部分参数和URL地址免过滤白名单的功能,这个白名单可以写死,也可以通过微服务框架中常用的配置管理中心动态获取。

这里的SecurityUtil.escapeHtmlWithoutAmpersandSecurityUtil.escapeJsonWithoutAmpersand是公司安全部提供的工具包,没有条件的可以使用如下开源的替代方案:

StringEscapeUtils.escapeHtml4这个方法来自Apache的工具类,maven坐标如下:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.4</version>
</dependency>

HtmlUtils.htmlEscape(value)是来自云Spring框架,maven坐标如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

最后编写测试程序HelloWorldController.java:

@RestController
@RequestMapping("/hello")
public class HelloWorldController {

    @GetMapping("/say")
    public String say(@RequestParam(name = "keyword", required = false) String keyword) {
        System.out.println(">>>keyword=" + keyword);
        return keyword;
    }

    @PostMapping("/talk1")
    public String talk1(@RequestParam(value = "id", required = false) Integer id,
                        @RequestParam(value = "roleName", required = false) String roleName,
                        @RequestParam(value = "roleDes", required = false) String roleDes) {
        System.out.println(">>>id=" + id + ",roleName=" + roleName + ",roleDes=" + roleDes);
        return JSON.toJSONString(roleName);
    }

    @PostMapping("/talk2")
    public String talk2(@RequestParam(value = "id", required = false) Integer id,
                        @RequestParam(value = "roleName", required = false) RoleName roleName,
                        @RequestParam(value = "roleDes", required = false) String roleDes) {
        System.out.println(">>>id=" + id + ",roleName=" + roleName + ",roleDes=" + roleDes);
        return JSON.toJSONString(roleName);
    }
}

测试1:


image.png

测试2:


image.png

测试3:


image.png

特别说明:
通常情况下,@RequestParam注解是无法直接将String转为JavaBean对象的,如果使用X-form表单提交的数据中还有包含json格式的数据,并且希望直接转换的,可以在类中编写一个valueOf的方法,具体实现可以参考下面的示例:

@Data
public class RoleName implements Serializable {
    private static final long serialVersionUID = -7166756495475187046L;

    private String key1;

    private String key2;

    public static RoleName valueOf(String jsonValue) {
        return JSON.parseObject(jsonValue, RoleName.class);
    }
}

简单做一个小结,上述实现方式是在Spring框架体系中增加了一级XSS相关的Filter,由于涉及到参数的修改,选择覆写HttpServletRequestWrapper类来过滤和修改请求参数,过滤时选择使用到了市面上常见的一些工具类,实现在入参的阶段就对参数进行拦截和过滤,保证问题参数不会直接进入到系统。

2.2. 统一对输出结果进行拦截和过滤

新建一个过滤器XssFilter.java

@WebFilter
@Component
public class XSSFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        XssAndSqlHttpServletRequestWrapper xssRequestWrapper = new XssAndSqlHttpServletRequestWrapper(req);
        chain.doFilter(xssRequestWrapper, response);
    }

    @Override
    public void destroy() {
    }

    /**
     * 过滤json类型的
     *
     * @param builder
     * @return
     */
    @Bean
    @Primary
    public ObjectMapper xssObjectMapper(Jackson2ObjectMapperBuilder builder) {
        //解析器
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        //注册xss解析器
        SimpleModule xssModule = new SimpleModule("XssStringJsonSerializer");
        xssModule.addSerializer(new XssStringJsonSerializer());
        objectMapper.registerModule(xssModule);
        //返回
        return objectMapper;
    }
}

新建一个HTTP请求的包装类XssAndSqlHttpServletRequestWrapper.java,来实现对于参数的过滤和覆写,其中xssObjectMapper这个是后面过滤json类型才用到的。

public class XssAndSqlHttpServletRequestWrapper extends HttpServletRequestWrapper {
    private HttpServletRequest request;

    public XssAndSqlHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
        this.request = request;
    }

    @Override
    public String getParameter(String name) {
        String value = request.getParameter(name);
        if (!StringUtils.isEmpty(value)) {
            value = StringEscapeUtils.escapeHtml4(value);
        }
        return value;
    }

    @Override
    public String[] getParameterValues(String name) {
        String[] parameterValues = super.getParameterValues(name);
        if (parameterValues == null) {
            return null;
        }
        for (int i = 0; i < parameterValues.length; i++) {
            String value = parameterValues[i];
            parameterValues[i] = StringEscapeUtils.escapeHtml4(value);
        }
        return parameterValues;
    }
}

StringEscapeUtils.escapeHtml4这个方法来自Apache的工具类,maven坐标如下:

<dependency>
 <groupId>org.apache.commons</groupId>
 <artifactId>commons-text</artifactId>
 <version>1.4</version>
</dependency>

新建XssStringJsonSerializer.java实现自定义反序列化

/**
 * @Description 这里是通过修改SpringMVC的json序列化来达到过滤xss的目的的。
 * @Author louxiujun
 * @Date 2020/9/5 23:40
 **/
public class XssStringJsonSerializer extends JsonSerializer<String> {
    @Override
    public Class<String> handledType() {
        return String.class;
    }

    @Override
    public void serialize(String value, JsonGenerator jsonGenerator,
                          SerializerProvider serializerProvider) throws IOException {
        if (value != null) {
            String encodedValue = StringEscapeUtils.escapeHtml4(value);
            jsonGenerator.writeString(encodedValue);
        }
    }
}

最后编写一个测试类:

@RestController
@RequestMapping(value = "/test")
public class TestController {

    @GetMapping(value = "/query")
    public Object testQuery(@RequestParam(value = "name", required = false) String name) {
        return name;
    }

    @PostMapping(value = "/json")
    public Object testJSON(@RequestBody TestRequestParam testRequestParam) {
        return testRequestParam;
    }


    @PostMapping(value = "/xform")
    public Object xform1(@RequestParam(value = "name", required = false) String name) {
        System.out.println(name);
        return name;
    }
}

测试1:测试get请求中的query String 中包含非法参数


image.png

测试2:测试HTTP POST 请求json格式的body体中含有非法参数


image.png

测试3:测试HTTP POST请求x-www-form-urlencoded格式的body体中含有非法参数


image.png

简单小结一下,同样采用了Filter过滤器和自定义Http wrapper的实现方法,该实现方式采用的是在返回结果返回给前端的做json序列化的时候进行参数的拦截和过滤,问题数据本质上已经存入系统中了。

参考资料

  1. 04_过滤器Filter_03_多个Filter的执行顺序
  2. 【快学SpringBoot】过滤XSS脚本攻击(包括json格式)