记一次参数走私导致的权限绕过

在实际业务中,通常会对请求参数进行解析并进行鉴权处理,来避免类似平行越权的风险。若解析请求参数时与Controller的解析方式存在差异,则可能可以绕过现有的安全措施,

0x00 前言

因为HTTP 是无状态的协议(对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息),其每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次 的发送者是不是同一个人。在进行接口业务请求时,若业务相关的关键参数未与当前的用户身份凭证进行绑定,导致相同权限的不同用户可以互相访问其业务模块。也就是常见的平行越权问题。

实际上很多业务场景下虽然接口繁多,但是基本上操作的资源ID都是有限的,如果在每个service方法对这些资源ID单独处理,一是会引入重复的代码,二是后续新的成员加入项目时,若没有遵循规范的话,在新接口开发时候会略过资源ID的鉴权处理,导致越权问题的发生。所以很多时候在Java Web中都会选择通过类似拦截器的方式,统一对这些资源ID进行鉴权处理。

拦截器从请求中提取资源ID,并结合当前用户的认证信息,进行权限检查。这通常涉及到查询数据库或其他权限管理服务。大致流程如下:

image.png

这种方法可以确保所有请求都经过一个集中的鉴权点,从而避免在每个单独的Service方法中重复权限检查的代码,并且可以降低新成员加入项目时因不熟悉规范而导致的安全风险。

下面是实际代码审计过程中遇到的一个由于解析差异导致的拦截器鉴权绕过问题。

0x01 分析过程

首先简单看看具体的鉴权实现:

在preHandle方法中,首先会获取当前用户的身份信息:

  1. UserInfo userInfo = queryUserDataAuthority(request);

然后调用AuthParam的构造方法,对请求的request进行封装,在构造方法中,根据系统请求方式分别进行不同资源ID的获取。

系统请求方式分为两种,一种是普通的GET请求,一种是application/json的请求。首先是GET请求,主要是通过httpServletRequestWrapper.getParameter()来获取资源ID的,获取到对应的value后会赋值给AuthParam对应的资源ID属性:

  1. public AuthParam(HttpServletRequestWrapper httpServletRequestWrapper) {
  2. String requestMethod = httpServletRequestWrapper.getMethod();
  3. if (HttpMethod.GET.is(requestMethod)) {
  4. String activityIdStr = httpServletRequestWrapper.getParameter(ACTIVITY_ID);
  5. if (StringUtil.isNotBlank(activityIdStr)) {
  6. this.activityId = Long.parseLong(activityIdStr);
  7. }
  8. }else{
  9. //......
  10. }
  11. }

对于其他的请求,会通过util方法获取HttpServletRequest请求的body,然后通过fastjson解析,同样的也会获取到对应的value后会赋值给AuthParam对应的资源ID属性:

  1. String body = getRequestBody(httpServletRequestWrapper);
  2. AuthParam authParam = JSONObject.parseObject(body, AuthParam.class);
  3. if (authParam != null) {
  4. this.activityId = authParam.getActivityId();
  5. //......
  6. }

封装完AuthParam后,会调用checkAuth方法进行权限检查:

  1. if (!checkAuth(userInfo, AuthParam)) {
  2. throw new BizException(ResponseEnum.AUTH_ERROR);
  3. }

在checkAuth中,会调用多个重载的方法对对应的资源ID进行鉴权,以activityID为例:

  1. private boolean checkAuth(UserInfo userInfo, long activityId ,Object handler) {
  2. //管理员无需鉴权
  3. if(isActivityAdmin(userInfo)){
  4. return true;
  5. }
  6. //判断当前请求是否是活动相关接口,如果不需要传递activityId则返回true,公开接口无非鉴权
  7. if (handler instanceof HandlerMethod) {
  8. HandlerMethod handlerMethod = (HandlerMethod) handler;
  9. Method method = handlerMethod.getMethod();
  10. // 获取方法的参数
  11. Parameter[] parameters = method.getParameters();
  12. // 遍历参数
  13. for (Parameter parameter : parameters) {
  14. //检查参数是否包含需要鉴权的资源ID,如果不包含说明不需要鉴权,返回true
  15. //......
  16. }
  17. if(activityId==null&&!isContainAuthParam){
  18. return false;
  19. }
  20. }
  21. //校验当前用户是否有权限操作对应的activity
  22. boolean hasPermission = activityService.hasPermission(userInfo,activityId);
  23. //......
  24. }

