До последнего байта: минимальный вариант Hello World для .NET

в 21:24, , рубрики: .net, C#, hello world, Занимательные задачки, минификация, низкоуровневое программирование, пет-проект, Программирование

Вот вам тупой вопрос, который вы сами, наверное, никогда себе не задавали. Каково минимальное количество байт, которые необходимо сохранить в исполняемом .NET-файле, чтобы CLR напечатала "Hello, World!" в консоли стандартного вывода?

Насколько сможем уменьшиться?

Насколько сможем уменьшиться?

В этом посте мы исследуем пределы файлового формата модулей .NET. Ужмём модуль, насколько это вообще возможно, но чтобы при этом он остался функционален и работал как обычный исполняемый файл на типичной машине с Windows, где установлен .NET Framework.

Окончательный вариант исходного кода к этому посту выложен на GitHub:

Полный исходный код

Правила

Вот правила, которые я сам для себя установил:

  • В приложении должна применяться управляемая входная точка, реализованная на C# или CIL. Эта входная точка должна отвечать за печать "Hello, World!" в стандартном выводе. Таким образом, мы не вправе проделывать с нативной входной точкой никаких фокусов, подобных тем, что показаны в этом посте. Сам процесс, как именно будет выполняться вывод текста, полностью определяется в теле этого метода.

  • Приложение работает на .NET Framework 4.x.x. Это делается, чтобы немного развязать себе руки, а также позволяет обойтись единственным исполняемым файлом и использовать некоторые возможности загрузчика Windows PE. Кроме того, приятно иметь исполняемый файл, который можно запустить простым двойным щелчком.

  • Никаких сторонних зависимостей. Разрешено ссылаться только на BCL (напр., mscorlib) и/или другие библиотеки, устанавливаемые на типичной машине с Windows. В противном случае было бы можно заменить в нашем маленьком приложении весь код, просто вызвав специально предусмотренную зависимость, а это было бы жульничество!  

  • Игнорируем нулевые байты в конце файла. Формат файлов PE, равно как и сама CLR, жёстко ограничивает выравнивание со сдвигом для каждой секции, хранимой в PE. На практике это означает, что минимально возможный файл .NET PE, который мог бы работать на Windows 10 или выше, не ужать менее чем до 1KB. Как видим, всё это весьма легко достижимо. Чтобы немного усложнить себе задачу, постараемся сделать «абсолютно минимальное описание» PE-файла «Hello World» для .NET, где будем считать, что никаких нулевых байт в хвосте у нас нет.

Итак, приступим!

К чему стремиться

Чтобы сформулировать базис, которого мы постараемся достичь, давайте для начала скомпилируем следующее приложение Hello World. Для этого воспользуемся новейшей (на момент создания этого поста) версией компилятора C#.

	using System;

namespace ConsoleApp1;

internal static class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine("Hello, World!");
    }
}

Присовокупим к этому следующий файл .csproj:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
        <LangVersion>10</LangVersion>
        <Nullable>enable</Nullable>
    </PropertyGroup>

</Project>

В результате у нас получается бинарник размером целых 4,6 КБ:

Размер стандартного приложения hello world.
Размер стандартного приложения hello world.

Как-то это многовато… Разумеется, можно было бы сделать лучше.

Удаляем аннотации ссылок, допускающих null

Изучив приложение в декомпиляторе .NET, начинаем лучше понимать, что же тут происходит. Начиная с версии 8.0 в C# существует такая концепция: ссылочные типы, допускающие null. Это специальные аннотации, по которым компилятор C# может судить о потенциально нежелательных нулевых ссылках, которые могут передаваться функциям, переменным и параметрам. Недостаток таких аннотаций в том, что они реализуются в виде пользовательских атрибутов, а такие атрибуты статически связываются в исполняемом файле и поэтому известны своей огромностью:

Ссылочные типы, допускающие null, привносят в образ .NET множество пользовательских атрибутов

Ссылочные типы, допускающие null, привносят в образ .NET множество пользовательских атрибутов

Давайте отключим их в нашем файле .csproj при помощи одной опции:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
        <LangVersion>10</LangVersion>
        
         <!-- отключаем проверку на ссылочные типы, допускающие null. -->
        <Nullable>disable</Nullable>
    </PropertyGroup>

</Project>

Притом, что нам удалось избавиться от всех атрибутов, но всё равно имеем дело с бинарником в 4,6 КБ. Всё дело в выравнивании файлов в PE.

