SSRF英文全拼為Server Side Request Forgery
,翻譯為服務端請求偽造。攻擊者在未能取得伺服器權限時,利用伺服器漏洞以伺服器的身分傳送一條建構好的請求給伺服器所在內網。關於內網資源的存取控制,想必大家心裡都有數。
上面這個說法如果不好懂,那老許就直接舉一個實際例子。現在許多寫作平台都支援透過URL的方式上傳圖片,如果伺服器對URL校驗不嚴格,此時就為惡意攻擊者提供了存取內網資源的可能。
“千里之堤,潰於蟻穴”,任何可能造成風險的漏洞我們程式設計師都不應忽視,而且這類漏洞很有可能會成為別人績效的墊腳石。為了不成為墊腳石,下面老許就和各位讀者一起看一下SSRF的攻防回合。
為什麼用「千變萬化」這個字?老許先不回答,請各位讀者耐心往下看。下面,老許用182.61.200.7
(www.baidu.com的一個IP位址)這個IP和各位讀者一起複習一下IPv4的不同表示方式。
注意⚠️:點分混合製中,以點分割地每一部分均可以寫作不同的進位(僅限於十、八和十六進位).
上面只有IPv4的不同表現方式,IPv6的位址也有三種不同表示方式。而這三種表現方式又可以有不同的寫法。下面以IPv6中的回環位址0:0:0:0:0:0:0:1
為例。
注意⚠️:冒分十六進位表示法中每個X的前導0是可以省略的,那麼我可以部分省略,部分不省略,從而將一個IPv6位址寫出不同的表現。 0位元壓縮表示法和內嵌IPv4位址表示法同理也可以將一個IPv6位址寫出不同的表現。
講了這麼多,老許已經無法統計一個IP可以有多少種不同的寫法,麻煩數學好的算一下。
內網IP你以為到這裡就完了嘛?當然不!不知道各位讀者有沒有聽過xip.io
這個網域。 xip
可以幫你做自訂的DNS解析,並且可以解析到任意IP位址(包含內部網路)。
我們透過xip
提供的網域解析,也可以將內網IP透過網域的方式存取。
關於內網IP的訪問到這裡仍將繼續!搞過Basic驗證的應該都知道,可以透過http://user:passwd@hostname/
進行資源存取。如果攻擊者換一種寫法或許可以繞過部分不夠嚴謹的邏輯,如下圖所示。
關於內網地址,老許掏空了所有的知識儲備總結出上述內容,因此老許說一句千變萬化的內網地址不過分吧!
此時此刻,老許只想問一句,當惡意攻擊者用這些不同表現形式的內網位址進行圖片上傳時,你怎麼將其識別出來並拒絕訪問。不會真的有大佬用正規表示式完成上述過濾吧,如果有請留言告訴我讓小弟學習一下。
花樣百出的內網位址我們已經基本了解,那麼現在的問題是怎麼將其轉為一個我們可以進行判斷的IP。總結上面的內網位址可分為三類:一、本身就是IP位址,僅表現形式不統一;二、一個指向內網IP的網域名稱;三、一個包含Basic驗證資訊和內網IP的位址。根據這三類特徵,在發起請求之前按照以下步驟可以識別內網位址並拒絕存取。
解析出位址中的HostName。
發起DNS解析,取得IP。
判斷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 }
下圖為依照上述步驟偵測請求是否為內部網路請求的結果。
小結:URL形式多樣,可以使用DNS解析來取得規範的IP,從而判斷是否為內網資源。
如果惡意攻擊者僅透過IP的不同寫法進行攻擊,那我們自然可以高枕無憂,然而這場矛與盾的較量才剛剛開局。
我們回顧回合一的防禦策略,偵測請求是否是內網資源是在正式發起請求之前,如果攻擊者在請求過程中透過URL跳轉進行內網資源存取則完全可以繞過回合一中的防禦策略。具體攻擊流程如下。
如圖所示,透過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跳转攻击就变得十分容易了。
根据前一次请求的响应直接拒绝307
和308
的跳转(此类跳转可以是POST请求,风险极高)。
解析出请求的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.Client
的CheckRedirect
可以防範URL跳轉攻擊。
眾所周知,發起一次HTTP請求需要先請求DNS服務取得網域對應的IP位址。如果攻擊者有可控的DNS服務,就可以透過DNS重綁定繞過前面的防禦策略進行攻擊。
具體流程如下圖所示。
驗證資源是是否合法時,伺服器進行了第一次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
方法里。
上述兩個防禦方案不只可以防範DNS重綁定攻擊,也同樣可以防範其他攻擊方式。事實上,老許更推薦方案二,簡直一勞永逸!
小結:
攻擊者可以透過自己的DNS服務進行DNS重綁定攻擊。
透過自訂http.Transport
可以防範DNS重綁定攻擊。
以上是Go中的SSRF攻防戰的詳細內容。更多資訊請關注PHP中文網其他相關文章!