最近系统上线之前需要做安全测试,安全测试人员指出了系统存在重放攻击,由于之前项目没有遇到过这样的问题,在这里就详细探讨一下重放攻击。

所谓重放攻击就是攻击者发送一个目的主机已接收过的包,来达到欺骗系统的目的,主要用于身份认证过程。攻击者利用网络监听或者其他方式盗取认证凭据,之后再把它重新发给认证服务器。从这个解释上理解,加密可以有效防止会话劫持,但是却防止不了重放攻击。重放攻击任何网络通讯过程中都可能发生。

1、基于timestamp的方案

每次HTTP请求,都需要加上timestamp参数,然后把timestamp和其他参数一起进行数字签名。因为一次正常的HTTP请求,从发出到达服务器一般都不会超过60s,所以服务器收到HTTP请求之后,首先判断时间戳参数与当前时间相比较,是否超过了60s,如果超过了则认为是非法的请求。
假如黑客通过抓包得到了我们的请求url:
https://www.clang.asia/index/Info?uid=ZX07&stime=1480862753&sign=80b886d71449cb33355d017893720666
其中

1
String sign = md5(uid, token, stime);

一般情况下,黑客从抓包重放请求耗时远远超过了60s,所以此时请求中的stime参数已经失效了。
如果黑客修改stime参数为当前的时间戳,则sign参数对应的数字签名就会失效,因为黑客不知道token值,没有办法生成新的数字签名。
但这种方式的漏洞也是显而易见的,如果在60s之内进行重放攻击,那就没办法了,所以这种方式不能保证请求仅一次有效。

2、基于nonce的方案

nonce的意思是仅一次有效的随机字符串,要求每次请求时,该参数要保证不同,所以该参数一般与时间戳有关,我们这里为了方便起见,直接使用时间戳的16进制,实际使用时可以加上客户端的ip地址、mac地址等信息做个哈希之后,作为nonce参数。
我们将每次请求的nonce参数存储到一个“集合”中,可以json格式存储到数据库或缓存中。
每次处理HTTP请求时,首先判断该请求的nonce参数是否在该“集合”中,如果存在则认为是非法请求。
假如黑客通过抓包得到了我们的请求url:
https://www.clang.asia/index/Info?uid=ZX07&nonce=58442c21&sign=80b886d71449cb33355d017893720666
其中

1
String sign = md5(uid, token, nonce);

这种方式也有很大的问题,那就是存储nonce参数的“集合”会越来越大,验证nonce是否存在“集合”中的耗时会越来越长。我们不能让nonce“集合”无限大,所以需要定期清理该“集合”,但是一旦该“集合”被清理,我们就无法验证被清理了的nonce参数了。也就是说,假设该“集合”平均1天清理一次的话,我们抓取到的该url,虽然当时无法进行重放攻击,但是我们还是可以每隔一天进行一次重放攻击的。而且存储24小时内,所有请求的“nonce”参数,也是一笔不小的开销。

3、基于timestamp和nonce的方案

那我们如果同时使用timestamp和nonce参数呢?nonce的一次性可以解决timestamp参数60s的问题,timestamp可以解决nonce参数“集合”越来越大的问题。
我们在timestamp方案的基础上,加上nonce参数,因为timstamp参数对于超过60s的请求,都认为非法请求,所以我们只需要存储60s的nonce参数的“集合”即可。
假如黑客通过抓包得到了我们的请求url:
https://www.clang.asia/index/Info?uid=ZX07&stime=1480862753&nonce=58442c21&sign=80b886d71449cb33355d017893720666
其中

1
String sign = md5(uid, token, stime, nonce);

如果在60s内,重放该HTTP请求,因为nonce参数已经在首次请求的时候被记录在服务器的nonce参数“集合”中,所以会被判断为非法请求。超过60s之后,stime参数就会失效,此时因为黑客不清楚token的值,所以无法重新生成签名。
综上,我们认为一次正常的HTTP请求发送不会超过60s,在60s之内的重放攻击可以由nonce参数保证,超过60s的重放攻击可以由stime参数保证。
因为nonce参数只会在60s之内起作用,所以只需要保存60s之内的nonce参数即可。
我们并不一定要每个60s去清理该nonce参数的集合,只需要在新的nonce到来时,判断nonce集合最后一次修改时间,超过60s的话,就清空该集合,存放新的nonce参数集合。其实nonce参数集合可以存放的时间更久一些,但是最少是60s。
随机数集合可以根据业务场景采用定期清理或根据大小自动清理的方案,例如该接口每秒的请求数最高为1000,则60s内的请求数量最多为1500*60=90000,则我们在每次请求后检查集合大小是否超过90000,若超高该数量则清空。
整体验证流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 获取token
String token = request.getHeader("token");
// 获取时间戳
String timestamp = request.getHeader("timestamp");
// 获取随机字符串
String nonceStr = request.getHeader("nonce");
// 获取请求地址
String url = request.getHeader("url");
// 获取签名
String signature = request.getHeader("sign");

// 判断参数是否为空
if (StringUtils.isBlank(token) || StringUtils.isBlank(timestamp) || StringUtils.isBlank(nonceStr) || StringUtils.isBlank(url) || StringUtils.isBlank(signature)) {
//非法请求
return;
}

//验证token有效性,得到用户信息
UserTokenInfo userTokenInfo = TokenUtils.getUserTokenInfo(token);

if (userTokenInfo == null) {
//token认证失败(防止token伪造)
return;
}

// 判断请求的url参数是否正确
if (!request.getRequestURI().equals(url)){
//非法请求 (防止跨域攻击)
return;
}

// 判断时间是否大于60秒
if(DateUtils.getSecond()-DateUtils.toSecond(timestamp)>60){
//请求超时(防止重放攻击)
return;
}

// 判断该用户的nonceStr参数是否已经在redis中
if (RedisUtils.haveNonceStr(userTokenInfo,nonceStr)){
//请求仅一次有效(防止短时间内的重放攻击)
return;
}

// 对请求头参数进行签名
String stringB = SignUtil.signature(token, timestamp, nonceStr, url,request);

// 如果签名验证不通过
if (!signature.equals(stringB)) {
//非法请求(防止请求参数被篡改)
return;
}

// 将本次用户请求的nonceStr参数存到redis中设置60秒后自动删除
RedisUtils.saveNonceStr(userTokenInfo,nonceStr,60);

//开始处理合法的请求
...