背景介绍
一切源于最近遇到一个验证码多发的漏洞场景,正常发送验证码的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参数:

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

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

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

跟进doService()的实现:

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

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

结果是org.apache.catalina.connector.RequestFacade,可以确定底下是Tomcat实现了(Catalina是Tomcat的Servlet容器)。
再往里找就能很容易找到几个关键的方法(在此不对方法做长篇大论的解析,只简单描述作用):
org.apache.catalina.connector.Request#parseParameters:负责解析请求体参数, 当Content-Type为application/x-www-form-urlencoded时,读取并调用Parameters#processParametersorg.apache.tomcat.util.http.Parameters#processParameters:负责把application/x-www-form-urlencoded的bytes按&、=切分,每解析出一对就调用一次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"]的发生地
下面直接把断点打在这三个方法后再调试:

调用Parameters#processParameters:

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

再解析一对:

至此,整个过程的第一步转换:从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:

可以看到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的地方,重新调试后在这里打个断点(不然启动服务也会在这里停很多次):

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

继续步过:

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

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

至于为什么会以逗号隔开,可以循着下面调用链跟进:
org.springframework.core.convert.support.ArrayToStringConverter#convert
-> org.springframework.core.convert.support.CollectionToStringConverter#convert
至此,两次转换都已清晰。回顾整个过程,大体上可以按照以下顺序:

写到这再回过头,本文虽然《Spring Boot视角下的HPP》,但是Spring Boot只是让我默认用上了:
Spring MVC
内置ConversionService / DataBinder
默认Web 容器Tomcat
所以叫《Tomcat+Spring MVC视角下的HPP》才更贴切。
此外,这个问题,基本可以说是与版本无关的,重复参数变String[]算是Servlet上台的常态;String[]变"A,B"虽然与Spring版本有关,但从很早就这样了:

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

安全启示
站在一个开发角度,在没有类似经验的情况下,真的很难发现前面的/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";
}
}


方案二: 从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";
}
}
不过当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;
}
}
除此之外,也可以改用JSON,但前端可能有一定的修改成本。
补充:
当
Content-Type: application/json时,请求体是{"email":"a@gmail.com","email":"b@gmail.com"},这在JSON语法上属于“重复键”。标准JSON对象的键必须唯一,重复键的行为由具体的解析器决定,但主流实现(例如Jackson)会采用后值覆盖前值的策略。
横向对比
开篇有说到,HPP源于不同的Web服务器、框架和编程语言对同名参数的处理方式不同:

总的来说会有四种情况:
仅取最后一个值:
仅取第一个值:
合并所有值(逗号分隔):
返回数组/List类型: