API横向越权修复之ID加密

  • 横向越权

横向越权一般发生在应用系统做了【认证】,但没有做【鉴权】的情况下,也是最常见的漏洞之一。

  • 认证:即识别是否有权限访问系统;
  • 鉴权:即识别在系统中的权限是什么;

例如:

// 访问某数据查询接口,接口返回ID为123的数据信息

POST : https://xxxx/iservice/queryInfo?detail_id=123 

请求接口时一般都会要求携带TOKEN,无论是JWT还是RSA的,至少不会是裸奔。这里的TOKEN就是【认证】信息,接口通过TOKEN去判断当前用户是否有请求接口的权限。但如果接口中没有做【鉴权】则会发生横向越权,用户通过修改detail_id的值就可以遍历DB中的所有记录

解决的思路:

  • 建立数据权限:常见的有:【RBAC:role based access control】, 基于角色的权限控制,一般用户不被直接授予权限,而是通过Role去获取权限。将数据的访问权限与角色绑定,用户拥有什么角色才能看到什么数据,这样即使遍历接口也只能查询到当前用户自己的数据;
  • ID加密:如例子中的detail_id,如果我们换成uuid,或其他无规则的值,也可以降低被遍历的可能性;

建立完善的权限策略是控制越权最合适的方法,但很多系统已经维护了很多年,里面的功能很庞大,往里面集成权限策略难度较大,需要去定义角色,梳理业务数据与角色的关系,然后开发权限管理功能,再挨个功能去添加鉴权;这里提供ID加密的方式去处理横向越权。

  • 目的:

      1、对原代码(业务)入侵小;
      2、降低数据遍历风险;
      3、投入人天小;
    
  • 代码实现思路

通过全局拦截API入参与返回值,对可遍历字段进行加解密。无需前端参与,后端返回数据时,对字段进行加密,加密算法保存在后端,前端使用加密字段进行后续业务处理,后端接口入参接收时进行解密。

序号 业务字段1 业务字段2 业务字段3 行ID【非业务字段,对用户不可见】
1 xxx xxx xxx wMul8LwP =》 实际值:123
2 xxx xxx xxx 3vRRDk6X =》 实际值:124
3 xxx xxx xxx TbxJ3IAe =》 实际值:125

用户选择查看序号1的行时,请求后端返回详细数据,接口如下:

https://xxxx/iservice/queryInfo?detail_id=wMul8LwP

此时如果要恶意遍历接口的话,难度相对较高,还可以将ID的加密强度提升来提供安全性。

  • 代码实现

以下均基于JAVA语言+springboot框架实现。通过反射,在拦截中判断字段是否有加密或解密注解,进行对应的加解密操作后流转。

  • 自定义注解
/**
 * 字段解密
 * @author lu
 */
