概述
产品在使用内部的后台管理系统时反馈的问题。
于是登录平台,发现如下报错详情:
排查
经过分析,不难得知,请求是从gateway网关转发到对应的统计服务 statistics,此服务有个接口/api/statistics/data/overview
。去ELK查找 statistics 服务的报错日志,没有发现ERROR日志。那就去 gateway 网关服务找,发现如下报错日志:
ERROR | o.s.b.a.web.reactive.error.AbstractErrorWebExceptionHandler | error | 122 | - [96c03c98] 500 Server Error for HTTP POST "/api/statistics/data/overview"
com.alibaba.fastjson.JSONException: autoType is not support. com.aba.rbac.modules.security.security.JwtUser
at com.alibaba.fastjson.parser.ParserConfig.checkAutoType(ParserConfig.java:1542)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:343)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1430)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1390)
at com.alibaba.fastjson.JSON.parse(JSON.java:181)
at com.alibaba.fastjson.JSON.parse(JSON.java:191)
at com.alibaba.fastjson.JSON.parse(JSON.java:147)
at com.alibaba.fastjson.JSON.parseObject(JSON.java:252)
at com.aba.gateway.filter.PermissionFilter.filter(PermissionFilter.java:123)
代码解析
报错的代码如下:
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Sets;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 后台操作权限过滤器
**/
@Slf4j
@Component
public class PermissionFilter implements GlobalFilter, Ordered {
@Autowired
private RedisTemplate redisTemplate;
@Resource
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.header}")
private String tokenHeader;
@Value("${gwb.referer}")
private String imsHost;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String requestPath = request.getURI().getPath();
HttpHeaders headers = request.getHeaders();
// 初始值,默认为false,表示无权限
AtomicBoolean isPermission = new AtomicBoolean(false);
String username = headers.getFirst("username");
String origin = headers.getFirst("origin");
if (!StringUtils.isEmpty(origin)) {
if (!origin.equals(imsHost) && !origin.contains("localhost")) {
log.error("origin非法:{}", origin);
throw new IllegalArgumentException("origin非法!");
}
}
// 排除登录接口
if (!requestPath.contains("/auth/login/ldap") && !requestPath.contains("/api/rbac")) {
Assert.notNull(username, "header中的username不能为空");
final String requestHeader = headers.getFirst(this.tokenHeader);
Boolean invalid;
if (StringUtils.isEmpty(requestHeader)) {
log.error("token为空!");
invalid = true;
} else {
try {
String authToken = requestHeader.substring(7);
invalid = jwtTokenUtil.isTokenExpired(authToken);
String tokenName = jwtTokenUtil.getUsernameFromToken(authToken);
if (!username.equals(tokenName)) {
Response<Void> response = Response.error(9642, "token非法!");
log.info("token中用户与username不一致!");
// 设置body
DataBuffer bodyDataBuffer = response.bufferFactory().wrap(JsonUtil.beanToJson(response).getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(bodyDataBuffer));
}
} catch (Exception e) {
log.error("jwt校验发生异常!", e);
invalid = true;
}
}
if (invalid) {
Response<Void> response = Response.error(9642, "token已失效!");
log.info("token失效!");
//设置body
DataBuffer bodyDataBuffer = response.bufferFactory().wrap(JsonUtil.beanToJson(response).getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(bodyDataBuffer));
}
ValueOperations<String, Object> operations = redisTemplate.opsForValue();
String postData = (String) operations.get(username);
HashSet<String> roles;
if (StringUtils.isBlank(postData)) {
roles = Sets.newHashSet();
} else {
// 报错行
JSONObject jsonObject = JSON.parseObject(postData);
roles = (HashSet<String>) jsonObject.get("roles");
}
if (roles.contains(requestPath)) {
isPermission.set(true);
} else {
roles.forEach(role -> {
if (requestPath.contains(role)) {
isPermission.set(true);
}
});
}
// 停止转发没有用户登录的请求
if (!isPermission.get()) {
Response<Void> response = Response.error(9641, "权限不足,请检查配置!");
DataBuffer bodyDataBuffer = response.bufferFactory().wrap(JsonUtil.beanToJson(response).getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(bodyDataBuffer));
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return Integer.MIN_VALUE;
}
}
代码解析:
检查前端请求的header=origin是否合法,检查header=username是否为空,解析header里的token是否为空,是否过期,从token里解析得到的username与header=username是否一致。全部检查通过后,从Redis里获取该用户username所具备的权限,权限是一个set集合,表示该用户具备的全部请求URL路径。对比权限set<url>
与用户请求的URL,是set集合里的元素,则放行,否则报错权限不足。
另外缓存数据是在登录时放在Redis里,也就是上面的/auth/login/ldap
接口里,各种检查通过后,最后做的事情。代码略。
johnny.wong用户权限信息存入缓存中:com.aba.rbac.modules.security.security.JwtUser@32525c81
与此同时,Google搜索得知。参考enable_autotype,FastJSON在1.2.24及之前的版本存在代码执行漏洞,当恶意攻击者提交一个精心构造的序列化数据到服务端时,由于FastJSON在反序列化时存在漏洞,可导致远程任意代码执行。
自1.2.25+版本,禁用部分autoType的功能,也就是@type
这种指定类型的功能会被限制在一定范围内使用。反序列化对象时,需要检查是否开启autoType。没有开启就会报错。
复现
生产环境发现问题,单纯靠分析日志定位到问题,并能立马解决问题,那就不叫什么问题。
如果可以在本地开发环境复现问题,那就不是什么大问题。
在上面提到的报错行打个调试断点,如期出现报错,那就好办:
报错出现在JSON反序列化那一行:JSONObject jsonObject = JSON.parseObject(postData);
。postData是Redis里面的缓存数据。来看一下Redis里的数据长什么样:
发现这里面的@type
和报错提到的全路径名一模一样。也就是说,我现在遇到的问题,95%概率就是上面Google搜索到的反序列化漏洞场景问题。
此时再来看看 gateway 网关服务最近的更改:
移除gateway网关服务里pom文件指定的版本号,直接使用parent
pom文件里指定的版本号:
即,从1.2.20升级到1.2.83。
之前gateway网关服务使用FastJSON 1.2.20版本时,实际上就存在反序列化漏洞。网关服务存在漏洞啊!!升级到1.2.83版本后,解决反序列化漏洞,但是需要手动开启autoType,autoType默认是关闭的,否则反序列化失败。
解决过程
基于上面的结论,加上本地可以复现问题,解决问题的思路当然简单。
全局
在肯定会执行的Spring Bean类里,增加如下static代码库,开启全局autoType:
@Configuration
public class RedisConfig {
static {
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
}
但是还是有报错:
从上面截图可知,此时的报错@type
是另一个全路径包名。
稳住,不慌。
继续看官方文档,Google搜索官方GitHub issue。
附:报错的源码代码片段:
if ((!internalWhite) && (autoTypeSupport || expectClassFlag)) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= fnv1a_64_magic_prime;
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, true);
if (clazz != null) {
return clazz;
}
}
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
if (Arrays.binarySearch(acceptHashCodes, fullHash) >= 0) {
continue;
}
throw new JSONException("autoType is not support. " + typeName);
}
}
}
全路径
全局开启有问题,那就开启白名单,指定一个个包名全路径:
static {
// ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
ParserConfig.getGlobalInstance().addAccept("com.aba.rbac.modules.security.security.JwtUser");
ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.SimpleGrantedAuthority");
}
无奈还是有报错:
报错源码如下:
if (!autoTypeSupport) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
char c = className.charAt(i);
hash ^= c;
hash *= fnv1a_64_magic_prime;
if (Arrays.binarySearch(denyHashCodes, hash) >= 0) {
if (typeName.endsWith("Exception") || typeName.endsWith("Error")) {
return null;
}
throw new JSONException("autoType is not support. " + typeName);
}
}
}
注:上面两次throw new JSONException("autoType is not support. " + typeName)
,都是在源码的方法
public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {
}
里面。
根据上面的代码,不难得知修复方法如下,既要开启全局autoType,同时还需要手动指定下面这个全路径包名:
static {
// https://github.com/alibaba/fastjson/wiki/enable_autotype
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
ParserConfig.getGlobalInstance().addAccept("com.aba.rbac.modules.security.security.JwtUser");
ParserConfig.getGlobalInstance().addAccept("org.springframework.security.core.authority.SimpleGrantedAuthority");
}
注:为了确保这个static代码块肯定被执行,一定要放在Spring Bean扫描到的类里面,或者直接放在Spring Boot启动类里。
参考
- FastJSON-autoType
- FastJSON autoType漏洞
- enable_autotype