警惕!Python 中少為人知的 10 個安全陷阱!

豌豆花下貓發表於2022-02-14

作者:Dennis Brinkrolf

譯者:豌豆花下貓@Python貓

原題:10 Unknown Security Pitfalls for Python

英文:https://blog.sonarsource.com/10-unknown-security-pitfalls-for-python

宣告:本翻譯是出於交流學習的目的,基於 CC BY-NC-SA 4.0 授權協議。為便於閱讀,內容略有改動。

Python 開發者們在使用標準庫和通用框架時,都以為自己的程式具有可靠的安全性。然而,在 Python 中,就像在任何其它程式語言中一樣,有一些特性可能會被開發者們誤解或誤用。通常而言,只有極少的微妙之處或細節會使開發者們疏忽大意,從而在程式碼中引入嚴重的安全漏洞。

在這篇博文中,我們將分享在實際 Python 專案中遇到的 10 個安全陷阱。我們選擇了一些在技術圈中不太為人所知的陷阱。通過介紹每個問題及其造成的影響,我們希望提高人們對這些問題的感知,並提高大家的安全意識。如果你正在使用這些特性,請一定要排查你的 Python 程式碼!

1.被優化掉的斷言

Python 支援以優化的方式執行程式碼。這使程式碼執行得更快,記憶體用得更少。當程式被大規模使用,或者可用的資源很少時,這種方法尤其有效。一些預打包的 Python 程式提供了優化的位元組碼。

然而,當程式碼被優化時,所有的 assert 語句都會被忽略。開發者有時會使用它們來判斷程式碼中的某些條件。例如,如果使用斷言來作身份驗證檢查,則可能導致安全繞過。

def superuser_action(request, user):
    assert user.is_super_user
    # execute action as super user

在這個例子中,第 2 行中的 assert 語句將被忽略,導致非超級使用者也可以執行到下一行程式碼。不推薦使用 assert 語句進行安全相關的檢查,但我們確實在實際的專案中看到過它們。

2. MakeDirs 許可權

os.makdirs 函式可以在作業系統中建立一個或多個資料夾。它的第二個引數 mode 用於指定建立的資料夾的預設許可權。在下面程式碼的第 2 行中,資料夾 A/B/C 是用 rwx------ (0o700) 許可權建立的。這意味著只有當前使用者(所有者)擁有這些資料夾的讀、寫和執行許可權。

def init_directories(request):
    os.makedirs("A/B/C", mode=0o700)
    return HttpResponse("Done!")

在 Python < 3.6 版本中,建立出的資料夾 A、B 和 C 的許可權都是 700。但是,在 Python > 3.6 版本中,只有最後一個資料夾 C 的許可權為 700,其它資料夾 A 和 B 的許可權為預設的 755。

因此,在 Python > 3.6 中,os.makdirs 函式等價於 Linux 的這條命令:mkdir -m 700 -p A/B/C
有些開發者沒有意識到版本之間的差異,這已經在 Django 中造成了一個許可權越級漏洞(cve - 2022 -24583),無獨有偶,這在 WordPress 中也造成了一個加固繞過問題

3.絕對路徑拼接

os.path.join(path, *paths) 函式用於將多個檔案路徑連線成一個組合的路徑。第一個引數通常包含了基礎路徑,而之後的每個引數都被當做元件拼接到基礎路徑後。

然而,這個函式有一個少有人知的特性。如果拼接的某個路徑以 / 開頭,那麼包括基礎路徑在內的所有字首路徑都將被刪除,該路徑將被視為絕對路徑。下面的示例揭示了開發者可能遇到的這個陷阱。

def read_file(request):
    filename = request.POST['filename']
    file_path = os.path.join("var", "lib", filename)
    if file_path.find(".") != -1:
        return HttpResponse("Failed!")
    with open(file_path) as f:
        return HttpResponse(f.read(), content_type='text/plain')

在第 3 行中,我們使用 os.path.join 函式將使用者輸入的檔名構造出目標路徑。在第 4 行中,檢查生成的路徑是否包含”.“,防止出現路徑遍歷漏洞。