Вручную создаём модуль .NET

Изучаем вывод декомпилятора далее и видим, что даже с отключёнными ссылками, допускающими null, компилятор C# всё равно выдаёт множество ссылок типов на пользовательские атрибуты в нашем приложении. В частности, они содержат множество атрибутов, присваиваемых самой сборке (таковы, например, метаданные о версии файла и информацию об авторских правах). Кроме того, наряду с классом Program у нас есть скрытый тип <Module>, который выглядит практически пустым:

Компилятор C# по-прежнему выдаёт множество ненужных метаданных

Компилятор C# по-прежнему выдаёт множество ненужных метаданных

Можно было бы попытаться выяснить, как отключить в компиляторе генерацию всех этих метаданных, но я рассудил, что, коль скоро мы стремимся к абсолютному минимуму, мы вполне могли бы сами собрать с нуля исполняемый файл .NET. Так мы полнее контролировали бы итоговый вывод, и компилятор мог бы выдавать только тот минимум, что нужен для печати "Hello World", обходясь без всех этих избыточных файловых метаданных. Ещё: можем просто поместить нашу функцию main в типе <Module>, таким образом также избавившись от класса Program. Ниже в качестве примера дана реализация, в которой мы собираем маленькое приложение Hello World при помощи AsmResolver:

// определяем новую сборку и модуль
var assembly = new AssemblyDefinition("assembly", new Version(1, 0, 0, 0));
var module = new ModuleDefinition("module.exe");
assembly.Modules.Add(module);

// получаем тип <Module>.
var moduleType = module.GetOrCreateModuleType();

// Напишем новый метод Main
var factory = module.CorLibTypeFactory;
var main = new MethodDefinition("main", MethodAttributes.Static, MethodSignature.CreateStatic(factory.Void));
main.CilMethodBody = new CilMethodBody(main)
{
    Instructions =
    {
        {Ldstr, "Hello, World!"},
        {Call, factory.CorLibScope
            .CreateTypeReference("System","Console")
            .CreateMemberReference("WriteLine", MethodSignature.CreateStatic(factory.Void, factory.String))
            .ImportWith(module.DefaultImporter)
        },
        Ret
    }
};

// Добавим main в <Module>
moduleType.Methods.Add(main);

// Зарегистрируем main как входную точку этого модуля:
module.ManagedEntryPointMethod = main;

// Запишем на диск.
module.Write("output.exe");

Мы уже славно поработали, смогли ужать файл наполовину:

Размер приложения hello world, выданного нам AsmResolver

Размер приложения hello world, выданного нам AsmResolver

Но можно сделать ещё лучше…

Избавляемся от импортов и перемещения адресов

Если рассмотреть получившийся у нас исполняемый файл в таком инструменте как CFF Explorer, то в этом файле заметим два раздела: .text и .reloc. Кроме того, в нём ещё содержится два очень больших каталога с данными, которые называются Imports и Base Relocations.

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

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

Такая ситуация весьма типична для любого AnyCPU или 32-разрядного исполняемого файла .NET. Директория с импортами необходима потому, что 32-разрядные исполняемые файлы .NET требуют неуправляемой входной точки под названием mscoree!_CorExeMain, что было рассмотрено в другом посте автора. Более того, по умолчанию исполняемые файлы .NET переносимы, то есть загрузчик Windows PE волен отобразить исполняемый файл на любой подходящий адрес в памяти. Таким образом, каждый 32-разрядный исполняемый файл .NET должен уметь обращаться с перемещением адресов, чтобы вызов к этой импортированной функции мог быть зарегистрирован в директории перемещения. Это проблематично, так как по умолчанию он укладывается в совершенно отдельный раздел. Этот раздел, как и любой другой, должен выравниваться по границе мельчайшего возможного раздела, равного 0x200 байт (1 КБ), поэтому мы раздуваем наш файл, как минимум, на это количество байт.  

К счастью для нас, 64-разрядные исполняемые файлы .NET больше не нуждаются в такой неуправляемой входной точке. Добавив в вышеприведённый скрипт всего две строки, мы можем избавиться от обеих директорий и всего раздела PE и, следовательно, счесать с нашего бинарника ещё целый килобайт:

// Вывод 64-разрядного модуля.
module.PEKind = OptionalHeaderMagic.PE64;
module.MachineType = MachineType.Amd64;
64-разрядные образы .NET не нуждаются ни в импортах, ни в перемещениях адресов

