请求防篡改,重放攻击实现

实现方式:Md5(数据+key) 加密的方式进行的。

  1. key可以是任意的字符串,然后“客户端”和“服务器端”各自保留一份,千万不能外泄。
  2. 数据+Key字符串拼接后的值用MD5加密生成 token 签名,将签名发送到服务器端,同时服务器端已同样的方式计算出签名,然后比较俩个MD5的值是否相同,来确定请求是否被篡改。
  3. 添加时间戳,并且在后端验证此次 token 的时间是否过期

步骤:

  • 被加密字符串生成规则为: 统一编码(经过ascii排序的json + 时间戳 + key) ,前后端生成规则必须一致
  • 前端生成 md5 token
  • 在请求头(或者直接写在请求体里面)上添加 token 和 time 时间戳,这里的时间戳需要和加密字符串中的时间戳一致
  • 后端解析(get + post)的请求参数
  • 生成与前端一样的字符串,并且再次生成 md5 token
  • 与前端的 token 比对,如果数据被修改,则token不一致,如果time时间戳被修改,则token不一致,请求异常
  • 将一致的 token 根据 redis 缓存的所有 token 进行比对,如果存在一样的 token 则为重放攻击,请求异常
  • 否则 将 token 存入 redis 列表,以便防止重放攻击
  • 根据业务需求进行 redis 缓存的删除,例如一小时删除一次
  • 如果一小时后有人再次使用此 token 进行重放攻击,则利用 time 时间戳跟系统时间进行判断,如果超过一小时,则请求异常 (一般来说前端发送的请求会在几秒内处理,不可能超过一小说)

实战代码

前端

这里给大家直接上工具

安装 crypto-js

npm i crypto-js

工具方法 typescript

import md5 from "crypto-js/md5";


//传入json对象,和key(任意字符串,越长越好),获取md5加密
function MD5(params: any, time: number) {
    let str = JSON.stringify(asciiSort(params));
    let s = encodeURIComponent(str + time + key).toLocaleLowerCase();
    return md5(s).toString();
}

//对json 对象进行 ascii码 排序
function asciiSort(obj: any) {
    // 键值排序
    var sortKeys = Reflect.ownKeys(obj).sort();
    var newObj = {};
    // 这里需要对每个参数进行 toString 保证和后端数据类型一致,否则字符串会不一样
    sortKeys.forEach((v) => Reflect.set(newObj, v, obj[v].toString()));
    return newObj;
}

axios 配置,对每个请求进行头添加 token 和 time 时间戳添加

import axios  from "axios";

// 携带 cookie
axios.defaults.withCredentials = true;

// 添加请求拦截器
axios.interceptors.request.use(
    function (config) {
        // 加密处理
        let time = Date.now();
        let obj = Object.assign({}, config.data || {}, config.params || {});
        config.headers = {
            token: MD5(obj, time),
            time: time,
        };
        // 在发送请求之前做些什么
        return config;
    },
    function (error) {
        console.error(error);

        // 对请求错误做些什么
        return Promise.reject(error);
    }
);

后端

需要用到的 jar 包, 对 java json 对象进行 ascii 排序

<!--fastjson-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.78</version>
        </dependency>

java MD5 加密 工具类

/**
         * MD5 加密 DigestUtils 是 org.apache.commons.codec.digest.DigestUtils 的工具类,springboot提供
         *
         * @param str 被加密的字符串
         * @return: java.lang.String
         */
        public static String encryption(String str, Long time) throws UnsupportedEncodingException {
            String s = URLEncoder.encode((str + time + KEY), "utf-8").toLowerCase(Locale.ROOT);
            return DigestUtils.md5DigestAsHex(s.getBytes(StandardCharsets.UTF_8));
        }

验证工具方法,注意下面的 parsePostRequest 的方法里面的 getReader 会造成某些框架中(例如springboot)后续对象转换时发生的错误,因为流只能被读取一次,所以我们需要重写请求类,进行 request post 请求体的重复读取。 详情看 : https://www.shuzhiduo.com/A/kvJ34RmA5g/

