在涉及跨系统接口调用时,我们容易碰到以下安全问题:
- 请求身份被伪造。
- 请求参数被篡改。
- 请求被抓包,然后重放攻击。
需求场景
假设我们有如下业务需求:
用户在 A 系统参与活动成功后,活动奖励以余额的形式下发到 B 系统。
初始方案:裸奔
在不考虑安全问题的情况下,我们很容易完成这个需求:
在B系统开放一个接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
@RequestMapping("addMoney") public SaResult addMoney(long userId, long money) { return SaResult.ok(); }
|
在 A 系统使用 http 工具类调用这个接口。
1 2 3
| long userId = 10001; long money = 1000; String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money);
|
上述代码简单的完成了需求,但是很明显它有安全问题
方案升级:增加secretKey校验
为防止 B 系统开放的接口被陌生人任意调用,我们增加一个 secretKey 参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @RequestMapping("addMoney") public SaResult addMoney(long userId, long money, String secretKey) { if( ! check(secretKey) ) { return SaResult.error("无效 secretKey,无法响应请求"); } return SaResult.ok(); }
|
由于 A 系统是我们 “自己人”,所以它可以拿着 secretKey 进行合法请求:
1 2 3 4 5
| long userId = 10001; long money = 1000; String secretKey = "xxxxxxxxxxxxxxxxxxxx"; String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "&secretKey=" + secretKey);
|
现在,即使 B 系统的接口被暴露了,也不会被陌生人任意调用了,安全性得到了一定的保证,但是仍然存在一些问题:
- 如果请求被抓包,secretKey 就会泄露,因为每次请求都在 url 中明文传输了 secretKey 参数。
- 如果请求被抓包,请求的其它参数就可以被任意修改,例如可以将 money 参数修改为 9999999,B系统无法确定参数是否被修改过。
方案再升级:使用摘要算法生成参数签名
首先,在 A 系统不要直接发起请求,而是先计算一个 sign 参数:
1 2 3 4 5 6 7 8 9 10 11
| long userId = 10001; long money = 1000; String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String sign = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "&sign=" + sign);
|
注意此处计算签名时,需要将所有参数按照字典顺序依次排列(key除外,挂在最后面)以下所有计算签名时同理,不再赘述。
然后在 B 系统接收请求时,使用同样的算法、同样的秘钥,生成 sign 字符串,与参数中 sign 值进行比较:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @RequestMapping("addMoney") public SaResult addMoney(long userId, long money, String sign) {
String sign2 = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey); if(!sign2.equals(sign)) { return SaResult.error("无效 sign,无法响应请求"); }
return SaResult.ok(); }
|
因为 sign 的值是由 userId、money、secretKey 三个参数共同决定的,所以只要有一个参数不一致,就会造成最终生成 sign 也是不一致的,所以,根据比对结果:
- 如果sign一致,说明这是个合法请求
- 如果sign不一致,说明发起请求的客户端密钥不正确,或者请求的参数被篡改过,是个不合法请求。
此方案有点:
- 不在url中直接传递secretKey参数了,避免了泄露风险
- 由于sign参数的限制,请求中的参数也不可被篡改,B系统可放心的使用这些参数
此方案扔存在以下缺陷:
- 被抓包后,请求可以被无限重放,B系统无法判断请求是真正来自于A系统,还是被抓包后重放的
方案再再升级:追加nonce随机字符串
首先,在 A 系统发起调用前,追加一个 nonce
参数,一起参与到签名中:
1 2 3 4 5 6 7 8 9 10 11 12
| long userId = 10001; long money = 1000; String nonce = SaFoxUtil.getRandomString(32); String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String sign = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "nonce=" + nonce + "&sign=" + sign);
|
然后在 B 系统接收请求时,也把 nonce 参数加进去生成 sign 字符串,进行比较:
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
| @RequestMapping("addMoney") public SaResult addMoney(long userId, long money, String nonce, String sign) {
if(CacheUtil.get("nonce_" + nonce) != null) { return SaResult.error("此 nonce 已被使用过了,请求无效"); }
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey); if(!sign2.equals(sign)) { return SaResult.error("无效 sign,无法响应请求"); }
CacheUtil.set("nonce_" + nonce, "1");
return SaResult.ok(); }
|
代码分析:
- 为方便理解,我们先看第 3 步:此处在校验签名成功后,将 nonce 随机字符串记入缓存中。
- 再看第 1 步:每次请求进来,先查看一下缓存中是否已经记录了这个随机字符串,如果是,则立即返回:无效请求。
这两步的组合,保证了一个 nonce 随机字符串只能被使用一次,如果请求被抓包后重放,是无法通过nonce校验的。
至此,问题似乎已经解决了……吗?
别急,我们还有一个问题没有考虑:这个 nonce 在字符串在缓存应该被保存多久呢?
- 保存 15 分钟?那抓包的人只需要等待 15 分钟,你的 nonce 记录在缓存中消失,请求就可以被重放了。
- 那保存 24 小时?保存一周?保存半个月?好像无论保存多久,都无法从根本上解决这个问题。
你可能会想到,那我永久保存吧。这样确实能解决问题,但显然服务器承载不了这么做,即使再微小的数据量,在时间的累加下,也总一天会超出服务器能够承载的上限。
方案再再再升级:追加timestamp时间戳
我们可以再追加一个 timestamp 时间戳参数,将请求的有效性限定在一个有限时间范围内,例如 15分钟。
首先,在 A 系统追加 timestamp 参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| long userId = 10001; long money = 1000; String nonce = SaFoxUtil.getRandomString(32); long timestamp = System.currentTimeMillis(); String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String sign = md5("money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);
String res = HttpUtil.request("http://b.com/api/addMoney" + "?userId=" + userId + "&money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&sign=" + sign);
|
在 B 系统检测这个 timestamp 是否超出了允许的范围
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
| @RequestMapping("addMoney") public SaResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {
long timestampDisparity = System.currentTimeMillis() - timestamp; if(timestampDisparity > 1000 * 60 * 15) { return SaResult.error("timestamp 时间差超出允许的范围,请求无效"); }
CacheUtil.set("nonce_" + nonce, "1", 1000 * 60 * 15);
return SaResult.ok(); }
|
至此,抓包者:
- 如果在 15 分钟内重放攻击,nonce 参数不答应:缓存中可以查出 nonce 值,直接拒绝响应请求。
- 如果在 15 分钟后重放攻击,timestamp 参数不答应:超出了允许的 timestamp 时间差,直接拒绝响应请求。
服务器的时钟差异造成安全问题
以上的代码,均假设 A 系统服务器与 B 系统服务器的时钟一致,才可以正常完成安全校验,但在实际的开发场景中,有些服务器会存在时钟不准确的问题。
假设 A 服务器与 B 服务器的时钟差异为 10 分钟,即:在 A 服务器为 8:00 的时候,B 服务器为 7:50。
- A 系统发起请求,其生成的时间戳也是代表 8:00。
- B 系统接受到请求后,完成业务处理,此时 nonce 的 ttl 为 15分钟,到期时间为 7:50 + 15分 = 8:05。
- 8.05 后,nonce 缓存消失,抓包者重放请求攻击:
- timestamp 校验通过:因为时间戳差距仅有 8.05 - 8.00 = 5分钟,小于 15 分钟,校验通过。
- nonce 校验通过:因为此时 nonce 缓存已经消失,可以通过校验。
- sign 校验通过:因为这本来就是由 A 系统构建的一个合法签名。
- 攻击完成。
要解决上述问题,有两种方案:
- 方案一:修改服务器时钟,使两个服务器时钟保持一致。
- 方案二:在代码层面兼容时钟不一致的场景。
要采用方案一的同学可自行搜索一下同步时钟的方法,在此暂不赘述,此处详细阐述一下方案二。
我们只需简单修改一下,B 系统校验参数的代码即可:
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
| @RequestMapping("addMoney") public SaResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {
long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp); if(timestampDisparity > 1000 * 60 * 15) { return SaResult.error("timestamp 时间差超出允许的范围,请求无效"); }
CacheUtil.set("nonce_" + nonce, "1", (1000 * 60 * 15) * 2);
return SaResult.ok(); }
|
以上代码中时间差的计算改为当前时间和请求时间的绝对值计算,同时将缓存时间设置为2倍时间差,这样就可以保证时间戳的计算和缓存nonce有一项能在异常请求时拦截成功
最终方案
此处再贴一下完整的代码。
A 系统(发起请求端):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| long userId = 10001; long money = 1000; String nonce = SaFoxUtil.getRandomString(32); long timestamp = System.currentTimeMillis(); String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String sign = md5("money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);
String res = HttpUtil.request("http://b.com/api/addMoney" + "?userId=" + userId + "&money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&sign=" + sign);
|
B 系统(接收请求端):
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
| @RequestMapping("addMoney") public SaResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {
long allowDisparity = 1000 * 60 * 15; long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp); if(timestampDisparity > allowDisparity) { return SaResult.error("timestamp 时间差超出允许的范围,请求无效"); }
if(CacheUtil.get("nonce_" + nonce) != null) { return SaResult.error("此 nonce 已被使用过了,请求无效"); }
String sign2 = md5("money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey); if(!sign2.equals(sign)) { return SaResult.error("无效 sign,无法响应请求"); }
CacheUtil.set("nonce_" + nonce, "1", allowDisparity * 2);
return SaResult.ok(); }
|
可以使用Sa-Token提供的API接口参数签名插件快速完成以上需求