Inline assembly in C# and .NET

A short guide on running x86 assembly from C#.

Have you ever wondered if you could write inline assembly in C# (or any other .NET-based language) like you can do with C/C++? Well, you probably did not, but if you plan to write speed critical code, it might be worth learning it.

Advantages of Inline Assembly

  • Write highly optimized, speed critical code
  • Directly access hardware and registers

Disadvantages of Inline Assembly

  • One big advantage of .NET is that it’s portable, but if you utilize inline assembly you will be stuck to one or more specific platforms (e.g. x86 and/or x64).
  • Higher risk of doing programming errors like memory leaks
  • Hard to debug

Requirements

  • Beginner level x86 assembly language knowledge (you should at least know calling conventions, push, pop, mov, call, registers, etc.)
  • Process.NET (optional) – it contains very useful memory abstractions.,
  • Fasm.NET (optional) (not available for .NET Core since it’s utilizes C++/CLI) – .NET wrapper for FASM assembler

Fasm.NET allows you to assemble assembly code from C# directly. Hence, your code will be more readable and also more maintainable. However, you can also use any other assembler or just hardcode the instructions directly (see example 4 and 5).

Setting Up the Project

Create a new console project. In this tutorial we will be using the x86 instruction set. This means that our program must also run in x86 mode. .NET projects will default to AnyCPU configuration, which would be problematic as we will only target x86. Open the .csproj and add the following line to enforce x86 mode to your <PropertyGroup>: <PlatformTarget>x86</PlatformTarget>.

For example 5 we will use unsafe code. To enable unsafe code, add <AllowUnsafeBlocks>true</AllowUnsafeBlocks> to your <PropertyGroup>.

Note: If you want to use inline assembly with .NET Core, you should skip the first four examples, as they depend on libraries which are not available for .NET Core.

Using Inline Assembly in C#

Example 1: Function returning a constant value

[SuppressUnmanagedCodeSecurity] // disable security checks for better performance
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // cdecl - let caller (.NET) clean the stack for us
private delegate int AssemblyConstantValueFunction();
static void Main(string[] args)
{
    const int valueToReturn = 1;
    var currentProcess = new ProcessSharp(System.Diagnostics.Process.GetCurrentProcess(), MemoryType.Local);
    FasmNet fasmNet = new FasmNet();
    // This function will simply return "1".  
    fasmNet.AddLine("use32"); //Tell FASM.Net to use x86 (32bit) mode
    fasmNet.AddLine("mov eax, {0}", {valueToReturn}); // copy "valueToReturn" variables value to eax
    fasmNet.AddLine("ret"); // in cdecl calling convention, return value is stored in eax; so this will return 1
    byte[] assembledCode = fasmNet.Assemble();
    // Allocate and write our assembled code to a constant location in heap (using Process.NET)
    var allocatedCodeMemory = currentProcess.MemoryFactory.Allocate(
        name: "Example1", // only used for debugging; not really needed
        size: assembledCode.Length,
        protection: MemoryProtectionFlags.ExecuteReadWrite /* It is important to mark the memory as executeable or we might get exceptions from DEP */
    );
    allocatedCodeMemory.Write(0, assembledCode); 
    var myAssemblyFunction = Marshal.GetDelegateForFunctionPointer<AssemblyConstantValueFunction>(allocatedCodeMemory.BaseAddress);
    var returnValue = myAssemblyFunction();
    // Warning: Potential memory leak!
    // Do not forget to dispose the allocated code memory after usage. 
    allocatedCodeMemory.Dispose();
    Console.WriteLine($"Example1 return value: {returnValue}, expected: {valueToReturn}");
    Console.ReadKey(true);
}

Example 2: Function Reading Registers

