前后端不分离 "老" 项目,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 @   大阿张  阅读(57)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示
主题色彩