Guantelete de seguridad ofensivo 2025: la sombra de los ladrones

· 9min · Juicecat

Sombra de los ladrones

Parte del CTF de OffSec de este año implicó una investigación forense de una máquina comprometida.

Nos dieron el siguiente mensaje:

Download the ZIP package. The password is "**Shadow234@**".

Thanks to your actions during the ProtoVault incident, you've gained the trust of the Etherians. The OffSec Legend, Cipherflare has called upon you to investigate the breach before more damage is done.

The Etherians offer fragments of evidence, just enough to begin the investigation:

- The user directory of **[email protected]** from the machine **WK001**
- Event logs from **WK001**

A ZIP archive awaits you.

Uncover the truth hidden in the darkness. Find what was taken, and how.

Dentro del zip, tenemos algunos archivos. index.md-1.png Dentro del directorio a.smith, obtenemos la carpeta de inicio del usuario. index.md-3.png

Mientras que el archivo .evtx es un registro de sysmon para ese sistema.

Parte 1 - Resumido por brevedad

Utilizo evtx_dump para analizar el archivo para facilitar su lectura:

juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% ~/Tools/evtx_dump -o jsonl -f logs_json.json logs.evtx

También se puede abrir en el visor de eventos de Windows, pero es mucho más fácil de analizar mediante CLI

index.md-2.png

Podemos usar jq para filtrar y refinar nuestra búsqueda.

juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% cat logs_json.json | jq '.Event.System["EventID"]' | head -n 50                                                                       
23
11
11
11
11


juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% cat logs_json.json | jq '.Event | select(.System["EventID"] == 3)' | head -n 50                                                       
{
  "#attributes": {
    "xmlns": "http://schemas.microsoft.com/win/2004/08/events/event"
  },
  "System": {
    "Provider": {
      "#attributes": {
        "Name": "Microsoft-Windows-Sysmon",
        "Guid": "5770385F-C22A-43E0-BF4C-06F5698FFBD9"
      }
    },
    "EventID": 3,
    "Version": 5,
    "Level": 4,
    "Task": 3,
    "Opcode": 0,
    "Keywords": "0x8000000000000000",
    "TimeCreated": {
      "#attributes": {
        "SystemTime": "2025-07-31T08:56:30.629677Z"
      }
    },
    "EventRecordID": 41617,
    "Correlation": null,
    "Execution": {
      "#attributes": {
        "ProcessID": 6648,
        "ThreadID": 10256
      }
    },
    "Channel": "Microsoft-Windows-Sysmon/Operational",
    "Computer": "WK001.megacorpone.com",
    "Security": {
      "#attributes": {
        "UserID": "S-1-5-18"
      }
    }
  },
  "EventData": {
    "RuleName": "-",
    "UtcTime": "2025-07-31 08:56:50.550",
    "ProcessGuid": "00000000-0000-0000-0000-000000000000",
    "ProcessId": 11360,
    "Image": "<unknown process>",
    "User": "-",
    "Protocol": "tcp",
    "Initiated": true,
    "SourceIsIpv6": false,
    "SourceIp": "10.10.10.245",
    "SourceHostname": "WK001.megacorpone.com",


juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% jq -c 'select(.Event.System.EventID == 3                                                                                              25-10-14 - 14:46:29
            and .Event.EventData.Initiated == true
            and .Event.EventData.Image != "C:\\Windows\\System32\\svchost.exe"
            and .Event.EventData.Image != "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
            and .Event.EventData.Image != "C:\\Windows\\System32\\taskhostw.exe")' logs_json.json | wc -l
15981

Finalmente (ahorrando tiempo al saltarnos gran parte de este desafío) nos encontramos con este evento:

  },
    "EventData": {
      "RuleName": "-",
      "UtcTime": "2025-08-05 09:02:06.865",
      "ProcessGuid": "8404BF77-C85E-6891-0E37-000000000C00",
      "ProcessId": 17852,
      "User": "MEGACORPONE\\a.smith",
      "Image": "C:\\Users\\a.smith\\AppData\\Local\\Microsoft\\Windows\\INetCache\\IE\\66HCZK0X\\captcha_privacy[1].epub",
      "TargetFilename": "C:\\Users\\a.smith\\AppData\\Local\\Temp\\101010245WK001.zip",
      "Hashes": "SHA1=756FF3A252D10493CE9C34297FA7BB6F84DC27A4,MD5=053C53EC53D5E6C720AB105BC46FAE2B,SHA256=B6A1646F23BA0A05B7C80A7D6261204384AB06F15983EB195EB5F0A3FEDF2475,IMPHASH=00000000000000000000000000000000",
      "IsExecutable": false,
      "Archived": "true"
    }
  }
}

