积跬步至千里

前后端不分离 "老" 项目,SQL 注入漏洞处理实践

前言

接上篇的 XSS 漏洞处理实践,这次是针对 SQL 注入漏洞的处理实践。我们的后端代码,在项目初期没有使用世面上的 ORM 框架,而是使用 springJdbcTemplate 简单的封装了增删改查的 DAO 方法。然后暴露一通用的 Controller 层接口,这样无论是前端还是后端都更加 “方便了”! 前端可以直接指定参数和将要使用的 sql 标识,然后调统一的接口,后端可以直接将通用的这个 DAO,传什么都行(可以写 sql 语句,或是 xml 中写的sql 等等),总之就是非常的通用。

随着项目的业务的增长,后端代码这套 "简便" 的封装逻辑、“开发范式”已经是项目安全漏洞的罪魁祸首。伴随而来的严重漏洞主要有:

  1. 越权漏洞
  2. SQL 注入漏洞

对于越权漏洞,此篇不细说。主要是大量的前端页面调用的都是后端那个通用的 Controller 层的方法,权限根本就控不了。对于 SQL 注入问题,归咎于那个通用的 DAO,代码中充斥着大量的 SQL 拼接,并且大量的 Controller 层直接 try catch 调用 service 异常直接将 SQL 异常信息返回,导致表以及字段信息严重暴露。

SQL 注入漏洞处理实践

屏蔽 SQL 异常信息返回客户端

由于项目中存在大量的 Controller 方法直接 try catch 调用 service的异常,并将异常信息直接返回给客户端,这里要将 SQL 异常信息屏蔽掉,防止暴露数据库表细节。怎么改呢?挨个改 Controller 方法肯定改不完的, 所以这里就优先针对这个通用的 DAO 下手了。

@Override
    public List<Map<String, Object>> queryList(String sql, HashMap<String, Object> params) {
        // ...
        try {
            log.info("queryList sql=> [{}]", sql);
            log.info("queryList params=> {}", Arrays.toString(oarr));
            List<Map<String, Object>> l = getJdbcTemplate(tableName).query(sql, oarr, this.columnMapper());
            EncryptUtil.decryptDataBatch(l, sql, "mobile");
            return l;
        } catch (Exception e) {
            this.printErrorContext(e,sql, params, p,AlarmMetricEnum.BASE_DAO_QUERY_LIST);
            throw e;
        }
    }

上面是一个通用的 queryList 方法,try catch 住最终的 getJdbcTemplate(tableName).query(sql, oarr, this.columnMapper());然后在 catch 中去记录 sql 执行异常并通知,最后将异常向上抛出去。这里我的思路是修改异常的 msg 信息,将原生异常 msg 替换掉,不改变原生异常类型,因为保不准调用方会有拿这个异常类型去判断去做些什么处理。

这里我是直接写了一个切面,切这个 DAO 类,代码如下:

@Aspect
@Component
public class DatabaseExceptionAspect {

    private static final Logger log = LoggerFactory.getLogger(DatabaseExceptionAspect.class);

    @AfterThrowing(pointcut = "execution(* *.*.*(..))", throwing = "ex") //
    public void handleException(Exception ex) {
        log.info("DatabaseExceptionAspect, 捕获异常:{}", ex.getMessage());
        if (ex instanceof BadSqlGrammarException) {
            handleBadSqlGrammarException((BadSqlGrammarException) ex);
        } else if (ex instanceof DuplicateKeyException) {
            handleDuplicateKeyException((DuplicateKeyException) ex);
        } else if (ex instanceof DataIntegrityViolationException) {
            handleDataIntegrityViolationException((DataIntegrityViolationException) ex);
        } else if (ex instanceof DataAccessException) {
            handleDataAccessException((DataAccessException) ex);
        }
    }
    
