防API重放攻击前后端完整设计

信息安全是一个永久的话题,一切信息安全措施与防范都是在加大不法犯罪的成本。而防 API 重放攻击就是是一项做了比没做好的安全功能。

API 重放攻击是什么

API 重放攻击(Replay Attacks)又称重播攻击、回放攻击,这种攻击会不断恶意或欺诈性地重复一个有效的 API 请求。攻击者利用网络监听或者其他方式盗取 API 请求,进行一定的处理后,再把它重新发给认证服务器,是黑客常用的攻击方式之一。

例子

  1. HTTPS 协议下:a 用户向 A 服务发起私钥申请
  2. A 服务校验请求携带的凭据等信息确认没问题发放私钥,这时请求响应信息将发送给 a 用户
  3. 在 a 用户 向 A 服务发起请求跳转过程,b 用户抓包到了这条请求,但 HTTPS 协议下加密无法破解。
  4. 但 b 用户不关心这请求无法破解,他仅需要知道该请求的目标 A 服务的接口是发放私钥的接口,重新发起该请求。
  5. 假设 A 服务并未做安全措施,那么 A 服务将校验请求携带的凭据等信息确认没问题发放私钥,这时请求响应信息将发送给 b 用户

以上例子的私钥可以自行替换其它信息

防 API 重防攻击有什么用

除了可以避免上述例子,还可以优化两个特性【接口的幂等性】和【接口要求防止参数篡改】。

接口的幂等性

是以"相同"的请求调用这个接口一次和调用这个接口多次,对系统产生的影响是相同的。但在重复的请求下虽然保证了系统的安全,但依旧会消耗系统资源。API 防重可以尽量避免。

接口参数防篡改

恶意抓取或拦截调用端到服务端之间的请求,并调整参数继续发送给服务端,即使不法份子知道防篡改规则,也还有防重措施在,而且API 防重可以和接口参数防篡改一起做。

单从爬虫上,就加大了爬虫的难度,在不能理解和破译加密规则的情况下,从接口级的数据爬取变成页面级的数据采集。

防 API 重放攻击措施在小型服务上好处极低,但业务请求量上来后,过滤重复请求将为系统节省不少资源。所以一般做在网关上

实现思路简述

服务环境简述:前后分离,后端微服务,网关自研分布式部署 (负载均衡算法)

  1. 为了确保每个请求唯一,我们为每个请求生成一个 UUID。
  2. 为了确保请求携带的 UUID 从调用端到服务端未被替换,我们对 UUID 加盐 hash 加密。
  3. 为了后端不无限存储 UUID,需要一个有效时间范围,前端再加一个时间戳 timestamp 一起加密,网关接收到的请求携带的时间戳5分钟以外的直接拒绝。
  4. 为了网关的性能,使用本地缓存,又因为网关是多节点部署,负载均衡算法改一致性 hash 算法。
  5. caffeine 每存储一个 UUID 内存占用比例约为 UUID 的三倍左右。为了减少内存的占用,且需求并不复杂,改为使用 Set 实现

后端设计细节与实现原理

设计细节之缓存

UUID 是 JAVA 中的基础数据类型,单个 UUID 128位 16进制后为 32 字节,也就是 32 byte,所以 2 千万 UUID 数据缓存本地内存中会产生大约 610MB 的大小。假设网关单节点 QPS 按照 2W计算,也就是需要缓存最近 16 分钟 30 秒的 UUID 才能达到 2 千万数据量,只要设置合理时间内缓存 UUID ,节点不会出现内存溢出问题。

为什么存储 UUID

千万不用尝试存储 UUID 转换为 String 后的字符串,即使你去掉了四个 - 字符,依旧有 32 个字符,根据对象内存大小计算公式,在64位指针压缩的情况下。
空 String 对象内存大小

对象头(8 字节)+ 引用 (4 字节 )  + char 数组(16 字节)+ 1个 int(4字节)+ 1个long(8字节)= 40 字节

若 String 对象不为空,因为 char 类型占用 2 字节。

40 + 2 * n
// 所以 UUID 转字符串后若去掉四个 - 字符。最后String内存大小占用为 40+2*32 = 104 字节

所以若存储的是 UUID 的 String 对象,内存将会是直接存 UUID 的三倍。

为什么不用 caffeine 缓存