64-разрядные образы .NET не нуждаются ни в импортах, ни в перемещениях адресов

Действительно, мы дошли до теоретически возможного минимального размера в 1 КБ, и у нас по-прежнему рабочий файл .NET PE:

Достигнут минимальный размер PE-файла.

Достигнут минимальный размер PE-файла.

Избавляемся от имён метаданных

На этом можно было бы закончить, но я решил немного глубже закопаться в задачу и посмотреть, что ещё можно было бы отсечь от бинарника и ещё сильнее минифицировать исполняемый файл hello world для .NET. Начиная с этого шага, мы не будем обращать внимания на то, каков размер файла по мнению Windows Explorer. Вместо этого обратимся к hex-редактору, разберёмся, где именно хранятся все до единого ненулевые байты, и уже тогда сможем поговорить об окончательном размере нашего файла. Применительно к тому файлу, который мы исследуем сейчас, уже видно, что нам удалось ужать его до 991 байта (0x3DF):

Интересующий нас размер равен индексу того байта, который следует за последним ненулевым байтом в файле.

Интересующий нас размер равен индексу того байта, который следует за последним ненулевым байтом в файле.

Из чего же складывается остающийся у нас на настоящий момент набор байт? Вновь заглянув в дизассемблер, можем заметить, что куча #Strings в двоичном файле .NET — это второй по размеру набор метаданных, сохранённый в файле. В нём содержатся все имена, применяемые в табличном потоке (#~), а в этом потоке обычно хранят все типы и методы, которые определяет и использует наше приложение. Оказывается, во время выполнения многие из этих имён, в сущности, неважны:

Имена занимают много места

Имена занимают много места

Следовательно, если установим их все в null, то действительно получим приложение, которое имеет примерно следующий вид:

Усечение имён

Усечение имён

Хотите — верьте, хотите — нет, но приложение по-прежнему нормально работает и с готовностью выводит“Hello World”, даже если такой метод выглядит некрасиво. Самое приятное, что таким маневром мы сбрили с нашего файла ещё целых 32 байта:

Размер файла после усечения имён

Размер файла после усечения имён

Избавляемся ещё от некоторых ненужных метаданных

Какие тут ещё есть ненужные метаданные, по поводу которых CLR особенно не беспокоится? Наша следующая цель — избавиться от потока #GUID. Этот поток присутствует практически в любом исполняемом файле .NET и, как понятно из названия, содержит список GUID. Правда, единственный тип метаданных, на которые он ссылается — это таблица Module. В этой таблице есть столбец Mvid; в нём должна стоять ссылка на тот GUID, который служит уникальным идентификатором модуля, позволяя находить его среди различных версий скомпилированных бинарников.

В модуле содержится опциональный MVID, представляющий собой GUID из 16 байт.

В модуле содержится опциональный MVID, представляющий собой GUID из 16 байт.

Версионирование нас не волнует, нам ведь нужен просто минимально возможный бинарник. Соответственно, и от этого компонента можно избавиться и сэкономить ещё 16 байт, которые исходно занимал Mvid. Правда, после такой манипуляции поток #GUID оказывается пуст, и поэтому тоже больше не нужен. Удалив весь этот поток, мы выигрываем ещё 16 байт, из которых состоял его заголовок; суммарно нам удалось выручить на этом шаге 32 байта.

Кроме того, есть метод Console::WriteLine, вызываемый нами в функции Main, он определяется в mscorlib. Как правило, ссылки на сборки BCL аннотируются публичным ключом — это токен размером 8 байт.

Ссылка на mscorlib содержит длинный токен публичного ключа.

Ссылка на mscorlib содержит длинный токен публичного ключа.

Оказывается, что, если в этой ссылке не будет токена публичного ключа, среда CLR просто не станет проверять токен этой сборки на подлинность. Поскольку в рамках этого эксперимента вопросы безопасности нас никак не волнуют, от этого фрагмента также можно избавиться.

Вот у нас получился файл, в котором всего 918 байт:

Размер после удаления GUID и токенов публичного ключа

Размер после удаления GUID и токенов публичного ключа

Избавляемся от Console.WriteLine

