Spring Boot视角下的HPP:重复参数如何影响后端参数解析

Spring Boot视角下的HPP:重复参数如何影响后端参数解析

 次点击
78 分钟阅读

背景介绍

一切源于最近遇到一个验证码多发的漏洞场景,正常发送验证码的POST请求参数是:

email=AAA@gmail.com

通过HTTP参数污染(HTTP Parameter Pollution,简称HPP)的方式,重复该email参数:

email=AAA@gmail.com&email=BBB@gmail.com

而后端对该参数也没有进行限制,导致两个邮箱都能收到同一个验证码。

一开始我觉得这个多发的验证码按理来说也可以使用,缓存中存的格式应该是:

"AAA@gmail.com" -> "111111"
"BBB@gmail.com" -> "111111"

但用这个验证码来提交表单会提示“输入验证码不正确”,显得很违背直觉。最终得知缓存中存的格式是:

"AAA@gmail.com,BBB@gmail.com" -> "111111"

但是又想不明白为什么POST参数中的email=AAA@gmail.com&email=BBB@gmail.com会被转成AAA@gmail.com,BBB@gmail.com,本来这个参数就没做校验而导致漏洞,更不可能手动对其转换。所以下文对其原因进行探究。

补充介绍下HTTP参数污染(HTTP Parameter Pollution,简称HPP)。这是一种利用服务器对重复参数处理不当的漏洞。HTTP协议允许同名参数的存在,同时,如果后台处理机制对同名参数的处理方式不当,就会造成“参数污染”,可能导致逻辑漏洞、权限提升或WAF绕过等后果。

不同的Web服务器、框架和编程语言对同名参数的处理方式不同,有的取第一个(如JSP/Tomcat),有的取最后一个(如PHP/Apache),有的合并(如Python/Apache),有的截断......不一而足。本文主要关注Spring Boot框架下的情况。

原因分析

下面简单复现下代码,以便进行分析:

package com.github.captchademo;