caffeine 是一个非常优秀的缓存框架,提供缓存超时,最开始也尝试使用了但因为数据量大起来的时候内存占用比较大,尤其是存储时必须Key,Value形式,多了一个Value,因为底部是 HashMap。虽然使用 W-TinyLFU 优秀算法,但奈何我并不需要它,所以含泪放弃。

在追寻自行实现时,想过使用布隆算法,因为这回更加节省内存占用空间,但清理和可能误判不是第一次果断放弃,也想过时间轮,但因为觉得使用时间做分割的转盘每一块就是一个线程安全的 Set 。后期有效时间戳 timestamp 调整。会导致内存最低的上限拉高。最后采用了一个简短的时间链,每隔一段时间创建一个线程安全的 Set。不在有效时间的 Set 全部抛弃。

Set集合生成代码

private Map<Long, Set<UUID>> timeRoulette = new HashMap<>();
public RequestValidityVerifier() {
    // 当前时间缩短1秒,以防止数据输入异常
    timeRoulette.put(System.currentTimeMillis()-1000L, Collections.synchronizedSet(new HashSet<>(7600)));
    ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
    executorService.scheduleAtFixedRate(()->{
   // 在创建时,更长的时间请求有可能落在前一个收集上
        long now = System.currentTimeMillis();
        timeRoulette.put(now, Collections.synchronizedSet(new HashSet<>(7600)));
   // 添加桶的生成时间,以确保桶中的所有标记此时过期
        timeRoulette.keySet().removeIf(key -> now - key > 300000+60000);
    },60000,60000, TimeUnit.MILLISECONDS);
}

UUID 匹配逻辑代码。时间戳超过有效时间判断在前面处理了

public Boolean isSymbolExists(VerifyResult<UUID> verifyResult) {
    // 从大到小
    List<Long> keyList = timeRoulette.keySet().stream().sorted((q,h)-> (int) (h - q)).collect(Collectors.toList());
    if(!keyList.isEmpty()){
        for(Long key : keyList){
   //获取还在有效时间的桶,因为从大到小排序桶,最先匹配的就是要存放这个时间范围的桶,不可能存在下个桶就像5分钟不应出现在10分钟-20分钟的桶之间。
            if((System.currentTimeMillis() - key) < MAX_TIMEOUT && key < verifyResult.getTimestamp()){
                Set<UUID> uuids = timeRoulette.get(key);
                return uuids.add(verifyResult.getT());
            }
        }
    }
    return false;
}

为什么使用一致性Hash环

首先在缓存高效考虑下使用本地缓存,而不是 Redis 。如果使 Redis 那么在网关分布式节点下将不需要考虑下次请求缓存命中问题,因为所有节点的缓存都在 Redis 上,但这样会有额外的网络开销,且如果要保证 Redis 高可用下又会涉及到 Redis 集群。所以在各节点使用本地缓存是最优的选择,将问题变成了一个,如何同步缓存数据。

如何同步缓存数据这个问题,可以看到更本质的问题是缓存里面有,如何命中。所以我选择使用一致性 Hash环 来解决这个问题,它可以保证有 A,B,C三个节点。请求携带 a 密文第一次落在 A 节点。那只要携带 a 密文的请求将都会落在 A 节点。

一致性 Hash 环原理:因为 Hash 值是 int,int 是 4 字节 32位。其中一位代表正负,所以范围为 0-2^32-1,然后首尾相连构成一个环。

如何使用:A,B,C三个节点 Hash值为 10,100,1000。那么当 a 密文 Hash 值计算为 20 时落在 B 节点,150 时落在 C 节点,计算为 1 或者1001 时落在 A 节点。顺时针存放最近一个节点。当然也可以逆时针,看代码实现。

使用虚拟节点:看到上面如果使用会注意到各节点 Hash 值分布不均,这时使用虚拟节点是将一个真实节点加上一些其它字符串如(v300) Hash 成多个值,尽量使节点分布均匀。

完整结构图如下一个真实节点对应两个虚拟节点,实际情况根据不同的 Hash 算法初始化的虚拟节点数量也不一样

img

相关代码

Hash 算法

/**
 * 使用FNV1 32 HASH算法计算服务器的HASH值(虚拟节点最好在150以上)
 * @param str
 * @return
 */
public static int getHash(String str) {
    final int p = 16777619;
    int hash = (int) 2166136261L;
    for (int i = 0; i < str.length(); i++)
        hash = (hash ^ str.charAt(i)) * p;
    hash += hash << 13;
    hash ^= hash >> 7;
    hash += hash << 3;
    hash ^= hash >> 17;
    hash += hash << 5;

    if (hash < 0)
        hash = Math.abs(hash);
    return hash;
}

