InjectSetConsole: inyección de código en procesos remotos sin VirtualAllocEx ni WriteProcessMemory

Cuando pensamos en process injection sobre Windows, probablemente la primera cadena que nos viene a la cabeza sea OpenProcess → VirtualAllocEx → WriteProcessMemory → CreateRemoteThread. Es una técnica conocida y, precisamente por eso, una de las cadenas que los EDR llevan años monitorizando.

El proyecto InjectSetConsole, de TwoSevenOneT, parte de una idea bastante más interesante: ¿y si no escribimos directamente en la memoria del proceso remoto? En lugar de utilizar WriteProcessMemory, el PoC consigue que el propio proceso objetivo reciba los datos a través de su stdin y sea él quien termine almacenándolos en memoria.

La técnica comienza creando un anonymous pipe mediante CreatePipe y lanzando el proceso objetivo con su entrada estándar conectada a uno de los extremos. El código utiliza STARTUPINFO para redirigir stdin, stdout y stderr:

CreatePipe(&hChildStd_IN_Rd, &hChildStd_IN_Wr, &saAttr, 0);
SetHandleInformation(hChildStd_IN_Wr, HANDLE_FLAG_INHERIT, 0);

si.hStdInput  = hChildStd_IN_Rd;
si.hStdOutput = hChildStd_OUT_Wr;
si.hStdError  = hChildStd_OUT_Wr;
si.dwFlags   |= STARTF_USESTDHANDLES;

CreateProcess(
    NULL, childPath.data(),
    NULL, NULL, TRUE,
    CREATE_NEW_CONSOLE,
    NULL, NULL, &si, &pi
);

A partir de aquí el atacante dispone de un canal de entrada perfectamente legítimo. El PoC introduce mediante ese canal un bloque de datos que contiene un marcador reconocible seguido del contenido que posteriormente pretende ejecutar:

WriteToPipeBin(
    hChildStd_IN_Wr,
    rawData,
    sizeof(rawData)
);

La clave está en que no conocemos de antemano dónde terminarán esos datos. No existe un VirtualAllocEx que nos devuelva una dirección remota. Por ello, el PoC utiliza una firma contenida en rawData y posteriormente recorre la memoria del proceso buscando esa secuencia.

La función FindPatternInRemoteProcess() utiliza precisamente las primitivas habituales de inspección de memoria, principalmente VirtualQueryEx y ReadProcessMemory, hasta localizar el marcador:

auto result =
    FindPatternInRemoteProcess(
        pi.hProcess,
        rawDataPattern
    );

Cuando encuentra la coincidencia, el inyector ya conoce la dirección donde el proceso ha terminado almacenando los datos. Es una inversión interesante respecto a la técnica tradicional: en lugar de reservar una dirección y escribir en ella, primero introducimos los datos y después descubrimos dónde los ha colocado el proceso.

El siguiente problema es que esa memoria no tiene por qué ser ejecutable. El PoC consulta la región y modifica sus permisos mediante VirtualProtectEx:

MEMORY_BASIC_INFORMATION mbi{};

VirtualQueryEx(
    pi.hProcess,
    (LPCVOID)*result,
    &mbi,
    sizeof(mbi)
);

DWORD oldProt = 0;

VirtualProtectEx(
    pi.hProcess,
    (LPVOID)*result,
    mbi.RegionSize,
    PAGE_EXECUTE_READWRITE,
    &oldProt
);

Aquí encontramos una de las paradojas de la técnica. Hemos conseguido eliminar VirtualAllocEx y WriteProcessMemory, pero la operación sigue dejando una huella clara: una región de memoria de otro proceso cambia sus protecciones para permitir ejecución.

Todavía falta conseguir que el procesador llegue hasta esa región. En lugar de crear un nuevo hilo mediante CreateRemoteThread, el proyecto obtiene el hilo principal del proceso y utiliza una técnica de thread hijacking para modificar su contexto de ejecución. En x64 esto implica, conceptualmente, redirigir RIP hacia la dirección encontrada:

DWORD tid = GetMainThreadId(pi.dwProcessId);

HijackThreadRip(
    tid,
    remoteAddress + 0x19,
    true
);

El 0x19 corresponde al desplazamiento existente entre el marcador utilizado para localizar el bloque y la parte que el PoC pretende ejecutar.

Por tanto, la cadena completa queda reducida a algo bastante elegante:

CreatePipe
    ↓
CreateProcess + stdin
    ↓
datos controlados → proceso
    ↓
FindPatternInRemoteProcess
    ↓
VirtualQueryEx / ReadProcessMemory
    ↓
VirtualProtectEx
    ↓
thread hijacking / RIP
    ↓
ejecución

La importancia de InjectSetConsole no está realmente en haber encontrado una API mágica que sustituya a WriteProcessMemory. Lo interesante es que cambia el canal mediante el cual los datos llegan a la memoria del proceso.

En una inyección tradicional tenemos:

ATACANTE ──WriteProcessMemory──> MEMORIA REMOTA

Aquí tenemos:

ATACANTE
    │
    │ stdin / pipe
    ▼
PROCESO
    │
    │ copia interna
    ▼
MEMORIA

El proceso víctima hace parte del trabajo por nosotros.

Y precisamente ahí aparece la verdadera lección para los EDR. Una detección basada únicamente en VirtualAllocEx + WriteProcessMemory + CreateRemoteThread puede perder esta variante, pero la cadena de comportamiento sigue siendo visible: creación del proceso, comunicación mediante pipe, inspección de memoria remota, cambio de protección y manipulación del contexto de un hilo.

Por eso quizá sea más correcto hablar de cambio de superficie de detección que de evasión completa.

La pregunta defensiva ya no debería ser simplemente "¿ha utilizado WriteProcessMemory?", sino algo bastante más interesante: ¿Cómo han aparecido estos bytes en el espacio de direcciones de este proceso y por qué un hilo legítimo ha terminado ejecutándolos?

Ahí es donde InjectSetConsole deja de ser simplemente otra técnica de process injection y se convierte en un buen ejemplo de la evolución de las técnicas ofensivas: cuando una API empieza a estar demasiado vigilada, no siempre hay que buscar otra API equivalente; a veces basta con conseguir que el propio proceso haga el trabajo.

Repositorio: TwoSevenOneT/InjectSetConsole

Comentarios