Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров593

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

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

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

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

Вступление

Контекст

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

Итог

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

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

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

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

Теги:
Хабы:
+2
Комментарии6

Публикации

Работа

Ближайшие события