横向越权一般发生在应用系统做了【认证】,但没有做【鉴权】的情况下,也是最常见的漏洞之一。
- 认证:即识别是否有权限访问系统;
- 鉴权:即识别在系统中的权限是什么;
例如:
// 访问某数据查询接口,接口返回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加密的方式去处理横向越权。
通过全局拦截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的业务 */@Overridepublic boolean supports(MethodParameter methodParameter, Class aClass) {return true;}/*** @title 写返回值前执行** */@Overridepublic 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 修饰的跳过!!!,避免出现递归死循环的问题,例如:PageInfoint 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 , PageInfoelse 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的业务 */@Overridepublic boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {return true;}/*** @title 读取参数前执行* @description 在此做些编码 / 解密 / 封装参数为对象的操作** POST 请求 JSON格式入参会进入这里** */@Overridepublic 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*/@Overridepublic Object afterBodyRead(Object body, HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {return body;}/*** @title 无请求参数时的处理*/@Overridepublic 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();// 请求JSONString 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");}}@Overridepublic InputStream getBody() {return body;}@Overridepublic 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 {@Overridepublic 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;}@Overridepublic void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {}
}
HandlerInterceptor 需要结合WebMvcConfigurer才能生效
/*** @author lu*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {@AutowiredRequestParamDecryptAdvice requestParamDecryptAdvice;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(requestParamDecryptAdvice);}
}
- 反射参数获取工具
/*** 自 java8 开始,可以通过反射得到方法的参数名,不过这有个条件:你必须手动在编译时开启-parameters 参数* 部署项目时不可能设置这种东西,*/
public class ParameterUtil {/*** Spring自带的参数提取工具类*/private static final DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();/*** 获取参数名** @param method 方法* @return 参数名*/@Nullablepublic static String[] getParameterNames(Method method) {return discoverer.getParameterNames(method);}/*** 获取参数名** @param ctor 构造函数* @return 参数名*/@Nullablepublic 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);}
@Datapublic class SupErpVo {// 可遍历字段加密@Encryptprivate Long id;private String supName;private String supDep;}
@Datapublic class SupErpRo {// 加密字段解密@Decryptprivate Long id;private String supName;}