大家好,我是小义,好久不见,一转眼已经2025年了,真是抱歉又鸽了好久。新年第一篇,讲一个小故事吧。
作为一名程序员,在编程的世界里摸爬滚打了这么久,本以为自己能应对各种技术难题,可没想到,最近却因为一个 SQL 注入安全漏洞,陷入了一场让人哭笑不得又无比头疼的 “背锅” 风波。
前段时间安全员在测试环境抛出了一个SQL注入的安全漏洞,说是我们系统的Bug。
我当时心里就 “咯噔” 一下,赶紧停下手里的活儿,着手去查看相关的代码和日志。
一番排查下来,我发现竟然是 SQL 注入的安全漏洞在作祟。其实对于 SQL 注入,相信大家都不陌生,它就是攻击者通过在用户输入的字段中注入恶意的 SQL 语句,从而达到非法获取数据库信息、篡改数据甚至破坏整个数据库结构的目的。比如常见的在登录页面,如果对用户输入的用户名和密码没有做好严格的校验和过滤,攻击者就可能输入类似 “' or '1'='1' --” 这样的语句,绕过正常的验证逻辑,直接登录系统,造成严重后果。
在我们这个项目里,原本以为对输入数据做了基本的处理,可没想到还是百密一疏。
在和关联方数据交互的接口处,对于传递给他们的一些参数,他们只是简单地进行了格式上的验证,却忽略了深层次的 SQL 语句安全检查。结果报错信息抛到了我们这边,我们也没有进一步捕获,导致把服务器异常回显给前端了。
关联方那边可不管这漏洞到底是怎么产生的,只知道是和我们对接的地方出了问题,所以一股脑地把责任都推到了我们这边。我当时那叫一个委屈,明明大家都应该对数据安全负责啊。
还好只是测试环境,赶紧修复就好了,遇到问题就要解决问题。我们都知道mybatis-plus本身其实提供了多种机制来防止 SQL 注入攻击的。
1、使用@Param 注解实现参数化查询。
2、使用 MyBatis Plus 的 CRUD 方法。
3、配置 SQL 注入过滤器(SqlExplainInterceptor)。
不过这是关联方报的错,我们还是得从入参考虑,最好就是在前端传递入参时一次性把非法参数给过滤掉了。那么怎么实现呢?
首先,把前端接口请求中传递的所有参数提取出来。提取参数需要用到输入流,而在 HTTP 请求中,流(Stream)通常只能读取一次,所以需要进行特殊的缓存处理。
@Slf4j
public class BodyReaderHttpServletRequest extends HttpServletRequestWrapper {
private String body;
public BodyReaderHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
StringBuilder stringBuilder=new StringBuilder();
BufferedReader bufferedReader=null;
try{
InputStream inputStream=request.getInputStream();
if(inputStream!=null){
bufferedReader =new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
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){
throw ex;
}finally {
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException ex) {
log.error("close bufferedReader error", ex);
}
}
}
body=stringBuilder.toString();
}
@Override
public ServletInputStream getInputStream() throws IOException{
final ByteArrayInputStream bais=new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
ServletInputStream servletInputStream= new ServletInputStream() {
@Override
public boolean isFinished() {
returnfalse;
}
@Override
public boolean isReady() {
returnfalse;
}
@Override
public void setReadListener(ReadListener listener) {
log.info("servletInputStream setReadListener do nothing");
}
@Override
public int read() throws IOException{
return bais.read();
}
};
return servletInputStream;
}
@Override
public BufferedReader getReader() throws IOException{
return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8));
}
public String getBody(){
return this.body;
}
@Override
public String getParameter(String name){
return super.getParameter(name);
}
@Override
public String getHeader(String name){
return super.getHeader(name);
}
@Override
public Enumeration<String>getHeaderNames(){
return super.getHeaderNames();
}
@Override
public Enumeration<String>getHeaders(String name){
return super.getHeaders(name);
}
}
接着就是对所有参数进行SQL注入的校验,通过过滤器来实现。
@WebFilter(urlPatterns = "/*",filterName = "RequestParamFilter")
@Configuration
public class RequestParamFilter implements Filter {
private static final String POST_METHOD = "POST";
@Value("#{'${requestParamFilter.urls}'.split(',')}")
private List<String> urls;
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
/**
* @description sql注入过滤
*/
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String uri = request.getRequestURI();
ServletResponse response = servletResponse;
//获取getInputStream中的流数据,然后将body里的数据重新写入传递
BodyReaderHttpServletRequest servletRequestWrapper = new BodyReaderHttpServletRequest(request);
//校验SQL注入
if (!(CollUtil.isNotEmpty(urls) && urls.contains(uri))) {
// 获得所有请求参数
String params = getAllParams(servletRequestWrapper);
if (sqlValidate(params)) {
LogUtils.info("请求入参非法!uri:{}",uri);
throw BusinessEnum.PARAM_ERROR.newException("请求入参非法!");
}
}
long currentTimeMillis = System.currentTimeMillis();
filterChain.doFilter(servletRequestWrapper,response);
LogUtils.info("request-uri:{}-->耗时:{}ms",uri, System.currentTimeMillis() - currentTimeMillis);
}
/**
* 获取所有请求参数值
*/
protected String getAllParams(BodyReaderHttpServletRequest servletRequestWrapper) {
String params = "";
//URL参数
Map<String, String[]> parameterMap = servletRequestWrapper.getParameterMap();
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String[] values = entry.getValue();
for (int i = 0; i < values.length; i++) {
params += values[i];
}
}
//POST请求体参数
if (POST_METHOD.equals(servletRequestWrapper.getMethod())) {
String body = servletRequestWrapper.getBody();
JSONObject jsonObject = JSON.parseObject(body, JSONObject.class);
for (Object value : jsonObject.values()) {
if (value instanceof JSONObject) {
JSONObject nestedJsonObject = (JSONObject) value;
for (Object val : nestedJsonObject.values()) {
String valStr = String.valueOf(val);
params += valStr;
}
} else {
String valueStr = String.valueOf(value);
params += valueStr;
}
}
}
return params;
}
/**
* @description 匹配效验
*/
protected boolean sqlValidate(String str){
// 统一转为小写
String s = str.toLowerCase();
// 过滤掉的sql关键字,特殊字符前面需要加\\进行转义
String badStr =
"select|update|and|or|delete|insert|truncate|char|into|substr|ascii|declare|exec|count|master|into|drop|execute|table|"+
"char|declare|sitename|xp_cmdshell|like|from|grant|use|group_concat|column_name|" +
"information_schema.columns|table_schema|union|where|order|by|" +
"'\\*|\\;|\\-|\\--|\\+|\\,|\\//|\\/|\\%|\\#";
//使用正则表达式进行匹配
Pattern pattern = Pattern.compile(badStr);
Matcher matcher = pattern.matcher(s);
return matcher.find();
}
@Override
public void destroy() {}
}
以上就是主要的核心代码的实现了,总算把漏洞给修复了。最后一句,安全问题容不得半点马虎,我们不能仅仅满足于功能的实现,更要站在攻击者的角度去审视自己的代码,多去思考可能存在的安全隐患。而且和关联方合作时,也不能仅仅依靠对方或者自己单方面去保障数据安全,而是要共同制定严格的安全规范和交互标准,这样才能为用户提供更可靠的服务。