写点什么

「Go 工具箱」go 语言 csrf 库的使用方式和实现原理

作者:Go学堂
  • 2022-10-28
    北京
  • 本文字数:7377 字

    阅读完需:约 24 分钟

「Go工具箱」go语言csrf库的使用方式和实现原理

上帝只垂青主动的人    --- 吴军 《格局》


大家好,我是渔夫子。「Go 学堂」新推出「Go 工具箱」系列,意在给大家分享使用 go 语言编写的、实用的、好玩的工具。


今天给大家推荐的是 web 应用安全防护方面的一个包:csrf。该包为 Go web 应用中常见的跨站请求伪造(CSRF)攻击提供预防功能。

csrf 小档案


项目地址https://github.com/gorilla/csrf

作者:Gorilla star:837 contributors: 25

功能简介:为 Go web 应用程序和服务提供跨站点请求伪造(csrf)预防功能。可作为 gin、echo 等主流框架的中间件使用。

相关知识:跨站请求伪造(CSRF)、contex.Contex、异或操作


第一部分 CSRF 相关知识

一、CSRF 及其实现原理

CSRF 是 CROSS Site Request Forgy 的缩写,即跨站请求伪造。我们看下他的攻击原理。如下图:



当用户访问一个网站的时候,第一次登录完成后,网站会将验证的相关信息保存在浏览器的 cookie 中。在对该网站的后续访问中,浏览器会自动携带该站点下的 cookie 信息,以便服务器校验认证信息。


因此,当服务器经过用户认证之后,服务器对后续的请求就只认 cookie 中的认证信息,不再区分请求的来源了。那么,攻击者就可以模拟一个正常的请求来做一些影响正常用户利益的事情(比如对于银行来说可以把用户的钱转账到攻击者账户中。或获取用户的敏感、重要的信息等)


相关知识:因为登录信息是基于 session-cookie 的。浏览器在访问网站时会自动发送该网站的 cookie 信息,网站只要能识别 cookie 中的信息,就会认为是认证已通过,而不会区分该请求的来源的。所以给攻击者创造了攻击的机会。

CSRF 攻击示例

假设有一个银行网站 A,下面的是一个转给账户 5000 元的请求,使用 Get 方法


GET https://abank.com/transfer.do?account=RandPerson&amount=$5000 HTTP/1.1
复制代码


然后,攻击者修改了该请求中的参数,将收款账户更改成了自己的,如下:


GET https://abank.com/transfer.do?account=SomeAttacker&amount=$5000 HTTP/1.1
复制代码


然后,攻击者将该请求地址放入到一个标签中:


<a href="https://abank.com/transfer.do?account=SomeAttacker&amount=$5000">Click for more information</a>
复制代码


最后,攻击者会以各种方式(放到自己的网站中、email、社交通讯工具等)引诱用户点击该链接。只要是用户点击了该链接,并且在之前已经登录了该网站,那么浏览器就会将带认证信息的 cookie 自动发送给该网站,网站认为这是一个正常的请求,由此,将给黑客转账 5000 元。造成合法用户的损失。


当然,如果是 post 表单形式,那么攻击者会将伪造的链接放到 form 表达中,并用 js 的方法让表单自动发送:


<body onload="document.forms[0].submit()>  <form id=”csrf” action="https://abank.com/transfer.do" method="POST">   <input type="hidden" name="account" value="SomeAttacker"/>   <input type="hidden" name="amount" value="$5000"/> </form></body>
<script> document.getElementById('csrf').submit();</script>
复制代码

二、如何预防

常见的有 3 种方法:


  • 一种是在网站中增加对请求来源的验证,比如在请求头中增加 REFFER 信息。

  • 一种是在浏览器中启用 SameSite 策略。该策略是告诉浏览器,只有请求来源是同网站的才能发送 cookie,跨站的请求不要发送 cookie。但这种也有漏洞,就是依赖于浏览器是否支持这种策略。

  • 一种是使用 Token 信息。由网站自己决定 token 的生成策略以及对 token 的验证。