Lo cual es extraño. Si te fijas, este es un evento de creación de archivo y el campo Image es un archivo epub, lo cual es extremadamente anormal. Esto nos lleva a investigar este archivo epub en cuestión. Como era de esperar, el archivo epub es en realidad un PE32 que, debido a un cambio malicioso en el registro, se ejecuta como ejecutable.

Ese fue un resumen de varias horas de investigación en los registros de eventos y el perfil de usuario en busca de migajas, pero el objetivo principal de esta publicación de blog es el desmontaje.

Parte 2: Desmontaje de archivos maliciosos

Subimos este archivo a Ghidra para su desmontaje e inmediatamente verificamos la función de entrada. Esta función de entrada solo llama a dos funciones, Stealers-shadow-1.png El primero no es nada interesante, solo un texto repetitivo. Stealers-shadow-2.png

La siguiente es una función de inyección utilizada para preparar los datos. Stealers-shadow-3.png

En rojo, vemos algo de configuración. Pero lo interesante es el azul y lo que hay debajo. Vemos estas líneas: Stealers-shadow-4.png

      uVar8 = _get_initial_narrow_environment();
      puVar9 = (undefined8 *)__p___argv();
      uVar1 = *puVar9;
      puVar10 = (undefined4 *)__p___argc();
      iVar5 = FUN_14002a820(*puVar10,uVar1,uVar8);
      cVar3 = FUN_140374c9c();
      if (cVar3 != '\0') {
        if (!bVar2) {
          _cexit();
        }
        __scrt_uninitialize_crt(1,0);
        return iVar5;
      }

Parece que está configurando variables y luego llamando a una función, pasando esas variables. En este caso, se llama a FUN_14002a820 y se le dan 3 argumentos. Luego, parece que se llama a FUN_140374c9c y se verifica que la salida sea exitosa (sin errores). Luego, se devuelve el valor de retorno de la función FUN_14002a820.

Para facilitar el seguimiento, cambiaremos el nombre de FUN_14002a820 a wrapper_main y luego seguiremos la referencia externa.

Stealers-shadow-5.png Parece que wrapper_main es en sí mismo un envoltorio para otro FUN_1402cef40. Cambiaremos el nombre de este a wrapper_2. También vemos que se pasan algunos parámetros más, pero volveremos a ellos más adelante. Veamos qué hay dentro de wrapper_2 Stealers-shadow-6.png Aquí tenemos mucho más con qué trabajar.

Te ahorraré el dolor de cabeza y te explicaré qué está haciendo esto. Primero, configura la pila y prepara el manejo de errores:

AddVectoredExceptionHandler(0, FUN_1402e1550);
local_90 = (undefined *)CONCAT44(local_90._4_4_, 0x5000);
SetThreadStackGuarantee((PULONG)&local_90);

Luego, inicializa un hilo:

  pvVar3 = GetCurrentThread();
  (*(code *)PTR_FUN_14054bd70)(pvVar3,"m");

