Go 中的 SSRF 攻防戰

寫在最前面

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

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

什麼是 SSRF

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 的地址。根據這三類特徵,在發起請求之前按照如下步驟可以識別內網地址並拒絕訪問。

  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
}

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

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

回合二:URL 跳轉

如果惡意攻擊者僅通過 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 跳轉攻擊就變得十分容易了。

  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 重綁定繞過前面的防禦策略進行攻擊。

具體流程如下圖所示。

驗證資源是是否合法時,服務器進行了第一次 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 重綁定攻擊,也同樣可以防範其他攻擊方式。事實上,老許更加推薦方案二,簡直一勞永逸!

小結:

  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-coder/blob/master/ssrf/main.go

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