本文章记录自己在web开发过程中遇到了常见漏洞并提供对应的解决方案。
所有数据都是有害的
后端95%以上的漏洞都是因为对用户的输入没有做严格的校验,从而产生一系列问题。
所以无论是用户手动填写的数据或是客户端浏览器或操作系统自动填写的数据,都可能产生安全问题,需要进行严格的安全性检查。
SQL注入漏洞
SQL注入漏洞分析
什么是SQL注入漏洞?
攻击者通过浏览器或者其他客户端将恶意SQL 语句插入到网站参数 中,网站应用程序未经过滤 ,便将恶意SQL语句带入数据库执行。
通过以上分析可以知道要想完成SQL注入攻击,需要满足以下2个条件:
-
某个请求需要服务端去操作数据库,并且在操作数据库时需要的参数来自于客户端用户的输入
-
服务端对客户端用户的输入没有做过滤校验
而在Java中执行SQL语句一般有如下方式:
-
使用JDBC的java.sql.Statement执行SQL语句。
-
使用JDBC的java.sql.PreparedStatement执行SQL语句。
-
使用第三方ORM框架执行SQL语句,比如Mybatis等
Statement是Java JDBC下执行SQL语句的一种原生方式,执行语句时是通过拼接来执行, 所以它存在将出现SQL注入漏洞风险。
PreparedStatement预编译的方式执行SQL语句,可以有效地防止 SQL注入攻击
这里重点说下使用Mybatis执行SQL语句,毕竟对于Java开发人员绝大多数情况使用的是Mybatis。
Mybatis执行SQL语句
对于Mybatis执行SQL语句:
- 当我们在mapper映射文件中使用
#{}
完成参数的传递时,Mybatis底层使用的是PreparedStatement - 当我们在mapper映射文件中使用
${}
****完成参数的传递时,Mybatis底层使用的是Statement
那是不是就意味着我们在使用Mybatis,都直接使用 #{} ?其实也不全是,只能说能用#{} 就用#{},但是对于有些情况我们不得不使用${}进行拼接。
必须使用${}情况
一句话概括:当接收的参数是sql关键字。需要进行sql语句关键字拼接的时候。必须使用${}
案例1:order by语句
需求:通过向sql语句中注入asc或desc关键字,来完成数据的升序或降序排列。
xml
<select id="selectAll" resultType="car">
select
id,car_num as carNum,brand,guide_price as guidePrice,produce_time as produceTime,car_type as carType
from
t_car
order by carNum #{key}
</select>
当我们直接测试执行上面的SQL语句时,会报错,因为采用#{}这种方式传值,最终sql语句会是这样:
select id,car_num as carNum,brand,guide_price as guidePrice,produce_time as produceTime,car_type as carType from t_car order by carNum 'desc'
desc是一个关键字,不能带单引号的,所以在进行sql语句关键字拼接的时候,必须使用${}
使用${} 改造
xml
<select id="selectAll" resultType="com.powernode.mybatis.pojo.Car">
select
id,car_num as carNum,brand,guide_price as guidePrice,produce_time as produceTime,car_type as carType
from
t_car
<!--order by carNum #{key}-->
order by carNum ${key}
</select>
再次执行测试上面的SQL语句就没有问题了。
案例2:拼接表名
在实际的开发中,有的表数据量非常庞大,可能会采用分表方式进行存储,比如每天生成一张表,表的名字与日期挂钩,例如:2022年8月1日生成的表:t_user20220108。2000年1月1日生成的表:t_user20000101。此时前端在进行查询的时候会提交一个具体的日期,比如前端提交的日期为:2000年1月1日,那么后端就会根据这个日期动态拼接表名为:t_user20000101。有了这个表名之后,将表名拼接到sql语句当中,返回查询结果。
那么拼接表名到sql语句当中应该使用#{} 还是 ${} 呢?
使用#{}会是这样:select * from 't_car'
使用${}会是这样:select * from t_car
所以在拼接表名时也必须使用 ${}
案例3:批量删除
如果我们是通过id来批量删除多条记录,而id的类型是字符串,那这个时候也不得不使用 ${}
如果id类型Long类型,则可以用 #{}
比如根据id来删除用户:
- delete from t_user where id = 1 or id = 2 or id = 3;
- delete from t_user where id in(1, 2, 3);
假设现在使用in的方式处理,前端传过来的字符串:1, 2, 3
使用#{} :delete from t_user where id in('1,2,3') 执行错误:1292 - Truncated incorrect DOUBLE value: '1,2,3'
SQL注入漏洞修复最佳实践
-
能使用PreparedStatement就使用PreparedStatement
-
只能使用Statement的情况下:
-
如果能明确参数类型是数值类型,先强制转换成数值类型,可以避免SQL注入
-
如果参数是字符串类型,则对参数进行过滤校验
-
下面提供一个参数过滤校验的模板代码:
java
/**
* 判单是否存在SQL注入过滤
*
* @param str 待验证的字符串
*/
public static boolean sqlInject(String str) {
if (StringUtils.isBlank(str)) {
return true;
}
//去掉'|"|;|\字符
str = StringUtils.replace(str, "'", "");
str = StringUtils.replace(str, """, "");
str = StringUtils.replace(str, ";", "");
str = StringUtils.replace(str, "\", "");
//转换成小写
str = str.toLowerCase();
//非法字符
String[] keywords = {"master", "truncate", "insert", "select", "delete", "update", "declare", "alter", "drop"};
//判断是否包含非法字符
for (String keyword : keywords) {
if (str.contains(keyword)) {
return false;
}
}
return true;
}
文件上传漏洞
文件上传漏洞分析
文件上传漏洞指的是客户端在进行文件上传时,服务端没有进行严格的限制过滤,导致攻击者将可执行脚本(WebShell)上传至目标服务器,以达到控制目标服务器的目的。
文件上传的各种方式以及其它的绕过一些校验的手段这里不讲,一句话概括:文件上传漏洞的本质还是未对文件名做严格校验,常见的主要有如下几种情况:未对文件做任何过滤,仅在前端通过js检验,只判断了Content-Type,后缀过滤不全,读取后缀方式错误等。
例如:下面代码尝试获取文件的后缀名
ini
suffixName=fileName.substring(fileName.indexOf("."));
上面这种写法是有问题的,当文件名为abc.jpg.jsp时SuffixName将等于.jpg.jsp,这明显是不会和黑名单中的后缀相等的
文件上传漏洞修复最佳实践
需求:客户端现在有一个上传图片的功能,服务端的代码该如何写才能避免文件上传漏洞呢?
下面给出代码示例:
java
@PostMapping("/uplod")
public String uplod(@RequestParam(value = "file") MultipartFile file) {
if (file.isEmpty()) {
return "fail";
}
String fileName = file.getOriginalFilename();
String contentType = file.getContentType();
// 校验文件后缀
if (fileName != null) {
String suffix = fileName.substring(fileName.lastIndexOf(".")).toLowerCase();
String[] picWhite = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
for (String pic : picWhite) {
if (!suffix.equals(pic)) {
return "fail";
}
}
}
// 校验contentType
String[] typeWhite = {"image/gif", "image/jpeg", "image/jpg", "image/png"};
for (String type : typeWhite) {
if (!type.equals(contentType)) {
return "fail";
}
}
// ...其它业务逻辑
// 最后一步 存储文件 需要对文件进行随机重命名,避免上传的文件被恶意利用
// 代码省略
return "success";
}
目录穿越漏洞
目录穿越漏洞分析
目录穿越漏洞主要出现在当需要用户提供路径或文件名时,如文件下载。在访问者提供需要下载的文件后,Web应用程序没有去检验文件名中是否存在"../"等特殊字符,没有对访问的文件进行限制,导致目录穿越,读取到本不应读取到的内容。
目录穿越漏洞修复最佳实践
对于目录穿越漏洞的防御相对简单,一般有以下方法:
-
对文件名进行过滤,防止出现"./"等特殊符号;
-
采用ID索引的方法来下载文件,而不是直接通过文件名;
-
对目录进行限制;合理配置权限等。
比如对文件名进行过滤,通过 if(fileName.indexOf("..")!=-1||fileName.charAt(0)=='/')
"判断是否存在".."和"/"字符,如果存在就返回空,这样便能在很大程度上避免目录穿越攻击。
XSS漏洞
XSS漏洞分析
跨站脚本(Cross-Site Scripting,XSS):由于 WEB 应用程序对用户的输入过滤不足 而产生的。攻击者利用网站漏洞把恶意的脚本代码注入到网页中,当其他用户浏览这些网页时,就会执行其中的恶意代码,对受害用户可能采取 Cookies 资料窃取、会话劫持、钓鱼欺骗等各种攻击。
XSS漏洞修复最佳实践
主要是对可能存在XSS漏洞的地方,比如个人信息、发表文章、留言板等,对用户填写的信息进行html过滤。
1、使用ESAPI
ESAPI是owasp提供的一套API级别的web应用解决方案。简单的说,ESAPI就是为了编写出更加安全的代码而设计出来的一些API,方便使用者调用,从而方便的编写安全的代码
项目中maven引入
xml
<!-- https://mvnrepository.com/artifact/org.owasp.esapi/esapi -->
<dependency>
<groupId>org.owasp.esapi</groupId>
<artifactId>esapi</artifactId>
<version>2.5.2.0</version>
</dependency>
api使用:
java
//对用户输入"input"进行HTML编码,防止XSS
String safe = input = ESAPI.encoder().encodeForHTML(input);
//根据自己不同的需要可以选用以下方法
//input = ESAPI.encoder().encodeForHTMLAttribute(input);
//input = ESAPI.encoder().encodeForJavaScript(input);
//input = ESAPI.encoder().encodeForCSS(input);
//input = ESAPI.encoder().encodeForURL(input);
//针对富文本进行html编码
2、使用Spring框架自带的HtmlUtils.htmlEscape编码输出到html实体:
java
String safe = HtmlUtils.htmlEscape(input)
3、使用commons-lang库的StringEscapeUtils.escapeHtml编码输出到html实体
java
String safe = StringEscapeUtils.escapeHtml(input)
CSRF漏洞
CSRF漏洞分析
CSRF 的全名是 Cross Site Request Forgery,翻译成中文就是跨站点请求伪造
CSRF 利用的是网站对用户网页浏览器的信任,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站 ,用户无意去访问的时候攻击者再添加一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。 导致用户在不知情的情况下执行了某些操作。
由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了 web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的
案例:
假如一家银行用以运行转账操作的 URL 地址如下:https://bank.example.com/withdraw?account=AccoutName&amount=1000&for=PayeeName
那么,一个恶意攻击者可以在另一个网站上放置如下代码:<img src="https://bank.example.com/withdraw?account=Alice&amount=1000&for=Badman" />
如果有账户名为 Alice 的用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会损失 1000 资金
CSRF漏洞修复最佳实践
1、CSRF
为什么能够攻击成功?
其本质原因是重要操作的所有参数都是可以被攻击者猜测到的。攻击者只有预测出 URL 的所有参数与参数值,才能成功地构造一个伪造的请求;反之,攻击者将无法攻击成功。
2、解决方案
所以我们可以对一些重要的操作随机生成一个token ,当用户请求执该操作时需要携带该token并以post方式提交。然后后端对token进行校验是否正确。
比如我们对于某些重要的操作随机生成一个token放到cookie中,前端在请求接口时从cookie中获取token值然后放请求体中以post方式提交。
前端代码示例:
js
function getCookie() {
var value = "; " + document.cookie;
var parts = value.split("; csrf_token=");
if (parts.length == 2)
return parts.pop().split(";").shift();
}
$.ajax({
type: "post",
url: "/xxxx",
data: {csrf_token:getCookie()},
dataType: "json",
success: function (data) {
if (data.ec == 200) {
//do something
}
}
});
后端应从POST请求体中提取csrf_token参数值,进行校验。
SpringSecurity中的CSRF
在SpringSecurity框架中也提供了CSRF 保护,以防止 CSRF 攻击应用程序, 会针对 PATCH,POST,PUT 和 DELETE 方法进行防护。
SpringSecurity框架的CSRF实现原理和我们上一小节提到的基本一致,就是后端生成token,前端请求接口时携带token,后端再对token进行校验。
下面是SpringSecurity框架中CsrfFilter的源码实现。
java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
// 从session中加载 Token (tokenRepository 定义了Token的生成,存储和获取的相关API)
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = (csrfToken == null);
// 如果是第一次访问就生成Token信息
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
// 把生成的Token信息存储在Session中
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
// 匹配是否是需要做CSRF防御的相关请求
if (!this.requireCsrfProtectionMatcher.matches(request)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match "
+ this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
return;
}
// 获取请求携带在header中的Token信息
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
// 从请求参数中获取Token信息
actualToken = request.getParameter(csrfToken.getParameterName());
}
// 判断请求中的Token是否和Session中存储的Token相等
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(
LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
// Token不相等,说明是CSRF攻击,抛出访问拒绝的异常
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
: new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, exception);
return;
}
// 说明是正常的访问,放过
filterChain.doFilter(request, response);
}