    private void handleBadSqlGrammarException(BadSqlGrammarException ex) throws BadSqlGrammarException {
        String filteredMessage = filterSensitiveInformation(ex.getMessage());
        throw new BadSqlGrammarException("", filteredMessage, new SQLException(filteredMessage));
    }
    // ... 省略

    private String filterSensitiveInformation(String message) {
        // 可以对message 处理,此处先返回固定值
        return "数据库操作异常";
    }
}

使用 @AfterThrowing 通知,然后判断异常的类型。这里将能够暴露数据库敏感信息的异常进行判断,然后替换 msg 再向上抛出去即可

过滤器 Filter 处理 SQL 注入

后端存在 sql 注入的问题 sql 在短时间内改是改不完的,这里选用 Filter 来处理 SQL 注入,也仅仅是简单的防御,采用正则去校验传参,没有任何正则表达式可以完美地防止所有SQL注入攻击,只是防御SQL注入的多个层次中的一层。

看了一下我们的接口请求类型存在普通的 GET 请求,存量大的 POST + application/x-www-form-urlencoded 以及 POST + application/json,还有附件上传的 POST + form-data 类型。这样的老项目接口没有遵从 RESTFUL 风格并不奇怪,都是在接口方法上标注 @RequestMapping ,全由前端决定是 POST还是 GET请求。对于POST + form-data 这种只用于附件上传,所以在过滤器中可以不处理,下面是代码:

public class SqlInjectFilter implements Filter {

    private static final Logger log = LoggerFactory.getLogger(SqlInjectFilter.class);

    private static final String CONF_KEY = "sql_inject_filter_config";

    private ConfigProp configProp = new ConfigProp();

    private static final AntPathMatcher ANT_MATCHER = new AntPathMatcher();

    // 没有任何正则表达式可以完美地防止所有SQL注入攻击,只是防御SQL注入的多个层次中的一层
    private static final String REFINED_SQL_INJECTION_REGEX =
            "(\\bEXEC(UTE)?\\b|UNION(\\s+ALL)?\\s+SELECT|INSERT\\s+INTO\\s+.+?VALUES|UPDATE\\s+.+?SET|DELETE\\s+FROM|\\bALTER\\b|\\bDROP\\b|\\bTRUNCATE\\b|\\bCREATE\\b|\\bGRANT\\b|\\bREVOKE\\b|\\bRENAME\\b|\\bSHOW\\b|\\bUSE\\b|\\/\\*.*?\\*\\/|--[\\s\\S]*?\\n|SELECT\\s+.*?\\s+FROM)";

    private static final Pattern SQL_PATTERN = Pattern.compile(REFINED_SQL_INJECTION_REGEX, Pattern.CASE_INSENSITIVE);

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        HttpServletResponse res = (HttpServletResponse) servletResponse;

        String requestURI = req.getRequestURI();
        // 对特定 path 放行
        if(StringUtil.ignoreSuffix(requestURI)){
            chain.doFilter(servletRequest, servletResponse);
            return;
        }
        
        // 未开启 filter 放行
        if (!this.configProp.isOpen) {
            chain.doFilter(servletRequest, servletResponse);
            return;
        }

        // 在排除路径内放行
        if (exclude(requestURI, this.configProp)) {
            chain.doFilter(servletRequest, servletResponse);
            return;
        }

        // 构建 wrapper 包装 request
        SqlInjectHttpServletRequestWrapper sqlInjectHttpServletRequestWrapper = new SqlInjectHttpServletRequestWrapper(req);
        
        // json 和 其他contentType 分开处理
        Map<String, Object> parameterMap = Maps.newTreeMap();
        Map<String, Object> jsonParameterMap = Maps.newTreeMap();
        this.loadParameterMap(parameterMap, jsonParameterMap, sqlInjectHttpServletRequestWrapper);

        // 递归校验是否有SQL关键字
        if (validateParametersForSQLInjection(parameterMap, res) || validateParametersForSQLInjection(jsonParameterMap, res)) {
            return;
        }

