Post

How small is the smallest .NET Hello World binary?

Here is a dumb question that you probably never asked yourself: What is the minimal amount of bytes we need to store in a .NET executable to have the CLR print the string"Hello, World!" to the standard output?

How small can we get?

In this post, we will explore the limits of the .NET module file format, get it as small as possible, while still having it function like a normal executable on a typical Windows machine with the .NET Framework installed.

The final source code for this post can be found on my GitHub:

Full Source Code

Rules

Here are the arbitrary rules I set up for myself:

  • The application must run a managed entry point implemented in C# or CIL. This entry point must be responsible for printing "Hello, World!" to the standard output. This means we cannot do any of the native entry point shenanigans like we did in a previous post. How it actually does the printing, however, is fully up to this method body.

  • The application runs on .NET Framework 4.x.x. We do this to give ourselves a little bit more freedom, and it allows us to have a single executable only and leverage some of the features of the Windows PE loader. It is also nice to have an executable that we can just double click.

  • No third-party dependencies. We are only allowed to reference the BCL (i.e., mscorlib) and/or other libraries that are installed on a typical Windows machine. Otherwise, we could replace all code within our small application with a call to a custom-made dependency, which would be cheating!

  • Ignore zero bytes at the end of the file. The PE file format, as well as the CLR itself, puts a hard limit on offset alignments for each section stored in the PE. Effectively it means that the theoretically smallest .NET PE that is able to run on Windows 10 or higher cannot be smaller than 1KB. As we will see this is rather easy to achieve. To challenge ourselves a bit more, we strive to get to the “bare minimum description” of a .NET hello world PE file, where we consider all trailing zero bytes as non-existent.

Let’s get hacking!

Establishing a baseline

To establish a baseline that we want to beat, let’s first start by compiling the following Hello World application using the latest version of the C# compiler by the time of writing this post.

1
2
3
4
5
6
7
8
9
10
11
using System;

namespace ConsoleApp1;

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

We accompany it with the following .csproj file:

1
2
3
4
5
6
7
8
9
10
<Project Sdk="Microsoft.NET.Sdk">

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

</Project>

This gives us a binary of a whopping 4.6KB file:

The size of a standard hello world application.

That seems excessive… Clearly we can do better than this.

Removing nullable reference annotations

Inspecting the application in a .NET decompiler gives us a bit more insight on what is going on. Since C# 8.0 we have known the concept of nullable reference types. These are special annotations that allows the C# compiler to reason about potentially unwanted null references to be passed on to functions, variables and parameters. The downside is that these annotations are implemented in the form of custom attributes, which are linked into the executable statically and notoriously large:

Nullable Reference Types add many Custom Attributes to a .NET image

Let’s disable that with one option in our .csproj file:

1
2
3
4
5
6
7
8
9
10
11
12
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
        <LangVersion>10</LangVersion>
        
         <!-- Disable nullable reference type checks. -->
        <Nullable>disable</Nullable>
    </PropertyGroup>

</Project>

While this does get rid of all the attributes, we are unfortunately still left with a binary that is 4.6KB in size, due to the PE file alignments.

Manually crafting a .NET module

Further inspecting the output in a decompiler shows that, even with nullable references disabled, the C# compiler still emits many type references to custom attributes in our application. In particular, they include many attributes assigned to the assembly itself, such as file version metadata and copyright information. Additionally, besides our class Program we also have a hidden <Module> type that looks rather empty:

The C# compiler still emits a lot of unnecessary metadata

We could try and figure out how to instruct the compiler to disable generating all this metadata, but I figured, if we are going to the extreme, we may as well just build a .NET executable file from scratch by ourselves. This way we have more control over the final output, allowing us to just emit the bare minimum that is required to print "Hello World", and not emit those unnecessary file metadata attributes. Furthermore, we can just place our main function into the <Module> type and get rid of our Program class as well. Below is an example implementation of building a small Hello World application using AsmResolver:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Define new assembly and module.
var assembly = new AssemblyDefinition("assembly", new Version(1, 0, 0, 0));
var module = new ModuleDefinition("module.exe");
assembly.Modules.Add(module);

// Obtain <Module> type.
var moduleType = module.GetOrCreateModuleType();

// Craft a new Main method.
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
    }
};

// Add main to <Module>
moduleType.Methods.Add(main);

// Register main as the entry point of the module:
module.ManagedEntryPointMethod = main;

// Write to disk.
module.Write("output.exe");

This did a great deal already, we cut our file size in half:

The size of a hello world application emitted by AsmResolver.

But we can do better…

Getting rid of Imports and Base Relocations

If we look into the resulting executable file using a tool like CFF Explorer, we can see that the file contains two sections .text and .reloc. Furthermore, it also contains two very large data directories called the Imports and Base Relocations directory respectively.