[SuppressUnmanagedCodeSecurity] // disable security checks for better performance
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // cdecl - let caller (.NET) clean the stack
private delegate IntPtr AssemblyReadRegistersFunction();
static void Main(string[] args)
{
    var currentProcess = new ProcessSharp(System.Diagnostics.Process.GetCurrentProcess(), MemoryType.Local);
    FasmNet fasmNet = new FasmNet();
    fasmNet.AddLine("use32"); //Tell FASM.Net to use x86 (32bit) mode
    fasmNet.AddLine("mov eax, [ebp+4]"); // Set return value to ebp+4 (return address)
    fasmNet.AddLine("ret"); // in cdecl calling convention, return value is stored in eax; so this will return the return address
    byte[] assembledCode = fasmNet.Assemble();
    var allocatedCodeMemory = currentProcess.MemoryFactory.Allocate(
        name: "Example2", // only used for debugging; not really needed
        size: assembledCode.Length,
        protection: MemoryProtectionFlags.ExecuteReadWrite /* It is important to mark the memory as executeable or we will get exceptions from DEP */
    );
    allocatedCodeMemory.Write(0, assembledCode);
    var myAssemblyFunction = Marshal.GetDelegateForFunctionPointer<AssemblyReadRegistersFunction>(allocatedCodeMemory.BaseAddress);
    var returnValue = myAssemblyFunction();
    // Warning: Potential memory leak!
    // Do not forget to dispose the allocated code memory after usage. 
    allocatedCodeMemory.Dispose();
    Console.WriteLine($"Example2 return value: 0x{returnValue.ToInt32():X}"); // Prints this methods JIT'ed address
    Console.ReadKey(true);
}

Example 3: Add Function With Parameters

[SuppressUnmanagedCodeSecurity] // disable security checks for better performance
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // cdecl - let caller (.NET CLR) clean the stack
private delegate int AssemblyAddFunction(int x, int y);
static void Main(string[] args)
{
    var currentProcess = new ProcessSharp(System.Diagnostics.Process.GetCurrentProcess(), MemoryType.Local);
    FasmNet fasmNet = new FasmNet();
    fasmNet.AddLine("use32"); //Tell FASM.Net to use x86 (32bit) mode
    fasmNet.AddLine("push ebp"); // init stack frame
    fasmNet.AddLine("mov ebp, esp"); // move base pointer
    fasmNet.AddLine("mov eax, [ebp+12]"); // set eax to second param (remember, in cdecl calling convention, params are pushed right-to-left)
    fasmNet.AddLine("mov edx, [ebp+8]"); // set edx to first param
    fasmNet.AddLine("add eax, edx"); //add edx (first param) to eax (second param) 
    fasmNet.AddLine("mov esp, ebp"); // reset base pointer
    fasmNet.AddLine("pop ebp"); // leave stack frame
    fasmNet.AddLine("ret");  // in cdecl calling convention, return value is stored in eax; so this will return both params added up
    byte[] assembledCode = fasmNet.Assemble();
    var allocatedCodeMemory = _currentProcess.MemoryFactory.Allocate(
        name: "Example3", // only used for debugging; not really needed
        size: assembledCode.Length,
        protection: MemoryProtectionFlags.ExecuteReadWrite /* It is important to mark the memory as executeable or we will get exceptions from DEP */
    );
    allocatedCodeMemory.Write(0, assembledCode);
    var myAssemblyFunction = Marshal.GetDelegateForFunctionPointer<AssemblyAddFunction>(allocatedCodeMemory.BaseAddress);
    var returnValue = myAssemblyFunction(10, -15);
    // Warning: Potential memory leak!
    // Do not forget to dispose the allocated code memory after usage. 
    allocatedCodeMemory.Dispose();
    Console.WriteLine($"Example3 return value: {returnValue}, expected: -5"); // Prints -5
    Console.ReadKey(true);
}

Example 4: Add Function With Parameters (Without Fasm.NET)

