安全测试的老哥说我的系统不抗揍

2,785 阅读6分钟

Hello,这里是爱 Coding,爱 Hiphop,爱喝点小酒的 AKA 柏炎。

我们部门每次大版本发布,都需要走一道公司的安全测试。

这不最近公司的安全测试标准提高了,我所负责的用户服务被一口气提了10个安全问题。

image-20220109142604456

好家伙,3.25没跑了。

img

不过用户中心是核心的底层业务服务,它的数据安全性与系统稳定性都是极其重要的,发现了Bug,我们只能逐个去修复了。

本文将针对其中比较典型三个问题做分析与解决方案阐述。

image-20220109102353145

一、IP伪造

日常业务开发的过程中,我们可能会需要获取请求接口的用户IP信息。

为了防止黑客通过爆破的方式登陆系统,我将记录每一次用户登陆的IP,在一定时间范围内连续输入错误的用户名或者密码,将锁定IP。此IP在锁定时间内无法再请求登陆接口。

修复前获取IP逻辑

  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配置

image-20220109114230543

2.编码实现

 /**
  * 获取真实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不能连续失败的逻辑防止盗刷,但是新规范下,安全测试还是不认。

image-20220109115050262

没办法,他们掌握着我们的产品上架的生杀大权,我只能去加上验证码的功能。

img

验证码的方案无非两种:前端生成验证码还是后端生成验证码。

由于我们的前端大佬比较懒,只能我们后端生成验证码了。

验证码的生成工具我选择了Hutool,开箱即用。

先来看一下Hutool生成验证码的使用方式

 //定义图形验证码的长、宽、验证码字符数、干扰元素个数
 CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(200, 100, 4, 20);
 ​
 //获取验证码的base64
 String captchaImage = circleCaptcha.getImageBase64Data();
 ​
 //获取验证码
 String code = circleCaptcha.getCode();

生成的验证码例如

16113807_sICp

简易版验证码前后端的校验逻辑:

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攻击就是高频的恶意请求,也就是高并发,高并发防刷你能想到什么?

可不就是限流吗?

img

3.1.网关限流

如果你使用的是gateway网关作为业务请求的入口,你可以直接设置一个单位时间内同一ip请求同一个url的限流器。

1.限流器

 @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.增加限流过滤工厂类

 @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.增加配置

 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,过滤器,或者拦截器的方式进行的单应用服务限流。

思路其实与网关限流很类似。

成熟的限流方案有滑动窗口、令牌桶或者漏桶,不做展开讲解。

四、总结

本文针对我在工作中碰到的三个安全测试问题做了详细的问题描述,并针对问题进行分析逐步得到解决方案。

现将问题与解决方案总结如下

image-20220109131752165

五、联系我

文中如有不正确之处,欢迎指正,写文不易,点个赞吧,么么哒~

微信:baiyan_lou

公众号:柏炎大叔