        chain.doFilter(sqlInjectHttpServletRequestWrapper, servletResponse);
    }

    private <T> boolean validateParametersForSQLInjection(T value, HttpServletResponse response) throws IOException {
        if (value instanceof String) {
            return !isSqlInject((String) value, response);
        } else if (value instanceof Map) {
            for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
                if (validateParametersForSQLInjection(entry.getValue(), response)) {
                    return true;
                }
            }
        } else if (value instanceof List) {
            for (Object item : (List<?>) value) {
                if (validateParametersForSQLInjection(item, response)) {
                    return true;
                }
            }
        }
        return false;
    }

    private void loadParameterMap(Map<String, Object> paramMap, Map<String, Object> jsonParamMap, SqlInjectHttpServletRequestWrapper requestWrapper) {
        // 区分 json 和其他类型请求参数,分开处理
        if ("POST".equalsIgnoreCase(requestWrapper.getMethod()) && isJsonContentType(requestWrapper.getContentType())) {
            String body = requestWrapper.getBody();
            if (JSONUtil.isTypeJSON(body)) {
                Object jsonBody = JSONObject.parse(body);
                if (jsonBody instanceof JSONObject) {
                    JSONObject jsonObject = (JSONObject) jsonBody;
                    jsonParamMap.putAll(jsonObject.getInnerMap());
                } else if (jsonBody instanceof JSONArray) {
                    jsonParamMap.put("body", jsonBody);
                }
            }
        }

        Map<String, String[]> parameterMap = requestWrapper.getParameterMap();
        Set<Map.Entry<String, String[]>> entries = parameterMap.entrySet();
        for (Map.Entry<String, String[]> entry : entries) {
            String[] values = entry.getValue();
            if (values == null) {
                continue;
            }
            if (values.length > 1) {
                paramMap.put(entry.getKey(), Arrays.asList(values));
            } else if (values.length == 1) {
                paramMap.put(entry.getKey(), values[0]);
            }
        }
    }

    private boolean exclude(String requestURI, ConfigProp configProp) {
        if (configProp.getExcludeUrl().contains(requestURI)) {
            return true;
        }
        for (String pattern : configProp.getExcludeUrlPattern()) {
            if (ANT_MATCHER.match(pattern, requestURI)) {
                return true;
            }
        }
        return false;
    }


    private boolean isSqlInject(String value, HttpServletResponse res) throws IOException {
        if (value!= null && SQL_PATTERN.matcher(value).find()) {
            log.info(" SqlInjectionFilter isSqlInject,入参中有非法字符: {}", value);
            outMessage(res, "存在非法请求参数");
            return false;
        }
        return true;
    }

    private void outMessage(HttpServletResponse res, String message) throws IOException {
        res.setCharacterEncoding("utf-8");
        res.setHeader("Cache-Control", "no-cache");
        res.setContentType("text/html");
        PrintWriter pw = res.getWriter();
        pw.print(message);
        pw.flush();
        pw.close();
    }

    private boolean isJsonContentType(String contentType) {
        if (contentType == null) {
            return false;
        }
        return contentType.toLowerCase().startsWith("application/json");
    }


    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // 加载管控点配置
        String config = ConfigUtil.getApplication(CONF_KEY);
        if (JSONUtil.isTypeJSON(config)) {
            this.configProp = JSON.parseObject(config, ConfigProp.class);
        }
        
        // 加载 init 参数
        String excludeUrl = filterConfig.getInitParameter("excludeUrl");
        if (StringUtil.isNotBlank(excludeUrl)) {
            this.configProp.getExcludeUrl().addAll(Arrays.asList(excludeUrl.split(",")));
        }

        String excludeUrlPattern = filterConfig.getInitParameter("excludeUrlPattern");
        if (StringUtil.isNotBlank(excludeUrlPattern)) {
            this.configProp.getExcludeUrlPattern().addAll(Arrays.asList(excludeUrlPattern.split(",")));
        }
    }

    private static class ConfigProp {
        // 是否开启
        boolean isOpen = true;
        // 排除的路径
        List<String> excludeUrl = new ArrayList<>();
        // 排除路径规则模板
        List<String> excludeUrlPattern = new ArrayList<>();

        public boolean isOpen() {
            return isOpen;
        }

        public void setOpen(boolean open) {
            isOpen = open;
        }

        public List<String> getExcludeUrl() {
            return excludeUrl;
        }

        public void setExcludeUrl(List<String> excludeUrl) {
            this.excludeUrl = excludeUrl;
        }

        public List<String> getExcludeUrlPattern() {
            return excludeUrlPattern;
        }

        public void setExcludeUrlPattern(List<String> excludeUrlPattern) {
            this.excludeUrlPattern = excludeUrlPattern;
        }
    }

}
public class SqlInjectHttpServletRequestWrapper extends HttpServletRequestWrapper {