public class GetCaptchaReq {
    private String email;
    public GetCaptchaReq() {}
    public String getEmail() { return email;}
    public void setEmail(String email) {  this.email = email;}
}
package com.github.captchademo;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CaptchaController {
    @PostMapping(path = "/getCaptcha", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public Object getCaptcha(GetCaptchaReq req, HttpServletRequest request) {
        String key = req.getEmail();
        return "success";
    }
}

发送的请求体中重复一下email参数:

image-AOsE.png

接下来就是打断点调试,但是断点怎么打很有讲究。如果不是站在上帝视角分析,第一步应该将断点打在最靠近结果的地方,即com.github.captchademo.GetCaptchaReq#setEmail

image-xtUa.png

发现传入的email形参已经是String类型的AAA@gmai.com,BBB@gmail.com,说明转换在setEmail()前就已经发生。大概查看下调用栈:

image-ICwN.png

在一些靠近栈顶的栈帧中,不乏...DataBinder......PropertyAccessor...这些关键词,可以看出来这里是Spring MVC在做一些参数组装的事情,暂时不管,继续往上游去找“边界API”(即谁从请求里拿的参数,其实这时心里应该有数了,是Tomcat容器层在干这个)。往上找到一个栈帧org.springframework.web.servlet.FrameworkServlet#processRequest

image.png

跟进doService()的实现:

image-TXIT.png

跟进org.springframework.web.servlet.DispatcherServlet#logRequest的实现,可以看到关键词getParameterMap()

image-WiPH.png

对这个request的运行时类型求值:

image-zQgS.png

结果是org.apache.catalina.connector.RequestFacade,可以确定底下是Tomcat实现了(Catalina是Tomcat的Servlet容器)。

再往里找就能很容易找到几个关键的方法(在此不对方法做长篇大论的解析,只简单描述作用):

  • org.apache.catalina.connector.Request#parseParameters:负责解析请求体参数, 当Content-Typeapplication/x-www-form-urlencoded时,读取并调用Parameters#processParameters

  • org.apache.tomcat.util.http.Parameters#processParameters:负责把application/x-www-form-urlencodedbytes&=切分,每解析出一对就调用一次addParameter()

  • org.apache.tomcat.util.http.Parameters#addParameter:把一个[参数名, 参数值]对加入到 paramHashValues(类型是LinkedHashMap<String, ArrayList<String>>),这里就是email=AAA@gmail.com&email=BBB@gmail.com"email" -> ["AAA@gmail.com","BBB@gmail.com"]的发生地

下面直接把断点打在这三个方法后再调试:

image-iojw.png

调用Parameters#processParameters

image-qWPy.png

解析出一对[参数名, 参数值],调用Parameters#addParameter

image-PyBH.png

再解析一对:

image-zOrh.png

至此,整个过程的第一步转换:从email=AAA@gmail.com&email=BBB@gmail.com"email" -> ["AAA@gmail.com","BBB@gmail.com"]的转换就结束了。

回顾前文,email最终存的是AAA@gmai.com,BBB@gmail.com,所以还要经历一次转换。

循着调用栈往回找,找到org.springframework.beans.AbstractNestablePropertyAccessor#processLocalProperty

image-suJV.png

可以看到pv.getValue()还是字符数组,到valueToApply就成了字符串。转换发生在valueToApply = this.convertForProperty(tokens.canonicalName, oldValue, originalValue, ph.toTypeDescriptor());这一行。

循着下面调用链跟进:

org.springframework.beans.AbstractNestablePropertyAccessor#convertForProperty
 -> org.springframework.beans.AbstractNestablePropertyAccessor#convertIfNecessary
  -> org.springframework.beans.TypeConverterDelegate#convertIfNecessary
   -> org.springframework.core.convert.support.GenericConversionService#convert
    -> org.springframework.core.convert.support.GenericConversionService

可以找到获取转换器converter的地方,重新调试后在这里打个断点(不然启动服务也会在这里停很多次):

image-Sjkf.png

可以看到这里的converter是NO_OPsourceTypetargetType都是String,一下子有点懵。但继续往下会发现其实是正常的,这里是在ArrayToStringConverter内部在对字符数组的每个元素逐个去问ConversionService怎么转成StringNO_OP_CONVERTER说明“元素级别转换不需要做”。

往下步过,可以看到已经回到数组层面:

image-CLSy.png

继续步过:

image-gpom.png

找到ArrayToStringConverter。继续步过又回到之前的地方:

image-dQkZ.png

继续步过,在最初发现第二步转换踪迹的那个栈帧,valueToAppy发生了变化:

image-uBga.png

至于为什么会以逗号隔开,可以循着下面调用链跟进:

org.springframework.core.convert.support.ArrayToStringConverter#convert
 -> org.springframework.core.convert.support.CollectionToStringConverter#convert
image-OOrI.png

至此,两次转换都已清晰。回顾整个过程,大体上可以按照以下顺序:

image-PEgq.png

写到这再回过头,本文虽然《Spring Boot视角下的HPP》,但是Spring Boot只是让我默认用上了:

  • Spring MVC

  • 内置ConversionService / DataBinder

  • 默认Web 容器Tomcat

所以叫《Tomcat+Spring MVC视角下的HPP》才更贴切。

此外,这个问题,基本可以说是与版本无关的,重复参数变String[]算是Servlet上台的常态;String[]变"A,B"虽然与Spring版本有关,但从很早就这样了:

image-CbQh.png

从Spring Framework 3.0开始就有了,而3.0已经是老东西了:

image-Akhi.png

安全启示

站在一个开发角度,在没有类似经验的情况下,真的很难发现前面的/getCaptcha接口代码有什么问题:

GetCaptchaReq作为一个自定义JavaBean,且没有任何注解,Spring默认当做@ModelAttribute处理(这里也不应该用别的注解,@RequestParam只适用于单个字段值,@RequestBody只适用于JSON场景),从表单参数(application/x-www-form-urlencoded)按字段名进行绑定(验证码接口用form表单也是主流、传统、合理的做法)。

但是在没有类似经验的情况下,漏洞就这么不经意地产生了。

站在安全视角,再黑/白盒层面也可以得到一些启示:

  • 黑盒:对Content-Type: application/x-www-form-urlencoded的GET/POST请求,重复敏感参数,观察重复参数是否被接受,参数是取第一个还是最后一个,是否报错......根据结果判断后端框架,或者在知道后端框架的基础上进行针对性的参数污染

  • 白盒:关注使用application/x-www-form-urlencoded且通过@ModelAttribute(显式或隐式)绑定JavaBean的接口

修改方案

回到前面的/getCaptcha接口代码,要怎么写来避免这种参数污染呢?这里假定业务是不需要接受多邮箱的:

方案一:修改email类型,再在Controller做限制

package com.github.captchademo;

public class GetCaptchaReq {
    private String[] email;
    public GetCaptchaReq() {}
    public String[] getEmail() { return email;}
    public void setEmail(String[] email) {  this.email = email;}
}
@RestController
public class CaptchaController {
    @PostMapping(path = "/getCaptcha", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public Object getCaptcha(GetCaptchaReq req, HttpServletRequest request) {
        if (req.getEmail() == null || req.getEmail().length != 1) {
            throw new IllegalArgumentException("Invalid email parameter");
        }
        String key = req.getEmail()[0];
        return "success";
    }
}
image-NUKA.png
image-OnZU.png

方案二: 从request拿参数 ,再装配进DTO

@RestController
public class CaptchaController {
    @PostMapping(path = "/getCaptcha", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public Object getCaptcha(HttpServletRequest request) {
        String[] emails = request.getParameterValues("email");
        if (emails == null || emails.length == 0) {
            return "email is required";
        }
        if (emails.length != 1) {
            return "HPP detected: duplicated email";
        }

        // 手工装配DTO
        GetCaptchaReq req = new GetCaptchaReq();
        req.setEmail(emails[0]);

        String key = req.getEmail();
        return "success";
    }
}
image-Ljva.png

不过当DTO字段很多时,手动装配比较麻烦。

方案三: 全局Filter

package com.github.captchademo;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Map;

@Component
public class HppBlockFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // 只拦截关心的 content-type
        String ct = request.getContentType();
        boolean isForm = ct != null && ct.startsWith("application/x-www-form-urlencoded");

        if (isForm) {
            Map<String, String[]> pm = request.getParameterMap();

            // 对“必须单值”的字段白名单做检测
            if (isMulti(pm, "email")) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("HPP detected: duplicated parameter");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    private boolean isMulti(Map<String, String[]> pm, String key) {
        String[] v = pm.get(key);
        return v != null && v.length > 1;
    }
}
package com.github.captchademo;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<HppBlockFilter> hppBlockFilterRegistration(HppBlockFilter filter) {
        FilterRegistrationBean<HppBlockFilter> reg = new FilterRegistrationBean<>();
        reg.setFilter(filter);
        reg.addUrlPatterns("/*"); // 全站生效
        reg.setOrder(Ordered.HIGHEST_PRECEDENCE); // 最早执行
        return reg;
    }
}
image-dmFd.png

除此之外,也可以改用JSON,但前端可能有一定的修改成本。

补充:

Content-Type: application/json时,请求体是{"email":"a@gmail.com","email":"b@gmail.com"},这在JSON语法上属于“重复键”。标准JSON对象的键必须唯一,重复键的行为由具体的解析器决定,但主流实现(例如Jackson)会采用后值覆盖前值的策略。

横向对比

开篇有说到,HPP源于不同的Web服务器、框架和编程语言对同名参数的处理方式不同:

1721964742-screen-shot-2019-03-26-at-14-41-52-1.webp

总的来说会有四种情况:

仅取最后一个值

Web环境

参数获取方式

?id=1&id=2

PHP/Apache

$_GET["par"] / $_REQUEST["par"]

id=2

PHP/Zeus

$_GET["par"]

id=2

Go/GoFrame

r.Get("par")

id=2

IBM Lotus Domino

-

id=2

仅取第一个值

Web环境

参数获取方式

?id=1&id=2

JSP/Tomcat

request.getParameter("par")

id=1

JSP/Oracle AS

request.getParameter()

id=1

JSP/Jetty

request.getParameter()

id=1

IBM HTTP Server

-

id=1

Perl (CGI)/Apache

Param("par")

id=1

Flask/Python

request.args.get("par")

id=1

合并所有值(逗号分隔)

Web环境

参数获取方式

?id=1&id=2

ASP.NET/IIS

Request.QueryString("par")

id=1,2

ASP/IIS

Request.QueryString("par")

id=1,2

SpringMVC (String类型)

@RequestParam String par

id=1,2

返回数组/List类型

Web环境

参数获取方式

?id=1&id=2

Python/Apache

getvalue("par")

["1", "2"]

Python/Zope

-

["1", "2"]

Node.js/Express

req.query.par

["1", "2"]

SpringMVC (数组类型)

@RequestParam String[] par

["1", "2"]

SpringMVC (List类型)

@RequestParam List<String> par

["1", "2"]

Go标准库

r.URL.Query()["par"]

["1", "2"]

© 本文著作权归作者所有,未经许可不得转载使用。