文章总结: 作者提出利用双APC链在进程初始化早期向受保护进程注入EDR监控DLL,解决传统LoadLibrary注入失效问题:先以UserModeAPC运行LdrLoadDllshellcode,再以KernelModeAPC强制线程可警报触发,实现内核层稳定注入并阻塞进程直至钩子就绪,提升EDR对高防护目标的覆盖一致性。 综合评分: 85 文章分类: EDR,内核安全,红队,漏洞分析,安全工具
通过异步过程调用提升 EDR 动态链接库注入的一致性
0xfluxsec
securitainment
2026年1月5日 13:33 中国香港
简介
你可以在 GitHub 上查看该项目,尤其可以查看 injection.rs 文件,其中包含本文讨论的代码。
此前我一直使用标准的进程注入方法,从 EDR 用户态引擎向进程注入 Sanctum DLL。这种方法对普通进程有效,但当我尝试向受保护进程或使用其他 Windows 保护机制的进程注入 DLL 时,很快就遇到了困难。
于是我开始考虑从内核层进行注入。起初我尝试通过将 DLL 映射到进程内存来实现 (但很快放弃了,因为工作量太大),之后我尝试使用 APC 调用 LoadLibraryW,你可以在 这里 看到我的实现尝试,但最终未能成功。回过头来看,我认为为 LoadLibraryW(或 A) 编写一个小型 shellcode 引导程序应该可以正常工作 (类似下文所述)。
在与 @eversinc33 交流后,他非常友善地建议我研究用 shellcode 引导 LdrLoadDll并基于此设置 APC。他还慷慨地提供了实现该功能的 C++ 代码,因此我的任务就是将其移植到 Rust。这本身就是个小挑战,因为我遇到了一些 FFI 问题,下文会详细讨论。
APC
简单来说,APC (Asynchronous Procedure Calls) 是 Windows 的一种机制,用于在线程空闲并处于可警报状态时执行代码。它允许调用者将任务排队,这些任务无需立即执行,而是在线程不忙时处理。
在用户态,APC 是完全文档化且预期使用的功能,但在内核中,它们并未得到官方支持 – 这意味着 Microsoft 可能会在内核版本之间更改其 API。话虽如此,从 2020 年左右的一些博客来看,该 API 已经保持稳定一段时间了。
恶意软件通常将 APC 用作进程注入的手段,这也是我希望通过这个 EDR 项目检测的策略之一。
在驱动程序中可通过 KeInitializeApc调用的 APC,其函数定义如下 (Rust):
pubfnKeInitializeApc(
Apc: PKAPC,
Thread: PKTHREAD,
ApcStateIndex: KAPC_ENVIRONMENT,
KernelRoutine: *const c_void,
RundownRoutine: *const c_void,
NormalRoutine: *const c_void,
ApcMode: KPROCESSOR_MODE,
NormalContext: PVOID,
);
各参数依次为:
-
Apc: 指向
KAPC非分页池对象的指针,包含 APC 相关信息。 -
Thread: 指向将执行 APC 的线程 (KTHREAD) 的指针。
-
ApcStateIndex: 定义使用的进程/线程上下文。如果使用 CurrentApcEnvironment排队 APC,则 APC 会在线程当前附加的进程上下文中执行。
-
KernelRoutine: 函数指针,指向在内核中以 APC_LEVEL运行的函数,在 APC 交付之前执行。由于它在 NormalRoutine之前运行,我们可以修改 NormalRoutine指针。
-
RundownRoutine: 函数指针,指向当排队了 APC 的线程终止时执行的函数。
-
NormalRoutine: 函数指针,指向 APC 交付时运行的函数 (即你希望 APC 执行的内容,可以是内核或用户内存)。
-
ApcMode: 决定 APC 的运行方式。KernelMode会使代码在内核模式下运行 (拥有对内存的完全访问权限等)。UserMode会在线程变为可警报时以用户模式执行代码。该参数实质上决定了 APC 的调度方式。
-
NormalContext: 作为
NormalRoutine第一个参数传递的参数。
这里的方法论是:为调用 LdrLoadDll的 shellcode 分配内存,为 LdrLoadDll所需参数分配内存,然后使用两个 APC 来执行整个过程。
- APC 1: 这是一个以 ApcMode: UserMode执行的 APC,起始地址为引导 shellcode。
- APC 2: 这是一个 KernelModeAPC,几乎立即交付,随后我们使目标进程中的线程变为可警报,从而强制 APC 1 运行。
可视化如下:
Sanctum EDR Windows Driver APC injection Rust
Shellcode
首先我们需要一个函数,将 shellcode 分配到目标进程中,在 r8和 r9中为 LdrLoadDll提供指针。
我们可以使用特殊的进程 ID -1在正在启动的进程中分配内存,它表示当前进程。得益于映像加载事件的回调通知例程的内在机制 (这段代码的运行位置),我们无需担心从进程 ID 获取进程句柄。
shellcode 本身以 r8、r9 和 rax 的清零指针槽开始。Rax 将是来自 ntdll.dll的 LdrLoadDll地址。
你可以在 这里 找到完整代码,这里提供一些简短的相关片段 (再次感谢 @eversinc33 提供的 shellcode):
letmut shellcode = [
0x48u8, 0x83, 0xEC, 0x28, // sub rsp, 0x28
0x48, 0x31, 0xD2, // xor rdx, rdx
0x48, 0x31, 0xC9, // xor rcx, rcx
0x49, 0xB8, 0, 0, 0, 0, 0, 0, 0, 0, // mov r8,
0x49, 0xB9, 0, 0, 0, 0, 0, 0, 0, 0, // mov r9,
0x48, 0xB8, 0, 0, 0, 0, 0, 0, 0, 0, // mov rax (adr of LdrLoadDll),
0xFF, 0xD0, // call rax
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0xC3, // ret
];
letmut remote_shellcode_memory =null_mut();
letmut shellcode_size = shellcode.len() asu64;
let cur_proc_handle: HANDLE = (-1isize) as HANDLE;
let status =unsafe {
ZwAllocateVirtualMemory(
cur_proc_handle,
&mut remote_shellcode_memory,
0,
&mut shellcode_size,
MEM_COMMIT,
PAGE_READWRITE,
)
};
然后我们可以根据需要将各种 UNICODE_STRING和指针修补到 shellcode 数组中:
const OFF_R8_IMM: usize=12;
const OFF_R9_IMM: usize=22;
const OFF_RAX_IMM: usize=32;
const PTR_WIDTH: usize=size_of::<usize>();
shellcode[OFF_R8_IMM..OFF_R8_IMM + PTR_WIDTH].copy_from_slice(&val_r8.to_le_bytes());
shellcode[OFF_R9_IMM..OFF_R9_IMM + PTR_WIDTH].copy_from_slice(&val_r9.to_le_bytes());
shellcode[OFF_RAX_IMM..OFF_RAX_IMM + PTR_WIDTH].copy_from_slice(&val_rax.to_le_bytes());
最后将 shellcode 复制到进程虚拟地址空间内分配的区域:
unsafe {
// Patch in the shellcode to the remote region in the target process
RtlCopyMemoryNonTemporal(
remote_shellcode_memory,
shellcode.as_ptr() as*const _,
shellcode_size,
);
}
寄存器 r8和 r9所需的各种字符串和缓冲区分配也需要进行同样的操作。
设置 APC
最后,我们需要设置 APC,使其在执行线程上排队一个用户态 APC 来运行加载 Sanctum DLL 的 shellcode,然后触发 KernelModeAPC,该 APC 使用未文档化的函数 KeTestAlertThread使线程变为可警报。
有个有趣的现象:最初 DLL 会暂停除自身外的所有线程,修补 NTDLL 并执行其他任务,然后恢复进程线程。改用这种方法后,当 DLL 尝试获取进程中线程的句柄时,会收到 ACCESS_DENIED的 NTSTATUS– 我不完全确定原因,这涉及到我的内部知识盲区。我可能会花些时间更深入研究这个问题,因为我对早期进程初始化的内部机制非常感兴趣。
在开始之前,由于一些不稳定的 FFI 代码,我花了大约一天时间与多个蓝屏和访问违规做斗争。由于我们必须在编译时链接一些 Rust WDK 中不易获得的函数,因此必须提供自己的定义。
我用于 KeInitializeApc的定义:
-
KernelRoutine: Option,
-
RundownRoutine: Option,
-
NormalRoutine: Option<*const c_void>,
我从某个 GitHub 项目获取了这个定义 (已记不清是哪个),但在检查访问违规周围的内存时发现,如果 NormalRoutine设置为 None,就会发生违规 – 因为 Rust 将其别名为 1– 显然,这既不是 null,也不是有效的内存地址或机器码。我将定义改为:
-
KernelRoutine:
*const c_void, -
RundownRoutine:
*const c_void, -
NormalRoutine:
*const c_void,
并且不再设置 None,而是简单地使用 null_mut()。
在普通 Rust 中,Option<T>的 None会表示为 0,而 Some(T)会表示为指向 T的指针。我之所以能捕获这个错误,是因为 Rust lint (我真的很喜欢 cargo) 解释说在 FFI 函数指针上使用 Option 可能产生未定义行为。我不知道 ‘1’ 是如何进入那个地址的,但这是我发现该 bug 后拍摄的截图:
- 是 KAPC 对象的地址。
- 是 NormalRoutine 中我预期为 null 的地址
- 是该地址中的内容,显然不是有效的机器码!
FFI bug Rust windows driver EDR
修复后,我们可以继续创建两个 APC、它们的回调例程,然后让它们运行!一个重要的注意点是 UserModeAPC 的 NormalRoutine是其虚拟地址空间中 shellcode 分配的地址。
再次说明,这里不展示完整代码 (参见上面的 GitHub 代码链接),以下是运行 shellcode 的 UserModeAPC 片段:
let apc =unsafe {
ExAllocatePool2(
POOL_FLAG_NON_PAGED,
size_of::<KAPC>() asu64,
u32::from_le_bytes(*b"sanc"),
)
} as*mut KAPC;
unsafe {
KeInitializeApc(
&mut*apc,
thread,
crate::ffi::_KAPC_ENVIRONMENT::OriginalApcEnvironment,
apc_callback_inject_sanctum as*const c_void,
rundown as*const c_void,
shellcode_addr,
UserMode asi8,
null_mut(),
);
}
let status =
unsafe { KeInsertQueueApc(&mut*apc, null_mut(), null_mut(), IO_NO_INCREMENT as _) };
if!nt_success(status as _) {
bail!("Failed to insert APC for shellcode execution. Code: {status:#X}");
}
以下是使线程可警报的 KernelModeAPC:
{
// ...
let kapc =unsafe {
ExAllocatePool2(
POOL_FLAG_NON_PAGED,
size_of::<KAPC>() asu64,
u32::from_le_bytes(*b"sanc"),
)
} as*mut KAPC;
unsafe {
KeInitializeApc(
&mut*kapc,
thread,
crate::ffi::_KAPC_ENVIRONMENT::OriginalApcEnvironment,
kernel_prepare_inject_apc as*const c_void,
rundown as*const c_void,
null_mut(),
KernelMode asi8,
null_mut(),
);
}
let status =
unsafe { KeInsertQueueApc(&mut*kapc, null_mut(), null_mut(), IO_NO_INCREMENT as _) };
if!nt_success(status as _) {
bail!("Failed to insert KAPC for shellcode execution. Code: {status:#X}");
}
}
unsafeextern"C"fnkernel_prepare_inject_apc(
apc: PRKAPC,
_normal_routine: PKNORMAL_ROUTINE,
_normal_context: *mut PVOID,
_system_arg_1: *mut PVOID,
_system_arg_2: *mut PVOID,
) {
unsafe { KeTestAlertThread(UserMode asi8) };
unsafe { rundown(apc) };
}
有趣的是,当查看 LdrLoadDll 完成后的返回值时,NTSTATUS 不是 0 (成功),而是被设置为 0xC00000E5,即 STATUS_INTERNAL_ERROR。我注意到当在 shellcode 中添加 int3指令时,它触发了两次 – 第二次运行时才成功。我强烈怀疑某些加载器锁/加载器内部机制是罪魁祸首,这可能是我需要回顾的内容 (更多是从学习角度,因为实际上它是有效且可控的)。
确保进程仅在注入 DLL 后才能继续
最后 – 我们如何确保进程只有在注入 Sanctum DLL 并在 ntdll.dll中设置了钩子后才允许运行?
很简单!首先,当 kernelbase.dll映射到新生成的进程时 (我们使用内核回调检测),我们进行阻塞,直到从用户态 EDR 引擎收到 IOCTL 确认 Sanctum DLL 已完成工作。收到确认后,我们即可解除对进程的阻塞,允许映像映射继续。
我从未看到打印说进程正在阻塞的信息,一切都发生得非常快,因此这样做对性能没有实际的负面影响。
结语
希望你喜欢这篇文章,进程映射、APC 和内核的内部机制总是非常有趣。如上所述,我已经发现了一些需要巩固的问题,如果有人有任何建议、更正或提示 – 我洗耳恭听!
下面参考资料中 Dennis Babkin 的博客记录了这个过程的一些非常有趣的副作用,我需要探索,特别是关于在 kernel32.dll 映射到进程之前,让模块存活于早期进程环境中的问题。
参考资料
- dennisbabkin.com
- repnz
- @eversinc33
Improving consistency with EDR DLL Injection via APCs
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:securitainment 0xfluxsec《通过异步过程调用提升 EDR 动态链接库注入的一致性》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论