Если посмотреть другие потоки метаданных, определяемые в нашей сборке, выяснится, что строка "Hello, World!" хранится у нас довольно неэффективным образом. В .NET все пользовательские строки кладутся в поток метаданных #US в виде массива с префиксом, указывающим его длину; этот массив состоит из символов по 16 разрядов в ширину, а за этими символами следует дополнительный нулевой байт. Такой подход  избран ради поддержки широкого набора символов UNICODE. Однако, все символы в той строке, которую мы хотим вывести на экран, обозначаются кодовым значением с точкой, размер которого менее 255 (0xFF), это максимальное значение, укладываемое в 1 байт. Зачем же нам тогда использовать по 2 байта на символ? Более того, это единственная пользовательская строка, которая понадобится нам в нашем бинарнике. Иметь полновесный 12-байтовый заголовок потока всего для одной строки кажется довольно избыточным:

Пользовательские строки в .NET всегда используют широкую символьную кодировку

Пользовательские строки в .NET всегда используют широкую символьную кодировку

К сожалению, не существует способа превратить эту широкосимвольную строку потока #US в однобайтовую строку ASCII, а также сообщить CLR, чтобы она соответствующим образом её интерпретировала.

Время проявить изобретательность!

Если бы мы хотели ввести на экран строку ASCII там, где предполагается широкосимвольная строка, нам бы понадобилась функция, принимающая строки такого типа. Функция Console::WriteLine не соответствует этому критерию, поэтому от неё нужно избавиться. Зато соответствует неуправляемая функция ucrtbase!puts. В .NET для вызова неуправляемых функций предусмотрена возможность под названием Platform Invoke (P/Invoke). Воспользовавшись P/Invoke, можно определить puts using P/Invoke в C#  следующим образом:

[DllImport("ucrtbase")]
static extern int puts(nint str);

Но здесь есть проблема. Функция puts принимает указатель на строку. Этот указатель должен быть действительным адресом времени исполнения и указывать на строку ASCII, завершаемую нулём, ту, которую мы хотим напечатать. Как нам узнать, где наша строка была сохранена во время компиляции, так, чтобы мы могли задвинуть её в наш метод main?

Оказывается, это решается так: достаточно снять флаг DynamicBase в поле DllCharacteristics опционального заголовка PE. Так мы сможем зафиксировать тот базовый адрес, на который модуль будет отображаться во время выполнения. После этого можем сами определить произвольный базовый адрес, поместить строку ASCII где угодно в нашем разделе .text и вычислить адрес времени выполнения по формуле module_base_address + rva_ascii_string.

var image = module.ToPEImage();

image.ImageBase = 0x00000000004e0000;
image.DllCharacteristics &= ~DllCharacteristics.DynamicBase;

Чтобы заставить CLR соблюдать этот флаг, также нужно убрать флаг ILOnly в директории с данными .NET:

image.DotNetDirectory!.Flags &= ~DotNetDirectoryFlags.ILOnly;

После этого можно просто передать вычисленный адрес как обычное целое число прямо в вызов нашей функции puts:

Заменить Console::WriteLine на ucrtbase!puts, чтобы в данном случае можно было пользоваться строкой ASCII.

Заменить Console::WriteLine на ucrtbase!puts, чтобы в данном случае можно было пользоваться строкой ASCII.

Вот как получается: мы избавились не только от широкосимвольной строки, но и от всего потока #US, а также от ссылки на System.Console::WriteLine, которая также увеличивала размер нашего файла на несколько байт. Правда, несколько байт нам вернулось обратно, так как появилось определение нового обязательного метода puts и связанные с ним метаданные P/Invoke, но это, конечно же, для большой экономии.

И вот мы добрались до 889 байт (0x379):

Размер файла после удаления Console::WriteLine и с использованием ASCII-строк

Размер файла после удаления Console::WriteLine и с использованием ASCII-строк

Другие микро-оптимизации

Есть ещё несколько вещей, которые вполне можно сделать.

Наше определение puts имеет каноническую форму, заданную в библиотеке исполнения C. Таким образом, функция определена в расчёте на возврат числа int32, означающего количество символов, которые были записаны в стандартный вывод. Однако нас это ограничение не интересует. Действительно, в методе main мы выталкиваем это значение прямо после вызова, только чтобы удовлетворить CLR:

Возврат int32 означает, что значение должно вновь всплыть из стека интерпретации.

Возврат int32 означает, что значение должно вновь всплыть из стека интерпретации.