@Target({
   ElementType.FIELD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Decrypt {
   

}
	解密在接口入参中使用,一般为RO对象,或者是基础类型的参数,所以作用域为FIELD或PARAMETER
/**
 * 字段加密
 * @author lu
 */
@Target({
   ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Encrypt {
   

}
	加密在返回值(VO)中使用,一般都为对象,所以注解作用域为FIELD;
  • 加密算法

    加密强度自己选择,这里以DES加密为例

/**
 * 加解密
 * @author lu
 */
@Slf4j
public class DesUtil {
   

    public static final String SECURITY_KEY = "IxDQ4e5bCEY";
    public static String encrypt(String info) {
   
        byte[] key = new byte[0];
        try {
   
            key = new BASE64Decoder().decodeBuffer(SECURITY_KEY);
        } catch (IOException e) {
   
            log.error("加密失败",e);
        }
        DES des = SecureUtil.des(key);
        String encrypt = des.encryptHex(info);
        return encrypt;
    }

    public static String decode(String encrypt) {
   
        byte[] key = new byte[0];
        try {
   
            key = new BASE64Decoder().decodeBuffer(SECURITY_KEY);
        } catch (IOException e) {
   
            log.error("解密失败",e);
        }
        DES des = SecureUtil.des(key);
        return des.decryptStr(encrypt);
    }
}
  • 接口返回值加密

      responseBodyAdvice —— 响应体的统一处理器,一般用来统一返回值使用。这里用于返回值字段加密。
    
/**
 * 返回值字段加密
 * @author lu
 */
@Slf4j
@RestControllerAdvice
public class ResponseEncryptAdvice implements ResponseBodyAdvice {
   

    /** 此处如果返回false , 则不执行当前Advice的业务 */
    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
   
        return true;
    }

    /**
     * @title 写返回值前执行
     *
     * */
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
   
        try {
   
            // 获取data类型
            Class clazz = body.getClass();
            // 是否是集合
            boolean isCollectionType = Collection.class.isAssignableFrom(clazz);
            if(isCollectionType){
   
                return encodeList(body);
            }else{
   
                return encode(body);
            }
        }catch (Exception e){
   
            log.error("请求后置处理异常",e);
        }
        return body;
    }

    /**
     * 递归加密
     */
    private JSONObject encode(Object object) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException, NoSuchFieldException {
   
        // 获取data类型
        Class clazz = object.getClass();
        // 转成JSON处理,字段加密后数据类型会变,原类无法处理
        JSONObject jsonObject = (JSONObject) JSONObject.toJSON(object);
        // 递归遍历类里及父类所有属性,找到所有带加密注解的字段
        Field[] fields = FieldsUtils.getClassAllFields(clazz);
        for (Field field : fields) {
   
            // 获取字段值
            field.setAccessible(true);
            Object val = field.get(object);
            if(val==null){
   
                // 空值不处理
                continue;
            }
            // final 修饰的跳过!!!,避免出现递归死循环的问题,例如:PageInfo
            int modify = field.getModifiers();
            if(Modifier.isFinal(modify)){
   
                continue;
            }
            // 字段类型
            Class valClass = val.getClass();
            // 是否是集合
            boolean isCollectionType = Collection.class.isAssignableFrom(valClass);
            // 是否是对象,排除掉基础数据类型与包装类
            boolean isObject = isJsonObject(val);
            // 如果是带加密注解的字段,不管什么类型,直接转String加密
            if(field.isAnnotationPresent(Encrypt.class)){
   
                // 字段加密
                jsonObject.put(field.getName(), DesUtil.encrypt(val.toString()));
            }
            // 如果是集合类型
            else if(isCollectionType){
   
                JSONArray jsonArray = encodeList(val);
                jsonObject.put(field.getName(),jsonArray);
            }
            // 基础数据类型
            else if(!isObject){
   
                // 基础数据类型且没有注解,直接放过
                continue;
            }
            // 如果是自定义的类,则继续下沉找是否有加密字段
            else /*if (valClass.getPackage().getName().startsWith("com.lu.test"))*/{
   
                jsonObject.put(field.getName(),encode(val));
            }
            /*// 其他接口带泛型的,例如:Ipage , PageInfo
            else if(valClass.equals(IPage.class)){
                // 调用获取行数据的方法
                Method method = valClass.getMethod("size");
            }else if(valClass.equals(PageInfo.class)){
                // 把PageInfo转成JSON处理
                JSONObject jsonObject = (JSONObject) JSONObject.toJSON(object);
                // 调用获取行数据的方法
                Method method = valClass.getMethod("getList");
                // 获取行数据
                Object rows = method.invoke(val);
            }*/
        }
        return jsonObject;
    }

    /**
     * 集合
     * @param object
     * @return
     */
    private JSONArray encodeList(Object object) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, NoSuchFieldException {
   
        // 以JSONARRAY存储
        JSONArray jsonArray =  new JSONArray();
        Class clazz = object.getClass();
        // 遍历集合
        Method sizeMethod = clazz.getMethod("size");
        // 调用List的size()方法获取元素数量
        int size = (int)sizeMethod.invoke(object);
        // 获取元素
        Method toArrayMethod = clazz.getMethod("toArray");
        Object[] elementArr = (Object[]) toArrayMethod.invoke(object);
        for (int i = 0; i < size; i++) {
   
            // 获取元素属性
            //Field listField = clazz.getDeclaredField("elementData");
            // 设置访问权限
            //listField.setAccessible(true);
            // 获取元素
            Object element = elementArr[i];
            // 丢进去递归
            jsonArray.add(encode(element));
        }
        return jsonArray;
    }

    /**
     * 判断是否是JSON字符串
     * @param object
     * @return
     */
    private static boolean isJsonObject(Object object) {
   
        try {
   
            JSONObject jsonObject = (JSONObject) JSONObject.toJSON(object);
            return true;
        } catch (Exception e) {
   
            return false;
        }
    }
}

适用于常见的返回值类型List<> ,Ipage , PageInfo, 以及自定义返回对象 。

  • 接口入参解密 (POST / JSON)

      入参的处理相对麻烦,因为参数的位置(contentType)多样性.
    
/**
 * 入参解密
 * @author lu
 */
@Slf4j
@RestControllerAdvice
public class RequestJsonBodyDecryptAdvice implements RequestBodyAdvice {
   

    /** 此处如果返回false , 则不执行当前Advice的业务 */
    @Override
    public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
   
        return true;
    }

    /**
     * @title 读取参数前执行
     * @description 在此做些编码 / 解密 / 封装参数为对象的操作
     *
     * POST 请求 JSON格式入参会进入这里
     *
     * */
    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) throws IOException {
   
        try{
   
            // 当前接口方法
            Method method = methodParameter.getMethod();
            // 获取参数集合 @RequestBody 只有一个参数
            Parameter[] parameters = method.getParameters();
            if(ArrayUtils.isNotEmpty(parameters)){
   
                for (Parameter parameter : parameters) {
   
                    if(parameter.isAnnotationPresent(RequestBody.class)){
   
                        Class bodyType = parameter.getType();
                        return  new DecryptHttpInputMessage(httpInputMessage,type,bodyType);
                    }
                }
            }
        }catch (Exception e){
   
            log.error("请求参数解密失败",e);
            throw new BusinessException("请求参数错误!");
        }
        return httpInputMessage;
    }

    /**
     * @title 读取参数后执行
     * @author Xingbz
     */
    @Override
    public Object afterBodyRead(Object body, HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
   
        return body;
    }

    /**
     * @title 无请求参数时的处理
     */
    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
   
        return body;
    }


    /**
     * 解密-使用解密后的数据,构造新的读取流
     */
    class DecryptHttpInputMessage implements HttpInputMessage {
   
        private HttpHeaders headers;

        private InputStream body;

        public DecryptHttpInputMessage(HttpInputMessage inputMessage, Type type,Class bodyType) throws Exception {
   
            // 转存请求头
            this.headers = inputMessage.getHeaders();
            // 请求JSON
            String bodyStr = StringUtils.defaultString(IOUtils.toString(inputMessage.getBody(), "UTF-8"));
            log.info("headers:{},body:{}",headers,bodyStr);
            try {
   
                // 有些保存接口是LIST,需要特殊处理
                if(bodyType.equals(List.class)){
   
                    // 获取LIST的内部泛型类
                    JSONArray jsonArray = JSONObject.parseArray(bodyStr);
                    JSONArray decryptArray = new JSONArray();
                    if (ObjectUtils.isEmpty(type)){
   
                        this.body = IOUtils.toInputStream(jsonArray.toJSONString(), "UTF-8");
                        return;
                    }
                    ParameterizedType parameterizedType = (ParameterizedType) type;
                    Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
                    Class<?> elementType = (Class<?>) actualTypeArguments[0];
                    for (Object o : jsonArray) {
   
                        decryptArray.add(decode(JSONObject.parseObject(JSONObject.toJSONString(o)),elementType));
                    }
                    // 传递到接口
                    this.body = IOUtils.toInputStream(decryptArray.toJSONString(), "UTF-8");
                }else {
   
                    // 先转成JSON对象
                    JSONObject jsonObject = JSONObject.parseObject(bodyStr);
                    // 解密
                    JSONObject finObject = decode(jsonObject, bodyType);
                    // 传递到接口
                    this.body = IOUtils.toInputStream(finObject.toJSONString(), "UTF-8");
                }
            } catch (Exception e) {
   
                log.error("加密参数【{}】解密失败:{}", bodyStr, e.getMessage(), e);
                // 传递到接口
                this.body = IOUtils.toInputStream(bodyStr, "UTF-8");
            }
        }

        @Override
        public InputStream getBody() {
   
            return body;
        }

        @Override
        public HttpHeaders getHeaders() {
   
            return headers;
        }

    }

    /**
     * 递归解密
     */
    private JSONObject decode(JSONObject jsonObject,Class type) throws IllegalAccessException, NoSuchMethodException, NoSuchFieldException {
   
        // 取对象里的属性
        Set<String> keys = jsonObject.keySet();
        // 取接受入参里的所有属性
        Field[] fields = FieldsUtils.getClassAllFields(type);
        // 匹配两者,解密、字段类型转换
        for (Field field : fields) {
   
            field.setAccessible(true);
            // 字段名
            String fieldName = field.getName();
            // 字段类型
            Class fieldType = field.getType();
            // 如果JSON中没有,则直接跳过
            if(!keys.contains(fieldName)){
   
                continue;
            }
            // 是否是集合
            boolean isCollectionType = Collection.class.isAssignableFrom(fieldType);
            // 当前字段是否带有解密注解
            if(field.isAnnotationPresent(Decrypt.class)){
   
                // 需要解密,一定是要String类型
                String val = jsonObject.getString(fieldName);
                // 解
                String finVal = DesUtil.decode(val);
                // 判断原来是什么类型
                //log.info("fileType:{}",fieldType);

                if(ClassUtils.isPrimitiveOrWrapper(fieldType)){
   
                    // 基础数据类型 直接替换
                    jsonObject.put(fieldName,finVal);
                }else if(fieldType.equals(List.class)){
   
                    // LIST集合,用array的接口换成LIST去接收
                    jsonObject.put(fieldName,JSONObject.parseArray(finVal));
                }else{
   
                    if(isJsonString(finVal)) {
   
                        jsonObject.put(fieldName,JSONObject.parseObject(finVal));
                    }else{
   
                        // 还存在一些BigDecimal类似的,无法被判断为基础数据类型,回到这里
                        jsonObject.put(fieldName,finVal);
                    }
                }
            }else if(fieldType.getPackage().getName().startsWith("com.lu.test")){
   
                // 如果是com.lu.test这个根包下的自定义对象,则递归向下找
                jsonObject.put(fieldName,decode(jsonObject.getJSONObject(fieldName),fieldType));
            }else if(isCollectionType){
   
                // 以JSONARRAY存储
                JSONArray jsonArray =  jsonObject.getJSONArray(fieldName);
                JSONArray decodeArray = new JSONArray();
                // 获取LIST中的泛型集合
                Type listType = field.getGenericType();
                ParameterizedType parameterizedType = (ParameterizedType) listType;
                Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
                Class<?> elementType = (Class<?>) actualTypeArguments[0];
                // 遍历集合
                for (Object o : jsonArray) {
   
                    // 获取元素属性
                    decodeArray.add(decode(JSONObject.parseObject(JSON.toJSONString(o)),elementType));
                }
                jsonObject.put(field.getName(),decodeArray);
            }
        }
        return jsonObject;
    }


    /**
     * 判断是否是JSON字符串
     * @param jsonString
     * @return
     */
    private static boolean isJsonString(String jsonString) {
   
        try {
   
            JSONObject.parseObject(jsonString);
            return true;
        } catch (Exception e) {
   
            return false;
        }
    }
}
  • 接口入参解密 (GET & POST FORM-DATA | URL)
