原文地址:http://moon-walker.iteye.com/blog/2397907,https://www.oschina.net/code/snippet_1029551_27153#45401
此文仅作为当时解决此问题记录
CSRF攻击
CSRF攻击全称为:Cross-site request forgery,直接翻译为:跨站请求伪造。直接看名称还是有点难以理解,容易跟XSS攻击搞混。在讲解如何防御之前,首先看看如何攻击,举个简单的攻击例子:
1、假设你知道身边的一个同事每天都会登陆他的xxx网上银行(假设这个银行没有做CSRF防御),由于习惯他一般会采用默认的浏览器登陆;
2、在他登陆网上银行之后,你往他的邮箱发一封邮件(他是你同事你当然知道他的邮箱),邮件的内容为一张图片(图片内容要足够吸引人去点击,比如“京东商城”满199-100什么的),图片的链接地址为xxx网上银行的转账操作链接比如:https://xxx.bank.com/ trans_money? money=10000&target_accuout=“你的银行账户”,该链接执行的操作是向你的账户转10000块。
3、你的同事收到邮件后,点击这个图片,会使用默认浏览器打开上述转账链接。由于他已经使用默认浏览器登陆了自己的网上银行,这时就会在你同事毫无知觉的情况下,借他的手向你的账户转10000块大洋(别转太多了,太多了需要短信验证什么的)。
上述攻击示例仅仅是为了说明CSRF的攻击方式,请勿尝试 不要拿自己的同事做猎物。当然现在的网上银行不会像我说的这么弱智,即便尝试也没有效果。
这里举例是用邮件向攻击目标推送“攻击链接”,也可以使用任意的其他方式,比起在其他网站上挂一个“攻击链接”,如果他在登陆自己“网上银行”的浏览器里同时打开了这个“其他网站”,你又成功的引诱他点击了这个“攻击链接”,就可以接攻击目标自己的手执行你想要的任何操作。这就是所谓的CSRF攻击,可见其危害之大。
攻击者一般会在通过扫描工具扫描系统是否存在CSRF漏洞的操作链接,然后分析这些链接是否有价值,比如删除或修改重要数据、发送邮件等。然后构造这些链接请求,在目标用户登陆该系统的情况下,通过各种手段诱导你去执行这些链接,从而达到自己的攻击目的。
CSRF防御
CSRF防御比较常见的手段是对每个请求进行token验证,对验证不通过的请求进行拦截。这种方式理论可以对每个请求都进行token验证,但这样系统就缺乏一些灵活性,根据具体情况,一般不会对所有的请求进行token校验,只对有数据更改的部分(post请求)或者敏感数据查询进行token校验。token验证流程如下:
1、客户端发起请求浏览一个页面,服务端收到请求 通过UUID生成一个随机数作为token,存放在服务器端,现在的系统一般都是多实例分布式部署,所有一般采用共享缓存进行存储,比如redis(为什么不使用session、request或者本地缓存?因为下一次请求有可能落到另外一台机器上)。
2、服务端渲染页面返回时,把这个新生成的token放到一个hidden的隐藏变量中。
3、客户端在请求或修改敏感数据时,在请求header中附带上这个token。服务端收到请求后,获取header中的token,与共享缓存中的token进行对比:
A、假设两个token相同,则通过验证,为了防止表单重复提交,这时可以在“共享缓存”中删除这个token。然后继续进行正常业务处理,在请求返回之前生成新token存放到“共享缓存”,连同该新token一起返回给客户端,以便后续请求继续使用。
B、假设两个token不相同,说明有可能是token已过期(“共享缓存”中的token不能无限期的存放,一般半个小时左右即可),或者是遭到了CSRF攻击。这时拦截该请求,返回请求失败。如果是token已过期,可以刷新页面获取新的token 即可继续操作,这也就是为什么有的网站在过一段时间直接需要刷新一下才能继续操作的原因。
可以看到这个token是实时变化的,CSRF攻击者无法进行伪造,从而达到防御的目的。
Spring MVC中的CSRF防御
通过上述流程,可以看到CSRF防御的关键就是Token的生成、删除和验证,这些操作都是在正常的业务操作之前或者之后进行的(验证和删除是之前,生成是之后)。在Spring MVC中很容易就能想到通过拦截器HandlerInterceptor进行处理(如果对spring拦截器不清楚的,可以点击这里)。具体的处理方式,根据链接是否有规律又分为两种:
拦截器统一处理方式:对链接有规律的处理比较简单(比如RESTful风格的链接),只需要对固定的链接进行链接,在preHandle中进行token的“验证和删除”,在postHandle中进行token的“生成”即可,这也是使用RESTful风格编程的福利:
拦截器xml配置:
CSRFInterceptor拦截器实现:
public class CSRFInterceptor implements HandlerInterceptor {
@Resource
private CSRFTokenUtil csrfTokenUtil;
//验证和删除token
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requstCSRFToken = request.getHeader("csrf-token");
if(csrfTokenUtil.verifyToken(requstCSRFToken)){
csrfTokenUtil.deleteToken(requstCSRFToken);//验证通过后,立即删除token,可以表单防止重复提交。
return true;
}
return false;
}
//生成token
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception {
String new_token = csrfTokenUtil.generate();
request.setAttribute("csrf-token", new_token); //页面上后续异步操作,需要新的token
}
//省略afterCompletion方法
}
另外还需要在,访问指定页面时,生成token
访问页面的请求生成token:
@RequestMapping("/form")
@ VerifyCSRFToken
@ResponseBody
public String form (Map map,Integer id) {
//省略业务代码
String new_token = csrfTokenUtil.generate();
map.put("csrf-token",new_token);//生成token
return “/form”
}
ok,大功告成,可见如果采用spring mvc的RESTful风格编程,对防御CSRF攻击是so eazy。
指定注解方式:但不幸的是我们有许多老系统,不是RESTful风格的,链接的规则也是杂乱无章,肿么办。这时可以采用拦截器加注解的方式,进行处理,处理起来稍微麻烦些,分三步说明:
1、创建拦截器,拦截所有的请求:
2、新建一个自定义注解VerifyCSRFToken,加到需要进行token验证的Controller方法中,并在这个方法返回之前,生成新token。
VerifyCSRFToken注解定义:
@Target({ java.lang.annotation.ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface VerifyCSRFToken {
//需要验证防跨站请求
public abstract boolean verify() default true;
}
访问页面的请求生成token:
@RequestMapping("/form")
@ VerifyCSRFToken
@ResponseBody
public String form (Map map,Integer id) {
//省略业务代码
String new_token = csrfTokenUtil.generate();
map.put("csrf-token",new_token);//生成token
return “/form”
}
需要进行防御的方法:
@RequestMapping("/update")
@ VerifyCSRFToken
@ResponseBody
public void update (Integer id) {
//省略业务代码
String new_token = csrfTokenUtil.generate();
result. put("csrf-token",new_token); //重新生成token
sendResultJson(result);
}
3、最后看下拦截器的处理,由于token的生成已经分散到各个Cotrlloer方法中,拦截器的postHandle无需处理。由于拦截器CSRFInterceptor拦截了所有的请求,在preHandle需要首先取出含有@ VerifyCSRFToken的方法,才能进行token校验。具体实现如下:
public class CSRFInterceptor implements HandlerInterceptor {
@Resource
private CSRFTokenUtil csrfTokenUtil;
//验证和删除token
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
VerifyCSRFToken verifyCSRFToken = method.getAnnotation(VerifyCSRFToken.class);
// 只对使用@VerifyCSRFToken注解的方法,进行csrf token校验
if (verifyCSRFToken != null) {
String requstCSRFToken = request.getHeader("csrf-token");
if (csrfTokenUtil.verifyToken(requstCSRFToken)) {
csrfTokenUtil.deleteToken(requstCSRFToken);//验证通过后,立即删除token,可以表单防止重复提交。
return true;
}
return false;
}
return true;
}
//生成token
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception {
}
//省略afterCompletion方法
}
对比两种方式:“拦截器统一处理方式”看起来更优雅,对业务没有侵入性,但链接必须是规则的RESTful风格。“指定注解方式”使用更灵活,可以对指定的方法进行防御,但对业务代码有一定的侵入性。两种方式可以根据自己的系统具体情况进行选择。
token工具类
前面代码实例中用的了CSRF的工具类CSRFTokenUtil,这个工具类封装了token的生成、校验、删除。前面也提到过,在分布式的部署系统中,只能使用“共享缓存”进行token在服务端的存在,本工具类使用的是redis。
另外,通过前面的代码我们会发现,每次访问一个新页面时都需要生成一个新的token放到redis,如果有恶意用户 一直刷新页面(多机并发刷),理论上会到导致redis缓存被刷爆。所有我们必须对每个用的token数量进行限制,但又不能太少,否则用户不能同时打开多个页面。这里我们限制每个用户,最多只能生产100个token,如果超过100不再生产新的token,而是随机选择这个100个中的一个token返回,采用的是redis的set(集合)数据类型进行存储(提示:下列代码中的redis的操作 进行过封装,请使用自己的项目中redis的使用方式替换),这样可以防止redis 恶意被刷,实现代码如下:
/**
* csrf攻击防御工具类
* Created by gantianxing on 2017/10/13.
*/
public class CSRFTokenUtil {
public static final String CSRF_TOKEN="csrf-token";
public static final int THIRTY_MINUTES = 30*60;//token缓存时间30分钟
@Resource
private RedisUtil redis;
/**
* 生成新token 放入redis set中(集合)
* 每个user最多允许100个token
* @return
*/
public String generate() {
int userId = getUserId();
if(userId > 0){
String key = "user_token"+userId;
int snum = redis.scard(key);
//如果该用户的token数大于100,则随机返回一个已有token,不在生成新token
if(snum > 100){
token = redis.srandmenber(key);
}else {//否则生成新token
String uuid = UUID.randomUUID().toString();
redis.sadd(key,THIRTY_MINUTES,uuid);
}
}
return token;
}
/**
* 验证token(在set中查找)
* @param page_token
* @return
*/
public boolean verifyToken(String page_token){
if(redis.isNotBlank(page_token)){
int userId = getUserId();
String key = "user_token"+userId;
//判断redis集合中是否存在
if(userId>0 && redis.sismember(key,page_token)){
return true;
}
}
return false;
}
/**
* 删除token(从set中删除)
* @param page_token
*/
public void deleteToken(String page_token){
int userId = getUserId();
if (userId>0){
String key = "user_token"+userId;
redis.srem(key, page_token);//从redis集合中删除
}
}
//获取当前用户id
private int getUserId(){
//获取用户id逻辑省略,一般会把用户信息放到TreadLocal中,从TreadLocal中获取
return 123;
}
}
关于token的工具类的编写,主要核心有两点:1、使用支持分布式的“共享缓存” 2、token要遵守谁创建谁使用的原则(就是跟用户绑定),同时必须限制每个用户创建token数量。
对于CSRF攻击方式,以及如何防御就总结到这里。