Hello,这里是爱 Coding,爱 Hiphop,爱喝点小酒的 AKA 柏炎。
我们部门每次大版本发布,都需要走一道公司的安全测试。
这不最近公司的安全测试标准提高了,我所负责的用户服务被一口气提了10个安全问题。
好家伙,3.25没跑了。
不过用户中心是核心的底层业务服务,它的数据安全性与系统稳定性都是极其重要的,发现了Bug,我们只能逐个去修复了。
本文将针对其中比较典型三个问题做分析与解决方案阐述。
一、IP伪造
日常业务开发的过程中,我们可能会需要获取请求接口的用户IP信息。
为了防止黑客通过爆破的方式登陆系统,我将记录每一次用户登陆的IP,在一定时间范围内连续输入错误的用户名或者密码,将锁定IP。此IP在锁定时间内无法再请求登陆接口。
修复前获取IP逻辑
代码语言:javascript复制 static String getIpAddr(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Forwarded-For");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
return "0:0:0:0:0:0:0:1".equals(ip) ? LOCAL_IP : ip;
}
从业务功能使用的角度上来看,这段代码没有任何问题,我们能够从HttpServletRequest中获取到报文中的IP数据。
但是发现没有,我们获取的IP数据都是从请求头中获取的,而请求头的所有报文信息都是可以通过报文进行伪造的。只要攻击的黑客弄一个IP池,不断的变化,我们的防爆破机制就失效了。
解决思路
说实话,我当时为了完成这个IP获取需求,上面的代码也是直接百度了一份,发现能用也就用了。
我并不知道Header中获取到的IP值的意思是什么(文中不阐述比如:Proxy-lient-IP这些请求头的含义)
。
不过好在安全测试给出了修复建议:
IP数据获取需要从remoteAddr中获取。
remote_addr 是服务端根据请求TCP包的ip指定的。假设从client到server中间没有任何代理,那么web服务器(Nginx,Apache等)就会把client的IP设为remote_addr;如果存在代理转发HTTP请求,web服务器会把最后一次代理服务器的IP设置为remote_addr。
因为我们的服务都是统一走的nginx代理,所以可以在nginx中取到remote_addr,然后设置一个独立的业务请求头传递给用户中心。
1.增加nginx配置
2.编码实现
代码语言:javascript复制 /**
* 获取真实ip,防止ip伪造
*
* @param request
* @return
*/
private static String getIpAddrFromRemoteAddr(HttpServletRequest request){
String ip = request.getHeader("X-Real-IP");
if (StringUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
return ip;
}
ip = request.getHeader("X-Forwarded-For");
if (StringUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
// 多次反向代理后会有多个IP值,第一个为真实IP。
int index = ip.indexOf(',');
if (index != -1) {
return ip.substring(0, index);
} else {
return ip;
}
} else {
return request.getRemoteAddr();
}
}
二、登陆未使用验证码
基本上所有的登陆都会通过使用验证码的方式去防刷登陆接口。
我们产品最开始不想要验证码逻辑,为了防止暴力破解密码。我们使用了同一IP不能连续失败的逻辑防止盗刷,但是新规范下,安全测试还是不认。
没办法,他们掌握着我们的产品上架的生杀大权,我只能去加上验证码的功能。
验证码的方案无非两种:前端生成验证码还是后端生成验证码。
由于我们的前端大佬比较懒,只能我们后端生成验证码了。
验证码的生成工具我选择了Hutool,开箱即用。
先来看一下Hutool生成验证码的使用方式
代码语言:javascript复制 //定义图形验证码的长、宽、验证码字符数、干扰元素个数
CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(200, 100, 4, 20);
//获取验证码的base64
String captchaImage = circleCaptcha.getImageBase64Data();
//获取验证码
String code = circleCaptcha.getCode();
生成的验证码例如
简易版验证码前后端的校验逻辑:
1.获取验证码接口
前端请求后端生成验证码接口,后端生成验证码,将base64做为key,验证码code作为value保存至redis,然后返回base64给前端
2.登陆
前端将用户输入的code与base64传到后端,校验base64在redis的值
三、DDos攻击
验证码逻辑做完之后发现还是存在了一个攻击点。
后端在生成验证码的时候是需要把base64作为redis的key存储到redis中的。
高频请求验证码接口的情况下,大量的base64的key导致redis的响应变慢,甚至撑爆redis。
这就是DDos攻击
一般来说是指攻击者利用“肉鸡”对目标网站在较短的时间内发起大量请求,大规模消耗目标网站的主机资源,让它无法正常服务。在线游戏、互联网金融等领域是 DDoS 攻击的高发行业。
我们公司是安全公司,有专门的安全产品可以处理这种场景。
那如果不购买对应的安全产品,我们如何在应用层面防止DDos攻击呢?
DDos攻击就是高频的恶意请求,也就是高并发,高并发防刷你能想到什么?
可不就是限流吗?
3.1.网关限流
如果你使用的是gateway网关作为业务请求的入口,你可以直接设置一个单位时间内同一ip请求同一个url的限流器。
1.限流器
代码语言:javascript复制 @Configuration
public class LimitConfig {
@Bean
@Primary
KeyResolver hostResolver() {
return exchange ->{
ServerHttpRequest serverHttpRequest = Objects.requireNonNull(exchange.getRequest());
return Mono.just(serverHttpRequest.getLocalAddress().getAddress().getHostAddress() ":" serverHttpRequest.getURI().getPath());
};
}
}
2.增加限流过滤工厂类
代码语言:javascript复制 @Component
@ConfigurationProperties("spring.cloud.gateway.filter.request-rate-limiter")
public class BaiyanRateLimiterGatewayFilterFactory extends AbstractGatewayFilterFactory<BaiyanRateLimiterGatewayFilterFactory.Config> {
private final RateLimiter defaultRateLimiter;
private final KeyResolver defaultKeyResolver;
public BaiyanRateLimiterGatewayFilterFactory(RateLimiter defaultRateLimiter,
KeyResolver defaultKeyResolver) {
super(Config.class);
this.defaultRateLimiter = defaultRateLimiter;
this.defaultKeyResolver = defaultKeyResolver;
}
public KeyResolver getDefaultKeyResolver() {
return defaultKeyResolver;
}
public RateLimiter getDefaultRateLimiter() {
return defaultRateLimiter;
}
@SuppressWarnings("unchecked")
@Override
public GatewayFilter apply(BaiyanRateLimiterGatewayFilterFactory.Config config) {
return new InnerFilter(config,this);
}
/**
* 内部配置加载类
*/
public static class Config {
private KeyResolver keyResolver;
private RateLimiter rateLimiter;
private HttpStatus statusCode = HttpStatus.TOO_MANY_REQUESTS;
public KeyResolver getKeyResolver() {
return keyResolver;
}
public BaiyanRateLimiterGatewayFilterFactory.Config setKeyResolver(KeyResolver keyResolver) {
this.keyResolver = keyResolver;
return this;
}
public RateLimiter getRateLimiter() {
return rateLimiter;
}
public BaiyanRateLimiterGatewayFilterFactory.Config setRateLimiter(RateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
return this;
}
public HttpStatus getStatusCode() {
return statusCode;
}
public BaiyanRateLimiterGatewayFilterFactory.Config setStatusCode(HttpStatus statusCode) {
this.statusCode = statusCode;
return this;
}
}
/**
* 内部类,用于指定限流过滤器的级别
*/
private class InnerFilter implements GatewayFilter, Ordered {
private Config config;
private BaiyanRateLimiterGatewayFilterFactory factory;
InnerFilter(BaiyanRateLimiterGatewayFilterFactory.Config config,BaiyanRateLimiterGatewayFilterFactory factory) {
this.config = config;
this.factory = factory;
}
@Override
@SuppressWarnings("unchecked")
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
KeyResolver resolver = (config.keyResolver == null) ? defaultKeyResolver : config.keyResolver;
RateLimiter<Object> limiter = (config.rateLimiter == null) ? defaultRateLimiter : config.rateLimiter;
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
return resolver.resolve(exchange).flatMap(key ->
limiter.isAllowed(route.getId(), key).flatMap(response -> {
for (Map.Entry<String, String> header : response.getHeaders().entrySet()) {
exchange.getResponse().getHeaders().add(header.getKey(), header.getValue());
}
if (response.isAllowed()) {
return chain.filter(exchange);
}
ServerHttpResponse rs = exchange.getResponse();
byte[] datas = GsonUtil.gsonToString(Result.error(429,"too many request","访问过快",null))
.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = rs.bufferFactory().wrap(datas);
rs.setStatusCode(HttpStatus.UNAUTHORIZED);
rs.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return rs.writeWith(Mono.just(buffer));
}));
}
@Override
public int getOrder() {
return GatewayFilterOrderConstant.RATE_LIMITER_FILTER;
}
}
}
3.增加配置
代码语言:javascript复制 spring:
cloud:
gateway:
# 网关路由策略
routes:
- id: auth
uri: lb://auth
predicates:
- Path=/api/**
filters:
#限流配置
- name: BaiyanRateLimiter
args:
# 每秒补充10个
redis-rate-limiter.replenishRate: 10
# 突发20个
redis-rate-limiter.burstCapacity: 20
# 每次请求消耗1个
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@hostResolver}"
3.2.应用限流
没有使用网关的系统,我们可以单独使用AOP,过滤器,或者拦截器的方式进行的单应用服务限流。
思路其实与网关限流很类似。
成熟的限流方案有滑动窗口、令牌桶或者漏桶,不做展开讲解。
四、总结
本文针对我在工作中碰到的三个安全测试问题做了详细的问题描述,并针对问题进行分析逐步得到解决方案。
现将问题与解决方案总结如下