横向越权一般发生在应用系统做了【认证】,但没有做【鉴权】的情况下,也是最常见的漏洞之一。
- 认证:即识别是否有权限访问系统;
- 鉴权:即识别在系统中的权限是什么;
例如:
// 访问某数据查询接口,接口返回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的业务 */
@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