談一談如何在Python開(kāi)發(fā)中拒絕SSRF漏洞

一 、SSRF漏洞常見(jiàn)防御手法及繞過(guò)方法
SSRF是一種常見(jiàn)的Web漏洞,通常存在于需要請(qǐng)求外部?jī)?nèi)容的邏輯中,比如本地化網(wǎng)絡(luò)圖片、XML解析時(shí)的外部實(shí)體注入、軟件的離線下載等。當(dāng)攻擊者傳入一個(gè)未經(jīng)驗(yàn)證的URL,后端代碼直接請(qǐng)求這個(gè)URL,將會(huì)造成SSRF漏洞。
具體危害體現(xiàn)在以下幾點(diǎn)上:
URL為內(nèi)網(wǎng)IP或域名,攻擊者將可以通過(guò)SSRF漏洞掃描目標(biāo)內(nèi)網(wǎng),查找內(nèi)網(wǎng)內(nèi)的漏洞,并想辦法反彈權(quán)限
URL中包含端口,攻擊者將可以掃描并發(fā)現(xiàn)內(nèi)網(wǎng)中機(jī)器的其他服務(wù),再進(jìn)一步進(jìn)行利用
當(dāng)請(qǐng)求方法允許其他協(xié)議的時(shí)候,將可能利用gopher、file等協(xié)議進(jìn)行第三方服務(wù)利用,如利用內(nèi)網(wǎng)的redis獲取權(quán)限、利用fastcgi進(jìn)行g(shù)etshell等
特別是這兩年,大量利用SSRF攻擊內(nèi)網(wǎng)服務(wù)的案例被爆出來(lái),導(dǎo)致SSRF漏洞慢慢受到重視。這就給Web應(yīng)用開(kāi)發(fā)者提出了一個(gè)難題:如何在保證業(yè)務(wù)正常的情況下防御SSRF漏洞?
很多開(kāi)發(fā)者認(rèn)為,只要檢查一下請(qǐng)求url的host不為內(nèi)網(wǎng)IP,即可防御SSRF。這個(gè)觀點(diǎn)其實(shí)提出了兩個(gè)技術(shù)要點(diǎn):
1.如何檢查IP是否為內(nèi)網(wǎng)IP
2.如何獲取真正請(qǐng)求的host
于是,攻擊者通過(guò)這兩個(gè)技術(shù)要點(diǎn),針對(duì)性地想出了很多繞過(guò)方法。
二、 如何檢查IP是否為內(nèi)網(wǎng)IP
這實(shí)際上是很多開(kāi)發(fā)者面臨的第一個(gè)問(wèn)題,很多新手甚至連內(nèi)網(wǎng)IP常用的段是多少也不清楚。
何謂內(nèi)網(wǎng)IP,實(shí)際上并沒(méi)有一個(gè)硬性的規(guī)定,多少到多少段必須設(shè)置為內(nèi)網(wǎng)。有的管理員可能會(huì)將內(nèi)網(wǎng)的IP設(shè)置為233.233.233.0/24段,當(dāng)然這是一個(gè)比較極端的例子。
通常我們會(huì)將以下三個(gè)段設(shè)置為內(nèi)網(wǎng)IP段,所有內(nèi)網(wǎng)內(nèi)的機(jī)器分配到的IP是在這些段中:
- 192.168.0.0/16 => 192.168.0.0 ~ 192.168.255.255
- 10.0.0.0/8 => 10.0.0.0 ~ 10.255.255.255
- 172.16.0.0/12 => 172.16.0.0 ~ 172.31.255.255
所以通常,我們只需要判斷目標(biāo)IP不在這三個(gè)段,另外還包括一個(gè) 127.0.0.0/8 段即可。
很多人會(huì)忘記 127.0.0.0/8 ,認(rèn)為本地地址就是 127.0.0.1 ,實(shí)際上本地回環(huán)包括了整個(gè)127段。你可以訪問(wèn)http://127.233.233.233/,會(huì)發(fā)現(xiàn)和請(qǐng)求127.0.0.1是一個(gè)結(jié)果:

所以我們需要防御的實(shí)際上是4個(gè)段,只要IP不落在這4個(gè)段中,就認(rèn)為是“安全”的。
網(wǎng)上一些開(kāi)發(fā)者會(huì)選擇使用“正則”的方式判斷目標(biāo)IP是否在這四個(gè)段中,這種判斷方法通常是會(huì)遺漏或誤判的,比如如下代碼:

這是Sec-News最老版本判斷內(nèi)網(wǎng)IP的方法,里面使用正則判斷IP是否在內(nèi)網(wǎng)的幾個(gè)段中。這個(gè)正則也是我當(dāng)時(shí)臨時(shí)在網(wǎng)上搜的,很明顯這里存在多個(gè)繞過(guò)的問(wèn)題:
1. 利用八進(jìn)制IP地址繞過(guò)
2. 利用十六進(jìn)制IP地址繞過(guò)
3. 利用十進(jìn)制的IP地址繞過(guò)
4. 利用IP地址的省略寫(xiě)法繞過(guò)
這四種方式我們可以依次試試:

四種寫(xiě)法(5個(gè)例子):012.0.0.1 、 0xa.0.0.1 、 167772161 、 10.1 、 0xA000001 實(shí)際上都請(qǐng)求的是10.0.0.1,但他們一個(gè)都匹配不上上述正則表達(dá)式。
更聰明一點(diǎn)的人是不會(huì)用正則表達(dá)式來(lái)檢測(cè)IP的(也許這類(lèi)人并不知道內(nèi)網(wǎng)IP的正則該怎么寫(xiě))。Wordpress的做法是,先將IP地址規(guī)范化,然后用“.”將其分割成數(shù)組parts,然后根據(jù)parts[0]和parts[1]的取值來(lái)判斷:

其實(shí)也略顯麻煩,而且曾經(jīng)也出現(xiàn)過(guò)用進(jìn)制方法繞過(guò)的案例( WordPress <4.5 SSRF 分析 ),不推薦使用。
我后來(lái)選擇了一種更為簡(jiǎn)單的方法。眾所周知,IP地址是可以轉(zhuǎn)換成一個(gè)整數(shù)的,在PHP中調(diào)用ip2long函數(shù)即可轉(zhuǎn)換,在Python使用inet_aton去轉(zhuǎn)換。
而且IP地址是和2^32內(nèi)的整數(shù)一一對(duì)應(yīng)的,也就是說(shuō)0.0.0.0 == 0,255.255.255.255 == 2^32 - 1。所以,我們判斷一個(gè)IP是否在某個(gè)IP段內(nèi),只需將IP段的起始值、目標(biāo)IP值全部轉(zhuǎn)換為整數(shù),然后比較大小即可。
于是,我們可以將之前的正則匹配的方法修改為如下方法:

這就是一個(gè)最簡(jiǎn)單的方法,也最容易理解。
假如你懂一點(diǎn)掩碼的知識(shí),你應(yīng)該知道IP地址的掩碼實(shí)際上就是(32 - IP地址所代表的數(shù)字的末尾bit數(shù))。所以,我們只需要保證目標(biāo)IP和內(nèi)網(wǎng)邊界IP的前“掩碼”位bit相等即可。借助位運(yùn)算,將以上判斷修改地更加簡(jiǎn)單:
- from socket import inet_aton
- from struct import unpack
- def ip2long(ip_addr):
- return unpack("!L", inet_aton(ip_addr))[0]
- def is_inner_ipaddress(ip):
- ip = ip2long(ip)
- return ip2long('127.0.0.0') >> 24 == ip >> 24 or \
- ip2long('10.0.0.0') >> 24 == ip >> 24 or \
- ip2long('172.16.0.0') >> 20 == ip >> 20 or \
- ip2long('192.168.0.0') >> 16 == ip >> 16
以上代碼也就是Python中判斷一個(gè)IP是否是內(nèi)網(wǎng)IP的最終方法,使用時(shí)調(diào)用is_inner_ipaddress(...)即可(注意自己編寫(xiě)捕捉異常的代碼)。
三、 host獲取與繞過(guò)
如何獲取"真正請(qǐng)求"的Host,這里需要考慮三個(gè)問(wèn)題:
1. 如何正確的獲取用戶輸入的URL的Host?
2. 只要Host只要不是內(nèi)網(wǎng)IP即可嗎?
3. 只要Host指向的IP不是內(nèi)網(wǎng)IP即可嗎?
如何正確的獲取用戶輸入的URL的Host?
第一個(gè)問(wèn)題,看起來(lái)很簡(jiǎn)單,但實(shí)際上有很多網(wǎng)站在獲取Host上犯過(guò)一些錯(cuò)誤。最常見(jiàn)的就是,使用http://233.233.233.233@10.0.0.1:8080/、http://10.0.0.1#233.233.233.233這樣的URL,讓后端認(rèn)為其Host是233.233.233.233,實(shí)際上請(qǐng)求的卻是10.0.0.1。這種方法利用的是程序員對(duì)URL解析的錯(cuò)誤,有很多程序員甚至?xí)谜齽t去解析URL。
在Python 3下,正確獲取一個(gè)URL的Host的方法:
- from urllib.parse import urlparse
- url = 'https://10.0.0.1/index.php'
- urlparse(url).hostname
這一步一定不能犯錯(cuò),否則后面的工作就白做了。
只要Host只要不是內(nèi)網(wǎng)IP即可嗎?
第二個(gè)問(wèn)題,只要檢查一下我們獲取到的Host是否是內(nèi)網(wǎng)IP,即可防御SSRF漏洞么?
答案是否定的,原因是,Host可能是IP形式,也可能是域名形式。如果Host是域名形式,我們是沒(méi)法直接比對(duì)的。只要其解析到內(nèi)網(wǎng)IP上,就可以繞過(guò)我們的is_inner_ipaddress了。
網(wǎng)上有個(gè)服務(wù) http://xip.io ,這是一個(gè)“神奇”的域名,它會(huì)自動(dòng)將包含某個(gè)IP地址的子域名解析到該IP。比如 127.0.0.1.xip.io ,將會(huì)自動(dòng)解析到127.0.0.1,www.10.0.0.1.xip.io將會(huì)解析到10.0.0.1:

這個(gè)域名極大的方便了我們進(jìn)行SSRF漏洞的測(cè)試,當(dāng)我們請(qǐng)求http://127.0.0.1.xip.io/info.php的時(shí)候,表面上請(qǐng)求的Host是127.0.0.1.xip.io,此時(shí)執(zhí)行is_inner_ipaddress('127.0.0.1.xip.io')是不會(huì)返回True的。但實(shí)際上請(qǐng)求的卻是127.0.0.1,這是一個(gè)標(biāo)準(zhǔn)的內(nèi)網(wǎng)IP。
所以,在檢查Host的時(shí)候,我們需要將Host解析為具體IP,再進(jìn)行判斷,代碼如下:
- import socket
- import re
- from urllib.parse import urlparse
- from socket import inet_aton
- from struct import unpack
- def check_ssrf(url):
- hostname = urlparse(url).hostname
- def ip2long(ip_addr):
- return unpack("!L", inet_aton(ip_addr))[0]
- def is_inner_ipaddress(ip):
- ip = ip2long(ip)
- return ip2long('127.0.0.0') >> 24 == ip >> 24 or \
- ip2long('10.0.0.0') >> 24 == ip >> 24 or \
- ip2long('172.16.0.0') >> 20 == ip >> 20 or \
- ip2long('192.168.0.0') >> 16 == ip >> 16
- try:
- if not re.match(r"^https?://.*/.*$", url):
- raise BaseException("url format error")
- ip_address = socket.getaddrinfo(hostname, 'http')[0][4][0]
- if is_inner_ipaddress(ip_address):
- raise BaseException("inner ip address attack")
- return True, "success"
- except BaseException as e:
- return False, str(e)
- except:
- return False, "unknow error"
首先判斷url是否是一個(gè)HTTP協(xié)議的URL(如果不檢查,攻擊者可能會(huì)利用file、gopher等協(xié)議進(jìn)行攻擊),然后獲取url的host,并解析該host,最終將解析完成的IP放入is_inner_ipaddress函數(shù)中檢查是否是內(nèi)網(wǎng)IP。
只要Host指向的IP不是內(nèi)網(wǎng)IP即可嗎?
第三個(gè)問(wèn)題,是不是做了以上工作,解析并判斷了Host指向的IP不是內(nèi)網(wǎng)IP,即防御了SSRF漏洞?
答案繼續(xù)是否定的,上述函數(shù)并不能正確防御SSRF漏洞。為什么?
當(dāng)我們請(qǐng)求的目標(biāo)返回30X狀態(tài)的時(shí)候,如果沒(méi)有禁止跳轉(zhuǎn)的設(shè)置,大部分HTTP庫(kù)會(huì)自動(dòng)跟進(jìn)跳轉(zhuǎn)。此時(shí)如果跳轉(zhuǎn)的地址是內(nèi)網(wǎng)地址,將會(huì)造成SSRF漏洞。
這個(gè)原因也很好理解,我以Python的requests庫(kù)為例。requests的API中有個(gè)設(shè)置,叫allow_redirects,當(dāng)將其設(shè)置為T(mén)rue的時(shí)候requests會(huì)自動(dòng)進(jìn)行30X跳轉(zhuǎn)。而默認(rèn)情況下(開(kāi)發(fā)者未傳入這個(gè)參數(shù)的情況下),requests會(huì)默認(rèn)將其設(shè)置為T(mén)rue:

所以,我們可以試試請(qǐng)求一個(gè)302跳轉(zhuǎn)的網(wǎng)址:

默認(rèn)情況下,將會(huì)跟蹤location指向的地址,所以返回的status code是最終訪問(wèn)的頁(yè)面的狀態(tài)碼。而設(shè)置了allow_redirects的情況下,將會(huì)直接返回302狀態(tài)碼。
所以,即使我們獲取了http://t.cn/R2iwH6d的Host,通過(guò)了is_inner_ipaddress檢查,也會(huì)因?yàn)?02跳轉(zhuǎn),跳到一個(gè)內(nèi)網(wǎng)IP,導(dǎo)致SSRF。
這種情況下,我們有兩種解決方法:
1. 設(shè)置allow_redirects=False,不允許目標(biāo)進(jìn)行跳轉(zhuǎn)
2. 每跳轉(zhuǎn)一次,就檢查一次新的Host是否是內(nèi)網(wǎng)IP,直到抵達(dá)最后的網(wǎng)址
第一種情況明顯是會(huì)影響業(yè)務(wù)的,只是規(guī)避問(wèn)題而未解決問(wèn)題。當(dāng)業(yè)務(wù)上需要目標(biāo)URL能夠跳轉(zhuǎn)的情況下,只能使用第二種方法了。
所以,歸納一下,完美解決SSRF漏洞的過(guò)程如下:
1. 解析目標(biāo)URL,獲取其Host
2. 解析Host,獲取Host指向的IP地址
3. 檢查IP地址是否為內(nèi)網(wǎng)IP
4. 請(qǐng)求URL
5. 如果有跳轉(zhuǎn),拿出跳轉(zhuǎn)URL,執(zhí)行1
0x04 使用requests庫(kù)的hooks屬性來(lái)檢查SSRF
那么,上一章說(shuō)的5個(gè)過(guò)程,具體用Python怎么實(shí)現(xiàn)?
我們可以寫(xiě)一個(gè)循環(huán),循環(huán)條件就是“該次請(qǐng)求的狀態(tài)碼是否是30X”,如果是就繼續(xù)執(zhí)行循環(huán),繼續(xù)跟進(jìn)location,如果不是,則退出循環(huán)。代碼如下:
- r = requests.get(url, allow_redirects=False)
- while r.is_redirect:
- url = r.headers['location']
- succ, errstr = check_ssrf(url)
- if not succ:
- raise Exception('SSRF Attack.')
- r = requests.get(url, allow_redirects=False)
這個(gè)代碼思路大概沒(méi)有問(wèn)題,但非常簡(jiǎn)陋,而且效率不高。
只要你翻翻requests的源代碼,你會(huì)發(fā)現(xiàn),它在處理30X跳轉(zhuǎn)的時(shí)候考慮了很多地方:
- 所有請(qǐng)求放在一個(gè)requests.Session()中
- 跳轉(zhuǎn)有個(gè)緩存,當(dāng)下次跳轉(zhuǎn)地址在緩存中的時(shí)候,就不用多次請(qǐng)求了
- 跳轉(zhuǎn)數(shù)量有最大限制,不可能無(wú)窮無(wú)盡跳下去
- 解決307跳轉(zhuǎn)出現(xiàn)的一些BUG等
如果說(shuō)就按照之前簡(jiǎn)陋的代碼編寫(xiě)程序,固然可以防御SSRF漏洞,但上述提高效率的方法均沒(méi)用到。
那么,有更好的解決方法么?當(dāng)然有,我們翻一下requests的源代碼,可以看到一行特殊的代碼:

hook的意思就是“劫持”,意思就是在hook的位置我可以插入我自己的代碼。我們看看dispatch_hook函數(shù)做了什么:
- def dispatch_hook(key, hooks, hook_data, **kwargs):
- """Dispatches a hook dictionary on a given piece of data."""
- hookshooks = hooks or dict()
- hookshooks = hooks.get(key)
- if hooks:
- if hasattr(hooks, '__call__'):
- hooks = [hooks]
- for hook in hooks:
- _hook_data = hook(hook_data, **kwargs)
- if _hook_data is not None:
- hook_data = _hook_data
- return hook_data
hooks是一個(gè)函數(shù),或者一系列函數(shù)。這里做的工作就是遍歷這些函數(shù),并調(diào)用:
- _hook_data = hook(hook_data,**kwargs)
我們翻翻文檔,可以找到hooks event的說(shuō)明 http://docs.python-requests.org/en/master/user/advanced/?highlight=hook#event-hooks :

文檔中定義了一個(gè)print_url函數(shù),將其作為一個(gè)hook函數(shù)。在請(qǐng)求的過(guò)程中,響應(yīng)對(duì)象被傳入了print_url函數(shù),請(qǐng)求的域名被打印了下來(lái)。
我們可以考慮一下,我們將檢查SSRF的過(guò)程也寫(xiě)為一個(gè)hook函數(shù),然后傳給requests.get,在之后的請(qǐng)求中一旦獲取response就會(huì)調(diào)用我們的hook函數(shù)。這樣,即使我設(shè)置allow_redirects=True,requests在每次請(qǐng)求后都會(huì)調(diào)用一次hook函數(shù),在hook函數(shù)里我只需檢查一下response.headers['location']即可。
說(shuō)干就干,先寫(xiě)一個(gè)hook函數(shù):

