横向越权一般发生在应用系统做了【认证】,但没有做【鉴权】的情况下,也是最常见的漏洞之一。
例如:
// 访问某数据查询接口,接口返回ID为123的数据信息
POST : https://xxxx/iservice/queryInfo?detail_id=123
请求接口时一般都会要求携带TOKEN,无论是JWT还是RSA的,至少不会是裸奔。这里的TOKEN就是【认证】信息,接口通过TOKEN去判断当前用户是否有请求接口的权限。但如果接口中没有做【鉴权】则会发生横向越权,用户通过修改detail_id的值就可以遍历DB中的所有记录。
解决的思路:
建立完善的权限策略是控制越权最合适的方法,但很多系统已经维护了很多年,里面的功能很庞大,往里面集成权限策略难度较大,需要去定义角色,梳理业务数据与角色的关系,然后开发权限管理功能,再挨个功能去添加鉴权;这里提供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;
}
}
}
/**
* 入参解密
*
* @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;
}