以上是大致的鉴权实现,获取请求的资源ID,然后进行鉴权,如果鉴权失败则抛出AUTH失败异常,整体解析过程是没有问题的。那么这里是否还会存在越权风险呢?下面具体进行分析:

1.1 JSON解析模式不一致

根据前面的逻辑,对于application/json的请求会通过fastjson对请求body进行解析,并将解析结果封装到AuthParam对应的资源属性中,用于后续的鉴权校验:

  1. AuthParam authParam = JSONObject.parseObject(body, AuthParam.class);
  2. if (authParam != null) {
  3. activityId = authParam.getActivityId();
  4. //......
  5. }

整体鉴权逻辑上没有什么问题。在Spring Boot中默认使用Jackson作为JSON转换器,主要依赖于Jackson-databind和Jackson-core库。若希望使用Fastjson进行参数解析,一般会剔除掉jacskon的引入,并将Fastjson添加到项目的依赖中,然后注册FastJsonHttpMessageConverter,例如下面的例子:

  1. @Configuration
  2. public class FastjsonConfig {
  3. @Bean
  4. public HttpMessageConverters fastJsonHttpMessageConverters() {
  5. FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
  6. // 定义消息转换器的SupportedMediaTypes
  7. List mediaTypes = java.util.Arrays.asList(
  8. new MediaType("application", "json", UTF_8),
  9. new MediaType("text", "json", UTF_8)
  10. );
  11. fastConverter.setSupportedMediaTypes(mediaTypes);
  12. // 定义消息转换器的FastJson配置类
  13. com.alibaba.fastjson.support.config.FastJsonConfig fastJsonConfig = new com.alibaba.fastjson.support.config.FastJsonConfig();
  14. // 配置日期格式
  15. fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");
  16. // 开启PrettyFormat打印格式化的json
  17. fastJsonConfig.setPrettyFormat(true);
  18. // 配置全局消息转换器
  19. fastConverter.setFastJsonConfig(fastJsonConfig);
  20. return new HttpMessageConverters(fastConverter);
  21. }
  22. }

检索了项目代码,并没有注册FastJsonHttpMessageConverter,同时依赖中仍包含jackson的引入。也就是说,除了拦截器的参数解析逻辑以外,在实际Controller中还是使用jackson进行参数的解析的

那么是不是可以考虑通过Jackson/Fastjson对json的解析差异,达到绕过鉴权逻辑的效果呢?例如可以让拦截器获取到的activityId是当前用户所属的,但是Controller实际解析时却是获取到其他用户的activityId,从而达到平行越权的利用。

1.1.1 Fastjson解析

在Fastjson中,会检查当前字段的类型fieldClass,然后调用对应的方法进行匹配。以当前项目版本1.2.76为例:

image.png

以activityId为例,其类型为Long,会调用scanFieldLong方法进行匹配:

image.png

查看scanFieldLong方法的具体实现,主要作用是解析JSON字符串中的数字字段并将其转换为long类型的值:

image.png

核心方法大致如下,首先在charArrayComprae方法中,会检查目标字符串(src)中的特定子串(由字符数组dest表示)是否与另一个字符数组完全匹配:

image.png

若不匹配则返回0L,其次如果当前字符是负号(-),则标记negative为true,表示这是一个负数,并将指针移动到下一个字符:

image.png

如果当前字符在’0’到’9’的范围内,开始构建数字。将当前字符转换为其对应的数字值,并存储在value变量中:

image.png

