Как я создал систему безопасности для плагинов: от идеи до реализации

от автора

Примечание: в статье будут приводиться примеры псевдокода.

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

Эта система безопасности скорее любительская — она была создана больше ради интереса и обучения, нежели для реального использования.

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

Вступление

Контекст

Система безопасности создавалась для моего менеджера плагинов — инструмента, который позволяет динамически загружать плагины из .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); }

Итог

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

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

Конечно я понимаю, что в системе все еще остается пространство для улучшений — однако потребности моего менеджера плагинов она полностью покрывает.

Спасибо за внимание к моему опыту! Буду рад любой обратной связи и идеям, как можно развить систему дальше.


ссылка на оригинал статьи https://habr.com/ru/articles/905232/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *