Go中的SSRF攻防戰

新世界雜貨鋪發表於2021-01-19
來自公眾號:新世界雜貨鋪

寫在最前面

“年年歲歲花相似,歲歲年年人不同”,沒有什麼是永恆的,很多東西都將成為過去式。比如,我以前在文章中自稱“筆者”,細細想來這個稱呼還是有一定的距離感,經過一番深思熟慮後,我打算將文章中的自稱改為“老許”。

關於自稱,老許就不扯太遠了,下面還是回到本篇的主旨。

什麼是SSRF

SSRF英文全拼為Server Side Request Forgery,翻譯為服務端請求偽造。攻擊者在未能取得伺服器許可權時,利用伺服器漏洞以伺服器的身份傳送一條構造好的請求給伺服器所在內網。關於內網資源的訪問控制,想必大家心裡都有數。

image

上面這個說法如果不好懂,那老許就直接舉一個實際例子。現在很多寫作平臺都支援通過URL的方式上傳圖片,如果伺服器對URL校驗不嚴格,此時就為惡意攻擊者提供了訪問內網資源的可能。

“千里之堤,潰於蟻穴”,任何可能造成風險的漏洞我們程式設計師都不應忽視,而且這類漏洞很有可能會成為別人績效的墊腳石。為了不成為墊腳石,下面老許就和各位讀者一起看一下SSRF的攻防回合。

回合一:千變萬化的內網地址

為什麼用“千變萬化”這個詞?老許先不回答,請各位讀者耐心往下看。下面,老許用182.61.200.7(www.baidu.com的一個IP地址)這個IP和各位讀者一起復習一下IPv4的不同表示方式。

image.png

注意⚠️:點分混合制中,以點分割地每一部分均可以寫作不同的進位制(僅限於十、八和十六進位制)。

上面僅是IPv4的不同表現方式,IPv6的地址也有三種不同表示方式。而這三種表現方式又可以有不同的寫法。下面以IPv6中的迴環地址0:0:0:0:0:0:0:1為例。

image.png

注意⚠️:冒分十六進位制表示法中每個X的前導0是可以省略的,那麼我可以部分省略,部分不省略,從而將一個IPv6地址寫出不同的表現形式。0位壓縮表示法和內嵌IPv4地址表示法同理也可以將一個IPv6地址寫出不同的表現形式。

講了這麼多,老許已經無法統計一個IP可以有多少種不同的寫法,麻煩數學好的算一下。

內網IP你以為到這兒就完了嘛?當然不!不知道各位讀者有沒有聽過xip.io這個域名。xip可以幫你做自定義的DNS解析,並且可以解析到任意IP地址(包括內網)。

image

我們通過xip提供的域名解析,還可以將內網IP通過域名的方式進行訪問。

關於內網IP的訪問到這兒仍將繼續!搞過Basic驗證的應該都知道,可以通過http://user:passwd@hostname/進行資源訪問。如果攻擊者換一種寫法或許可以繞過部分不夠嚴謹的邏輯,如下所示。

image

關於內網地址,老許掏空了所有的知識儲備總結出上述內容,因此老許說一句千變萬化的內網地址不過分吧!

此時此刻,老許只想問一句,當惡意攻擊者用這些不同表現形式的內網地址進行圖片上傳時,你怎麼將其識別出來並拒絕訪問。不會真的有大佬用正規表示式完成上述過濾吧,如果有請留言告訴我讓小弟學習一下。

花樣百出的內網地址我們已經基本瞭解,那麼現在的問題是怎麼將其轉為一個我們可以進行判斷的IP。總結上面的內網地址可分為三類:一、本身就是IP地址,僅表現形式不統一;二、一個指向內網IP的域名;三、一個包含Basic驗證資訊和內網IP的地址。根據這三類特徵,在發起請求之前按照如下步驟可以識別內網地址並拒絕訪問。

  1. 解析出地址中的HostName。
  2. 發起DNS解析,獲得IP。
  3. 判斷IP是否是內網地址。

上述步驟中關於內網地址的判斷,請不要忽略IPv6的迴環地址和IPv6的唯一本地地址。下面是老許判斷IP是否為內網IP的邏輯。

// IsLocalIP 判斷是否是內網ip
func IsLocalIP(ip net.IP) bool {
    if ip == nil {
        return false
    }
    // 判斷是否是迴環地址, ipv4時是127.0.0.1;ipv6時是::1
    if ip.IsLoopback() {
        return true
    }
    // 判斷ipv4是否是內網
    if ip4 := ip.To4(); ip4 != nil {
        return ip4[0] == 10 || // 10.0.0.0/8
            (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || // 172.16.0.0/12
            (ip4[0] == 192 && ip4[1] == 168) // 192.168.0.0/16
    }
    // 判斷ipv6是否是內網
    if ip16 := ip.To16(); ip16 != nil {
        // 參考 https://tools.ietf.org/html/rfc4193#section-3
        // 參考 https://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses
        // 判斷ipv6唯一本地地址
        return 0xfd == ip16[0]
    }
    // 不是ip直接返回false
    return false
}

下圖為按照上述步驟檢測請求是否是內網請求的結果。

image

小結:URL形式多樣,可以使用DNS解析獲取規範的IP,從而判斷是否是內網資源。

回合二:URL跳轉

如果惡意攻擊者僅通過IP的不同寫法進行攻擊,那我們自然可以高枕無憂,然而這場矛與盾的較量才剛剛開局。

我們回顧一下回合一的防禦策略,檢測請求是否是內網資源是在正式發起請求之前,如果攻擊者在請求過程中通過URL跳轉進行內網資源訪問則完全可以繞過回合一中的防禦策略。具體攻擊流程如下。

image

如圖所示,通過URL跳轉攻擊者可獲得內網資源。在介紹如何防禦URL跳轉攻擊之前,老許和各位讀者先一起復習一下HTTP重定向狀態碼——3xx。

根據維基百科的資料,3xx重定向碼範圍從300到308共9個。老許特意瞧了一眼go的原始碼,發現官方的http.Client發出的請求僅支援如下幾個重定向碼。

301:請求的資源已永久移動到新位置;該響應可快取;重定向請求一定是GET請求。

302:要求客戶端執行臨時重定向;只有在Cache-Control或Expires中進行指定的情況下,這個響應才是可快取的;重定向請求一定是GET請求。

303:當POST(或PUT / DELETE)請求的響應在另一個URI能被找到時可用此code,這個code存在主要是為了允許由指令碼啟用的POST請求輸出重定向到一個新的資源;303響應禁止被快取;重定向請求一定是GET請求。

307:臨時重定向;不可更改請求方法,如果原請求是POST,則重定向請求也是POST。

308:永久重定向;不可更改請求方法,如果原請求是POST,則重定向請求也是POST。

3xx狀態碼複習就到這裡,我們繼續SSRF的攻防回合討論。既然服務端的URL跳轉可能帶來風險,那我們只要禁用URL跳轉就完全可以規避此類風險。然而我們並不能這麼做,這個做法在規避風險的同時也極有可能誤傷正常的請求。那到底該如何防範此類攻擊手段呢?

看過老許“Go中的HTTP請求之——HTTP1.1請求流程分析”這篇文章的讀者應該知道,對於重定向有業務需求時,可以自定義http.Client的CheckRedirect。下面我們先看一下CheckRedirect的定義。

CheckRedirect func(req *Request, via []*Request) error

這裡特別說明一下,req是即將發出的請求且請求中包含前一次請求的響應,via是已經發出的請求。在知曉這些條件後,防禦URL跳轉攻擊就變得十分容易了。

  1. 根據前一次請求的響應直接拒絕307308的跳轉(此類跳轉可以是POST請求,風險極高)。
  2. 解析出請求的IP,並判斷是否是內網IP。

根據上述步驟,可如下定義http.Client

client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // 跳轉超過10次,也拒絕繼續跳轉
        if len(via) >= 10 {
            return fmt.Errorf("redirect too much")
        }
        statusCode := req.Response.StatusCode
        if statusCode == 307 || statusCode == 308 {
            // 拒絕跳轉訪問
            return fmt.Errorf("unsupport redirect method")
        }
        // 判斷ip
        ips, err := net.LookupIP(req.URL.Host)
        if err != nil {
            return err
        }
        for _, ip := range ips {
            if IsLocalIP(ip) {
                return fmt.Errorf("have local ip")
            }
            fmt.Printf("%s -> %s is localip?: %v\n", req.URL, ip.String(), IsLocalIP(ip))
        }
        return nil
    },
}

如上自定義CheckRedirect可以防範URL跳轉攻擊,但此方式會進行多次DNS解析,效率不佳。後文會結合其他攻擊方式介紹更加有效率的防禦措施。

小結:通過自定義http.ClientCheckRedirect可以防範URL跳轉攻擊。

回合三:DNS Rebinding

眾所周知,發起一次HTTP請求需要先請求DNS服務獲取域名對應的IP地址。如果攻擊者有可控的DNS服務,就可以通過DNS重繫結繞過前面的防禦策略進行攻擊。

