Среди Python-боярей принято ругать язык PHP и программистов, которые на нем пишут. Дескать, дизайн языка ужасный, разработчики формошлепы, уязвимости лезут из каждого $_GET['id']
и как на таком говне вообще можно что-то разрабатывать в 202х году.
А что сам 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-блок выделен цветом):
|
|
А теперь тоже самое, но с оптимизацией:
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
, в свою очередь, вырезают символы ../
из пути и предотвращают эту уязвимость. Но она все еще может быть проэксплуатирована в таком коде:
|
|
Из-за прямого чтения содержимого файла из архива - не проводится никакой предварительной обработки имен файлов, это делают только фукнкции 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 из-за применения нормализации:
|
|
Функция экранирования 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. Рассмотрим пример:
|
|
Функция восстановления пароля провдит регистронезависимую проверку на существование пользователя и если пользователь найден, то высылает пароль на почту полученную из параметров запроса. Проблема в том, что для существующего пользователя с почтой 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
Ссылки
- Unicode & Character Encodings in Python: A Painless Guide (есть перевод)
- Clean As You Code essentials - What are Quality Profiles and Quality Gates?
- Hack the Stack with LocalStack: Code Vulnerabilities Explained
- Code security: now there’s a tool for developers
- Fortify Taxonomy: Software Security Errors
- How I found (and fixed) a vulnerability in Python