Проблемы с безопасностью в Python - (не)Уникальный опыт

Проблемы с безопасностью в Python


Среди Python-боярей принято ругать язык PHP и программистов, которые на нем пишут. Дескать, дизайн языка ужасный, разработчики формошлепы, уязвимости лезут из каждого $_GET['id'] и как на таком говне вообще можно что-то разрабатывать в 202х году.

/img/python-security-pitfalls/php-creator.png

А что сам Python? Как там с безопасностью? Очевидно, что как и везде - плохо. При чем это “плохо” часто приобретает довольно неявные формы. О них и поговорим.

Вся эта статья, представляет из себя мои заметки и мысли основанные на другой статье - 10 Unknown Security Pitfalls for Python. Сама по себе она отличная, но местами мне не хватило примеров, ссылок и глубины повествования. Эти пробелы я и попробую закрыть.

Оптимизация утверждений

Код на питоне может быть отимизирован во время выполнения и в этом режиме игнорируется инструкция assert. Следовательно, все проверки предусловий, которые выполняются этой инструкцией будут проигнорированы поэтому их не рекомендуется использовать из соображений безопасности. Для оптимизации кода, достаточно передать интерпритатору флаг -O. Возьмем очень синтетический пример:

def superuser_action(user):
    assert user == "admin"
    print("Excecute admin actions...")
	
superuser_action("user")

И запустим его без оптимизации

$ python asserts.py
Traceback (most recent call last):
  File "/home/user/asserts.py", line 7, in <module>
    superuser_action("user")
  File "/home/user/asserts.py", line 4, in superuser_action
    assert user == "admin"
AssertionError

Отлично, питон стоит на страже наших интересов. А потом господин разработчик решил, что все работает как-то медленно (GIL, ну вы знаете…) и применил оптимизацию.

$ python -O asserts.py
Excecute admin actions...

Теперь немного изменим пример, чтобы понять, что там происходит под капотом в байткоде

import dis

def superuser_action(user):
    assert user == "admin"
    print("Excecute admin actions...")
	
dis.dis(superuser_action)

Результат выполнения скрипта без оптимизаций (assert-блок выделен цветом):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
6           0 LOAD_FAST                0 (user)
            2 LOAD_CONST               1 ('admin')
            4 COMPARE_OP               2 (==)
            6 POP_JUMP_IF_TRUE         6 (to 12)
            8 LOAD_ASSERTION_ERROR
           10 RAISE_VARARGS            1
7     >>   12 LOAD_GLOBAL              0 (print)
           14 LOAD_CONST               2 ('Excecute admin actions...')
           16 CALL_FUNCTION            1
           18 POP_TOP
           20 LOAD_CONST               0 (None)
           22 RETURN_VALUE

А теперь тоже самое, но с оптимизацией:

7           0 LOAD_GLOBAL              0 (print)
            2 LOAD_CONST               1 ('Excecute admin actions...')
            4 CALL_FUNCTION            1
            6 POP_TOP
            8 LOAD_CONST               0 (None)
           10 RETURN_VALUE

Assert-блок был просто вырезан. Так работает добрая магия оптимизаций. Но кто в здравом уме будет проверять права доступа к методу DELETE /user/{id} через assert? ;)

Разрешения по умолчанию для os.makedirs

С помощью функции os.makedirs можно создать одну или несколько папок:

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

В примере выше будет создано три папки, но в зависимости от версии питона права на них будут разные. До версии 3.6 все папки будут созданы с одинаковыми правами - 700 (rwx------), что дает право чтения, записи и выполнения только создателю папок. А вот после версии 3.6, разрешение 700 получит только папка C, а две предыдущие будут созданы с разрешением по умолчанию - 755 (rwxr-xr-x).

Существующие уязвимости на эту тему:

Особенность при объединении абсолютных путей

У функции os.path.join, есть интересная особенность, которая может привести к проблемам с безопасностью. Если к одному или нескольким существующим базовым параметрам добавить еще один содержащий / в начале, то все базовые параметры будут проигнорированы:

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')

В примере выше есть примитивная защита от обхода директории, но ее можно легко обойти если в качестве имени файла передать /etc/passwd. Переменная file_path в этом случае, вместо ожидаемого значения var/lib/etc/passwd примет значение /etc/passwd. Это не является ошибкой и описано в документации. Но документацию никто не читает и поэтому существуют известные уязвимости вызванные этой особенностью:

Обходи директории при создании временных файлов

Параметры prefix и suffix у функции tempfile.NamedTemporaryFile уязвимы к обходу директории.

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')