Las siguientes líneas hacen un par de cosas: primero, configura el acceso a TLS (Thread Local Storage) a través del segmento GS. Luego configura un bucle para incrementar DAT_14054bff0 con bloqueo y lo vuelve a escribir en la ubicación TLS. Efectivamente, esto es solo registra el hilo en un contexto de tiempo de ejecución global y garantiza una inicialización segura para subprocesos.

  lVar5 = *(longlong *)
           (*(longlong *)(*(longlong *)(unaff_GS_OFFSET + 0x58) + (ulonglong)_tls_index * 8) + 0xe8)
  ;
  lVar4 = DAT_14054bff0;
  if (lVar5 == 0) {
    do {
      if (lVar4 == -1) {
        FUN_140383ec0();
        do {
          invalidInstructionException();
        } while( true );
      }
      lVar5 = lVar4 + 1;
      LOCK();
      bVar6 = lVar4 != DAT_14054bff0;
      lVar1 = lVar5;
      if (bVar6) {
        lVar4 = DAT_14054bff0;
        lVar1 = DAT_14054bff0;
      }
      DAT_14054bff0 = lVar1;
      UNLOCK();
    } while (bVar6);
    *(longlong *)
     (*(longlong *)(*(longlong *)(unaff_GS_OFFSET + 0x58) + (ulonglong)_tls_index * 8) + 0xe8) =
         lVar5;
  }

Ahora aquí está la parte importante.

iVar2 = (**(code **)(param_2 + 0x28))(param_1);

Hemos visto esto antes, pero éste requiere un poco de comprensión de los lenguajes de programación de bajo nivel. Lo que hace esta línea de código es esencialmente encontrar el dato ubicado por param_2 (que actúa como un puntero) y agrega el desplazamiento 0x28 a esto. Obtiene cualquier dato que se encuentre en este punto y lo llama como una función, pasando param_1 como argumento.

Esta es una manera larga de explicar que está muy confuso y que todavía tenemos que ver la función "principal" real.

Para encontrarlo, necesitaremos averiguar en qué se resuelve param_2 + 0x28, lo que significa encontrar param_2

Bueno, recordad que ya tenemos 3 envoltorios de fondo. Así que revisemos el contenedor encima de este para descubrir de dónde viene el parámetro: Stealers-shadow-8.png Aquí vemos que &DAT_14038c518 se pasa como param_2. Podemos navegar hasta este puntero haciendo doble clic en él, y nos lleva a donde están almacenados esos datos. Stealers-shadow-9.png

Parece que simplemente se ha puesto a cero. Afortunadamente, no estamos buscando esto directamente, sino que es solo una base en la que compensaremos con nuestro 0x28. Entonces, lo que podemos hacer es presionar GRAMO e ingresar la dirección de DAT_14038c518 y agregar nuestro desplazamiento.

Stealers-shadow-10.png

¡Y encontramos una dirección de memoria en esta ubicación! Ahora, gracias a Ghidra, se muestra como FUN_1400583d0, por lo que sabemos que hay una función aquí. Stealers-shadow-11.png Entremos en esta función y cámbiele el nombre a main mientras estamos en ello. Stealers-shadow-12.png

Excelente, otro envoltorio! Sigamos adelante y sigamos FUN_14004eea0 Stealers-shadow-13.png ¿Qué es eso? ¿¡Otro envoltorio!? Excelente. Pero ahora nos encontramos en una encrucijada. Este envoltorio se ve diferente. Básicamente, lo que hace es tomar los datos almacenados en param_1, interpretarlos como una dirección de memoria y ejecutar lo que se encuentre allí. Una vez más, necesitaremos rastrear los orígenes del parámetro.

En otras palabras, FUN_14004eea0(code *param_1) (*param_1)(); invoca la función cuya dirección está en param_1.

Con solo mirar el código C, no podríamos rastrear de dónde proviene esta variable. Sin embargo, cuando miramos las instrucciones desensambladas, vemos que el valor simplemente se almacena en el registro RCX. Stealers-shadow-14.png

Y, de hecho, la capa contenedora anterior preparó este registro para ser leído: Stealers-shadow-15.png

Ahora estamos un poco estancados: necesitamos encontrar lo que hay en el registro RCX, pero estos solo existen en tiempo de ejecución, por lo que necesitaremos depurar el programa y establecer un punto de interrupción en esta ubicación, lo cual debería ser fácil ya que tenemos la dirección de la función en la que queremos interrumpir. Stealers-shadow-17.png

