前后端不分离 "老" 项目,SQL 注入漏洞处理实践
前言#
接上篇的 XSS
漏洞处理实践,这次是针对 SQL
注入漏洞的处理实践。我们的后端代码,在项目初期没有使用世面上的 ORM
框架,而是使用 spring
的 JdbcTemplate
简单的封装了增删改查的 DAO
方法。然后暴露一通用的 Controller
层接口,这样无论是前端还是后端都更加 “方便了”! 前端可以直接指定参数和将要使用的 sql
标识,然后调统一的接口,后端可以直接将通用的这个 DAO
,传什么都行(可以写 sql 语句,或是 xml 中写的sql 等等),总之就是非常的通用。
随着项目的业务的增长,后端代码这套 "简便" 的封装逻辑、“开发范式”已经是项目安全漏洞的罪魁祸首。伴随而来的严重漏洞主要有:
- 越权漏洞
- 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;
}
}
说一下处理的思路:
- 对这个
Filter
做一些额外配置,即在init
方法中进行初始化配置,可以排除路径等等 - 在
doFilter
方法中首先对放行path
进行判断,然后使用自定义的wrapper
包装request
。因为这里也要对post
+application/json
请求的参数也要过滤,而request
对象读取一次流数据,流就关闭了,所以需要自定义的wrapper
包装request
,将请求内容缓存。之后控制器再调用getReader
读取请求内容就不会由于在Filter
读取过而报错。 - 在自定义的
SqlInjectHttpServletRequestWrapper
中的构造首先判断了下是否是附件上传类型,如果是就不将inputStream
转为json
字符串了,同样在getInputStream
和getReader
方法中都进行了判断。 - 在过滤器
loadParameterMap
方法中使用两个map
去存储请求参数,然后在validateParametersForSQLInjection
方法中递归去判断请求参数的值中是否存在非法恶意sql
- 在
isSqlInject
方法中使用正则去匹配请求参数的值,这里使用部分匹配,比如select
不会认为是非法,但是select * from
就是非法的
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!