具體流程如下圖所示。

image

驗證資源是是否合法時,伺服器進行了第一次DNS解析,獲得了一個非內網的IP且TTL為0。對解析的IP進行判斷,發現非內網IP可以後續請求。由於攻擊者的DNS Server將TTL設定為0,所以正式發起請求時需要再次進行DNS解析。此時DNS Server返回內網地址,由於已經進入請求資源階段再無防禦措施,所以攻擊者可獲得內網資源。

額外提一嘴,老許特意看了Go中DNS解析的部分原始碼,發現Go並沒有對DNS的結果作快取,所以即使TTL不為0也存在DNS重繫結的風險。

在發起請求的過程中有DNS解析才讓攻擊者有機可乘。如果我們能對該過程進行控制,就可以避免DNS重繫結的風險。對HTTP請求控制可以通過自定義http.Transport來實現,而自定義http.Transport也有兩個方案。

方案一

dialer := &net.Dialer{}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, err := net.SplitHostPort(addr)
    // 解析host和 埠
    if err != nil {
        return nil, err
    }
    // dns解析域名
    ips, err := net.LookupIP(host)
    if err != nil {
        return nil, err
    }
    // 對所有的ip序列發起請求
    for _, ip := range ips {
        fmt.Printf("%v -> %v is localip?: %v\n", addr, ip.String(), IsLocalIP(ip))
        if IsLocalIP(ip) {
            continue
        }
        // 非內網IP可繼續訪問
        // 拼接地址
        addr := net.JoinHostPort(ip.String(), port)
        // 此時的addr僅包含IP和埠資訊
        con, err := dialer.DialContext(ctx, network, addr)
        if err == nil {
            return con, nil
        }
        fmt.Println(err)
    }

    return nil, fmt.Errorf("connect failed")
}
// 使用此client請求,可避免DNS重繫結風險
client := &http.Client{
    Transport: transport,
}

transport.DialContext的作用是建立未加密的TCP連線,我們通過自定義此函式可規避DNS重繫結風險。另外特別說明一下,如果傳遞給dialer.DialContext方法的地址是常規IP格式則可使用net包中的parseIPZone函式直接解析成功,否則會繼續發起DNS解析請求。

方案二

dialer := &net.Dialer{}
dialer.Control = func(network, address string, c syscall.RawConn) error {
    // address 已經是ip:port的格式
    host, _, err := net.SplitHostPort(address)
    if err != nil {
        return err
    }
    fmt.Printf("%v is localip?: %v\n", address, IsLocalIP(net.ParseIP(host)))
    return nil
}
transport := http.DefaultTransport.(*http.Transport).Clone()
// 使用官方庫的實現建立TCP連線
transport.DialContext = dialer.DialContext
// 使用此client請求,可避免DNS重繫結風險
client := &http.Client{
    Transport: transport,
}

dialer.Control在建立網路連線之後實際撥號之前呼叫,且僅在go版本大於等於1.11時可用,其具體呼叫位置在sock_posix.go中的(*netFD).dial方法裡。

image

上述兩個防禦方案不僅僅可以防範DNS重繫結攻擊,也同樣可以防範其他攻擊方式。事實上,老許更加推薦方案二,簡直一勞永逸!

小結

  1. 攻擊者可以通過自己的DNS服務進行DNS重繫結攻擊。
  2. 通過自定義http.Transport可以防範DNS重繫結攻擊。

個人經驗

1、不要下發詳細的錯誤資訊!不要下發詳細的錯誤資訊!不要下發詳細的錯誤資訊!

如果是為了開發除錯,請將錯誤資訊打進日誌檔案裡。強調這一點不僅僅是為了防範SSRF攻擊,更是為了避免敏感資訊洩漏。例如,DB操作失敗後直接將error資訊下發,而這個error資訊很有可能包含SQL語句。

再額外多說一嘴,老許的公司對打進日誌檔案的某些資訊還要求脫敏,可謂是十分嚴格了。

2、限制請求埠。

在結束之前特別說明一下,SSRF漏洞並不只針對HTTP協議。本篇只討論HTTP協議是因為go中通過http.Client發起請求時會檢測協議型別,某P*P語言這方面檢測就會弱很多。雖然http.Client會檢測協議型別,但是攻擊者仍然可以通過漏洞不斷更換埠進行內網埠探測。

最後,衷心希望本文能夠對各位讀者有一定的幫助。

  1. 寫本文時, 筆者所用go版本為: go1.15.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...

相關文章