在循环中如果遇到非数字字符,则根据字符类型进行不同的处理,例如:

  • 如果是.(小数点),则设置匹配状态为NOT_MATCH(-1),并返回0L。
  • 如果是’,’或’}’(逗号或右大括号),则表示数字结束,处理结束的逻辑。
  • 如果是其他非空白字符,则设置匹配状态为NOT_MATCH(-1),并返回0L。

    在数字解析结束后,会验证数字的有效性。并根据negative的值决定是否返回负数。

    最后将解析出的字段值(fieldValue)设置到目标对象(object)的相应字段上。

    如果 valueParsedtrue,则进入设置字段值的逻辑:

  • 如果 object 为 null,表示目标对象尚未创建,这通常发生在处理复杂类型或集合类型时。在这种情况下,fieldValues 映射会被用来存储字段名和对应的值,以便后续创建对象时使用。

  • 如果 fieldValue 为 null,并且字段类型不是基本数据类型(Integer, Long, Float, Double, Boolean),则调用 fieldDeserializer.setValue 方法来设置字段值。这通常意味着字段类型是一个对象或复杂类型,需要特殊处理。
  • 如果字段类型是 String 类型,并且启用了字符串修剪(trim)特性(通过 Feature.TrimStringFieldValue 标志检查),则调用 trim() 方法来去除字段值字符串的前后空白字符。
  • 最后,调用 fieldDeserializer.setValue 方法将处理后的字段值 fieldValue 设置到目标对象 object 的相应字段上。

image.png

如果没有匹配到字段,则调用parseField方法尝试解析字段名:

image.png

如果是Long类型的话会进行如下解析,如果当前解析位置(i)小于最大解析长度(max),则读取第一个字符并转换为数字,从 48 减去字符的ASCII值(’0’的ASCII值为48),得到数字的第一位,然后循环逐个字符地读取并解析出来数字的每一位:

image.png

以上是大致的参数解析过程。在这个过程中发现一个比较有趣的点。下面以实际例子进行说明。

在Java中,使用第三方库处理JSON数据是非常常见的,而Fastjson是其中一个流行的JSON处理库。当使用Fastjson解析JSON数据时,如果遇到具有重复键的JSON对象,Fastjson的行为是保留最后一个出现的键值对。例如下面的例子:

  1. String body ="{\"activityId\":123,\"activityId\":321}";
  2. AuthParam authParam = JSONObject.parseObject(body, AuthParam.class);
  3. System.out.println(authParam.getActivityId());

按照前面的解析逻辑,这里返回的应该是最后一个出现的键值对321:

image.png

上面提到过如果在解析过程中,如果没有匹配到字段,则调用parseField方法尝试解析字段名,那么假设对上面解析内容修改如下,可以结合charArrayComprae方法来达到匹配失败的效果:

  1. String body ="{\"activityId\"\n:123,\"activityId\":321}";
  2. AuthParam authParam = JSONObject.parseObject(body, AuthParam.class);
  3. System.out.println(authParam.getActivityId());

此时返回的内容却是第一个出现的键值对123:

image.png

原因大致是没有匹配到字段,则调用parseField方法尝试解析字段名,这里的坐标会从第一个参数位置开始进行匹配。

而对于jackson来说,2.13.3版本并不会存在类似的差异,获取到的均是最后一个出现的键值对:

  1. String body ="{\"activityId\"\n:123,\"activityId\":321}";
  2. ObjectMapper objectMapper = new ObjectMapper();
  3. AuthParam authParam = objectMapper.readValue(body, AuthParam.class);

结合这里的解析差异,可以对相应的资源ID进行参数走私,绕过对应的鉴权处理进行平行越权。

Fastjson在处理重复键时行为不一致的情况,这可能是由于Fastjson内部的实现细节导致的。每个版本可能都会有差异,看了下1.2.24版本的fastjson,虽然在没有匹配到字段,调用parseField方法尝试解析字段名的逻辑类似,但是在后续解析时会根据对于重复键值的情况会再匹配一次,此时获取到的结果是最后一个出现的键值对321:

image.png

1.1.2 绕过思路

结合上面的分析,以activityId为例,正常情况下,不论是fastjson还是jackson,获取到的activityId都是一致的。

当尝试以如下方式进行请求时,对于fastjson获取到的是属于当前用户的activityId,而jackson获取到的activityId却是非当前用户所属的:

  1. {
  2. "activityId"
  3. :属于当前用户的activityId,
  4. "activityId":非当前用户所属的activityId
  5. }

此时在checkAuth方法处理时,因为对应的activityId属于当前用户,会返回true,而实际在Controller解析时却是交互的非当前用户所属的activityId,此时绕过了对应的鉴权逻辑,越权获取到了他人的活动信息。

