Skip to main content

Command Palette

Search for a command to run...

Executing Native Code in .NET Without External DLLs

Updated
•3 min read•View as Markdown

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.