當(dāng)r.is_redirect為T(mén)rue的時(shí)候,也就是說(shuō)這次請(qǐng)求包含一個(gè)跳轉(zhuǎn)。獲取此時(shí)的r.headers['location'],并進(jìn)行一些處理,最后傳入check_ssrf。當(dāng)檢查不通過(guò)時(shí),拋出一個(gè)異常。
然后編寫(xiě)一個(gè)請(qǐng)求函數(shù)safe_request_url,意思是“安全地請(qǐng)求一個(gè)URL”。使用這個(gè)函數(shù)請(qǐng)求的域名,將不會(huì)出現(xiàn)SSRF漏洞:

我們可以看到,在第一次請(qǐng)求url前,還是需要check_ssrf一次的。因?yàn)閔ook函數(shù)_request_check_location只是檢查30X跳轉(zhuǎn)時(shí)是否存在SSRF漏洞,而沒(méi)有檢查最初請(qǐng)求是否存在SSRF漏洞。
不過(guò)上面的代碼還不算完善,因?yàn)開(kāi)request_check_location覆蓋了原有(用戶可能定義的其他hooks)的hooks屬性,所以需要簡(jiǎn)單調(diào)整一下。
最終,給出完整代碼:
- import socket
- import re
- import requests
- from urllib.parse import urlparse
- from socket import inet_aton
- from struct import unpack
- from requests.utils import requote_uri
- def check_ssrf(url):
- hostname = urlparse(url).hostname
- def ip2long(ip_addr):
- return unpack("!L", inet_aton(ip_addr))[0]
- def is_inner_ipaddress(ip):
- ip = ip2long(ip)
- return ip2long('127.0.0.0') >> 24 == ip >> 24 or \
- ip2long('10.0.0.0') >> 24 == ip >> 24 or \
- ip2long('172.16.0.0') >> 20 == ip >> 20 or \
- ip2long('192.168.0.0') >> 16 == ip >> 16
- try:
- if not re.match(r"^https?://.*/.*$", url):
- raise BaseException("url format error")
- ip_address = socket.getaddrinfo(hostname, 'http')[0][4][0]
- if is_inner_ipaddress(ip_address):
- raise BaseException("inner ip address attack")
- return True, "success"
- except BaseException as e:
- return False, str(e)
- except:
- return False, "unknow error"
- def safe_request_url(url, **kwargs):
- def _request_check_location(r, *args, **kwargs):
- if not r.is_redirect:
- return
- url = r.headers['location']
- # The scheme should be lower case...
- parsed = urlparse(url)
- url = parsed.geturl()
- # Facilitate relative 'location' headers, as allowed by RFC 7231.
- # (e.g. '/path/to/resource' instead of 'http://domain.tld/path/to/resource')
- # Compliant with RFC3986, we percent encode the url.
- if not parsed.netloc:
- url = urljoin(r.url, requote_uri(url))
- else:
- url = requote_uri(url)
- succ, errstr = check_ssrf(url)
- if not succ:
- raise requests.exceptions.InvalidURL("SSRF Attack: %s" % (errstr, ))
- success, errstr = check_ssrf(url)
- if not success:
- raise requests.exceptions.InvalidURL("SSRF Attack: %s" % (errstr,))
- all_hooks = kwargs.get('hooks', dict())
- if 'response' in all_hooks:
- if hasattr(all_hooks['response'], '__call__'):
- r_hooks = [all_hooks['response']]
- else:
- r_hooks = all_hooks['response']
- r_hooks.append(_request_check_location)
- else:
- r_hooks = [_request_check_location]
- all_hooks['response'] = r_hooks
- kwargs['hooks'] = all_hooks
- return requests.get(url, **kwargs)
外部程序只要調(diào)用safe_request_url(url)即可安全地請(qǐng)求某個(gè)URL,該函數(shù)的參數(shù)與requests.get函數(shù)參數(shù)相同。
完美在Python Web開(kāi)發(fā)中解決SSRF漏洞。其他語(yǔ)言的解決方案類(lèi)似,大家可以自己去探索。
參考內(nèi)容:
http://www.luteam.com/?p=211
http://docs.python-requests.org/