同时,jackson跟fastjson在实际解释时也会有其他的差异,某些畸形JSON仍可正常解析,例如下面的例子,在jackson能正常解析而在fastjson会抛出异常:

image.png

image.png

结合类似的差异,在特定的情况下,例如一些公开接口明显是不需要传递资源ID并鉴权处理的。如果AuthParam中的资源ID内容为null,则认为是公开接口,不进行对应的资源鉴权。通过畸形解析报错,此时获取拦截器到的资源ID为null认为是公开接口,实际上Controller能正常解析并越权获取到了对应的敏感信息。

更多的区别可以参考https://forum.butian.net/share/1679 。同样的,类似WAF等防护设备,同样可能因为解析差异的问题导致防御被绕过,在实际测试中可以额外的关注。

1.2 @RequestMapping的灵活性

除此以外,可以看到对于GET请求,会通过request.getParameter()方法获取对应的资源ID并封装到AuthParam中,用于后续的权限校验。对于普通的GET请求在解析上跟Controller是一致的。但是这里没有考虑到@RequestMapping的灵活性。下面以实际的例子进行说明:

  1. @RequestMapping(value = "/demo")
  2. public String demo(@RequestParam("param") String param){
  3. return "param:"+param;
  4. }

当使用 @RequestMapping 若没有明确指定请求方法(即不使用 @GetMapping@PostMapping 等具体的请求方法注解)时,这个方法将默认对所有HTTP请求方法开放,例如demo接口可以以POST方法甚至是multipart的方式进行请求:

image.png

那么此时明显获取到的AuthParam相关的资源ID为null。结合实际的业务场景,类似一些公开接口明显是不需要传递资源ID并鉴权处理的。如果接口很多,通过excludePathPatterns()维护明显不现实。如果在实际鉴权方法中,是通过类似的逻辑,如果AuthParam中的资源ID内容为null,则认为是公开接口,不进行对应的资源鉴权:

  1. if (activityId == null) {
  2. return true;
  3. }

那么结合@RequestMapping 的特点,可以通过Multipart的方式请求非公开接口,利用拦截器中AuthParam资源ID内容为null而实际Controller能解析对应的值的差异,绕过对应的鉴权措施:

image.png

但是很遗憾,在核心checkAuth方法中,会获取当前请求的Controller(HandlerExecutionChain对象可以通过RequestAttributes在拦截器的preHandle方法中获取。HandlerExecutionChain对象的getHandler方法返回一个Object类型,通常是一个Controller类的实例),然后检查参数中是否定义了对应的资源ID,如果没有才进行豁免。并不能通过上述思路对GET请求的业务接口进行越权操作。

  1. if (handler instanceof HandlerMethod) {
  2. HandlerMethod handlerMethod = (HandlerMethod) handler;
  3. Method method = handlerMethod.getMethod();
  4. // 获取方法的参数
  5. Parameter[] parameters = method.getParameters();
  6. // 遍历参数
  7. for (Parameter parameter : parameters) {
  8. //检查参数是否包含需要鉴权的资源ID,如果不包含说明不需要鉴权,返回true
  9. //......
  10. }
  11. }

在日常的代码审计中也可以额外关注类似的问题,有时候会有意想不到的惊喜。

0x02 其他

上述案例中主要是因为在解析请求参数时,由于拦截器与Controller的参数解析差异导致了对应的绕过风险。实际上除了拦截器以外,Spring中的Aspect也是一个不错的鉴权选择。

其中@Pointcut()是比较常用的方案之一,表示需要切入的位置,比如某些类或者某些方法,也就是先定一个范围,当用户访问到设定范围内的方法,即会执行该切面定义,从而达到鉴权或其他目的。

同时结合excution表达式可以灵活设置生效的方法范围。包括对应的参数。相比上述案例中拦截器的实现,可以不考虑Controller的参数获取方式,直接定位service层方法进行拦截,在一定程度下可避免解析差异导致的安全风险。

  • execution(修饰符匹配式? 返回类型匹配式 类名匹配式? 方法名匹配式(参数匹配式) 异常匹配式?)
  • 发表于 2024-04-23 09:00:02
  • 阅读 ( 17098 )
  • 分类:漏洞分析

1 条评论

tkswifty
tkswifty

64 篇文章

站长统计