/**
 * 入参解密
 *
 * @author lu
 */
@Slf4j
@Component
public class RequestParamDecryptAdvice implements HandlerInterceptor {
   

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
   
        //部分get请求不会带Content-Type
        /*if (ObjectUtils.isEmpty(request.getContentType())){
            return true;
        }*/
        // application/json 直接放过
        if(ObjectUtils.isNotEmpty(request.getContentType()) && request.getContentType().toLowerCase().contains("application/json")){
   
            return true;
        }
        if (ObjectUtils.isEmpty(request.getContentType())){
   
            log.warn("当前请求 contentType 为空!");
        }
        // 请求头里获取解密标识
        String flag = request.getHeader(DecryptRequestWrapper.DECRYPT_FLAG);
        if (StringUtils.isEmpty(flag)) {
   
            // 生成包装类
            DecryptRequestWrapper decryptRequest = new DecryptRequestWrapper(request);
            // 判断是否需要解密
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Method method = handlerMethod.getMethod();
            //获取参数名集合
            String[] parameterNames = ParameterUtil.getParameterNames(method);
            // 获取参数集合
            Parameter[] parameters = method.getParameters();
            if (ArrayUtils.isNotEmpty(parameters)) {
   
                try {
   
                    // 遍历参数
                    int i=0;
                    for (Parameter parameter : parameters) {
   
                        // 获取参数类型
                        Class paramterType = parameter.getType();
                        // 断参数是否是基本数据类型以及包装类 && 带有解密注解
                        if (ClassUtils.isPrimitiveOrWrapper(paramterType) && parameter.isAnnotationPresent(Decrypt.class)) {
   
                            // 获取密文
                            //JDK版本必须是1.8及以上
                            //编译时候必须有编译选项:javac -parameters打开,默认是关闭的
                            // 否则这里parameter.getName()拿不到真实的参数名字
                            if (i>parameters.length-1) {
   
                                return true ;
                            }
                            String orginVal = request.getParameter(parameterNames[i]);
                            // 空值不处理
                            if (StringUtils.isEmpty(orginVal)) {
   
                                log.warn("形参:{}未从入参中获取到值", parameterNames[i]);
                                continue;
                            }
                            // 非空解密 覆盖
                            decryptRequest.setParameter(parameterNames[i], DesUtil.decode(orginVal));

                        }
                        // 自动以RO处理
                        else if (paramterType.getPackage().getName().startsWith("com.lu.test")) {
   
                            // 获取所有字段
                            Field[] fields = paramterType.getDeclaredFields();
                            // 是否带解密注解(这里不递归,不考虑RO里还玩嵌套的,不是JSON格式的应该不存在这种情况)
                            for (Field field : fields) {
   
                                if (ClassUtils.isPrimitiveOrWrapper(field.getType()) && field.isAnnotationPresent(Decrypt.class)) {
   
                                    // 获取密文
                                    String orginVal = request.getParameter(field.getName());
                                    // 空值不处理
                                    if (StringUtils.isEmpty(orginVal)) {
   
                                        log.warn("形参:{}未从入参中获取到值", field.getName());
                                        continue;
                                    }
                                    // 非空解密 覆盖
                                    decryptRequest.setParameter(field.getName(), DesUtil.decode(orginVal));
                                }
                            }
                        }
                        i++;
                        // 其他常见的 request/response对象这些都不处理
                    }
                } catch (Exception e) {
   
                    log.error("字段解密异常!", e);
                }
            }
            String uri = request.getRequestURI().replace(request.getContextPath(), "");
            request.getRequestDispatcher(uri).forward(decryptRequest, response);
            return false;
        }
        // 已经解密的
        return true;
    }


    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
   
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
   
    }
}
	HandlerInterceptor 需要结合WebMvcConfigurer才能生效
