做了一個概率小游戲,沒想到服務器被打爆被攻擊了!原因竟然是他?真沒想到...
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%的惡意請求了,你還有更好的防攻擊手段么,歡迎評論區留言討論。

浙公網安備 33010602011771號