寫 Python 潛在會踩到的安全性地雷

很多不算是 Python 的鍋,要算就算攻擊者們太有創意啦我想

最近讀到了篇 10 Unknown Security Pitfalls for Python 提到了幾個地雷,重寫個副本下來加深自己的記憶。以下程式碼皆是以 Flask 的格式撰寫。

1. assert 是會被移除的

這個其實在 Python 介紹 assert 的文件上就有提及,即 assert 僅在偵錯模式下有效,故在最佳化狀態下 assert 是會從 byte code 中移除。

原文中提及這點是用來警惕不要把安全性檢查的語法掛在 assert 底下,以避免在生產環境中這些檢查其實被跳過。

2. os.makedirs 的權限設置

os.makedirs 是一個很方便可以建立多層資料夾的函數,並且它也允許使用 mode 參數設置資料夾權限。但是在 Python 3.6 以上時僅最後一層的資料夾會套用到所指派的權限,而新建的母資料夾們卻會使用 755 權限,所以並沒有完善的保護到所有的資料。

Gea-Suan Lin 的文章 則提及相對於 shell script,套用 umask 之後 mkdir 帶著 -p 參數建出來的多層資料夾全部都可以套用到設定的權限下。

3. 絕對路徑造成的遍歷攻擊

原文提及的情境是當我們要回應一個檔案的內容,而檔案名稱由請求中的參數建議時要小心的狀況:

def get_image():
filename = request.args.get("filename")
filepath = os.path.join("/www/images/", filename)
return filepath(filepath, mimetype="image/jpeg")

在這樣的範例中,我們想像中它只會回應 /www/images/ 底下的圖片出去,然而卻有一個陷阱是如果 filename 是一個絕對路徑,則 filepath 會完全變成 filename 的值,然後某個攻擊者指定想看的檔案就被送出去了。

範例及原文皆是用 os.path.join 達成這個狀況,自己試驗了一下確認了 pathlib 也有一樣的行為。

4. 暫存檔前後綴造成的錯誤

tempfile 裡有不少的 API 可以設定檔名或路徑前綴及後綴,在正常使用下可以用來讓 /tmp 那堆檔案們變得好理解一點,但一樣可透過精心設計的請求來暴露出伺服器的資訊:

def save_data():
payload = request.files.get("file")
with tempfile.NamedTemporaryFile(prefix=file.filename) as fp:
fp.write(payload.read())
return f"{fp.name} saved"

想像中這樣可以把檔案暫存到 /tmp 底下,方便做一些額外的處理,但當檔案名稱被設計為相對路徑之後則有可能暴露路徑結構,例如設計為 ../etc/apache2/ 則可以造成讓暫存檔案被錯誤的放置到 /etc/apache2/ 去。這個漏洞的說明在 BPO-35278,而原作者提及這個手段已經在 PHP 及 Ruby 各造成了對應 CVE。

在找這個坑的相關資訊時也注意到 Flask 在教學文件中就有提及到這個攻擊,並建議了開發者應該透過 secure_filename 去對抽出來的檔案名稱做清理;但 Django 的 User-uploaded content 部分沒有太明確提到有沒有處理這個漏洞。

5. Zip Slip 目錄走訪漏洞

依然是萬惡(?)的相對路徑,這個漏洞的起因是 zip 檔案名稱有辦法以 ../ 起始,進而造成後端在寫檔的時候可能會指向錯誤的位置,是一個無論什麼後端語言都會踩到的坑:

def extract_content():
payload = request.files.get("file")
with zipfile.ZipFile(payload) as zip_, \
tempfile.TemporaryDirectory() as dir_:
for fileinfo in zip_.infolist():
if fileinfo.filename.endswith(".html"):
zip_.extract(fileinfo, os.path.join(dir_, fileinfo.filename))

這個邏輯只是想要把檔案丟到一個暫存資料夾而已,但當他嘗試去解壓一個名為 ../../usr/share/nginx/www/index.html 的檔案時,那就有機會把本來網站的首頁偷改掉了。

6. 不完整的 RegEx 檢查

我們很常利用 regular expression 做輸入檢查,但用它來實作安全性檢查的時候卻可能遺漏某些部分:

def is_sql_injection(name):
pattern = re.compile(r".*(union)|(select).*")
if pattern.match(name):
return True
return False

