天天看點

Go中的SSRF攻防戰

來自公衆号:新世界雜貨鋪

文章目錄

      • 寫在最前面
      • 什麼是SSRF
      • 回合一:千變萬化的内網位址
      • 回合二:URL跳轉
      • 回合三:DNS Rebinding
      • 個人經驗

寫在最前面

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

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

什麼是SSRF

SSRF英文全拼為

Server Side Request Forgery

,翻譯為服務端請求僞造。攻擊者在未能取得伺服器權限時,利用伺服器漏洞以伺服器的身份發送一條構造好的請求給伺服器所在内網。關于内網資源的通路控制,想必大家心裡都有數。

Go中的SSRF攻防戰

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

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

回合一:千變萬化的内網位址

為什麼用“千變萬化”這個詞?老許先不回答,請各位讀者耐心往下看。下面,老許用

182.61.200.7

(www.baidu.com的一個IP位址)這個IP和各位讀者一起複習一下IPv4的不同表示方式。

Go中的SSRF攻防戰

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

上面僅是IPv4的不同表現方式,IPv6的位址也有三種不同表示方式。而這三種表現方式又可以有不同的寫法。下面以IPv6中的回環位址

0:0:0:0:0:0:0:1

為例。

Go中的SSRF攻防戰

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

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

内網IP你以為到這兒就完了嘛?當然不!不知道各位讀者有沒有聽過

xip.io

這個域名。

xip

可以幫你做自定義的DNS解析,并且可以解析到任意IP位址(包括内網)。

Go中的SSRF攻防戰

我們通過

xip

提供的域名解析,還可以将内網IP通過域名的方式進行通路。

關于内網IP的通路到這兒仍将繼續!搞過Basic驗證的應該都知道,可以通過

http://user:[email protected]/

進行資源通路。如果攻擊者換一種寫法或許可以繞過部分不夠嚴謹的邏輯,如下所示。

Go中的SSRF攻防戰

關于内網位址,老許掏空了所有的知識儲備總結出上述内容,是以老許說一句千變萬化的内網位址不過分吧!

此時此刻,老許隻想問一句,當惡意攻擊者用這些不同表現形式的内網位址進行圖檔上傳時,你怎麼将其識别出來并拒絕通路。不會真的有大佬用正規表達式完成上述過濾吧,如果有請留言告訴我讓小弟學習一下。

花樣百出的内網位址我們已經基本了解,那麼現在的問題是怎麼将其轉為一個我們可以進行判斷的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
}

           

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

Go中的SSRF攻防戰

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

回合二:URL跳轉

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

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

Go中的SSRF攻防戰

如圖所示,通過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

的定義。

這裡特别說明一下,

req

是即将發出的請求且請求中包含前一次請求的響應,

via

是已經發出的請求。在知曉這些條件後,防禦URL跳轉攻擊就變得十分容易了。

  1. 根據前一次請求的響應直接拒絕

    307

    308

    的跳轉(此類跳轉可以是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.Client

CheckRedirect

可以防範URL跳轉攻擊。

回合三:DNS Rebinding

衆所周知,發起一次HTTP請求需要先請求DNS服務擷取域名對應的IP位址。如果攻擊者有可控的DNS服務,就可以通過DNS重綁定繞過前面的防禦政策進行攻擊。

具體流程如下圖所示。

Go中的SSRF攻防戰

驗證資源是是否合法時,伺服器進行了第一次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

方法裡。

Go中的SSRF攻防戰

上述兩個防禦方案不僅僅可以防範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

繼續閱讀