但是,如果攻擊者傳入的檔名引數為”/a/b/c.txt“,那麼第 3 行得到的變數 file_path 會是一個絕對路徑(/a/b/c.txt)。即 os.path.join 會忽略掉”var/lib“部分,攻擊者可以不使用“.”字元就讀取到任何檔案。儘管 os.path.join 的文件中描述了這種行為,但這還是導致了許多漏洞(Cuckoo Sandbox EvasionCVE-2020-35736)。

4. 任意的臨時檔案

tempfile.NamedTemporaryFile 函式用於建立具有特定名稱的臨時檔案。但是,prefix(字首)和 suffix(字尾)引數很容易受到路徑遍歷攻擊(Issue 35278)。如果攻擊者控制了這些引數之一,他就可以在檔案系統中的任意位置建立出一個臨時檔案。下面的示例揭示了開發者可能遇到的一個陷阱。

def touch_tmp_file(request):
    id = request.GET['id']
    tmp_file = tempfile.NamedTemporaryFile(prefix=id)
    return HttpResponse(f"tmp file: {tmp_file} created!", content_type='text/plain')

在第 3 行中,使用者輸入的 id 被當作臨時檔案的字首。如果攻擊者傳入的 id 引數是“/../var/www/test”,則會建立出這樣的臨時檔案:/var/www/test_zdllj17。粗看起來,這可能是無害的,但它會為攻擊者創造出挖掘更復雜的漏洞的基礎。

5.擴充套件的 Zip Slip

在 Web 應用中,通常需要解壓上傳後的壓縮檔案。在 Python 中,很多人都知道 TarFile.extractall 與 TarFile.extract 函式容易受到 Zip Slip 攻擊。攻擊者通過篡改壓縮包中的檔名,使其包含路徑遍歷(../)字元,從而發起攻擊。

這就是為什麼壓縮檔案應該始終被視為不受信來源的原因。zipfile.extractall 與 zipfile.extract 函式可以對 zip 內容進行清洗,從而防止這類路徑遍歷漏洞。

但是,這並不意味著在 ZipFile 庫中不會出現路徑遍歷漏洞。下面是一段解壓縮檔案的程式碼。

def extract_html(request):
    filename = request.FILES['filename']
    zf = zipfile.ZipFile(filename.temporary_file_path(), "r")
    for entry in zf.namelist():
        if entry.endswith(".html"):
            file_content = zf.read(entry)
            with open(entry, "wb") as fp:
                fp.write(file_content)
    zf.close()
    return HttpResponse("HTML files extracted!")

第 3 行程式碼根據使用者上傳檔案的臨時路徑,建立出一個 ZipFile 處理器。第 4 - 8 行程式碼將所有以“.html”結尾的壓縮項提取出來。第 4 行中的 zf.namelist 函式會取到 zip 內壓縮項的名稱。注意,只有 zipfile.extract 與 zipfile.extractall 函式會對壓縮項進行清洗,其它任何函式都不會。

在這種情況下,攻擊者可以建立一個檔名,例如“../../../var/www/html”,內容隨意填。該惡意檔案的內容會在第 6 行被讀取,並在第 7-8 行寫入被攻擊者控制的路徑。因此,攻擊者可以在整個伺服器上建立任意的 HTML 檔案。

如上所述,壓縮包中的檔案應該被看作是不受信任的。如果你不使用 zipfile.extractall 或者 zipfile.extract,你就必須對 zip 內檔案的名稱進行“消毒”,例如使用 os.path.basename。否則,它可能導致嚴重的安全漏洞,就像在 NLTK Downloader (CVE-2019-14751)中發現的那樣。

6. 不完整的正規表示式匹配

正規表示式(regex)是大多數 Web 程式不可或缺的一部分。我們經常能看到它被自定義的 Web 應用防火牆(WAF,Web Application Firewalls)用來作輸入驗證,例如檢測惡意字串。在 Python 中,re.match 和 re.search 之間有著細微的區別,我們將在下面的程式碼片段中演示。

def is_sql_injection(request):
    pattern = re.compile(r".*(union)|(select).*")
    name_to_test = request.GET['name']
    if re.search(pattern, name_to_test):
        return True
    return False