Если в качестве id передать /../var/www/test, то будет создан файл с именем /var/www/test_zdllj17. В контексте конкретного сервиса это может дать основу для более сложной атаки.

Обход директории при распаковке zip файла

Функции tarfile.TarFile.extractall и tarfile.TarFile.extract подвержены уязвимости Zip Slip. Аналогичные функции для работы с zip архивами zipfile.ZipFile.extract и zipfile.ZipFile.extractall, в свою очередь, вырезают символы ../ из пути и предотвращают эту уязвимость. Но она все еще может быть проэксплуатирована в таком коде:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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!")

Из-за прямого чтения содержимого файла из архива - не проводится никакой предварительной обработки имен файлов, это делают только фукнкции extract/extractall, что позволяет выполнить обход директории и создать произвольный HTML по требуемому пути. Также существуют известные уязвимости из-за этого механизма:

Неполное соответствие регулярному выражению

Существует небольшая разница между функциями re.search и re.match. Функция re.match не ищет совпадения в новых строках, что дает возможность обходить проверки написанные с ее помощью:

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

Для того чтобы обойти проверку и выполнить инъекцию нужно передать строку вида aaaaaa \n union select, которую этот код успешно пропустит.

Обход экранирования с помощью юникода

В стандарте Unicode определены четыре алгоритма нормализации для разных символов. Приложение может использовать эту нормализацию для хранения данных, таких как имя пользователя, унифицированным способом независимым от человеческого языка. Порой это приводит к уязвимостям: CVE-2019-9636.

Следующий пример уязвим к XSS из-за применения нормализации:

1
2
3
4
5
6
7
8
9
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)

Функция экранирования escape не сработает если в параметре передать такой код %EF%B9%A4script%EF%B9%A5alert(1)%EF%B9%A4/script%EF%B9%A5. С ее точки зрения здесь нечего экранировать. А вот функция нормализации преобразует это в ﹤script﹥alert(1)﹤/script﹥. Такой код попадет на страницу и будет выполнен.

Коллизия регистра символов юникода

При сопоставлении символов с их кодами, Unicode пытается объединить эти символы для различных языков. Это приводит к высокой вероятность того, что разные символы будут имеють одинаковое отображение (layout). Например, турецкий символ в нижнем регистре - ı (без точки) превратится в I в верхнем регистре. В алфавитах на основе латиницы, символ i также превратится в I в верхнем регистре. В терминах юникода это означает, что два разных символа сопоставляются с одним и тем же кодом в верхнем регистре. Такое поведение уже приводило к критическим уязвимостям в Django: CVE-2019-19844. Рассмотрим пример:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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!")

Функция восстановления пароля провдит регистронезависимую проверку на существование пользователя и если пользователь найден, то высылает пароль на почту полученную из параметров запроса. Проблема в том, что для существующего пользователя с почтой foo@mix.com , злоумышленник может выполнить восстановление пароля на подконтрольную ему почту foo@mıx.com, которая после прохождения через функцию upper() будет преобразована к FOO@MIX.COM из-за коллизии. Таким образом будет пройдена проверка на существование пользователя, но пароль будет отправлен на почту злоумышленника.

Нормализация IP адресов

До версии питона 3.8, IP адреса нормализировались с помощью библиотеки ipaddress, которая удаляла ведущие нули у октетов адреса. Эта особенность уже приводила к серьезным уязвимостям в Django: CVE-2021-33571. По сути, это дает возможность обойти некоторые валидации и при выполнении атаки SSRF:

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!")

В примере выше проводится валидация по списку запрещенных адресов, что уже само по себе является плохой практикой. Если на вход такого валидатора передать адрес 127.0.00.1, то он успешно пройдет валидацию и попадет в функцию ipaddress.IPv4Address, где будет нормализован до 127.0.0.1 и передан в GET запрос в качестве адреса хоста.

Разбор URL параметров

До версии питона 3.7, функция urllib.parse.parse_qsl позволяла использовать символы ; и & в качестве разделителей для параметров URL. При этом другие языки не рассматривают символ ; в качестве разделителя, что может привести к уязвимостям в микросервисной системе. Для примера возьмем сервис на PHP, который взаимодействует с сервисом на Python. Если передать в эту цепочку передать URL вида http://victim.com/?a=1;b=2, то PHP сервис увидит только один параметр a со значением 1;b=2. Далее это улетит в сервис на Python, который увидит уже два параметра a=1 и b=2. Такое поведение приводило например к отравлению кэша в Django: CVE-2021-23336

Ссылки


Смотрите также