Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
![](https://habrastorage.org/getpro/habr/upload_files/54d/582/542/54d58254239e3fd092be70bd4e23e57b.png)
Написание кода на любом языке программирования сопряжено с исследованием особенностей, которые позволяют быстро и эффективно написать алгоритм. В этой статье попробуем рассказать чуть больше о структуре файлов, которые получаются в результате компиляции приложений, написанных на языке программирования C#.
Исполняемые файлы Windows
Определимся с областью изучения. C# практически намертво спаян с операционной системой Windows и её подсистемой .NET, хотя и есть версия .NET Core, которая предпринимает попытки стать кроссплафторменной. В нашей статье будем говорить о той части, которая относится именно к Windows.
Итак, любой файл, который будет получаться после компиляции кода на C#, это нативный исполняемый файл PE или PE+ (зависит от выбранной разрядности процессора). Грубо говоря, создание именно исполняемого файла — это просто способ заставить операционную систему воспринимать приложения для .NET как нативное приложение, написанное на таких языках как C++/ASM и т.д.
Сам по себе полученный исполняемый файл можно описать как набор служебных данных — заголовков
и полезных данных — секций
. Общую структуру файла очень здорово описал хабровчанин здесь. Труд был создан еще в далеком 2015 году, но не потерял своей актуальности. Для полноты картины, которую придется изучать под микроскопом, приведу общий и сокращенный вид того самого исполняемого файла:
![](https://habrastorage.org/getpro/habr/upload_files/462/126/8c0/4621268c05a574b56b74d924ae133de4.png)
Представленный выше файл является шаблоном, который должны создавать компиляторы, если необходимо запускать приложение. Стоит сказать, что такую структуру в операционной системе используют все исполняемые файлы, перечислим наиболее часто встречаемые их типы:
sys — файлы драйверов
exe — файлы приложений
dll — расшариваемые библиотеки и драйвера
cpl — апплеты, которые могут использоваться для расширения функционала приложений в Windows. Для примера можно набрать WIN+R и вести control.cpl появится интерфейс для настройки системы.
...
Но как операционная система понимает, к какому типу файлов в итоге отнести, то что отдает пользователь или зовет приложение? Ответ очень прост, в структуре PE файла существует ряд полей, которые могут быть использованы для того чтобы определить что делать с файлом. Вот некоторые из них:
Characteristics
его описание можно найти тутSubsystem
, его описание так же можно обнаружить по ссылке выше
Оба этих поля дают возможность понять, какой тип файла придется разбирать и какая подсистема Windows отвечает за его работоспособность. Да, в Windows рекомендованный путь разработки — переиспользование библиотек, которые предоставляются сразу с операционной системой. Наборы библиотек объединяются в подсистемы, одна из них как раз платформа .NET. Кстати, для нее константы не найдете в документации, её там нет.
Для примера сравним 2 файла: первый создан с помошью компилятора для C++, а второй — компилятором C#. (Естественно, для тестов будем брать компиляторы Visual Studio).
Так как файл PE — это бинарный формат, поэтому нам придется прибегнуть к использованию софта, умеющего его разбирать. Для скрина будем использовать Explorer Suite.
![](https://habrastorage.org/getpro/habr/upload_files/a96/3e7/4c0/a963e74c0423907f64a044b3029cc061.png)
На картинке представлены 2 исполняемых файла, слева для создания использовался C#, справа C++. Рассматривать будем в первую очередь части, которые относятся именно к служебной информации, а именно заголовки. Как видно, параметры одинаковые. Что наталкивает на мысль — "А как же операционка понимает, что это .NET?". Ответ стоит искать в секции, которая во многих компиляторах используется для заточения кода, который и будет выполнять алгоритм приложения. Называется эта секция text
. В обычном случае в этой секции находится только код и константы, которые он использует, однако если приложение написано для .NET, то в начале секции будет дополнительный набор служебных данных. Здесь их называют стримами и они содержат в структуре данные, которые могут описать всё, чем пользуется платформа:
#~ — стрим метаданных, тут все вперемешку: данные о типах, методах и т.д.
#Strings — строки, которые содержат информацию из пространств имен их членов и имен
#US — строки, которые были созданы программистом и используются в методах
#GUID — GUID данные, которые используются в приложении
#Blob — по сути, сырые данные, на которые могут ссылаться другие стримы
Вот такая матрешка. Все это парсится автоматически операционной системой и в момент, когда все разобрано, запускается платформа .NET. Процесс запуска тоже реализован весьма интересно. .NET это по сути виртуальная машина, которая понимает команды, отличные от того, что привык разбирать железный процессор современных устройств. Поэтому, чтобы выполнить код, в каждый .NET файл вставляется специальный трамплин, который запускает начальную функцию виртуальной мащины .NET. Если открыть исполняемые файлы в дизассемблерных приложениях и перевести курсор на первые команды, например в Hiew, то можно увидеть вот такие команды. Слева приложение написанное на C#, справа приложение созданное на C++:
![](https://habrastorage.org/getpro/habr/upload_files/f58/f3a/7dd/f58f3a7dd968f9e6bfd5596bfb2087cb.png)
Как видно, .NET даже понятнее, чем то, что писалось на C++ для чтения невооруженным глазом. Теперь о грустном. По причине такой структуры C# исполняемого файла, вернуть исходный читаемый код из уже созданного приложения очень просто. Для этого можно использовать любой декомпилятор, например dnSpy. Декомпилятор вернет все методы и их названия и даже можно будет с этим файлом работать из-под отладчика. Слева исходный код приложения, справа декомпилированная версия.
![](https://habrastorage.org/getpro/habr/upload_files/71c/dbb/684/71cdbb684de11d059984674ad1589c9e.png)
С одной стороны, такое положение вещей очень удобно. Не всегда надо обращаться к настоящим исходникам и можно искать и исправлять проблемы, но это так же открывает большой простор для нелегитимного переиспользования кода. Как это исправить? Общий рецепт может не понравиться тем, кто любит писать стабильный код — использование методов обфускации, шифрования и упаковки.
В современном мире все подобные подходы реализуются так называемыми протекторами и некоторыми пакерами. Попробуем реализовать такой обфускатор и поищем возможные варианты запаковывания результирующего файла.
Способы защиты приложений
Для защиты алгоритма приложений можно разделить на несколько подходов, причем каждый из них можно развивать до совершенства. Различают несколько видов защиты:
запутывание кода путем изменения имен функций — понять алгоритм из чтения такого исходника проблематично, но возможно
запутывание кода путем добавления мусорных конструкций. Добавленные конструкции занимают процессорное время, но никак не влияют на конечный результат
запутывание алгоритма бесконечными безусловными и условными прыжками - серьезно запутывает задачу отладки
У этих подходов есть общий термин — обфускация.
шифрование частей исполняемого файла или самых важных с точки зрения алгоритма частей приложения
упаковка частей исполняемого файла или самых важных с точки зрения алгоритма частей
Отличие последних двух пунктов заключается в том, что при шифровании основная задача не дать прочесть зашифрованные данные и без знания ключа алгоритма ничего нельзя выполнить, а в случае упаковки основная задача алгоритма уменьшение размера данных.
Эти подходы можно объединить. Попробуем создать приложение, которое позволит использовать хотя бы один способ зашиты — переименование методов.
Mono.cecil
Применение методов защиты возможно на разных этапах создания программного обеспечения. Считается, что самый эффективный способ это интеграция защиты в исходный код. Но для этого подхода нужно создавать целые библиотеки функций, которые самостоятельно будут работать с низкоуровневым представлением файла и команд приложения. Процесс может затянуться, поэтому будем реализовывать подход с преобразованием уже скомпилированного приложения. В качестве инстумента будем использовать проект, который позволяет программно работать с уже созданными структурами .NET приложения.
Библиотека выбрана не случайно, именно при помощи ее создаются приложения, которые декомпилируют уже собранные проекты. Почему это возможно? .NET приложение в результате компиляции представляет собой набор команд, которые написаны на специальном ассемблере — IL. Конструкции этого ассемблера напрямую могут быть связаны с конструкциями языков программирования .NET платформы. Поэтому достаточно просто получать исходный код приложений после компиляции. Так же, кстати, можно и переводить приложения автоматически на другие языки платформы.
Для использования библиотеки можно загрузить NuGet пакет, для этого достаточно прописать Mono.Cecil в поиске и установить пакет для использования в проекте.
Для преобразований будем использовать простую программу:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TestApp2
{
internal class Program
{
static int sumTwoNumbers(int a, int b)
{
return a + b;
}
static int multiple(int a, int b)
{
return a * b;
}
static void Main(string[] args)
{
Console.WriteLine(sumTwoNumbers(4, 3));
Console.WriteLine(multiple(4, 3));
}
}
}
Приложение для выполнения обфускации может выглядеть так:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Mono.Cecil;
namespace TestApp
{
internal class Program
{
private static Random random = new Random();
public static string RandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[random.Next(s.Length)]).ToArray());
}
static void Main(string[] args)
{
AssemblyDefinition asm = AssemblyDefinition.ReadAssembly("TestApp2.exe");
foreach (TypeDefinition t in asm.MainModule.Types)
{
if (t.Name == "Program")
{
foreach (MethodDefinition m in t.Methods)
{
if (m.Name == "sumTwoNumbers" || m.Name == "multiple")
{
m.Name = RandomString(5);
}
}
}
}
asm.Write("TestApp2_obf.exe");
}
}
}
Смотрим, что получилось через dnspy: слева исходный вид, справа пропатченная версия:
![](https://habrastorage.org/getpro/habr/upload_files/5f8/33c/57d/5f833c57db9e89346fa2b91eee886650.png)
Приложения, как и ожидалось, работают одинаково, однако стоит упомянуть, что для корректного запуска такого приложения лучше пользоваться Release версией, там нет отладочных вставок, которые могут аварийно завершать исправленный файл.
Исправление названий коснулось только функций, которые были созданы программистом, при желании можно включить и стандартные имена. Продолжить изменение можно переключившись на названия переменных. Таким образом можно минимально усложнить восстановление алгоритма приложения.
Статью написал Александр Колесников в преддверии старта курса «C# Developer. Professional».
Всех желающих приглашаем на бесплатное demo-занятие «Что полезного в новых версиях C#?». На этом открытом занятии мы разберем ключевые нововведения .Net 4.8 и познакомимся с полезными и часто используемыми новшествами .Net 4.7.2. Если интересно, записывайтесь.