Примечание: в статье будут приводиться примеры псевдокода.
В этой статье я расскажу, как реализовал систему безопасности для своего пет-проекта - менеджера плагинов. Расскажу, какие задачи пришлось решать, и как получилось встроить защиту без потери гибкости и архитектурной чистоты.
Эта система безопасности скорее любительская - она была создана больше ради интереса и обучения, нежели для реального использования.
Стоит подметить, что я начинающий разработчик, поэтому я не претендую на идеальность принятых мною решений.
Вступление
Контекст
Система безопасности создавалась для моего менеджера плагинов - инструмента, который позволяет динамически загружать плагины из .dll сборок, реализующие специальное API.
Плагины бывают разные: от выполнения простого скрипта до работы с файловой системой или отправки HTTP-запросов. Каждый плагин может иметь JSON-конфигурацию, в которой он может указывать необходимые ему зависимости (имя зависимости + минимальная версия).
В основе менеджера лежит система метаданных, которая создаётся при регистрации каждой сборки.
Она содержит:
Информацию о найденных в ней плагинах
Данные для быстрого поиска нужной сборки.
Метаданные активно используются другими частями системы - логгерами, трекерами и, теперь, системой безопасности.
Причина создания сервиса безопасности
Причина довольно проста: .dll сборки загружаются динамически, и без специальной системы безопасности нет гарантий того, что в сборке не содержится вредоносный код, который может навредить системе.
Поэтому и была реализована система безопасности, которая реагирует на регистрацию сборки и проводит необходимые проверки. Теперь важно определить какие задачи должна решать система безопасности.
Главные задачи системы безопасности
На первый взгляд задача кажется простой - достаточно проанализировать сборку с помощью библиотеки Mono.Cecil и отловить "нежелательные" конструкции.
Но всё осложняется тем, что в API предусмотрены плагины, которые должны законно работать с файловой системой или сетью. Простая фильтрация кода не позволит им этого делать. То есть необходимо создать механизм который позволит взаимодействовать с файловой системой или сетью, но при этом все взаимодействия должны контролироваться пользователем.
Помимо обеспечения хорошей безопасности систему необходимо создать гибкой - чтобы пользователь мог настраивать ее под свои нужды.
Также необходимо учитывать возможные уязвимости, и закрыть пути к созданию подделок безопасных сервисов.
Ну и не менее важная цель - качественно интегрировать новый компонент, чтобы он не усложнял архитектуру и не загрязнял код.
Реализацию системы безопасности я проводил пошагово - от малого к большему.
Начало реализации
Интеграция в менеджер
Интегрировать новую систему безопасности получилось довольно просто - с помощью реализации паттерна "Наблюдатель". Этот паттерн в моем случае идеально подошел, так как все необходимые интерфейсы в проекте уже реализованы. Таким образом хранилище метаданных сборок стало субъектом наблюдения, а система безопасности - его наблюдателем. Как только в хранилище появляются новые метаданные - система безопасности реагирует на это и получает метаданные добавленной в хранилище сборки.
public class AssemblyMetadataRepository : IObservableMetadataRepository { private readonly List<IMetadataRepositoryObserver> _observers = new(); public void AddMetadata(AssemblyMetadata metadata) { // Сообщаем наблюдателям о появлении новых метаданных foreach (var observer in _observers) observer.OnMetadataAdded(metadata); } } public class AssemblySecurityService : IMetadataRepositoryObserver { // Реагируем на появление новых метаданных public void OnMetadataAdded(AssemblyMetadata metadata) { CheckSafety(metadata) } private bool CheckSafety(AssemblyMetadata metadata) { // Проверка базопасности ... } }
Такая интеграция не добавила сложности в код, а проверка безопасности теперь будет происходить автоматически, не требуя дополнительных вызовов.
Первый шаг - статическая обработка сборок
Первым делом я добавил статический анализ сборки с помощью ранее упомянутой библиотеки Mono.Cecil
Изначально я анализировал только подозрительные пространства имен, по типу System.IO, System.Net, System.Reflection, System.Reflection.Emit, и пространства имен вручную добавленные пользователем. Спустя некоторое время я также добавил анализ на реализацию контрактов безопасных сервисов, для того чтобы избежать уязвимостей создания подделок.
public class AssemblySecurityService { private readonly HashSet<string> _blockedNamespaces = new(); // Можно добавить запрещенное пространство имен public void AddBlockedNamespace(string blockedNamespace) { // Валидация ... _blockedNamespaces.Add(blockedNamespace) } // Анализируем используемые в сборке пространства имен public bool AnalyzeNamespaces(string pathToAssembly) { // Извлекаем сборку для статического анализа var assembly = AssemblyDefinition.ReadAssembly(pathToAssembly); ... // Проверяем пространства имен сборки на подозрительные if (_blockedNamespaces.Any(banned => namespacesFromAssembly.StartsWith(banned))) return false; return true; } // Проверяем какие интерфейсы реализуют типы в сборке public bool AnalyzeInterfaces(string pathToAssembly) { // Извлекаем сборку для статического анализа var assembly = AssemblyDefinition.ReadAssembly(pathToAssembly); ... // Проверяем, не реализует ли тип из сборки запрещенный интерфейс if (typeFromAssembly.InterfaceType == typeof(ISafetyService)) return false; return true; } }
Второй шаг - внедрение безопасных сервисов
Следующим шагом стало внедрение безопасных сервисов, через которые плагин сможет безопасно взаимодействовать с файловой системой или сетью.
На уровне API будет реализован контракт безопасного сервиса, который позволит взаимодействовать только с разрешенными путями. Реализация этих сервисов будет находиться в отдельной сборке, которую я назвал инфраструктурной - она будет недоступной для плагинов.
Это даёт нам несколько преимуществ:
API не знает о реализации безопасных сервисов. Но знает о контракте, через который плагины и будут взаимодействовать с сервисом. Это даёт нам дополнительную защиту от создания подделок.
Статический анализ кода не будет засекать запрещенное пространство имён
System.IO, поскольку оно используется в другой сборке - инфраструктурной.
Для большей безопасности созданные безопасные сервисы не будут владеть методами добавления разрешений, создадим для этого отдельный компонент - контроллер разрешений, который также будет отвечать за валидацию/нормализацию путей. При создании экземпляра сервиса будем передавать контроллер с разрешениями - сервис извлечет из него все разрешения.
// Сборка PluginAPI // Контракт безопасного сервиса. Будет использоваться плагинами. public interface ISafetyService { void DoSafetyAction(); } // Сборка PluginInfrastructure - невидима для PluginAPI // Реализация безопасного сервиса (передаем контроллер с разрешениями) public class SafetyService(SafetyPermissionController controller) : ISafetyService { public void DoSafetyAction() { // Реализация сервиса ... } } // Контроллер в который добавляем разрешенные пути public class SafetyPermissionController { public void AddPermission(string permission) { // Валидация, нормализация, добавление разрешения ... } public IEnumerable<string> GetAllPermissions() { // Получаем все разрешения ... } }
Реализация безопасных сервисов
После того как я подготовил проект к изменениям - создал необходимые интерфейсы, продумал логику, я приступил к реализации безопасных сервисов.
1. Базовая реализация
Изначально реализация была очень простой - сервис просто хранил разрешенные пути. При обращении к сервису он проверял, разрешен ли запрашиваемый путь.
2. Политики доступа
Чуть позже я реализовал политику доступа к ресурсам: теперь для каждого пути можно указать, можно ли по нему читать или записывать данные.
3. Дополнительные ограничения
Чтобы повысить безопасность и стабильность я добавил:
Ограничение размера файла, с которым сервисы могут работать. Пользователь может настроить это ограничение.
Буферизация данных - для оптимизации памяти и защиты от "файловых бомб", если плагин неожиданно начнет читать или записывать огромные файлы.
4. Конфигурирование через API
Все настройки доступны через API менеджера плагинов - пользователь сам может задать допустимые пути, политики и ограничения. Это делает сервисы гибкими и более удобными в использовании.
public class Permission { public string Path { get; set; } // Можно ли прочитать данные по пути public bool CanReadFromPath { get; set; } // Можно ли записать данные по пути public bool CanWriteToPath { get; set; } } public class SafetyService { // Храним разрешения private readonly Dictionary<string, Permission> _permissions; // Ограничение на размер обрабатываемых данных private readonly long _maxFileSize; // Получаем настройки извне (настройки пользователя) public SafetyService(ISafetyServiceController controller, SafetyServiceSettings settings) { _permissions = controller.GetPermissions(); _maxFileSize = settings.MaxFileSizeBytes; } // Проверяем: есть ли разрешение + разрешена ли операция private bool IsPathAllowed(string path, bool isRead) { // Проверяем разрешение, политику доступа } public void DoAction(string path) { IsPathAllowed(path, true); // Проверяем размер данных, используем буферизацию для работы с ними } }
Как плагин будет получать необходимые разрешения?
Как я указывал выше - плагин может иметь JSON-конфигурацию. Теперь в этой конфигурации плагин сможет указывать к каким внешним ресурсам ему необходим доступ - например доступ к определенной папке/файлу, или URL.
Важное примечание:
Изначально плагин будет содержать пустую реализацию безопасного сервиса. Это обеспечивает безопасность плагина по умолчанию. Если конфигурация плагина прошла проверку безопасности - менеджер создает полноценный сервис с разрешениями и внедряет его в плагин.
Пример конфигурации плагина:
{ "Permissions": { "FileSystem": [ "D:\\SomePath", "D:\\SecondPath" ], "Network": [ "http://somesite.com" ] } }
Проверка безопасности конфигурации плагина
Для этого добавим новый компонент - PermissionSecurityService.
Он:
Хранит разрешенные пользователем пути и политики доступа к ним (например путь D:\SomePath - только для чтения)
При регистрации сборки анализирует конфигурации ее плагинов.
Если плагин запрашивает доступ к ресурсу, который не разрешен в компоненте - сборка не проходит проверку.
public class PermissionSecurityService { private readonly Dictionary<string, Permission> _permissions = new(); // Добавить разрешение, указать политику доступа public void AddPermission(string path, bool canRead, bool canWrite) { // Валидация, нормализация ... // Создаем и добавляем разрешение _permissions.Add(path, new Permission(path, canRead, canWrite)) } // Вызывается при регистрации сборки в менеджере public void OnMetadataAdded(AssemblyMetadata metadata) { // Получаем метаданные плагинов, которые содержат конфигурацию var plugins = metadata.Plugins; // Сверяем конфигурацию каждого плагина с разрешениями компонента ... } }
Поскольку в системе безопасности теперь работают два компонента - статическая проверка и анализ конфигурации - я объединил их в единый фасад SecurityService.
Я понимаю что такая проверка получилась очень строгой. Но это был осознанный выбор - только пользователь решает, какие ресурсы доступны плагину.
Чтобы сгладить такую строгость я добавил:
Импорт настроек безопасности из JSON-конфигурации. Это нужно для того чтобы не пришлось добавлять множество настроек вручную через код.
Рекурсивное добавление разрешений (опционально). Это удобно если нужно открыть доступ ко всей структуре папок, а не перечислять все вручную. Например если пользователь добавит рекурсивный доступ к
D:\SomePath, плагин сможет также взаимодействовать сD:\SomePath/FirstPath/SecondPath.
Внедрение сервисов в плагин
Честно говоря, это одна из частей системы в которой я все еще вижу потенциал для улучшения.
Сначала я хотел реализовать внедрение по такому шаблону:
API плагинов ничего не знает о внедрении сервисов (то-есть не видит методов для внедрения).
О внедрении сервисов знает только менеджер - это можно было бы реализовать через методы расширения.
Однако я столкнулся с такой проблемой:
Для того чтобы расширяющий метод мог внедрить сервис - он должен иметь доступ к полю сервиса, то есть поле плагина должно быть публичным.
Но если у плагина будет публичное поле - нет смысла в расширяющем методе, API все-равно сможет устанавливать значение в это поле.
Поэтому пока я пришел к такому решению: сервис можно внедрить только 1 раз. Я реализовал это с помощью флага, который указывает был ли уже ранее внедрен сервис. Если сервис уже внедрен - новый сервис не внедрится. Это защищает плагин от случайной или намеренной подмены сервиса после его инициализации.
Вот как это примерно выглядит:
// Базовый класс для работы с файловой системой, который реализовывается плагинами. public abstract class FilePluginBase : IFilePlugin { // Флаг который указывает, был ли внедрен сервис private bool _isServiceInjected = false; // Свойство которое содержит безопасный сервис. Изначально содержит заглушку // без реализации. protected ISafetyService FileSystemService { get; private set; } = new FileSystemServiceStub(); // Метод который отвечает за внедрение безопасного сервиса. После первого внедрения // устанавливает флаг в true, из-за чего больше внедрить сервис не получится. public void InjectService(ISafetyService service) { if (_isServiceInjected) return; FileSystemService = service; _isServiceInjected = true; } public abstract Task WriteFileAsync(byte[] data); public abstract Task<byte[]> ReadFileAsync(); }
В целом, я думаю текущая реализация вполне покрывает потребности моего менеджера, но если у вас есть идеи на этот счет - с радостью приму их к сведению!
Уязвимости и их устранение
Теперь пора рассказать о уязвимостях которые были обнаружены в моей системе безопасности, и о том, как я их устранил.
1. Создание подделок безопасного сервиса
Первая уязвимость позволяла создавать подделку безопасных сервисов. Опасность этой уязвимости заключается в том, что она позволяет обойти ограничения безопасных сервисов, что дает плагину доступ к любому файлу в файловой системе.
Детали уязвимости
Создаем новую сборку, в ней реализовываем подделку безопасного сервиса. В сборке с плагинами устанавливаем ссылку на созданную сборку - теперь в плагинах мы можем создать экземпляр поддельного сервиса и получить возможность взаимодействовать с файловой системой через него.
Пример подделки:
// Сборка с поддельным сервисом - FakeService.dll public class FakeService : ISafetyService { public void DoAction(string path) { // На сервис могут не действовать никакие ограничения, что позволяет получить // доступ к любому файлу File.Delete(path); } } // Сборка с плагинами - ссылается на FakeService.dll public class Plugin : IPlugin { // Выполняется при запуске плагина public void Execute() { // Создаем поддельный сервис, который не будет зафиксирован системой безопасности. // Теперь мы можем получить доступ к любому файлу var fakeService = new FakeService(); fakeService.DoAction("D:\очень_ценный_файл.txt"); } }
Оценка опасности
Исходя из деталей уязвимости мы понимаем - для того чтобы плагин смог получить доступ к поддельному сервису - загрузчик должен разрешить зависимости этой сборки.
Зависимость может разрешиться если:
Cборка с подделкой лежит в той же папке, что и сборка с плагинами.
В папке с плагинами лежит файл
deps.json, который хранит путь к необходимой зависимости.
Cтоит учитывать, что эта уязвимость сработает только если пользователь отдельно зарегистрирует опасную сборку по ее имени. Если пользователь зарегистрирует все сборки из каталога разом - сборка с поддельными сервисами попадет в область видимости менеджера и не пройдет проверку безопасности.
Я бы оценил опасность этой уязвимости на 3/5 - позволяет без ограничений взаимодействовать с файловой системой, но требует определенных условий, которые никак не зависят от плагина.
Устранение
Устранить эту уязвимость получилось довольно просто - в системе безопасности будем использовать AssemblyDependencyResolver для разрешения зависимостей загружаемых сборок - которые также будут проходить проверку.
public class AssemblySecurityService : IAssemblySecurityService { // Используется для защиты от бесконечной рекурсии private readonly HashSet<string> _checkedAssemblies = new(); public bool CheckSafety(string assemblyPath) { // Если сборка уже была обработана - значит сборка безопасная if(_checkedAssemblies.Add(assemblyPath)) return true; // Считываем сборку var assembly = AssemblyDefinition.ReadAssembly(assemblyPath); // Регистрируем разрешитель зависимостей var dependencyResolver = new AssemblyDependencyResolver(assemblyPath); // Логика проверки безопасности ... // Получаем список всех зависимостей сборки foreach (var reference in assembly.MainModule.AssemblyReference) { // Получаем путь к зависимости с помощью Resolver'a var resolvedPath = dependencyResolver.ResolveAssemblyToPath(reference) // Если зависимость была найдена - проверяем ее на безопасность // (вызываем рекурсию). if (resolvedPath is not null) return CheckSafety(resolvedPath;) } return true; } }
2. Уязвимость "доверенной" сборки
Эта уязвимость родилась после устранения первой. Дело в том, что проверка всех сборок на которые ссылается сборка с плагинами - очень неэффективная идея. Сборка ссылается на огромное множество стандартных библиотек .NET, и анализировать их бессмысленно - в них не может быть вредоносного кода.
Поэтому я создал механизм, который определяет, является ли сборка доверенной. Если сборка доверенная - она не проверяется системой безопасности.
Примечание
Я долго думал, стоит ли добавлять эту уязвимость в этот список, поскольку я прекрасно осознавал ее существование при реализации механизма - это скорее была временная мера. И все-таки я решил ее сюда добавить - чтобы показать как делать не надо.
Детали уязвимости
Уязвимость заключается в "дырявости" механизма проверки на доверенность. Дело в том, что изначально он просто проверял, начинается ли имя сборки с System. или Microsoft..
Вот как примерно выглядел метод проверки на доверенность:
private bool IsTrustedAssembly(string name) { return name.StartsWith("System.", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) || name.Equals("PluginAPI", StringComparison.OrdinalIgnoreCase) || name == "System" || name == "mscorlib" || name == "netstandard"; }
Недостаток очевиден - злоумышленник может просто создать сборку, имя которой начинается, например, с System., и механизм посчитает такую сборку доверенной.
Опасность уязвимости
Я бы оценил опасность этой уязвимости на 3.5/5, так как она позволяет пропустить проверку безопасности сборки - а это позволяет запихнуть в нее все что угодно. Однако, если представить что мой менеджер плагинов это инструмент без открытого исходного кода - злоумышленнику нужно знать как реализован механизм проверки доверенности, да и в целом знать о том, существует ли такой механизм вообще.
Устранение
Для устранения этой уязвимости был полностью изменен механизм проверки доверенности. Теперь он проверяет не имя сборки, а ее public token. В компонент был добавлен список доверенных токенов - токенов которыми Microsoft подписывают свои .NET сборки.
Реализация механизма получилась очень простой:
// Список доверенных public токенов private readonly HashSet<string> _trustedTokens = new() { "b03f5f7f11d50a3a", "7cec85d7bea7798e", "2c2e8d52f28b9f5e" }; // Метод для проверки доверенности - вытягивает из AssemblyName публичный токен // и проверяет, является ли сборка достоверной. private bool IsTrustedAssembly(AssemblyNameReference reference) { var publicKey = BitConverter.ToString(reference.PublicKeyToken).Replace("-","").ToLowerInvariant(); return _trustedTokens.Contains(publicKey); }
Итог
За время работы над системой безопасности я прошел путь от планирования до полноценной реализации. Главное чему меня научил этот проект - как смотреть на решение не только с точки зрения функциональности, но и с точки зрения потенциальных уязвимостей.
Я считаю что мне удалось удачно внедрить систему безопасности в свой менеджер плагинов - без лишней сложности и загрязнений кода. Плюс ко всему система безопасности получилась довольно гибкой - пользователь целиком может управлять как политиками доступа к ресурсам, так и настройками безопасных сервисов.
Конечно я понимаю, что в системе все еще остается пространство для улучшений - однако потребности моего менеджера плагинов она полностью покрывает.
Спасибо за внимание к моему опыту! Буду рад любой обратной связи и идеям, как можно развить систему дальше.