By default, 32-bit .NET images contain imports and base relocations that take a lot of space.

This is pretty typical for any AnyCPU or 32-bit .NET executable file. The imports directory is required because 32-bit .NET executable files require an unmanaged entry point calling mscoree!_CorExeMain, as we have seen in a previous post. Furthermore, by default .NET executables are relocatable, that is, the Windows PE Loader is free to map the executable at any memory address it likes. This means every 32-bit .NET executable also needs a base relocation for the call to this imported function to be registered in the relocation directory. This is problematic, because it is by default put in a fully separate section. As every section needs to be aligned to the smallest possible section alignment of 0x200 bytes (1KB), we inflate our file by at least that amount of bytes just because of that.

Fortunately for us, 64-bit .NET executables do not need such an unmanaged entry point anymore. With just two extra lines added to our previous script, we can get rid of both directories, an entire PE section, and thus shave off an entire kilobyte worth of data from our binary file:

1
2
3
// Output a 64-bit module.
module.PEKind = OptionalHeaderMagic.PE64;
module.MachineType = MachineType.Amd64;

64-bit .NET images do not need imports or base relocations.

And indeed, we get to the theoretically possible minimum size of 1KB of a valid .NET PE file:

The minimum size of a PE file is reached.

Getting rid of metadata names

We could have called it quits here, but I decided to look a little bit deeper into what really we can strip out of a binary to get to the absolute bare minimum of a .NET hello world executable. From now on, we won’t be looking at the file size as reported by Windows Explorer. Instead, we will be looking at a hex editor and see where the last non-zero byte is stored and consider that to be our final file size. If we do this for our current file, we can actually see we are already down to a size of 991 bytes (0x3DF):

The size we will be considering is the index of of the byte after the last non-zero byte in the file.

What is still contributing to this amount of bytes? If we look again in a disassembler, we can see that the #Strings heap in a .NET binary is the second largest metadata stream stored in the file. It contains all the names that the tables stream (#~) uses, which stores all the types and methods that our application defines and uses. As it so turns out, many of these names are actually not really important to the runtime:

Names take up a lot of space.

Thus, setting these to null instead will give us an application that looks a bit like the following:

Truncating names.

Believe it or not, the application still runs fine and happily outputs “Hello World”, regardless of whether this looks fine or not. Best of all, it shaved a whopping 32 bytes from our file:

The size of the file after stripping names.

Getting rid of more unnecessary metadata

What other unnecessary metadata is there that the CLR does not really care about?

Our next target is getting rid of the #GUID stream. This stream is present in virtually any .NET executable, and contains, as its names implies, a list of GUIDs. However, the only type of metadata that really references it, is the Module table. This table has a column called Mvid, which is supposed to reference a GUID that makes the module uniquely identifiable across different versions of compiled binaries.

A module contains an optional MVID, which is a GUID of 16 bytes.

We do not care about versioning, we just want the smallest binary possible. We can just get rid of it and save 16 bytes that were originally making up the Mvid. However, by doing so, the #GUID stream is now empty and thus is no longer needed. By removing the stream in its entirety, we save another 16 bytes that make up its header, making a total of 32 bytes that we save with this.

Additionally, the Console::WriteLine method that we call in our Main function is defined in mscorlib. Typically, references to BCL assemblies are annotated with a public key token of 8 bytes.

The reference to mscorlib contains a long public key token.

It so turns out that if there is no public key token present in this reference, the CLR then just does not verify this assembly token for authenticity. Since we do not care about security anyways in our experiment, we can get rid of this too.

This brings us down to a file of 918 bytes in total:

The size after stripping GUIDs and public key tokens.

Getting rid of Console.WriteLine

If we look at other metadata streams defined in our assembly, we find that our "Hello, World!" string is actually stored in a rather inefficient manner. In .NET, all user strings are put in the #US metadata stream as a length-prefixed array of 16-bit wide characters followed by an additional zero byte. This is done to support a wide range of the UNICODE character set. However, all the characters in the string that we want to print have a code-point value smaller than 255 (0xFF), the max value of a single byte. Why should we then use 2 bytes per character? Furthermore, this is the only user string that we need in our binary. Having an entire 12-bytes stream header for just one string seems rather excessive:

User strings in .NET always use wide character encoding.

Unfortunately, there is no way turn this wide-character string in the #US stream to a single-byte ASCII string, and to tell the CLR to interpret it as such.

Time to get creative!

If we want to print an ASCII string as opposed to a wide-character string, we need a function that accepts those types of strings. Console::WriteLine is not a function that fits this criterium, so we need to get rid of it. However, the unmanaged function ucrtbase!puts does. .NET allows for invoking unmanaged functions by using a feature called Platform Invoke (P/Invoke). We can define puts using P/Invoke in the following manner in C#:

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

