Skip to main content

Command Palette

Search for a command to run...

Executing Native Code in .NET Without External DLLs

Updated
3 min read

This example demonstrates how to execute native x86 machine code directly from .NET and, conversely, how to call a managed .NET method from that native code — all without loading any external libraries.

How it works

The core idea is to allocate a chunk of memory, mark it as executable, and then point a delegate to it.

  1. Get a Pointer to Managed Code: We use MethodHandle.GetFunctionPointer() to get the memory address of Math.Max.

  2. Allocate Executable Memory: Standard .NET memory is not executable for security reasons (DEP). We use the Win32 API VirtualAlloc to request a region with ExecuteReadWrite permissions.

  3. Calculate Relative Offsets: The x86 CALL instruction (0xE8) uses relative addressing. We calculate the distance between our native buffer and the target Math.Max function.

  4. Write Shellcode: We manually write the x86 opcodes into the allocated memory.

  5. Execute via Delegate: Marshal.GetDelegateForFunctionPointer wraps our raw memory address into a callable .NET delegate.

using System;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;

class Program
{
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    private delegate ulong LongFunction(uint arg1, uint arg2);

    static void Main(string[] args)
    {
        // 1. Get the address of the managed method Math.Max(uint, uint)
        MethodInfo methodInfo = typeof(Math).GetMethod("Max",
            BindingFlags.Static | BindingFlags.Public, null,
            new Type[] { typeof(uint), typeof(uint) }, null);

        IntPtr functionPtr = methodInfo.MethodHandle.GetFunctionPointer();
        Console.WriteLine($"Math.Max address: 0x{functionPtr.ToInt64():X}");

        // 2. Allocate executable memory (x86 architecture)
        IntPtr region = VirtualAlloc(IntPtr.Zero, (UIntPtr)4096,
            AllocationType.Commit, MemoryProtection.ExecuteReadWrite);

        // 3. Calculate relative address for the 'call' instruction
        // The offset is: TargetAddress - (CurrentInstructionAddress + InstructionLength)
        // 14 is the offset from the start of our code to the instruction following 'call'
        uint relativeAddr = (uint)functionPtr - (uint)region - 14;
        byte[] addrBytes = BitConverter.GetBytes(relativeAddr);

        // 4. Prepare x86 machine code: f(x, y) = max(x, y) * max(x, y)
        byte[] code = new byte[]
        {
            0x55,                         // push ebp
            0x89, 0xE5,                   // mov ebp, esp
            0x8B, 0x4D, 0x08,             // mov ecx, [ebp + 8]  (arg1)
            0x8B, 0x55, 0x0C,             // mov edx, [ebp + 12] (arg2)
            0xE8, addrBytes[0], addrBytes[1], addrBytes[2], addrBytes[3], // call Math.Max
            0xF7, 0xE0,                   // mul eax (square the result of Max)
            0x5D,                         // pop ebp
            0xC3                          // ret
        };

        // 5. Copy code to allocated memory and create a delegate
        Marshal.Copy(code, 0, region, code.Length);
        Console.WriteLine($"Native code memory: 0x{region.ToInt64():X}");

        LongFunction func = (LongFunction)Marshal.GetDelegateForFunctionPointer(region, typeof(LongFunction));

        // 6. Execute the magic
        ulong result = func(0x10, 0x20); // max(16, 32)^2 = 32^2 = 1024
        Console.WriteLine($"Result: {result}");

        // Cleanup
        VirtualFree(region, UIntPtr.Zero, ReleaseType.Release);
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr length, AllocationType allocationType, MemoryProtection memoryProtection);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool VirtualFree(IntPtr address, UIntPtr length, ReleaseType releaseType);

    [Flags]
    public enum AllocationType : uint 
    { 
        Commit = 0x1000 
    }

    [Flags]
    public enum ReleaseType : uint 
    { 
        Release = 0x8000
    }

    [Flags]
    public enum MemoryProtection : uint 
    { 
        ExecuteReadWrite = 0x40 
    }
}

Important Considerations

  • Architecture: This specific shellcode is x86 (32-bit). It will crash on x64 due to different calling conventions and register sizes (e.g., RAX instead of EAX).

  • JIT Inlining: In a real-world scenario, the JIT compiler might inline Math.Max, or its function pointer might change. For a stable "managed-to-native" bridge, you usually use RuntimeHelpers.PrepareMethod.

  • Security: Writing to executable memory is a common technique in exploits. Modern systems with Arbitrary Code Execution (ACE) protection might block such operations.