«Как защитить код своего .net приложение?» – один из тех вопросов, который можно часто услышать на различных форумах.
Самый распространённый вариант – обфускация. С одной стороны — прост в использовании, а с другой — не достаточно надёжно прячет исходники. Предложу свой вариант, хорошо подходящий для утилит, использование которых предполагается самим автором (либо доверенным лицам), код которых показывать нежелательно.
Защита будет строиться на шифровании сборок симметричным ключом и динамическом их дешифровании в процессе работы приложения. Ключ шифрования будет определяться пользователем на этапе развёртывания и вводиться как пароль при запуске.
Разобьём всё на этапы:
- Предварительные работы
- Ввод пароля
- Дешифровка сборок
- Переопределение загрузки сборок
- Запуск приложения
- Вишенка на торте
- Дополнительные настройки проекта
И отдельным пунктом пойдёт:
- Развёртывание и шифрование сборок
Предварительные работы
Поскольку нам необходимо как-то расшифровывать приложение перед запуском, сделаем обёртку, которая возьмёт на себя эту неблагодарную работу.
Обёрткой будет обычное консольное приложение.
Ввод пароля
Вводимый пароль надо где-то хранить. Обычно для этих целей используются строки, но в .net они неизменяемы, а значит, введённый пароль можно без проблем вытащить отладчиком. Чтобы избежать этого, воспользуемся специальным классом SecureString (пространство имён System.Security), который хранит свои данные в зашифрованном виде.
private static bool ReadPassword()
{
ConsoleKeyInfo consoleKey = Console.ReadKey(true);
while (consoleKey.Key != ConsoleKey.Enter)
{
if (consoleKey.Key == ConsoleKey.Escape)
{
return false;
}
_password.AppendChar(consoleKey.KeyChar);
consoleKey = Console.ReadKey(true);
}
return _password.Length > 0;
}
При вводе на экране не будут отображаться вводимые символы, а после нажатия Enter ввод закончится.
_password – поле класса, в котором сохраняется введённый пользователем пароль.
Дешифровка сборок
Шифрование делится на два типа: симметричное и асимметричное. В симметричном для шифрования и дешифрования используется один и тот же ключ, в асимметричном разные.
Поскольку нам разные ключи не нужны, остановимся на симметричном шифровании.
Чтобы расшифровать что-то зашифрованное, нам нужны три компонента:
- Зашифрованные данные;
- Ключ, которым они зашифрованы;
- Вектор инициализации (IV) – несекретные данные, которые использовались для первого этапа шифрования.
Поскольку вектор инициализации не секретен, его можно хранить вместе с зашифрованными данными.
Для облегчения работы напишем специальный класс CryptedData:
public sealed class CryptedData
{
/// <summary>
/// Возвращает или устанавливает вектор инициализации.
/// </summary>
public byte[] IV
{
get;
set;
}
/// <summary>
/// Возвращает или устанавливает данные.
/// </summary>
public byte[] EncryptedSource
{
get;
set;
}
/// <summary>
/// Возвращает вектор инициализации и зашифрованные данные в виде единого массива.
/// </summary>
public byte[] ToArray()
{
using (MemoryStream ms = new MemoryStream())
{
Store(ms);
return ms.ToArray();
}
}
/// <summary>
/// Сохраняет вектор инициализации и зашифрованные данные в поток.
/// </summary>
/// <param name="output">Поток, в который необходимо сохранить данные.</param>
public void Store(Stream output)
{
Validate(this);
if (!output.CanWrite)
{
throw new ArgumentException("В переданный поток запрещена запись", "output");
}
using (BinaryWriter bw = new BinaryWriter(output))
{
bw.Write(IV.Length);
bw.Write(IV);
bw.Write(EncryptedSource.Length);
bw.Write(EncryptedSource);
}
}
/// <summary>
/// Возвращает зашифрованные данные и вектор инициализации.
/// </summary>
/// <param name="input">Поток с входящими данными.</param>
public static CryptedData Create(Stream input)
{
if (!input.CanRead)
{
throw new ArgumentException("Из входящего потока запрещено чтение", "input");
}
CryptedData data = new CryptedData();
using (BinaryReader reader = new BinaryReader(input))
{
int ivLength = reader.ReadInt32();
data.IV = reader.ReadBytes(ivLength);
int sourceLength = reader.ReadInt32();
data.EncryptedSource = reader.ReadBytes(sourceLength);
}
Validate(data);
return data;
}
/// <summary>
/// Проверяет валидность данных.
/// </summary>
/// <param name="data">Данные, которые необходимо проверить.</param>
private static void Validate(CryptedData data)
{
if (data.IV == null || data.IV.Length == 0)
{
throw new ArgumentException("IV должно быть ненулевой длинны");
}
if (data.IV.Length > byte.MaxValue)
{
throw new ArgumentException("Длинна IV не может быть больше " + byte.MaxValue);
}
if (data.EncryptedSource == null || data.EncryptedSource.Length == 0)
{
throw new ArgumentException("Souce должно быть ненулевой длинны");
}
}
}
Шифровать мы будем используя алгоритмом AES. Для удобства создадим низкоуровневую обёртку:
public static class AesCryptography
{
/// <summary>
/// Возвращает вектор инициализации.
/// </summary>
/// <returns></returns>
internal static byte[] CreateIv()
{
using (AesManaged aes = new AesManaged())
{
aes.GenerateIV();
return aes.IV;
}
}
/// <summary>
/// Зашифровывает данные.
/// </summary>
/// <param name="source">Данные.</param>
/// <param name="key">Ключ шифрования.</param>
/// <param name="iv">Вектор инициализации.</param>
/// <returns>Зашифрованные данные.</returns>
internal static byte[] Encrypt(byte[] source, byte[] key, byte[] iv)
{
Validate(source, key, iv);
using (AesManaged aes = new AesManaged())
{
using (ICryptoTransform transform = aes.CreateEncryptor(key, iv))
{
using (MemoryStream ms = new MemoryStream())
{
using (CryptoStream cs = new CryptoStream(ms, transform, CryptoStreamMode.Write))
{
cs.Write(source, 0, source.Length);
}
byte[] encryptedBytes = ms.ToArray();
return encryptedBytes;
}
}
}
}
/// <summary>
/// Расшифровывает текст.
/// </summary>
/// <param name="source">Данные для расшифровки.</param>
/// <param name="key">Ключ шифрования.</param>
/// <param name="iv">Вектор инициализации.</param>
/// <returns>Расшифрованные данные.</returns>
internal static byte[] Decrypt(byte[] source, byte[] key, byte[] iv)
{
Validate(source, key, iv);
using (AesManaged aes = new AesManaged())
{
using (ICryptoTransform transform = aes.CreateDecryptor(key, iv))
{
using (MemoryStream ms = new MemoryStream(source))
{
using (CryptoStream cs = new CryptoStream(ms, transform, CryptoStreamMode.Read))
{
List<byte> bytes = new List<byte>(1024);
int b;
while ((b = cs.ReadByte()) != -1)
{
bytes.Add((byte)b);
}
return bytes.ToArray();
}
}
}
}
}
/// <summary>
/// Проверяет данные.
/// </summary>
/// <param name="source">Данные.</param>
/// <param name="key">Ключ шифрования.</param>
/// <param name="iv">Вектор инициализации.</param>
private static void Validate(byte[] source, byte[] key, byte[] iv)
{
if (source == null)
{
throw new ArgumentNullException("source");
}
else if (source.Length == 0)
{
throw new ArgumentException("Данные не могут быть пустыми", "source");
}
if (key == null)
{
throw new ArgumentNullException("key");
}
else if (key.Length == 0)
{
throw new ArgumentException("Ключ не может быть пустым", "key");
}
if (key.Length.IsOneOf(16, 24, 32) == false)
{
throw new ArgumentException("Длина ключа должна быть 128, 192 или 256 бит (16, 24, 32 байта)", "key");
}
if (iv == null)
{
throw new ArgumentNullException("iv");
}
else if (iv.Length != 16)
{
throw new ArgumentException("Длина вектора инициализации должна быть 128 бит (16 байт)", "iv");
}
}
public static bool IsOneOf<T>(this T value, params T[] values)
{
return value.IsOneOf(values as IEnumerable<T>);
}
public static bool IsOneOf<T>(this T value, IEnumerable<T> values)
{
if (values == null)
{
throw new ArgumentNullException("values");
}
foreach (T t in values)
{
if (Equals(t, value))
{
return true;
}
}
return false;
}
}
Добавим ещё один уровень абстракции, для большей модульности:
internal static class CryptographyHelper
{
/// <summary>
/// Зашифровывает строку.
/// </summary>
/// <param name="source">Исходные данные.</param>
/// <param name="password">Ключ шифрования.</param>
/// <returns></returns>
public static CryptedData Encrypt(byte[] source, SecureString password)
{
byte[] iv = AesCryptography.CreateIv();
byte[] key = GetKey(password);
byte[] encrypted = AesCryptography.Encrypt(source, key, iv);
return new CryptedData()
{
EncryptedSource = encrypted,
IV = iv
};
}
/// <summary>
/// Расшифровывает данные.
/// </summary>
/// <param name="data">Данные, которые необходимо расшифровать.</param>
/// <param name="password">Пароль шифрования.</param>
/// <returns>Расшифрованные данные.</returns>
public static byte[] Decrypt(CryptedData data, SecureString password)
{
byte[] key = GetKey(password);
byte[] decrypted = AesCryptography.Decrypt(data.EncryptedSource, key, data.IV);
return decrypted;
}
/// <summary>
/// Дополняет длину ключа при необходимости.
/// </summary>
/// <param name="key">Ключ, который необходимо дополнить.</param>
/// <returns></returns>
private static byte[] GetKey(SecureString key)
{
using (InsecureString insecure = new InsecureString(key))
{
using (SHA256Managed sha256 = new SHA256Managed())
{
byte[] rawKey = new byte[key.Length];
int i = 0;
foreach (char c in insecure)
{
rawKey[i++] = Convert.ToByte(c);
}
byte[] hashedKey = sha256.ComputeHash(rawKey);
Array.Clear(rawKey, 0, rawKey.Length);
return hashedKey;
}
}
}
}
В последнем методе – GetKey – присутствует немного магии.
Первый момент – длина ключа должна быть равна 128, 192 или 256 битам. А в качестве пароля для запуска может быть строка произвольной длины. Поэтому просто хешируем строку пароля с помощью sha256 и получаем искомую длину.
Вторая магия покруче и связана с SecureString. Этот класс доступен только на запись, и чтобы получить его содержимое нам необходимо воспользоваться небезопасным (unsafe) кодом:
[CLSCompliant(false)]
public sealed class InsecureString : IDisposable, IEnumerable<char>
{
internal InsecureString(SecureString secureString)
{
_secureString = secureString;
Initialize();
}
public string Value { get; private set; }
private readonly SecureString _secureString;
private GCHandle _gcHandle;
#if !DEBUG
[DebuggerHidden]
#endif
private void Initialize()
{
unsafe
{
// We are about to create an unencrypted version of our sensitive string and store it in memory.
// Don't let anyone (GC) make a copy.
// To do this, create a new gc handle so we can "pin" the memory.
// The gc handle will be pinned and later, we will put info in this string.
_gcHandle = new GCHandle();
// insecurePointer will be temporarily used to access the SecureString
IntPtr insecurePointer = IntPtr.Zero;
RuntimeHelpers.TryCode code = delegate
{
// create a new string of appropriate length that is filled with 0's
Value = new string((char)0, _secureString.Length);
// Even though we are in the ExecuteCodeWithGuaranteedCleanup, processing can be interupted.
// We need to make sure nothing happens between when memory is allocated and
// when _gcHandle has been assigned the value. Otherwise, we can't cleanup later.
// PrepareConstrainedRegions is better than a try/catch. Not even a threadexception will interupt this processing.
// A CER is not the same as ExecuteCodeWithGuaranteedCleanup. A CER does not have a cleanup.
Action alloc = delegate { _gcHandle = GCHandle.Alloc(Value, GCHandleType.Pinned); };
ExecuteInConstrainedRegion(alloc);
// Even though we are in the ExecuteCodeWithGuaranteedCleanup, processing can be interupted.
// We need to make sure nothing happens between when memory is allocated and
// when insecurePointer has been assigned the value. Otherwise, we can't cleanup later.
// PrepareConstrainedRegions is better than a try/catch. Not even a threadexception will interupt this processing.
// A CER is not the same as ExecuteCodeWithGuaranteedCleanup. A CER does not have a cleanup.
Action toBSTR = delegate { insecurePointer = Marshal.SecureStringToBSTR(_secureString); };
ExecuteInConstrainedRegion(toBSTR);
// get a pointer to our new "pinned" string
char* value = (char*)_gcHandle.AddrOfPinnedObject();
// get a pointer to the unencrypted string
char* charPointer = (char*)insecurePointer;
// copy
for (int i = 0; i < _secureString.Length; i++)
{
value[i] = charPointer[i];
}
};
RuntimeHelpers.CleanupCode cleanup = delegate
{
// insecurePointer was temporarily used to access the securestring
// set the string to all 0's and then clean it up. this is important.
// this prevents sniffers from seeing the sensitive info as it is cleaned up.
if (insecurePointer != IntPtr.Zero)
{
Marshal.ZeroFreeBSTR(insecurePointer);
}
};
// Better than a try/catch. Not even a threadexception will bypass the cleanup code
RuntimeHelpers.ExecuteCodeWithGuaranteedCleanup(code, cleanup, null);
}
}
#if !DEBUG
[DebuggerHidden]
#endif
public void Dispose()
{
unsafe
{
// we have created an insecurestring
if (_gcHandle.IsAllocated)
{
// get the address of our gchandle and set all chars to 0's
char* insecurePointer = (char*)_gcHandle.AddrOfPinnedObject();
for (int i = 0; i < _secureString.Length; i++)
{
insecurePointer[i] = (char)0;
}
#if DEBUG
string disposed = "¡DISPOSED¡";
disposed = disposed.Substring(0, Math.Min(disposed.Length, _secureString.Length));
for (int i = 0; i < disposed.Length; ++i)
{
insecurePointer[i] = disposed[i];
}
#endif
_gcHandle.Free();
}
}
}
public IEnumerator<char> GetEnumerator()
{
if (_gcHandle.IsAllocated)
{
return Value.GetEnumerator();
}
else
{
return new List<char>().GetEnumerator();
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private static void ExecuteInConstrainedRegion(Action action)
{
RuntimeHelpers.PrepareConstrainedRegions();
try
{
}
finally
{
action();
}
}
}
Что происходит в этом коде?
- Подготавливаются два куска кода, один основной, который делает всю работу, второй – код очистки в случае исключения;
- Основной код создаёт новую строку, в которой будет храниться значение SecureString и которая будет принудительно очищена в методе Dispose;
- Копирует данные из SecureString во внутреннюю строку через указатели и блокирует внутреннюю строку для сборщика мусора;
- Через внутреннюю строку мы можем получить данные SecureString.
В методе Dispose происходит затирание внутренней строки через указатели.
Важно держать «окно жизни» экземпляра InsecureString как можно более коротким, чтобы минимизировать риск чтения данных защищённой строки отладчиком.
Хеширование, описанное выше, помогает в этом, поскольку экземпляр InsecureString нам нужен только для получения хеша, а дальше мы работаем с самим хешем, из которого не вытащить первоначальное значение SecureString.
Переопределение загрузки сборок
Поскольку мы планируем использовать зашифрованные сборки, нам нужно поменять стандартный механизм их загрузки.
За загрузку сборок отвечает домен приложения (AppDomain), через специальное событие AssemblyResolve.
/// <summary>
/// Обработчик подгрузки сборки.
/// </summary>
private static Assembly CurrentDomainOnAssemblyResolve(object sender, ResolveEventArgs args)
{
string[] fileParts = args.Name.Split(",".ToCharArray());
string assemblyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileParts[0] + ".edll");
string symbolsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileParts[0] + ".epdb");
byte[] assemblyBytes = null, symbolsBytes = null;
if (File.Exists(assemblyPath))
{
assemblyBytes = DecryptFile(assemblyPath);
}
if (File.Exists(symbolsPath))
{
symbolsBytes = DecryptFile(symbolsPath);
}
return Assembly.Load(assemblyBytes, symbolsBytes);
}
/// <summary>
/// Расшифровывает файл.
/// </summary>
/// <param name="path">Путь к файлы.</param>
/// <returns>Расшифрованные данные файла.</returns>
private static byte[] DecryptFile(string path)
{
CryptedData data;
using (FileStream fs = File.OpenRead(path))
{
data = CryptedData.Create(fs);
}
byte[] bytes = CryptographyHelper.Decrypt(data, _password);
return bytes;
}
Поскольку к моменту входа в Main они могут уже понадобиться, переопределять механизм будем раньше, в конструкторе типа. Там же, для удобства, прикрутим обработку исключений:
static Program()
{
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) => Console.WriteLine(eventArgs.ExceptionObject.ToString());
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainOnAssemblyResolve;
_password = new SecureString();
}
Запуск приложения
С запуском всё совсем просто:
private static void RunApplication()
{
SetConsoleWindowVisibility(false);
App app = new App();
MainWindow window = new MainWindow();
app.Run(window);
}
Вишенка на торте
Осталось два момента:
- Скрывать окно консоли перед появлением приложения;
- Маскировать само наличие приложения
Сокрытие окна консоли
Нам понадобится импортировать пару неуправляемых методов
[DllImport("user32.dll")]
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll")]
static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
и скрыть само окно
/// <summary>
/// Устанавливает видимость окна консоли.
/// </summary>
/// <param name="visible">Видимость окна консоли.</param>
private static void SetConsoleWindowVisibility(bool visible)
{
IntPtr hWnd = FindWindow(null, Console.Title);
if (hWnd != IntPtr.Zero)
{
if (visible)
ShowWindow(hWnd, 1); //1 = SW_SHOWNORMAL
else
ShowWindow(hWnd, 0); //0 = SW_HIDE
}
}
Маскировка приложения
Мы можем замаскировать наше приложение, выдав ошибку сразу после запуска
[STAThread]
public static void Main(string[] args)
{
//Выводит фейковое сообщение об ошибке
try
{
ArgumentException ex = new ArgumentException("There is not enough data to start application");
throw ex;
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.ToString());
Console.WriteLine("Press Esc to exit");
}
if (!ReadPassword())
return;
RunApplication();
}
Понятно, что против дизассемблера такое не сработает, но праздношатающихся отведёт.
Дополнительные настройки проекта
Для корректной работы, в сборке загрузчика должен быть разрешён небезопасный код. Проще всего это сделать через настройки проекта.
Развёртывание и шифрование сборок
Нам надо зашифровать сборки сразу после компиляции. Напишем пару функций для этого:
/// <summary>
/// Зашифровывает сборки и удаляет оригинальные файлы.
/// </summary>
private static void EncryptAssemblies()
{
Wiper wiper = new Wiper();
foreach (string file in Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll"))
{
byte[] source = File.ReadAllBytes(file);
CryptedData crypted = CryptographyHelper.Encrypt(source, _password);
string resultPath = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file) + ".edll");
File.WriteAllBytes(resultPath, crypted.ToArray());
//удаляем оригинальную сборку
wiper.WipeFile(file, 3);
//File.Delete(file);
}
string currentAssemblyName = Assembly.GetEntryAssembly().GetName().Name;
foreach (string file in Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.pdb"))
{
if (Path.GetFileNameWithoutExtension(file) == currentAssemblyName)
continue;
byte[] source = File.ReadAllBytes(file);
CryptedData crypted = CryptographyHelper.Encrypt(source, _password);
string resultPath = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file) + ".epdb");
File.WriteAllBytes(resultPath, crypted.ToArray());
//удаляем оригинальную сборку
wiper.WipeFile(file, 3);
}
}
Для того, чтобы затереть оригинальный файл, воспользуемся вспомогательным классом Wiper, который в несколько проходов перезаписывает файл случайными данными, а затем удаляет.
internal sealed class Wiper
{
/// <summary>
/// Deletes a file in a secure way by overwriting it with
/// random garbage data n times.
/// </summary>
/// <param name="filename">Full path of the file to be deleted</param>
/// <param name="timesToWrite">Specifies the number of times the file should be overwritten</param>
public void WipeFile(string filename, int timesToWrite)
{
if (File.Exists(filename))
{
// Set the files attributes to normal in case it's read-only.
File.SetAttributes(filename, FileAttributes.Normal);
// Calculate the total number of sectors in the file.
double sectors = Math.Ceiling(new FileInfo(filename).Length / 512.0);
// Create a dummy-buffer the size of a sector.
byte[] dummyBuffer = new byte[512];
// Create a cryptographic Random Number Generator.
// This is what I use to create the garbage data.
using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
{
// Open a FileStream to the file.
FileStream inputStream = new FileStream(filename, FileMode.Open);
for (int currentPass = 0; currentPass < timesToWrite; currentPass++)
{
// Go to the beginning of the stream
inputStream.Position = 0;
// Loop all sectors
for (int sectorsWritten = 0; sectorsWritten < sectors; sectorsWritten++)
{
// Fill the dummy-buffer with random data
rng.GetBytes(dummyBuffer);
// Write it to the stream
inputStream.Write(dummyBuffer, 0, dummyBuffer.Length);
}
}
// Truncate the file to 0 bytes.
// This will hide the original file-length if you try to recover the file.
inputStream.SetLength(0);
// Close the stream.
inputStream.Close();
// As an extra precaution I change the dates of the file so the
// original dates are hidden if you try to recover the file.
DateTime dt = new DateTime(2037, 1, 1, 0, 0, 0);
File.SetCreationTime(filename, dt);
File.SetLastAccessTime(filename, dt);
File.SetLastWriteTime(filename, dt);
File.SetCreationTimeUtc(filename, dt);
File.SetLastAccessTimeUtc(filename, dt);
File.SetLastWriteTimeUtc(filename, dt);
// Finally, delete the file
File.Delete(filename);
}
}
}
}
Послесловие
Очевидно слабое места подобной защиты – необходимость знать пароль каждому, кто будет пользоваться приложением.
Также важно понимать, код сборок можно достать дизассемблером в процессе использования приложения.
Но если есть необходимость скрыть то, что делает утилита, которой вы сами пользуетесь, от посторонних глаз, такой подход себя оправдывает.
Материалы
Автор: hmspns