Debido a que este programa es un ejecutable PE32 y no un binario DUENDE, no podemos usar Ghidra o gdb para depurarlo (mis programas preferidos para este tipo de trabajo), sino que necesitaremos un depurador de Windows. Mi depurador de Windows preferido es x64dbg.

El trabajo que debemos hacer en el depurador es bastante mínimo: solo buscamos inspeccionar la memoria en un determinado punto de interrupción. Por lo tanto, no me molestaré en explicar demasiado aquí y en su lugar simplemente repasaré los detalles de alto nivel.

Lo primero que hay que notar son las diferentes direcciones a la izquierda; debido a esto, no podemos simplemente establecer un punto de interrupción en 0x14004eea0. La razón de esta diferencia es la diferencia en cómo se carga el programa.

  • Ghidra analiza el archivo estáticamente. Utiliza el base de imagen registrado en el encabezado PE (por ejemplo, 0x140000000) como inicio de todas las direcciones virtuales.
  • x64dbg muestra las direcciones después de que Windows haya cargado el programa. El cargador de Windows aplica Aleatorización del diseño del espacio de direcciones (ASLR), por lo que asigna la imagen a un base aleatoria (por ejemplo, 0x00007FF6C3E51000).

Stealers-shadow-16.png

Como no podremos hacerlo de la manera más fácil, podemos usar compensaciones relativas. Si volvemos a Ghidra, podemos desplazarnos hacia arriba para ver la dirección base utilizada: Stealers-shadow-19.png

Luego, para encontrar el desplazamiento de nuestra función, simplemente lo restamos de la dirección de nuestra función: 0x14004eea0 - 0x140000000 = 0x4eea0

Sin embargo, dado que queremos pausar e inspeccionar el contenido de RCX antes de llamar a la función, en su lugar tomaré esta dirección: Stealers-shadow-25.png Que está en desplazamiento 0x583d4

Para obtener la dirección base de nuestro exe en x64dbg, podemos abrir la pestaña del mapa de memoria y ver dónde está cargado. Stealers-shadow-24.png

Afortunadamente, en realidad no necesitamos hacer cálculos y podemos simplemente insertarlo como un comando en x64dbg para visitar este desplazamiento: Stealers-shadow-26.png

Entonces podemos reanudar la ejecución y alcanzar este punto de interrupción. Stealers-shadow-27.png De hecho, vemos que las instrucciones desmontadas coinciden también con las que estábamos viendo en Ghidra. Desde aquí, podemos inspeccionar el contenido de la memoria a la derecha, lo que nos da el valor RCX: 0x000000248D5BFE70

Pero espera... Esto no tiene sentido. Esperamos que haya un puntero de función en este registro y este valor esté por debajo de nuestro desplazamiento base. En este caso, en realidad hicimos una pausa justo cuando ANTES RCX está configurado. Así que podemos avanzar una instrucción y vemos que el contenido de RCX cambia a algo que parece más correcto: Stealers-shadow-28.png

0x00007FF718F6A600, que coincide con nuestra compensación. Podemos hacer clic derecho en este valor y seguirlo hasta la ventana de volcado, en la que podemos hacer clic derecho en la dirección de memoria y copiar su desplazamiento de archivo. Stealers-shadow-30.png

Lo que nos da: 0x29A00. En Ghidra, podemos navegar hasta este desplazamiento: Stealers-shadow-31.png

Que es una función sus muy. Stealers-shadow-32.png

En su interior vemos un montón de cosas muy interesantes que indican enumeración, como la siguiente línea:

    FUN_1400285d0(&lStack_e0,
                  *(undefined8 *)((longlong)&PTR_s_%USERPROFILE%\Documents_140388ff0 + lVar11),
                  *(undefined8 *)((longlong)&DAT_140388ff8 + lVar11));

Parece que itera sobre los datos del usuario, lo cual tiene sentido dado el contexto malicioso de este archivo.

En aras de la digestibilidad, esta investigación continuará en otra publicación del blog. Pero por ahora hemos descubierto los mecanismos básicos de este malware.