[SuppressUnmanagedCodeSecurity] // disable security checks for better performance
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // cdecl - let caller (.NET CLR) clean the stack
private delegate int AssemblyAddFunction(int x, int y);
static void Main(string[] args)
{
    var currentProcess = new ProcessSharp(System.Diagnostics.Process.GetCurrentProcess(), MemoryType.Local);
    //You can use any x86 assembler
    //For this example I have used https://defuse.ca/online-x86-assembler.htm
    // Without FASM.Net I strongly suggest you to comment each instruction (e.g. "0 push ebp")
    byte[] assembledCode =
    {
        0x55,               // 0 push ebp            ; init stack frame
        0x89, 0xE5,         // 1 mov  ebp, esp       ; move base pointer
        0x8B, 0x45, 0x0C,   // 3 mov  eax, [ebp+12]   ; set eax to second param (remember, in cdecl calling convention, params are pushed right-to-left)
        0x8B, 0x55, 0x08,   // 6 mov  edx, [ebp+8]  ; set edx to first param
        0x01, 0xD0,         // 9 add  eax, edx       ; add edx (first param) to eax (second param) 
        0x89, 0xEC,         // A mov  esp, ebp       ; reset base pointer
        0x5D,               // C pop  ebp            ; leave stack frame
        0xC3                // D ret                 ; in cdecl calling convention, return value is stored in eax; so this will return both params added up
    };
    var allocatedCodeMemory = _currentProcess.MemoryFactory.Allocate(
        name: "Example4", // only used for debugging; not really needed
        size: assembledCode.Length,
        protection: MemoryProtectionFlags.ExecuteReadWrite /* It is important to mark the memory as executeable or we will get exceptions from DEP */
    );
    allocatedCodeMemory.Write(0, assembledCode);
    var myAssemblyFunction = Marshal.GetDelegateForFunctionPointer<AssemblyAddFunction>(allocatedCodeMemory.BaseAddress);
    var returnValue = myAssemblyFunction(10, -15);
    // Warning: Potential memory leak!
    // Do not forget to dispose the allocated code memory after usage. 
    allocatedCodeMemory.Dispose();
    Console.WriteLine($"Example3 (no Fasm.NET) return value: {returnValue}, expected: -5"); // Prints -5
    Console.ReadKey(true);
}

Example 5: Add Function With Parameters (without any dependencies)

[SuppressUnmanagedCodeSecurity] // disable security checks for better performance
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // cdecl - let caller (.NET CLR) clean the stack
private delegate int AssemblyAddFunction(int x, int y);
[DllImport("kernel32.dll")]
private static extern bool VirtualProtectEx(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect); 
static void Main(string[] args)
{
    var process = System.Diagnostics.Process.GetCurrentProcess();
    //You can use any x86 assembler
    //For this example I have used https://defuse.ca/online-x86-assembler.htm
    // Without FASM.Net I strongly suggest you to comment each instruction (e.g. "0 push ebp")
    byte[] assembledCode =
    {
        0x55,               // 0 push ebp            ; init stack frame
        0x89, 0xE5,         // 1 mov ebp, esp ; move base pointer
        0x8B, 0x45, 0x0C,   // 3 mov  eax, [ebp+12]   ; set eax to second param (remember, in cdecl calling convention, params are pushed right-to-left)
        0x8B, 0x55, 0x08,   // 6 mov  edx, [ebp+8]  ; set edx to first param
        0x01, 0xD0,         // 9 add  eax, edx       ; add edx (first param) to eax (second param) 
        0x89, 0xEC,         // A mov esp, ebp ; reset base pointer        
        0x5D,               // C pop  ebp            ; leave stack frame
        0xC3                // D ret                 ; in cdecl calling convention, return value is stored in eax; so this will return both params added up
    };
    int returnValue;
    unsafe
    {
        fixed (byte* ptr = assembledCode)
        {
            var memoryAddress = (IntPtr)ptr;
            // Mark memory as EXECUTE_READWRITE to prevent DEP exceptions
            if (!VirtualProtectEx(process.Handle, memoryAddress,
            (UIntPtr) assembledCode.Length, 0x40 /* EXECUTE_READWRITE */, out uint _))
            {
                throw new Win32Exception();
            }
            var myAssemblyFunction = Marshal.GetDelegateForFunctionPointer<AssemblyAddFunction>(memoryAddress);
            returnValue = myAssemblyFunction(10, -15);
        }               
    }
    // Note: We do not have to dispose memory ourself; the CLR will handle this.  
    Console.WriteLine($"Example3 (no dependencies) return value: {returnValue}, expected: -5"); // Prints -5
    Console.ReadKey(true);
}

Notes

  • Examples 1-4 only work with .NET Framework as they utilzie .NET Core incompatible libraries.
  • Example 5 works on any .NET runtime (mono/.NET Core/.NET Framework).
  • Keep in mind that this tutorial does not cache the compiled result
  • Always flag the allocated memory as executable (e.g. via VirtualProtectEx, see Example 5). Otherwise, you might get exceptions from DEP.
  • Always deallocate the memory you have allocated to prevent memory leaks (when not using fixed statements).

You can find all code on GitHub: https://github.com/Trojaner/csharp-inline-assembly.

Tags