接口安全:防篡改和防重放

API 作为应用程序之间的桥梁,承载了大量的数据交换任务。然而,那些暴露在互联网中的接口也可能成为攻击者的目标。为了确保数据传输的安全性,我们必须采取有效的安全措施来防范篡改攻击和重放攻击。下面我们将以 Spring Boot 应用中的 API 安全设计为例,讲解下如何实施这些安全措施。

什么是篡改攻击和重放攻击

篡改攻击

篡改攻击利用了数据在传输过程中的不安全性。攻击者可以通过中间人攻击(Man-in-the-Middle, MITM)等方式拦截数据包,然后修改其中的关键字段,比如金额、用户身份信息等,再将修改后的数据包发送给接收方,以达到恶意目的。这种攻击可以发生在客户端到服务器之间的任何点,尤其是在没有加密或加密强度不足的情况下。

比如:攻击者截获用户认证请求,可以修改其认证信息,冒充合法用户登录系统。

重放攻击

重放攻击是指攻击者拦截并重新发送之前有效的数据包,以达到重复执行某项操作的目的。这种攻击利用了系统的状态管理和时间敏感性不足的问题。重放攻击依赖于数据包的有效性和可重复性。攻击者截获一个有效的数据包(如登录请求),然后在适当的时候再次发送这个数据包,从而绕过系统的身份验证或其他控制机制。

比如:攻击者截获一次成功的转账请求,然后多次重放该请求,导致用户账户被多次扣款。

防御大法

  • HTTPS(本文不做详细赘述)

    • HTTPS 通过 SSL/TLS 协议对数据进行加密,确保数据在传输过程中不被窃听或篡改。此外,HTTPS 还提供了身份验证机制,确保通信双方的身份。
  • 基于数字签名防篡改

    • 数字签名基于密钥的加密技术。
    • 发送方使用使用约定好的密钥对传输参数进行签名,生成一个签名值,并将签名值放入请求 header 中;
    • 接收方使用约定的密钥对请求参数再次进行签名;
    • 接收方对两次签名的值进行对比,对比一致则认为请求合法,不一致则说明请求被篡改。
  • 基于时间戳防重放

    • 时间戳是一种确保数据新鲜度的方法。每个请求中加入时间戳,并在服务器端检查时间戳的新鲜度。时间戳确保请求在一定时间内是有效的。服务器收到请求后,会检查时间戳是否在允许的时间范围内。如果时间戳过期,则拒绝请求。
    • 发送方每次请求都在请求 header 中放入时间戳参数,并且时间戳要和传输参数一起进行数字签名;
    • 接收方收到请求后,首先取请求 header 中的时间戳并与当前时间进行比较,如果时间差超过了预设的阈值,则认为签名过期。

签名验签编码思路

进行签名验签的 Header 信息:

参数类型说明
X-Signatureheader请求的签名值
X-Timestampheader请求时的时间戳
X-Algorithmheader签名时使用的算法

签名验签的数据部分:

  1. 时间戳:headerX-Timestamp 的值;
  2. Query 参数:Query 请求参数,例如 request?username=aaa&age=18,多个 Query 参数需要对 key 按字典(ASCII 码)升序排序后,再按照 value1+value2 方法拼接;
  3. Body 数据:实际的请求体内容。

过滤器编码如下:

@Slf4j
public class SignatureCheckFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
        CacheRequestBodyWrapper requestWrapper = new CacheRequestBodyWrapper(request);
        // 获取请求头中的签名和时间戳
        String signature = requestWrapper.getHeader("X-Signature");
        String timestampStr = requestWrapper.getHeader("X-Timestamp");
        String algorithm = requestWrapper.getHeader("X-Algorithm");

        if (Objects.isNull(signature) || Objects.isNull(timestampStr)) {
            log.warn("Missing required headers");
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing required headers");
            return;
        }

        // 重放时间限制
        long timestamp = Long.parseLong(timestampStr);
        if (System.currentTimeMillis() - timestamp >= 60 * 1000) { // 这里写死60s,实际可做配置
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Signature has been expired.");
            return;
        }

        // 获取 query 请求字符串
        String requestQuery = getRequestQueryStr(requestWrapper);

        // 获取 body 数据
        String body = getRequestBody(requestWrapper);

        // 按照规则进行签名
        String signData = timestamp + requestQuery + body;

        String newSignature = DigestUtils.getSignature(signData, algorithm, "UTF-8");
        log.info("计算出的新的签名值:----------->>>>>> {}", newSignature);
        log.info("header里面的签名值:---------->>>>>> {}", signature);

        if (!newSignature.equals(signature)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Signature verification failed");
            return;
        }
        filterChain.doFilter(requestWrapper, response); // 注意这里传递的是 cachedRequest
    }

    /**
     * 获取请求 body
     *
     * @param request  HttpServletRequest
     * @return 请求 body 字符串
     */
    private String getRequestBody(CacheRequestBodyWrapper request) throws IOException, UnsupportedEncodingException {
        StringBuilder sb = new StringBuilder();
        BufferedReader reader = request.getReader();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        return sb.toString();
    }

    /**
     * 获取 query 请求字符串
     *
     * @param request HttpServletRequest
     * @return 请求字符串
     */
    private String getRequestQueryStr(HttpServletRequest request) {
        List<String> reqList = new ArrayList<>();
        Enumeration<String> reqEnu = request.getParameterNames();
        while (reqEnu.hasMoreElements()) {
            reqList.add(reqEnu.nextElement());
        }
        Collections.sort(reqList);
        StringBuilder requestQuery = new StringBuilder();
        for (String key : reqList) {
            String value = request.getParameter(key);
            if (value != null) {
                requestQuery.append(value);
            }
        }
        log.info("获取的query请求字符串是:------>>> {}", requestQuery);
        return requestQuery.toString();
    }
}

注册过滤器:

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<SignatureCheckFilter> signatureCheckFilter(){
        FilterRegistrationBean<SignatureCheckFilter> registrationBean = new FilterRegistrationBean<>();
        SignatureCheckFilter filter = new SignatureCheckFilter();
        registrationBean.setFilter(filter);
        registrationBean.addUrlPatterns("/xxx/xxxxxx/*"); // 指定需要处理的url
        registrationBean.setOrder(1);
        return registrationBean;
    }

实现防篡改和防重放攻击的方式有很多种,本文只是简单介绍了基于数字签名和时间戳的实现方案,并提供了一个编码思路。希望通过这些内容,大家可以有所启发,并能够在实际项目中灵活运用这些安全措施。


【Java驿站】持续给大家更新,扫描下方👇👇👇二维码,关注【Java驿站】公众号


Johnny
0 声望0 粉丝