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

情景初现

我们新建一个正常的应用,名称为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过期前利用用户的身份做一些非法请求。

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,142评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,298评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,068评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,081评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,099评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,071评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,990评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,832评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,274评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,488评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,649评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,378评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,979评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,625评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,643评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,545评论 2 352

推荐阅读更多精彩内容