// 解析 get 请求参数,并生成 map
    public static Map<String, Object> parseGetRequest(HttpServletRequest request) {
        Map<String, Object> map = new LinkedHashMap<>();
        Enumeration<String> parameterNames = request.getParameterNames();
        while (parameterNames.hasMoreElements()) {
            String element = parameterNames.nextElement();
            map.put(element, request.getParameter(element));
        }
        return map;
    }

    // 解析 post 请求参数,并生成 map
    public static Map<String, Object> parsePostRequest(HttpServletRequest req) throws IOException {
        BufferedReader bufferReaderBody= new BufferedReader(req.getReader());
        return JSON.parseObject(Optional.ofNullable(bufferReaderBody.readLine()).orElse("{}"));
    }


    /**
     * 请求检验
     */
    public static boolean checkToken(String token, String time, Map<String, Object> map) throws IOException {
        if (StringUtils.notEmpty(time) && StringUtils.notEmpty(token)) {

            // 判断请求是否超时
            long l = Long.parseLong(time);
            // 10分钟 方便测试
            if (System.currentTimeMillis() - l > TimeUnit.MINUTES.toMillis(10)) {
                return false;
            }

            // 升序排序 json 对象
            String encryption = SecurityUtils.MD5.encryption(JSON.toJSONString(map, SerializerFeature.MapSortField), l);
            // 判断是否篡改数据
            if (token.equals(encryption)) {
                // 全部都不一样 则不是重放攻击
                if (TokenTask.TOKENS.stream().noneMatch(t -> t.equals(token))) {
                    // 添加 token 防止重放攻击
                    TokenTask.TOKENS.add(token);
                    return true;
                }
            }
        }
        return false;
    }

每小时(这里方便测验写10分钟) 定时删除 token 缓存 , 这里使用 java 列表实现token缓存池,有条件的可以自己写 redis 部分代码

// 这里是 springboot 定时任务,其他场景可使用 线程池 进行定时任务
@Component
@Slf4j
public class TokenTask {


    public static final List<String> TOKENS = new ArrayList<>();

    @Scheduled(cron = "0 */1 * * * ?")
    public void deleteTokenTask(){
        log.debug("[TASK : deleteTokenTask]  tokens size : "+ TokenTask.TOKENS.size());
        TokenTask.TOKENS.clear();
    }


}

最后,在某个全局请求过滤器中,做参数验证

// 某个请求过滤器,这里仅作参考,抛异常或者返回 false 进行请求拦截,按照你的业务做
	public void filter(){
		...
                // 获取 post 参数
                Map<String, Object> postParams = parsePostRequest(request);
                // 获取 get 参数
                Map<String, Object> getParams = parseGetRequest(request);
                
                String token = Stream.of( request.getHeader("token"),
                        getParams.get("token"),
                        postParams.get("token"))
                        .filter(Objects::nonNull)
                        .map(Object::toString).findAny().orElse(null);
                String time = Stream.of( request.getHeader("time"),
                        getParams.get("time"),
                        postParams.get("time"))
                        .filter(Objects::nonNull)
                        .map(Object::toString).findAny().orElse(null);

                // 跟前端一样合并 get 和 post 参数,并排除 token 和 time 字段
                postParams.putAll(getParams);
                postParams.remove("token");
                postParams.remove("time");
                // 检测 token 信息
                if (!checkToken(token, time, postParams)) {
                    throw new Exception("请求异常");
                }
        ...
	}

实战开始

首先请求接口, account 参数为 enncy ,并且可以看到 token 和 time 都被加到了请求头上

ios请求防篡改 如何防止请求被篡改_Web安全

接口成功返回数据

ios请求防篡改 如何防止请求被篡改_ios请求防篡改_02

  1. 尝试一下直接浏览器访问这个接口

ios请求防篡改 如何防止请求被篡改_java_03

  1. 尝试一下重放攻击,谷歌 f12 点击复制请求代码

ios请求防篡改 如何防止请求被篡改_ios请求防篡改_04


可以看到请求异常,说明成功防御重放攻击

ios请求防篡改 如何防止请求被篡改_ios请求防篡改_05


3. 修改参数 account 成 xxx 进行访问

ios请求防篡改 如何防止请求被篡改_ios请求防篡改_06

  1. 修改时间戳

很多人会问:
网友: 你就这样把key定义在前端文件,不怕泄露吗??
答:

  1. 后端的源码基本不会暴露,如果暴露了,那么key 看不看得到也就无关紧要了,因为你源码都被别人知道了。
  2. 因为key是 和 json 数据 一起加密的, 所以抓包请求看不到key
  3. vue 项目打包后是加密的, 很难反编译出来得到key,可以使用一些混淆工具库吧代码进行二次混淆加密,除非逆向大佬才可能拿到。如果你的项目暴露出源码,请在打包的时候确保 source map 代码没有打包出来,否则可以使用 source map 进行反编译