Поскольку это, так или иначе, 64-разрядный файл PE, функция puts будет использовать принятые в x64 соглашения о вызовах, описанное Microsoft. Проще говоря, это означает, что во время выполнения возвращаемое значение на самом деле не проталкивается в стек, как происходило бы при вызовах обычных методов .NET. Напротив, оно записывается в регистр RAX. Поскольку мы никоим образом не используем это значение, мы можем просто превратить определение в void, фактически, закрывая глаза на что угодно, записываемое в этот регистр. Поскольку теперь эта функция больше ничего не возвращает, никакие значения также не задвигаются в стек интерпретации нашего метода main. Соответственно, можем избавиться и от инструкции pop в методе main:

При изменении типа на void инструкция pop нам больше не требуется

При изменении типа на void инструкция pop нам больше не требуется

Также можно немного рациональнее разместить ту строку ASCII, которую мы передаём функции puts. Файлы в формате PE содержат множество сегментов, выравниваемых по определённой байтовой границе. В частности, как уже упоминалось ранее, разделы выравниваются по ближайшему кратному 0x200 (1 КБ). Это касается и первой секции. Однако, заголовки формата PE, содержащиеся в нашем файле, занимают менее 0x200 байт, поэтому оказывается, что между нашими заголовками и первой секцией файла есть участок, занятый заполнителем:

В образах PE присутствует заполнитель — пустые данные, расположенные между заголовками и первой секцией

В образах PE присутствует заполнитель — пустые данные, расположенные между заголовками и первой секцией

Оказывается, что загрузчик Windows PE всегда отображает заголовки PE как участок памяти, доступной для чтения. Нам повезло: сюда же относится и вышеупомянутый заполнитель.

Давайте же переместим туда нашу строку!

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

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

Переместив туда нашу строку, мы фактически ужимаем наш файл ещё на 13 байт.

Поскольку мы также больше не ссылаемся на Console::WriteLine, нам также не требуется хранить в нашем бинарнике ссылку на mscorlib. Это позволит выручить ещё немного пространства, поскольку в таком случае нам придётся хранить в табличном потоке (#~) на одну таблицу меньше. Кроме того, можно удалить и имя mscorlib из потока #Strings.

Мы больше не зависим от "mscorlib", следовательно, и ссылаться на неё нам больше не нужно.

Мы больше не зависим от "mscorlib", следовательно, и ссылаться на неё нам больше не нужно.

На закуску можно попробовать что-то совсем странное. В директории с метаданными .NET содержится поле VersionString, в котором указана минимально необходимая версия .NET Framework, на которой сможет работать этот исполняемый файл. По умолчанию для бинарников .NET 4.0+ здесь содержится строка "v4.0.30319", заполненная нулевыми байтами до ближайшего кратного четвёрки (всего 12 bytes). Однако мы можем обрезать эту строку, оставив только v4.0., то есть убрав ещё 4 байта после заполнителя. Так мы обманом заставим .NET продолжать грузиться до версии CLR 4.0 и сможем успешно запустить программу.

В директории с метаданными .NET содержится строка с указанием версии, в которой записана требуемая версия среды исполнения — эту строку также можно обрезать

В директории с метаданными .NET содержится строка с указанием версии, в которой записана требуемая версия среды исполнения — эту строку также можно обрезать

Обратите внимание: по какой-то причине ведущая точка оказывается важной. Не знаю, почему, но, если удалить из программы хотя бы ещё одну любую мелочь, то она не загрузится.

Итоговый размер получившегося у нас файла — 834 байта (0x342):

Окончательный вид получившегося у нас файла

Окончательный вид получившегося у нас файла

При помощи ZIP его можно заархивировать всего до 476 байт (сравните с 582 байтами – таков был результат архивации, если бы мы не занимались никакой оптимизацией по достижении предела в 1 КБ). Вот на этом я решил закругляться.

Наконец, в доказательство того, что программа по-прежнему работает нормально — вот вам скриншот:

Всё равно работает!

Всё равно работает!

Заключение

Вот таким тупым способом я скоротал субботу.

Хотя этот проект и получился весьма бесполезным, но мне всё равно нравится время от времени прыгать в бездонные кроличьи норы. Всегда интересно тестировать границы «вполне сложившихся» систем, даже если результат труда кажется бесполезным.

Резюмируем: мы смогли пройти от файла Hello World размером 4,6 КБ, собранного компилятором C#, до вручную полученного файла PE размером 834 Б, не считая ведущих нулевых байт. Не думаю, что можно было бы ужать его ещё сильнее, но рад буду ошибиться!

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

Автор:
Sivchenko_translate

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js