這樣的一段檢查原意是擋下 unionselect 這兩個關鍵字(姑且不論如果 name 真的有這兩個單字怎麼辦😂),但這邊的缺陷是 re.match 會從起始處開始作配對,而 . 本身並不匹配換行字元,所以如果有一個多行的輸入如 -- some comments;\nUNION ... 那麼就可以通過檢查,且這無關有沒有設定 re.MULTILINE 這個 flag。

在這個範例中若使用 re.search 雖然不會發生問題,但考慮到各種其他特殊的字串等 corner cases,原文建議了不用使用 regex 來實作安全性檢查。

7. Unicode 正規化造成的旁繞攻擊漏洞

設定的情境是,我們有一個 view 接收了使用者的輸入,然後套用了 escape 要避免 XSS 攻擊、再套用 unicodedata.normalize 要解決一些字碼問題置換的問題,然後要顯示到網頁上。

def show_bulletin():
comment = request.form.get("comment")
comment = flask.escape(comment)
comment = unicodedata.normalize("NFKC", comment)
return render_template("bulletin.html", comment=comment)

結果——又中招了,在這樣的情境中攻擊者可以設計讓參數帶有 (\ufe64 / \ufe65),這兩個符號在正規化後會被轉換為 HTML tag 常用的 <>,然後就又可以達成 XSS 了。而對應的解法就是先做完正規化/資料消毒後才處理跳脫,懶惰一點就是在顯示時直接利用內建的 tag 如 Flask 的 e 或 Django 的 escape 做跳脫。

8. Unicode 字元碰撞

在 Unicode 中有些字元在轉換到大小寫之後會發生碰撞,舉例來說土耳其字母 ı (\u0131) 的大寫跟英文 i (\x69) 的大寫都是 I (\x49),因此在某些檢查中有機會利用這個特性達成旁繞攻擊。

原文中的舉例是使用者請求了重設密碼,而我們在檢查使用者存在之後將信件寄往了使用者信箱:

def reset_password():
address = request.values.get("mail_addr")
with connection.cursor() as cur:
cur.execute(
"SELECT id FROM user WHERE mail=%(address)s",
{
"address": address.upper(),
}
)
result = cur.fetchone()
if not result:
return
send_password_reset_mail(address)

那麼當攻擊者輸入 example@hotmaıl.com (i被置換) 進行檢查、而使用者存在時,密碼重設的信就會被送到 xn--hotmal-t9a.com 而不是 hotmail.com 去了。迴避的方法則是在這種場景中,以資料庫撈出來的地址為準來送信。

9. IP 地址正規化造成的旁繞漏洞

Python 內建的 ipaddress 原本會自動無視多於的 0,即 127.00.00.1 會跟 127.0.0.1 等價,直觀上很正常、但這樣的設計卻造成了潛在伺服器端請求偽造 (SSRF) 攻擊可能,手法是利用補零的方式繞過檢查清單,如:

def forward_request():
ip = request.args.get("ip")
if ip in {
"127.0.0.1",
"10.1.2.3",
}:
abort(HTTPStatus.FORBIDDEN)
ip = ipaddress.ip_address(ip)
requests.get(f"http://{ip}/v1/predict")

那麼 127.00.00.1 就不會被擋下來。這個漏洞有在 Django 那邊列了一個 CVE-2021-33571

另外在 IP 補零的手法讓我想到了 Go 之前也有一個八進制轉換的行為造成漏洞 (link),總之 Python 後來是直接禁止了這個行為,在 3.8.12 之後直接跳錯。

10. URL 參數解析

一樣是 Python 內建函式庫的問題,urllib.parse.parse_qsparse_qsl 在早期一點的版本有接受 ; 作為 URL 參數的分隔符號,而不僅只標準的 &。在具有這樣個行為下:

?foo=bar;baz=qax

在其他地方可能會被解釋為 {"foo": "bar;baz=qax"}(雖然理論上 ;= 該變成 %3B%3D),但在 Python 這裡卻成了 {"foo": "bar", "baz": "qax"}。這就造成了如果請求是先打到其他服務、在那邊做完檢查後被轉過來 Python 這裡時可能會有不同的解釋。

而這個行為後來在 bpo-42967 中做了修改,回溯支援到 3.6.13 有修正。