    private static final Logger log = LoggerFactory.getLogger(SqlInjectHttpServletRequestWrapper.class);

    private final String body;
    private boolean isFormDataContentType = false;

    public SqlInjectHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        if (ServletFileUpload.isMultipartContent(request)) { // form-data 不处理
            body = null;
            isFormDataContentType = true;
        } else {
            StringBuilder stringBuilder = new StringBuilder();
            BufferedReader bufferedReader = null;
            try {
                InputStream inputStream = request.getInputStream();

                if (inputStream != null) {
                    bufferedReader = new BufferedReader(new InputStreamReader(inputStream));

                    char[] charBuffer = new char[128];
                    int bytesRead = -1;

                    while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
                        stringBuilder.append(charBuffer, 0, bytesRead);
                    }
                } else {
                    stringBuilder.append("");
                }
            } catch (IOException ex) {
                log.error("Error reading the request body...");
            } finally {
                if (bufferedReader != null) {
                    try {
                        bufferedReader.close();
                    } catch (IOException ex) {
                        log.error("Error closing bufferedReader...");
                    }
                }
            }
            body = stringBuilder.toString();
        }
    }


    @Override
    public ServletInputStream getInputStream() throws IOException {
        if (isFormDataContentType) {
            return super.getInputStream();
        }
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        if (isFormDataContentType) {
            return super.getReader();
        }
        return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8));
    }

    @Override
    public String getParameter(String name) {
        return super.getParameter(name);
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        return super.getParameterMap();
    }

    @Override
    public Enumeration<String> getParameterNames() {
        return super.getParameterNames();
    }

    @Override
    public String[] getParameterValues(String name) {
        return super.getParameterValues(name);
    }

    public String getBody() {
        return this.body;
    }

}

说一下处理的思路:

  1. 对这个 Filter 做一些额外配置,即在 init 方法中进行初始化配置,可以排除路径等等
  2. doFilter 方法中首先对放行 path 进行判断,然后使用自定义的 wrapper 包装 request。因为这里也要对 post + application/json请求的参数也要过滤,而 request 对象读取一次流数据,流就关闭了,所以需要自定义的 wrapper 包装 request,将请求内容缓存。之后控制器再调用getReader读取请求内容就不会由于在 Filter 读取过而报错。
  3. 在自定义的 SqlInjectHttpServletRequestWrapper 中的构造首先判断了下是否是附件上传类型,如果是就不将 inputStream 转为 json字符串了,同样在 getInputStreamgetReader方法中都进行了判断。
  4. 在过滤器 loadParameterMap 方法中使用两个 map 去存储请求参数,然后在 validateParametersForSQLInjection 方法中递归去判断请求参数的值中是否存在非法恶意 sql
  5. isSqlInject 方法中使用正则去匹配请求参数的值,这里使用部分匹配,比如 select 不会认为是非法,但是 select * from 就是非法的
posted @ 2024-08-28 15:08  大阿张  阅读(35)  评论(0编辑  收藏  举报