1. 前言
事情是这样的,上个月在刷知乎的过程中,发现了以下几个有趣的问题。
作为程序员,看着这种概率与决策,感觉非常有趣,有时候常在想,能不能用程序模拟一下,选择哪个选择,我最终取胜的概率最大呢?
于是就有了我的服务器被打爆攻击的事情了,欲哭无泪。让我给大家讲讲我怎么和攻击者在线上斗智斗勇的。
先给大家简单看看这个游戏的效果。



2. 事件过程
2.1 事情起因
这个小游戏是非常简单的,完全看个人运气,有些人可能运气就能抽中比较长的时间,有些人可能运气非常差抽中时间很短。因此在10.6号左右为了增加游戏的趣味性,我就上线了排行榜机制!!!
万万没想到,大家的‘斗志’实在太高了,有些人通过爬取我的后端接口,给自己一个非常夸张的数据,让自己排第一名,也就是他也是一名程序员,然后通过绕过前端的手段,直接给我后端放进夸张的数据。当时用户名满天飞,什么‘xxx一日游’, ‘我是第一名’,‘比不过我吧’等等名称满天飞。
作为资深程序员,我能忍?平时的八股文派上用场了。
2.2 第一回合 - 防重放
- 先做一些简单的数据校验,比如用户名的长度,数据的范围等等,非常的基础
- 对前端的UA,REFER等做一些基础的校验
- 加一个token校验,也就是前端要通过某些规则生成一个token传给后端,后端在根据这个规则来校验这个token是否合法,如果不合法,则直接拒绝,说明用户是非法请求,代码如下
public static boolean extractSecret(StringRedisTemplate redisService, String timestamp, String token, TreeMap<String, String> map) { if (StringUtils.isEmpty(timestamp) || StringUtils.isEmpty(token)) { return false; } long ts = NumberUtils.toLong(timestamp, 0); long now = System.currentTimeMillis(); if ((now - ts) > SecretUtils.NONCE_DURATION) { return false; } StringBuilder sb = new StringBuilder(); map.put("salt", SALT); for (Map.Entry<String, String> entry : map.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); if (sb.length() > 0) { sb.append("&"); } sb.append(key).append("=").append(value); } String targetToken = DigestUtils.md5DigestAsHex(sb.toString().getBytes()); if (!token.equals(targetToken)) { return false; } String s = redisService.opsForValue().get(timestamp); if (StringUtils.isNotEmpty(s)) { return false; } else { redisService.opsForValue().set(timestamp, timestamp, NONCE_DURATION, TimeUnit.MILLISECONDS); } return true; }
- 首先前端会生成一个时间戳,然后将时间戳+所有请求的参数,通过字典序进行排序
- 排序之后,生成一个类似于 a=1&b=2&c=3 的字符串,然后使用md5生成一个token传给后端
- 后端接收以后,首先判断时间戳是否过了很久,保证不是手动生成的
- 其他根据规则自己也生成一个token,比较两个token是否相同,为了保险起见,一般双方会约定一个salt盐这个一个参数,起到混淆视听的作用
- 最后后端会把这个时间戳、token放进redis,保证一个token只能使用一次,使用了之后就不能再次使用了
通过这个防重放的防御,大部分水货程序员就会拦截在了门外
2.3 第二回合 - 前端js加盐混淆视听
过了一段时间,攻击者竟然破解了我的加密手段,针对token的生成规则,有些大佬可以通过f12非常方便的看到前端的代码,然后获取到规则,然后利用代码进行攻击,于是乎
- 针对前端盐,我进行了混淆视听,举一个例子
- 比如我的salt=abc 现在我换成如下代码,你还能看得懂么?
// 签名生成核心 const _0xsig = { // 混淆配置矩阵(多层编码) _0xa1: [0x64,0x61,0x5f,0x6c,0x61,0x6f,0x5f,0x62,0x69,0x65,0x5f,0x7a,0x61,0x69,0x5f,0x73,0x68,0x75,0x61,0x5f,0x6a,0x69,0x65,0x5f,0x6b,0x6f,0x75,0x5f,0x6c,0x65], _0xa2: [0x77,0x6f,0x5f,0x64,0x65,0x5f,0x6a,0x69,0x65,0x5f,0x6b,0x6f,0x75,0x5f,0x62,0x61,0x6f,0x5f,0x6c,0x65], _0xa3: [0x73,0x68,0x6f,0x75,0x5f,0x78,0x69,0x61,0x5f,0x6c,0x69,0x75,0x5f,0x71,0x69,0x6e,0x67], _0xb1: [115,97,108,116], _0xb2: [115,97,108,116,95,118,50], _0xb3: [115,108,97,116,95,118,51], _0xc1: 0x1a2b, _0xc2: 0x3c4d, _0xc3: 0x5e6f, // 生成签名 _0xgen(_0xdata) { const _0xt = Date.now()[_0x3c4d(2)](); const _0xp = { ..._0xdata, timestamp: _0xt }; // 提取密钥和值 const _0xkeys = this._0xextK(); const _0xvals = this._0xextV(); // 构建参数对象 const _0xall = { ..._0xp }; for (let _0xi = 0; _0xi < _0xkeys.length; _0xi++) { _0xall[_0xkeys[_0xi]] = _0xvals[_0xi]; } // 排序并拼接 const _0xks = Object.keys(_0xall).sort(); const _0xstr = _0xks.map(_0xk => `${_0xk}=${_0xall[_0xk]}`).join('&'); const _0xtk = _0xmd5(_0xstr); return { ..._0xp, token: _0xtk }; }, // 创建签名 create(_0xparams) { return this._0xgen(_0xparams); } };
大概率你看的很懵逼,这种方式一般人几乎破解不了,除非通过AI进行分析
- 我通过对字符a等进行16进制,然后通过增加多盐的方式,增加攻击者的攻击成本
- 像网易云、知乎等都是采用这种方法
2.4 第三回合 - IP限流
过了一段时间,攻击者又又又破解了,并且好像非常生气,开始恶意请求我的接口了,通过脚本一直刷我的接口,让我的服务器直接挂掉,当时我的服务器承受不住这么高的流量,就直接重启了,重启后,又被打爆,我当时真的特么无语了,对这种人。
而且我的服务器的流量一直被刷,都快刷欠费了,真的不能忍,我都想直接把应用给下线了。当然作为资深程序员怎么能忍受
- 针对高频IP地址进行限流,比如1s内请求10s,10s内请求100次的ip,肯定不是一个正常用户,是一个非法用户,直接封禁
- 加密代码不再开源(之前一直开源,感觉攻击者偷偷看我的commit,我在明他在暗,怎么玩),直接修改salt参数,并且启用多重盐,让你怎么破解,具体限流代码如下
private boolean checkRateLimit(String ip, String uri, HttpServletResponse response) throws IOException { // 1. 检查是否在黑名单中 String blacklistKey = BLACKLIST_KEY_PREFIX + ip; String blacklistValue = stringRedisTemplate.opsForValue().get(blacklistKey); if (blacklistValue != null) { Long ttl = stringRedisTemplate.getExpire(blacklistKey, TimeUnit.SECONDS); log.error("IP黑名单拦截 - IP={}, URI={}, 剩余时长={}秒", ip, uri, ttl); writeErrorResponse(response, "签名验证失败"); return false; } // 2. 检查访问频率 String rateLimitKey = RATE_LIMIT_KEY_PREFIX + ip; String countStr = stringRedisTemplate.opsForValue().get(rateLimitKey); long count = 0; if (countStr != null) { count = Long.parseLong(countStr); } // 3. 递增计数 Long newCount = stringRedisTemplate.opsForValue().increment(rateLimitKey, 1); // 4. 如果是第一次访问,设置过期时间 if (count == 0) { stringRedisTemplate.expire(rateLimitKey, RATE_LIMIT_WINDOW, TimeUnit.SECONDS); log.info("IP限流 - IP={}, {}秒内第1次请求{}", ip, RATE_LIMIT_WINDOW, uri); return true; } // 5. 检查是否超过限制 if (newCount > RATE_LIMIT_MAX_COUNT) { // 超过限制,加入黑名单 stringRedisTemplate.opsForValue().set( blacklistKey, String.valueOf(newCount), BLACKLIST_DURATION, TimeUnit.SECONDS ); log.error("IP限流触发 - IP={}, {}秒内请求{}次(限制{}次),已拉黑{}秒, URI={}", ip, RATE_LIMIT_WINDOW, newCount, RATE_LIMIT_MAX_COUNT, BLACKLIST_DURATION, uri); writeErrorResponse(response, "签名验证失败"); return false; } // 6. 正常通过,记录日志 Long ttl = stringRedisTemplate.getExpire(rateLimitKey, TimeUnit.SECONDS); log.info("IP限流 - IP={}, {}秒内第{}次请求{}(限制{}次),剩余{}秒", ip, RATE_LIMIT_WINDOW, newCount, uri, RATE_LIMIT_MAX_COUNT, ttl); return true; }
大概的意思就是请求多少秒内请求超过多少次,我就认为你不是一个正常用户,直接封禁即可。
2.5 第四回合
别看了,木有了,又又又又被破解了,我实在没招了,看看评论区的大佬们有没有什么好的办法支支招
3. 最后
通过这个例子,我们发现攻击者与我们一来一回,真所谓是道高一丈,魔高一丈。攻击者力量比较大,毕竟人多。
我们简单总结一下,我们大概有以下技术手段可以防止攻击者的攻击
- 后端的一些基础数据校验,比如针对用户名,存活时间,浏览器UA等等
- 防重放token校验,通过和前端约定一些规则,通过规则来生成token,防止恶意请求
- 在token校验的基础上,我们使用了salt盐,并且对盐的生成进行了混淆,导致攻击者的攻击成本非常的高
- 针对大量脚本刷接口的行为,我们利用redis进行了ip限流,如果在某个时间内请求超过了某个次数,直接禁止请求
基本上通过以上技术手段,我们可以拦截99%的恶意请求了,你还有更好的防攻击手段么,欢迎评论区留言讨论。