However, there is a problem. The puts function accepts a pointer to a string. This pointer must be a valid runtime address that points to the start of the zero-terminated ASCII string that we want to print. How do we know where our string is stored at compile-time so that we can push it in our main method?

It so turns out we can solve this by unchecking the DynamicBase flag in the DllCharacteristics field of the PE’s optional header. This allows us to fix the base address the module will be mapped on at runtime. We can then decide an arbitrary base address, put the ASCII string anywhere in our .text section, and calculate the runtime address by the formula module_base_address + rva_ascii_string.

1
2
3
4
var image = module.ToPEImage();

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

In order to have the CLR actually respect this flag, we also need to unset the ILOnly flag in the .NET data directory:

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

We can then simply pass the calculated address directly in our puts function call as a normal integer:

Replace Console::WriteLine with ucrtbase!puts, allowing us to use an ASCII string instead.

And there we go, we not only got rid of our wide-character string, but also the entire #US stream, as well as the reference to System.Console::WriteLine which also contributes quite a few bytes to the size of our file. In turn, we got a few bytes back due to the new required puts method definition and its associated P/Invoke metadata, but it is for sure a big shrink.

We are now down to 889 bytes (0x379):

The size of the file after removing Console::WriteLine and using ASCII strings.

Other micro optimizations

There are a few things we still can do.

Our puts definition follows the canonical definition as provided by the C runtime library. This means the function is defined to return an int32 representing the number of characters that were written to the standard output. However, we do not care about this value. Indeed, in our main method, we pop this value right after the call to keep the CLR happy:

Returning an int32 means the value needs to be popped from the evaluation stack again.

Since this is a 64-bit PE file anyways, the puts function will use the x64 calling conventions as described by Microsoft. In simple terms, this means at runtime the return value is not really pushed on the stack as with normal .NET method calls, but rather put in the RAX register. Since we do not use this value anyways, we can just turn the definition into void, effectively disregarding whatever is put into this register. As the function is now no longer returning anything, nothing is also pushed onto the evaluation stack in our main method. This allows us to get rid of the pop instruction in our main method:

Changing to a void means the pop instruction is no longer required.

We can also move the ASCII string that we pass on to the puts function to a slightly better place. The PE file format contains a lot of segments that are aligned to a certain byte-boundary. In particular, as was mentioned before, sections are aligned to the nearest multiple of 0x200 (1KB). This also includes the first section. However, since the PE file headers of our file take up less space than 0x200 bytes, we end up with a chunk of padding data between our headers and first section data:

PE images contain some padding between the headers and the first section.

It so turns out the Windows PE Loader always maps the PE headers as a chunk of readable memory. The good news is, it also includes this padding data.

Let’s move our string there!

Place the string to print into the unused padding segment.

By moving our string there, we effectively truncated our file by 13 bytes.

Since we also do not reference Console::WriteLine anymore, we also do not longer need the reference to mscorlib to be stored in our binary. This also saves quite a bit of space, since it means one less table to store in the tables stream (#~), as well as the name mscorlib to be removed from the #Strings stream.

We no longer depend on "mscorlib", thus we do no longer need a reference to it.

Finally, we can end with a bit of a weird one. The .NET metadata directory contains a field called VersionString, containing the minimum required version of the .NET Framework that is required to run this .NET executable. By default, for .NET 4.0+ binaries, this contains the string "v4.0.30319" padded with zero bytes to the nearest multiple of 4 (totaling 12 bytes). However, we can truncate this string to just v4.0., stripping a total of 4 bytes after padding, to trick .NET to still boot up the CLR version 4.0 and run the program successfully.

The .NET metadata directory contains a version string specifying the required runtime which can be truncated.

Note that, for some reason, the trailing . seems to be important. I have no idea why, but getting rid of anything more than this string will make the program not boot up correctly.

Our final size is 834 bytes (0x342):

The final size of our binary.

We can ZIP it to get it to a mere 476 bytes (compared to 582 bytes if we did not do any optimizations after reaching the 1KB limit). This is where I decided to call it quits.

Finally, to prove the program still works fine, here is a screenshot:

It still works!

Final Words

This was a dumb way to spend my Saturday.

Even though this is probably quite a useless project, I still like diving into these dumb rabbit holes every now and then. Exploring the limits of well-established systems is always fun, even if the end result is kind of pointless.

To summarize what we have done, we went from a Hello World file of 4.6 KB compiled by the C# compiler to a handcrafted PE file of 834 B excluding trailing zero bytes. I don’t think we can get any smaller than this, but I am happy to be proven wrong!

As said before, the final source code to produce the binary can be found on my GitHub:

Full Source Code

Happy hacking!

This post is licensed under CC BY 4.0 by the author.