美文网首页
CSRF跨站请求伪造的原理及解决方案实例演示

CSRF跨站请求伪造的原理及解决方案实例演示

作者: 文景大大 | 来源:发表于2020-04-08 21:22 被阅读0次

    情景初现

    我们新建一个正常的应用,名称为app1,其中只有一个请求。

    pom.xml

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
    

    application.properties

    server.servlet.context-path=/app1
    server.port=8080
    

    CsrfRest.java

    @Slf4j
    @RestController
    public class CsrfRest {
        @GetMapping("/buy")
        public String buy(){
            log.info("成功下单!");
            return "buy success";
        }
    }
    

    我们再创建一个钓鱼网站应用,名称为app2,其中的danger.html就是诱使用户点击的页面。

    pom.xml

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-thymeleaf</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
    

    application.properties

    server.servlet.context-path=/app2
    server.port=9090
    spring.thymeleaf.prefix=classpath:/templates/
    

    CsrfController.java

    @Controller
    public class CsrfController {
        @RequestMapping("/danger")
        public String danger(){
            return "danger";
        }
    }
    

    danger.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8"/>
        <title>危险页面</title>
    </head>
    <body>
    <h1>这是危险页面!</h1>
    <!-- 诱使用户点击该链接,伪造用户的请求 -->
    <a href="http://localhost:8080/app1/buy">点击免费获取1万元!</a>
    </body>
    </html>
    

    以上两个应用都启动后,我们访问钓鱼网站的危险页面http://localhost:9090/app2/danger,就会出现诱使用户点击的链接,如果用户点击该链接,那么钓鱼网站就会伪造用户的请求,对app1应用发起访问。

    当然,上述例子没有使用到cookie和session的登录验证机制,正常情况下,钓鱼网站app2会尝试获取用户在app1网站的cookie再发起伪造的请求。一般的主流浏览器都是遵循cookie的同源策略的,也就是钓鱼网站app2其实是没有办法获取app1用户的cookie的,但是万一用户使用的是非主流的浏览器呢,或者某个浏览器的cookie同源策略的实现有漏洞怎么办?app1网站总不能把自己的安全寄希望于第三方的浏览器厂商手里吧,当然是掌握在自己手里最为靠谱。

    所以才有了如下的防止CSRF跨站请求伪造的方法。

    方案一、验证请求来源

    我们在正常访问一个网站的时候,第一个请求访问一般不会在请求头中带有referer字段,referer字段代表的是,当前这次请求的上一次请求是什么,即当前请求的来源地址是哪里。

    只有第二次请求开始,才会在请求头中包含referer这个字段值。

    为了防止CSRF,我们在敏感操作上加上对referer的验证,如果该字段为空,或者值不是以我们网站开头的,那么就认为是跨站请求伪造。我们改造上面app1中的buy请求如下:

        @GetMapping("/buy")
        public String buy(HttpServletRequest request){
            String referer = request.getHeader("referer");
            if(StringUtils.isEmpty(referer) || !referer.startsWith("http://localhost:8080/app1")){
                log.info("这是一个非法请求!");
                return "this is a bad request";
            }
            log.info("成功下单!");
            return "buy success";
        }
    

    然后,再同时启动app1和app2两个应用。这次,从app2的危险页面跳转到app1的buy请求会带着referer的值为http://localhost:9090/app2/danger,我们就可以识别出来这个一个伪造的请求。

    正式应用中,我们不会这样在每个请求中都加上关于referer的校验,而是添加一个拦截器来实现,这里就不赘述了,可以参考另一篇文章《基于HandlerInterceptor的拦截器使用及实现》

    使用referer看起来非常的简单有效,但是存在如下的一些问题:

    • referer值是有浏览器提供的,那么安全性说到底还是依赖于第三方的浏览器,万一它有漏洞呢,所以不是很保险;
    • 可以使用抓包工具对request的referer进行修改,使其绕过服务器的校验,因此也不是很安全;
    • 某些用户认为referer会泄露自己的隐私信息,所以某些浏览器可以设置请求不带referer值,那么这部分合法请求会被过滤掉,这是不合理的。

    方案二、自定义token进行验证

    在用户成功登录后,我们为其生成一个自定义token属性,并放在返回头中给前端,并要求后续所有请求都需要在header中带着token给到服务器,服务器对token进行验证。

    为什么不把token放在表单中?URL里面?或者cookie里面?

    • 万一有很多表单元素,前端需要对所有表单都增加token隐藏控件,费时费力,而且,黑客是很有可能窃取表单中的token值的;
    • URL拷贝给别人,黑客就能轻而易举地获取token信息了;
    • cookie也有被盗用的风险,其安全性依赖浏览器,不靠谱;
    • cookie只有WEB浏览器使用地比较多,移动应用是没有cookie的,到时后端的token校验需要开发两套代码,费时费力,完全可以放在header中,一套后端代码搞定;
    • 可以摒弃cookie+session的认证体系,解决了分布式环境中session的问题;

    最简单的实现就是自己在代码里面按照自定义规则生成token,然后返回给前端,要求前端在所有的请求头里面加上token,服务器获取token之后,再按照自定义的规则校验该token是否合法和有效。

    不过业界已经有成熟的工具了,那就是JWT,下面我们来改造下app1中的buy例子:

    pom.xml

            <dependency>
                <groupId>com.auth0</groupId>
                <artifactId>java-jwt</artifactId>
                <version>3.4.0</version>
            </dependency>
    

    CsrfRest.java

    @Slf4j
    @RestController
    public class CsrfRest {
    
        private static final String SECRET = "mySecret";
        private static final Algorithm ALGORITHM = Algorithm.HMAC256(SECRET);
        private String token = null;
    
        @GetMapping("/login")
        public String login(){
            log.info("登录成功!");
            // 省去了将token返回给前端的逻辑
            this.token = this.generateToken("zhangsan");
            return "login success";
        }
    
        @GetMapping("/buy")
        public String buy(){
            // 省去了从请求头中获取token的逻辑
            this.verifyToken(token);
            log.info("成功下单!");
            return "buy success";
        }
    
        private String generateToken(String username){
            // JWT头部信息
            Map<String,Object> headMap = new HashMap<>(16);
            // Algorithm.HMAC256()的算法ID就是HS256
            headMap.put("algorithm","HS256");
            headMap.put("type","JWT");
    
            // 60秒后过期
            long currentTime = System.currentTimeMillis();
            long expireTime = currentTime + 60 *1000;
    
            return JWT.create()
                    // 设置头部信息-非必须
                    .withHeader(headMap)
                    // 设置自定义信息-非必须
                    .withClaim("username",username)
                    // token的签发者-非必须
                    .withIssuer("app1")
                    // 设置主题信息-非必须
                    .withSubject("mySubject")
                    // 设置观众-非必须
                    .withAudience("front")
                    // 设置生成签名的时间-非必须
                    .withIssuedAt(new Date(currentTime))
                    // 设置签名过期的时间-非必须
                    .withExpiresAt(new Date(expireTime))
                    // 使用指定算法进行签名-必须
                    .sign(ALGORITHM);
        }
    
        private void verifyToken(String token){
            // 基于相同的算法生成验证器
            JWTVerifier verifier = JWT.require(ALGORITHM).build();
            verifier.verify(token);
        }
    
    }
    

    这里先访问了login,获取了token,然后在有效期内访问buy,则验证成功;当超过了有效期再次访问buy,就报异常了:

    com.auth0.jwt.exceptions.TokenExpiredException: The Token has expired on Wed Apr 08 18:24:22 CST 2020.
    

    当然,这里只是简单的实例,真实场景中,token的验证肯定是使用拦截器来实现的。

    最后补充一下,即使采用了JWT的方案,也不是一定就能防御CSRF的,第三者可以通过在正常的网站中植入他自己的网址,当用户在登录情况下访问的时候,就会将用户的token和referer发送给第三者,第三者最起码可以在token过期前利用用户的身份做一些非法请求。

    参考文献

    相关文章

      网友评论

          本文标题:CSRF跨站请求伪造的原理及解决方案实例演示

          本文链接:https://www.haomeiwen.com/subject/ptcxmhtx.html