在第 2 行中,我們定義了一個匹配 union 或者 select 的模式,以檢測可能的 SQL 注入。這是一個糟糕的寫法,因為你可以輕易地繞過這些黑名單,但我們已經線上上的程式中見過它。在第 4 行中,函式 re.match 使用前面定義好的模式,檢查第 3 行中的使用者輸入內容是否包含這些惡意的值。

然而,與 re.search 函式不同的是,re.match 函式不匹配新行。例如,如果攻擊者提交了值 aaaaaa \n union select,這個輸入就匹配不上正規表示式。因此,檢查可以被繞過,失去保護作用。

總而言之,我們不建議使用正規表示式黑名單進行任何安全檢查。

7. Unicode 清洗器繞過

Unicode 支援用多種形式來表示字元,並將這些字元對映到碼點。在 Unicode 標準中,不同的 Unicode 字元有四種歸一化方案。程式可以使用這些歸一化方法,以獨立於人類語言的標準方式來儲存資料,例如使用者名稱。

然而,攻擊者可以利用這些歸一化,這已經導致了 Python 的 urllib 出現漏洞(CVE-2019-9636)。下面的程式碼片段演示了一個基於 NFKC 歸一化的跨站點指令碼漏洞(XSS,Cross-Site Scripting)。

import unicodedata
from django.shortcuts import render
from django.utils.html import escape

def render_input(request):
    user_input = escape(request.GET['p'])
    normalized_user_input = unicodedata.normalize("NFKC", user_input)
    context = {'my_input': normalized_user_input}
    return render(request, 'test.html', context)

在第 6 行中,使用者輸入的內容被 Django 的 escape 函式處理了,以防止 XSS 漏洞。在第 7 行中,經過清洗的輸入被 NFKC 演算法歸一化,以便在第 8-9 行中通過 test.html 模板正確地渲染。

templates/test.html

<!DOCTYPE html>
<html lang="en">
<body>
{{ my_input | safe}}
</body>
</html>

在模板 test.html 中,第 4 行的變數 my_input 被標記為安全的,因為開發人員預期有特殊字元,並且認為該變數已經被 escape 函式清洗了。通過標記關鍵字 safe, Django 不會再次對變數進行清洗。

但是,由於第 7 行(view.py)的歸一化,字元“%EF%B9%A4”會被轉換為“<”,“%EF%B9%A5”被轉換為“>”。這導致攻擊者可以注入任意的 HTML 標記,進而觸發 XSS 漏洞。為了防止這個漏洞,就應該在把使用者輸入做完歸一化之後,再進行清洗。

8. Unicode 編碼碰撞

前文說過,Unicode 字元會被對映成碼點。然而,有許多不同的人類語言,Unicode 試圖將它們統一起來。這就意味著不同的字元很有可能擁有相同的“layout”。例如,小寫的土耳其語 ı(沒有點)的字元是英語中大寫的 I。在拉丁字母中,字元 i 也是用大寫的 I 表示。在 Unicode 標準中,這兩個不同的字元都以大寫形式對映到同一個碼點。

這種行為是可以被利用的,實際上已經在 Django 中導致了一個嚴重的漏洞(CVE-2019-19844)。下面的程式碼是一個重置密碼的示例。

from django.core.mail import send_mail
from django.http import HttpResponse
from vuln.models import User

def reset_pw(request):
    email = request.GET['email']
    result = User.objects.filter(email__exact=email.upper()).first()
    if not result:
        return HttpResponse("User not found!")
    send_mail('Reset Password','Your new pw: 123456.', 'from@example.com', [email], fail_silently=False)
    return HttpResponse("Password reset email send!")

第 6 行程式碼獲取了使用者輸入的 email,第 7-9 行程式碼檢查這個 email 值,查詢是否存在具有該 email 的使用者。如果使用者存在,則第 10 行程式碼依據第 6 行中輸入的 email 地址,給使用者傳送郵件。需要指出的是,第 7-9 行中對郵件地址的檢查是不區分大小寫的,使用了 upper 函式。