存储/删除/获取真实节点

private final static int VIRTUAL_NODES = 150;
private final SortedMap<Integer, String> virtualNodes = new TreeMap<>();
// 存储
public void addFactNodes(String ip) {
    for (int i = 0; i < VIRTUAL_NODES; i++) {
        String virtualNodeName = ip + "&&VN" + i;
        int hash = getHash(virtualNodeName);
        virtualNodes.put(hash, virtualNodeName);
    }
}
// 删除
public void removeFactNodes(String ip) {
    int  i = 0;
    while(virtualNodes.containsValue(ip + "&&VN" + i)) {
        virtualNodes.remove(getHash(ip + "&&VN" + i));
        i++;
    }
}
// 获取真实节点
public String getServer(String key) {
    int hash = HashNodeUtil.getHash(key);
    if(this.virtualNodes.isEmpty()){
        return null;
    }
    SortedMap<Integer, String> subMap = this.virtualNodes.tailMap(hash);
    String virtualNode;
    if (subMap.isEmpty()) {
        Integer i = this.virtualNodes.firstKey();
        virtualNode = this.virtualNodes.get(i);
    } else {
        Integer i = subMap.firstKey();
        virtualNode = subMap.get(i);
    }
    if (!StringUtils.isEmpty(virtualNode)) {
        return virtualNode.substring(0, virtualNode.indexOf("&&"));
    }
    return null;
}

谁在同步这个 Hash 环

当然是”注册中心“在同步

前端设计细节与实现原理

需要给前端提供一个方法,最好是一个远程引用的 JS 这样便于统一维护和管理。前端加密使用 crypto-js。

crypto-js 的 CDN 地址,调试时远程引用,调试完复制 min 的 JS 到自身的 JS 上。这样前端只要引用一个地方。

使用 crypto-js 加密除了要知道后端的加密规则,还需要知道加密时将字符串转换字节的编码是什么,还有 UUID 是几版本的。不同的字节编码加密出来的密文是不一样的。

例子:后端使用 HmacMD5 加密后用 base64 处理,版本 4 的 UUID ,字符串转换字节的编码为 UTF_8。

未处理前 JS

<head>
    <script src="https://cdn.bootcdn.net/ajax/libs/crypto-js/4.1.1/core.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/crypto-js/4.1.1/hmac.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/crypto-js/4.1.1/md5.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/crypto-js/4.1.1/enc-base64.js"></script>
</head>
<body>
</body>
<script>
    (function(root,algorithm){
        root.API = algorithm();
    })(this, function () {
        // 版本4的UUID生成方法
        function RandomlyUniqueValue() {
            var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
            var uuid = [], i;
            var r;
            uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
            uuid[14] = '4';
            for (i = 0; i < 36; i++) {
                if (!uuid[i]) {
                  r = 0 | Math.random()*16;
                  uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r];
                }
            }
            return uuid.join('');
        }
        var API = {
            apiSymbol:function(){
                let timestamp = new Date().getTime();
                let uuid = RandomlyUniqueValue();
                let data = CryptoJS.enc.Utf8.parse(uuid+timestamp); //获取要加密数据字符串的 utf-8 编码字节
                let key = CryptoJS.enc.Utf8.parse("123456789"); // 获取盐字符串的 utf-8 编码字节
                let value = CryptoJS.HmacMD5(data,key);// 加密得到 Hex 编码格式的字节
                let hexValue = CryptoJS.enc.Hex.stringify(value);// 通过 Hex 编码格式将该字节转字符串
                // 组装明文签名,获取字符串的 utf-8 编码字节
                let result = CryptoJS.enc.Utf8.parse(uuid+";"+timestamp+";"+hexValue);
                return CryptoJS.enc.Base64.stringify(result); // 返回 base64 处理后的字符串
            }
        };
        return API;
    })
    console.log(API.apiSymbol());
</script>

如果有其它问题可以在控制台断点调试,看 crypto-js 的 js 实现。

然后再将前面例子里引用的四个 js 后面加上 .min 。获取它们的压缩文件,将所有压缩文件复制集中到自身这个 JS 。

操作6

然后通过 JS混淆网站 混淆代码。可以使用默认或者自行研究配置

操作8

总结

唯有笔记和文章才让我感觉真学会了它