CSRF原理及防御
我们的项目中经常会看到诸如CSRF Filter
之类的代码,那么什么是CSRF攻击呢,攻击原理是什么?如何防御攻击?
什么是CSRF攻击?
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种伪装成受害者去提交恶意请求的攻击。攻击者继承了受害者的身份和权限并假装成受害者去执行不受欢迎的功能。针对大多数站点,浏览器请求会自动包含与站点相关的所有凭据,例如用户的会话cookie
,IP地址
,Windows域凭证
等等。因此,如果此时用户已经通过了某站点的身份认证,CSRF攻击发生时,该站点是无法区分该请求是真实用户发送的还是一个伪造的请求。
CSRF攻击的目标是可以导致服务器状态改变的功能,例如改变受害者的邮箱地址或密码,或者购买一些东西。伪造受害者去获得响应并不会有利于攻击者,因为只有受害者会收到响应,攻击者不会收到响应(所以只读接口其实不需要进行CSRF防御)。CSRF攻击的目标是可以改变服务器状态的请求。
其中有一种特殊的CSRF攻击:攻击者可以通过伪造用户的登录去获得受害者私人的数据,这种特殊的CSRF叫做「登录CSRF」。攻击者强制未认证用户登录到攻击者控制的一个账户上。如果受害者没有意识到,他们就可能添加例如信用卡信息的这种私人数据到这个账户上。然后,攻击者就能够重新登录账号并查看这些信息,以及受害者在该网页应用的活动信息。
有时候攻击者可以将CSRF攻击存储在安全性不高的站点里面。这种安全漏洞称为存储性CSRF漏洞
。他可以简单地通过将图片或IFRAME标记
存储在接受HTML
为输入字段的接口或者通过更复杂的跨站脚本攻击来完成。一旦可以将CSRF攻击存储到被攻击的站点上,那么攻击的严重性将会放大。尤其是被攻击的可能性会大幅增加,因为与网络上的一个随机网页相比,受害者更有可能去浏览这个包含攻击的页面;其次当受害者在浏览该站点时的状态必然是身份已认证也会严重性加大。
CSRF攻击还有许多其他的名称,包括XSRF
、Sea Surf
、Session Riding
、Cross-Site Reference Forgery
和Hostile Linking
。微软在他们的安全威胁建模过程中以及他们在线文档的很多地方都讲这种攻击类型称之为One-Click
攻击。
CSRF攻击流程图
下面给出一些攻击的例子
- 当用户打开钓鱼网站,钓鱼网站后台自动发起了一个对资源网站的POST表单提交请求,因为此时用户已经登录资源网站,所以资源网站无法判断该请求是否为伪造
<form action="http://bank.example/withdraw" method=POST>
<input type="hidden" name="account" value="xiaoming" />
<input type="hidden" name="amount" value="10000" />
<input type="hidden" name="for" value="hacker" />
</form>
<script> document.forms[0].submit(); </script>
- 当用户访问带有图片的钓鱼网页后,如果里面有一个超链接,则会进行转账
<img src="http://www.mybank.com/Transfer.php?toBankId=11&money=1000">
如何防御?
根据CSRF的两个特点来防御:
- CSRF(通常)发生在第三方域名(如果发生在自身域将更加危险)
- CSRF攻击并不能够获取
Cookie
中的内容,只是使用。
同步器令牌模式
首先CSRF 令牌需要在服务端被生成。令牌可以是在会话级别的也可以在请求级别的。请求级别的令牌必然比会话级别的令牌更安全,因为攻击者利用被盗用的令牌的时间会大大缩短。但是,具体如何使用还是需要基于现实的一些考虑。比如,如果使用请求级别的令牌,那么浏览器的后退能力可能就不能使用了。因为即使后退,前一页所包含的令牌也是失效的。所以在浏览器中当用户使用了后退这种操作,就会导致服务端CSRF误报的可能性。而在会话级别的令牌实现中,令牌的值被存储到服务端的会话中并且在每一个请求中被使用直到回话过期。
当客户端发起一个请求的时候,服务端组件(比如 Servlet Filter)必须将请求当中的令牌和会话当中存储的令牌进行比对,来确认请求中令牌的存在及有效性。如果请求中不包含令牌,或者请求中提供的token的值并没有和用户的回话中存储的令牌值匹配上,那这个请求就需要被拒绝。如果请求被拒绝,服务端可以考虑做一些其他的操作,例如将这次请求事件记录为正在进行的潜在CSRF攻击。
同时CSRF令牌需要有以下几个特点:
- 每一个用户回话唯一
- 加密
- 不可预测性(可以使用加密方法生成一个大的随机值)
同步器令牌模式之所以有效是因为攻击者无法获取到令牌,也就不能向服务端构造一个非法的请求了
对于同步器令牌模式,CSRF令牌不能被存储在cookie
当中!
CSRF令牌可以作为响应负载的一部分传输到客户端,例如放在返回的HTML
中,或者是返回的JSON响应当中。然后令牌可以作为一个表单的隐藏字段,或AJAX
请求中一个自定义的请求头中的值或者JSON请求参数的一个字段中返回给服务端。需要确保这个令牌不会被服务的日志打印或者URL中泄露出来。GET
请求中的CSRF令牌可能会在多个地方被泄露,比如浏览器历史记录、日志文件、记录HTTP请求第一行的网络实用工具中。如果被保护的站点链接到外部的站点,那么使用GET
请求,Referer
请求头中依旧会有CSRF令牌泄露的风险。
举个例子,前端表单中使用隐藏字段提交csrf令牌:
<form action="/transfer.do" method="post">
<input type="hidden" name="CSRFToken" value="OWY4NmQwODE4ODRjN2Q2NTlhMmZlYWEwYzU1YWQwMTVhM2JmNGYxYjJiMGI4MjJjZDE1ZDZMGYwMGEwOA==">
...
</form>
通过JavaScript
在自定义HTTP请求投中插入CSRF令牌会比在表单中使用隐藏字段参数的形式更安全,因为它使用了自定义请求头。
双重cookie验证
如果因为一些原因无法在服务端维护CSRF令牌,那另外一个可替代的防御方法是双重cookie
验证技术。这种技术是比较容易实现的并且是无状态的。在这种方式中,我们会发送一个随机值到cookie
和请求参数中,当服务端接受到请求后,需要验证cookie
和请求参数中的值是否一致。当用户访问(甚至在身份验证以防止登录CSRF之前),站点就应该生成一个加密强度高的伪随机值,并且设置为用户计算机上与会话身份区分开的cookie
当中。然后站点需要在每一个事务请求中都包含这个伪随机的值,可以放在表单隐藏值(或者放置在请求的参数/请求头中)。如果在服务端校验这两处的值相同,那么服务端就认为这是一个合法请求并接受这个请求;如果校验失败,那么就会拒绝这个请求。
为了加强这个方案的安全性,可以将令牌放置在一个加密的cookie
当中,而不是身份验证的cookie
当中(因为身份验证的cookie
通常在子域中共享)。当服务端接受到请求时,服务端需要先解密该cookie
,然后将解密后的cookie值与隐藏表单或者请求中或者请求头中的值进行匹配。这是一种可行的方法因为子域是无法在缺少加密密钥这种必要信息的情况下制作正确的加密cookie
。
另外一种针对加密cookie的简单的替换方法是将放置在cookie
中的值通过HMAC
加密算法进行加密(HMAC
加密使用的密钥仅有服务端知道)。这种方式跟加密cookie
很像(两者都需要服务端持有一个仅服务端可知的密钥信息),但是这种方式的计算密度小于加解密cookie
。无论加密cookie
还是HMAC
,攻击者都无法在不知道服务端存储的密钥的情况下,从普通令牌中重新创建一个cookie
值。
Java Spring项目实战
在SpringBoot
中,如果引入了Spring
的Security
4.0以上的包
只要引入了Spring
的Security
4.0以上的包,那么会自动开启csrf
拦截。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
如果需要自定义csrf
配置,可以继承org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
抽象类,然后重写configure
方法:
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
// 禁用csrf拦截
http.csrf().disable();
}
}
CSRF Filter
不管是同步器令牌模式还是双重cookie
验证最后的步骤都是将请求中的令牌进行验证。
Spring中对CSRF的拦截的核心类是org.springframework.security.web.csrf.CsrfFilter
核心逻辑:
tokenRepository
为csrfToken
的仓储,如果为CookieCsrfTokenRepository
,则token
存储在cookie
当中,使用双重Cookie
验证的逻辑来拦截攻击;如果tokenRepository
为HttpSessionCsrfTokenRepository
,则token
存储在session
中;因为在分布式应用中将token
存储到本地session
有很大的压力,所以可以基于Redis缓存,将token
存储在全局缓存当中。
以下是CSRF Filter
的核心方法doFilterInternal()
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
// 从tokenRepository中获取CsrfToken
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
if (missingToken) {
// 如果获取不到,则生成一个
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
// 判断该请求是否需要CSRF拦截,如果不需要,则直接执行接下来的流程
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
// 先从header中获取csrfToken,如果没有的话,就从请求体中获取
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
// 如果存储的token和实际token不一致,那么就会返回错误页面,集团一般会返回淘宝错误页
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
this.accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}
filterChain.doFilter(request, response);
}