/**
 * @author lu
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
   

    @Autowired
    RequestParamDecryptAdvice requestParamDecryptAdvice;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
   
        registry.addInterceptor(requestParamDecryptAdvice);
    }
}
  • 反射参数获取工具
/**
 * 自 java8 开始,可以通过反射得到方法的参数名,不过这有个条件:你必须手动在编译时开启-parameters 参数
 * 部署项目时不可能设置这种东西,
 */
public class ParameterUtil {
   
    /**
     * Spring自带的参数提取工具类
     */
    private static final DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();

    /**
     * 获取参数名
     *
     * @param method 方法
     * @return 参数名
     */
    @Nullable
    public static String[] getParameterNames(Method method) {
   
        return discoverer.getParameterNames(method);
    }

    /**
     * 获取参数名
     *
     * @param ctor 构造函数
     * @return 参数名
     */
    @Nullable
    public static String[] getParameterNames(Constructor<?> ctor) {
   
        return discoverer.getParameterNames(ctor);
    }
}
  • 应用场景

    // ResponseResult 为自定义的统一返回值
    @PostMapping("/query1")
    public ResponseResult<SupErpVo> querySup(@RequestBody SupErpRo ro) {
   
        return supService.querySup(ro);
    }
	// ResponseResult 为自定义的统一返回值
	// IPage 为分页插件返回值
    @GetMapping("/query2")
    public ResponseResult<IPage<SupErpVo>> querySup(@RequestParam @Decrypt Long headId) {
   
        return supService.querySup(headId);
    }
	@Data
	public class SupErpVo {
   
	    
	    // 可遍历字段加密
	    @Encrypt
	    private Long id;
	
	    private String supName;
	    
	    private String supDep;
	}
	@Data
	public class SupErpRo {
   
	    
	    // 加密字段解密
	    @Decrypt
	    private Long id;
	
	    private String supName;
	    
	}

相关推荐

  1. API横向越权修复ID加密

    2024-02-01 11:38:03       26 阅读
  2. iOSAPP支持横竖

    2024-02-01 11:38:03       7 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-02-01 11:38:03       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-02-01 11:38:03       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-02-01 11:38:03       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-02-01 11:38:03       18 阅读

热门阅读

  1. Flask 之 SQLAlchemy

    2024-02-01 11:38:03       33 阅读
  2. Rust - 变量

    2024-02-01 11:38:03       31 阅读