其中使用 Token 信息这种是三种方法中最安全的一种。接下来我们就看看今天要推荐的 CSRF 包是如何利用 token 进行预防的。

第二部分 CSRF 包的使用及其实现原理

三、CSRF 包的使用及实现原理

csrf 包的安装

go get github.com/gorilla/csrf
复制代码

基本使用

该包主要包括三个功能:


  • 通过 csrf.Protect 函数生成一个 csrf 中间件或请求处理器,用于后续的生成及校验 token 的流程。

  • 通过 csrf.Token 函数,可以在响应中输出当前生成的 token 值。

  • 通过 csrf.TemplateField 函数,可以在 html 模版中输出一个 hidden 的 input,用于在 form 表单中提交 token。


该包的使用很简单。首先通过 csrf.Protect 函数生成一个中间件或请求处理器,然后在启动 web server 时对真实的请求处理器进行包装。


我们来看下该包和主流 web 框架结合使用的实例。

使用 net/http 包启动的服务

package main
import ( "fmt" "github.com/gorilla/csrf" "net/http")
func main() { muxServer := http.NewServeMux()
muxServer.HandleFunc("/", IndexHandler)
CSRF := csrf.Protect([]byte("32-byte-long-auth-key"))
http.ListenAndServe(":8000", CSRF(muxServer))}
func IndexHandler(w http.ResponseWriter, r *http.Request) { // 获取token值 token := csrf.Token(r) // 将token写入到header中 w.Header().Set("X-CSRF-Token", token) fmt.Fprintln(w, "hello world.Go")}
复制代码

echo 框架下使用 csrf 包

package main
import ( "github.com/gorilla/csrf" "net/http"
"github.com/labstack/echo")
func main() { e := echo.New() e.POST("/", func(c echo.Context) error { return c.String(http.StatusOK, "Hello, World!") }) // 使用自定义的CSRF中间件 e.Use(CSRFMiddle()) e.Logger.Fatal(e.Start(":8080"))}
// 自定义CSRF中间件func CSRFMiddle() echo.MiddlewareFunc { csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key")) // 这里使用echo的WrapMiddleware函数将csrfMiddleware转换成echo的中间件返回值 return echo.WrapMiddleware(csrfMiddleware)}
复制代码

gin 框架下使用 csrf 包

import ( "fmt" "github.com/gin-gonic/gin" "github.com/gorilla/csrf" adapter "github.com/gwatts/gin-adapter")
// 定义中间件func CSRFMiddle() gin.HandlerFunc { csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key")) // 这里使用adpater包将csrfMiddleware转换成gin的中间件返回值 return adapter.Wrap(csrfMiddleware)}

func main() { r := gin.New()
// 在路由中使用中间件 r.Use(CSRFMiddle())
// 定义路由 r.POST("/", IndexHandler)
// 启动http服务 r.Run(":8080")}
func IndexHandler(ctx *gin.Context) { ctx.String(200, "hello world")}
复制代码

beego 框架下使用 csrf 包

package main
import ( "github.com/beego/beego" "github.com/gorilla/csrf")

func main() { beego.Router("/", &MainController{})
beego.RunWithMiddleWares(":8080", CSRFMiddle())}
type MainController struct { beego.Controller}
func (this *MainController) Get() { this.Ctx.Output.Body([]byte("Hello World"))}
func CSRFMiddle() beego.MiddleWare { csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key")) // 这里使用adpater包将csrfMiddleware转换成gin的中间件返回值 return csrfMiddleware}
复制代码


实际上,要通过 token 预防 CSRF 主要做以下 3 件事情:每次生成一个唯一的 token、将 token 写入到 cookie 同时下发给客户端、校验 token。接下来我们就来看看 csrf 包是如何实现如上步骤的。

实现原理

csrf 结构体

该包的实现是基于 csrf 这样一个结构体:


type csrf struct { h    http.Handler sc   *securecookie.SecureCookie st   store opts options}
复制代码


该结构体同时实现了一个 ServeHTTP 方法:


func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request)
复制代码


在 Go 中,我们知道 ServeHTTP 是在内建包 net/http 中定义的一个请求处理器的接口:


type Handler interface { ServeHTTP(ResponseWriter, *Request)}
复制代码


凡是实现了该接口的结构体就能作为请求的处理器。在 go 的所有 web 框架中,处理器本质上也都是基于该接口实现的。


好了,现在我们来分析下 csrf 这个结构体的成员:


  • 「h」:是一个 http.Handler,作为实际处理请求的处理器。该 h 的来源是经 Protect 函数返回值包装后的,即开始示例中 CSRF(muxServer)中的 muxServer。又因为 csrf 也是一个请求处理器,请求就会先执行 csrf 的 ServeHTTP 方法的逻辑,如果通过了,再执行 h 的 ServeHTTP 逻辑。

  • 「sc」:类型是*securecookie.SecureCookie,第三方包,该包的作用是对 cookie 的值进行加密/解密。在调用 csrf.Protect 方法时,传递的第一个 32 字节长的参数就是用于该包进行对称加密用的秘钥。下一篇文章我们会详细介绍该包是如何实现对 cookie 内容进行/加解密的。

  • 「st」:类型是 store,是 csrf 包中定义的一个接口类型。该属性的作用是将 token 存储在什么地方。默认是使用 cookieStore 类型。即将 token 存储在 cookie 中。

  • 「opts」:Options 属性,用于设置 csrf 的选项的。比如 token 存储在 cookie 中的名字,token 在表单中的名字等。


这里大家可能有这样一个疑问:csrf 攻击就是基于 cookie 来进行攻击的,为什么还要把 token 存储在 cookie 中呢?在一次请求中,会有两个地方存储 token:一个是 cookie 中,一个是请求体中(query 中,header 中,或 form 中),当服务端收到请求时,会同时取出这两个地方的 token,进而进行比较。所以如果攻击者伪造了一个请求,服务器能接收到 cookie 中的 token,但不能接收到请求体中的 token,所以伪造的攻击还是无效的。

csrf 包的工作流程

在开始的“使用 net/http 包启动的服务”示例中,我们先调用了 Protect 方法,然后又用返回值对 muxServer 进行了包装。大家是不是有点云里雾里,为什么要这么调用呢?接下来咱们就来分析下 Protect 这个函数以及 csrf 包的工作流程。


在使用 csrf 的时候,首先要调用的就是 Protect 函数。Protect 的定义如下:


func Protect(authKey []byte, opts ...Option) func(http.Handler) http.Handler
复制代码


该函数接收一个秘钥和一个选项切片参数。返回值是一个函数类型:func(http.Handler) http.Handler。实际的执行逻辑是在返回的函数中。如下:


CSRF := csrf.Protect([]byte("32-byte-long-auth-key"))
http.ListenAndServe(":8000", CSRF(muxServer))
// Protect源码func Protect(authKey []byte, opts ...Option) func(http.Handler) http.Handler { return func(h http.Handler) http.Handler { cs := parseOptions(h, opts...)
// Set the defaults if no options have been specified if cs.opts.ErrorHandler == nil { cs.opts.ErrorHandler = http.HandlerFunc(unauthorizedHandler) }
if cs.opts.MaxAge < 0 { // Default of 12 hours cs.opts.MaxAge = defaultAge }
if cs.opts.FieldName == "" { cs.opts.FieldName = fieldName }
if cs.opts.CookieName == "" { cs.opts.CookieName = cookieName }
if cs.opts.RequestHeader == "" { cs.opts.RequestHeader = headerName }
// Create an authenticated securecookie instance. if cs.sc == nil { cs.sc = securecookie.New(authKey, nil) // Use JSON serialization (faster than one-off gob encoding) cs.sc.SetSerializer(securecookie.JSONEncoder{}) // Set the MaxAge of the underlying securecookie. cs.sc.MaxAge(cs.opts.MaxAge) }
if cs.st == nil { // Default to the cookieStore cs.st = &cookieStore{ name: cs.opts.CookieName, maxAge: cs.opts.MaxAge, secure: cs.opts.Secure, httpOnly: cs.opts.HttpOnly, sameSite: cs.opts.SameSite, path: cs.opts.Path, domain: cs.opts.Domain, sc: cs.sc, } }
return cs }}
复制代码


Protect 的实现源码起始很简单,就是在一个闭包中初始化了一个 csrf 结构体。示例中 CSRF 就是返回来的func(http.Handler) http.Handler函数。再调用 CSRF(muxServer),执行初始化 csrf 结构体的实例,同时将 muxServer 包装到 csrf 结构体的 h 属性上,最后将该 csrf 结构体对象返回。因为 csrf 结构体也实现了 ServeHTTP 接口,所以 csrf 自然也就是可以处理请求的 http.Handler 类型了。


当一个请求来了之后,先执行 csrf 结构体中的 ServeHTTP 方法,然后再执行实际的 http.Handler。以最开始的请求为例,csrf 包的工作流程如下:



大致了解了 csrf 的工作流程后,我们再来分析各个环节的实现。


  • 「生成唯一的 token」


在该包中生成随机、唯一的 token 是通过随机数来生成的。主要生成逻辑如下:


func generateRandomBytes(n int) ([]byte, error) { b := make([]byte, n) _, err := rand.Read(b) // err == nil only if len(b) == n if err != nil {  return nil, err }
return b, nil
}
复制代码


crypto/rand 包中的 rand.Read 函数可以随机生成指定字节个数的随机数。但这里出的随机数是字节值,如果序列化成字符串则会是乱码。那如何将字节序列序列化成可见的字符编码呢?那就是对字节进行编码。这里使用的是标准库中的 encoding/json 包。该包能够对各种类型进行可视化编码。如果对字节序列进行编码,本质上是使用了 base64 的标准编码。如下:


realToken := generateRandomBytes(32)
//编码后,encodeToken是base64编码的字符串encodeToken := json.Encode(realToken)
复制代码


  • 「token 的存储位置」


生成 token 之后,token 会存储在两个位置:


  • 一个是随响应将 token 写入 cookie 中。在 cookie 中的 token 将用于下次请求的基准 token 和请求中携带的 token 进行比较。该实现是通过 csrf 中的 cookieStore 来存储到 cookie 中的(store 类型)。在 cookie 中 name 默认是 _gorilla_csrf。同时,通过 cookieStore 类型存储到 cookie 的值是经过加密的,加密使用的是 securecookie.SecureCookie 包

  • 一个是存储在请求的上下文中。存在这里的 token 是原始 token 经过转码的,会随着响应下发给客户端,以便下次请求时随请求体一起发送。该实现是通过 context.ValueContext 存储在请求的上下文中。


生成 token 后为什么要存在 cookie 中呢?CSRF 的攻击原理不就是基于浏览器自动发送 cookie 造成的吗?攻击者伪造的请求还是会直接从 cookie 中获取 token,附带在请求中不就行了吗?答案是否定的。在请求中保存的 token,是经过转码后的,跟 cookie 中的 token 不一样。在收到请求时,再对 token 进行解码,然后再和 cookie 中的 token 进行比较。看下下面的实现:


func mask(realToken []byte, r *http.Request) string { otp, err := generateRandomBytes(tokenLength) if err != nil {  return "" }
// XOR the OTP with the real token to generate a masked token. Append the // OTP to the front of the masked token to allow unmasking in the subsequent // request. return base64.StdEncoding.EncodeToString(append(otp, xorToken(otp, realToken)...))}
复制代码


这里我们看到,先生成一个和 token 一样长度的随机值 otp,然后让实际的 realToken 和 opt 通过 xorToken 进行异或操作,将异或操作的结果放到随机值的末尾,然后再进行 base64 编码产生的。



算法反作弊系统流程图-token 编码过程.png


假设一个 token 是 32 位的字节,那么最终的 maskToken 由 64 位组成。前 32 位是 otp 的随机值,后 32 位是异或之后的 token。两个组合起来就是最终的 maskToken。如下图:



这里利用了异或操作的原理来进行转码和解码。我们假设A ^ B = C。那么会有 A = C ^ B


所以,要想还原异或前的真实 token 值,则从 maskToken 中取出前 32 个字节和后 32 字节,再进行异或操作就能得到真实的 token 了。然后就可以和 cookie 中存储的真实的 token 进行比较了。同时因为经过异或转码的 token,攻击者想要进行伪造就很难了。


  • 「输出 token」


在上述我们已经知道经过异或操作对原始 token 进行了转码,我们叫做 maskToken。该 token 要下发给客户端(HEADER、form 或其他位置)。那么,客户端用什么字段来接收呢?


默认情况下,maskToken 是存储在以下位置的:


  • 若在 HEADER 头中,则保存在名为  X-CSRF-Token 的字段中。

  • 若在 form 表单,则保存在名为 gorilla.csrf.Token 的 input 中。


当然,我们在初始化 csrf 的实例时,可以指定保存的位置。例如,我们指定 HEADER 头中的字段名为 X-CSRF-Token-Request 中,则可以使用如下代码:


csrf.Protect([]byte("32-byte-long-auth-key"),              RequestHeader("X-CSRF-Token-Request"))
复制代码


csrf 中可以指定的选项如下:


  • RequestHeader 选项函数:指定在 HEADER 中存储 token 的字段名称。

  • FieldName 选项函数:指定 form 表中存储 token 的 input 的 name

  • MaxAge 选项函数:指定 cookie 中值的有效期

  • Domain 选项函数:指定 cookie 的存储域名

  • Path 选项函数:指定 cookie 的存储路径

  • HttpOnly 选项函数:指定 cookie 的值只能在服务端设置,禁止在客户端使用 javascript 修改

  • SameSite 选项函数:指定 cookie 的 SameSite 属性

  • ErrorHandler 选项函数:指定当 token 校验不通过或生成 token 失败时的错误响应的 handler

  • 「更新 token」


在调用 csrf.ServeHTTP 函数中,每次都会生成一个新的 token,存储在对应的位置上,同时下发给客户端,以便该请求的后续请求携带 token 值给服务端进行验证。所以,该请求之前的 token 也就失效了。

为什么 GET、HEAD、OPTIONS、TRACE 的请求方法不需要 token 验证

在 csrf 包中,我们还看到有这么一段判断逻辑:


// Idempotent (safe) methods as defined by RFC7231 section 4.2.2.safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}
if !contains(safeMethods, r.Method) { //这里进行token的校验}
复制代码


为什么 GET、HEAD、OPTIONS、TRACE 方法的请求不需求 token 验证呢?因为根据 RFC7231 文档的规定,这些方法的请求本质上是一种 幂等 的访问方法,这说明开发 web 的时候 g 这些请求不应该用于修改数据库状态,而只作为一个请求访问或者链接跳转。通俗地讲,发送一个 GET 请求不应该引起任何数据状态的改变。用于修改状态更加合适的是 post 方法,特别是对用户信息状态改变的情况。


所以,如果严格按照 RFC 的规定来开发的话,这些请求不应该修改数据,而只是获取数据。获取数据对于攻击者来说也没实际价值。

四、总结

CSRF 攻击是基于将验证信息存储于 cookie 中,同时浏览器在发送请求时会自动携带 cookie 的原理进行的。所以,其预防原理也就是验证请求来源的真实性。csrf 包就是利用了 token 校验的原理,让前后连续的请求签发 token、下次请求验证 token 的方式进行预防的。

---特别推荐---

特别推荐:一个专注 go 项目实战、项目中踩坑经验及避坑指南、各种好玩的 go 工具的公众号。「Go 学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100 个 go 常见的错误》pdf 文档。

发布于: 刚刚阅读数: 4
用户头像

Go学堂

关注

还未添加个人签名 2019-08-06 加入

还未添加个人简介

评论

发布
暂无评论
「Go工具箱」go语言csrf库的使用方式和实现原理_golang_Go学堂_InfoQ写作社区