前後端不分離 "老" 專案,SQL 注入漏洞處理實踐

大阿张發表於2024-08-28

前言

接上篇的 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 就是非法的

相關文章