至於攻擊,我們假設資料庫中存在一個郵箱地址為 foo@mix.com 的使用者。那麼,攻擊者可以簡單地傳入 foo@mıx.com 作為第 6 行中的 email,其中 i 被替換為土耳其語 ı。第 7 行程式碼將郵箱轉換成大寫,結果是 FOO@MIX.COM。這意味著找到了一個使用者,因此會傳送一封重置密碼的郵件。

然而,郵件被髮送到第 6 行未轉換的郵件地址,也就是包含了土耳其語的 ı。換句話說,其他使用者的密碼被髮送到了攻擊者控制的郵件地址。為了防止這個漏洞,可以將第 10 行替換成使用資料庫中的使用者郵箱。即使發生編碼衝突,攻擊者在這種情況下也得不到任何好處。

9. IP 地址歸一化

在 Python < 3.8 中,IP 地址會被 ipaddress 庫歸一化,因此字首的零會被刪除。這種行為乍一看可能是無害的,但它已經在 Django 中導致了一個高嚴重性的漏洞(CVE-2021-33571)。攻擊者可以利用歸一化繞過校驗程式,發起服務端請求偽造攻擊(SSRF,Server-Side Request Forgery)。

下面的程式碼展示瞭如何繞過這樣的校驗器。

import requests
import ipaddress

def send_request(request):
    ip = request.GET['ip']
    try:
        if ip in ["127.0.0.1", "0.0.0.0"]:
            return HttpResponse("Not allowed!")
        ip = str(ipaddress.IPv4Address(ip))
    except ipaddress.AddressValueError:
        return HttpResponse("Error at validation!")
    requests.get('https://' + ip)
    return HttpResponse("Request send!")

第 5 行程式碼獲取使用者傳入的一個 IP 地址,第 7 行程式碼使用一個黑名單來檢查該 IP 是否為本地地址,以防止可能的 SSRF 漏洞。這份黑名單並不完整,僅作為示例。

第 9 行程式碼檢查該 IP 是否為 IPv4 地址,同時將 IP 歸一化。在完成驗證後,第 12 行程式碼會對該 IP 發起實際的請求。

但是,攻擊者可以傳入 127.0.001 這樣的 IP 地址,在第 7 行的黑名單列表中找不到。然後,第 9 行程式碼使用 ipaddress.IPv4Address 將 IP 歸一化為 127.0.0.1。因此,攻擊者就能夠繞過 SSRF 校驗器,並向本地網路地址傳送請求。

10. URL 查詢引數解析

在 Python < 3.7 中,urllib.parse.parse_qsl 函式允許使用“;”和“&”字元作為 URL 的查詢變數的分隔符。有趣的是“;”字元不能被其它語言識別為分隔符。

在下面的例子中,我們將展示為什麼這種行為會導致漏洞。假設我們正在執行一個基礎設施,其中前端是一個 PHP 程式,後端則是一個 Python 程式。

攻擊者向 PHP 前端傳送以下的 GET 請求:

GET https://victim.com/?a=1;b=2

PHP 前端只識別出一個查詢引數“a”,其內容為“1;b=2”。PHP 不把“;”字元作為查詢引數的分隔符。現在,前端會將攻擊者的請求直接轉發給內部的 Python 程式:

GET https://internal.backend/?a=1;b=2

如果使用了 urllib.parse.parse_qsl,Python 程式會處理成兩個查詢引數,即“a=1”和“b=2”。這種查詢引數解析的差異可能會導致致命的安全漏洞,比如 Django 中的 Web 快取投毒漏洞(CVE-2021-23336)。

總結

在這篇博文中,我們介紹了 10 個 Python 安全陷阱,我們認為開發者不太瞭解它們。每個細微的陷阱都很容易被忽視,並在過去導致了線上程式的安全漏洞。

正如前文所述,安全陷阱可能出現在各種操作中,從處理檔案、目錄、壓縮檔案、URL、IP 到簡單的字串。一種常見的情況是庫函式的使用,這些函式可能有意想不到的行為。這提醒我們一定要升級到最新版本,並仔細閱讀文件。在 SonarSource 中,我們正在研究這些缺陷,以便將來不斷改進我們的程式碼分析器。

相關文章