go 語言 csrf 庫的使用方式和實現原理

上帝只垂青主動的人    --- 吳軍 《格局》

大家好,我是漁夫子。本號新推出「Go 工具箱」系列,意在給大家分享使用 go 語言編寫的、實用的、好玩的工具。

今天給大家推薦的是 web 應用安全防護方面的一個包:csrf。該包爲 Go web 應用中常見的跨站請求僞造(CSRF)攻擊提供預防功能。

csrf 小檔案

n7PB7A

第一部分 CSRF 相關知識

一、CSRF 及其實現原理

CSRF 是 CROSS Site Request Forgy 的縮寫,即跨站請求僞造。我們看下他的攻擊原理。如下圖:

算法反作弊系統流程圖 - csrf.png

當用戶訪問一個網站的時候,第一次登錄完成後,網站會將驗證的相關信息保存在瀏覽器的 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" />
   <input type="hidden" />
 </form>
</body>

<script>
  document.getElementById('csrf').submit();
</script>

二、如何預防

常見的有 3 種方法:

其中使用 Token 信息這種是三種方法中最安全的一種。接下來我們就看看今天要推薦的 CSRF 包是如何利用 token 進行預防的。

第二部分 CSRF 包的使用及其實現原理

三、CSRF 包的使用及實現原理

csrf 包的安裝

go get github.com/gorilla/csrf

基本使用

該包主要包括三個功能:

該包的使用很簡單。首先通過 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 這個結構體的成員:

這裏大家可能有這樣一個疑問: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 是通過隨機數來生成的。主要生成邏輯如下:

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 後爲什麼要存在 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 進行了轉碼,我們叫做 maskToken。該 token 要下發給客戶端(HEADER、form 或其他位置)。那麼,客戶端用什麼字段來接收呢?

默認情況下,maskToken 是存儲在以下位置的:

當然,我們在初始化 csrf 的實例時,可以指定保存的位置。例如,我們指定 HEADER 頭中的字段名爲 X-CSRF-Token-Request 中,則可以使用如下代碼:

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

csrf 中可以指定的選項如下:

在調用 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 的方式進行預防的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/lrMJYBbNwisE-c19upq7nA