Материалы этой статьи описывают механизмы компоновки, компрессии, динамической загрузки, а также пути элементарной защиты .NET-сборок стандартными средствами среды разработки Visual Studio. Однако, возможно, нижесказанное будет в некоторой степени справедливо и для других программных платформ.
Для подобных целей создано немало утилит, в том числе платных, но зачастую работают они как чёрные ящики и разработчик лишь в малой степени способен контролировать процесс. Иногда, после компоновки, приложения и вовсе отказываются запускаться, а причину выявить довольно сложно.
Но, обладая определёнными знаниями, можно выполнить весь процесс самостоятельно с полным контролем на каждом шаге и возможностью отладки уже упакованных сборок…
Для начала стоит взглянуть на пример приложения, где применены эти механизмы, кроме защиты, это текстовый редактор Poet. Внешне программа представляет собой единый exe-файл небольшого размера, хотя на самом деле состоит из нескольких библиотек.
Сразу стоит отметить, что в упаковке сборок много плюсов при отсутствии явных минусов:
• множество сборок можно склеить в одну и даже включить всё в исполняемый файл. Приложение становится монолитным и стабильным, ведь никакая из его частей нигде не потеряется.
• эти же сборки легко сжать (заархивировать), после чего файлы станут занимать гораздо меньше места.
• заметно увеличится скорость запуска приложения! Субъективно, Poet стал стартовать в 2-4 раза быстрее на различных машинах, после компоновки всех файлов в один.
• появится примитивная защита от любопытных глаз, то есть, дизассемблировав файл, посторонний не увидит всего кода. Ему потребуется извлечь сборки, для чего необходимы некоторые навыки.
• возникают удобные возможности для более серьёзной защиты…
• упаковка сборок не исключает одновременного использования неупакованных версий, если это для чего-то нужно.
• сохраняется возможность дебажить сборки!
1. Динамическая загрузка сборок в домен приложения
Если файлы сборок добавить в отдельный файл ресурсов, например, Assemblies.resx, то они будут представлять собой просто массив байт. Загрузить их в домен приложения очень просто с помощью небольшого класса AssemblyLoader представленного чуть ниже. Достаточно в нужный момент, обычно при запуске приложения, вызвать
AssemblyLoader.ResolveAssemblies<Assemblies>(AppDomain.CurrentDomain);
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Loader
{
public static class AssemblyLoader
{
public static void ResolveAssembly(this AppDomain domain, string assemblyFullName)
{
try
{
domain.CreateInstance(assemblyFullName, "AnyType");
}
catch (Exception exception)
{
Console.WriteLine(exception.Message);
}
}
public static void ResolveAssemblies<TResource>(this AppDomain domain, Func<byte[], byte[]> converter = null)
{
var assemblies = ResolveAssembliesFromStaticResource<TResource>(converter);
ResolveAssemblies(domain, assemblies);
}
public static void ResolveAssemblies(this AppDomain domain, List<Assembly> assemblies)
{
ResolveEventHandler handler = (sender, args) => assemblies.Find(a => a.FullName == args.Name);
domain.AssemblyResolve += handler;
assemblies.ForEach(a => ResolveAssembly(domain, a.FullName));
domain.AssemblyResolve -= handler;
}
public static void ResolveAssembly(this AppDomain domain, Assembly assembly)
{
ResolveEventHandler handler = (sender, args) => assembly;
domain.AssemblyResolve += handler;
ResolveAssembly(domain, assembly.FullName);
domain.AssemblyResolve -= handler;
}
public static List<Assembly> ResolveAssembliesFromStaticResource<TResource>(Func<byte[], byte[]> converter = null)
{
var assemblyDatyType = typeof (byte[]);
var assemblyDataItems =
typeof (TResource)
.GetProperties(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
.Where(p => p.PropertyType == assemblyDatyType)
.Select(p => p.GetValue(null, null))
.Cast<byte[]>()
.ToList();
var assemblies = new List<Assembly>();
foreach (var assemblyData in assemblyDataItems)
{
try
{
var rawAssembly = converter == null ? assemblyData : converter(assemblyData);
var assembly = Assembly.Load(rawAssembly);
assemblies.Add(assembly);
}
catch (Exception exception)
{
Console.WriteLine(exception.Message);
}
}
return assemblies;
}
}
}
Автор статьи долго не мог понять, каким образом инициировать процесс загрузки сборки в домен. При попытке создания неизвестного типа вместо вызова события domain.AssemblyResolve += handler; просто падали эксепшены, а событие само не вызывалось. А основная магия, как оказалось, находится в одной строке domain.CreateInstance(assemblyFullName, «AnyType»);, в своё время потребовалось несколько дней, проведённых в обнимку с MSDN, чтобы найти на задворках пример, который раскрывал этот секрет.
2. При этом никто не запрещает сжимать или шифровать массивы байт
Для компрессии может оказаться полезным очень простой класс Compressor. Только теперь инициировать загрузку сборок нужно немного по-иному
AssemblyLoader.ResolveAssemblies<Resources>(
AppDomain.CurrentDomain,
bytes => Foundation.Compressor.ConvertBytes(bytes));
using System.IO;
using System.IO.Compression;
namespace Foundation
{
public static class Compressor
{
public static void ConvertFile(string inputFileName, CompressionMode compressionMode, string outputFileName = null)
{
outputFileName = outputFileName ??
(compressionMode == CompressionMode.Compress
? inputFileName + ".compressed"
: inputFileName.Replace(".compressed", string.Empty));
var inputFileBytes = File.ReadAllBytes(inputFileName);
ConvertBytesToFile(inputFileBytes, outputFileName, compressionMode);
}
public static void ConvertBytesToFile(byte[] inputFileBytes, string outputFileName, CompressionMode compressionMode = CompressionMode.Decompress)
{
var bytes = ConvertBytes(inputFileBytes, compressionMode);
File.WriteAllBytes(outputFileName, bytes);
}
public static byte[] ConvertBytes(byte[] inputFileBytes, CompressionMode mode = CompressionMode.Decompress)
{
using (var inputStream = new MemoryStream(inputFileBytes))
using (var outputStream = new MemoryStream())
{
if (mode == CompressionMode.Compress)
using (var convertStream = new GZipStream(outputStream, CompressionMode.Compress))
inputStream.CopyTo(convertStream);
if (mode == CompressionMode.Decompress)
using (var convertStream = new GZipStream(inputStream, CompressionMode.Decompress))
convertStream.CopyTo(outputStream);
var bytes = outputStream.ToArray();
return bytes;
}
}
}
}
Также нам понадобится элементарная консольная утилита для архивирования
using System;
using System.IO.Compression;
using System.Linq;
using Foundation;
namespace FileCompressor
{
class Program
{
private static void Main(string[] args)
{
try
{
args.Where(n => !n.ToLower().Contains(".compressed"))
.ToList()
.ForEach(n => Compressor.ConvertFile(n, CompressionMode.Compress));
args.Where(n => n.ToLower().Contains(".compressed"))
.ToList()
.ForEach(n => Compressor.ConvertFile(n, CompressionMode.Decompress));
}
catch (Exception exception)
{
Console.WriteLine(exception.Message);
Console.ReadKey();
}
}
}
}
Visual Studio позволяет перед компиляцией проекта и после выполнить произвольную командную строку. Это настраивается в свойствах проекта. То есть мы можем настроить всё так, чтобы после сборки библиотеки, файл её сжимался при необходимости (например, нашим архиватором) и автоматически копировался в нужное место (попадал в ресурсы). При правильной настройке всё прекрасно дебажится, поскольку версия, загруженная в домен, соответствует скомпилированной. Это важно, поскольку, если приложение после компоновки перестанет запускаться или возникнут другие проблемы, вы с помощью отладки легко сможете выявить причину и устранить её.
Весь этот процесс настройки, каким бы он ни казался сложным, достаточно провести один раз, а дальше при перекомпиляции всё будет обновляться автоматически без участия разработчика.
В идеале, для десктоп-приложений удобно применять следующую схему: все сборки без сжатия нужно включить в основной исполняемый файл, а потом сделать приложение-загрузчик, в который уже в сжатом виде поместить этот основной исполняемый файл. Сжимать несколько склеенных файлов эффективнее, чем каждый по отдельности.
Все ключи приведены в статье, нужно только разобраться и применить знания в своих проектах. Но если возникнут сложности, то существует возможность купить исходные коды редактора Poet. Кроме данных примеров, в них содержится целый ряд других уникальных и полезных решений. Возможно, для кого-то будет более экономически выгодно подсмотреть что-то там, чем несколько дней или недель искать и разрабатывать решения самому.
3. Теперь мы плавно подошли к возможностям защиты
Сборки представляют собой просто массивы байт, с которыми можно делать, что угодно… И если максимально усложнить алгоритм дешифрации, то постороннему не так-то просто будет вытащить раскодированные сборки. Саму дешифрацию можно вынести в нативный C++ код, или же для веб-приложений получать ключи дешифрации от сервера. Остаются две проблемы — процессы операционной системы можно дебажить или снимать с них дамп памяти.
От дебаггера защититься не сложно
static class AntiDebugger
{
private static int Fails { get; set; }
private static DateTime TimeStamp { get; set; }
public static void Run()
{
TimeStamp = DateTime.Now;
new Thread(ProtectThread).Start();
//new Thread(ProtectThread).Start();
}
private static void ProtectThread()
{
while (true)
{
var now = DateTime.Now;
if (TimeStamp.AddSeconds(5) < now) Fails++;
else
{
TimeStamp = now;
Fails = 0;
}
// One fail may attend when pc wake up from the sleep mode
if (Fails >= 2) InitProtection();
Thread.Sleep(TimeSpan.FromSeconds(3));
}
}
private static void InitProtection()
{
Fails = 0;
Console.WriteLine("Debugger detected!"); //Process.GetCurrentProcess().Kill();
//Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
//for (var i = 0; i < 100; i++)
//{
// new Thread(() => { while (true) ; }) {Priority = ThreadPriority.Highest}.Start();
//}
}
}
Один или пару потоков через заданный интервал проверяют и обновляют значение переменной TimeStamp. Если TimeStamp содержит слишком старое значение, то это указывает на то, что компьютер вышел из спящего режима, перевели часы либо присутствует попытка отладки. Если ошибка повторяется два и более раз, то наиболее вероятен последний вариант, поэтому нужно включить защиту. Можно просто аварийно завершить свой процесс или же даже привести к зависанию операционную систему, просто запустив тысячу и более потоков с бесконечным циклом и высоким приоритетом. Этот трюк выводит систему из строя даже на мощных многоядерных компьютерах, поэтому нужно предусмотреть варианты ложного срабатывания и быть аккуратным.
Подобную технику можно попробовать применить для защиты от снятия дампа памяти, поскольку это не моментальная операция. Только здесь нужно уже вместо потоков использовать хотя бы два процесса, которые следят друг за другом и при остановке одного из них тут же выполняют защитные действия.
Итоги
Надеюсь, что статья окажется разработчикам полезной. Ещё раз повторюсь, что все ключевые моменты описаны в ней хорошо, остаётся только их применить в деле. Если вдруг возникнут трудности, то всегда можно приобрести полные исходные коды редактора Poet с сайта makeloft.by.
P.S. Информация для денежных пожертвований и благодарностей.
P.P.S. Превью-версия свободной библиотеки Foundation Framework.
Автор: Makeman