Примечание: в статье будут приводиться примеры псевдокода.
В этой статье я расскажу, как реализовал систему безопасности для своего пет-проекта - менеджера плагинов. Расскажу, какие задачи пришлось решать, и как получилось встроить защиту без потери гибкости и архитектурной чистоты.
Эта система безопасности скорее любительская - она была создана больше ради интереса и обучения, нежели для реального использования.
Стоит подметить, что я начинающий разработчик, поэтому я не претендую на идеальность принятых мною решений.
Вступление
Контекст
Система безопасности создавалась для моего менеджера плагинов - инструмента, который позволяет динамически загружать плагины из .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);
}
Итог
За время работы над системой безопасности я прошел путь от планирования до полноценной реализации. Главное чему меня научил этот проект - как смотреть на решение не только с точки зрения функциональности, но и с точки зрения потенциальных уязвимостей.
Я считаю что мне удалось удачно внедрить систему безопасности в свой менеджер плагинов - без лишней сложности и загрязнений кода. Плюс ко всему система безопасности получилась довольно гибкой - пользователь целиком может управлять как политиками доступа к ресурсам, так и настройками безопасных сервисов.
Конечно я понимаю, что в системе все еще остается пространство для улучшений - однако потребности моего менеджера плагинов она полностью покрывает.
Спасибо за внимание к моему опыту! Буду рад любой обратной связи и идеям, как можно развить систему дальше.